package web import ( "context" "embed" "errors" "fmt" "html/template" "io/fs" "log/slog" "net/http" "sort" "strings" "time" "github.com/m/projax/store" ) //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) } // 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 }, "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 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 return &Server{Store: s, pages: pages, Logger: logger}, 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/classify", s.handleClassify) 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 } fmt.Fprintln(w, "ok") }) 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, "tree_section", data) return } s.render(w, "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, "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 } } 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")), } 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, `