Per t-projax-6-sliceB-readpath. mBrian migration (m/mBrian#73) is live on msupabase with 65 nodes + 78 child_of + 81 projax-* edges. This commit makes the projax read path source from there behind an env switch. CLIENT ARCH: direct pgxpool against mbrian.* schema (same SUPABASE_DATABASE_URL the projax binary already uses for projax.*) — matches flexsiebels/head's cross-coupling pattern. No MCP token plumbing. CONTRACT (all three honoured) - External links are SELF-EDGES (source=target=item, rel='projax-*', payload in edges.metadata). linkFromEdge reads the node's outbound projax-* edges; ref_id derived per ref_type from metadata (caldav url, gitea owner/repo, mai-project mai_project_id). - Slugs finalised: 'work'/'dania' resolve to mBrian's canonical nodes; projax-side squatters (renamed-aside, not deleted) are documented in the parity test as legacy-only and skipped from field comparison. - created_at/updated_at NOT preserved — ItemsCreatedInRange orders off metadata.projax.start_time when present, fall back to mBrian created_at. Aggregator surfaces (timeline / dashboard) read off caldav DTSTART + gitea updated_at, so they're unaffected. NEW FILES - store/mbrian.go: MBrianReader concrete impl. Bulk-loads projax- managed nodes + child_of edges in one pair of queries per call, builds a graphContext in memory, derives Paths via ancestor walk (depth-capped at 64 like projax's trigger). Implements every ItemReader method. - store/mbrian_parity_test.go: 5 parity tests against the live db — ListAll field equality (skipping the renamed squatter slugs), spot-check resolves, caldav-list link round-trip, gitea-repo link round-trip, AllTags union, NotFound consistency. All 5 GREEN. - cmd/projax-remap-views/main.go: one-shot tool to rewrite projax.views.filter_json.project_id from old projax uuids to new mBrian uuids using the audit map mBrian dropped (head will relay the path). Dry-run default; --apply commits. Idempotent. - docs/plans/slice-b-views-projectid-gap.md: surfaces the gap + the remediation path. Must run remap BEFORE slice E drops projax.items. CHANGES - store/adapter.go: kept the ItemReader interface + *Store assertion; removed the prep stub (replaced by mbrian.go). - web/server.go: Server.Items store.ItemReader field. web.New defaults Items to the concrete *Store (legacy path). main.go overrides to MBrianReader when PROJAX_BACKEND=mbrian. - All read-path call sites in web/ swapped from s.Store.<readMethod>( to s.Items.<readMethod>( for the 15 ItemReader methods. MCP tools unchanged (separate scope; can pivot in a follow-up). Writes still flow through s.Store. - cmd/projax/main.go: PROJAX_BACKEND env switch with "store" (default) and "mbrian" values. Logs the choice at startup. Unknown value refuses to start. SMOKE - go build ./... green; go vet green. - go test ./store/ -count=1 — all parity tests pass against live data. - Local server boot with PROJAX_BACKEND=mbrian — backs binding logs "backend=mbrian (read path via store.MBrianReader)" and serves /views/tree (auth wall protects deeper smoke; parity tests cover that surface). PRE-EXISTING failure NOT addressed in this commit: 3 timeline_filter tests in web/ already failed on main (legacy /timeline URL hits the Phase 5j 301 redirect to /views/timeline). No diff vs main in those test files; out of scope for slice B. OUT OF SCOPE FOR SLICE B (deferred): - MCP read tools migration to ItemReader (separate diff, low risk). - Aggregator's LinkLister wired to ItemReader (currently consumes *Store directly through Server.Aggregator()). - views.filter_json.project_id remap RUN — tool ships here, run waits on the head's relay of the audit-map path. - Slice C write-path. Slice D mai-bridge worker. Slice E drop.
469 lines
14 KiB
Go
469 lines
14 KiB
Go
package web
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
|
||
"github.com/m/projax/store"
|
||
)
|
||
|
||
// Phase 5j paliad-shape views handlers. Slice B introduces the route
|
||
// family; slices C–G evolve the render, editor, system-views, sidebar,
|
||
// and polish layers.
|
||
//
|
||
// Route table:
|
||
// GET /views → handleViewsLanding (MRU 302 or shell)
|
||
// GET /views/{slug} → handleViewRender (saved or system)
|
||
// GET /views/new → handleViewEditor (blank)
|
||
// GET /views/{slug}/edit → handleViewEditor (existing)
|
||
// POST /views → handleViewCreate
|
||
// POST /views/{slug} → handleViewUpdate
|
||
// POST /views/{slug}/delete → handleViewDelete
|
||
// POST /views/reorder → handleViewReorder (slice G — wired now,
|
||
// used in v2)
|
||
|
||
// handleViewsLanding implements m's Q5 pick: 302 to the most-recently-used
|
||
// view if any, else render the onboarding shell listing every saved view.
|
||
func (s *Server) handleViewsLanding(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Query().Get("nodefault") != "1" {
|
||
mr, err := s.Store.MostRecentView(r.Context())
|
||
if err != nil {
|
||
s.Logger.Warn("views landing: mru", "err", err)
|
||
} else if mr != nil {
|
||
http.Redirect(w, r, "/views/"+mr.Slug, http.StatusFound)
|
||
return
|
||
}
|
||
}
|
||
views, err := s.Store.ListViews(r.Context())
|
||
if err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
s.render(w, r, "views_landing", map[string]any{
|
||
"Title": "views",
|
||
"Views": views,
|
||
})
|
||
}
|
||
|
||
// handleViewRender resolves a slug into either a user view (Slice A
|
||
// schema) or a system view (Slice C), then renders the appropriate
|
||
// template. The render path also fire-and-forgets a TouchView so the
|
||
// view climbs the MRU ladder for the next /views landing redirect.
|
||
//
|
||
// Slice B implementation: only user views are wired; system views
|
||
// resolve via LookupSystemView (added in Slice C) and 404 in this slice
|
||
// when the slug is unknown.
|
||
func (s *Server) handleViewRender(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
if slug == "" {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
v, err := s.Store.GetView(r.Context(), slug)
|
||
if errors.Is(err, store.ErrViewNotFound) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
if err := s.Store.TouchView(r.Context(), slug); err != nil {
|
||
s.Logger.Warn("touch view", "slug", slug, "err", err)
|
||
}
|
||
|
||
// Parse the saved spec.
|
||
filter, viewType, groupBy := decodeViewSpec(v.FilterJSON)
|
||
// Allow URL chip overlay so chip clicks inside a saved view narrow
|
||
// further. The page chip URLs round-trip ?view= via the URL anchor
|
||
// added in slice E's sidebar wiring; here we just respect anything
|
||
// the user typed in the query.
|
||
urlFilter := ParseTreeFilter(r.URL.Query())
|
||
overlayURLOntoSavedFilter(&filter, urlFilter, r.URL.Query())
|
||
if raw := strings.TrimSpace(r.URL.Query().Get("view_type")); raw != "" {
|
||
viewType = raw
|
||
}
|
||
if raw := strings.TrimSpace(r.URL.Query().Get("group_by")); raw != "" {
|
||
groupBy = raw
|
||
}
|
||
|
||
s.renderViewPage(w, r, v, filter, viewType, groupBy)
|
||
}
|
||
|
||
// renderViewPage runs the shared render path for a resolved view (user
|
||
// view or future system view). Slice B reuses the tree handler's
|
||
// rendering pieces — list / card / kanban share the tree-section
|
||
// dispatch shape. Calendar / timeline view_types fall back to list in
|
||
// slice B; slice D wires their dedicated templates.
|
||
func (s *Server) renderViewPage(w http.ResponseWriter, r *http.Request, v *store.View, filter TreeFilter, viewType, groupBy string) {
|
||
items, err := s.Items.ListAll(r.Context())
|
||
if err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
tags, err := s.Items.AllTags(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
|
||
}
|
||
viewSet := PageViewTypes("/")
|
||
if viewType == "" {
|
||
viewType = viewSet.Default
|
||
}
|
||
viewType = viewSet.Resolve(viewType)
|
||
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
||
base := "/views/" + v.Slug
|
||
counts := computeChipCounts(items, filter, linkKinds, tags, base)
|
||
cardItems := flatMatchedItems(items, filter, linkKinds)
|
||
if groupBy == "" {
|
||
groupBy = ParseGroupBy(r.URL.Query())
|
||
}
|
||
kanban := BuildKanbanBoard(cardItems, groupBy)
|
||
groupByChips := GroupByChips(base, filter, groupBy)
|
||
data := map[string]any{
|
||
"Title": v.Name,
|
||
"View": v,
|
||
"Roots": roots,
|
||
"Orphans": orphans,
|
||
"Total": total,
|
||
"OrphanN": orphanN,
|
||
"Matched": matched,
|
||
"AllTags": tags,
|
||
"Filter": filter,
|
||
"Counts": counts,
|
||
"Projects": parentOptionsFromItems(items),
|
||
"BasePath": base,
|
||
"ProjectChipTarget": "#tree-section",
|
||
"ViewType": viewType,
|
||
"ViewTypeChips": ViewTypeChips(base, filter, viewType),
|
||
"CardItems": cardItems,
|
||
"Kanban": kanban,
|
||
"GroupBy": groupBy,
|
||
"GroupByChips": groupByChips,
|
||
"ActiveTags": filter.Tags,
|
||
}
|
||
if r.Header.Get("HX-Request") == "true" {
|
||
s.render(w, r, "tree_section", data)
|
||
return
|
||
}
|
||
s.render(w, r, "view_render", data)
|
||
}
|
||
|
||
// handleViewEditor renders the create / edit form. Slice B ships a
|
||
// minimal placeholder; Slice D rebuilds the form with the chip strip
|
||
// + slug derivation + icon picker.
|
||
func (s *Server) handleViewEditor(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
var (
|
||
view *store.View
|
||
err error
|
||
title = "new view"
|
||
)
|
||
if slug != "" {
|
||
view, err = s.Store.GetView(r.Context(), slug)
|
||
if errors.Is(err, store.ErrViewNotFound) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
if err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
title = "edit " + view.Name
|
||
}
|
||
filterQuery := ""
|
||
currentViewType := "list"
|
||
if view != nil {
|
||
f, vt, _ := decodeViewSpec(view.FilterJSON)
|
||
filterQuery = f.QueryString()
|
||
if vt != "" {
|
||
currentViewType = vt
|
||
}
|
||
}
|
||
s.render(w, r, "view_editor", map[string]any{
|
||
"Title": title,
|
||
"View": view,
|
||
"FilterQuery": filterQuery,
|
||
"ViewTypes": []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
|
||
"CurrentVT": currentViewType,
|
||
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
|
||
"SortDirOptions": []string{"", "asc", "desc"},
|
||
"IconKeys": IconRegistryKeys(),
|
||
})
|
||
}
|
||
|
||
// handleViewCreate accepts the create form POST.
|
||
func (s *Server) handleViewCreate(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseForm(); err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
in, err := viewInputFromForm(r.PostForm)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
v, err := s.Store.CreateView(r.Context(), in)
|
||
if err != nil {
|
||
s.writeViewError(w, err)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
|
||
}
|
||
|
||
// handleViewUpdate accepts the edit form POST.
|
||
func (s *Server) handleViewUpdate(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
if err := r.ParseForm(); err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
in, err := viewInputFromForm(r.PostForm)
|
||
if err != nil {
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
return
|
||
}
|
||
v, err := s.Store.UpdateView(r.Context(), slug, in)
|
||
if err != nil {
|
||
if errors.Is(err, store.ErrViewNotFound) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
s.writeViewError(w, err)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
|
||
}
|
||
|
||
// handleViewDelete soft-… nope. New schema is hard-delete (no
|
||
// deleted_at). One POST removes the row.
|
||
func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request) {
|
||
slug := r.PathValue("slug")
|
||
if err := s.Store.DeleteView(r.Context(), slug); err != nil {
|
||
if errors.Is(err, store.ErrViewNotFound) {
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
http.Redirect(w, r, "/views", http.StatusSeeOther)
|
||
}
|
||
|
||
// handleViewReorder takes a comma-separated slug list and applies new
|
||
// sort_order values. Wired now so slice G's drag UI has a target.
|
||
func (s *Server) handleViewReorder(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseForm(); err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
raw := strings.TrimSpace(r.PostForm.Get("slugs"))
|
||
if raw == "" {
|
||
http.Error(w, "slugs is required", http.StatusBadRequest)
|
||
return
|
||
}
|
||
slugs := strings.Split(raw, ",")
|
||
for i, slug := range slugs {
|
||
slugs[i] = strings.TrimSpace(slug)
|
||
}
|
||
if err := s.Store.ReorderViews(r.Context(), slugs); err != nil {
|
||
s.fail(w, r, err)
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// writeViewError maps the typed store errors to friendly HTTP status +
|
||
// banner copy. Falls back to 400 for anything else.
|
||
func (s *Server) writeViewError(w http.ResponseWriter, err error) {
|
||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||
switch {
|
||
case errors.Is(err, store.ErrViewSlugFormat):
|
||
http.Error(w, "slug must match ^[a-z0-9][a-z0-9-]{0,62}$ (lowercase, no underscores, no leading dash)", http.StatusBadRequest)
|
||
case errors.Is(err, store.ErrViewSlugReserved):
|
||
http.Error(w, "slug is reserved (system views and top-level routes shadow it)", http.StatusBadRequest)
|
||
case errors.Is(err, store.ErrViewSlugTaken):
|
||
http.Error(w, "slug already exists — pick a different one", http.StatusConflict)
|
||
default:
|
||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||
}
|
||
}
|
||
|
||
// viewInputFromForm decodes the create/edit form. Slug + name are
|
||
// required; the rest defaults sensibly. filter_query is optional and
|
||
// canonicalises into filter_json on save (URL-query form is what the
|
||
// editor's chip strip emits in slice D).
|
||
func viewInputFromForm(form url.Values) (store.ViewInput, error) {
|
||
in := store.ViewInput{
|
||
Slug: strings.TrimSpace(form.Get("slug")),
|
||
Name: strings.TrimSpace(form.Get("name")),
|
||
SortField: strings.TrimSpace(form.Get("sort_field")),
|
||
SortDir: strings.TrimSpace(form.Get("sort_dir")),
|
||
GroupBy: strings.TrimSpace(form.Get("group_by")),
|
||
ShowCount: form.Get("show_count") == "1",
|
||
}
|
||
if iconRaw := strings.TrimSpace(form.Get("icon")); iconRaw != "" {
|
||
in.Icon = &iconRaw
|
||
}
|
||
viewType := strings.TrimSpace(form.Get("view_type"))
|
||
if viewType == "" {
|
||
viewType = ViewTypeList
|
||
}
|
||
fq := strings.TrimSpace(form.Get("filter_query"))
|
||
filterJSON, err := encodeFilterToJSON(fq, viewType)
|
||
if err != nil {
|
||
return in, fmt.Errorf("filter_query: %w", err)
|
||
}
|
||
in.FilterJSON = filterJSON
|
||
return in, nil
|
||
}
|
||
|
||
// encodeFilterToJSON turns a URL-query-form filter + view_type into the
|
||
// canonical filter_json shape stored on the view. view_type lives inside
|
||
// the JSON per m's Q2 pick.
|
||
func encodeFilterToJSON(query, viewType string) ([]byte, error) {
|
||
q, err := url.ParseQuery(strings.TrimPrefix(query, "?"))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
f := ParseTreeFilter(q)
|
||
payload := map[string]any{
|
||
"view_type": viewType,
|
||
}
|
||
if f.Q != "" {
|
||
payload["q"] = f.Q
|
||
}
|
||
if len(f.Tags) > 0 {
|
||
payload["tags"] = f.Tags
|
||
}
|
||
if len(f.Management) > 0 {
|
||
payload["management"] = f.Management
|
||
}
|
||
if !(len(f.Status) == 1 && f.Status[0] == "active") {
|
||
payload["status"] = f.Status
|
||
}
|
||
if len(f.HasLinks) > 0 {
|
||
payload["has_links"] = f.HasLinks
|
||
}
|
||
if f.Public != nil {
|
||
payload["public"] = *f.Public
|
||
}
|
||
if f.ShowArchived {
|
||
payload["show_archived"] = true
|
||
}
|
||
if f.ProjectPath != "" {
|
||
payload["project_path"] = f.ProjectPath
|
||
if !f.IncludeDescendants {
|
||
payload["include_descendants"] = false
|
||
}
|
||
}
|
||
return json.Marshal(payload)
|
||
}
|
||
|
||
// decodeViewSpec parses filter_json into a TreeFilter + view_type +
|
||
// group_by. Inverse of encodeFilterToJSON.
|
||
func decodeViewSpec(filterJSON []byte) (TreeFilter, string, string) {
|
||
f := TreeFilter{
|
||
Status: []string{"active"},
|
||
IncludeDescendants: true,
|
||
}
|
||
viewType := ""
|
||
groupBy := ""
|
||
if len(filterJSON) == 0 {
|
||
return f, viewType, groupBy
|
||
}
|
||
payload := map[string]any{}
|
||
if err := json.Unmarshal(filterJSON, &payload); err != nil {
|
||
return f, viewType, groupBy
|
||
}
|
||
if v, ok := payload["view_type"].(string); ok {
|
||
viewType = v
|
||
}
|
||
if v, ok := payload["group_by"].(string); ok {
|
||
groupBy = v
|
||
}
|
||
if v, ok := payload["q"].(string); ok {
|
||
f.Q = v
|
||
}
|
||
if v, ok := payload["tags"].([]any); ok {
|
||
f.Tags = anySliceToStrings(v)
|
||
}
|
||
if v, ok := payload["management"].([]any); ok {
|
||
f.Management = anySliceToStrings(v)
|
||
}
|
||
if v, ok := payload["status"].([]any); ok {
|
||
f.Status = anySliceToStrings(v)
|
||
if len(f.Status) == 0 {
|
||
f.Status = []string{"active"}
|
||
}
|
||
}
|
||
if v, ok := payload["has_links"].([]any); ok {
|
||
f.HasLinks = anySliceToStrings(v)
|
||
}
|
||
if v, ok := payload["public"].(bool); ok {
|
||
f.Public = &v
|
||
}
|
||
if v, ok := payload["show_archived"].(bool); ok && v {
|
||
f.ShowArchived = true
|
||
}
|
||
if v, ok := payload["project_path"].(string); ok {
|
||
f.ProjectPath = v
|
||
}
|
||
if v, ok := payload["include_descendants"].(bool); ok {
|
||
f.IncludeDescendants = v
|
||
}
|
||
return f, viewType, groupBy
|
||
}
|
||
|
||
// overlayURLOntoSavedFilter applies URL-query chip values on top of the
|
||
// saved-view baseline. Same pattern the 5i fix-shift had (URL overrides
|
||
// saved); slice B reintroduces it here on the /views/{slug} render path.
|
||
func overlayURLOntoSavedFilter(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
|
||
if q.Get("q") != "" {
|
||
base.Q = urlFilter.Q
|
||
}
|
||
if _, ok := q["tag"]; ok {
|
||
base.Tags = urlFilter.Tags
|
||
}
|
||
if _, ok := q["mgmt"]; ok {
|
||
base.Management = urlFilter.Management
|
||
}
|
||
if _, ok := q["status"]; ok {
|
||
base.Status = urlFilter.Status
|
||
}
|
||
if _, ok := q["has"]; ok {
|
||
base.HasLinks = urlFilter.HasLinks
|
||
}
|
||
if q.Get("show-archived") != "" {
|
||
base.ShowArchived = urlFilter.ShowArchived
|
||
}
|
||
if q.Get("public") != "" {
|
||
base.Public = urlFilter.Public
|
||
}
|
||
if q.Get("project") != "" {
|
||
base.ProjectPath = urlFilter.ProjectPath
|
||
}
|
||
if q.Get("project_descendants") != "" {
|
||
base.IncludeDescendants = urlFilter.IncludeDescendants
|
||
}
|
||
}
|
||
|
||
func anySliceToStrings(in []any) []string {
|
||
out := make([]string, 0, len(in))
|
||
for _, v := range in {
|
||
if s, ok := v.(string); ok {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|