package web import ( "context" "embed" "errors" "fmt" "html/template" "io/fs" "log/slog" "net/http" "sort" "strings" "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 } // 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{"tree", "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 } // 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", ) 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 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") }) 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 } activeTags := parseCSV(r.URL.Query().Get("tag")) roots, orphans, total, orphanN := buildForest(items, activeTags) s.render(w, "tree", map[string]any{ "Title": "tree", "Roots": roots, "Orphans": orphans, "Total": total, "OrphanN": orphanN, "AllTags": tags, "ActiveTags": activeTags, }) } func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) { path := strings.TrimPrefix(r.URL.Path, "/i/") if path == "" { http.NotFound(w, r) return } it, err := s.Store.GetByPath(r.Context(), path) 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 } 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, }) } 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 } } 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, `