Files
projax/web/bulk.go
mAi 0e490bb600 feat(phase 3d auto-tag): backfill area tags, bulk-edit UI, soft-delete cleanup
- migration 0012: one-shot populate empty tags from each item's area-roots
  (so chips on /?tag=work etc. actually filter the 40+ mai-backfilled rows)
- migration 0013: cleanup 12 orphan item_links + BEFORE-UPDATE trigger that
  cascades soft-delete to item_links going forward — closes the data drift
  that made TestItemsUnifiedSurfacesMaiPointer fail since 3c
- /admin/bulk page: flat filter+checkbox list with one-tx Apply for add/
  remove tag, set management, set status. Per-row inline chip add/remove
  via /admin/bulk/chip. Reuses tree_filter URL params 1:1.
- design.md §3.2 + §4.1 updated; tag+management section notes 0012
- bulk + tag-backfill + soft-delete-cascade tests cover the new surface
2026-05-15 18:49:58 +02:00

372 lines
11 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, "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)
}
}