package web import ( "context" "embed" "errors" "fmt" "html/template" "io/fs" "log/slog" "mime" "net/http" "net/url" "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 `` → // 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] calendar *cache.TTLCache[*calendarPayload] 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. project_chip.tmpl is the Phase 5i Slice // A shared partial that every Views-supporting page includes inside its // filter strip. treeTmpl, err := template.New("tree").Funcs(funcs).ParseFS(templatesFS, "templates/layout.tmpl", "templates/tree.tmpl", "templates/tree_section.tmpl", "templates/tree_card.tmpl", "templates/tree_kanban.tmpl", "templates/project_chip.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", "templates/tree_card.tmpl", "templates/tree_kanban.tmpl", "templates/project_chip.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. Phase 5h: the section fragment // dispatches to one of three view partials (tiles / cards / events-view), // so the tiles partial joins both bundles. dashTmpl, err := template.New("dashboard").Funcs(funcs).ParseFS(templatesFS, "templates/layout.tmpl", "templates/dashboard.tmpl", "templates/dashboard_section.tmpl", "templates/dashboard_tiles.tmpl", "templates/project_chip.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", "templates/dashboard_tiles.tmpl", "templates/project_chip.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", "templates/project_chip.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", "templates/project_chip.tmpl", ) if err != nil { return nil, fmt.Errorf("parse timeline_section: %w", err) } pages["timeline_section"] = timelineSection // Calendar page — month grid view, Phase 5e. Bundles the section // partial so HTMX swaps (filter chip strip) and the full-page render // share definitions. calTmpl, err := template.New("calendar").Funcs(funcs).ParseFS(templatesFS, "templates/layout.tmpl", "templates/calendar.tmpl", "templates/calendar_section.tmpl", "templates/project_chip.tmpl", ) if err != nil { return nil, fmt.Errorf("parse calendar: %w", err) } pages["calendar"] = calTmpl calSection, err := template.New("calendar_section").Funcs(funcs).ParseFS(templatesFS, "templates/calendar_section.tmpl", "templates/project_chip.tmpl", ) if err != nil { return nil, fmt.Errorf("parse calendar_section: %w", err) } pages["calendar_section"] = calSection // 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), calendar: cache.NewTTL[*calendarPayload](calendarCacheTTL), 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 /calendar", s.handleCalendar) 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("POST /dashboard/pin", s.handleDashboardPin) 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) // /views routes land in slice B (paliad-shape: GET /views, GET // /views/{slug}, GET /views/new, GET /views/{slug}/edit, plus POST CRUD). // Between slice A and slice B these URLs 404 by design. 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()) viewSet := PageViewTypes("/") view := ParseViewType(r.URL.Query(), viewSet) // Phase 5j: ?view= overlay + is_default_for resolution deleted with the // 5i shape. /views/{slug} (slice B+) renders saved views as their own // pages; legacy ?view= URLs are 302-redirected from a dedicated // handler (slice C). handleTree stays focused on the tree-as-tree // surface and no longer hijacks itself based on a query param. roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds) counts := computeChipCounts(items, filter, linkKinds, tags) // Phase 5i Slice B: the card view renders a flat grid of matched items // (no tree structure). Build from items + filter directly rather than // reusing the post-prune `roots` (which still keeps ancestors). cardItems := flatMatchedItems(items, filter, linkKinds) // Phase 5i Slice C: kanban groups the same matched set into columns. groupBy := ParseGroupBy(r.URL.Query()) kanban := BuildKanbanBoard(cardItems, groupBy) groupByChips := GroupByChips("/", filter, groupBy) data := map[string]any{ "Title": "tree", "Roots": roots, "Orphans": orphans, "Total": total, "OrphanN": orphanN, "Matched": matched, "AllTags": tags, "Filter": filter, "Counts": counts, "Projects": parentOptionsFromItems(items), "BasePath": "/", "ProjectChipTarget": "#tree-section", "ViewType": view, "ViewTypeChips": ViewTypeChips("/", filter, view), "CardItems": cardItems, "Kanban": kanban, "GroupBy": groupBy, "GroupByChips": groupByChips, // 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, `Moved to %s`, 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). // parseValues collects every value for `key` from a url.Values map and // splits each on the same comma/whitespace separators parseCSV accepts. // Handles both filter-strip styles: // - `?tag=foo,bar` (tree page hidden-input chip pattern) // - `?tag=foo&tag=bar` (HTMX multi-select form submission) // // Mixed shapes work too (`?tag=foo,bar&tag=baz` → [foo bar baz]). // Without this, `q.Get(key)` returned only the first value, so the // second tag/mgmt/has selection from any . 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 } return parentOptionsFromItems(items), nil } // parentOptionsFromItems builds the same flat option list parentOptions // returns, but from an already-loaded items slice. Callers that have already // fetched items (handleTree, handleDashboard, …) use this to avoid a second // ListAll round-trip when they only need the picker options. func parentOptionsFromItems(items []*store.Item) []ParentOption { out := make([]ParentOption, 0, len(items)) for _, it := range items { 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 } // (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 `` and // `` 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) } // Phase 5g: layout.tmpl marks the active sidebar/bottom-nav item by // comparing .Path to each item's href. Each request gets its own copy; // tests can still override with an explicit Path value. if _, set := data["Path"]; !set { data["Path"] = r.URL.Path } 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" case "calendar_section": entry = "calendar-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) }) }