Closes the Phase 5i implementation chain. When `views.is_default_for=<page>`
is set, opening that page with a "clean" URL (no chip params, no
?view=) auto-applies the saved filter + view_type. A "Showing default
view: <name> · clear" banner makes the swap visible and gives the user
a one-click out. Adding any chip param to the URL bypasses the default;
?nodefault=1 is the explicit opt-out for "I want the bare default tree".
New web/views.go: applyDefaultView gates on the param-cleanness check
+ Store.DefaultViewFor lookup. Resolution + view_type revalidation
mirror the slice D ?view=<uuid> path so a kanban-default opened on a
route that doesn't allow kanban falls back cleanly.
handleTree wires it into the existing slice D else-branch (no default
when ?view= is set). DefaultBanner field passes the applied view to
the template for the banner.
Test:
- TestDefaultViewAppliedOnCleanURL — seeds a tree default with
filter_json={tags:[work]} + view_type=card, then asserts: clean GET /
applies (card grid + banner with the view's name); ?tag=dev bypasses
(forest, no banner); ?nodefault=1 opt-out (forest, no banner).
1065 lines
35 KiB
Go
1065 lines
35 KiB
Go
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 `<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]
|
|
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", "views"} {
|
|
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)
|
|
mux.HandleFunc("GET /views", s.handleViewsIndex)
|
|
mux.HandleFunc("POST /views", s.handleViewCreate)
|
|
mux.HandleFunc("GET /views/", s.handleViewRedirect)
|
|
mux.HandleFunc("POST /views/", s.handleViewWrite)
|
|
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)
|
|
var defaultBanner *store.View
|
|
// Phase 5i Slice D: ?view=<uuid> resolves a saved view's filter +
|
|
// view_type into the current request, overriding URL-only chip state.
|
|
// Resolution failure (deleted view, malformed payload) is logged and
|
|
// silently falls back to the URL-derived filter — the page stays
|
|
// renderable rather than 500ing.
|
|
if saved, err := s.applySavedView(r, &filter, &view); err == nil && saved != nil {
|
|
// Re-validate view_type against the route catalog so a saved
|
|
// kanban-view URL opened on / (before slice C ships kanban) lands on
|
|
// the default with the chip showing the wanted view as locked.
|
|
view = viewSet.Resolve(view)
|
|
} else if err != nil {
|
|
s.Logger.Warn("applySavedView", "id", r.URL.Query().Get("view"), "err", err)
|
|
} else {
|
|
// Phase 5i Slice E: no explicit ?view= → check for a page default.
|
|
// applyDefaultView returns nil unless the URL is "clean" (no chip
|
|
// state) AND a default exists for this page.
|
|
if def, err := s.applyDefaultView(r, "tree", &filter, &view); err == nil && def != nil {
|
|
view = viewSet.Resolve(view)
|
|
defaultBanner = def
|
|
} else if err != nil {
|
|
s.Logger.Warn("applyDefaultView", "page", "tree", "err", err)
|
|
}
|
|
}
|
|
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,
|
|
"DefaultBanner": defaultBanner,
|
|
// 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).
|
|
// 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 <select multiple> filter strip
|
|
// silently dropped.
|
|
func parseValues(q url.Values, key string) []string {
|
|
if vs, ok := q[key]; ok {
|
|
return parseCSV(strings.Join(vs, ","))
|
|
}
|
|
return []string{}
|
|
}
|
|
|
|
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
|
|
}
|
|
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 `<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)
|
|
}
|
|
// 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)
|
|
})
|
|
}
|