Files
projax/web/tree_filter.go
mAi 173d7ddbb2 feat(views): Phase 5j slice A — paliad-shape schema redesign
Hard-replaces the 5i projax.views table per m's Q10 pick (2026-05-29):
no real data to preserve after a few hours, and the shape changes are
big enough that a clean recreate beats a 6-step ALTER.

Schema (migration 0017_views_redesign.sql):
- id (uuid), slug (text, format-CHECK'd, UNIQUE), name, icon,
  filter_json (jsonb — INCLUDES view_type per m's Q2), sort_field,
  sort_dir, group_by, sort_order, show_count, last_used_at,
  created_at, updated_at.
- DROPPED: pinned, is_default_for, view_type column. m's Q9 picked
  MRU (last_used_at) over per-page-default; Q2 placed view_type
  inside filter_json so the JSON owns the canonical render spec.
- Constraints: slug regex, sort_dir enum. NO view_type CHECK — the
  JSON-shape validator owns it now.
- Indexes: slug UNIQUE, (sort_order, name), (last_used_at DESC).
- updated_at trigger reused; projax_admin ownership preserved.

Store (store/views.go rewrite):
- View struct: Slug as the user-facing key; uuid kept on ID for the
  legacy `?view=<uuid>` 302-redirect path that lands in slice C.
- ListViews ordered by sort_order, name (matches sidebar).
- GetView(slug) + GetViewByID(uuid). MostRecentView() drives the
  /views landing redirect (slice B).
- TouchView(slug) bumps last_used_at fire-and-forget.
- ReorderViews([]slugs) wires the column for slice G's drag UI.
- CreateView server-assigns sort_order = MAX+1 inside the tx.
- UpdateView replaces every writeable field; renames are supported.
- Validation: slug format regex + reserved-list rejection +
  filter_json JSON well-formed check before round-trip.
- ErrViewNotFound / ErrViewSlugTaken / ErrViewSlugReserved /
  ErrViewSlugFormat surface to handlers as the typed error set.

Cleanup of the 5i overlay (drops what the new shape obsoletes):
- web/views.go: gutted to a stub. applySavedView, applyDefaultView,
  overlayURLFields, filterQueryToJSON, filterJSONToQuery,
  filterFromJSONPayload, anySliceToStrings + every old handler
  (handleViewsIndex, handleViewCreate, handleViewWrite, handleViewEdit,
  handleViewRedirect, handleViewDelete) deleted.
- web/server.go: dropped the /views route registrations and the
  applySavedView + applyDefaultView calls in handleTree.
  DefaultBanner data-map field removed.
- web/tree_filter.go: TreeFilter.ViewID field removed; ParseTreeFilter
  and QueryString stop reading/emitting ?view=.
- web/templates/views.tmpl and view_edit.tmpl deleted.
- web/templates/tree_section.tmpl: default-banner block deleted.
- web/views_test.go: deleted (every test was against the 5i shape).

Between slice A and slice B, /views/* URLs return 404 by design.
Slice B reintroduces the route family in paliad-shape:
  GET /views          → MRU landing
  GET /views/{slug}   → render
  GET /views/new      → editor
  GET /views/{slug}/edit → editor
  POST /views, /views/{slug}, /views/{slug}/delete → CRUD

Tests (store/views_test.go, new):
- TestViewSlugCRUD — create / get-by-slug / get-by-id / rename /
  delete round-trip, including rename-leaves-old-slug-gone.
- TestViewSlugFormatRejected — uppercase, underscore, leading dash,
  length-cap, empty all surface ErrViewSlugFormat.
- TestViewReservedSlugRejected — tree/dashboard/calendar/timeline/graph
  and friends all reject with ErrViewSlugReserved.
- TestViewSlugCollision — duplicate slug surfaces ErrViewSlugTaken.
- TestViewMRU — TouchView + MostRecentView ordering against a
  controlled pair of slugs (resilient to other suites' touched views).
- TestViewReorder — ReorderViews rewrites sort_order ascending.

Web tests stay green (the 5i overlay tests are gone, the rest don't
touch the views shape).
2026-05-29 11:41:28 +02:00

543 lines
16 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
// 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) 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
}