Files
projax/web/tree_filter.go
mAi 6f0a318979 fix(filters): preserve every value from <select multiple> filter strips
Symptom (m-reported): /calendar filters don't work.

Root cause: ParseTreeFilter and calendar's ?kind parser both used
`r.URL.Query().Get(key)` to read tag/mgmt/has/status/kind. `Get()`
returns ONLY the first value when a URL has the same key repeated, and
the HTMX filter-strip forms (calendar_section.tmpl, timeline_section,
dashboard_section, graph, bulk) all use `<select multiple name="tag">`
which the browser serialises as `?tag=foo&tag=bar` — repeated params,
not the comma-joined `?tag=foo,bar` the tree page emits from its hidden
input. Every second-and-beyond chip silently dropped on every filter
submission across every page with a multi-select strip; m happened to
catch it on /calendar.

Fix (single helper, four call-site swaps):

- web/server.go parseValues(q, key): collects q[key] (the full slice of
  values), joins on comma, runs parseCSV. Accepts both URL shapes:
    ?tag=foo,bar          → ["foo", "bar"]
    ?tag=foo&tag=bar      → ["foo", "bar"]
    ?tag=foo,bar&tag=baz  → ["foo", "bar", "baz"]

- web/tree_filter.go ParseTreeFilter: tag / mgmt / status / has all
  switch from `parseCSV(q.Get(...))` to `parseValues(q, ...)`. q / show-
  archived / public stay on `q.Get` — they're single-value by design.

- web/calendar.go parseCalendarQuery: ?kind handling drops the bespoke
  q.Get + strings.Split + dedup-map and uses `parseValues(..., "kind")`
  for the same reason. Behaviour preserved for legacy comma-joined
  `?kind=event,doc` AND new repeated-param submission.

Regression test:

- TestCalendarFilterMultiValueTagsFromForm seeds three items — one with
  both test tags (A+B), one with only A, one with only B — drops a
  dated link on each, then probes `/calendar?tag=A&tag=B`. Before the
  fix the A-only note leaked through (the parser kept just tag=A);
  after, only the A+B item appears per the AND-across-tags contract.

Full web suite green. Pre-existing db/TestBackfillTagsFromArea failure
unchanged (independent of this change).

Same fix transparently repairs /timeline, /dashboard, /graph, /bulk —
they all consume ParseTreeFilter and shared the bug.
2026-05-26 11:56:42 +02:00

450 lines
12 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
Public *bool // Phase 4d — nil = no filter; true = public only; false = private only
}
// 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
}
// 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",
}
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
}
}
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")
}
}
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 {
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
}
}
// Public (Phase 4d): when set, must match the item's flag.
if f.Public != nil && *f.Public != it.Public {
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
}