Files
projax/web/bulk.go
mAi 9ee26002f8 refactor(web): validate item writes via internal/itemwrite/
Phase 5c slice B. Three web write paths now pre-validate via the
itemwrite package before calling store.Create / Update / Reparent.

- handleDetailWrite: ValidateFormat + ValidateAgainstStore on (title,
  slug, status, parent_ids) before the store.Update call.
- handleNewSubmit: same pair, scoped to a new item (no ID yet).
- handleReparent: format + DB-aware checks; validator catches
  self-parent, unknown-parent, cycle. The existing
  "parent_ids required" guard stays as a separate fast-fail.
- handleBulkApply: set_status pre-flight against the validator. Other
  bulk actions (add_tag / set_mgmt / set_public / timeline_todos)
  don't mutate validated fields so they pass through unchanged.

On ValidationError the handler responds 400 + a human banner keyed on
err.Kind via the new s.itemWriteFailure helper. itemWriteBannerCopy
centralises the Kind→copy mapping so web/server.go and web/bulk.go
share one phrasing.

No web test source touched — all web/*_test.go assert on observable
behaviour (HTTP status, response body) and the new validator path
preserves both for valid AND invalid inputs the SQL trigger would
have rejected anyway. Tests stay green unmodified.

Task: t-projax-5c-itemwrite
2026-05-22 00:36:14 +02:00

456 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/internal/itemwrite"
"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:
// Pre-flight bulk action via itemwrite where applicable. set_status
// is the only bulk action today that mutates a validated field
// (status enum); the others (add_tag / set_mgmt / set_public /
// timeline_todos) operate outside the validator's rule set.
if action.SetStatus != "" {
if ve := itemwrite.ValidateFormat(itemwrite.Input{Title: "x", Slug: "x", Status: action.SetStatus}); ve != nil {
banner = "Cannot apply: " + itemWriteBannerCopy(ve)
break
}
}
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)
}
}