Files
projax/web/server.go
mAi 9ee26002f8 refactor(web): validate item writes via internal/itemwrite/
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
2026-05-22 00:36:14 +02:00

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)
})
}