Files
projax/web/tree_filter.go
mAi d5e7796cf6 feat(phase 3b filtering): full tree-page filter bar (search + chips + counts + HTMX swap)
Tree page (/) gains every navigation dimension m asked for:

- Debounced search input matching title/slug/aliases/content_md/paths
  case-insensitively (?q=…)
- Tag chip row (?tag=a,b — AND within tags, as before)
- Management chip row with ?mgmt=mai,self,external,unmanaged
  (OR within management; "unmanaged" is the synthetic empty-array case)
- Status chip row with ?status=active,done,archived (default = active;
  archived rows only surface when the separate show-archived toggle is on)
- Has-link chip row ?has=caldav-list,gitea-repo
- Each chip carries the count it would yield if toggled — honest user
  cue, computed via per-dimension recomputation in pure Go (cheap at
  m's scale)
- URL is the source of truth — every filter goes through the query
  string, so any view is bookmarkable; HTMX swaps the tree-section in
  place with hx-push-url=true on every chip click and on search keyup
- Empty-state copy with a clear-all link

Implementation:

- web/tree_filter.go new: TreeFilter struct + ParseTreeFilter +
  QueryString/URL + Toggle* helpers + Matches + applyTreeFilter
  (replacement for buildForest) + computeChipCounts.
- web/tree_filter_test.go: parse defaults + every dimension's match +
  URL round-trip + ancestor-keep semantics + chip counting.
- Linkages: linkKindsByItem on Server fans across the two has-link
  ref_types in one pass and feeds the filter.
- tree.tmpl reduced to a one-liner that calls tree-section; new
  tree_section partial powers both the initial page render and HTMX
  fragment swaps (matches the pattern from phases 2.a/b/d).

docs/design.md §4: tree-filter contract — URL keys, AND/OR rules,
count semantics, archived ergonomics.
2026-05-15 18:21:26 +02:00

404 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
// 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 {
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.
func ParseTreeFilter(q url.Values) TreeFilter {
f := TreeFilter{
Q: strings.TrimSpace(q.Get("q")),
Tags: parseCSV(q.Get("tag")),
Management: parseCSV(q.Get("mgmt")),
Status: parseCSV(q.Get("status")),
HasLinks: parseCSV(q.Get("has")),
ShowArchived: q.Get("show-archived") == "1",
}
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")
}
return v.Encode()
}
// URL builds a `/?…` URL for this filter. Empty filter → "/".
func (f TreeFilter) URL() string {
q := f.QueryString()
if q == "" {
return "/"
}
return "/?" + 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
}
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
}
}
// 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 })
}
// 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) ChipCounts {
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.URL(),
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.URL(),
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.URL(),
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.URL(),
Count: count(next),
Active: contains(current.HasLinks, h),
})
}
{
next := current.ToggleShowArchived()
out.ShowArchived = ChipCount{
Label: "show archived",
URL: next.URL(),
Count: count(next),
Active: current.ShowArchived,
}
}
return out
}