Files
projax/web/bulk.go
mAi 0bea9c1ba4 feat(phase 4f): per-item timeline_exclude flag (hide noise from /timeline)
m's stated use case: home VTODOs (shopping list) shouldn't pollute the
chronological /timeline by default, but they should stay visible on the
home detail page itself. This adds an item-level switch with four kinds
and a URL override to peek at everything when wanted.

## Schema (migration 0015)
- timeline_exclude text[] NOT NULL DEFAULT '{}'
- items_timeline_exclude_idx GIN
- items_unified view rebuilt to surface the new column
- Behaviour-neutral: empty array = unchanged from today. m flips the
  toggle himself via /admin/bulk or the detail-page form.

## Aggregation
- web/timeline.go: pre-compute the per-kind keep-list via keepFor(kind)
  before fanning out — items with the kind in their exclude array are
  dropped entirely (no CalDAV call wasted on excluded sources). Doc and
  creation rows check the per-item flag inline. `?include_excluded=1`
  (URL) and `include_excluded:true` (MCP arg) override the filter.
- store.Item.ExcludesTimelineKind(kind) helper accepts either singular
  ("todo") or plural ("todos") to bridge the kind-constant / persisted-
  value naming choice — see comment for the why.

## UI
- /i/{path} grows a "Timeline behaviour" collapsible section with four
  checkboxes (todos / events / docs / creation) and helper text. Open by
  default when any kind is excluded, so m can see at a glance what's
  hidden for this item.
- /admin/bulk gains a "timeline todos" select with "Exclude from timeline"
  and "Re-include on timeline" — the other three kinds stay editable
  per-item only per the task brief (most common use case is just todos).

## MCP
- update_item accepts timeline_exclude as a partial-update field with an
  enum-restricted whitelist; unknown values dropped silently.
- itemView always emits timeline_exclude (defaults to []) so consumers
  can render the toggle state without a second round-trip.

## Tests
- Migration + GIN index landed
- Item with timeline_exclude=['todos'] hides the VTODO from /timeline
- ?include_excluded=1 brings it back
- Bulk action toggles the array idempotently in both directions
- Detail page renders all 4 checkbox affordances

## docs/design.md
§12 gains a "Per-item exclusion" subsection documenting semantics, the
URL override, the bulk action, and the "detail page still shows everything"
invariant.

## Out of scope (per task brief)
- Per-tag exclusion (per-item is clearer)
- Per-day exclusion (overkill)
- Dashboard exclusion (m only flagged timeline; dashboard's "today" view
  should still show shopping today if it's due today)
- Auto-seeding home with timeline_exclude=['todos'] (m runs once himself
  via /admin/bulk after the deploy — schema change stays behaviour-neutral)
2026-05-17 19:28:49 +02:00

445 lines
14 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 (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/m/projax/store"
)
// handleBulk renders the bulk-edit admin page: a filter bar (reusing the
// tree-page TreeFilter so URL params translate 1:1), a checkbox list of every
// matching item, and an action bar that posts to /admin/bulk/apply. The page
// is intentionally desktop-only — m bulk-edits from a keyboard.
func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
linkKinds, err := s.linkKindsByItem(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
filter := ParseTreeFilter(r.URL.Query())
// Bulk filters never default to status=active; the page is for editing,
// and m wants to see done/archived rows too. Override the implicit default.
if r.URL.Query().Get("status") == "" {
filter.Status = nil
}
rows := filterFlat(items, filter, linkKinds)
data := map[string]any{
"Title": "bulk edit",
"Rows": rows,
"AllTags": allTags,
"Filter": filter,
"Total": len(items),
"Matched": len(rows),
}
if r.Header.Get("HX-Request") == "true" {
s.render(w, r, "bulk_section", data)
return
}
s.render(w, r, "bulk", data)
}
// filterFlat returns every item matching the filter as a flat sorted list
// (no tree shape — bulk-edit treats items as a flat working set). Sort by
// primary path so siblings cluster together.
func filterFlat(items []*store.Item, f TreeFilter, linkKinds map[string]map[string]struct{}) []*store.Item {
out := []*store.Item{}
for _, it := range items {
// Bulk filter is "match this item directly" — no descendant credit.
// Override status default: if Status is nil treat as "any".
if !bulkMatches(f, it, linkKinds[it.ID]) {
continue
}
out = append(out, it)
}
sort.Slice(out, func(i, j int) bool { return out[i].PrimaryPath() < out[j].PrimaryPath() })
return out
}
// bulkMatches is a near-clone of TreeFilter.Matches but with Status treated
// as "any" when the slice is empty (the bulk-edit page deliberately surfaces
// every status when m has not picked one). Archived is still honoured
// through ShowArchived.
func bulkMatches(f TreeFilter, it *store.Item, itemLinkKinds map[string]struct{}) bool {
if len(f.Status) > 0 && !contains(f.Status, it.Status) {
return false
}
if it.Archived && !f.ShowArchived {
// "any status" mode still hides archived rows unless ShowArchived is
// explicit; otherwise a single archived item would dominate the list.
// When the user filters to status=archived the previous branch already
// accepted it, and ShowArchived stays default-off — which means we'd
// still hide it here. Honour status=archived as an implicit ShowArchived.
if !contains(f.Status, "archived") {
return false
}
}
for _, t := range f.Tags {
if !it.HasTag(t) {
return false
}
}
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
}
}
for _, h := range f.HasLinks {
if _, ok := itemLinkKinds[h]; !ok {
return false
}
}
if f.Q != "" {
q := strings.ToLower(f.Q)
hit := strings.Contains(strings.ToLower(it.Title), q) ||
strings.Contains(strings.ToLower(it.Slug), q) ||
strings.Contains(strings.ToLower(it.ContentMD), q)
if !hit {
for _, p := range it.Paths {
if strings.Contains(strings.ToLower(p), q) {
hit = true
break
}
}
}
if !hit {
return false
}
}
return true
}
// bulkAction parses the action descriptor sent by the form. Exactly one field
// is non-zero per request; the dispatch picks the matching code path.
type bulkAction struct {
AddTag string // free-text input
RemoveTag string
SetMgmt string // "mai" / "self" / "external" / "clear"
SetStatus string // "active" / "done" / "archived"
SetPublic string // "" / "make_public" / "make_private" — Phase 4d
TimelineTodos string // "" / "exclude" / "include" — Phase 4f
}
func parseBulkAction(r *http.Request) bulkAction {
get := func(k string) string { return strings.TrimSpace(r.FormValue(k)) }
return bulkAction{
AddTag: strings.ToLower(get("add_tag")),
RemoveTag: strings.ToLower(get("remove_tag")),
SetMgmt: get("set_mgmt"),
SetStatus: get("set_status"),
SetPublic: get("set_public"),
TimelineTodos: get("timeline_todos"),
}
}
func (a bulkAction) describe() string {
switch {
case a.AddTag != "":
return "add tag " + a.AddTag
case a.RemoveTag != "":
return "remove tag " + a.RemoveTag
case a.SetMgmt != "":
return "set management " + a.SetMgmt
case a.SetStatus != "":
return "set status " + a.SetStatus
case a.SetPublic == "make_public":
return "make public"
case a.SetPublic == "make_private":
return "make private"
case a.TimelineTodos == "exclude":
return "exclude todos from timeline"
case a.TimelineTodos == "include":
return "re-include todos on timeline"
}
return ""
}
// handleBulkApply applies one action to every selected item in a single
// transaction. The form posts:
// ids=<uuid>&ids=<uuid>&… plus one action field (add_tag / remove_tag /
// set_mgmt / set_status). The action is validated here, dispatched to the
// matching SQL, and returns the re-rendered list partial.
func (s *Server) handleBulkApply(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
ids := dedupeStrings(r.Form["ids"])
action := parseBulkAction(r)
// Validation banners: re-render the section instead of replacing it with
// a plain-text 400. The previous behaviour caused HTMX swaps to wipe out
// the page chrome when m clicked Apply without ticking a row or filling
// an action field. Two distinct messages so m can tell what he missed.
var banner string
switch {
case len(ids) == 0 && action.describe() == "":
banner = "Pick at least one row and choose an action before clicking Apply."
case len(ids) == 0:
banner = "No rows selected — tick the checkboxes for the rows you want to change."
case action.describe() == "":
banner = "No action chosen — type a tag, pick a management mode, or pick a status before clicking Apply."
default:
if err := s.applyBulk(r.Context(), ids, action); err != nil {
s.fail(w, r, err)
return
}
}
// HTMX: re-render the list partial with the same filter context (and
// banner, if any). The form sends every filter input alongside the
// action via hx-include="#bulk-filter".
if r.Header.Get("HX-Request") == "true" {
s.renderBulkList(w, r, banner)
return
}
// Non-HTMX fallback: redirect back to the bulk page preserving filters.
dest := "/admin/bulk"
if q := r.FormValue("filter_query"); q != "" {
dest = "/admin/bulk?" + q
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
// applyBulk runs the action against every id in a single transaction so
// partial failures roll back. Each branch is its own UPDATE because Postgres
// array operators cannot be parameterised cleanly across different operations.
func (s *Server) applyBulk(ctx context.Context, ids []string, a bulkAction) error {
tx, err := s.Store.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return fmt.Errorf("begin bulk tx: %w", err)
}
defer tx.Rollback(ctx)
switch {
case a.AddTag != "":
// array_append guards against duplicate tag with a CASE: only append
// when the tag isn't already present.
_, err = tx.Exec(ctx, `
update projax.items
set tags = case when $2 = any(tags) then tags else array_append(tags, $2) end
where id = any($1::uuid[]) and deleted_at is null`,
ids, a.AddTag)
case a.RemoveTag != "":
_, err = tx.Exec(ctx, `
update projax.items
set tags = array_remove(tags, $2)
where id = any($1::uuid[]) and deleted_at is null`,
ids, a.RemoveTag)
case a.SetMgmt != "":
if a.SetMgmt == "clear" {
_, err = tx.Exec(ctx, `
update projax.items set management = '{}'::text[]
where id = any($1::uuid[]) and deleted_at is null`,
ids)
} else {
// Replace management entirely — single-mode semantics matches
// the chip group on detail.tmpl.
_, err = tx.Exec(ctx, `
update projax.items set management = ARRAY[$2]::text[]
where id = any($1::uuid[]) and deleted_at is null`,
ids, a.SetMgmt)
}
case a.SetStatus != "":
_, err = tx.Exec(ctx, `
update projax.items set status = $2
where id = any($1::uuid[]) and deleted_at is null`,
ids, a.SetStatus)
case a.SetPublic == "make_public":
_, err = tx.Exec(ctx, `
update projax.items set public = true
where id = any($1::uuid[]) and deleted_at is null`,
ids)
case a.SetPublic == "make_private":
_, err = tx.Exec(ctx, `
update projax.items set public = false
where id = any($1::uuid[]) and deleted_at is null`,
ids)
case a.TimelineTodos == "exclude":
// Idempotent: append 'todos' only when not already in the array.
_, err = tx.Exec(ctx, `
update projax.items
set timeline_exclude = case
when 'todos' = any(timeline_exclude) then timeline_exclude
else array_append(timeline_exclude, 'todos')
end
where id = any($1::uuid[]) and deleted_at is null`,
ids)
case a.TimelineTodos == "include":
_, err = tx.Exec(ctx, `
update projax.items
set timeline_exclude = array_remove(timeline_exclude, 'todos')
where id = any($1::uuid[]) and deleted_at is null`,
ids)
default:
return errors.New("bulk: empty action")
}
if err != nil {
return fmt.Errorf("bulk %s: %w", a.describe(), err)
}
return tx.Commit(ctx)
}
// normaliseFormStrings deduplicates, lowercases, and trims the slice of
// values a multi-value form key submitted. Mirrors what parseCSV would do
// to a single comma-joined string — but takes the already-split slice that
// r.Form[k] returns for multi-select inputs. Drops empties.
func normaliseFormStrings(in []string) []string {
if len(in) == 0 {
return []string{}
}
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, v := range in {
t := strings.ToLower(strings.TrimSpace(v))
if t == "" {
continue
}
if _, ok := seen[t]; ok {
continue
}
seen[t] = struct{}{}
out = append(out, t)
}
return out
}
// renderBulkList re-renders the bulk section after a bulk apply or a
// validation failure. The apply form ships every filter input alongside the
// action via hx-include, so this reads them straight off r.Form (which the
// caller has already ParseForm'd). banner, when non-empty, surfaces an
// inline note above the action bar.
//
// Multi-value selects (tag, mgmt, status, has) submit ONE name=value pair
// per selected option. r.Form[k] is []string{...}; r.FormValue returns only
// the first. Must use the slice form here or the second+ values silently
// drop on every Apply round-trip.
func (s *Server) renderBulkList(w http.ResponseWriter, r *http.Request, banner string) {
items, err := s.Store.ListAll(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
linkKinds, err := s.linkKindsByItem(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
allTags, err := s.Store.AllTags(r.Context())
if err != nil {
s.fail(w, r, err)
return
}
filter := TreeFilter{
Q: strings.TrimSpace(r.FormValue("q")),
Tags: normaliseFormStrings(r.Form["tag"]),
Management: normaliseFormStrings(r.Form["mgmt"]),
Status: normaliseFormStrings(r.Form["status"]),
HasLinks: normaliseFormStrings(r.Form["has"]),
ShowArchived: r.FormValue("show-archived") == "1",
}
rows := filterFlat(items, filter, linkKinds)
s.render(w, r, "bulk_section", map[string]any{
"Title": "bulk edit",
"Rows": rows,
"AllTags": allTags,
"Filter": filter,
"Banner": banner,
"Total": len(items),
"Matched": len(rows),
})
}
// handleBulkChip handles per-row inline chip edits — the small +/× buttons
// next to a tag chip on the bulk page. POST /admin/bulk/chip with id + op +
// kind + value. op ∈ {add, remove}; kind ∈ {tag, mgmt}. Returns the re-rendered
// chip cell.
func (s *Server) handleBulkChip(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.fail(w, r, err)
return
}
id := strings.TrimSpace(r.FormValue("id"))
op := strings.TrimSpace(r.FormValue("op"))
kind := strings.TrimSpace(r.FormValue("kind"))
val := strings.ToLower(strings.TrimSpace(r.FormValue("value")))
if id == "" || op == "" || kind == "" || val == "" {
http.Error(w, "id, op, kind, value all required", http.StatusBadRequest)
return
}
a := bulkAction{}
switch {
case kind == "tag" && op == "add":
a.AddTag = val
case kind == "tag" && op == "remove":
a.RemoveTag = val
case kind == "mgmt" && op == "set":
a.SetMgmt = val
default:
http.Error(w, "unsupported op/kind", http.StatusBadRequest)
return
}
if err := s.applyBulk(r.Context(), []string{id}, a); err != nil {
s.fail(w, r, err)
return
}
it, err := s.Store.GetByID(r.Context(), id)
if err != nil {
s.fail(w, r, err)
return
}
// Return just the chip cell's inner HTML (HTMX hx-swap=innerHTML on the <td>).
// The chip templates render against the Item directly, not a map — we pass
// it through .Item-keyed data so the same fragment-render path works.
switch kind {
case "tag":
s.renderChip(w, "bulk_chip_tags", it)
case "mgmt":
s.renderChip(w, "bulk_chip_mgmt", it)
default:
http.Error(w, "unknown chip kind", http.StatusBadRequest)
}
}
// renderChip renders a chip-cell template directly against an *Item.
func (s *Server) renderChip(w http.ResponseWriter, name string, it *store.Item) {
t, ok := s.pages[name]
if !ok {
http.Error(w, "unknown page: "+name, http.StatusInternalServerError)
return
}
entry := strings.ReplaceAll(name, "_", "-")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := t.ExecuteTemplate(w, entry, it); err != nil {
s.Logger.Error("render chip", "page", name, "err", err)
}
}