package web import ( "net/url" "sort" "strings" "github.com/m/projax/store" ) // TreeFilter is the parsed state of the tree-page filter chips + search input. // Every dimension is independent; matching is AND across dimensions, OR within. type TreeFilter struct { Q string // search query, case-insensitive substring Tags []string // ALL must be present Management []string // ANY of these matches (incl. synthetic "unmanaged") Status []string // ANY of these matches (default = ["active"]) HasLinks []string // ANY of these ref_types must be linked to the item ("caldav-list", "gitea-repo") ShowArchived bool // when false, hide items with archived=true even if Status matches Public *bool // Phase 4d — nil = no filter; true = public only; false = private only // Phase 5i Slice A — project scope. // ProjectPath is the picked project's primary path (e.g. "work.upc"). Empty // means no project filter. IncludeDescendants defaults to true; when false, // only items whose paths include the exact ProjectPath match (no subtree). // Per m's Q5 pick (2026-05-26), descendants are NOT always-on — the chip // exposes an explicit on/off toggle. ProjectPath string IncludeDescendants bool } // Active reports whether any filter dimension is set to something other than // the implicit default. Used for the "clear filters" link visibility. func (f TreeFilter) Active() bool { if f.Q != "" || len(f.Tags) > 0 || len(f.Management) > 0 || len(f.HasLinks) > 0 || f.ShowArchived || f.Public != nil { return true } if f.ProjectPath != "" { return true } // Status is the only dimension with a default; treat it as "active" if it // deviates from {"active"}. if len(f.Status) != 1 || f.Status[0] != "active" { return true } return false } // ParseTreeFilter pulls the filter state from a URL query. // Defaults: Status=["active"], ShowArchived=false. Other dimensions empty. // Multi-value dimensions (tag/mgmt/status/has) use parseValues so BOTH the // comma-joined hidden-input style (`?tag=foo,bar`, tree page) AND the // repeated-param HTMX multi-select style (`?tag=foo&tag=bar`, every // filter-strip form) round-trip into the same TreeFilter shape. The // prior `q.Get(key)` calls silently dropped every second-and-beyond // value from any multi-select submission — see // TestCalendarFilterMultiValueTagsFromForm for the regression. func ParseTreeFilter(q url.Values) TreeFilter { f := TreeFilter{ Q: strings.TrimSpace(q.Get("q")), Tags: parseValues(q, "tag"), Management: parseValues(q, "mgmt"), Status: parseValues(q, "status"), HasLinks: parseValues(q, "has"), ShowArchived: q.Get("show-archived") == "1", ProjectPath: strings.TrimSpace(q.Get("project")), IncludeDescendants: true, } if v := strings.TrimSpace(q.Get("public")); v != "" { // Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil. switch strings.ToLower(v) { case "1", "true", "yes", "on": b := true f.Public = &b case "0", "false", "no", "off": b := false f.Public = &b } } // project_descendants=0 flips the toggle off; any other / missing value // leaves the default (true). Matches the show-archived parsing pattern. if q.Get("project_descendants") == "0" { f.IncludeDescendants = false } if len(f.Status) == 0 { f.Status = []string{"active"} } return f } // QueryString re-encodes the filter back to a URL query string. Keys are // emitted in a stable order so URL-bar history doesn't churn. Default values // are elided (no key emitted when the dimension is at default). func (f TreeFilter) QueryString() string { v := url.Values{} if f.Q != "" { v.Set("q", f.Q) } if len(f.Tags) > 0 { v.Set("tag", strings.Join(f.Tags, ",")) } if len(f.Management) > 0 { v.Set("mgmt", strings.Join(f.Management, ",")) } if !(len(f.Status) == 1 && f.Status[0] == "active") { v.Set("status", strings.Join(f.Status, ",")) } if len(f.HasLinks) > 0 { v.Set("has", strings.Join(f.HasLinks, ",")) } if f.ShowArchived { v.Set("show-archived", "1") } if f.Public != nil { if *f.Public { v.Set("public", "1") } else { v.Set("public", "0") } } if f.ProjectPath != "" { v.Set("project", f.ProjectPath) // IncludeDescendants=true is the default — elide. Only emit when the // user has explicitly turned descendants off (the chip's "off" state). if !f.IncludeDescendants { v.Set("project_descendants", "0") } } return v.Encode() } // TogglePublic flips through the three states: nil → public-only → private-only → nil. func (f TreeFilter) TogglePublic() TreeFilter { next := f switch { case f.Public == nil: t := true next.Public = &t case *f.Public: t := false next.Public = &t default: next.Public = nil } return next } // URL builds a `/?…` URL for this filter. Empty filter → "/". func (f TreeFilter) URL() string { return f.URLOn("/") } // URLOn builds a URL anchored at `base` for this filter. Empty filter → // `base` unchanged. Used by Views-supporting pages (dashboard, timeline, // calendar) to construct chip URLs that stay on the current route, where the // default URL() always lands on "/". func (f TreeFilter) URLOn(base string) string { q := f.QueryString() if q == "" { return base } return base + "?" + q } // ToggleTag returns a copy with tag added/removed. func (f TreeFilter) ToggleTag(tag string) TreeFilter { next := f next.Tags = toggleString(f.Tags, tag) return next } // ToggleManagement returns a copy with the management mode toggled. The // synthetic value "unmanaged" matches items whose management array is empty. func (f TreeFilter) ToggleManagement(mode string) TreeFilter { next := f next.Management = toggleString(f.Management, mode) return next } // ToggleStatus toggles a status. "active" is the implicit default; toggling // it off when no others are selected leaves the dimension at ["active"] // (so the user can't accidentally hide every row). func (f TreeFilter) ToggleStatus(status string) TreeFilter { next := f next.Status = toggleString(f.Status, status) if len(next.Status) == 0 { next.Status = []string{"active"} } return next } func (f TreeFilter) ToggleHas(kind string) TreeFilter { next := f next.HasLinks = toggleString(f.HasLinks, kind) return next } func (f TreeFilter) ToggleShowArchived() TreeFilter { next := f next.ShowArchived = !f.ShowArchived return next } // SetProject returns a copy scoped to the given primary path. Empty path // clears the scope. IncludeDescendants resets to true (the safe default) when // the project is cleared so a future SetProject doesn't inherit a stale off // state. func (f TreeFilter) SetProject(path string) TreeFilter { next := f next.ProjectPath = strings.TrimSpace(path) if next.ProjectPath == "" { next.IncludeDescendants = true } return next } // ToggleIncludeDescendants flips the descendants toggle. The chip stays // settable even with no project picked (so the URL bar can carry the user's // preference for the next project they pick), but Matches only consults it // when ProjectPath is set. func (f TreeFilter) ToggleIncludeDescendants() TreeFilter { next := f next.IncludeDescendants = !f.IncludeDescendants return next } func toggleString(in []string, val string) []string { found := false out := make([]string, 0, len(in)) for _, s := range in { if s == val { found = true continue } out = append(out, s) } if !found { out = append(out, val) sort.Strings(out) } return out } // Matches reports whether the item matches every active filter dimension on // its own (no descendant credit). hasLinks is a per-item set of ref_types // linked to this item, precomputed by the caller for cheap lookup. func (f TreeFilter) Matches(it *store.Item, itemLinkKinds map[string]struct{}) bool { // Status / archived. Default-status semantics: when Status=["active"], an // item with status=active passes; archived items are filtered out unless // ShowArchived is on. if !contains(f.Status, it.Status) { return false } if it.Archived && !f.ShowArchived { return false } // Tags AND. for _, t := range f.Tags { if !it.HasTag(t) { return false } } // Management OR (with synthetic "unmanaged" matching empty []). if len(f.Management) > 0 { ok := false for _, m := range f.Management { if m == "unmanaged" && len(it.Management) == 0 { ok = true break } if it.HasManagement(m) { ok = true break } } if !ok { return false } } // Has-link AND (every requested link kind must be present). for _, h := range f.HasLinks { if _, ok := itemLinkKinds[h]; !ok { return false } } // Public (Phase 4d): when set, must match the item's flag. if f.Public != nil && *f.Public != it.Public { return false } // Project scope (Phase 5i Slice A). When set, the item must have at least // one path equal to ProjectPath (exact match), and — when // IncludeDescendants is on — paths that are descendants of ProjectPath // (prefix + ".") also match. Multi-parent items are in scope as long as // ANY of their paths qualifies. if f.ProjectPath != "" { prefix := f.ProjectPath + "." hit := false for _, p := range it.Paths { if p == f.ProjectPath { hit = true break } if f.IncludeDescendants && strings.HasPrefix(p, prefix) { hit = true break } } if !hit { return false } } // q substring match. if f.Q != "" { q := strings.ToLower(f.Q) hit := false if strings.Contains(strings.ToLower(it.Title), q) { hit = true } if !hit && strings.Contains(strings.ToLower(it.Slug), q) { hit = true } if !hit && strings.Contains(strings.ToLower(it.ContentMD), q) { hit = true } if !hit { for _, p := range it.Paths { if strings.Contains(strings.ToLower(p), q) { hit = true break } } } if !hit { for _, a := range it.Aliases { if strings.Contains(strings.ToLower(a), q) { hit = true break } } } if !hit { return false } } return true } func contains(haystack []string, needle string) bool { for _, h := range haystack { if h == needle { return true } } return false } // applyTreeFilter is the post-Phase-3b replacement for buildForest. It builds // the same DAG-as-forest shape, then prunes branches that neither match nor // have a matching descendant. `matched` counts items (deduped by id) that the // filter accepted directly — distinct from `total`. func applyTreeFilter(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) (roots []*treeNode, orphans []*store.Item, total, orphanN, matched int) { for _, it := range items { total++ if len(it.ParentIDs) == 0 && it.HasManagement("mai") { orphans = append(orphans, it) orphanN++ } } // Build forest (one node per parent edge, multi-parent items duplicated). nodeFor := func(it *store.Item) *treeNode { return &treeNode{Item: it} } rootNodes := []*treeNode{} childByParent := make(map[string][]*treeNode, len(items)) for _, it := range items { if len(it.ParentIDs) == 0 { rootNodes = append(rootNodes, nodeFor(it)) continue } for _, pid := range it.ParentIDs { childByParent[pid] = append(childByParent[pid], nodeFor(it)) } } var attach func(n *treeNode) attach = func(n *treeNode) { n.Children = childByParent[n.Item.ID] for _, c := range n.Children { attach(c) } } for _, r := range rootNodes { attach(r) } roots = rootNodes // Pre-compute per-item match against the filter — done once, even though // the same item may appear in multiple branches. hit := make(map[string]bool, len(items)) for _, it := range items { hit[it.ID] = f.Matches(it, linkKinds[it.ID]) if hit[it.ID] { matched++ } } // Prune the forest, keeping ancestors of any match. var keep func(n *treeNode) bool keep = func(n *treeNode) bool { filtered := n.Children[:0] for _, c := range n.Children { if keep(c) { filtered = append(filtered, c) } } n.Children = filtered if hit[n.Item.ID] { return true } return len(n.Children) > 0 } filtered := roots[:0] for _, n := range roots { if keep(n) { filtered = append(filtered, n) } } roots = filtered // Stable ordering. sortNodes(roots) sortItems(orphans) return } func sortNodes(roots []*treeNode) { sort.Slice(roots, func(i, j int) bool { return roots[i].Item.Slug < roots[j].Item.Slug }) for _, r := range roots { sortNodes(r.Children) } } func sortItems(in []*store.Item) { sort.Slice(in, func(i, j int) bool { return in[i].Slug < in[j].Slug }) } // flatMatchedItems returns every item that passes the filter directly — no // ancestor-keep, no DAG shape. Used by Phase 5i Slice B's card view: a flat // grid of tiles for the filtered set. Stable order by primary path. func flatMatchedItems(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) []*store.Item { out := make([]*store.Item, 0, len(items)) for _, it := range items { if f.Matches(it, linkKinds[it.ID]) { out = append(out, it) } } sort.Slice(out, func(i, j int) bool { return out[i].PrimaryPath() < out[j].PrimaryPath() }) return out } // ChipCount packages a chip label, the URL that toggles it, the count it // would yield if it were toggled on (or current count if already on), and a // flag for whether it's currently active. Used by the template for every // chip row. type ChipCount struct { Label string URL string Count int Active bool } // ChipCounts groups every per-dimension count so the template can render them // in stable order. type ChipCounts struct { Tags []ChipCount Management []ChipCount Status []ChipCount Has []ChipCount ShowArchived ChipCount } // computeChipCounts produces the count each chip would yield if toggled. // For an already-active chip the count is the current match count (so users // see what they're filtered down to). For an inactive chip the count is what // they'd get if they added it. At m's scale (≤100 items × ≤30 chips) this is // trivially cheap; no caching needed. func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string, base string) ChipCounts { if base == "" { base = "/" } count := func(f TreeFilter) int { // Branch-keep semantics aren't relevant for chip counts — we want a // raw "how many items match this filter directly" so the chip number // is honest. n := 0 for _, it := range items { if f.Matches(it, linkKinds[it.ID]) { n++ } } return n } out := ChipCounts{} for _, tag := range allTags { next := current.ToggleTag(tag) out.Tags = append(out.Tags, ChipCount{ Label: tag, URL: next.URLOn(base), Count: count(next), Active: contains(current.Tags, tag), }) } for _, mode := range []string{"mai", "self", "external", "unmanaged"} { next := current.ToggleManagement(mode) out.Management = append(out.Management, ChipCount{ Label: mode, URL: next.URLOn(base), Count: count(next), Active: contains(current.Management, mode), }) } for _, st := range []string{"active", "done", "archived"} { next := current.ToggleStatus(st) out.Status = append(out.Status, ChipCount{ Label: st, URL: next.URLOn(base), Count: count(next), Active: contains(current.Status, st), }) } for _, h := range []string{"caldav-list", "gitea-repo"} { next := current.ToggleHas(h) out.Has = append(out.Has, ChipCount{ Label: h, URL: next.URLOn(base), Count: count(next), Active: contains(current.HasLinks, h), }) } { next := current.ToggleShowArchived() out.ShowArchived = ChipCount{ Label: "show archived", URL: next.URLOn(base), Count: count(next), Active: current.ShowArchived, } } return out }