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.
404 lines
11 KiB
Go
404 lines
11 KiB
Go
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
|
||
}
|