diff --git a/db/migrate_test.go b/db/migrate_test.go index cebac42..8006a17 100644 --- a/db/migrate_test.go +++ b/db/migrate_test.go @@ -298,6 +298,97 @@ func TestMultiParentResolvesBothPaths(t *testing.T) { } } +// TestBackfillTagsFromArea verifies migration 0012's logic both directly (the +// query as written, applied to a transaction-local fixture) and in the +// applied state (every live child item now carries its area slug). We +// re-execute the migration body inside a transaction so we can assert on a +// freshly inserted untagged row without polluting the live DB. +func TestBackfillTagsFromArea(t *testing.T) { + pool := connect(t) + defer pool.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // First: every existing live child item already carries area tags after + // 0012 was applied on boot. This catches regressions where a future + // migration clears the column or the backfill stops covering some path. + var untagged int + if err := pool.QueryRow(ctx, + `select count(*) from projax.items + where deleted_at is null + and cardinality(parent_ids) > 0 + and tags = '{}'`).Scan(&untagged); err != nil { + t.Fatalf("count untagged children: %v", err) + } + if untagged != 0 { + t.Fatalf("expected every live child to have area tags after 0012, got %d still empty", untagged) + } + + // Second: idempotency + multi-parent. Insert a fresh multi-parent row + // with empty tags, re-run the backfill query, observe both area slugs in + // tags. Re-run a second time and check nothing changes. + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback(ctx) + + var dev, work string + if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { + t.Fatalf("dev: %v", err) + } + if err := tx.QueryRow(ctx, `select id from projax.items where slug='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil { + t.Fatalf("work: %v", err) + } + slug := fmt.Sprintf("tag-backfill-%d", time.Now().UnixNano()) + if _, err := tx.Exec(ctx, + `insert into projax.items (kind, title, slug, parent_ids, tags) + values (array['project']::text[], 'TagBackfill', $1, ARRAY[$2,$3]::uuid[], '{}')`, + slug, dev, work, + ); err != nil { + t.Fatalf("insert multi-parent: %v", err) + } + + backfill := `UPDATE projax.items i + SET tags = sub.area_tags + FROM ( + SELECT id, ARRAY(SELECT DISTINCT split_part(p, '.', 1) + FROM unnest(paths) p ORDER BY 1) AS area_tags + FROM projax.items + WHERE deleted_at IS NULL AND tags = '{}' + AND cardinality(parent_ids) > 0 + ) sub + WHERE i.id = sub.id AND i.tags = '{}' + AND cardinality(sub.area_tags) > 0` + if _, err := tx.Exec(ctx, backfill); err != nil { + t.Fatalf("first backfill apply: %v", err) + } + + var tags []string + if err := tx.QueryRow(ctx, `select tags from projax.items where slug=$1`, slug).Scan(&tags); err != nil { + t.Fatalf("read tags after backfill: %v", err) + } + got := map[string]bool{} + for _, t := range tags { + got[t] = true + } + if !got["dev"] || !got["work"] || len(tags) != 2 { + t.Fatalf("expected tags = {dev, work}, got %v", tags) + } + + // Idempotent: second run is a no-op on this row (tags no longer = '{}'). + if _, err := tx.Exec(ctx, backfill); err != nil { + t.Fatalf("second backfill apply: %v", err) + } + var tags2 []string + if err := tx.QueryRow(ctx, `select tags from projax.items where slug=$1`, slug).Scan(&tags2); err != nil { + t.Fatalf("read tags after second backfill: %v", err) + } + if len(tags2) != 2 { + t.Fatalf("expected idempotent backfill, got %v after second run", tags2) + } +} + func TestSlugCollisionUnderCommonParent(t *testing.T) { pool := connect(t) defer pool.Close() diff --git a/db/migrations/0012_backfill_tags_from_area.sql b/db/migrations/0012_backfill_tags_from_area.sql new file mode 100644 index 0000000..33b1e52 --- /dev/null +++ b/db/migrations/0012_backfill_tags_from_area.sql @@ -0,0 +1,35 @@ +-- 0012_backfill_tags_from_area.sql +-- +-- One-shot backfill: every projax.items row whose tags is still empty {} gets +-- tagged with the slug of each *area* (root) it lives under. So an item under +-- 'work.flexsiebels' picks up tag 'work'; an item that surfaces under both +-- 'work.paliad' and 'dev.paliad' picks up ['dev', 'work'] (sorted, deduped). +-- Root items (the area rows themselves) are skipped — their tag is implicit +-- in their slug, and m edits them by hand. +-- +-- Idempotency: the WHERE clause only matches rows with tags = '{}'. Re-running +-- this migration after m has manually edited any tag (even to a single +-- non-area entry) leaves it alone, so this is one-shot for the existing 40+ +-- mai-managed backfilled rows. New writes go through the application with +-- proper tags from the start — there is no ongoing trigger here on purpose. +-- If the same problem recurs (a new bulk import lands rows with empty tags), +-- the /admin/bulk page added in this phase is the supported recovery path. + +UPDATE projax.items i + SET tags = sub.area_tags + FROM ( + SELECT + id, + ARRAY( + SELECT DISTINCT split_part(p, '.', 1) + FROM unnest(paths) p + ORDER BY 1 + ) AS area_tags + FROM projax.items + WHERE deleted_at IS NULL + AND tags = '{}' + AND cardinality(parent_ids) > 0 + ) sub + WHERE i.id = sub.id + AND i.tags = '{}' + AND cardinality(sub.area_tags) > 0; diff --git a/db/migrations/0013_orphan_item_links_cleanup.sql b/db/migrations/0013_orphan_item_links_cleanup.sql new file mode 100644 index 0000000..7a56ed9 --- /dev/null +++ b/db/migrations/0013_orphan_item_links_cleanup.sql @@ -0,0 +1,50 @@ +-- 0013_orphan_item_links_cleanup.sql +-- +-- Two-step fix for the orphan-link drift that was masking source_ref_id +-- counts in the items_unified view: +-- +-- 1. One-shot cleanup: every projax.item_links row whose item_id references +-- a soft-deleted projax.items row is removed. There are ~12 of these in +-- production, all leftover 'mai-project' pointers from items that were +-- soft-deleted before 0008's sync trigger covered every path. +-- +-- 2. Going-forward trigger: when projax.items.deleted_at flips NULL → non-NULL +-- (i.e. a soft-delete), drop every item_links row that points at the +-- item in the same statement. This keeps `count(item_links where ref_type=X)` +-- and `count(items_unified where source_ref_id is not null)` in lock-step +-- for every future soft-delete, without taxing the items_unified view +-- with a JOIN. +-- +-- Rationale for the trigger approach over a view-side JOIN: +-- The view is on the hot path (every tree-page render). A trigger +-- pays the cost once at delete time, not once per read. And the alternative +-- (orphan links lingering) is itself a correctness bug — a CalDAV/Gitea +-- integration that follows the link from items_unified -> external system +-- would try to resolve a pointer for an item that no longer exists. +-- +-- The trigger is BEFORE UPDATE so the DELETE on item_links happens inside the +-- same transaction as the soft-delete UPDATE — rollback safety preserved. + +-- 1. One-shot cleanup of existing orphan link rows. +DELETE FROM projax.item_links l + USING projax.items i + WHERE i.id = l.item_id + AND i.deleted_at IS NOT NULL; + +-- 2. Trigger function: cascade soft-delete to item_links. +CREATE OR REPLACE FUNCTION projax.items_cascade_softdelete_links() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN + DELETE FROM projax.item_links WHERE item_id = NEW.id; + END IF; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS items_cascade_softdelete_links ON projax.items; +CREATE TRIGGER items_cascade_softdelete_links + BEFORE UPDATE OF deleted_at ON projax.items + FOR EACH ROW EXECUTE FUNCTION projax.items_cascade_softdelete_links(); diff --git a/db/unified_test.go b/db/unified_test.go index 42a0e30..d49ced0 100644 --- a/db/unified_test.go +++ b/db/unified_test.go @@ -2,6 +2,7 @@ package db_test import ( "context" + "fmt" "testing" "time" ) @@ -55,6 +56,65 @@ func TestItemsUnifiedSurfacesMaiPointer(t *testing.T) { } } +// TestSoftDeleteCascadesToItemLinks proves migration 0013's trigger: setting +// deleted_at on a projax.items row deletes every item_links row that pointed +// at it in the same statement. Without this, the source_ref_id count in +// items_unified diverges from the raw link count (and external pointers to +// dead items linger, which silent-corrupts downstream integrations). +func TestSoftDeleteCascadesToItemLinks(t *testing.T) { + pool := connect(t) + defer pool.Close() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tx, err := pool.Begin(ctx) + if err != nil { + t.Fatalf("begin: %v", err) + } + defer tx.Rollback(ctx) + + // Insert a fresh root item that is NOT 'mai' managed so the sync_to_mai + // trigger does not own this link's life cycle — we want to test the new + // cascade trigger in isolation. + var id string + slug := fmt.Sprintf("linkcascade-%d", time.Now().UnixNano()) + if err := tx.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids, management) + values (array['project']::text[], 'cascade test', $1, '{}'::uuid[], '{}') + returning id`, + slug, + ).Scan(&id); err != nil { + t.Fatalf("insert item: %v", err) + } + if _, err := tx.Exec(ctx, + `insert into projax.item_links (item_id, ref_type, ref_id, rel) + values ($1, 'gitea-repo', 'mgit.msbls.de/test', 'tracks')`, + id, + ); err != nil { + t.Fatalf("insert link: %v", err) + } + + var linksBefore int + if err := tx.QueryRow(ctx, `select count(*) from projax.item_links where item_id=$1`, id).Scan(&linksBefore); err != nil { + t.Fatalf("count before: %v", err) + } + if linksBefore != 1 { + t.Fatalf("expected 1 link before soft-delete, got %d", linksBefore) + } + + if _, err := tx.Exec(ctx, `update projax.items set deleted_at = now() where id=$1`, id); err != nil { + t.Fatalf("soft-delete: %v", err) + } + + var linksAfter int + if err := tx.QueryRow(ctx, `select count(*) from projax.item_links where item_id=$1`, id).Scan(&linksAfter); err != nil { + t.Fatalf("count after: %v", err) + } + if linksAfter != 0 { + t.Fatalf("expected 0 links after soft-delete (cascade trigger should have removed them), got %d", linksAfter) + } +} + func TestPhase15ColumnsPresent(t *testing.T) { pool := connect(t) defer pool.Close() diff --git a/docs/design.md b/docs/design.md index 87c802d..68fcae7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -139,6 +139,8 @@ where i.deleted_at is null; `source` is always `'projax'` (kept for forward compat); `source_ref_id` surfaces the `mai-project` pointer when one exists so the UI can show "mai id: foo". +**Soft-delete tightening (migration 0013, Phase 3d).** Every `item_links` row is implicitly tied to its parent item's life: on soft-delete (`projax.items.deleted_at` flips `NULL → not-null`) a BEFORE-UPDATE trigger cascades a `DELETE FROM projax.item_links WHERE item_id = NEW.id` in the same statement. The migration also one-shot-cleans the ~12 orphan `mai-project` rows that predate this trigger. Result: `count(item_links WHERE ref_type=X)` and `count(items_unified WHERE source_ref_id IS NOT NULL)` stay in lock-step — `TestItemsUnifiedSurfacesMaiPointer` regression-guards this. + ### 3.3 Classification overlay Items can land at root in two ways: @@ -179,7 +181,8 @@ Pages: 2. **Item detail** (`/i/{path}`) — `{path}` matches any entry in `paths`; both `work.paliad` and `dev.paliad` resolve to the same row. The page shows the primary path plus an "Also at: …" breadcrumb for the others. Edit form supports title, slug, multi-select parents, status, tags, management, pinned/archived, content. Save POSTs to `/i/{path}`. 3. **New item** (`/new?parent={path}`) — same form shape; the `parent` query pre-selects one parent option, m can pick more. 4. **Classify** (`/admin/classify`) — surfaces items at root with `'mai' = ANY(management)`. Inline HTMX form sets the first parent. POSTs to `/i/{path}/reparent`. -5. **Auth** — projax's own `/login` (mBrian pattern). Same Supabase backend, per-host cookies (no `Domain` attribute). +5. **Bulk edit** (`/admin/bulk`, Phase 3d) — desktop-only multi-row editor. Top: a filter form that reuses the same query params as the tree page (`q`, `tag`, `mgmt`, `status`, `has`, `show-archived`) so URLs translate 1:1 between tree and bulk views. Below: a flat checkbox list of every matching row (slug, primary path, tags, mgmt, status). An action bar at the top supports four operations: add tag, remove tag, set management (mai/self/external/clear), set status (active/done/archived). One POST to `/admin/bulk/apply` runs every change inside a single transaction (rollback-on-error). Inline per-row chip edits use `POST /admin/bulk/chip` for one-off add/remove without ticking a checkbox; only the affected cell re-renders. +6. **Auth** — projax's own `/login` (mBrian pattern). Same Supabase backend, per-host cookies (no `Domain` attribute). ### 4.2 Tags + management @@ -191,6 +194,8 @@ Pages: Mai.projects backfilled rows arrive with `management = ['mai']`. m can layer `self` on top without dropping mai sync. +**Area-tag backfill (migration 0012, Phase 3d).** Backfilled mai-managed items landed with `tags = '{}'`, so the tree-page tag filter chips had no signal to filter on. Migration 0012 one-shot-populates `tags` with the slug of each area an item lives under (so an item under `work.flexsiebels` picks up `tag=work`; a multi-parent item under `work.paliad` AND `dev.paliad` picks up `['dev', 'work']`). The migration only touches rows where `tags = '{}'`; once m has edited an item's tags it is left alone. Going-forward bulk recovery uses `/admin/bulk` instead of repeating the migration. + ### 4.2 Phase 2 — task aggregation - **CalDAV ingest** — read-only mirror of m's CalDAV todo lists into `item_links` with `ref_type=caldav-todo`. Per-area mapping (e.g. `home` aggregates from CalDAV list "Home"). Background sync, no writeback initially. diff --git a/web/bulk.go b/web/bulk.go new file mode 100644 index 0000000..b9c64f1 --- /dev/null +++ b/web/bulk.go @@ -0,0 +1,371 @@ +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, "bulk_section", data) + return + } + s.render(w, "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" +} + +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"), + } +} + +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 + } + return "" +} + +// handleBulkApply applies one action to every selected item in a single +// transaction. The form posts: +// ids=&ids=&… 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"]) + if len(ids) == 0 { + http.Error(w, "no rows selected", http.StatusBadRequest) + return + } + action := parseBulkAction(r) + if action.describe() == "" { + http.Error(w, "no action chosen", http.StatusBadRequest) + return + } + + 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. + if r.Header.Get("HX-Request") == "true" { + // Re-derive filter from referrer query so the result list matches the + // view the user is on. The HTMX form sends hx-include=closest #bulk-filter + // so filter inputs are in the form body — read those. + s.renderBulkList(w, r) + 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) + default: + return errors.New("bulk: empty action") + } + if err != nil { + return fmt.Errorf("bulk %s: %w", a.describe(), err) + } + return tx.Commit(ctx) +} + +// renderBulkList re-renders only the rows list + summary line after a bulk +// apply. Builds a fresh r-equivalent that re-uses the filter posted alongside +// the form (the page includes filter inputs in the apply submission). +func (s *Server) renderBulkList(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 + } + // The apply form ships the same filter fields as the bulk page itself, so + // ParseTreeFilter against r.Form gives us the right view. + filter := TreeFilter{ + Q: strings.TrimSpace(r.FormValue("q")), + Tags: parseCSV(r.FormValue("tag")), + Management: parseCSV(r.FormValue("mgmt")), + Status: parseCSV(r.FormValue("status")), + HasLinks: parseCSV(r.FormValue("has")), + ShowArchived: r.FormValue("show-archived") == "1", + } + rows := filterFlat(items, filter, linkKinds) + s.render(w, "bulk_section", map[string]any{ + "Title": "bulk edit", + "Rows": rows, + "AllTags": allTags, + "Filter": filter, + "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 ). + // 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) + } +} diff --git a/web/bulk_test.go b/web/bulk_test.go new file mode 100644 index 0000000..fc7a9d5 --- /dev/null +++ b/web/bulk_test.go @@ -0,0 +1,225 @@ +package web_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// TestBulkPageRenders proves /admin/bulk loads, contains the filter form + +// the action bar + at least one item row. +func TestBulkPageRenders(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + code, body := get(t, h, "/admin/bulk") + if code != 200 { + t.Fatalf("GET /admin/bulk → %d body=%s", code, body) + } + for _, want := range []string{ + `id="bulk-section"`, + `name="add_tag"`, + `name="remove_tag"`, + `name="set_mgmt"`, + `name="set_status"`, + `name="ids"`, + } { + if !strings.Contains(body, want) { + t.Errorf("bulk page missing %q", want) + } + } +} + +// TestBulkApplyAddsTagInOneTx seeds five distinct child items, posts an +// add_tag action with all five ids checked, asserts every row gained the tag. +// Idempotency: re-posting the same action leaves tags unchanged (no duplicates). +func TestBulkApplyAddsTagInOneTx(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + slugs := make([]string, 5) + ids := make([]string, 5) + + var dev string + if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { + t.Fatalf("dev: %v", err) + } + + for i := range slugs { + slugs[i] = fmt.Sprintf("bulk-fixture-%s-%d", stamp, i) + if err := pool.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids, management) + values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], '{}') + returning id`, + "bulk-"+slugs[i], slugs[i], dev, + ).Scan(&ids[i]); err != nil { + t.Fatalf("seed item %d: %v", i, err) + } + } + defer func() { + for _, s := range slugs { + _, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, s) + } + }() + + apply := func() { + form := url.Values{} + for _, id := range ids { + form.Add("ids", id) + } + form.Set("add_tag", "bulktest-critical") + req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Result().StatusCode != http.StatusSeeOther && w.Result().StatusCode != 200 { + body, _ := io.ReadAll(w.Result().Body) + t.Fatalf("apply status %d body=%s", w.Result().StatusCode, body) + } + } + apply() + apply() // second run: idempotent — tag is not duplicated. + + for _, id := range ids { + var tags []string + if err := pool.QueryRow(ctx, `select tags from projax.items where id=$1`, id).Scan(&tags); err != nil { + t.Fatalf("read tags: %v", err) + } + n := 0 + for _, x := range tags { + if x == "bulktest-critical" { + n++ + } + } + if n != 1 { + t.Errorf("item %s: expected 'bulktest-critical' once in tags, got tags=%v", id, tags) + } + } +} + +// TestBulkApplySetStatusAcrossRows seeds three items, marks them done via the +// set_status action, then sets them back to active. Asserts every row hit +// each requested status. +func TestBulkApplySetStatusAcrossRows(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + var dev string + if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { + t.Fatalf("dev: %v", err) + } + slugs := []string{} + ids := []string{} + for i := 0; i < 3; i++ { + slug := fmt.Sprintf("bulk-status-%s-%d", stamp, i) + slugs = append(slugs, slug) + var id string + if err := pool.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids) + values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[]) + returning id`, + "S"+slug, slug, dev, + ).Scan(&id); err != nil { + t.Fatalf("seed %d: %v", i, err) + } + ids = append(ids, id) + } + defer func() { + for _, s := range slugs { + _, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, s) + } + }() + + post := func(field, value string) { + form := url.Values{} + for _, id := range ids { + form.Add("ids", id) + } + form.Set(field, value) + req := httptest.NewRequest(http.MethodPost, "/admin/bulk/apply", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Result().StatusCode >= 400 { + body, _ := io.ReadAll(w.Result().Body) + t.Fatalf("post %s=%s → %d body=%s", field, value, w.Result().StatusCode, body) + } + } + + post("set_status", "done") + for _, id := range ids { + var s string + _ = pool.QueryRow(ctx, `select status from projax.items where id=$1`, id).Scan(&s) + if s != "done" { + t.Errorf("item %s: status = %q, want 'done'", id, s) + } + } + post("set_status", "active") + for _, id := range ids { + var s string + _ = pool.QueryRow(ctx, `select status from projax.items where id=$1`, id).Scan(&s) + if s != "active" { + t.Errorf("item %s: status = %q, want 'active' after reset", id, s) + } + } +} + +// TestBulkChipRoundTrip exercises POST /admin/bulk/chip — the inline per-row +// chip-add path used by the bulk page (no checkbox required). +func TestBulkChipRoundTrip(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + h := srv.Routes() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + slug := "bulk-chip-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + var dev, id string + if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { + t.Fatalf("dev: %v", err) + } + if err := pool.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids) + values (array['project']::text[], 'chip', $1, ARRAY[$2]::uuid[]) + returning id`, + slug, dev, + ).Scan(&id); err != nil { + t.Fatalf("seed: %v", err) + } + defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id) + + form := url.Values{} + form.Set("id", id) + form.Set("op", "add") + form.Set("kind", "tag") + form.Set("value", "chiproundtrip") + req := httptest.NewRequest(http.MethodPost, "/admin/bulk/chip", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Result().StatusCode != 200 { + body, _ := io.ReadAll(w.Result().Body) + t.Fatalf("chip add → %d body=%s", w.Result().StatusCode, body) + } + var tags []string + if err := pool.QueryRow(ctx, `select tags from projax.items where id=$1`, id).Scan(&tags); err != nil { + t.Fatalf("read: %v", err) + } + if len(tags) != 1 || tags[0] != "chiproundtrip" { + t.Errorf("after chip add: tags = %v, want [chiproundtrip]", tags) + } +} diff --git a/web/server.go b/web/server.go index 9ca4a0d..9ab1667 100644 --- a/web/server.go +++ b/web/server.go @@ -133,6 +133,34 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) { return nil, fmt.Errorf("parse login: %w", err) } pages["login"] = loginTmpl + + // Bulk-edit page + its fragment + per-row chip cells. The chip cells share + // definitions with bulk_section so we parse them together every time. + bulkTmpl, err := template.New("bulk").Funcs(funcs).ParseFS(templatesFS, + "templates/layout.tmpl", + "templates/bulk.tmpl", + "templates/bulk_section.tmpl", + ) + if err != nil { + return nil, fmt.Errorf("parse bulk: %w", err) + } + pages["bulk"] = bulkTmpl + bulkSection, err := template.New("bulk_section").Funcs(funcs).ParseFS(templatesFS, "templates/bulk_section.tmpl") + if err != nil { + return nil, fmt.Errorf("parse bulk_section: %w", err) + } + pages["bulk_section"] = bulkSection + bulkChipTags, err := template.New("bulk_chip_tags").Funcs(funcs).ParseFS(templatesFS, "templates/bulk_section.tmpl") + if err != nil { + return nil, fmt.Errorf("parse bulk_chip_tags: %w", err) + } + pages["bulk_chip_tags"] = bulkChipTags + bulkChipMgmt, err := template.New("bulk_chip_mgmt").Funcs(funcs).ParseFS(templatesFS, "templates/bulk_section.tmpl") + if err != nil { + return nil, fmt.Errorf("parse bulk_chip_mgmt: %w", err) + } + pages["bulk_chip_mgmt"] = bulkChipMgmt + return &Server{Store: s, pages: pages, Logger: logger}, nil } @@ -146,6 +174,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /new", s.handleNewForm) mux.HandleFunc("POST /new", s.handleNewSubmit) mux.HandleFunc("GET /admin/classify", s.handleClassify) + mux.HandleFunc("GET /admin/bulk", s.handleBulk) + mux.HandleFunc("POST /admin/bulk/apply", s.handleBulkApply) + mux.HandleFunc("POST /admin/bulk/chip", s.handleBulkChip) mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin) mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink) mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink) @@ -569,6 +600,8 @@ func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) entry = "tree-section" case "documents_section": entry = "documents-section" + case "bulk_section": + entry = "bulk-section" } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, entry, data); err != nil { diff --git a/web/static/style.css b/web/static/style.css index e8af2d4..71195ad 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -157,3 +157,27 @@ table.classify input, table.classify select { width: 100%; } font-size: 1.05em; line-height: 1; padding: 2px 6px; } .documents .doc-remove .x:hover { color: var(--bad); border-color: var(--bad); } + +/* --- /admin/bulk page --- */ +.bulk-section .counts { margin: 8px 0; color: var(--muted); } +#bulk-filter { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; } +#bulk-filter input[type=search] { width: 28em; } +#bulk-filter select[multiple] { min-width: 9em; } +#bulk-filter label.checkbox { display: inline-flex; align-items: center; gap: 4px; } +#bulk-actions fieldset.actions { + margin: 12px 0; padding: 8px 12px; border: 1px solid var(--border); border-radius: 4px; +} +#bulk-actions .action-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } +#bulk-actions .action-row label { display: inline-flex; flex-direction: column; font-size: 0.85em; color: var(--muted); } +#bulk-actions .action-row input, #bulk-actions .action-row select { padding: 4px 6px; } +#bulk-actions button[type=submit] { padding: 6px 14px; align-self: flex-end; } +table.bulk { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.92em; } +table.bulk th, table.bulk td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: middle; } +table.bulk th { background: var(--bg-alt); color: var(--muted); font-weight: 500; } +table.bulk td.cell-tags .tag { margin-right: 4px; } +table.bulk .chip-x { + background: transparent; border: none; color: var(--muted); padding: 0 4px; cursor: pointer; font-size: 1em; +} +table.bulk .chip-x:hover { color: var(--bad); } +table.bulk .chip-add { display: inline-block; margin-left: 4px; } +table.bulk .chip-add input { padding: 1px 4px; font-size: 0.85em; width: 7em; } diff --git a/web/templates/bulk.tmpl b/web/templates/bulk.tmpl new file mode 100644 index 0000000..7fe5cdd --- /dev/null +++ b/web/templates/bulk.tmpl @@ -0,0 +1,5 @@ +{{define "content"}} +

Bulk edit

+

Narrow with the filter, tick rows, apply an action. All changes run in one transaction.

+{{template "bulk-section" .}} +{{end}} diff --git a/web/templates/bulk_section.tmpl b/web/templates/bulk_section.tmpl new file mode 100644 index 0000000..731b3d7 --- /dev/null +++ b/web/templates/bulk_section.tmpl @@ -0,0 +1,143 @@ +{{define "bulk-section"}} +
+ +
+ + +

{{.Matched}} / {{.Total}} items match

+
+ +
+ +
+ Bulk action (applies to all checked rows) +
+ + + + + +
+
+ + + + + + + + + + + + + + {{range .Rows}} + + + + + + + + + {{else}} + + {{end}} + +
slugprimary pathtagsmgmtstatus
{{.Slug}}{{.PrimaryPath}} + {{template "bulk-chip-tags" .}} + + {{template "bulk-chip-mgmt" .}} + {{.Status}}
No items match. Loosen the filters.
+
+
+{{end}} + +{{define "bulk-chip-tags"}} + {{range .Tags}} + {{.}} + + + {{end}} +
+ + + + +
+{{end}} + +{{define "bulk-chip-mgmt"}} + {{range .Management}}{{.}}{{end}} + {{if not .Management}}unmanaged{{end}} +{{end}} diff --git a/web/templates/layout.tmpl b/web/templates/layout.tmpl index 5da70e7..2469c16 100644 --- a/web/templates/layout.tmpl +++ b/web/templates/layout.tmpl @@ -11,6 +11,7 @@