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/store" ) // 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 *timelineCache 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: newTimelineCache(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) in := store.UpdateInput{ Title: strings.TrimSpace(r.FormValue("title")), Slug: strings.TrimSpace(r.FormValue("slug")), ParentIDs: parentIDs, ContentMD: r.FormValue("content_md"), Status: strings.TrimSpace(r.FormValue("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 } 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, `