Files
projax/web/tree_filter.go
mAi f6cf050c3f feat(phase 4d): public-listing fields so projax becomes the portfolio source of truth
Adds five additive columns on projax.items and propagates them through
every read/write path. flexsiebels.de (and any future portfolio renderer)
can now pull the public set via the MCP `list_items(public=true)` filter
and stop hard-coding project lists.

## Schema (migration 0014)
- public               boolean       default false (partial index when true)
- public_description   text          default ''
- public_live_url      text          default ''
- public_source_url    text          default ''
- public_screenshots   text[]        default '{}'
- items_unified view rebuilt to include the five new columns
- items_public_idx     PARTIAL INDEX WHERE public = true (5% of rows)

## Store
- Item struct + scan/scanItems extended (5 cols)
- UpdateInput accepts the new fields with full-replace semantics
- new SetPublic(ids, bool) for bulk write
- SearchFilters gains Public *bool — nil = no filter

## MCP
- list_items: new `public` boolean filter (input schema + handler)
- update_item: 5 new partial-update fields (nil pointer = leave alone)
- itemView always emits the 5 fields (even when public=false) so consumers
  can preview "what would publish" without a second round-trip
- 2 new integration tests against the DB

## Web
- /i/{path} grows a "Public listing" fieldset: toggle + textarea + 2 URL
  inputs + screenshot list editor with add/remove rows + inline JS for
  the editor. Values persist when public is off so toggling never
  destroys typed-in content.
- /admin/bulk action bar gains "Make public" / "Make private" via a new
  select; SQL update is a single statement per action.
- /?public=1 and /?public=0 chip parameters narrow the tree page.
  Active() + QueryString() + TogglePublic() round-trip the state.
- parseScreenshotList helper trims + drops empties + preserves order
- 5 integration tests: migration landed, form round-trip, bulk action
  round-trip, detail-page affordances, tree-filter narrowing

## docs/design.md §15
Documents the schema, MCP contract, UI surfaces, flexsiebels consumption
pattern, and what's NOT in scope (flexsiebels-side render, asset hosting,
approval workflows).

## Out of scope (per task brief)
- Flexsiebels rendering — separate task in m/flexsiebels.de after this ships
- Asset hosting (projax stores URLs, never bytes — same PER discipline)
- Multi-stage publish workflow (boolean is enough)
2026-05-17 19:11:26 +02:00

443 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.
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 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
}