Phase 5c slice B. Three web write paths now pre-validate via the itemwrite package before calling store.Create / Update / Reparent. - handleDetailWrite: ValidateFormat + ValidateAgainstStore on (title, slug, status, parent_ids) before the store.Update call. - handleNewSubmit: same pair, scoped to a new item (no ID yet). - handleReparent: format + DB-aware checks; validator catches self-parent, unknown-parent, cycle. The existing "parent_ids required" guard stays as a separate fast-fail. - handleBulkApply: set_status pre-flight against the validator. Other bulk actions (add_tag / set_mgmt / set_public / timeline_todos) don't mutate validated fields so they pass through unchanged. On ValidationError the handler responds 400 + a human banner keyed on err.Kind via the new s.itemWriteFailure helper. itemWriteBannerCopy centralises the Kind→copy mapping so web/server.go and web/bulk.go share one phrasing. No web test source touched — all web/*_test.go assert on observable behaviour (HTTP status, response body) and the new validator path preserves both for valid AND invalid inputs the SQL trigger would have rejected anyway. Tests stay green unmodified. Task: t-projax-5c-itemwrite
938 lines
30 KiB
Go
938 lines
30 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log/slog"
|
|
"mime"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/m/projax/internal/aggregate"
|
|
"github.com/m/projax/internal/cache"
|
|
"github.com/m/projax/internal/itemwrite"
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// itemWriteFailure surfaces an *itemwrite.ValidationError to the client.
|
|
// HTTP code: 400 for invalid input. The body is a one-line human banner
|
|
// keyed on Kind so handlers don't have to duplicate copy-table fragments.
|
|
// Phase 5c uses this instead of the pre-existing raw-pgErr-on-failure
|
|
// pattern in handleDetailWrite / handleNewSubmit / handleReparent.
|
|
func (s *Server) itemWriteFailure(w http.ResponseWriter, r *http.Request, ve *itemwrite.ValidationError) {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprintln(w, itemWriteBannerCopy(ve))
|
|
s.Logger.Warn("itemwrite reject", "path", r.URL.Path, "kind", ve.Kind, "detail", ve.Detail)
|
|
}
|
|
|
|
// itemWriteBannerCopy maps a ValidationError.Kind to the human-facing
|
|
// banner copy. Centralised so web/server.go + web/bulk.go share one
|
|
// authoritative phrasing.
|
|
func itemWriteBannerCopy(ve *itemwrite.ValidationError) string {
|
|
switch ve.Kind {
|
|
case itemwrite.KindMissingRequired:
|
|
return "Missing required field: " + ve.Detail
|
|
case itemwrite.KindInvalidSlugFormat:
|
|
return ve.Detail
|
|
case itemwrite.KindInvalidStatus:
|
|
return ve.Detail
|
|
case itemwrite.KindSelfParent:
|
|
return "An item cannot be its own parent."
|
|
case itemwrite.KindUnknownParent:
|
|
return ve.Detail
|
|
case itemwrite.KindSlugCollision:
|
|
return ve.Detail
|
|
case itemwrite.KindCycle:
|
|
return "Cannot reparent: this move would put the item in its own ancestor closure."
|
|
case itemwrite.KindUnresolvablePath:
|
|
return ve.Detail
|
|
}
|
|
return "Invalid input: " + ve.Detail
|
|
}
|
|
|
|
// Register MIME types stdlib doesn't ship by default. The web-app manifest
|
|
// spec requires application/manifest+json for the `<link rel=manifest>` →
|
|
// without this Go's FileServer falls back to text/plain and Chrome refuses
|
|
// to treat the file as a manifest.
|
|
func init() {
|
|
_ = mime.AddExtensionType(".webmanifest", "application/manifest+json")
|
|
}
|
|
|
|
//go:embed templates/*.tmpl
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
// Server bundles handlers, templates, and the store.
|
|
type Server struct {
|
|
Store *store.Store
|
|
pages map[string]*template.Template
|
|
Logger *slog.Logger
|
|
Auth *AuthConfig // nil → no auth (local dev / tests)
|
|
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
|
|
Gitea *GiteaDeps // nil → Gitea integration disabled
|
|
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
|
|
Version string // build-time -ldflags injection; surfaced on /admin
|
|
dashboard *cache.TTLCache[*dashboardPayload]
|
|
timeline *cache.TTLCache[*TimelinePayload]
|
|
adminHealth *adminHealthCache
|
|
}
|
|
|
|
// Aggregator builds a fresh *aggregate.Aggregator wired to the server's
|
|
// current CalDAV/Gitea deps. Per-call construction so main.go can install
|
|
// CalDAV/Gitea after web.New without having to wire a re-init hook.
|
|
func (s *Server) Aggregator() *aggregate.Aggregator {
|
|
var cal aggregate.CalDAVClient
|
|
if s.CalDAV != nil {
|
|
cal = s.CalDAV.Client
|
|
}
|
|
var git aggregate.GiteaClient
|
|
var cache aggregate.IssueCache
|
|
if s.Gitea != nil {
|
|
git = s.Gitea.Client
|
|
cache = s.Gitea.Cache
|
|
}
|
|
return aggregate.New(s.Store, cal, git, cache, s.Logger)
|
|
}
|
|
|
|
// New builds a Server. Each page is parsed alongside the layout into its own
|
|
// Template so per-page `define "content"` blocks don't shadow each other. The
|
|
// login page is intentionally NOT wrapped in the regular layout (chrome would
|
|
// imply you're already inside the app).
|
|
func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
funcs := template.FuncMap{
|
|
"deref": func(p *string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
return *p
|
|
},
|
|
"join": func(sep string, parts []string) string { return strings.Join(parts, sep) },
|
|
"contains": func(haystack []string, needle string) bool {
|
|
for _, h := range haystack {
|
|
if h == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
|
|
"subF": func(a, b any) float64 { return toFloat(a) - toFloat(b) },
|
|
"mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) },
|
|
"tagToggleURL": func(active []string, tag string, isActive bool) string {
|
|
next := []string{}
|
|
if isActive {
|
|
for _, t := range active {
|
|
if t != tag {
|
|
next = append(next, t)
|
|
}
|
|
}
|
|
} else {
|
|
next = append(next, active...)
|
|
next = append(next, tag)
|
|
}
|
|
if len(next) == 0 {
|
|
return "/"
|
|
}
|
|
return "/?tag=" + strings.Join(next, ",")
|
|
},
|
|
}
|
|
pages := map[string]*template.Template{}
|
|
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error"} {
|
|
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/"+name+".tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", name, err)
|
|
}
|
|
pages[name] = t
|
|
}
|
|
// tree bundles the tree-section partial so HTMX swaps and the initial
|
|
// page render share definitions.
|
|
treeTmpl, err := template.New("tree").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/tree.tmpl",
|
|
"templates/tree_section.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse tree: %w", err)
|
|
}
|
|
pages["tree"] = treeTmpl
|
|
// Standalone tree-section template for HTMX fragment responses.
|
|
treeSection, err := template.New("tree_section").Funcs(funcs).ParseFS(templatesFS, "templates/tree_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse tree_section: %w", err)
|
|
}
|
|
pages["tree_section"] = treeSection
|
|
// detail bundles the shared tasks-section + issues-section partials so
|
|
// HTMX swaps and the initial page render hit the same template definitions.
|
|
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/detail.tmpl",
|
|
"templates/tasks_section.tmpl",
|
|
"templates/issues_section.tmpl",
|
|
"templates/documents_section.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse detail: %w", err)
|
|
}
|
|
pages["detail"] = detailTmpl
|
|
// Standalone tasks-section template for HTMX fragment responses.
|
|
tasksFragment, err := template.New("tasks_section").Funcs(funcs).ParseFS(templatesFS, "templates/tasks_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse tasks_section: %w", err)
|
|
}
|
|
pages["tasks_section"] = tasksFragment
|
|
// Standalone issues-section template for HTMX fragment responses (Phase 3h
|
|
// writeback re-renders the issues card after a close/comment/create).
|
|
issuesFragment, err := template.New("issues_section").Funcs(funcs).ParseFS(templatesFS, "templates/issues_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse issues_section: %w", err)
|
|
}
|
|
pages["issues_section"] = issuesFragment
|
|
// Standalone documents-section template for HTMX fragment responses.
|
|
docsFragment, err := template.New("documents_section").Funcs(funcs).ParseFS(templatesFS, "templates/documents_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse documents_section: %w", err)
|
|
}
|
|
pages["documents_section"] = docsFragment
|
|
loginTmpl, err := template.New("login").Funcs(funcs).ParseFS(templatesFS, "templates/login.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse login: %w", err)
|
|
}
|
|
pages["login"] = loginTmpl
|
|
|
|
// Graph page (layout chrome + SVG body) and a standalone SVG entry for
|
|
// the ?download=svg path.
|
|
graphTmpl, err := template.New("graph").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/graph.tmpl",
|
|
"templates/graph_svg.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse graph: %w", err)
|
|
}
|
|
pages["graph"] = graphTmpl
|
|
graphSVG, err := template.New("graph_svg").Funcs(funcs).ParseFS(templatesFS, "templates/graph_svg.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse graph_svg: %w", err)
|
|
}
|
|
pages["graph_svg"] = graphSVG
|
|
|
|
// Admin index — landing page with the 3 admin cards + system health panel.
|
|
adminTmpl, err := template.New("admin").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/admin.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse admin: %w", err)
|
|
}
|
|
pages["admin"] = adminTmpl
|
|
|
|
// Dashboard page + its section fragment.
|
|
dashTmpl, err := template.New("dashboard").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/dashboard.tmpl",
|
|
"templates/dashboard_section.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse dashboard: %w", err)
|
|
}
|
|
pages["dashboard"] = dashTmpl
|
|
dashSection, err := template.New("dashboard_section").Funcs(funcs).ParseFS(templatesFS, "templates/dashboard_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse dashboard_section: %w", err)
|
|
}
|
|
pages["dashboard_section"] = dashSection
|
|
|
|
// Timeline page + its section fragment.
|
|
timelineTmpl, err := template.New("timeline").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/timeline.tmpl",
|
|
"templates/timeline_section.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse timeline: %w", err)
|
|
}
|
|
pages["timeline"] = timelineTmpl
|
|
timelineSection, err := template.New("timeline_section").Funcs(funcs).ParseFS(templatesFS, "templates/timeline_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse timeline_section: %w", err)
|
|
}
|
|
pages["timeline_section"] = timelineSection
|
|
|
|
// Bulk-edit page + its fragment + per-row chip cells. The chip cells share
|
|
// definitions with bulk_section so we parse them together every time.
|
|
bulkTmpl, err := template.New("bulk").Funcs(funcs).ParseFS(templatesFS,
|
|
"templates/layout.tmpl",
|
|
"templates/bulk.tmpl",
|
|
"templates/bulk_section.tmpl",
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse bulk: %w", err)
|
|
}
|
|
pages["bulk"] = bulkTmpl
|
|
bulkSection, err := template.New("bulk_section").Funcs(funcs).ParseFS(templatesFS, "templates/bulk_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse bulk_section: %w", err)
|
|
}
|
|
pages["bulk_section"] = bulkSection
|
|
bulkChipTags, err := template.New("bulk_chip_tags").Funcs(funcs).ParseFS(templatesFS, "templates/bulk_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse bulk_chip_tags: %w", err)
|
|
}
|
|
pages["bulk_chip_tags"] = bulkChipTags
|
|
bulkChipMgmt, err := template.New("bulk_chip_mgmt").Funcs(funcs).ParseFS(templatesFS, "templates/bulk_section.tmpl")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse bulk_chip_mgmt: %w", err)
|
|
}
|
|
pages["bulk_chip_mgmt"] = bulkChipMgmt
|
|
|
|
return &Server{
|
|
Store: s,
|
|
pages: pages,
|
|
Logger: logger,
|
|
dashboard: cache.NewTTL[*dashboardPayload](dashboardCacheTTL),
|
|
timeline: cache.NewTTL[*TimelinePayload](timelineCacheTTL),
|
|
adminHealth: newAdminHealthCache(),
|
|
}, nil
|
|
}
|
|
|
|
// Routes wires every URL to a handler and returns the mux.
|
|
func (s *Server) Routes() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /", s.handleTree)
|
|
mux.HandleFunc("GET /i/", s.handleDetail)
|
|
mux.HandleFunc("POST /i/", s.handleDetailWrite)
|
|
mux.HandleFunc("GET /new", s.handleNewForm)
|
|
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
|
mux.HandleFunc("GET /admin", s.handleAdminIndex)
|
|
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
|
mux.HandleFunc("GET /dashboard", s.handleDashboard)
|
|
mux.HandleFunc("GET /timeline", s.handleTimeline)
|
|
mux.HandleFunc("GET /graph", s.handleGraph)
|
|
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
|
|
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)
|
|
mux.HandleFunc("POST /dashboard/task/delete", s.handleDashboardTaskDelete)
|
|
mux.HandleFunc("GET /admin/bulk", s.handleBulk)
|
|
mux.HandleFunc("POST /admin/bulk/apply", s.handleBulkApply)
|
|
mux.HandleFunc("POST /admin/bulk/chip", s.handleBulkChip)
|
|
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
|
|
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
|
|
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
|
|
mux.HandleFunc("GET /login", s.handleLoginForm)
|
|
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
|
mux.HandleFunc("POST /logout", s.handleLogout)
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.Store.Pool.Ping(r.Context()); err != nil {
|
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
// Surface the build-time git SHA so any worker can verify "deploy
|
|
// rolled" without needing an authed session. Body is two
|
|
// human-readable lines so curl piped to head still reads cleanly.
|
|
fmt.Fprintln(w, "ok")
|
|
fmt.Fprintf(w, "version: %s\n", s.Version)
|
|
})
|
|
|
|
if s.MCP != nil {
|
|
// Mount MCP routes with explicit method+path patterns. A prefix pattern
|
|
// like `/mcp/` would conflict with `GET /` under Go 1.22's strict
|
|
// ServeMux (the prefix matches more methods than the subtree root).
|
|
mcpHandler := http.StripPrefix("/mcp", s.MCP)
|
|
mux.Handle("POST /mcp/rpc", mcpHandler)
|
|
mux.Handle("GET /mcp/rpc", mcpHandler)
|
|
}
|
|
|
|
static, _ := fs.Sub(staticFS, "static")
|
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(static))))
|
|
|
|
var h http.Handler = mux
|
|
if s.Auth != nil {
|
|
h = authMiddleware(*s.Auth, s.Logger, h)
|
|
}
|
|
return logging(s.Logger, h)
|
|
}
|
|
|
|
// --- handlers ---
|
|
|
|
type treeNode struct {
|
|
Item *store.Item
|
|
Children []*treeNode
|
|
}
|
|
|
|
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
items, err := s.Store.ListAll(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
tags, err := s.Store.AllTags(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
linkKinds, err := s.linkKindsByItem(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
filter := ParseTreeFilter(r.URL.Query())
|
|
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
|
counts := computeChipCounts(items, filter, linkKinds, tags)
|
|
data := map[string]any{
|
|
"Title": "tree",
|
|
"Roots": roots,
|
|
"Orphans": orphans,
|
|
"Total": total,
|
|
"OrphanN": orphanN,
|
|
"Matched": matched,
|
|
"AllTags": tags,
|
|
"Filter": filter,
|
|
"Counts": counts,
|
|
// ActiveTags kept for backwards-compat with the old template path; removed
|
|
// after the template migrates fully.
|
|
"ActiveTags": filter.Tags,
|
|
}
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
// Fragment swap: only the tree section. The browser keeps the chip
|
|
// chrome (which itself is HTMX-driven) up to date because we push the
|
|
// URL via hx-push-url at chip-click time.
|
|
s.render(w, r, "tree_section", data)
|
|
return
|
|
}
|
|
s.render(w, r, "tree", data)
|
|
}
|
|
|
|
// linkKindsByItem returns a map: itemID → set of ref_types attached to that item.
|
|
// Used by the tree filter for has-link chips. Two ref_types matter at v1:
|
|
// caldav-list and gitea-repo.
|
|
func (s *Server) linkKindsByItem(ctx context.Context) (map[string]map[string]struct{}, error) {
|
|
out := map[string]map[string]struct{}{}
|
|
for _, t := range []string{"caldav-list", "gitea-repo"} {
|
|
links, err := s.Store.LinksByRefType(ctx, t)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, l := range links {
|
|
set, ok := out[l.ItemID]
|
|
if !ok {
|
|
set = map[string]struct{}{}
|
|
out[l.ItemID] = set
|
|
}
|
|
set[t] = struct{}{}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/i/")
|
|
if path == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
// PER URL resolution: try the full path first; if it 404s and the trailing
|
|
// segment looks like YYMMDD, retry against the shorter path and surface
|
|
// the date as a render hint to scroll/highlight the matching row.
|
|
it, err := s.Store.GetByPath(r.Context(), path)
|
|
var highlight *time.Time
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
if base, d := parsePER(path); d != nil {
|
|
if it2, err2 := s.Store.GetByPath(r.Context(), base); err2 == nil {
|
|
it, err, highlight = it2, nil, d
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parents, err := s.parentOptions(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
tasks, err := s.detailTodos(r.Context(), it)
|
|
if err != nil {
|
|
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
|
|
}
|
|
issues, err := s.detailIssues(r.Context(), it)
|
|
if err != nil {
|
|
s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err)
|
|
}
|
|
openTotal := 0
|
|
for _, ri := range issues {
|
|
openTotal += ri.OpenCount
|
|
}
|
|
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
|
|
if err != nil {
|
|
s.Logger.Warn("detail docs", "path", it.PrimaryPath(), "err", err)
|
|
}
|
|
documents := computePERs(it.PrimaryPath(), docs)
|
|
s.render(w, r, "detail", map[string]any{
|
|
"Title": it.Title,
|
|
"Item": it,
|
|
"ParentOptions": parents,
|
|
"StatusOptions": []string{"active", "done", "archived"},
|
|
"Tasks": tasks,
|
|
"CalDAVOn": s.CalDAV != nil,
|
|
"Issues": issues,
|
|
"IssuesOpenTotal": openTotal,
|
|
"GiteaOn": s.Gitea != nil,
|
|
"Documents": documents,
|
|
"HighlightDate": highlight,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/i/")
|
|
if base, ok := strings.CutSuffix(path, "/reparent"); ok {
|
|
s.handleReparent(w, r, base)
|
|
return
|
|
}
|
|
if base, ok := strings.CutSuffix(path, "/caldav/create"); ok {
|
|
s.handleCalDAVCreate(w, r, base)
|
|
return
|
|
}
|
|
for _, action := range []string{"complete", "reopen", "edit", "delete", "todo-create"} {
|
|
if base, ok := strings.CutSuffix(path, "/caldav/todo/"+action); ok {
|
|
s.handleCalDAVTodoAction(w, r, base, action)
|
|
return
|
|
}
|
|
}
|
|
for _, action := range []string{"close", "reopen", "comment", "create"} {
|
|
if base, ok := strings.CutSuffix(path, "/issues/"+action); ok {
|
|
s.handleIssueAction(w, r, base, action)
|
|
return
|
|
}
|
|
}
|
|
if base, ok := strings.CutSuffix(path, "/links/add"); ok {
|
|
s.handleLinksAdd(w, r, base)
|
|
return
|
|
}
|
|
if base, ok := strings.CutSuffix(path, "/links/remove"); ok {
|
|
s.handleLinksRemove(w, r, base)
|
|
return
|
|
}
|
|
it, err := s.Store.GetByPath(r.Context(), path)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parentIDs := r.Form["parent_ids"]
|
|
if len(parentIDs) == 0 {
|
|
// Legacy single-value field for the classify HTMX action.
|
|
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
|
parentIDs = []string{v}
|
|
}
|
|
}
|
|
parentIDs = dedupeStrings(parentIDs)
|
|
title := strings.TrimSpace(r.FormValue("title"))
|
|
slug := strings.TrimSpace(r.FormValue("slug"))
|
|
status := strings.TrimSpace(r.FormValue("status"))
|
|
if ve := itemwrite.ValidateFormat(itemwrite.Input{
|
|
ID: it.ID, Title: title, Slug: slug, Status: status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
|
|
ID: it.ID, Title: title, Slug: slug, Status: status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
in := store.UpdateInput{
|
|
Title: title,
|
|
Slug: slug,
|
|
ParentIDs: parentIDs,
|
|
ContentMD: r.FormValue("content_md"),
|
|
Status: status,
|
|
Pinned: r.FormValue("pinned") == "1",
|
|
Archived: r.FormValue("archived") == "1",
|
|
Tags: parseCSV(r.FormValue("tags")),
|
|
Management: parseCSV(r.FormValue("management")),
|
|
// Phase 4d public-listing fields. The form includes the toggle + four
|
|
// inputs whenever the user has edit access; missing fields fall through
|
|
// to zero (false / "" / empty array), which matches "make private +
|
|
// clear values" semantics — by design.
|
|
Public: r.FormValue("public") == "1",
|
|
PublicDescription: r.FormValue("public_description"),
|
|
PublicLiveURL: strings.TrimSpace(r.FormValue("public_live_url")),
|
|
PublicSourceURL: strings.TrimSpace(r.FormValue("public_source_url")),
|
|
PublicScreenshots: parseScreenshotList(r.Form["public_screenshots"]),
|
|
// Phase 4f: timeline-exclude form field is a multi-value checkbox set
|
|
// (`name="timeline_exclude" value="todos"`, …). parseTimelineExcludeList
|
|
// keeps only the known kinds so a stray value can't poison the array.
|
|
TimelineExclude: parseTimelineExcludeList(r.Form["timeline_exclude"]),
|
|
}
|
|
updated, err := s.Store.Update(r.Context(), it.ID, in)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/i/"+updated.PrimaryPath(), http.StatusSeeOther)
|
|
}
|
|
|
|
// handleReparent replaces parent_ids. /admin/classify uses this to move
|
|
// a root mai-managed item under a chosen parent without touching other fields.
|
|
// HTMX-friendly: returns a fragment when HX-Request is set.
|
|
func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path string) {
|
|
it, err := s.Store.GetByPath(r.Context(), path)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parentIDs := r.Form["parent_ids"]
|
|
if len(parentIDs) == 0 {
|
|
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
|
parentIDs = []string{v}
|
|
}
|
|
}
|
|
parentIDs = dedupeStrings(parentIDs)
|
|
if len(parentIDs) == 0 {
|
|
http.Error(w, "reparent: parent_ids required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Reparent doesn't change title/slug/status, so the validator only
|
|
// exercises rules around parent_ids: self-parent, unknown-parent,
|
|
// cycle. Format check runs against the existing item's fields.
|
|
if ve := itemwrite.ValidateFormat(itemwrite.Input{
|
|
ID: it.ID, Title: it.Title, Slug: it.Slug, Status: it.Status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
|
|
ID: it.ID, Title: it.Title, Slug: it.Slug, Status: it.Status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
moved, err := s.Store.Reparent(r.Context(), it.ID, parentIDs)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
fmt.Fprintf(w, `<tr class="classified"><td colspan="5">Moved to <a href="/i/%s">%s</a></td></tr>`,
|
|
template.HTMLEscapeString(moved.PrimaryPath()), template.HTMLEscapeString(moved.PrimaryPath()))
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/i/"+moved.PrimaryPath(), http.StatusSeeOther)
|
|
}
|
|
|
|
// dedupeStrings preserves order, drops empties.
|
|
func dedupeStrings(in []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(in))
|
|
for _, s := range in {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[s]; ok {
|
|
continue
|
|
}
|
|
seen[s] = struct{}{}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseScreenshotList trims each entry and drops empties, preserving order.
|
|
// Used by the Public-listing form whose list editor submits one URL per
|
|
// repeated `public_screenshots` field. Order matters — the public renderer
|
|
// shows them top-down — so no deduping or sorting here.
|
|
func parseScreenshotList(raw []string) []string {
|
|
out := make([]string, 0, len(raw))
|
|
for _, v := range raw {
|
|
s := strings.TrimSpace(v)
|
|
if s == "" {
|
|
continue
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseTimelineExcludeList accepts the multi-value `timeline_exclude` form
|
|
// field and returns the deduplicated subset of recognised kinds. Any
|
|
// unknown value is dropped silently — the form is a fixed checkbox set,
|
|
// so unknown values only appear via a crafted POST.
|
|
func parseTimelineExcludeList(raw []string) []string {
|
|
allowed := map[string]struct{}{
|
|
"todos": {},
|
|
"events": {},
|
|
"docs": {},
|
|
"creation": {},
|
|
}
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(raw))
|
|
for _, v := range raw {
|
|
v = strings.TrimSpace(v)
|
|
if _, ok := allowed[v]; !ok {
|
|
continue
|
|
}
|
|
if _, dup := seen[v]; dup {
|
|
continue
|
|
}
|
|
seen[v] = struct{}{}
|
|
out = append(out, v)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseCSV splits a comma/space-delimited chip input into a deduplicated,
|
|
// trimmed lowercase string slice. Empty input → []string{} (nil avoided so
|
|
// JSON/SQL writes get an explicit empty array).
|
|
func parseCSV(raw string) []string {
|
|
if strings.TrimSpace(raw) == "" {
|
|
return []string{}
|
|
}
|
|
seen := map[string]struct{}{}
|
|
out := []string{}
|
|
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
|
|
return r == ',' || r == ' ' || r == '\t' || r == '\n'
|
|
}) {
|
|
t := strings.ToLower(strings.TrimSpace(part))
|
|
if t == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[t]; ok {
|
|
continue
|
|
}
|
|
seen[t] = struct{}{}
|
|
out = append(out, t)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
|
parentPath := r.URL.Query().Get("parent")
|
|
var parent *store.Item
|
|
if parentPath != "" {
|
|
p, err := s.Store.GetByPath(r.Context(), parentPath)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parent = p
|
|
}
|
|
s.render(w, r, "new", map[string]any{
|
|
"Title": "new",
|
|
"Parent": parent,
|
|
"StatusOptions": []string{"active", "done", "archived"},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
kind := strings.TrimSpace(r.FormValue("kind"))
|
|
if kind == "" {
|
|
kind = "project"
|
|
}
|
|
parentIDs := r.Form["parent_ids"]
|
|
if len(parentIDs) == 0 {
|
|
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
|
parentIDs = []string{v}
|
|
}
|
|
}
|
|
parentIDs = dedupeStrings(parentIDs)
|
|
title := strings.TrimSpace(r.FormValue("title"))
|
|
slug := strings.TrimSpace(r.FormValue("slug"))
|
|
status := strings.TrimSpace(r.FormValue("status"))
|
|
// New items have no ID yet — pre-flight format checks (title/slug/status)
|
|
// then DB-aware checks (parent existence + slug collision under parents).
|
|
if ve := itemwrite.ValidateFormat(itemwrite.Input{
|
|
Title: title, Slug: slug, Status: status, ParentIDs: parentIDs,
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
|
|
Title: title, Slug: slug, Status: status, ParentIDs: parentIDs,
|
|
}); ve != nil {
|
|
s.itemWriteFailure(w, r, ve)
|
|
return
|
|
}
|
|
in := store.CreateInput{
|
|
Kind: []string{kind},
|
|
Title: title,
|
|
Slug: slug,
|
|
ParentIDs: parentIDs,
|
|
ContentMD: r.FormValue("content_md"),
|
|
Status: status,
|
|
Tags: parseCSV(r.FormValue("tags")),
|
|
Management: parseCSV(r.FormValue("management")),
|
|
}
|
|
it, err := s.Store.Create(r.Context(), in)
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
|
}
|
|
|
|
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
|
orphans, err := s.Store.MaiOrphans(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
parents, err := s.parentOptions(r.Context())
|
|
if err != nil {
|
|
s.fail(w, r, err)
|
|
return
|
|
}
|
|
s.render(w, r, "classify", map[string]any{
|
|
"Title": "classify",
|
|
"Orphans": orphans,
|
|
"ParentOptions": parents,
|
|
})
|
|
}
|
|
|
|
// --- helpers ---
|
|
|
|
// ParentOption is a flat option for the parent <select>.
|
|
type ParentOption struct {
|
|
ID string
|
|
Path string
|
|
}
|
|
|
|
func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
|
|
items, err := s.Store.ListAll(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var out []ParentOption
|
|
for _, it := range items {
|
|
// Surface every primary path as a candidate parent — multi-parent
|
|
// items appear once per parent option using their primary path so the
|
|
// UI stays unambiguous.
|
|
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
|
return out, nil
|
|
}
|
|
|
|
// (buildForest + nodeHasAllTags removed in Phase 3b — superseded by
|
|
// applyTreeFilter in tree_filter.go which handles every filter dimension.)
|
|
|
|
// render writes the named page to w, looking up the user's chosen theme from
|
|
// the projax_theme cookie on r so the layout's `<html data-theme=…>` and
|
|
// `<meta name="theme-color">` flip together. Templates that omit the layout
|
|
// (HTMX fragments, the login page) ignore the injection silently.
|
|
func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
|
|
t, ok := s.pages[name]
|
|
if !ok {
|
|
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if data == nil {
|
|
data = map[string]any{}
|
|
}
|
|
theme := themeFromRequest(r)
|
|
// Don't clobber if a caller set it explicitly (e.g. tests).
|
|
if _, set := data["Theme"]; !set {
|
|
data["Theme"] = theme
|
|
}
|
|
if _, set := data["ThemeColor"]; !set {
|
|
data["ThemeColor"] = themeColorForMeta(theme)
|
|
}
|
|
entry := "layout"
|
|
switch name {
|
|
case "login":
|
|
// Login page is intentionally standalone — no nav chrome.
|
|
entry = "login"
|
|
case "tasks_section":
|
|
// HTMX fragment — no layout chrome.
|
|
entry = "tasks-section"
|
|
case "issues_section":
|
|
entry = "issues-section"
|
|
case "tree_section":
|
|
entry = "tree-section"
|
|
case "documents_section":
|
|
entry = "documents-section"
|
|
case "bulk_section":
|
|
entry = "bulk-section"
|
|
case "dashboard_section":
|
|
entry = "dashboard-section"
|
|
case "timeline_section":
|
|
entry = "timeline-section"
|
|
}
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := t.ExecuteTemplate(w, entry, data); err != nil {
|
|
s.Logger.Error("render", "page", name, "err", err)
|
|
}
|
|
}
|
|
|
|
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
status = http.StatusNotFound
|
|
}
|
|
w.WriteHeader(status)
|
|
s.render(w, r, "error", map[string]any{
|
|
"Title": "error",
|
|
"Message": err.Error(),
|
|
})
|
|
s.Logger.Error("handler", "path", r.URL.Path, "err", err)
|
|
}
|
|
|
|
// toFloat coerces template numeric inputs (int, int64, float, etc.) to
|
|
// float64 so the SVG template's coordinate math composes without per-call
|
|
// type juggling.
|
|
func toFloat(v any) float64 {
|
|
switch x := v.(type) {
|
|
case float64:
|
|
return x
|
|
case float32:
|
|
return float64(x)
|
|
case int:
|
|
return float64(x)
|
|
case int64:
|
|
return float64(x)
|
|
case int32:
|
|
return float64(x)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// logging wraps the mux with a tiny access log.
|
|
func logging(logger *slog.Logger, next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
next.ServeHTTP(w, r)
|
|
logger.Info("req", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
|
|
})
|
|
}
|