Wire all web-side writes to depend on the interfaces (Server.Writes for writes, Server.Items for the write-pre-flight reads) instead of the concrete *Store, so PROJAX_BACKEND will flip them with the reader: - handleDetailWrite / handleReparent / handleNewSubmit: Update / Reparent / Create now go through s.Writes; ValidateAgainstStore now reads s.Items (was s.Store) so cycle + collision detection runs against the live backend, not stale projax.items. - dashboard_pin: SetPinned via s.Writes. - links: AddLinkDated / DeleteLink via s.Writes. linkBelongsToItem now resolves ownership through s.Items.LinksByType — a direct projax.item_links query would reject every delete under the mBrian backend. Dropped the now-dead isNoRows + errors import. - caldav: all four AddLink + the unlink DeleteLink via s.Writes. - bulk applyBulk: replaced the raw single-tx multi-row UPDATE with interface calls — make_public/private map to SetPublic; the field mutations (tags/mgmt/status/timeline-exclude) are read-modify-write via Update. Cross-row tx atomicity is dropped (mBrian's HTTP write API has no multi-node tx); acceptable at m's bulk-edit scale, one write path across both backends. Added updateInputFromItem + appendUnique/removeValue. - itemwrite: slug uniqueness is now per-user-global (Q6=a, matching mBrian's idx_nodes_slug) instead of per-parent. Strictly tighter, so still correct on the legacy backend. Test updated to assert the new rule. Build green. Web suite: only the 8 pre-existing failures remain (4 project_filter + TestTimelineKindMultiValueSurvives + 3 timeline_filter, all /timeline-301 / seeding issues on main, unrelated to slice C). No new failures from the rewiring.
502 lines
16 KiB
Go
502 lines
16 KiB
Go
package web
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"slices"
|
||
"sort"
|
||
"strings"
|
||
|
||
"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.Items.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.Items.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
|
||
}
|
||
}
|
||
// Phase 5i Slice A: project scope. Same predicate as TreeFilter.Matches —
|
||
// at least one of the item's paths must equal ProjectPath, with the
|
||
// IncludeDescendants toggle gating the prefix-match for the subtree.
|
||
// bulkMatches was a near-clone of Matches() that wasn't updated when
|
||
// the project dim landed, so /admin/bulk silently ignored ?project=…
|
||
// (and the chip's hidden-input round-trip too).
|
||
if f.ProjectPath != "" {
|
||
prefix := f.ProjectPath + "."
|
||
hit := false
|
||
for _, p := range it.Paths {
|
||
if p == f.ProjectPath {
|
||
hit = true
|
||
break
|
||
}
|
||
if f.IncludeDescendants && strings.HasPrefix(p, prefix) {
|
||
hit = true
|
||
break
|
||
}
|
||
}
|
||
if !hit {
|
||
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 applies one action to every id through the write adapter
|
||
// (s.Writes), so it targets whichever backend PROJAX_BACKEND selects.
|
||
//
|
||
// make_public / make_private map straight to the bulk-by-id SetPublic
|
||
// writer method. The field-mutating actions (tags / management / status /
|
||
// timeline-exclude) are read-modify-write: load each item via the reader,
|
||
// apply the single-field change, and write the full row back via Update.
|
||
//
|
||
// This replaces the previous single-transaction multi-row UPDATE. Phase 6
|
||
// moves writes onto mBrian's HTTP write API, which has no cross-node
|
||
// transaction, so a mid-batch failure leaves earlier rows already applied
|
||
// (the call returns the error; the caller re-renders actual state). That
|
||
// trade is acceptable at m's bulk-edit scale and keeps one write path
|
||
// across both backends instead of a SQL fast-path that only works on the
|
||
// legacy store.
|
||
func (s *Server) applyBulk(ctx context.Context, ids []string, a bulkAction) error {
|
||
switch a.SetPublic {
|
||
case "make_public":
|
||
return s.Writes.SetPublic(ctx, ids, true)
|
||
case "make_private":
|
||
return s.Writes.SetPublic(ctx, ids, false)
|
||
}
|
||
|
||
for _, id := range ids {
|
||
it, err := s.Items.GetByID(ctx, id)
|
||
if err != nil {
|
||
return fmt.Errorf("bulk %s: load %s: %w", a.describe(), id, err)
|
||
}
|
||
in := updateInputFromItem(it)
|
||
switch {
|
||
case a.AddTag != "":
|
||
in.Tags = appendUnique(in.Tags, a.AddTag)
|
||
case a.RemoveTag != "":
|
||
in.Tags = removeValue(in.Tags, a.RemoveTag)
|
||
case a.SetMgmt == "clear":
|
||
in.Management = []string{}
|
||
case a.SetMgmt != "":
|
||
// Replace management entirely — single-mode semantics matches
|
||
// the chip group on detail.tmpl.
|
||
in.Management = []string{a.SetMgmt}
|
||
case a.SetStatus != "":
|
||
in.Status = a.SetStatus
|
||
case a.TimelineTodos == "exclude":
|
||
in.TimelineExclude = appendUnique(in.TimelineExclude, "todos")
|
||
case a.TimelineTodos == "include":
|
||
in.TimelineExclude = removeValue(in.TimelineExclude, "todos")
|
||
default:
|
||
return errors.New("bulk: empty action")
|
||
}
|
||
if _, err := s.Writes.Update(ctx, id, in); err != nil {
|
||
return fmt.Errorf("bulk %s on %s: %w", a.describe(), id, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// updateInputFromItem projects an Item into a full UpdateInput so a
|
||
// single-field bulk mutation can round-trip through the full-replace
|
||
// Update without clobbering the item's other fields. Metadata is omitted
|
||
// deliberately — UpdateInput doesn't carry it and the write path leaves
|
||
// node metadata untouched.
|
||
func updateInputFromItem(it *store.Item) store.UpdateInput {
|
||
return store.UpdateInput{
|
||
Title: it.Title,
|
||
Slug: it.Slug,
|
||
ParentIDs: it.ParentIDs,
|
||
ContentMD: it.ContentMD,
|
||
Status: it.Status,
|
||
Pinned: it.Pinned,
|
||
Archived: it.Archived,
|
||
StartTime: it.StartTime,
|
||
EndTime: it.EndTime,
|
||
Tags: it.Tags,
|
||
Management: it.Management,
|
||
Public: it.Public,
|
||
PublicDescription: it.PublicDescription,
|
||
PublicLiveURL: it.PublicLiveURL,
|
||
PublicSourceURL: it.PublicSourceURL,
|
||
PublicScreenshots: it.PublicScreenshots,
|
||
TimelineExclude: it.TimelineExclude,
|
||
}
|
||
}
|
||
|
||
// appendUnique appends v to out only when it isn't already present,
|
||
// mirroring the old array_append-with-CASE SQL.
|
||
func appendUnique(out []string, v string) []string {
|
||
if slices.Contains(out, v) {
|
||
return out
|
||
}
|
||
return append(out, v)
|
||
}
|
||
|
||
// removeValue drops every occurrence of v, mirroring array_remove.
|
||
func removeValue(in []string, v string) []string {
|
||
out := make([]string, 0, len(in))
|
||
for _, x := range in {
|
||
if x != v {
|
||
out = append(out, x)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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.Items.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.Items.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.Items.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)
|
||
}
|
||
}
|