Merge branch 'mai/knuth/phase-3d-auto-tag'

This commit is contained in:
mAi
2026-05-15 18:50:16 +02:00
12 changed files with 1044 additions and 1 deletions

View File

@@ -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()

View File

@@ -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;

View File

@@ -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();

View File

@@ -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()

View File

@@ -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.

371
web/bulk.go Normal file
View File

@@ -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=<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"])
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 <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)
}
}

225
web/bulk_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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; }

5
web/templates/bulk.tmpl Normal file
View File

@@ -0,0 +1,5 @@
{{define "content"}}
<h1>Bulk edit</h1>
<p>Narrow with the filter, tick rows, apply an action. All changes run in one transaction.</p>
{{template "bulk-section" .}}
{{end}}

View File

@@ -0,0 +1,143 @@
{{define "bulk-section"}}
<section id="bulk-section" class="bulk-section">
<section class="tagbar" id="bulk-filterbar">
<form id="bulk-filter" class="search"
hx-get="/admin/bulk"
hx-target="#bulk-section"
hx-swap="outerHTML"
hx-trigger="keyup changed delay:200ms from:input[name=q], change from:select, change from:input[type=hidden], change from:input[name=show-archived]"
hx-push-url="true">
<input type="search" name="q" value="{{.Filter.Q}}" placeholder="search title, slug, content…" autocomplete="off">
<label>tag&nbsp;
<select name="tag" multiple size="3">
{{$selTags := .Filter.Tags}}
{{range .AllTags}}<option value="{{.}}" {{if contains $selTags .}}selected{{end}}>{{.}}</option>{{end}}
</select>
</label>
<label>mgmt&nbsp;
<select name="mgmt" multiple size="4">
{{$selM := .Filter.Management}}
<option value="mai" {{if contains $selM "mai"}}selected{{end}}>mai</option>
<option value="self" {{if contains $selM "self"}}selected{{end}}>self</option>
<option value="external" {{if contains $selM "external"}}selected{{end}}>external</option>
<option value="unmanaged" {{if contains $selM "unmanaged"}}selected{{end}}>unmanaged</option>
</select>
</label>
<label>status&nbsp;
<select name="status" multiple size="3">
{{$selS := .Filter.Status}}
<option value="active" {{if contains $selS "active"}}selected{{end}}>active</option>
<option value="done" {{if contains $selS "done"}}selected{{end}}>done</option>
<option value="archived" {{if contains $selS "archived"}}selected{{end}}>archived</option>
</select>
</label>
<label class="checkbox">
<input type="checkbox" name="show-archived" value="1" {{if .Filter.ShowArchived}}checked{{end}}>
show archived
</label>
</form>
<p class="counts"><strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match</p>
</section>
<form id="bulk-actions"
method="post"
action="/admin/bulk/apply"
hx-post="/admin/bulk/apply"
hx-target="#bulk-section"
hx-swap="outerHTML"
hx-include="#bulk-filter">
<fieldset class="actions">
<legend>Bulk action <small>(applies to all checked rows)</small></legend>
<div class="action-row">
<label>+ tag <input name="add_tag" placeholder="tag-slug"></label>
<label> tag <input name="remove_tag" placeholder="tag-slug"></label>
<label>set management
<select name="set_mgmt">
<option value="">—</option>
<option value="mai">mai</option>
<option value="self">self</option>
<option value="external">external</option>
<option value="clear">clear</option>
</select>
</label>
<label>set status
<select name="set_status">
<option value="">—</option>
<option value="active">active</option>
<option value="done">done</option>
<option value="archived">archived</option>
</select>
</label>
<button type="submit">Apply</button>
</div>
</fieldset>
<table class="bulk">
<thead>
<tr>
<th><input type="checkbox" id="bulk-all" onclick="document.querySelectorAll('input[name=ids]').forEach(c=>c.checked=this.checked)"></th>
<th>slug</th>
<th>primary path</th>
<th>tags</th>
<th>mgmt</th>
<th>status</th>
</tr>
</thead>
<tbody>
{{range .Rows}}
<tr id="bulk-row-{{.ID}}">
<td><input type="checkbox" name="ids" value="{{.ID}}"></td>
<td><a href="/i/{{.PrimaryPath}}">{{.Slug}}</a></td>
<td><small class="muted">{{.PrimaryPath}}</small></td>
<td class="cell-tags" id="cell-tags-{{.ID}}">
{{template "bulk-chip-tags" .}}
</td>
<td class="cell-mgmt" id="cell-mgmt-{{.ID}}">
{{template "bulk-chip-mgmt" .}}
</td>
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
</tr>
{{else}}
<tr><td colspan="6"><em>No items match. Loosen the filters.</em></td></tr>
{{end}}
</tbody>
</table>
</form>
</section>
{{end}}
{{define "bulk-chip-tags"}}
{{range .Tags}}
<span class="tag">{{.}}
<button class="chip-x"
hx-post="/admin/bulk/chip"
hx-vals='{"id":"{{$.ID}}","op":"remove","kind":"tag","value":"{{.}}"}'
hx-target="#cell-tags-{{$.ID}}"
hx-swap="innerHTML"
type="button"
title="remove tag">×</button>
</span>
{{end}}
<form class="chip-add" onsubmit="return false"
hx-post="/admin/bulk/chip"
hx-target="#cell-tags-{{.ID}}"
hx-swap="innerHTML"
hx-trigger="submit">
<input type="hidden" name="id" value="{{.ID}}">
<input type="hidden" name="op" value="add">
<input type="hidden" name="kind" value="tag">
<input name="value" placeholder="+tag" size="6">
</form>
{{end}}
{{define "bulk-chip-mgmt"}}
{{range .Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
{{if not .Management}}<span class="muted">unmanaged</span>{{end}}
{{end}}

View File

@@ -11,6 +11,7 @@
<nav>
<a href="/" class="brand">projax</a>
<a href="/admin/classify">classify orphans</a>
<a href="/admin/bulk">bulk edit</a>
<a href="/admin/caldav">caldav</a>
<form method="post" action="/logout" class="logout-form">
<button type="submit" class="logout-btn">sign out</button>