Compare commits
36 Commits
731f443569
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a619768cca | |||
| ef507b4e1b | |||
| 018665fee7 | |||
| 728788225c | |||
| b72744b567 | |||
| 63a2e13036 | |||
| e65385e609 | |||
| 0dfa0e2ab7 | |||
| 6436b524d6 | |||
| eaecd3944e | |||
| 7c84c96f8b | |||
| e133f51706 | |||
| 663f21bdb0 | |||
| bc56733bc8 | |||
| e43055b670 | |||
| 67577396a2 | |||
| d0ec02cb63 | |||
| 307a898dbd | |||
| b22f50ca7b | |||
| 4fdeca8269 | |||
| 9607d4b307 | |||
| 38182df651 | |||
| 2702c699d1 | |||
| a5b0971b9d | |||
| b3e7183478 | |||
| a44edf3917 | |||
| 9a8ea8f31e | |||
| df83ab7255 | |||
| 1f8c626aed | |||
| 4918f48b51 | |||
| f820fa5830 | |||
| 0ad610d018 | |||
| e305f0e0ae | |||
| a9f062a67e | |||
| 173d7ddbb2 | |||
| 590bb28063 |
139
cmd/projax-remap-views/main.go
Normal file
139
cmd/projax-remap-views/main.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// projax-remap-views rewrites projax.views.filter_json.project_id from
|
||||
// the OLD projax.items uuid to the new mBrian node uuid using the audit
|
||||
// map mBrian dropped after the migration. Dry-run by default; pass
|
||||
// --apply to commit.
|
||||
//
|
||||
// Phase 6 Slice B gap remediation — see
|
||||
// docs/plans/slice-b-views-projectid-gap.md for the surrounding context.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// projax-remap-views --map /path/to/uuid-map.json # dry-run
|
||||
// projax-remap-views --map /path/to/uuid-map.json --apply # commit
|
||||
//
|
||||
// Map shape: {"<old-uuid>": "<new-uuid>", ...}
|
||||
//
|
||||
// Env: PROJAX_DB_URL or SUPABASE_DATABASE_URL — direct postgres URL.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mapPath := flag.String("map", "", "JSON file with {old-uuid: new-uuid} map")
|
||||
apply := flag.Bool("apply", false, "commit changes (default: dry-run)")
|
||||
flag.Parse()
|
||||
|
||||
if *mapPath == "" {
|
||||
die("--map is required")
|
||||
}
|
||||
mp, err := loadMap(*mapPath)
|
||||
if err != nil {
|
||||
die("load map: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "loaded %d uuid mappings\n", len(mp))
|
||||
|
||||
dbURL := os.Getenv("PROJAX_DB_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
|
||||
}
|
||||
if dbURL == "" {
|
||||
die("set PROJAX_DB_URL or SUPABASE_DATABASE_URL")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
die("pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT slug, filter_json::text FROM projax.views WHERE filter_json ? 'project_id'`)
|
||||
if err != nil {
|
||||
die("query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
type view struct {
|
||||
slug string
|
||||
filter map[string]any
|
||||
}
|
||||
var pending []view
|
||||
for rows.Next() {
|
||||
var slug, raw string
|
||||
if err := rows.Scan(&slug, &raw); err != nil {
|
||||
die("scan: %v", err)
|
||||
}
|
||||
var fj map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &fj); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "skip %s: invalid JSON: %v\n", slug, err)
|
||||
continue
|
||||
}
|
||||
oldID, _ := fj["project_id"].(string)
|
||||
newID, ok := mp[oldID]
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "skip %s: project_id %q not in map (possibly already remapped)\n", slug, oldID)
|
||||
continue
|
||||
}
|
||||
fj["project_id"] = newID
|
||||
pending = append(pending, view{slug: slug, filter: fj})
|
||||
fmt.Fprintf(os.Stderr, " %s: %s → %s\n", slug, oldID, newID)
|
||||
}
|
||||
|
||||
if len(pending) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "nothing to remap")
|
||||
return
|
||||
}
|
||||
|
||||
if !*apply {
|
||||
fmt.Fprintf(os.Stderr, "DRY RUN — %d view(s) would be remapped; pass --apply to commit\n", len(pending))
|
||||
return
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
die("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
for _, v := range pending {
|
||||
payload, err := json.Marshal(v.filter)
|
||||
if err != nil {
|
||||
die("marshal %s: %v", v.slug, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE projax.views SET filter_json = $1::jsonb WHERE slug = $2`,
|
||||
string(payload), v.slug); err != nil {
|
||||
die("update %s: %v", v.slug, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
die("commit: %v", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "committed %d remap(s)\n", len(pending))
|
||||
}
|
||||
|
||||
func loadMap(path string) (map[string]string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
349
cmd/projax-snapshot/main.go
Normal file
349
cmd/projax-snapshot/main.go
Normal file
@@ -0,0 +1,349 @@
|
||||
// projax-snapshot dumps the current projax.items + projax.item_links state
|
||||
// to a JSON file so the mBrian-side migration script (m/mBrian#73) can
|
||||
// consume it. Read-only; no schema changes; idempotent across runs.
|
||||
//
|
||||
// Phase 6 Slice 0 — first projax-side step in the mBrian-backend migration.
|
||||
// See docs/plans/mbrian-backend-migration.md §7 + §8 for the surrounding
|
||||
// context. The file shape is documented in the m/mBrian#73 issue body
|
||||
// (the two-pass node-then-edge layout the migration script expects).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// projax-snapshot # write ./projax_snapshot.json
|
||||
// projax-snapshot --out path/to/file.json # custom output path
|
||||
//
|
||||
// Env: PROJAX_DB_URL or SUPABASE_DATABASE_URL — direct postgres URL into
|
||||
// msupabase (same conventions as the main projax binary).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Snapshot is the top-level JSON shape mBrian-side consumes.
|
||||
type Snapshot struct {
|
||||
Version string `json:"version"` // doc-evolution marker; bump on shape changes
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
GitCommit string `json:"git_commit,omitempty"` // optional build-time injection
|
||||
Items []Item `json:"items"`
|
||||
Links []ItemLink `json:"links"`
|
||||
SpotChecks []SpotCheck `json:"spot_checks"` // 5 representative items per m/mBrian#73 §3
|
||||
}
|
||||
|
||||
// Item mirrors every column on projax.items as of this commit. Field
|
||||
// order matches the SQL projection; types are JSON-friendly (uuid →
|
||||
// string, jsonb → map). Anything nullable surfaces as omitempty / *T.
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Kind []string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Paths []string `json:"paths"`
|
||||
ParentIDs []string `json:"parent_ids"`
|
||||
ContentMD string `json:"content_md"`
|
||||
Aliases []string `json:"aliases"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Status string `json:"status"`
|
||||
Pinned bool `json:"pinned"`
|
||||
Archived bool `json:"archived"`
|
||||
StartTime *time.Time `json:"start_time,omitempty"`
|
||||
EndTime *time.Time `json:"end_time,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Management []string `json:"management"`
|
||||
Public bool `json:"public"`
|
||||
PublicDescription string `json:"public_description,omitempty"`
|
||||
PublicLiveURL string `json:"public_live_url,omitempty"`
|
||||
PublicSourceURL string `json:"public_source_url,omitempty"`
|
||||
PublicScreenshots []string `json:"public_screenshots,omitempty"`
|
||||
TimelineExclude []string `json:"timeline_exclude,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ItemLink mirrors projax.item_links. ref_type values become projax-*
|
||||
// edge rel names on the mBrian side; the payload lands in edges.metadata
|
||||
// per the issue body §1.
|
||||
type ItemLink struct {
|
||||
ID string `json:"id"`
|
||||
ItemID string `json:"item_id"`
|
||||
RefType string `json:"ref_type"`
|
||||
RefID string `json:"ref_id"`
|
||||
Rel string `json:"rel"`
|
||||
Note *string `json:"note,omitempty"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
EventDate *time.Time `json:"event_date,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// SpotCheck names one of the 5 representative items the mBrian-side
|
||||
// script verifies post-migration. The reason text is mirrored from
|
||||
// m/mBrian#73 §3 so future readers don't need to cross-reference.
|
||||
type SpotCheck struct {
|
||||
ItemID string `json:"item_id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "projax_snapshot.json", "output JSON path")
|
||||
flag.Parse()
|
||||
|
||||
dbURL := os.Getenv("PROJAX_DB_URL")
|
||||
if dbURL == "" {
|
||||
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
|
||||
}
|
||||
if dbURL == "" {
|
||||
die("set PROJAX_DB_URL or SUPABASE_DATABASE_URL")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
die("pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
items, err := loadItems(ctx, pool)
|
||||
if err != nil {
|
||||
die("load items: %v", err)
|
||||
}
|
||||
links, err := loadLinks(ctx, pool)
|
||||
if err != nil {
|
||||
die("load links: %v", err)
|
||||
}
|
||||
spots := pickSpotChecks(items, links)
|
||||
|
||||
snap := Snapshot{
|
||||
Version: "1",
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
Items: items,
|
||||
Links: links,
|
||||
SpotChecks: spots,
|
||||
}
|
||||
|
||||
buf, err := json.MarshalIndent(snap, "", " ")
|
||||
if err != nil {
|
||||
die("marshal: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(*out, buf, 0644); err != nil {
|
||||
die("write %s: %v", *out, err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"wrote %s — %d items, %d links, %d spot-checks\n",
|
||||
*out, len(items), len(links), len(spots))
|
||||
}
|
||||
|
||||
func loadItems(ctx context.Context, pool *pgxpool.Pool) ([]Item, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT id, kind, title, slug, paths, parent_ids, content_md, aliases,
|
||||
metadata, status, pinned, archived, start_time, end_time,
|
||||
tags, management,
|
||||
public, coalesce(public_description, ''),
|
||||
coalesce(public_live_url, ''),
|
||||
coalesce(public_source_url, ''),
|
||||
public_screenshots,
|
||||
timeline_exclude,
|
||||
created_at, updated_at
|
||||
FROM projax.items
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY paths NULLS FIRST, slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Item{}
|
||||
for rows.Next() {
|
||||
var it Item
|
||||
if err := rows.Scan(
|
||||
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Paths, &it.ParentIDs,
|
||||
&it.ContentMD, &it.Aliases, &it.Metadata, &it.Status, &it.Pinned, &it.Archived,
|
||||
&it.StartTime, &it.EndTime, &it.Tags, &it.Management,
|
||||
&it.Public, &it.PublicDescription, &it.PublicLiveURL, &it.PublicSourceURL,
|
||||
&it.PublicScreenshots, &it.TimelineExclude, &it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Normalise empty slices: pgx hands back nil for empty array
|
||||
// columns, which renders as `null` in JSON. Coerce to [] for
|
||||
// downstream-script ergonomics.
|
||||
if it.Kind == nil {
|
||||
it.Kind = []string{}
|
||||
}
|
||||
if it.Paths == nil {
|
||||
it.Paths = []string{}
|
||||
}
|
||||
if it.ParentIDs == nil {
|
||||
it.ParentIDs = []string{}
|
||||
}
|
||||
if it.Aliases == nil {
|
||||
it.Aliases = []string{}
|
||||
}
|
||||
if it.Tags == nil {
|
||||
it.Tags = []string{}
|
||||
}
|
||||
if it.Management == nil {
|
||||
it.Management = []string{}
|
||||
}
|
||||
if it.PublicScreenshots == nil {
|
||||
it.PublicScreenshots = []string{}
|
||||
}
|
||||
if it.TimelineExclude == nil {
|
||||
it.TimelineExclude = []string{}
|
||||
}
|
||||
if it.Metadata == nil {
|
||||
it.Metadata = map[string]any{}
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func loadLinks(ctx context.Context, pool *pgxpool.Pool) ([]ItemLink, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT id, item_id, ref_type, ref_id, rel, note, metadata,
|
||||
event_date, created_at
|
||||
FROM projax.item_links
|
||||
ORDER BY item_id, ref_type, created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []ItemLink{}
|
||||
for rows.Next() {
|
||||
var l ItemLink
|
||||
if err := rows.Scan(
|
||||
&l.ID, &l.ItemID, &l.RefType, &l.RefID, &l.Rel, &l.Note,
|
||||
&l.Metadata, &l.EventDate, &l.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if l.Metadata == nil {
|
||||
l.Metadata = map[string]any{}
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// pickSpotChecks selects the 5 representative items the mBrian-side
|
||||
// migration script verifies post-migration, per m/mBrian#73 §3:
|
||||
//
|
||||
// 1. A simple root area (dev).
|
||||
// 2. A single-parent project (dev.paliad — or whichever single-parent
|
||||
// project we can find).
|
||||
// 3. A multi-parent project (any item with >1 parent_id).
|
||||
// 4. A project with a caldav-list link.
|
||||
// 5. A project with public=true and public_description / public_live_url
|
||||
// populated.
|
||||
//
|
||||
// Failures to find any one of the 5 are non-fatal — the SpotChecks slice
|
||||
// just shrinks. mBrian-side script logs whatever's missing.
|
||||
func pickSpotChecks(items []Item, links []ItemLink) []SpotCheck {
|
||||
byID := map[string]*Item{}
|
||||
for i := range items {
|
||||
byID[items[i].ID] = &items[i]
|
||||
}
|
||||
caldavItems := map[string]bool{}
|
||||
for _, l := range links {
|
||||
if l.RefType == "caldav-list" {
|
||||
caldavItems[l.ItemID] = true
|
||||
}
|
||||
}
|
||||
out := []SpotCheck{}
|
||||
|
||||
// 1. Root area "dev" if present.
|
||||
for _, it := range items {
|
||||
if it.Slug == "dev" && len(it.ParentIDs) == 0 {
|
||||
out = append(out, SpotCheck{
|
||||
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
|
||||
Reason: "root area (dev) — verify type=['project'] + metadata.projax.kind='area' round-trip",
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Single-parent project — prefer dev.paliad if present, else any.
|
||||
added2 := false
|
||||
for _, it := range items {
|
||||
if it.Slug == "paliad" && len(it.ParentIDs) == 1 {
|
||||
out = append(out, SpotCheck{
|
||||
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
|
||||
Reason: "single-parent project (dev.paliad) — verify one child_of edge",
|
||||
})
|
||||
added2 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !added2 {
|
||||
for _, it := range items {
|
||||
if len(it.ParentIDs) == 1 && !containsString(it.Kind, "mai-managed") {
|
||||
out = append(out, SpotCheck{
|
||||
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
|
||||
Reason: "single-parent project — verify one child_of edge",
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Multi-parent project — any item with cardinality(parent_ids) > 1.
|
||||
for _, it := range items {
|
||||
if len(it.ParentIDs) > 1 {
|
||||
out = append(out, SpotCheck{
|
||||
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
|
||||
Reason: fmt.Sprintf("multi-parent project (%d parents) — verify all child_of edges land", len(it.ParentIDs)),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Project with a caldav-list link.
|
||||
for _, it := range items {
|
||||
if caldavItems[it.ID] {
|
||||
out = append(out, SpotCheck{
|
||||
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
|
||||
Reason: "caldav-list-linked project — verify edges.metadata.url payload round-trip",
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Project with public=true + public_description populated.
|
||||
for _, it := range items {
|
||||
if it.Public && it.PublicDescription != "" {
|
||||
out = append(out, SpotCheck{
|
||||
ItemID: it.ID, Slug: it.Slug, Title: it.Title,
|
||||
Reason: "public-listing project — verify metadata.projax.public.* bundle preserved for flexsiebels renderer",
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Stable order for deterministic output.
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Slug < out[j].Slug })
|
||||
return out
|
||||
}
|
||||
|
||||
func containsString(haystack []string, needle string) bool {
|
||||
for _, s := range haystack {
|
||||
if s == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func die(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -65,12 +66,47 @@ func main() {
|
||||
logger.Info("migrations applied")
|
||||
}
|
||||
|
||||
srv, err := web.New(store.New(pool), logger)
|
||||
st := store.New(pool)
|
||||
srv, err := web.New(st, logger)
|
||||
if err != nil {
|
||||
logger.Error("server init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
srv.Version = gitCommit
|
||||
|
||||
// Phase 6 Slice C — backend selector. PROJAX_BACKEND=mbrian flips BOTH
|
||||
// the read path (srv.Items → MBrianReader, direct DB) AND the write path
|
||||
// (srv.Writes → MBrianWriter, scoped HTTP API) together; default keeps
|
||||
// the legacy pgx-against-projax.items path on both so production
|
||||
// rollback is one env flip.
|
||||
//
|
||||
// The flip MUST be atomic: slice B flipped only the reader, so a
|
||||
// read-then-write round-trip read an mBrian uuid then wrote it against
|
||||
// projax.items and was rejected. Reads and writes now always share a
|
||||
// backend — never one without the other.
|
||||
backend := strings.ToLower(strings.TrimSpace(os.Getenv("PROJAX_BACKEND")))
|
||||
switch backend {
|
||||
case "mbrian":
|
||||
apiURL := strings.TrimSpace(os.Getenv("PROJAX_MBRIAN_API_URL"))
|
||||
apiToken := strings.TrimSpace(os.Getenv("PROJAX_MBRIAN_API_TOKEN"))
|
||||
srv.Items = store.NewMBrianReader(pool)
|
||||
srv.Writes = store.NewMBrianWriter(apiURL, apiToken, pool)
|
||||
if apiURL == "" || apiToken == "" {
|
||||
// Reads work direct-DB without these, but writes fail closed
|
||||
// (clean 503-style error) until both are set. Warn loudly rather
|
||||
// than exit — head sets them in Dokploy at cutover and the writer
|
||||
// surfaces a legible error if a write lands first.
|
||||
logger.Warn("backend=mbrian but PROJAX_MBRIAN_API_URL/PROJAX_MBRIAN_API_TOKEN not both set — writes will fail closed until configured")
|
||||
}
|
||||
logger.Info("backend=mbrian (reads via store.MBrianReader, writes via store.MBrianWriter HTTP API)", "api_url", apiURL)
|
||||
case "", "store":
|
||||
// Default — srv.Items and srv.Writes are both the *Store from web.New.
|
||||
logger.Info("backend=store (reads + writes via legacy *store.Store)")
|
||||
default:
|
||||
logger.Error("unknown PROJAX_BACKEND value", "value", backend)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("startup", "version", gitCommit)
|
||||
|
||||
if supaURL := os.Getenv("SUPABASE_URL"); supaURL != "" {
|
||||
@@ -119,7 +155,11 @@ func main() {
|
||||
// shared *aggregate.Aggregator instead of pointing back at
|
||||
// *web.Server. Passing nil here disables the `timeline` tool
|
||||
// cleanly; the rest of the projax MCP toolset stays usable.
|
||||
mcp.RegisterProjaxTools(mcpSrv, store.New(pool), srv.Aggregator())
|
||||
// MCP flips with the backend too: pass the same reader+writer the web
|
||||
// handlers use (srv.Items / srv.Writes, already set by the
|
||||
// PROJAX_BACKEND switch above). The `timeline` tool keeps the legacy
|
||||
// *Store (st) + aggregator — out of slice-C scope.
|
||||
mcp.RegisterProjaxTools(mcpSrv, srv.Items, srv.Writes, st, srv.Aggregator())
|
||||
mcpMux := http.NewServeMux()
|
||||
mcpSrv.Routes(mcpMux)
|
||||
srv.MCP = mcpMux
|
||||
|
||||
101
db/migrations/0017_views_redesign.sql
Normal file
101
db/migrations/0017_views_redesign.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- 0017_views_redesign.sql
|
||||
--
|
||||
-- Phase 5j Slice A: paliad-shape redesign of projax.views.
|
||||
--
|
||||
-- 5i (0016) modelled views as overlays on existing pages keyed by uuid.
|
||||
-- m's feedback: that's the wrong shape — views should be first-class
|
||||
-- pages at /views/{slug}, mirroring paliad's user_views model.
|
||||
--
|
||||
-- This migration HARD-REPLACES the 5i table. m's pick on Q10 (2026-05-29):
|
||||
-- hard-replace is fine because 5i was hours old with no persisted user
|
||||
-- data of value. Any rows present get dropped along with the table.
|
||||
--
|
||||
-- m's other picks worth marking inline:
|
||||
-- Q2 (2026-05-29): view_type lives INSIDE filter_json, not as a
|
||||
-- top-level column with a CHECK constraint. Keeps the
|
||||
-- schema lean — the renderer parses the JSON anyway.
|
||||
-- Q9 (2026-05-29): is_default_for column dropped entirely. MRU
|
||||
-- (last_used_at) replaces the per-page-default model.
|
||||
-- Q11 (2026-05-29): graph stays outside the views enum; no graph
|
||||
-- view_type ever lands in filter_json.
|
||||
|
||||
DROP TABLE IF EXISTS projax.views CASCADE;
|
||||
|
||||
CREATE TABLE projax.views (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- URL-routable identifier. Application-layer validator enforces the
|
||||
-- regex `^[a-z0-9][a-z0-9-]{0,62}$` + a reserved-slug list (system
|
||||
-- slugs + top-level route segments). Globally unique — single-user
|
||||
-- v1; no user_id prefix.
|
||||
slug text NOT NULL,
|
||||
|
||||
-- Display name. Free-form; user picks whatever language they think in.
|
||||
-- Rendered verbatim in the sidebar.
|
||||
name text NOT NULL,
|
||||
|
||||
-- Frontend icon-registry key. NULL → default folder glyph. Length cap
|
||||
-- keeps stored value sane even if the registry is bypassed.
|
||||
icon text,
|
||||
|
||||
-- Canonical view definition. Includes view_type (per m's Q2 pick),
|
||||
-- plus the standard TreeFilter dimensions (q, tags, management, …),
|
||||
-- plus optional sort/group hints. Renderer parses the JSON; the DB
|
||||
-- never has to look inside.
|
||||
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
-- Sort + grouping hints used by the renderers (list/card/kanban).
|
||||
-- Kept as top-level columns so the editor can index them quickly,
|
||||
-- though they're conceptually part of the render spec.
|
||||
sort_field text,
|
||||
sort_dir text,
|
||||
group_by text,
|
||||
|
||||
-- Sidebar ordering. Server-assigned MAX+1 on create so two parallel
|
||||
-- inserts don't collide. Drag-reorder UI lands in slice G; this
|
||||
-- column is wired now so the data shape is stable.
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
|
||||
-- Opt-in count badge on the sidebar entry. Defaults false so casual
|
||||
-- views don't pay the COUNT(*) cost.
|
||||
show_count boolean NOT NULL DEFAULT false,
|
||||
|
||||
-- MRU landing on /views — `handleViewsLanding` 302s here when set.
|
||||
-- Touched fire-and-forget on every render.
|
||||
last_used_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT views_sort_dir_chk
|
||||
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
|
||||
CONSTRAINT views_slug_format_chk
|
||||
CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX views_slug_uniq ON projax.views (slug);
|
||||
CREATE INDEX views_sort_order_idx ON projax.views (sort_order, name);
|
||||
CREATE INDEX views_last_used_idx ON projax.views (last_used_at DESC NULLS LAST);
|
||||
|
||||
-- updated_at trigger. Re-created here (CREATE OR REPLACE on the function)
|
||||
-- because 0016 dropped with CASCADE above.
|
||||
CREATE OR REPLACE FUNCTION projax.views_touch_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS views_touch_updated_at ON projax.views;
|
||||
CREATE TRIGGER views_touch_updated_at
|
||||
BEFORE UPDATE ON projax.views
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.views_touch_updated_at();
|
||||
|
||||
DO $own$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN
|
||||
EXECUTE 'ALTER TABLE projax.views OWNER TO projax_admin';
|
||||
EXECUTE 'ALTER FUNCTION projax.views_touch_updated_at() OWNER TO projax_admin';
|
||||
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON projax.views TO projax_admin';
|
||||
END IF;
|
||||
END $own$;
|
||||
434
docs/plans/mbrian-backend-migration.md
Normal file
434
docs/plans/mbrian-backend-migration.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# mBrian-as-backend migration — Phase 6 design
|
||||
|
||||
**Status**: Phase A design — re-baselined against live mBrian schema (2026-05-29).
|
||||
**Branch**: `mai/kahn/phase-6a-mbrian-design`.
|
||||
**Author**: kahn (inventor), 2026-05-29.
|
||||
**Source decision** (m, issue m/projax#5, 12:43 2026-05-29): Option A — full backend migration. *"I think we need the project-management element inside of mBrian for it to be the complete 2nd Brain experience. The data itself is not too important yet."*
|
||||
|
||||
**m's overriding directive** (2026-05-29 via head): *"keep the database simple so it remains easily modifiable."*
|
||||
|
||||
**Constraint**: data-loss tolerant on the 47 current `projax.items`.
|
||||
|
||||
**m's answers on §10 (2026-05-29)**: every inventor pick confirmed.
|
||||
|
||||
> Q1=reuse 'project' / Q2=(b) handler bridge / Q3=(a) clients projax-side / Q4=(a) file Gitea on m/mBrian via otto/head — m: *"mbrian must own the migration"* / Q5=(a) views stay projax-resident / Q6=(a) per-user slug / Q7=(a) hard-cut / Q8=(a) tags in metadata / Q9=(a) projax-side cycle detection / Q10=(a) keep projax MCP via adapter / Q11=keep `projax_origin` audit metadata.
|
||||
|
||||
**Re-baseline note**: §3's original ask was built off a stale `db/001_initial_schema.sql` read. Head verified the live mBrian schema after m's answers. Three of the six asks (MB-A, MB-C, MB-D) turned out already-satisfied — `edges.metadata` exists since `db/010_flexsiebels_compat.sql`, `'project'` type exists since `db/033`, the per-user slug unique index ships in `db/001`. The remaining mBrian-side artifact is small. §3 + §8 now reflect that. The big shift: **mBrian owns the one-shot data-migration script** — that's what "mbrian must own the migration" means — while projax owns the read+write rewiring on its own side afterward.
|
||||
|
||||
---
|
||||
|
||||
## §1 — Diagnosis
|
||||
|
||||
projax today stores its own structured data in `projax.items` + `projax.item_links` (msupabase, schema `projax`). It's a parallel knowledge surface to mBrian's main graph — both store nodes-with-content-and-edges, both speak SQL+jsonb, both ship MCP. The duplication has cost: project context (held by projax) is invisible to mBrian's reasoning paths; mBrian's relationship graph (held by mbrian) is invisible to projax's tile / timeline aggregations.
|
||||
|
||||
m's call closes the gap by making mBrian canonical. Projax keeps its UI — the /views routes, the Tiles dashboard, the calendar grid, the timeline spine, the /tree forest, the just-shipped /views/{slug} family, and the system-view chrome — but every read and write goes through mBrian instead of `projax.items`. Same surface, single source.
|
||||
|
||||
End-state contract:
|
||||
|
||||
- One node graph. Every project, task-context, area, link bundle lives in `mbrian.nodes` + `mbrian.edges`.
|
||||
- projax's UI is a structured editor + aggregation surface over that graph (think paliad-shape views, mBrian-shape data).
|
||||
- mBrian's existing surfaces (the web editor, the trackers, the synthesis filings) keep working unchanged — projax data appears alongside everything else.
|
||||
- CalDAV / Gitea / mai.projects integrations stay projax-handled at the consumption layer; the items they hang off of live in mBrian.
|
||||
- The 47-item migration is one-shot. Anything lossy gets logged + flagged for manual repair; we don't preserve at all costs.
|
||||
|
||||
---
|
||||
|
||||
## §2 — Schema mapping (the load-bearing section)
|
||||
|
||||
### Per-column map: `projax.items` → mBrian shape
|
||||
|
||||
| projax column | mBrian destination | notes |
|
||||
|---|---|---|
|
||||
| `id` (uuid) | `nodes.id` | new uuids on migration; legacy ids never round-trip |
|
||||
| `kind` (text[]) | `nodes.type` | direct shape match; projax `'project'` becomes mBrian `'project'` (already in live schema, mig 033). **Areas keep `type=['project']` + `metadata.projax.kind='area'`** — per m's "keep the database simple" directive, no new mBrian type. Zero DDL. |
|
||||
| `title` | `nodes.title` | 1:1 |
|
||||
| `slug` | `nodes.slug` | mBrian = unique per user; projax = unique per parent — see §2.1 |
|
||||
| `paths` (text[]) | derived from `child_of` edges + `nodes.path` cache | DAG resolution via edge walk; see §2.2 |
|
||||
| `parent_ids` (uuid[]) | edges `(source=this, rel='child_of', target=parent)` | one edge per parent; preserves multi-parent |
|
||||
| `content_md` | `nodes.content_md` | 1:1 |
|
||||
| `aliases` (text[]) | `nodes.aliases` | 1:1 |
|
||||
| `metadata` (jsonb) | `nodes.metadata` | merge; projax metadata keeps its existing shape under a `projax` sub-key to avoid colliding with mBrian's metadata schema |
|
||||
| `status` (text) | `nodes.metadata.projax.status` | active/done/archived; mBrian's `archived` bool covers part of it but loses the active/done split |
|
||||
| `pinned` (bool) | `nodes.pinned` | 1:1 |
|
||||
| `archived` (bool) | `nodes.archived` | 1:1; status='archived' implies this too |
|
||||
| `start_time`, `end_time` (timestamptz) | `nodes.metadata.projax.start_time` / `end_time` | mBrian has no first-class start/end |
|
||||
| `tags` (text[]) | `nodes.metadata.projax.tags` | mBrian convention puts tags as separate `[tag]` nodes joined via `tagged` edges; we keep tags in metadata for the migration window then optionally re-shape — see Q8 |
|
||||
| `management` (text[]) | `nodes.metadata.projax.management` | mai/self/external/unmanaged — projax-specific concept; stays in metadata |
|
||||
| `public`, `public_description`, `public_live_url`, `public_source_url`, `public_screenshots` | `nodes.metadata.projax.public.{...}` | mBrian's `visibility` is a different model (personal/public/...); we keep projax's bundle in metadata so the flexsiebels portfolio renderer keeps working |
|
||||
| `timeline_exclude` (text[]) | `nodes.metadata.projax.timeline_exclude` | projax-only concept |
|
||||
| `created_at` | `nodes.created_at` | 1:1 |
|
||||
| `updated_at` | `nodes.updated_at` | trigger-maintained on both sides |
|
||||
| `deleted_at` | `nodes.deleted_at` | 1:1 |
|
||||
|
||||
### §2.1 — Slug uniqueness (settled)
|
||||
|
||||
projax today enforces slug uniqueness **per parent**. mBrian's live schema has `CREATE UNIQUE INDEX idx_nodes_slug ON mbrian.nodes (user_id, slug)` — uniqueness **per user**. Per m's Q6=(a), projax adopts mBrian's model: one `paliad` node total, connected to both `dev` and `work` via two `child_of` edges. The DAG-as-multiple-paths view is a render-time concept; the storage is one node.
|
||||
|
||||
projax handlers' itemwrite validator (Phase 5c) loses its per-parent slug rule, gains a per-user check (against the projax-managed subset of nodes). This is **stricter** — m can't have two different "paliad" projects under different roots. Settled per m's answer.
|
||||
|
||||
**Pre-migration dedup**: the 47-item migration script (which lives mBrian-side, see §3+§7) scans for slug collisions across the projax dataset and folds collisions into one node with multiple `child_of` edges. Skip-with-log on anything weirder.
|
||||
|
||||
### §2.2 — paths array vs single path
|
||||
|
||||
projax's `paths text[]` is computed from `parent_ids` (one path per ancestor lineage). mBrian's `path text` is a single denormalized cache; the canonical structure is `child_of` edges.
|
||||
|
||||
For projax UI to keep showing multi-paths ("Also at: work.paliad"), the store-adapter layer (§4) re-derives `paths[]` from the edge graph on each fetch. Cheap at m's scale (≤200 nodes); cache lightly if profiling bites.
|
||||
|
||||
### `projax.item_links` → mBrian edges
|
||||
|
||||
Each `item_links` row becomes a mBrian edge with a typed `rel`. The `ref_id` semantics differ:
|
||||
|
||||
| projax ref_type | mBrian shape | notes |
|
||||
|---|---|---|
|
||||
| `caldav-list` | edge `rel='projax-caldav-list'`, `metadata.url=...` | external URL — no target node exists; edge carries the URL in `note` or `metadata` |
|
||||
| `gitea-repo` | edge `rel='projax-gitea-repo'`, metadata={owner, repo} | same shape |
|
||||
| `gitea-issue` | edge `rel='projax-gitea-issue'`, metadata={owner, repo, number} | same |
|
||||
| `mai-project` | edge `rel='projax-mai-project'`, metadata={mai_project_id} | bridge for the Phase 1.5 bidirectional sync |
|
||||
| `mbrian-node` | edge `(source=this, rel='related_to', target=<mbrian uuid>)` | already mBrian — this becomes a regular node-to-node edge |
|
||||
| `url` | edge `rel='projax-url'`, metadata={url} | unstructured link |
|
||||
| `document`, `note` | edge `rel='projax-doc'`, metadata={...} | PER day-granular dated artifacts |
|
||||
|
||||
mBrian edges support `note text` plus an `auto bool` flag. Both used by projax: `auto=false` for human-added links, `note` carries human annotation. The structured payload (URL, repo info, etc.) lands in a metadata jsonb that we add via a new `edges.metadata` column — see §3.
|
||||
|
||||
### Open question on edge payloads
|
||||
|
||||
mBrian's `edges` table today has no `metadata jsonb` column — only `rel`, `note`, `sort_order`, `node_id`, `auto`. For projax's typed external-ref payloads (caldav URLs, gitea repo names), we need either:
|
||||
- (a) Add `metadata jsonb` to `mbrian.edges` (mBrian-side schema work, see §3 Q-A).
|
||||
- (b) Use the `node_id` "complex edge" feature: the edge points at a third node that holds the metadata. Heavier per-link cost; one node per external ref.
|
||||
- (c) Stash structured payload inside `note text` as JSON. Hacky; loses index-ability.
|
||||
|
||||
**Inventor pick: (a)** — adds one nullable column to `edges`, indexes optionally, keeps the simple shape and matches projax's existing item_links model.
|
||||
|
||||
---
|
||||
|
||||
## §3 — mBrian-side requirements (re-baselined against live schema)
|
||||
|
||||
Head verified the live mBrian schema after m's answers. Three of the original six asks turned out already-satisfied. What's actually needed reduces to one [schema] convention node + ownership of the one-shot data-migration script. Per m's Q4=(a), this lands as a Gitea issue on `m/mBrian` with the "blocks projax phase 6" tag; head files it.
|
||||
|
||||
### Already satisfied (no DDL needed)
|
||||
|
||||
| original ask | live-schema status |
|
||||
|---|---|
|
||||
| MB-A — `edges.metadata jsonb` column | **Already exists** — added in `db/010_flexsiebels_compat.sql`: `ALTER TABLE mbrian.edges ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'` plus GIN `idx_edges_metadata`. Already used by mig 039/040. projax link payloads land here directly. |
|
||||
| MB-C — `'project'` type registration | **Already exists** — confirmed in `db/033` + inbox tests. m's Q1=(a) reuses it. |
|
||||
| MB-C — `'area'` type registration | **NOT needed** — per m's "keep the database simple," areas reuse `type=['project']` with `metadata.projax.kind='area'`. Zero DDL. |
|
||||
| MB-D — per-user slug uniqueness | **Already enforced** — `CREATE UNIQUE INDEX idx_nodes_slug ON mbrian.nodes (user_id, slug)` in `db/001`. Handles the bulk migration as-is, modulo the pre-write dedup pass in the script (§7). |
|
||||
| MB-E — read MCP coverage | **Confirmed** by head — type-array filter, edge query by `rel` + source/target, FTS search all present in mBrian's MCP today. Optional bulk "node + outbound edges" endpoint may improve adapter perf, but v1 ships without it. |
|
||||
| MB-F — write MCP coverage | **Confirmed** by head — create_node, update_node, soft-delete, create_edge, delete_edge all present. |
|
||||
|
||||
### Remaining mBrian-side artifact
|
||||
|
||||
**MB-B — projax-integration `[schema]` convention node.** One new mBrian node, no DDL. Lives under a new `[topic]` hub `projax-integration`. Documents:
|
||||
|
||||
1. The projax edge relations: `child_of` (already in use everywhere), `projax-caldav-list`, `projax-gitea-repo`, `projax-gitea-issue`, `projax-mai-project`, `projax-url`, `projax-doc`. Each entry: rel name + the metadata jsonb shape (e.g. `projax-caldav-list` carries `{url: text}`).
|
||||
2. The projax type usage: `'project'` for both projects and areas; `metadata.projax.kind` distinguishes (`area` vs default `project`). `'mai-managed'` as a co-type marker for nodes mirroring `mai.projects` rows.
|
||||
3. The projax metadata shape: `metadata.projax.{status, tags, management, public, timeline_exclude, start_time, end_time, kind}` — the subset of projax columns that don't have a first-class mBrian counterpart.
|
||||
4. A pointer to `projax_origin` audit metadata (set per migrated node, per m's Q11=keep).
|
||||
|
||||
mBrian-side coder writes this node by creating it via mBrian's editor or MCP. No migration file needed.
|
||||
|
||||
### mBrian owns the data-migration script
|
||||
|
||||
Per m's directive "mbrian must own the migration," the one-shot script that creates the 47 nodes + their edges lives in `m/mBrian` (likely `scripts/migrate-from-projax.ts` or similar — mBrian's stack picks). projax-side provides:
|
||||
|
||||
- A frozen snapshot of `projax.items` + `projax.item_links` rows (CSV or JSON dump produced by a projax-side helper).
|
||||
- The mapping rules from §2 + §2.2 in a form mBrian-side can implement against (this plan doc is the canonical source).
|
||||
- A spot-check checklist (5 representative items) for post-migration validation.
|
||||
|
||||
The script's blast radius lives on mBrian's side; projax-side blocks on its successful run before slice C kicks off.
|
||||
|
||||
### Cross-repo coordination shape
|
||||
|
||||
One Gitea issue on `m/mBrian` (filed by head), tagged "blocks projax phase 6". The issue body covers MB-B + script ownership + the snapshot-handoff protocol. Body draft delivered to head with this re-baseline (see Phase A workflow §14).
|
||||
|
||||
---
|
||||
|
||||
## §4 — projax-side read-path replacement
|
||||
|
||||
The store package becomes a thin adapter over mBrian. Consumers stay shape-stable: `*store.Item` still exposes Kind / Title / Slug / Paths / ParentIDs / ContentMD / Aliases / Metadata / Status / Pinned / Archived / Tags / Management / Public* / TimelineExclude / etc. Internally those come from mBrian nodes + metadata + edge-walks.
|
||||
|
||||
| projax call site | new implementation |
|
||||
|---|---|
|
||||
| `store.Store.ListAll(ctx)` | mBrian: `SELECT FROM mbrian.nodes WHERE 'projax' = ANY(metadata.projax_origin) ... ORDER BY title` (or via MCP `list_nodes`). Returns []*Item adapted from each node. |
|
||||
| `store.Store.GetByPath(ctx, path)` | resolve path → leaf node by walking `child_of` edges from the path's root segment; cache hits during render |
|
||||
| `store.Store.GetByID(ctx, id)` | direct mBrian fetch |
|
||||
| `store.Store.LinksByRefType(ctx, t)` | edge query `rel='projax-<t>'` over all projax-managed nodes |
|
||||
| `store.Store.AllTags(ctx)` | aggregate over `metadata.projax.tags` arrays across projax nodes |
|
||||
| `store.Store.MaiOrphans(ctx)` | mBrian: find projax-managed nodes with no `child_of` edge + `metadata.projax.management contains 'mai'` |
|
||||
| `store.Store.DatedLinks(ctx, id)` | edge query `rel IN ('projax-doc', 'projax-url')` for the node, filtered to those with `metadata.event_date` set |
|
||||
|
||||
The aggregator (`internal/aggregate/`) doesn't see mBrian — it gets `[]*store.Item` from the adapter. CalDAV + Gitea external fetches stay where they are.
|
||||
|
||||
Views (Phase 5j `projax.views` table) decision point — see Q5.
|
||||
|
||||
### Adapter layer surface
|
||||
|
||||
```go
|
||||
package store
|
||||
|
||||
type Store struct {
|
||||
mb *mbrian.Client // MCP-style client or direct SQL
|
||||
}
|
||||
|
||||
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) { ... }
|
||||
// every existing method keeps its signature; bodies rewrite to mBrian calls
|
||||
```
|
||||
|
||||
The Item struct stays unchanged. Tests against the adapter assert "given this mBrian state, ListAll returns these items". Existing aggregator + handler tests stay green because they only see `*Item`.
|
||||
|
||||
---
|
||||
|
||||
## §5 — projax-side write-path replacement
|
||||
|
||||
Every projax write rewires to mBrian.
|
||||
|
||||
| projax handler | new behaviour |
|
||||
|---|---|
|
||||
| `POST /i/{path}` (detail edit, `handleDetailWrite`) | mBrian update_node + edge re-write for `parent_ids` changes |
|
||||
| `POST /new` (`handleNewSubmit`) | mBrian create_node + `child_of` edges |
|
||||
| `POST /i/{path}/reparent` (`handleReparent`) | edge delete + re-create for `child_of` |
|
||||
| `/admin/bulk` (`handleBulkApply`, `handleBulkChip`) | bulk mBrian updates; one mBrian write per row |
|
||||
| `/admin/classify` (`handleClassify`) | mBrian update + add `child_of` edge |
|
||||
| `POST /views/...` (5j editor) | unchanged if views stay in `projax.views`; rewired if they move (Q5) |
|
||||
| MCP `create_item` / `update_item` / `delete_item` | mBrian MCP create / update / soft_delete |
|
||||
| MCP `add_link` / `remove_link` | mBrian create_edge / delete_edge |
|
||||
|
||||
### Validation (Phase 5c itemwrite package)
|
||||
|
||||
The pre-flight validator stays as projax-handler logic — projax UI / MCP still surface friendly errors for `KindInvalidSlugFormat` / `KindSlugCollision` / `KindCycle` / etc. before round-tripping. The DB-level enforcement moves to mBrian's per-user unique index on slug (covers collision) + projax's `paths` recomputation (covers cycle detection). Trigger-level cycle detection on mBrian's edges is a mBrian-side ask (mb-G optional).
|
||||
|
||||
### Cycle + slug-collision semantics
|
||||
|
||||
Per §2.1: projax loses per-parent slug uniqueness; per-user uniqueness wins. The validator's KindSlugCollision rule needs updating to reject any duplicate slug across the whole projax-managed set, not just under the same parent.
|
||||
|
||||
Cycle detection: projax today does it via the path trigger (cycle = self-ancestor). After migration, projax fetches all projax nodes + their child_of edges, walks the closure on every write, rejects cycles. Cheap at m's scale.
|
||||
|
||||
---
|
||||
|
||||
## §6 — Integrations (CalDAV / Gitea / mai.projects)
|
||||
|
||||
### CalDAV + Gitea
|
||||
|
||||
The link bundle (per §2.2) moves to mBrian edges with structured metadata. The CalDAV / Gitea **clients** + their caches stay projax-side (the aggregator owns these). The render path queries mBrian for "which items have caldav-list edges + what URLs," then fans out to the existing CalDAV client. Net effect: the fan-out stays where it is; only the source of "what to fan out for" changes.
|
||||
|
||||
### mai.projects bidirectional sync (Phase 1.5)
|
||||
|
||||
The Phase 1.5 trigger pair (mai.projects ↔ projax.items) is the most fragile piece of the integration today. After Phase 6:
|
||||
|
||||
- (a) **Keep the trigger pair**, pointing the mai.projects view at the migrated mBrian nodes. Requires rewriting the trigger functions to read from mBrian; significant complexity because mai.projects expects projax.items columns.
|
||||
- (b) **Move the bridge to projax handler layer**: a sync worker watches mai.projects changes + writes mBrian; mBrian node changes flow back via a webhook or periodic poll. Slower but decoupled.
|
||||
- (c) **Drop the bridge entirely**: mai.projects becomes legacy; mai workers consume mBrian directly via MCP. Cleanest, but requires mai-side work to migrate workers/tasks/sessions FKs.
|
||||
|
||||
**Inventor pick: (b)** — the bridge stays operational without bleeding mBrian schema details into mai.projects code, and m can sunset it gradually. (c) is the right long-term shape but it's another migration project; out of scope for Phase 6.
|
||||
|
||||
This is **Q2** for m.
|
||||
|
||||
---
|
||||
|
||||
## §7 — Migration mechanics (mBrian-owned)
|
||||
|
||||
Per m's Q7=(a) hard-cut + Q4=(a) "mbrian must own the migration": the one-shot script lives in `m/mBrian`. projax-side provides the input snapshot + the rules in this doc; mBrian-side owns the execution.
|
||||
|
||||
### projax-side input snapshot
|
||||
|
||||
A helper command in `cmd/projax-snapshot/main.go` produces a `projax_snapshot.json` containing every live `projax.items` row + every `projax.item_links` row, shaped for direct consumption by the mBrian-side script. One file, deterministic, round-trippable. Ships in slice 0 (the snapshot handoff, see §8).
|
||||
|
||||
### mBrian-side script outline (for the m/mBrian issue body)
|
||||
|
||||
1. Load `projax_snapshot.json`.
|
||||
2. Two-pass: pass 1 creates every node; pass 2 writes every edge (parent edges + item_links → projax-* edges).
|
||||
3. For each item:
|
||||
a. New mBrian uuid OR preserve the projax uuid (mBrian-side picks; either works given m's Q11 audit metadata is the durable reference).
|
||||
b. INSERT into `mbrian.nodes` with `type=['project']` (or `['project']` + co-type per `kind`), `title`, `slug`, `aliases`, `metadata={projax: {...}, projax_origin: <old_id>}`.
|
||||
c. Where projax had multiple paths (same node under multiple parents), DEDUPE by slug — one node, multiple `child_of` edges.
|
||||
4. For each parent edge: INSERT `mbrian.edges (source=new_id, target=parent_new_id, rel='child_of')`.
|
||||
5. For each item_links row: INSERT `mbrian.edges` with `rel='projax-<ref_type>'` and `metadata` carrying the structured payload per §2.2.
|
||||
6. For projax.views (5j): NOT migrated — per m's Q5=(a), the views table stays projax-resident.
|
||||
7. Smoke check: count(mbrian.nodes WHERE metadata->>'projax_origin' is not null) == count(items in snapshot).
|
||||
8. Hand off to projax with the new uuid map (`{old_uuid: new_uuid}`) so projax-side caches can warm.
|
||||
|
||||
### Idempotency
|
||||
|
||||
Pre-flight: the script checks `metadata.projax_origin` and skips already-migrated origins on re-run. m can re-run safely if the script aborts mid-way.
|
||||
|
||||
### Lossy bits (acceptable per m's stance)
|
||||
|
||||
- `paths text[]` array is not preserved — projax-side adapter recomputes from edges per §4.
|
||||
- mai.projects mirror rows: per Q2=(b), a handler-layer bridge worker re-syncs after migration; the Phase 1.5 trigger pair stays disabled.
|
||||
|
||||
### Blast-radius containment
|
||||
|
||||
mBrian-side runs the script with triggers paused, smoke-checks the count + spot-checks the 5 representative items in projax's checklist, then commits + signals projax-side to start slice C (read-path).
|
||||
|
||||
---
|
||||
|
||||
## §8 — Implementation slicing (re-baselined)
|
||||
|
||||
Six slices. The big shift from the original draft: the mBrian-side ask compresses to one [schema] convention node + one migration script (both mBrian-owned per m's Q4). Slice 0 is a small projax-side helper that ships the snapshot. The hard gate is the migration landing — projax-side B reads it as the trigger to start.
|
||||
|
||||
- **0. projax-side snapshot helper** — `cmd/projax-snapshot/main.go`. Dumps live `projax.items` + `projax.item_links` to `projax_snapshot.json`. Ships first; minimal risk; deliverable mBrian needs.
|
||||
|
||||
- **A. mBrian-side: [schema] convention node + data-migration script** — m/mBrian owns. The [schema] node lives under a new `[topic]` hub `projax-integration`. The script consumes the snapshot from slice 0 and writes 47ish nodes + their edges per §7. mBrian-side post-flight: smoke-check count + spot-check 5 items per the projax checklist.
|
||||
|
||||
- **B. projax-side read-path adapter** — projax-side. `store/` package rewired against mBrian's MCP / SQL surface. The `Item` struct stays; method bodies rewrite. All UI + aggregator tests stay green (they only see Item shape). Per-request snapshot cache to avoid N+1 calls. Reads-only soak before slice C.
|
||||
|
||||
- **C. projax-side write-path** — projax-side. Every handler + MCP write rewires through the adapter to mBrian. itemwrite validator updates for the per-user slug rule (Q6). Cycle detection on the in-memory closure (Q9).
|
||||
|
||||
- **D. mai.projects bridge worker** — projax-side (Q2=(b)). Disable the Phase 1.5 trigger pair; ship a small worker that observes mai.projects writes + reflects them into mBrian, and vice versa. Decoupled, killable.
|
||||
|
||||
- **E. Drop `projax.items` + `projax.item_links`** — projax-side. Migration `0018_drop_projax_items.sql`. Triggers off after one shift's stable read+write soak on mBrian. `projax.views` stays (Q5).
|
||||
|
||||
Dependency graph:
|
||||
|
||||
```
|
||||
0 (projax snapshot) ──→ A (mBrian [schema] node + migration script run)
|
||||
│
|
||||
▼
|
||||
B (projax read-path) ──→ C (projax write-path)
|
||||
│
|
||||
├──→ D (mai bridge worker)
|
||||
▼
|
||||
E (drop projax tables)
|
||||
```
|
||||
|
||||
Slice 0 unblocks A. A is mBrian-owned and the hard gate for everything else. B → C can ship together if green; otherwise B-first soak.
|
||||
|
||||
CalDAV / Gitea integrations stay where they are (Q3=(a)) — no slice F needed in the original sense.
|
||||
|
||||
---
|
||||
|
||||
## §9 — Cross-repo coordination (settled)
|
||||
|
||||
Per m's Q4=(a) + his words *"mbrian must own the migration"*:
|
||||
|
||||
1. **Protocol**: file a Gitea issue on `m/mBrian` with "blocks projax phase 6" tag. Routed via otto/head per global Channel Routing. Head files it; kahn drafts the body.
|
||||
|
||||
2. **Ownership split**:
|
||||
- mBrian-side owns: the `[schema]` convention node (MB-B) + the one-shot data-migration script.
|
||||
- projax-side owns: the snapshot helper (slice 0), the read-path adapter (slice B), the write-path (slice C), the mai bridge (slice D), the table drop (slice E).
|
||||
|
||||
3. **Sequencing**: slice 0 produces the snapshot → mBrian-side A consumes it + runs the migration → mBrian-side signals back → projax-side starts B. The Gitea issue is the durable trace; the delegation reply chain is the real-time signal.
|
||||
|
||||
4. **Design-doc sharing**: this plan stays in `m/projax`. The m/mBrian issue body (drafted alongside this re-baseline, delivered to head) excerpts §2 (schema mapping), §3 (the one [schema] node ask), §7 (the script outline), and the spot-check checklist.
|
||||
|
||||
---
|
||||
|
||||
## §10 — Open questions (all answered 2026-05-29)
|
||||
|
||||
All 11 questions resolved. m confirmed every inventor pick. Section retained as the historical record + so a future hand can audit the decision rationale.
|
||||
|
||||
|
||||
|
||||
The 8 from issue #5 plus what surfaced during this survey.
|
||||
|
||||
**Q1 — mBrian node type for projax items**
|
||||
- (a) Reuse existing `'project'` type, add `'area'` if missing, multi-typed for both. — **inventor pick** (existing type minimises mBrian-side churn).
|
||||
- (b) New dedicated `'projax-item'` / `'work-item'` type.
|
||||
|
||||
**Q2 — mai.projects bidirectional sync disposition** (§6)
|
||||
- (a) Keep the trigger pair (rewrite to read from mBrian).
|
||||
- (b) Move to projax handler-layer bridge worker. — **inventor pick** (clean decoupling).
|
||||
- (c) Drop entirely; migrate mai-side FKs.
|
||||
|
||||
**Q3 — CalDAV + Gitea integration ownership** (§6)
|
||||
- (a) Clients + caches stay projax-side; only the "which items have these links" lookup moves to mBrian. — **inventor pick** (minimal change to aggregator).
|
||||
- (b) Migrate CalDAV/Gitea ownership to mBrian edges + projax becomes a pure renderer.
|
||||
|
||||
**Q4 — mBrian head contact protocol** (§9)
|
||||
- (a) Through otto/head per Channel Routing (default per global rule). — **inventor pick**.
|
||||
- (b) Direct to a future mBrian/head worker.
|
||||
- (c) m himself owns mBrian schema work — file Gitea issue on m/mBrian.
|
||||
|
||||
**Q5 — projax.views (5j) disposition**
|
||||
- (a) Keep as projax-resident table — views are projax-UI state, not graph data. — **inventor pick**.
|
||||
- (b) Migrate to mBrian nodes with type=`[view]`; one node per saved view.
|
||||
- (c) Drop the table; user views become a derived shape from mBrian metadata on the items themselves.
|
||||
|
||||
**Q6 — Slug uniqueness model**
|
||||
- (a) Adopt mBrian's per-user unique (loses "two paliads under different roots" case). — **inventor pick** (simpler; m hasn't used the per-parent split in practice).
|
||||
- (b) Keep projax's per-parent rule via projax-handler validator + mBrian per-user check disabled for projax nodes (requires mBrian-side scoped-uniqueness work).
|
||||
|
||||
**Q7 — Migration mechanics** (§7)
|
||||
- (a) Hard-cut, one script, accept data loss. — **inventor pick** (matches m's stance).
|
||||
- (b) Phased dual-write + soak.
|
||||
|
||||
**Q8 — Tags model**
|
||||
- (a) Keep tags in `metadata.projax.tags` (projax sees them as before; mBrian doesn't index them). — **inventor pick** for v1.
|
||||
- (b) Lift each tag to a `[tag]` node + `tagged` edges (mBrian convention).
|
||||
- (c) Hybrid — keep metadata for projax compatibility AND wire tagged-edges for mBrian visibility.
|
||||
|
||||
Q8(c) is the "right" long-term shape but doubles the write surface in slice D. Recommend deferring to a Phase 7 polish.
|
||||
|
||||
**Q9 — Cycle detection placement**
|
||||
- (a) projax-handler-side via in-memory closure walk before write. — **inventor pick** (cheap at m's scale).
|
||||
- (b) mBrian-side via trigger on `edges` (mb-G ask).
|
||||
|
||||
**Q10 — Projax MCP surface**
|
||||
- (a) Keep projax MCP tools (`mcp__projax__*`); they now route through the adapter. — **inventor pick** (no MCP client change).
|
||||
- (b) Sunset projax MCP; users call mBrian MCP directly.
|
||||
|
||||
**Q11 — `projax_origin` audit metadata** (§7)
|
||||
Per the migration script, every migrated node carries `metadata.projax_origin = <old uuid>`. Keep indefinitely (audit trail), purge after one shift (cleanup), or never write it (trust). **Inventor pick**: keep indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## §11 — Risk register
|
||||
|
||||
| risk | likelihood | mitigation |
|
||||
|---|---|---|
|
||||
| mBrian-side schema work (slice A) blocks projax indefinitely | medium | clear delegation + Gitea issue with "blocks projax phase 6" tag; m can dispatch fast-track |
|
||||
| 47-item migration script silently drops fields | low | smoke check (item count parity) + spot-check 5 items post-migration before slice C |
|
||||
| Slug collision on multi-rooted items (e.g. two `paliad`s) | medium | pre-migration script: detect collisions, dedupe to one node with multiple `child_of` edges, log skips |
|
||||
| mai.projects trigger pair breaks mid-migration | medium | turn off the triggers before migration, rebuild post-migration (Q2 (b) bridge takes over) |
|
||||
| Adapter introduces N+1 mBrian calls during render | medium | one ListAll + one LinksByRef query per request, cached per-request; profile after slice C |
|
||||
| Phase 5j views surface breaks | low | views stay projax-resident per inventor pick on Q5; no migration cost |
|
||||
| flexsiebels.de public-listing renderer breaks | medium | metadata.projax.public.* bundle preserves the shape; spot-test before slice E |
|
||||
| Cross-repo coordination delay | medium | filed as Gitea issue (durable) + delegation (real-time signal); both paths active |
|
||||
|
||||
---
|
||||
|
||||
## §12 — Test plan headlines
|
||||
|
||||
### Slice B (migration script)
|
||||
- `TestMigrateScriptSmokes` — 5 hand-crafted projax.items + 3 item_links → mBrian nodes + edges; count parity assertion.
|
||||
- `TestMigrateScriptIdempotent` — second run = no new nodes.
|
||||
- `TestMigrateScriptSlugCollision` — two multi-rooted items same slug → one node with two `child_of` edges, log entry.
|
||||
|
||||
### Slice C (read-path)
|
||||
- `TestAdapterListAllReturnsItemsFromMBrian` — seed mBrian nodes with `projax_origin`, ListAll returns matching Items.
|
||||
- `TestAdapterGetByPathResolvesEdges` — `dev.paliad` walks `child_of` edges to leaf node.
|
||||
- `TestAdapterPathsArrayMultiRoot` — node with two `child_of` edges produces 2 entries in `it.Paths`.
|
||||
|
||||
### Slice D (write-path)
|
||||
- `TestHandleDetailWriteUpdatesMBrian` — POST /i/dev.paliad updates the mBrian node's title.
|
||||
- `TestHandleReparentRewritesChildOf` — POST /i/dev.paliad/reparent deletes old edge + creates new one.
|
||||
- `TestSlugCollisionRejected` — second create with same slug rejected with KindSlugCollision.
|
||||
|
||||
### Slice E (drop)
|
||||
- migration `0018_drop_projax_items.sql` smoke test: `\dt projax.*` returns only `projax.views` + `projax.schema_migrations`.
|
||||
|
||||
### Slice F (integrations)
|
||||
- per Q2 answer — bridge-worker test (Option b) OR mai-FK migration test (Option c).
|
||||
|
||||
---
|
||||
|
||||
## §13 — References
|
||||
|
||||
- `~/dev/mBrian/db/001_initial_schema.sql` — mBrian schema baseline.
|
||||
- `~/dev/mBrian/docs/schema.md` — schema doc.
|
||||
- `~/dev/mBrian/CLAUDE.md` — mBrian conventions + relation to flexsiebels.
|
||||
- `projax/store/store.go` — current Item struct + projax store API.
|
||||
- `projax/store/views.go` — Phase 5j views table.
|
||||
- `projax/docs/design.md` — current PRD.
|
||||
- `projax/docs/plans/views-redesign.md` — Phase 5j design.
|
||||
- `m/projax` issue #5 — m's Option A pick.
|
||||
|
||||
---
|
||||
|
||||
## §14 — Status
|
||||
|
||||
- **Phase A (this doc)**: drafted by kahn 2026-05-29, re-baselined same day against live mBrian schema after m's 11 answers landed. All §10 questions resolved.
|
||||
- **m/mBrian Gitea issue**: body drafted; head files it under "blocks projax phase 6" tag.
|
||||
- **Phase B (projax-side coder)**: blocked on (1) slice 0 snapshot helper ships + (2) mBrian-side migration runs + signals back. NO coder flip yet.
|
||||
- **Slice 0 (projax-side snapshot helper)**: scoped, not yet built. Smallest first-step on projax-side; ready when head greenlights.
|
||||
- **No code changes** in this branch beyond this doc.
|
||||
228
docs/plans/slice-b-adapter-contract.md
Normal file
228
docs/plans/slice-b-adapter-contract.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# Phase 6 Slice B — read-path adapter contract
|
||||
|
||||
**Status**: prep work (this doc). No implementation.
|
||||
**Branch**: `mai/kahn/phase-6-sliceB-prep`.
|
||||
**Author**: kahn (coder, prep mode), 2026-05-29.
|
||||
**Parent plan**: `docs/plans/mbrian-backend-migration.md` (on `main`).
|
||||
**Scope boundary**: contract + compile-checking skeleton only. The mBrian-backed implementation waits on m/mBrian#73 landing the migration + handing over the uuid map.
|
||||
|
||||
---
|
||||
|
||||
## §1 — Consumer inventory
|
||||
|
||||
Every read-path call site against `*store.Store` and the projax-shaped `Item` / `ItemLink` types. The interface (§2) is the union of these.
|
||||
|
||||
### §1.1 — `*store.Store` read methods (source: `store/store.go`)
|
||||
|
||||
| method | signature | semantics |
|
||||
|---|---|---|
|
||||
| `ListAll` | `(ctx) ([]*Item, error)` | every live item, ordered by `paths NULLS FIRST, slug` |
|
||||
| `GetByID` | `(ctx, id) (*Item, error)` | single item by uuid |
|
||||
| `GetByPath` | `(ctx, path) (*Item, error)` | resolve `dev.paliad` style path to leaf item |
|
||||
| `GetByPathOrSlug` | `(ctx, key) (*Item, error)` | path first, fall back to bare slug |
|
||||
| `Roots` | `(ctx) ([]*Item, error)` | items with `cardinality(parent_ids) = 0` |
|
||||
| `MaiOrphans` | `(ctx) ([]*Item, error)` | mai-managed root items needing classify |
|
||||
| `ListByFilters` | `(ctx, SearchFilters) ([]*Item, error)` | structured search (status / mgmt / has-link / paths-prefix) |
|
||||
| `Search` | `(ctx, q, limit) ([]*Item, error)` | trigram + FTS title/content/aliases |
|
||||
| `AllTags` | `(ctx) ([]string, error)` | union of every item's tags |
|
||||
| `LinksByType` | `(ctx, itemID, refType) ([]*ItemLink, error)` | one item's links of a given `ref_type` (empty = all) |
|
||||
| `LinksByRefType` | `(ctx, refType) ([]*ItemLink, error)` | every link of a given ref_type across items |
|
||||
| `DatedLinks` | `(ctx, itemID) ([]*ItemLink, error)` | one item's links anchored to a date (PER artifacts) |
|
||||
| `DatedLinksRange` | `(ctx, from, to) ([]*ItemLinkWithItem, error)` | dated links within window, joined with their item |
|
||||
| `RecentDocuments` | `(ctx, since, limit) ([]*ItemLinkWithItem, error)` | recent dated docs, joined with their item |
|
||||
| `ItemsCreatedInRange` | `(ctx, from, to) ([]*Item, error)` | items created within window |
|
||||
|
||||
### §1.2 — Consumer call sites (by file)
|
||||
|
||||
Each row = one read-path call site. Direct Pool access (admin.go counts, bulk.go filter-tx, links.go event-date update) is flagged separately at the bottom — those rework targets are out of slice B's read-path scope.
|
||||
|
||||
| consumer | method | use case |
|
||||
|---|---|---|
|
||||
| `web/server.go handleTree` | `ListAll`, `AllTags`, `linkKindsByItem` (LinksByRefType ×N) | render /views/tree with chip-counted forest |
|
||||
| `web/server.go handleDetail` | `GetByPath` ×2 (PER fallback), `LinksByType` (caldav), `DatedLinks` | render /i/{path} detail page |
|
||||
| `web/server.go parentOptions` | `ListAll` | populate parent <select> on /new + /reparent |
|
||||
| `web/server.go handleClassify` | `MaiOrphans`, `parentOptions` | render /admin/classify |
|
||||
| `web/dashboard.go handleDashboard` | `ListAll`, `LinksByRefType` (caldav), `LinksByType` (gitea) ×N, `RecentDocuments` | Tiles + tasks + events + docs cards |
|
||||
| `web/calendar.go handleCalendar` | `ListAll` | month grid scope |
|
||||
| `web/timeline.go handleTimeline` + `buildTimeline` | `ListAll`, `linkKindsByItem` | chronological spine |
|
||||
| `web/graph.go handleGraph` | `ListAll`, `AllTags` | DAG SVG render |
|
||||
| `web/bulk.go handleBulk` | `ListAll`, `AllTags`, `GetByID` | /admin/bulk filtered checklist |
|
||||
| `web/caldav.go` (admin + create/unlink) | `ListAll`, `LinksByRefType`, `LinksByType`, `GetByPath` | /admin/caldav surface |
|
||||
| `web/gitea.go detailIssues` | `LinksByType` (gitea-repo) | /i/{path} issues card |
|
||||
| `web/gitea_writeback.go` | `GetByPath`, `LinksByType` | issue close/comment/create handlers |
|
||||
| `web/links.go` (add/remove/list) | `GetByPath`, `DatedLinks` | /i/{path} documents section |
|
||||
| `web/dashboard_pin.go` | `SetPinned` — WRITE, not slice B | pin toggle (slice C) |
|
||||
| `web/views.go handleViewRender` | `ListAll`, `AllTags`, `linkKindsByItem` | /views/{slug} render (5j) |
|
||||
| `web/system_views.go legacyRedirect` | `GetViewByID` — views CRUD (NOT in scope) | legacy 5i uuid → 5j slug redirect |
|
||||
| `internal/aggregate aggregator.go` | takes `LinkLister` interface (LinksByType + ItemsCreatedInRange) | shared fan-out across tasks/events/issues/docs |
|
||||
| `mcp/tools.go` (read tools) | `ListByFilters`, `LinksByRefType`, `GetByID`, `GetByPathOrSlug`, `LinksByType`, `ListAll`, `Search`, `RecentDocuments` (via dashboard fan-out reuse) | every read-side MCP tool |
|
||||
|
||||
### §1.3 — Direct Pool access (out-of-scope for slice B, flagged for slice C)
|
||||
|
||||
These bypass the store API and pull `*pgxpool.Pool` directly. Slice C (write-path) reworks them; flagging here so slice B's interface stays minimal:
|
||||
|
||||
- `web/admin.go` — three count queries (`SELECT count(*) FROM projax.items WHERE …`) for the admin index. Either: (a) add `Counts(ctx) (AdminCounts, error)` to the adapter, (b) compute in-handler from `ListAll`. Adapter pick.
|
||||
- `web/bulk.go handleBulkApply` — multi-row UPDATE inside a tx. Pure write; slice C.
|
||||
- `web/links.go handleSetEventDate` — single UPDATE on `item_links.event_date`. Pure write; slice C.
|
||||
|
||||
### §1.4 — `*Item` + `*ItemLink` shape contract (consumer side)
|
||||
|
||||
Adapter MUST return these exact field sets in the result types. Nothing under `metadata.projax.*` in mBrian leaks to consumers; the adapter parses + materialises into the `Item` fields below.
|
||||
|
||||
| field | semantics in slice B adapter |
|
||||
|---|---|
|
||||
| `Item.ID` | mBrian node uuid (post-migration); preserved old uuid OK per Q11 |
|
||||
| `Item.Kind` | `[]string{"project", ...}` — mBrian `node.type[]` 1:1 |
|
||||
| `Item.Title`, `Item.Slug`, `Item.ContentMD`, `Item.Aliases` | mBrian `node.title/slug/content_md/aliases` 1:1 |
|
||||
| `Item.Paths` | **derived** from `child_of` edge walk + the node's own slug. Adapter computes per-call (cached per-request) |
|
||||
| `Item.ParentIDs` | **derived** from outbound `child_of` edges |
|
||||
| `Item.Metadata` | `node.metadata` MINUS the `projax` sub-key (which gets unpacked into the struct fields below) |
|
||||
| `Item.Status` | `node.metadata.projax.status` (default "active") |
|
||||
| `Item.Pinned`, `Item.Archived` | `node.pinned`, `node.archived` 1:1 |
|
||||
| `Item.StartTime`, `Item.EndTime` | `node.metadata.projax.start_time` / `.end_time` (timestamptz strings) |
|
||||
| `Item.Tags`, `Item.Management`, `Item.TimelineExclude` | `node.metadata.projax.tags` / `.management` / `.timeline_exclude` |
|
||||
| `Item.Public`, `Item.PublicDescription`, `Item.PublicLiveURL`, `Item.PublicSourceURL`, `Item.PublicScreenshots` | `node.metadata.projax.public.{enabled, description, live_url, source_url, screenshots}` |
|
||||
| `Item.CreatedAt`, `Item.UpdatedAt` | `node.created_at`, `node.updated_at` 1:1 |
|
||||
| `Item.Source` | always `"projax"` (legacy field; new adapter sets this to maintain consumer assumption) |
|
||||
| `Item.SourceRefID` | mai.projects.id from `projax-mai-project` edge metadata when present |
|
||||
| `ItemLink.ID` | mBrian edge uuid |
|
||||
| `ItemLink.ItemID` | edge `source_id` (the projax-side end) |
|
||||
| `ItemLink.RefType` | strip `projax-` prefix from edge `rel` (`projax-caldav-list` → `caldav-list`) |
|
||||
| `ItemLink.RefID` | edge `metadata.ref_id` OR derived from rel-specific payload (caldav: `url`; gitea-repo: `owner/repo`; mai-project: `mai_project_id`) — see §3 gaps |
|
||||
| `ItemLink.Rel` | edge `note` (free-form annotation) OR a constant per rel-type (e.g. `'contains'`) |
|
||||
| `ItemLink.Metadata` | edge `metadata` MINUS the `ref_id` extraction |
|
||||
| `ItemLink.EventDate` | edge `metadata.event_date` (date string parsed) |
|
||||
| `ItemLink.CreatedAt` | edge `created_at` 1:1 |
|
||||
|
||||
### §1.5 — Views (Phase 5j) — explicitly NOT in slice B
|
||||
|
||||
Per m's Q5=(a), `projax.views` stays projax-resident. All view CRUD methods (`ListViews`, `GetView`, `GetViewByID`, `CreateView`, `UpdateView`, `DeleteView`, `TouchView`, `MostRecentView`, `ReorderViews`) stay on the existing `*Store` and are NOT part of the adapter interface. The `Server` struct uses the adapter for items+links and the existing `Store` for views.
|
||||
|
||||
---
|
||||
|
||||
## §2 — Adapter interface contract
|
||||
|
||||
Defined in `store/adapter.go` (this branch). Pure projax-shaped structs in/out; zero mBrian type leakage. The existing `*store.Store` already satisfies this interface (it's just a subset of its public surface) — the compile-time assertion makes that explicit. Slice B impl ships a second satisfier (`*MBrianReader`) that wraps mBrian access.
|
||||
|
||||
```go
|
||||
// ItemReader is the read-only contract every projax UI handler / aggregator /
|
||||
// MCP read tool depends on. Slice B implements a second satisfier on top of
|
||||
// mBrian's MCP/SQL surface.
|
||||
type ItemReader interface {
|
||||
// Item lookups
|
||||
ListAll(ctx context.Context) ([]*Item, error)
|
||||
GetByID(ctx context.Context, id string) (*Item, error)
|
||||
GetByPath(ctx context.Context, path string) (*Item, error)
|
||||
GetByPathOrSlug(ctx context.Context, key string) (*Item, error)
|
||||
Roots(ctx context.Context) ([]*Item, error)
|
||||
MaiOrphans(ctx context.Context) ([]*Item, error)
|
||||
ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error)
|
||||
Search(ctx context.Context, q string, limit int) ([]*Item, error)
|
||||
ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error)
|
||||
AllTags(ctx context.Context) ([]string, error)
|
||||
|
||||
// Link lookups
|
||||
LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error)
|
||||
LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error)
|
||||
DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error)
|
||||
DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error)
|
||||
RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error)
|
||||
}
|
||||
```
|
||||
|
||||
### §2.1 — Methods needing edge-walk-derived data
|
||||
|
||||
Slice B's mBrian impl must compute these from `child_of` edges + node fields. Cost is one outbound-edges fetch per node OR one bulk edges-by-rel query per request, depending on how the adapter caches.
|
||||
|
||||
- `Item.Paths` — every method returning `*Item` or `[]*Item`.
|
||||
- `Item.ParentIDs` — same.
|
||||
- `GetByPath` — walks edges to resolve `dev.paliad` to a leaf node.
|
||||
- `Roots` — filter where no outbound `child_of` edge.
|
||||
- `MaiOrphans` — `Roots` ∩ `metadata.projax.management ⊇ {'mai'}`.
|
||||
|
||||
### §2.2 — Methods needing metadata-unpack
|
||||
|
||||
Adapter parses `metadata.projax.*` on read; writes (slice C) re-serialise. Affected fields: Status, Tags, Management, TimelineExclude, Public + 4 public_* fields, StartTime, EndTime.
|
||||
|
||||
### §2.3 — Methods needing edge.metadata filters
|
||||
|
||||
- `LinksByType(itemID, refType)`: WHERE source_id=$1 AND rel = 'projax-' || $2.
|
||||
- `LinksByRefType(refType)`: WHERE rel = 'projax-' || $1.
|
||||
- `DatedLinks(itemID)`: source_id=$1 AND metadata ? 'event_date'.
|
||||
- `DatedLinksRange(from, to)`: metadata->>'event_date' BETWEEN $1 AND $2.
|
||||
- `RecentDocuments(since, limit)`: dated links since $1 ORDER BY metadata->>'event_date' DESC LIMIT $2.
|
||||
|
||||
mBrian's `idx_edges_metadata` GIN index already exists (mig 010); these queries are index-eligible.
|
||||
|
||||
---
|
||||
|
||||
## §3 — Gap flags
|
||||
|
||||
Items the known mBrian schema needs to satisfy cleanly. The migration script handles most; flag here for the slice-B impl + the migration worker as cross-check items.
|
||||
|
||||
| gap | shape | status |
|
||||
|---|---|---|
|
||||
| **`item_links.rel` (free-form annotation) preservation** | projax has both a typed `ref_type` AND a free-form `rel` text (`"contains"`, `"source"`, etc.) on item_links. mBrian's edge `rel` is the typed name; the free-form annotation maps to `edge.note`. Migration must NOT drop the projax `rel` value. | Add to m/mBrian#73 §1 edge mapping: source `rel` → mBrian `edges.note`. |
|
||||
| **`ItemLink.RefID` semantics per type** | projax `ref_id` is a typed external pointer (caldav url, gitea `owner/repo`, gitea-issue id, mai project uuid, bare url). mBrian edges carry the payload in metadata. Need a per-rel-type extraction rule. Suggested: `metadata.ref_id` for the canonical reference + leaves structured payload alongside (`url` for caldav, `owner`/`repo` for gitea). | Slice B impl reads back per-rel-type; document in m/mBrian#73 issue for the migration script to write consistently. |
|
||||
| **`paths text[]` recomputation cost** | Adapter computes paths from `child_of` edge walk per call. For `ListAll` over ~65 items, one bulk edges-by-rel query joined with the node id set is N rows where N = total `child_of` edges. Cheap at m's scale; add per-request memoisation. | Slice B impl. No mBrian-side action. |
|
||||
| **`AllTags` aggregation** | Union of `metadata.projax.tags[]` across all projax-managed nodes. No mBrian index on metadata-array-element. At m's scale (<200 nodes), full-scan is fine; if we grow, add a derived `[tag]` node graph (m's Q8 deferred to Phase 7). | Slice B impl, no mBrian-side action. |
|
||||
| **`Roots` / `MaiOrphans` predicate** | "No outbound `child_of` edge" requires a subquery / left-join-where-null pattern. Index-eligible via `idx_edges_source_rel` on `(source_id, rel)`. | Slice B impl. |
|
||||
| **`ItemsCreatedInRange`** | Direct over `nodes.created_at`; trivial. Scoped to `metadata.projax_origin IS NOT NULL` so non-projax mBrian nodes don't leak into projax surfaces. | Slice B impl + a `metadata GIN` query (already indexed). |
|
||||
| **`Item.Source` field expectation** | The legacy `Source` field on `Item` reads `"projax"` everywhere consumers check it (some MCP tools branch on it). Adapter sets a constant. | Slice B impl detail, no DB action. |
|
||||
| **`SourceRefID` for mai bridge** | When a node has a `projax-mai-project` edge, expose its `metadata.mai_project_id` as `Item.SourceRefID`. Slice D (mai bridge worker) writes these edges. | Slice B impl reads existing edges; slice D writes new ones. |
|
||||
| **`ItemLinkWithItem` join shape** | Used by `DatedLinksRange` and `RecentDocuments`. Adapter does two queries (edges-with-dates + node-by-id batch) + an in-memory join, OR one combined MCP call if mBrian exposes a bulk-edges-with-source-node helper. Both work; pick by perf. | Slice B impl, no mBrian-side change required. |
|
||||
| **Admin counts (web/admin.go direct Pool)** | Three count(*) queries (total items, total mai-managed, total public). Adapter gains `Counts(ctx) (AdminCounts, error)` — small extension. | Add to ItemReader interface in slice B (low-risk; constant-return until impl) OR keep as a separate `AdminReader` interface. Recommend adding to ItemReader for cohesion. |
|
||||
|
||||
---
|
||||
|
||||
## §4 — Skeleton (this branch)
|
||||
|
||||
The Go file `store/adapter.go` ships in this branch with:
|
||||
|
||||
1. `ItemReader` interface as in §2.
|
||||
2. `var _ ItemReader = (*Store)(nil)` compile-time assertion. (Drops in cleanly because `*Store` already exposes every method in the contract.)
|
||||
3. `MBrianReader` struct with stubbed method bodies that return `errNotImplementedSliceB`. Each stub carries a one-line comment naming the §3 gap it depends on (if any) so slice B's impl-fill knows what to look up.
|
||||
4. `var _ ItemReader = (*MBrianReader)(nil)` compile-time assertion so the stubs stay aligned with the interface.
|
||||
|
||||
`go build ./...` is green with the skeleton in place. No tests, no behaviour, no mBrian client dependency.
|
||||
|
||||
The actual mBrian client wiring (whether MCP-over-stdio, direct Postgres against `mbrian.*` schema, or the in-process submodule pattern flexsiebels uses) is the first decision slice-B-impl makes; it stays out of this prep step.
|
||||
|
||||
---
|
||||
|
||||
## §5 — Wiring shape after slice B impl
|
||||
|
||||
For reference of the post-slice-B shape (no code in this slice):
|
||||
|
||||
```go
|
||||
// Server struct keeps two readers: ItemReader (slice-B mBrian-backed) +
|
||||
// existing *Store (views CRUD only).
|
||||
type Server struct {
|
||||
Items ItemReader // slice B: MBrianReader; today: *Store
|
||||
Store *store.Store // views CRUD only after slice B
|
||||
// ... rest unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Every handler that today reads `s.Store.ListAll(...)` becomes `s.Items.ListAll(...)`. Mechanical rename. Slice B impl ships both — adapter wiring + the rename across handlers — as one diff once the migration completes.
|
||||
|
||||
---
|
||||
|
||||
## §6 — What's NOT in this prep
|
||||
|
||||
- mBrian-MCP client wiring.
|
||||
- Any test of mBrian-backed behaviour.
|
||||
- Write-path methods (slice C scope).
|
||||
- View CRUD migration (Q5=(a) stays projax-resident).
|
||||
- mai bridge worker (slice D).
|
||||
- Drop projax tables (slice E).
|
||||
|
||||
---
|
||||
|
||||
## §7 — References
|
||||
|
||||
- `docs/plans/mbrian-backend-migration.md` (on `main`) — parent plan.
|
||||
- `cmd/projax-snapshot/` (slice 0, merged at `38182df`) — input for mBrian's migration.
|
||||
- m/mBrian#73 — mBrian-side schema convention node + migration script (in flight).
|
||||
- `store/store.go` — current `*Store` implementation; the interface `*Store` already satisfies.
|
||||
- `internal/aggregate/aggregator.go` — existing `LinkLister` interface precedent (a narrow projection of `*Store`).
|
||||
103
docs/plans/slice-b-views-projectid-gap.md
Normal file
103
docs/plans/slice-b-views-projectid-gap.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Slice B gap — `projax.views.filter_json.project_id` uuid-map
|
||||
|
||||
**Status**: gap flagged + tooling shipped. Must run remap BEFORE Slice E
|
||||
drops `projax.items`.
|
||||
**Branch**: `mai/kahn/phase-6-sliceB`.
|
||||
**Author**: kahn (coder), 2026-05-31.
|
||||
**Parent**: `docs/plans/mbrian-backend-migration.md` (§7 lossy bits).
|
||||
|
||||
## The gap
|
||||
|
||||
Phase 5j saved views (`projax.views.filter_json`) carry `project_id` set
|
||||
to the **OLD** `projax.items.id` uuid. After Slice B's read path flips
|
||||
to mBrian, those uuids no longer resolve — a saved view scoped to
|
||||
`dev.paliad` references an id that mBrian's `paliad` node doesn't have
|
||||
(the migration script issued a fresh uuid + recorded the old id under
|
||||
`metadata.projax_origin` per m's Q11).
|
||||
|
||||
Symptoms once `PROJAX_BACKEND=mbrian` rolls in production:
|
||||
- Opening `/views/{slug}` for any saved view that carries
|
||||
`filter_json.project_id` returns the unfiltered set (the scope filter
|
||||
silently no-ops).
|
||||
- The chip rendering still labels the view "scoped to dev.paliad"
|
||||
because the cached `project_path` is unaffected — the discrepancy
|
||||
surfaces as "label says scope, content doesn't filter."
|
||||
|
||||
This must be fixed before Slice E drops `projax.items` or the old-id
|
||||
provenance disappears.
|
||||
|
||||
## Two viable remediations
|
||||
|
||||
### (a) One-shot uuid remap via the migration audit map
|
||||
|
||||
mBrian dropped the `{old_projax_uuid → new_mbrian_uuid}` map at a shared
|
||||
mRiver path (head will relay). One-time SQL:
|
||||
|
||||
```sql
|
||||
UPDATE projax.views
|
||||
SET filter_json = jsonb_set(
|
||||
filter_json,
|
||||
'{project_id}',
|
||||
to_jsonb(<new>::text)
|
||||
)
|
||||
WHERE filter_json->>'project_id' = '<old>';
|
||||
```
|
||||
|
||||
…iterated across the map. Pure data fix, no schema change. Idempotent
|
||||
(re-running against an already-remapped row is a no-op because the old
|
||||
uuid no longer matches).
|
||||
|
||||
### (b) Resolve by slug instead of id
|
||||
|
||||
Change the views resolver to look up `metadata.projax_origin = <id>`
|
||||
when the new uuid doesn't resolve, OR store the slug in
|
||||
`filter_json.project_path` (already present) as the canonical pointer.
|
||||
Heavier change in the views read path; ships in a future slice.
|
||||
|
||||
## Recommended path
|
||||
|
||||
**Hybrid**: (a) for the existing rows now (idempotent, surgical), and
|
||||
flag (b) as a follow-up in slice C/D for new view writes (use slug
|
||||
instead of uuid in the editor — the slug is the durable name post-
|
||||
migration anyway).
|
||||
|
||||
## Tool shipped this slice
|
||||
|
||||
`cmd/projax-remap-views/main.go` (this commit). Usage:
|
||||
|
||||
```
|
||||
projax-remap-views --map /path/to/uuid-map.json
|
||||
```
|
||||
|
||||
Map shape:
|
||||
```json
|
||||
{
|
||||
"old-projax-uuid-1": "new-mbrian-uuid-1",
|
||||
"old-projax-uuid-2": "new-mbrian-uuid-2",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The tool:
|
||||
- Loads the uuid map from JSON.
|
||||
- Walks every live `projax.views` row.
|
||||
- For each row with `filter_json.project_id` matching a map key,
|
||||
rewrites it to the new uuid.
|
||||
- Prints a per-row before/after summary; commit only happens on
|
||||
`--apply`. Default is dry-run.
|
||||
|
||||
Idempotent on re-run: rows already pointing at a new uuid don't match
|
||||
any map key on the second pass, so they pass through untouched.
|
||||
|
||||
## When to run
|
||||
|
||||
After the mBrian migration completes (already shipped) + after head
|
||||
relays the uuid-map path. Before any user is on
|
||||
`PROJAX_BACKEND=mbrian`. The tool runs against msupabase using the same
|
||||
`SUPABASE_DATABASE_URL` the projax binary uses.
|
||||
|
||||
## Validation
|
||||
|
||||
Post-remap: pick the spot-check saved views (if any exist), open them
|
||||
through both `PROJAX_BACKEND=store` and `PROJAX_BACKEND=mbrian`, confirm
|
||||
the filtered set matches.
|
||||
139
docs/plans/slice-c-writepath-contract.md
Normal file
139
docs/plans/slice-c-writepath-contract.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Phase 6 Slice C — write-path adapter contract
|
||||
|
||||
**Status**: implemented, branch `mai/kahn/phase-6-sliceC`, pending head review + cutover.
|
||||
**Author**: kahn (coder), 2026-06-01.
|
||||
**Parent plan**: `docs/plans/mbrian-backend-migration.md` (§5 write-path).
|
||||
**Sibling**: `docs/plans/slice-b-adapter-contract.md` (read-path, merged).
|
||||
**Write mechanism**: mBrian scoped HTTP write API — m/mBrian#73 issuecomment-10720.
|
||||
|
||||
---
|
||||
|
||||
## §1 — What slice C fixes
|
||||
|
||||
Slice B flipped only the reader: `PROJAX_BACKEND=mbrian` made `srv.Items`
|
||||
read mBrian while writes still went to `projax.items`. A read-then-write
|
||||
round-trip (create a child of an mBrian-read parent, reparent, edit) read
|
||||
an mBrian uuid and then wrote it against `projax.items`, which rejected it
|
||||
("parent id does not resolve to a live item"). Production was rolled back
|
||||
to `PROJAX_BACKEND=store`.
|
||||
|
||||
Slice C adds the write twin so the env flag selects **both** reader and
|
||||
writer **atomically**. There is no longer any code path where reads use
|
||||
one backend and writes the other — on the web UI **or** the MCP surface.
|
||||
|
||||
## §2 — The write mechanism (settled by mbrian/head)
|
||||
|
||||
Option (c): a scoped HTTP write API on mBrian, NOT direct SQL and NOT
|
||||
SECURITY DEFINER functions. mBrian's slug generation / collision
|
||||
resolution / singleton logic lives in `db.ts`; reimplementing it in SQL
|
||||
would drift. projax writes POST/PATCH/DELETE through the API, which reuses
|
||||
`db.ts`, so projax-born nodes are byte-identical to UI/MCP/migration nodes.
|
||||
|
||||
`MBrianWriter` is therefore an **HTTP client**, not a pgx writer. Reads stay
|
||||
direct-DB (`MBrianReader`, slice B). The asymmetry is deliberate.
|
||||
|
||||
Scoping guard: every node the API touches must carry
|
||||
`metadata.projax_origin`; non-projax nodes (m's journal/contacts/health)
|
||||
return 403. New projax nodes mint a fresh `projax_origin` uuid client-side.
|
||||
|
||||
Config (projax-side env): `PROJAX_MBRIAN_API_URL` + `PROJAX_MBRIAN_API_TOKEN`.
|
||||
Base URL `https://mbrian.x.msbls.de`, Tailscale-only, bearer auth.
|
||||
|
||||
### Endpoints
|
||||
| method | path | body | success |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/projax/nodes` | `{title, content_md?, aliases?, mai_managed?, projax_origin, projax:{…}}` | 201 `{id, slug}` |
|
||||
| PATCH | `/api/projax/nodes/{id}` | `{title?, content_md?, aliases?, projax?:{…partial}}` | 200 `{id, slug}` |
|
||||
| DELETE | `/api/projax/nodes/{id}` | — | 204 (soft-delete) |
|
||||
| POST | `/api/projax/edges` | `{source, target, rel, metadata?}` | 201/200 `{id}` |
|
||||
| DELETE | `/api/projax/edges` | `{source, target, rel}` | 204 |
|
||||
|
||||
Errors (all `{"error": "..."}`): 400 malformed/disallowed-rel, 401 bad
|
||||
token, 403 not projax-owned, 404 not found, 500 db.ts error, 503 token not
|
||||
configured server-side (fail-closed → "write backend not ready").
|
||||
|
||||
## §3 — ItemWriter interface (`store/adapter.go`)
|
||||
|
||||
Extracted from `*Store`'s write surface; two satisfiers — `*Store`
|
||||
(legacy) and `*MBrianWriter` (HTTP).
|
||||
|
||||
```
|
||||
Create / Update / Reparent / AddParent / SetPublic / SetPinned /
|
||||
SoftDelete / SoftDeleteCascade — item writes
|
||||
AddLink / AddLinkDated / DeleteLink — link writes (projax-* self-edges)
|
||||
```
|
||||
|
||||
`Server.Writes store.ItemWriter` is the twin of `Server.Items`. Both default
|
||||
to the concrete `*Store` in `web.New`; `main.go` overrides both atomically.
|
||||
|
||||
### Per-method mBrian mapping
|
||||
- **Create** → POST node (mint `projax_origin`), POST `child_of` edge per
|
||||
parent, then read back via the reader so the returned `Item.ID` is the
|
||||
live mBrian uuid + derived path (the round-trip fix).
|
||||
- **Update** → PATCH node fields + `syncParents` (child_of edge diff) + read-back.
|
||||
- **Reparent / AddParent** → child_of edge diff / idempotent add.
|
||||
- **SetPublic** → read-modify-write the full `projax.public` object (PATCH
|
||||
shallow-merge replaces the whole sub-object).
|
||||
- **SoftDeleteCascade** → projax-side descendant resolution via the reader's
|
||||
derived paths, then per-node DELETE (HTTP API is single-node; no tx).
|
||||
- **AddLink/AddLinkDated** → POST self-edge `(source=target=item,
|
||||
rel='projax-<refType>')`, payload shaped in edge metadata so the reader's
|
||||
`linkFromEdge` round-trips (typed payload + `projax_rel` + `ref_id` +
|
||||
`event_date` + `note`).
|
||||
- **DeleteLink** → resolve `(source,target,rel)` from the edge id via a
|
||||
direct read-back, then DELETE; guards against deleting >1 edge sharing
|
||||
the tuple.
|
||||
|
||||
### Validator (`internal/itemwrite`)
|
||||
`ValidateAgainstStore` already takes a `Reader` (GetByID + ListAll), which
|
||||
`ItemReader` satisfies — handlers now pass `s.Items`, so cycle + collision
|
||||
checks run against the live backend, not stale `projax.items`. Slug
|
||||
uniqueness changed per-parent → **per-user-global** (Q6=a, matching
|
||||
mBrian's `idx_nodes_slug`). Strictly tighter, so still correct on legacy.
|
||||
|
||||
### /admin/bulk
|
||||
`applyBulk` no longer runs a raw single-tx multi-row UPDATE: make_public/
|
||||
private → `SetPublic`; tag/mgmt/status/timeline-exclude → read-modify-write
|
||||
via `Update`. Cross-row tx atomicity is dropped (no multi-node tx on the
|
||||
HTTP API); acceptable at m's bulk-edit scale, one write path both backends.
|
||||
|
||||
## §4 — MCP scope correction
|
||||
|
||||
The handover deferred MCP read migration. That is incompatible with
|
||||
atomicity once MCP **writes** move: an MCP client could `get_item` (read)
|
||||
then `update_item` (write) and hit the slice-B bug on the MCP surface. So
|
||||
slice C migrates the **whole** MCP item/link surface — `RegisterProjaxTools`
|
||||
takes `(reader, writer, legacy *Store, agg)`; read tools use the reader,
|
||||
write tools reader+writer, both flipping with `PROJAX_BACKEND`. The
|
||||
`timeline` tool keeps the legacy `*Store` + aggregator (out of scope,
|
||||
consistent with the web dashboard/timeline).
|
||||
|
||||
## §5 — Known API gaps (flagged to head; reconcile before relying on them)
|
||||
|
||||
Current data hits **none** of the active gaps (verified: 43 mai-project +
|
||||
37 gitea-repo + 1 caldav link, all one-per-(item,ref_type); no docs/issues).
|
||||
|
||||
| id | severity | gap |
|
||||
|---|---|---|
|
||||
| **G1** | latent | Edges key on `(source,target,rel)` only. POST is idempotent on that tuple and DELETE removes by it — an item can't hold >1 link of the same ref_type (multiple dated docs, gitea-issues, calendars). 0 such cases today. Needs edge-by-id ops, or `(…,ref_id)` identity, or accept-the-limitation. `DeleteLink` refuses (doesn't multi-delete) if the case ever arises. |
|
||||
| **G2** | medium | POST `/edges` has no `note` field; `AddLinkDated`'s note rides in `metadata.note`, but the reader reads `ItemLink.Note` from the `edge.note` column. Notes added post-cutover won't surface until the reader also reads `metadata.note` (or the API gains a note field). |
|
||||
| **G3** | active | PATCH exposes no `pinned`/`archived`; the reader reads them from node columns. The writer captures them in `metadata.projax.{pinned,archived}` so intent isn't lost, but the dashboard star / archive toggle won't round-trip until the reader falls back to `metadata.projax` (recommended — keeps them alongside status/tags) or the API exposes the columns. |
|
||||
| **G4** | minor | Create has no arbitrary top-level metadata passthrough; `CreateInput.Metadata` keys outside the projax bundle are dropped (no current caller sets them). |
|
||||
|
||||
## §6 — The critical test
|
||||
|
||||
The read→write→read round-trip the slice-B cutover missed: create a child
|
||||
of an mBrian-read parent → persists → renders. This needs the live API +
|
||||
DB, so it is head's cutover step (head holds the credential, sets it in
|
||||
Dokploy, smoke-tests from projax). Unit tests cover the HTTP mechanics
|
||||
(`store/mbrian_writer_test.go`): request construction, error mapping
|
||||
(401/403/404/503), fail-closed-without-token, edge/link shaping. The reader
|
||||
parity tests cover materialisation.
|
||||
|
||||
## §7 — Out of scope (later slices)
|
||||
- Aggregator still consumes `*Store` (dashboard/timeline rollups) — slice D+.
|
||||
- Views CRUD stays projax-resident (`*Store`, Q5=a) — not in ItemWriter.
|
||||
- mai.projects bridge worker — slice D.
|
||||
- Drop `projax.items` / `projax.item_links` — slice E.
|
||||
- `web/admin.go` count queries still hit `projax.items` via Pool directly
|
||||
(read-path, flagged in slice-B doc §1.3) — not slice C write scope.
|
||||
496
docs/plans/views-redesign.md
Normal file
496
docs/plans/views-redesign.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# Views redesign — paliad-shape first-class views (Phase 5j)
|
||||
|
||||
**Status**: Phase A design (this doc).
|
||||
**Branch**: `mai/kahn/phase-5j-views-redesign`.
|
||||
**Author**: kahn (inventor), 2026-05-26.
|
||||
**Source feedback** (m, 13:19 2026-05-26): *"It's not really what I wanted. It should like the paliad custom views, not of the existing views a variant but individually created views."*
|
||||
|
||||
**Replaces**: Phase 5i. Hours-old, no real data, drop-and-rebuild is the cleanest path.
|
||||
|
||||
---
|
||||
|
||||
## §1 — Diagnosis: why 5i diverged from intent
|
||||
|
||||
5i modelled views as an **overlay** on top of existing pages. The contract was:
|
||||
|
||||
> User opens `/?view=<uuid>` → the saved filter+view_type fields onto whatever the existing tree handler renders.
|
||||
|
||||
That choice flowed from m's original phrasing: "view types (card / list / calendar / kanban)" — which sounded like skin-on-top-of-pages. Implementation followed: TreeFilter grew a `ViewID`, an `applySavedView` overlay landed in the tree handler, the sidebar `Views` entry pointed to `/views` as a list-management page, and saved views had no URL of their own.
|
||||
|
||||
m's **actual** mental model, anchored in paliad: a view IS a page. The slug goes in the URL. System defaults (dashboard, calendar, timeline, ...) and user-created views share the same `/views/{slug}` route shape. Nothing is "an overlay" — views are first-class destinations, indexed in the sidebar, with their own editor.
|
||||
|
||||
The fix: tear out the 5i overlay code and rebuild around the paliad model. This redesign mirrors paliad's structure but adapts to projax's constraints (single-user, no auth.uid(), no RLS, existing route surface).
|
||||
|
||||
---
|
||||
|
||||
## §2 — paliad-shape data model for projax
|
||||
|
||||
### Schema (migration `0017_views_redesign.sql`)
|
||||
|
||||
**Recommendation: hard-replace.** Drop `projax.views` (created hours ago in 5i Slice D), recreate fresh. No real user data lost — at most a couple of throwaway saved-view rows from m's testing.
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS projax.views CASCADE;
|
||||
|
||||
CREATE TABLE projax.views (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text NOT NULL,
|
||||
name text NOT NULL,
|
||||
icon text, -- nullable; matches frontend icon registry
|
||||
filter_json jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
view_type text NOT NULL, -- card | list | calendar | kanban | timeline
|
||||
sort_field text,
|
||||
sort_dir text,
|
||||
group_by text,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
show_count boolean NOT NULL DEFAULT false,
|
||||
last_used_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT views_view_type_chk
|
||||
CHECK (view_type IN ('card','list','calendar','kanban','timeline')),
|
||||
CONSTRAINT views_sort_dir_chk
|
||||
CHECK (sort_dir IS NULL OR sort_dir IN ('asc','desc')),
|
||||
CONSTRAINT views_kanban_needs_group
|
||||
CHECK (view_type <> 'kanban' OR group_by IS NOT NULL),
|
||||
CONSTRAINT views_slug_format_chk
|
||||
CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,62}$')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX views_slug_uniq ON projax.views (slug);
|
||||
CREATE INDEX views_sort_order_idx ON projax.views (sort_order, name);
|
||||
|
||||
-- updated_at trigger reused from 0016 (kept under a new name or recreated).
|
||||
CREATE OR REPLACE FUNCTION projax.views_touch_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at := now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
DROP TRIGGER IF EXISTS views_touch_updated_at ON projax.views;
|
||||
CREATE TRIGGER views_touch_updated_at
|
||||
BEFORE UPDATE ON projax.views
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.views_touch_updated_at();
|
||||
```
|
||||
|
||||
### Key shifts from 5i
|
||||
|
||||
| field | 5i | 5j | reason |
|
||||
|---|---|---|---|
|
||||
| primary key | uuid only | uuid; **slug is the URL key** | paliad parity — URLs use slugs, not uuids |
|
||||
| slug | absent | required, unique, regex-validated | URL routability |
|
||||
| icon | absent | nullable text | sidebar icon picker |
|
||||
| sort_order | absent | server-assigned MAX+1 | drag-reorder; paliad parity |
|
||||
| show_count | absent | bool, opt-in | sidebar row-count badge; opt-in cost |
|
||||
| last_used_at | absent | nullable timestamptz | `/views` landing MRU redirect |
|
||||
| pinned | bool | **dropped** | `sort_order` subsumes the use case |
|
||||
| is_default_for | text page | **dropped** | per-page-default model gone; MRU replaces it |
|
||||
|
||||
### `filter_json` shape
|
||||
|
||||
Unchanged from 5i (the JSON shape stayed correct). Keys mirror TreeFilter dims: `q`, `tags[]`, `management[]`, `status[]`, `has_links[]`, `public`, `show_archived`, `project_path`, `include_descendants`. The shape is forward-compatible; new TreeFilter dimensions land without migrations.
|
||||
|
||||
`view_type` stays a top-level column (not inside `filter_json`) because the editor + sidebar both read it without needing to parse JSON.
|
||||
|
||||
### Single-user simplifications vs paliad
|
||||
|
||||
- **No `user_id` column** — projax is Tailscale-only single-user.
|
||||
- **No RLS** — same reason.
|
||||
- **`UNIQUE (slug)` is global**, not per-user.
|
||||
|
||||
If multi-user ever lands, the column + index gain a `user_id` prefix; the rest of the design holds.
|
||||
|
||||
---
|
||||
|
||||
## §3 — Reserved slugs (system views)
|
||||
|
||||
The big call: **do existing pages become system views, or do they stay distinct routes?**
|
||||
|
||||
### Three options
|
||||
|
||||
**(a) Keep current routes; add /views/{slug} for user views only.**
|
||||
- `/`, `/dashboard`, `/calendar`, `/timeline`, `/graph` stay exactly as today.
|
||||
- `/views/{slug}` is exclusively for user-created views.
|
||||
- Reserved-slug list is just `{new, edit}` (the literal route segments) + any future top-level URL we'd not want a user view to shadow.
|
||||
- **Cost**: nothing changes for muscle memory. User views are an additive concept beside existing pages.
|
||||
- **Drawback**: the conceptual asymmetry m flagged stays — system pages live at `/`/`/dashboard`, user views live at `/views/{slug}`. Two URL families.
|
||||
|
||||
**(b) Full migration. Existing pages become system views at `/views/{slug}`.**
|
||||
- New URLs: `/views/tree`, `/views/dashboard`, `/views/calendar`, `/views/timeline`, `/views/graph` (or drop graph from the unified shape — see §3.1).
|
||||
- Legacy `/`, `/dashboard`, etc. become 301 redirects to their `/views/{slug}` counterpart.
|
||||
- Reserved slugs: `{tree, dashboard, calendar, timeline, graph, new, edit, admin, login, logout, healthz, mcp, static, i, views}` — everything projax owns at the top level.
|
||||
- **Cost**: every internal link in templates needs updating; bookmarks 301 (fine); browser muscle memory absorbs after one shift.
|
||||
- **Benefit**: one URL family. The "create a new view" mental model is uniform with how system pages live.
|
||||
|
||||
**(c) Hybrid. Legacy routes stay; `/views/{slug}` aliases system pages and hosts user views.**
|
||||
- `/` keeps serving the tree; **also** `/views/tree` resolves to the same handler.
|
||||
- `/dashboard` keeps; also `/views/dashboard`. Etc.
|
||||
- Reserved slugs match (b) for the same coverage.
|
||||
- User views land at `/views/{their-slug}` alongside system slugs in one URL family.
|
||||
- **Cost**: small — system-view handlers register two route entries instead of one. No redirects to maintain.
|
||||
- **Benefit**: muscle memory + bookmark stability AND first-class /views/{slug} URL family. Two paths to the same render; user picks whichever they remember. If `/views/{slug}` catches on, a future shift can deprecate the legacy URLs cleanly.
|
||||
|
||||
### Inventor pick: (c) hybrid
|
||||
|
||||
**Reasoning**: m's bug report explicitly said "individually created views" — the gap was user-view first-classness, not legacy-URL banishment. (c) closes the gap with zero migration cost. (b) is cleaner architecturally but introduces avoidable churn; the upside (one URL family) doesn't outweigh the risk of breaking some link or muscle-memory in m's daily flow. (a) leaves the two-families asymmetry m's feedback was pointing at.
|
||||
|
||||
This is **Q1 in §9** — head should ratify or override before coder.
|
||||
|
||||
### §3.1 — Graph as a system view?
|
||||
|
||||
Graph is the DAG SVG render. It's NOT in the view_type enum (per 5i design, intentionally — graph is its own visualization, not a "list of items rendered as X"). Recommend: keep `/graph` and `/views/graph` (under (c)) but **graph is not a user-creatable view_type** — the create form omits it. Reserved slug `graph` blocks user views from clobbering it.
|
||||
|
||||
### Reserved-slug list (combining (c) + projax's existing top-level routes)
|
||||
|
||||
```go
|
||||
var reservedViewSlugs = []string{
|
||||
// System pages (also reachable via /views/<slug> as aliases under (c)):
|
||||
"tree", "dashboard", "calendar", "timeline", "graph",
|
||||
// /views sub-routes:
|
||||
"new", "edit",
|
||||
// Top-level application URLs:
|
||||
"admin", "login", "logout", "healthz", "mcp", "static", "i", "views",
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §4 — Routes
|
||||
|
||||
For option (c). Under (b), drop the legacy entries; under (a), drop the `/views/{system-slug}` aliases.
|
||||
|
||||
| route | handler | renders | semantics |
|
||||
|---|---|---|---|
|
||||
| `GET /views` | `handleViewsLanding` | 302 to MRU view, else onboarding shell | landing |
|
||||
| `GET /views/{slug}` | `handleViewRender` | view template per view_type | render saved or system view |
|
||||
| `GET /views/new` | `handleViewEditor` | editor blank | editor — new |
|
||||
| `GET /views/{slug}/edit` | `handleViewEditor` | editor pre-filled | editor — edit existing |
|
||||
| `POST /views` | `handleViewCreate` | redirect to `/views/{slug}` | create |
|
||||
| `POST /views/{slug}` | `handleViewUpdate` | redirect to `/views/{slug}` | update |
|
||||
| `POST /views/{slug}/delete` | `handleViewDelete` | redirect to `/views` | delete |
|
||||
| `POST /views/reorder` | `handleViewReorder` | 204 / HTMX OK | drag-reorder (slice G) |
|
||||
| `POST /views/{slug}/touch` | `handleViewTouch` | 204 fire-and-forget | bump last_used_at on render |
|
||||
|
||||
The render path (`GET /views/{slug}`):
|
||||
1. Resolve slug. If a user view → load row. If a reserved system slug → load the corresponding code-resident `SystemView` struct.
|
||||
2. Touch `last_used_at` (user views only — system views don't track MRU per call).
|
||||
3. Dispatch to the view_type's renderer (the same per-view-type templates from 5i: `tree_card.tmpl`, `tree_kanban.tmpl`, `tree_section.tmpl` for list, plus the existing `calendar_section.tmpl` and `timeline_section.tmpl`).
|
||||
4. Apply chip-overlay semantics from the 5i fix — URL chips overlay the saved filter so chip clicks narrow within the view (the one piece of 5i worth keeping; see §7).
|
||||
|
||||
Editor (`GET /views/new` and `GET /views/{slug}/edit`) is a dedicated full-page form, not a modal. Paliad shipped dedicated pages; projax inherits the same shape.
|
||||
|
||||
---
|
||||
|
||||
## §5 — Sidebar integration
|
||||
|
||||
Replace the single "Views" sidebar entry (5i) with a "Views" section listing every user view. System views stay in the existing main-nav block at the top; they're already the muscle-memory entries (Tree, Dashboard, Calendar, Timeline, Graph).
|
||||
|
||||
ASCII sketch (5g sidebar shape, with 5j additions):
|
||||
|
||||
```
|
||||
[ sidebar ]
|
||||
─────────────
|
||||
⌂ Tree
|
||||
□ Dashboard
|
||||
▣ Calendar
|
||||
⊿ Timeline
|
||||
⨀ Graph
|
||||
─────────────
|
||||
Views ← new section header
|
||||
📂 Active mai work ← user view (icon + name)
|
||||
⏰ This week deadlines ← row-count badge if show_count
|
||||
★ Patents kanban ← drag-reorder handle on hover
|
||||
+ New view ← /views/new
|
||||
─────────────
|
||||
⚙ Admin
|
||||
─────────────
|
||||
☾ Theme
|
||||
```
|
||||
|
||||
The Views section's entries come from `ListViews()` ordered by `sort_order` ASC, then `name`. Each entry:
|
||||
- Icon resolved against a small frontend registry (the icon column is a key; the registry maps it to an SVG). Keys: `folder`, `clock`, `star`, `tag`, `file-text`, `box`, `inbox`, etc. Default key: `folder`.
|
||||
- Optional badge with row count when `show_count=true` — computed by running the view's filter against `ListAll()` (cheap; projax's scale is ~150 items max).
|
||||
- Active state when the current URL is `/views/{this-slug}` or a legacy alias resolving to it.
|
||||
|
||||
Drag-reorder lands in a later slice (G). Click-to-open is the v1 interaction.
|
||||
|
||||
Mobile bottom-nav drawer (5g slice B) gets the same section.
|
||||
|
||||
---
|
||||
|
||||
## §6 — Editor surface
|
||||
|
||||
Single editor template (`templates/view_editor.tmpl`) reused for both `/views/new` and `/views/{slug}/edit`. Distinguishes via the presence of `.View` in the data map.
|
||||
|
||||
Fields:
|
||||
- **Name** — text input, required, max 80 chars.
|
||||
- **Slug** — text input, regex `^[a-z0-9][a-z0-9-]{0,62}$`, **auto-derived** from name via HTMX on `change` against a `POST /views/derive-slug?name=<x>` helper endpoint OR on the client (simpler: derive on the server side in `handleViewCreate` if the field is empty; provide a "regenerate" link in edit mode). m can hand-edit.
|
||||
- **Icon** — `<select>` with the registered icon keys + a visible preview. Slice D ships the form field; the registry SVG additions can grow incrementally.
|
||||
- **View type** — radio group (5 values: card/list/calendar/kanban/timeline).
|
||||
- **Filter (chip strip)** — full TreeFilter chip strip inline in the editor: tag, mgmt, status, has, public, project picker + descendants toggle. Each chip click updates a hidden `filter_json` field via HTMX — so the editor's preview pane reflects the saved filter live.
|
||||
- **Sort field** — text input (`title` / `updated_at` / `start_time`).
|
||||
- **Sort dir** — `<select>` (asc/desc).
|
||||
- **Group by** — `<select>` (status/area/tag/management). Required when view_type=kanban.
|
||||
- **Show count** — checkbox.
|
||||
|
||||
A small "Preview" pane next to the form shows the first N items the filter currently matches. Optional in slice D; can land in slice G if scope bites.
|
||||
|
||||
Save → 302 to `/views/{slug}`. Cancel → `/views` (or the previous URL if HTMX-loaded).
|
||||
|
||||
**Drops the HTMX modal** the 5i fix-shift added — dedicated pages are clearer for a page-level concept and match paliad's pattern.
|
||||
|
||||
---
|
||||
|
||||
## §7 — Migration from 5i overlay
|
||||
|
||||
Specific deletions and salvages:
|
||||
|
||||
### Code to delete
|
||||
| file | what to remove |
|
||||
|---|---|
|
||||
| `web/tree_filter.go` | `ViewID` field on TreeFilter; `ParseTreeFilter`/`QueryString` handling |
|
||||
| `web/views.go` | `applySavedView`, `applyDefaultView`, `overlayURLFields`, `filterQueryToJSON`/`filterJSONToQuery`, the `Prefill` index handler logic |
|
||||
| `web/server.go` | the `?view=<uuid>` overlay block in `handleTree`; the `DefaultBanner` data map field |
|
||||
| `web/templates/tree_section.tmpl` | the `default-banner` block; the `<input type="hidden" name="view">` |
|
||||
| `web/templates/views.tmpl` | full rewrite — it's the list-management surface, redesigned in §5 + §6 |
|
||||
| `web/templates/view_edit.tmpl` | full rewrite to the new editor shape |
|
||||
|
||||
### Code to keep
|
||||
- `templates/tree_card.tmpl`, `templates/tree_kanban.tmpl` — these are per-view_type renderers, reusable.
|
||||
- `web/view_type.go` (the 5-value enum + `PageViewTypes` catalog) — still valid as the renderer dispatch table.
|
||||
- `web/kanban.go` (`BuildKanbanBoard`) — view_type=kanban consumer.
|
||||
- `templates/project_chip.tmpl` — the project filter chip strip works inside the editor.
|
||||
- The 5i chip-overlay-on-saved-view fix is the **one piece of substance** worth keeping conceptually: on `/views/{slug}`, URL chip params overlay the saved filter. The overlay function gets a new home (`handleViewRender`'s filter-resolution path) but the rule is the same.
|
||||
|
||||
### Backwards compatibility for the old `?view=<uuid>` URL
|
||||
|
||||
Two options:
|
||||
- (i) **404 on `?view=`** for existing pages — the URL never makes sense in the new model. Cost: any stale bookmark dies, but only m used it for hours.
|
||||
- (ii) **302-redirect `/<page>?view=<uuid>` to `/views/<slug>`** by looking up the slug from the uuid. Smoother for m's recent bookmarks. Cost: one extra DB hit on the redirect path; the redirect can target the slug or, if the uuid no longer resolves (because we hard-recreated the table), 302 to `/views`.
|
||||
|
||||
Inventor pick: (ii) — small code, no broken bookmarks for the brief 5i window.
|
||||
|
||||
### `is_default_for` semantics
|
||||
|
||||
Drop entirely. The MRU mechanism (`last_used_at` → `/views` landing) replaces "what should I see on /views". Per-page defaults are gone; if m wants a specific view to be the landing experience, he opens it once and it becomes MRU.
|
||||
|
||||
If m later wants a "this is my default" hint stronger than MRU (i.e., pinning), `sort_order=0` reserved for a pinned slot + an `is_pinned` flag is the natural extension. **Not in scope for v1.**
|
||||
|
||||
---
|
||||
|
||||
## §8 — Implementation slicing
|
||||
|
||||
Seven slices; A → B → C → D → E are the critical path; F + G are polish.
|
||||
|
||||
### Slice A — Schema redesign
|
||||
|
||||
- Migration `0017_views_redesign.sql`: `DROP TABLE projax.views CASCADE; CREATE TABLE` with new shape. (See §2 schema.)
|
||||
- `store/views.go`: rewrite. Rename `View.ID` flow to be slug-driven; `GetView(slug)` instead of `GetView(uuid)`. Keep CRUD shape; add `Touch(slug)` for MRU; add `MostRecent()` returning the MRU view (or nil); add `Reorder([]string slugs)` for slice G.
|
||||
- Drop `DefaultViewFor` (no longer applicable).
|
||||
- Tests: round-trip CRUD by slug; reserved-slug rejection at the validator; slug-format regex enforcement; MRU.
|
||||
|
||||
### Slice B — Route migration (paliad-shape)
|
||||
|
||||
- Replace the 5i `/views/<uuid>` routes with the paliad-shape route table from §4.
|
||||
- `handleViewsLanding` → MRU redirect or onboarding shell.
|
||||
- `handleViewRender` → resolve slug (user view first, then system view), apply chip overlay, dispatch to the view_type's renderer.
|
||||
- `handleViewEditor` → dedicated form page (slug-driven).
|
||||
- `handleViewCreate` / `handleViewUpdate` / `handleViewDelete` → form POST handlers.
|
||||
- `handleViewTouch` → fire-and-forget MRU update.
|
||||
- Wire the legacy `?view=<uuid>` redirect (per §7-ii) on existing pages.
|
||||
- Tests: each route hit, slug routing, MRU redirect, onboarding shell on empty state, reserved-slug rejection.
|
||||
|
||||
### Slice C — System views
|
||||
|
||||
- New `web/system_views.go` with `SystemView` struct + `TreeSystemView()`, `DashboardSystemView()`, `CalendarSystemView()`, `TimelineSystemView()`, `AllSystemViews()`, `LookupSystemView(slug)`.
|
||||
- Each function returns the `(filter_json, view_type, group_by, sort)` tuple matching today's page.
|
||||
- `handleViewRender` falls back to `LookupSystemView` when the slug isn't in the DB.
|
||||
- Reserved-slug list (combining system slugs + route segments).
|
||||
- Under (c) hybrid: legacy routes `/`, `/dashboard`, `/calendar`, `/timeline` each gain a sibling registration so `/views/{system-slug}` resolves to the same handler. (Or: legacy routes 302 to `/views/{slug}` — simpler if m's fine with one canonical URL.)
|
||||
- Tests: system-view lookup, slug aliases hit the same template, reserved-slug rejection during user-view create.
|
||||
|
||||
### Slice D — Editor surface
|
||||
|
||||
- New `templates/view_editor.tmpl` — full form per §6.
|
||||
- Slug derivation helper (`POST /views/derive-slug` or server-side fill).
|
||||
- Icon picker (a `<select>` for v1 — frontend registry expansion is incremental).
|
||||
- Inline chip strip inside the form; HTMX updates a hidden `filter_json` on every chip click.
|
||||
- Tests: GET /views/new renders blank form; GET /views/{slug}/edit pre-fills; POST creates/updates round-trip.
|
||||
|
||||
### Slice E — Sidebar integration
|
||||
|
||||
- `templates/layout.tmpl`: insert a "Views" section between main nav and `/admin`.
|
||||
- Server-side: every page-render pulls `ListViews()` into the layout data map (cached lightly so each request doesn't hit the DB twice).
|
||||
- Active-state CSS + icon rendering.
|
||||
- Mobile drawer (5g slice B) gets the same section.
|
||||
- Tests: sidebar shows user views; clicking navigates to `/views/{slug}`; active state matches URL.
|
||||
|
||||
### Slice F — Migration cleanup (delete 5i overlay)
|
||||
|
||||
- Remove TreeFilter.ViewID.
|
||||
- Remove `applySavedView`, `applyDefaultView`, `overlayURLFields`, the default-view banner.
|
||||
- Remove the 5i `/views/<id>` redirect handler (slice B replaces it).
|
||||
- Tests adjusted: drop the `ViewID` round-trip test; drop `TestSavedViewAppliedOnQueryParam`, `TestDefaultViewAppliedOnCleanURL`, `TestViewEditFlow` — their slice-A successors cover the new shapes.
|
||||
|
||||
### Slice G — Polish
|
||||
|
||||
- Drag-reorder UI via HTMX `hx-post="/views/reorder"` with sortable.js or a tiny vanilla drag-handle (m's HTMX-only constraint allows minimal vendored JS if needed).
|
||||
- `show_count` badge wiring (run filter against `ListAll()`, render the count next to the sidebar entry).
|
||||
- Preview pane in the editor (optional).
|
||||
- Icon registry expansion (curated SVGs).
|
||||
|
||||
Slices F and G are independent. The implementation chain is **A → B → C → D → E → (F either before or after E) → G**.
|
||||
|
||||
---
|
||||
|
||||
## §9 — Open questions for head delegation
|
||||
|
||||
Inventor picks marked. Process: **NO direct chip-picker** without head's explicit grant for this round.
|
||||
|
||||
### Q1 — System-view shape (§3)
|
||||
|
||||
(a) Keep current routes only; user views beside them at `/views/{slug}` — current asymmetry stays.
|
||||
(b) Full migration; existing pages become system views, legacy URLs 301-redirect — paliad parity.
|
||||
(c) Hybrid; both URL families coexist, system slugs aliased — preserves muscle memory.
|
||||
|
||||
**Inventor pick**: (c). Closes the asymmetry m flagged, zero migration cost. (b) is cleaner but risks broken bookmarks for thin upside.
|
||||
|
||||
### Q2 — `view_type` field placement
|
||||
|
||||
- (a) Top-level column (5j inventor pick — matches 5i, query-able without parsing JSON).
|
||||
- (b) Inside `filter_json`.
|
||||
|
||||
**Inventor pick**: (a).
|
||||
|
||||
### Q3 — Legacy `?view=<uuid>` URL handling (§7)
|
||||
|
||||
- (a) 404 — clean break.
|
||||
- (b) 302-redirect to `/views/<slug>` by uuid lookup — smoother for m's recent bookmarks. Inventor pick.
|
||||
|
||||
**Inventor pick**: (b).
|
||||
|
||||
### Q4 — Editor surface (§6)
|
||||
|
||||
- (a) Dedicated pages `/views/new` + `/views/{slug}/edit` — paliad parity, inventor pick.
|
||||
- (b) Keep the HTMX modal from the 5i fix — less navigation but harder to share/bookmark mid-edit.
|
||||
|
||||
**Inventor pick**: (a).
|
||||
|
||||
### Q5 — `/views` landing MRU redirect
|
||||
|
||||
- (a) 302 to MRU saved view if any, else onboarding shell (paliad model, inventor pick).
|
||||
- (b) Always show the views index list page.
|
||||
|
||||
**Inventor pick**: (a).
|
||||
|
||||
### Q6 — Icon picker in v1?
|
||||
|
||||
- (a) Yes — small select + 8-12 curated keys; rendered inline in the sidebar entries.
|
||||
- (b) v2 — ship without icons in v1; sidebar uses a generic folder glyph for every entry.
|
||||
|
||||
**Inventor pick**: (a) — the schema column lands either way; UI cost for a `<select>` is trivial.
|
||||
|
||||
### Q7 — Drag-reorder in v1?
|
||||
|
||||
- (a) Yes (slice G in v1).
|
||||
- (b) v2 — `sort_order` column is server-assigned MAX+1 on create; reorder UI lands later.
|
||||
|
||||
**Inventor pick**: (b). Don't expand v1 scope; reorder is a UX polish that can ship a week after.
|
||||
|
||||
### Q8 — `show_count` badge in v1?
|
||||
|
||||
- (a) Yes — opt-in checkbox in editor + sidebar badge.
|
||||
- (b) v2 — column lands in the schema; UI lands later.
|
||||
|
||||
**Inventor pick**: (a) — checkbox in editor + 2-line render in sidebar is cheap and answers the "how many things match my view" question m asks naturally.
|
||||
|
||||
### Q9 — Legacy `is_default_for` semantics (§7)
|
||||
|
||||
Inventor picks **dropped entirely**, replaced by MRU. Flag if m wants pin / default semantics back.
|
||||
|
||||
### Q10 — Drop and recreate `projax.views`?
|
||||
|
||||
- (a) Hard-replace via `DROP TABLE ... CASCADE` — inventor pick (table is hours old, ~zero data loss).
|
||||
- (b) ALTER TABLE migration that adds new columns + drops old ones gracefully — more conservative; preserves any rows m has created.
|
||||
|
||||
**Inventor pick**: (a). The shape change is large enough that a clean re-create is cleaner than a 6-step ALTER.
|
||||
|
||||
### Q11 — `view_type=graph`?
|
||||
|
||||
The graph DAG SVG render isn't in the view_type enum. Should:
|
||||
- (a) Stay outside the views system — `/graph` and `/views/graph` (system slug) both serve it, user views can't be `view_type=graph`. Inventor pick.
|
||||
- (b) Add `graph` as a sixth view_type — opens user-creatable graph views.
|
||||
|
||||
**Inventor pick**: (a). Graph layout is single-purpose (DAG); a "graph of my filtered set" doesn't have a clear product story today.
|
||||
|
||||
---
|
||||
|
||||
## §10 — Risk register
|
||||
|
||||
| risk | likelihood | mitigation |
|
||||
|---|---|---|
|
||||
| Slug collision on rename | medium | UNIQUE index + handler maps the unique-violation to a friendly "slug already in use" error |
|
||||
| URL drift (legacy bookmarks break) | low under (c), high under (b) | (c) keeps legacy URLs; (b) ships with 301 redirects + a session of m verifying his bookmarks |
|
||||
| MRU thrash on rapid view switches | low | `last_used_at` is fire-and-forget; the worst case is one stale 302 |
|
||||
| System-view + user-view slug collision | n/a | reserved-list rejection in validator (slice A) |
|
||||
| sidebar query cost | low | `ListViews()` is one indexed lookup per page render; cache lightly if it shows in profiling |
|
||||
| Editor's chip strip drifts from the page chip strip | medium | share the same template (project_chip.tmpl already shared); add a dedicated `view_filter_chips.tmpl` if drift bites |
|
||||
|
||||
---
|
||||
|
||||
## §11 — Test plan headlines
|
||||
|
||||
### Slice A
|
||||
- `TestViewSlugCRUD` — create/get/update/delete by slug round-trip.
|
||||
- `TestViewSlugFormatRejected` — uppercase, underscore, leading-digit-allowed but no-leading-dash, length-cap 63.
|
||||
- `TestViewReservedSlugRejected` — create with slug `tree` / `dashboard` / `admin` / `new` etc. all 400.
|
||||
- `TestViewTouch` — Touch bumps `last_used_at`.
|
||||
- `TestViewMostRecent` — MRU returns most recently touched.
|
||||
|
||||
### Slice B
|
||||
- `TestViewsLandingMRU` — `/views` 302s to MRU view when one exists.
|
||||
- `TestViewsLandingOnboarding` — `/views` renders shell when no views.
|
||||
- `TestViewRender` — `/views/{slug}` resolves a user view; renders the right view_type template.
|
||||
- `TestLegacyOverlayRedirect` — `/?view=<uuid>` 302s to `/views/{slug}`.
|
||||
|
||||
### Slice C
|
||||
- `TestSystemViewLookup` — `tree` / `dashboard` / `calendar` / `timeline` / `graph` resolve via `LookupSystemView`.
|
||||
- `TestSystemViewSlugAlias` — `/views/dashboard` and `/dashboard` produce identical render output.
|
||||
|
||||
### Slice D
|
||||
- `TestEditorBlank` — `/views/new` renders empty form.
|
||||
- `TestEditorPrefilled` — `/views/{slug}/edit` reflects every persisted field.
|
||||
- `TestSlugDerivation` — name "Active mai work" → slug "active-mai-work".
|
||||
|
||||
### Slice E
|
||||
- `TestSidebarListsViews` — layout includes every user view.
|
||||
- `TestSidebarActiveState` — `/views/{slug}` marks that entry active.
|
||||
|
||||
### Slice F
|
||||
- All 5i overlay tests deleted; no residue references TreeFilter.ViewID.
|
||||
|
||||
### Slice G
|
||||
- `TestReorderUpdatesSortOrder` — POST `/views/reorder` with a sorted slug list updates the column.
|
||||
- `TestShowCountBadge` — sidebar badge reflects the filter's match count.
|
||||
|
||||
---
|
||||
|
||||
## §12 — References
|
||||
|
||||
- `~/dev/paliad/internal/db/migrations/056_user_views.up.sql` — schema reference.
|
||||
- `~/dev/paliad/internal/services/user_view_service.go` — CRUD reference.
|
||||
- `~/dev/paliad/internal/services/system_views.go` — reserved-slug + system-view registration.
|
||||
- `~/dev/paliad/internal/handlers/views_pages.go` — route table.
|
||||
- `~/dev/paliad/frontend/src/{views,views-editor}.tsx` — editor + sidebar reference (UX only; not ported).
|
||||
- `docs/plans/views-system.md` (5i) — historical record of the wrong-shape implementation.
|
||||
- `docs/design.md` §4 (Interfaces).
|
||||
|
||||
---
|
||||
|
||||
## §13 — Status
|
||||
|
||||
- **Phase A (this doc)**: drafted by kahn, 2026-05-26. Awaiting head delegation of §9 questions to m.
|
||||
- **No chip-picker for 5j** unless head explicitly re-grants per the project's escalation rule.
|
||||
- **Phase B (coder)**: blocked on m's sign-off via head. Slice ordering A → B → C → D → E → F → G.
|
||||
- **No code changes** in this branch beyond this doc.
|
||||
@@ -136,8 +136,13 @@ func ValidateFormat(in Input) *ValidationError {
|
||||
|
||||
// ValidateAgainstStore adds the DB-aware checks: every parent id must
|
||||
// resolve to a live item, the proposed parent_ids must not introduce a
|
||||
// cycle, and no sibling under any common parent already carries this slug.
|
||||
// Mirrors db/migrations/0010_multi_parent.sql trigger logic in Go.
|
||||
// cycle, and no other live item already carries this slug (per-user-global
|
||||
// uniqueness — Phase 6 / Q6=a).
|
||||
//
|
||||
// The Reader is satisfied by both the legacy *store.Store and the slice-B
|
||||
// *store.MBrianReader, so cycle + collision detection runs against
|
||||
// whichever backend PROJAX_BACKEND selects. Handlers pass s.Items here, not
|
||||
// s.Store, so a write pre-flight never validates against the wrong dataset.
|
||||
//
|
||||
// Callers should run ValidateFormat first — this function assumes the
|
||||
// pure checks already passed.
|
||||
@@ -197,45 +202,23 @@ func ValidateAgainstStore(ctx context.Context, r Reader, in Input) *ValidationEr
|
||||
}
|
||||
}
|
||||
|
||||
// Rules 8/9: slug uniqueness. For each parent_id, check whether any
|
||||
// sibling under that parent already carries the same slug. Roots
|
||||
// (parent_ids empty) check against other roots.
|
||||
if len(in.ParentIDs) == 0 {
|
||||
for _, it := range items {
|
||||
if len(it.ParentIDs) != 0 {
|
||||
continue
|
||||
}
|
||||
if it.ID == in.ID {
|
||||
continue
|
||||
}
|
||||
if it.Slug == in.Slug {
|
||||
return &ValidationError{
|
||||
Kind: KindSlugCollision,
|
||||
Path: in.Path,
|
||||
Detail: fmt.Sprintf("a root item with slug %q already exists", in.Slug),
|
||||
}
|
||||
}
|
||||
// Rules 8/9 (Phase 6 / Q6=a): slug uniqueness is per-user-global, not
|
||||
// per-parent. mBrian's idx_nodes_slug enforces (user_id, slug)
|
||||
// uniqueness, so two projax items can never share a slug regardless of
|
||||
// where they sit in the DAG — the old "two paliads under different
|
||||
// roots" case no longer holds (m confirmed he doesn't rely on it). This
|
||||
// is strictly tighter than the legacy per-parent rule, so it stays
|
||||
// correct on the legacy *Store backend too (the projax.items per-parent
|
||||
// index is never reached because this pre-flight rejects first).
|
||||
for _, it := range items {
|
||||
if it.ID == in.ID {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
parentSet := make(map[string]struct{}, len(in.ParentIDs))
|
||||
for _, pid := range in.ParentIDs {
|
||||
parentSet[pid] = struct{}{}
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.ID == in.ID {
|
||||
continue
|
||||
}
|
||||
if it.Slug != in.Slug {
|
||||
continue
|
||||
}
|
||||
for _, sibPID := range it.ParentIDs {
|
||||
if _, common := parentSet[sibPID]; common {
|
||||
return &ValidationError{
|
||||
Kind: KindSlugCollision,
|
||||
Path: in.Path,
|
||||
Detail: fmt.Sprintf("slug %q already exists under parent %q", in.Slug, sibPID),
|
||||
}
|
||||
}
|
||||
if it.Slug == in.Slug {
|
||||
return &ValidationError{
|
||||
Kind: KindSlugCollision,
|
||||
Path: in.Path,
|
||||
Detail: fmt.Sprintf("an item with slug %q already exists", in.Slug),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,21 +147,33 @@ func TestValidateStoreSlugCollisionRoot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateStoreSlugCollisionSibling(t *testing.T) {
|
||||
func TestValidateStoreSlugCollisionPerUser(t *testing.T) {
|
||||
// Phase 6 / Q6=a: slug uniqueness is per-user-global, not per-parent.
|
||||
r := &stubReader{items: []*store.Item{
|
||||
mkItem("parent", "parent"),
|
||||
mkItem("child1", "kid", "parent"),
|
||||
mkItem("other", "other"),
|
||||
}}
|
||||
// New child under same parent with the same slug — reject.
|
||||
// New child under the same parent with the same slug — reject.
|
||||
err := ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "kid", ParentIDs: []string{"parent"}})
|
||||
if err == nil || err.Kind != KindSlugCollision {
|
||||
t.Fatalf("expected slug-collision under common parent, got %v", err)
|
||||
}
|
||||
// Same slug under a different parent is fine.
|
||||
// Same slug under a DIFFERENT parent now also collides — the old
|
||||
// per-parent escape ("two paliads under different roots") is gone.
|
||||
err = ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "kid", ParentIDs: []string{"other"}})
|
||||
if err == nil || err.Kind != KindSlugCollision {
|
||||
t.Fatalf("expected per-user slug-collision regardless of parent, got %v", err)
|
||||
}
|
||||
// A novel slug under any parent is fine.
|
||||
err = ValidateAgainstStore(context.Background(), r, Input{Title: "x", Slug: "novel", ParentIDs: []string{"other"}})
|
||||
if err != nil {
|
||||
t.Errorf("different-parent slug should pass, got %v", err)
|
||||
t.Errorf("novel slug should pass, got %v", err)
|
||||
}
|
||||
// Updating the existing child to keep its own slug is not a collision.
|
||||
err = ValidateAgainstStore(context.Background(), r, Input{ID: "child1", Title: "x", Slug: "kid", ParentIDs: []string{"parent"}})
|
||||
if err != nil {
|
||||
t.Errorf("self-update keeping own slug should pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,10 @@ func (stubLinkLister) ItemsCreatedInRange(_ context.Context, _, _ time.Time) ([]
|
||||
func newToolServer(t *testing.T, agg *aggregate.Aggregator) *Server {
|
||||
t.Helper()
|
||||
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
// Pass a nil *store.Store — the timeline tool's store-backed paths
|
||||
// short-circuit cleanly on errors in this test surface (we just probe
|
||||
// registration + arg parsing here).
|
||||
RegisterProjaxTools(srv, nil, agg)
|
||||
// Pass nil reader/writer/legacy store — the timeline tool's store-backed
|
||||
// paths short-circuit cleanly on errors in this test surface (we just
|
||||
// probe registration + arg parsing here).
|
||||
RegisterProjaxTools(srv, nil, nil, nil, agg)
|
||||
return srv
|
||||
}
|
||||
|
||||
|
||||
139
mcp/tools.go
139
mcp/tools.go
@@ -31,6 +31,27 @@ func ValidationToolError(ve *itemwrite.ValidationError) *ToolError {
|
||||
}
|
||||
}
|
||||
|
||||
// slugAwareToolError promotes the adapter's slug sentinels into typed
|
||||
// validation tool errors (so MCP clients get {kind, detail} on a slug
|
||||
// collision / invalid slug from the mBrian backend, just like the
|
||||
// pre-flight validator), and falls back to InternalError otherwise.
|
||||
func slugAwareToolError(err error) *ToolError {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrSlugTaken):
|
||||
return ValidationToolError(&itemwrite.ValidationError{
|
||||
Kind: itemwrite.KindSlugCollision,
|
||||
Detail: "slug already taken (possibly by a deleted item)",
|
||||
})
|
||||
case errors.Is(err, store.ErrInvalidSlug):
|
||||
return ValidationToolError(&itemwrite.ValidationError{
|
||||
Kind: itemwrite.KindInvalidSlugFormat,
|
||||
Detail: "invalid slug — lower-case, no dots or whitespace",
|
||||
})
|
||||
default:
|
||||
return InternalError(err)
|
||||
}
|
||||
}
|
||||
|
||||
// TimelineArgs is the MCP-facing input shape for the `timeline` tool — a
|
||||
// JSON-friendly equivalent of the URL query string web/timeline.go consumes.
|
||||
type TimelineArgs struct {
|
||||
@@ -46,12 +67,18 @@ type TimelineArgs struct {
|
||||
IncludeExcluded bool `json:"include_excluded"` // ignore per-item timeline_exclude arrays
|
||||
}
|
||||
|
||||
// RegisterProjaxTools wires every projax-flavoured tool onto an *mcp.Server.
|
||||
// All tools delegate to *store.Store directly so business logic is shared
|
||||
// with the web UI — no duplication. The optional agg argument adds the
|
||||
// timeline tool when non-nil (it needs the fan-out aggregator; passing nil
|
||||
// keeps the rest of the toolset usable without aggregate deps).
|
||||
func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator) {
|
||||
// RegisterProjaxTools wires the projax MCP toolset. Phase 6 Slice C splits
|
||||
// the single *Store dependency into a reader (rd) + writer (wr) so the MCP
|
||||
// item/link surface flips with PROJAX_BACKEND atomically — the same
|
||||
// reader/writer the web handlers use. If MCP reads stayed on projax.items
|
||||
// while writes targeted mBrian (or vice-versa) an MCP client could read an
|
||||
// id from one backend and write it to the other: the exact slice-B
|
||||
// half-flip bug, just on the MCP surface.
|
||||
//
|
||||
// The `timeline` tool stays on the legacy *Store (its companion
|
||||
// *aggregate.Aggregator is out of slice-C scope), consistent with the web
|
||||
// dashboard/timeline which also aggregate via *Store.
|
||||
func RegisterProjaxTools(s *Server, rd store.ItemReader, wr store.ItemWriter, legacy *store.Store, agg *aggregate.Aggregator) {
|
||||
s.Register(Tool{
|
||||
Name: "list_items",
|
||||
Description: "List projax items with optional filters (parent_path, tags, management, kind, status, q, has_repo, has_caldav, public).",
|
||||
@@ -70,7 +97,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"limit": {"type": "integer", "minimum": 0}
|
||||
}
|
||||
}`),
|
||||
Handler: listItemsTool(st),
|
||||
Handler: listItemsTool(rd),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "get_item",
|
||||
@@ -83,7 +110,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"include_links": {"type": "boolean", "description": "Include item_links in the response (default true)"}
|
||||
}
|
||||
}`),
|
||||
Handler: getItemTool(st),
|
||||
Handler: getItemTool(rd),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "create_item",
|
||||
@@ -103,7 +130,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"metadata": {"type": "object"}
|
||||
}
|
||||
}`),
|
||||
Handler: createItemTool(st),
|
||||
Handler: createItemTool(rd, wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "update_item",
|
||||
@@ -130,7 +157,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"timeline_exclude": {"type": "array", "items": {"type": "string", "enum": ["todos","events","docs","creation"]}, "description": "Phase 4f — kinds to hide from /timeline (per item)"}
|
||||
}
|
||||
}`),
|
||||
Handler: updateItemTool(st),
|
||||
Handler: updateItemTool(rd, wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "delete_item",
|
||||
@@ -143,7 +170,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"cascade": {"type": "boolean", "description": "Soft-delete every descendant too"}
|
||||
}
|
||||
}`),
|
||||
Handler: deleteItemTool(st),
|
||||
Handler: deleteItemTool(rd, wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "list_links",
|
||||
@@ -156,7 +183,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"ref_type": {"type": "string", "description": "Optional ref_type filter (e.g. 'gitea-repo')"}
|
||||
}
|
||||
}`),
|
||||
Handler: listLinksTool(st),
|
||||
Handler: listLinksTool(rd),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "add_link",
|
||||
@@ -175,7 +202,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"metadata": {"type": "object"}
|
||||
}
|
||||
}`),
|
||||
Handler: addLinkTool(st),
|
||||
Handler: addLinkTool(rd, wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "remove_link",
|
||||
@@ -185,7 +212,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"required": ["link_id"],
|
||||
"properties": {"link_id": {"type": "string"}}
|
||||
}`),
|
||||
Handler: removeLinkTool(st),
|
||||
Handler: removeLinkTool(wr),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "search",
|
||||
@@ -198,7 +225,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 200}
|
||||
}
|
||||
}`),
|
||||
Handler: searchTool(st),
|
||||
Handler: searchTool(rd),
|
||||
})
|
||||
s.Register(Tool{
|
||||
Name: "tree",
|
||||
@@ -210,7 +237,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"depth": {"type": "integer", "minimum": 0, "description": "Max depth (0 = unlimited)"}
|
||||
}
|
||||
}`),
|
||||
Handler: treeTool(st),
|
||||
Handler: treeTool(rd),
|
||||
})
|
||||
if agg != nil {
|
||||
s.Register(Tool{
|
||||
@@ -230,7 +257,7 @@ func RegisterProjaxTools(s *Server, st *store.Store, agg *aggregate.Aggregator)
|
||||
"q": {"type": "string", "description": "Substring match against title/slug/aliases/content_md"}
|
||||
}
|
||||
}`),
|
||||
Handler: timelineTool(st, agg),
|
||||
Handler: timelineTool(legacy, agg),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -710,14 +737,14 @@ func mapOr(v map[string]any) map[string]any {
|
||||
}
|
||||
|
||||
// resolveItem turns an id-or-path argument pair into a concrete *store.Item.
|
||||
func resolveItem(ctx context.Context, st *store.Store, id, path string) (*store.Item, error) {
|
||||
func resolveItem(ctx context.Context, rd store.ItemReader, id, path string) (*store.Item, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
path = strings.TrimSpace(path)
|
||||
if id != "" {
|
||||
return st.GetByID(ctx, id)
|
||||
return rd.GetByID(ctx, id)
|
||||
}
|
||||
if path != "" {
|
||||
return st.GetByPathOrSlug(ctx, path)
|
||||
return rd.GetByPathOrSlug(ctx, path)
|
||||
}
|
||||
return nil, errors.New("either id or path is required")
|
||||
}
|
||||
@@ -731,7 +758,7 @@ func parseInput[T any](raw json.RawMessage, dst *T) error {
|
||||
|
||||
// --- list_items ---
|
||||
|
||||
func listItemsTool(st *store.Store) ToolHandler {
|
||||
func listItemsTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
ParentPath string `json:"parent_path"`
|
||||
Tags []string `json:"tags"`
|
||||
@@ -749,7 +776,7 @@ func listItemsTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
items, err := st.ListByFilters(ctx, store.SearchFilters{
|
||||
items, err := rd.ListByFilters(ctx, store.SearchFilters{
|
||||
ParentPath: in.ParentPath,
|
||||
Tags: in.Tags,
|
||||
Management: in.Management,
|
||||
@@ -774,7 +801,7 @@ func listItemsTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- get_item ---
|
||||
|
||||
func getItemTool(st *store.Store) ToolHandler {
|
||||
func getItemTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -785,7 +812,7 @@ func getItemTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -795,13 +822,13 @@ func getItemTool(st *store.Store) ToolHandler {
|
||||
include = *in.IncludeLinks
|
||||
}
|
||||
if include {
|
||||
links, err := st.LinksByType(ctx, it.ID, "") // pass "" → all types
|
||||
links, err := rd.LinksByType(ctx, it.ID, "") // pass "" → all types
|
||||
// LinksByType filters by ref_type — empty would return nothing. So
|
||||
// we explicitly list_all by fanning across the known types.
|
||||
_ = err
|
||||
links = nil
|
||||
for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} {
|
||||
ll, err := st.LinksByType(ctx, it.ID, t)
|
||||
ll, err := rd.LinksByType(ctx, it.ID, t)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -819,7 +846,7 @@ func getItemTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- create_item ---
|
||||
|
||||
func createItemTool(st *store.Store) ToolHandler {
|
||||
func createItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
@@ -836,7 +863,7 @@ func createItemTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
parentIDs, err := resolveParentPaths(ctx, st, in.ParentPaths)
|
||||
parentIDs, err := resolveParentPaths(ctx, rd, in.ParentPaths)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -851,12 +878,12 @@ func createItemTool(st *store.Store) ToolHandler {
|
||||
}); ve != nil {
|
||||
return nil, ValidationToolError(ve)
|
||||
}
|
||||
if ve := itemwrite.ValidateAgainstStore(ctx, st, itemwrite.Input{
|
||||
if ve := itemwrite.ValidateAgainstStore(ctx, rd, itemwrite.Input{
|
||||
Title: in.Title, Slug: in.Slug, Status: in.Status, ParentIDs: parentIDs,
|
||||
}); ve != nil {
|
||||
return nil, ValidationToolError(ve)
|
||||
}
|
||||
it, err := st.Create(ctx, store.CreateInput{
|
||||
it, err := wr.Create(ctx, store.CreateInput{
|
||||
Kind: kind,
|
||||
Title: in.Title,
|
||||
Slug: in.Slug,
|
||||
@@ -868,20 +895,20 @@ func createItemTool(st *store.Store) ToolHandler {
|
||||
Metadata: in.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
return nil, slugAwareToolError(err)
|
||||
}
|
||||
return toItemView(it), nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([]string, error) {
|
||||
func resolveParentPaths(ctx context.Context, rd store.ItemReader, paths []string) ([]string, error) {
|
||||
out := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
it, err := st.GetByPathOrSlug(ctx, p)
|
||||
it, err := rd.GetByPathOrSlug(ctx, p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parent path %q: %w", p, err)
|
||||
}
|
||||
@@ -892,7 +919,7 @@ func resolveParentPaths(ctx context.Context, st *store.Store, paths []string) ([
|
||||
|
||||
// --- update_item ---
|
||||
|
||||
func updateItemTool(st *store.Store) ToolHandler {
|
||||
func updateItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -917,7 +944,7 @@ func updateItemTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -996,7 +1023,7 @@ func updateItemTool(st *store.Store) ToolHandler {
|
||||
patch.TimelineExclude = out
|
||||
}
|
||||
if in.ParentPaths != nil {
|
||||
pids, err := resolveParentPaths(ctx, st, *in.ParentPaths)
|
||||
pids, err := resolveParentPaths(ctx, rd, *in.ParentPaths)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1015,12 +1042,12 @@ func updateItemTool(st *store.Store) ToolHandler {
|
||||
if ve := itemwrite.ValidateFormat(validateIn); ve != nil {
|
||||
return nil, ValidationToolError(ve)
|
||||
}
|
||||
if ve := itemwrite.ValidateAgainstStore(ctx, st, validateIn); ve != nil {
|
||||
if ve := itemwrite.ValidateAgainstStore(ctx, rd, validateIn); ve != nil {
|
||||
return nil, ValidationToolError(ve)
|
||||
}
|
||||
updated, err := st.Update(ctx, it.ID, patch)
|
||||
updated, err := wr.Update(ctx, it.ID, patch)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
return nil, slugAwareToolError(err)
|
||||
}
|
||||
return toItemView(updated), nil
|
||||
}
|
||||
@@ -1028,7 +1055,7 @@ func updateItemTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- delete_item ---
|
||||
|
||||
func deleteItemTool(st *store.Store) ToolHandler {
|
||||
func deleteItemTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -1039,11 +1066,11 @@ func deleteItemTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
if err := st.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil {
|
||||
if err := wr.SoftDeleteCascade(ctx, it.ID, in.Cascade); err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
return map[string]any{"deleted": it.ID, "cascade": in.Cascade}, nil
|
||||
@@ -1052,7 +1079,7 @@ func deleteItemTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- list_links ---
|
||||
|
||||
func listLinksTool(st *store.Store) ToolHandler {
|
||||
func listLinksTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -1063,16 +1090,16 @@ func listLinksTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
var links []*store.ItemLink
|
||||
if in.RefType != "" {
|
||||
links, err = st.LinksByType(ctx, it.ID, in.RefType)
|
||||
links, err = rd.LinksByType(ctx, it.ID, in.RefType)
|
||||
} else {
|
||||
for _, t := range []string{"caldav-list", "gitea-repo", "mai-project", "mbrian-node", "url", "mai-task"} {
|
||||
ll, lerr := st.LinksByType(ctx, it.ID, t)
|
||||
ll, lerr := rd.LinksByType(ctx, it.ID, t)
|
||||
if lerr != nil {
|
||||
continue
|
||||
}
|
||||
@@ -1092,7 +1119,7 @@ func listLinksTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- add_link / remove_link ---
|
||||
|
||||
func addLinkTool(st *store.Store) ToolHandler {
|
||||
func addLinkTool(rd store.ItemReader, wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
@@ -1111,7 +1138,7 @@ func addLinkTool(st *store.Store) ToolHandler {
|
||||
if in.RefType == "" || in.RefID == "" {
|
||||
return nil, &ToolError{Code: codeInternalError, Msg: "ref_type and ref_id are required"}
|
||||
}
|
||||
it, err := resolveItem(ctx, st, in.ID, in.Path)
|
||||
it, err := resolveItem(ctx, rd, in.ID, in.Path)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1132,7 +1159,7 @@ func addLinkTool(st *store.Store) ToolHandler {
|
||||
}
|
||||
datePtr = &t
|
||||
}
|
||||
link, err := st.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md)
|
||||
link, err := wr.AddLinkDated(ctx, it.ID, in.RefType, in.RefID, in.Rel, notePtr, datePtr, md)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1140,7 +1167,7 @@ func addLinkTool(st *store.Store) ToolHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func removeLinkTool(st *store.Store) ToolHandler {
|
||||
func removeLinkTool(wr store.ItemWriter) ToolHandler {
|
||||
type input struct {
|
||||
LinkID string `json:"link_id"`
|
||||
}
|
||||
@@ -1152,7 +1179,7 @@ func removeLinkTool(st *store.Store) ToolHandler {
|
||||
if in.LinkID == "" {
|
||||
return nil, &ToolError{Code: codeInternalError, Msg: "link_id is required"}
|
||||
}
|
||||
if err := st.DeleteLink(ctx, in.LinkID); err != nil {
|
||||
if err := wr.DeleteLink(ctx, in.LinkID); err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
return map[string]any{"deleted": in.LinkID}, nil
|
||||
@@ -1161,7 +1188,7 @@ func removeLinkTool(st *store.Store) ToolHandler {
|
||||
|
||||
// --- search ---
|
||||
|
||||
func searchTool(st *store.Store) ToolHandler {
|
||||
func searchTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
Query string `json:"query"`
|
||||
Limit int `json:"limit"`
|
||||
@@ -1174,7 +1201,7 @@ func searchTool(st *store.Store) ToolHandler {
|
||||
if in.Query == "" {
|
||||
return nil, &ToolError{Code: codeInternalError, Msg: "query is required"}
|
||||
}
|
||||
items, err := st.Search(ctx, in.Query, in.Limit)
|
||||
items, err := rd.Search(ctx, in.Query, in.Limit)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1194,7 +1221,7 @@ type treeNode struct {
|
||||
Children []*treeNode `json:"children"`
|
||||
}
|
||||
|
||||
func treeTool(st *store.Store) ToolHandler {
|
||||
func treeTool(rd store.ItemReader) ToolHandler {
|
||||
type input struct {
|
||||
RootPath string `json:"root_path"`
|
||||
Depth int `json:"depth"`
|
||||
@@ -1204,7 +1231,7 @@ func treeTool(st *store.Store) ToolHandler {
|
||||
if err := parseInput(raw, &in); err != nil {
|
||||
return nil, InternalError(fmt.Errorf("bad params: %w", err))
|
||||
}
|
||||
items, err := st.ListAll(ctx)
|
||||
items, err := rd.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
@@ -1235,7 +1262,7 @@ func treeTool(st *store.Store) ToolHandler {
|
||||
}
|
||||
var out []*treeNode
|
||||
if in.RootPath != "" {
|
||||
root, err := st.GetByPathOrSlug(ctx, in.RootPath)
|
||||
root, err := rd.GetByPathOrSlug(ctx, in.RootPath)
|
||||
if err != nil {
|
||||
return nil, InternalError(err)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func mustDBServer(t *testing.T) (*Server, *pgxpool.Pool) {
|
||||
srv := New("projax-test", "0.0.1", "tok", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
// The MCP tests don't need a real timeline builder — passing nil keeps
|
||||
// the timeline tool unregistered without requiring a web.Server here.
|
||||
RegisterProjaxTools(srv, st, nil)
|
||||
RegisterProjaxTools(srv, st, st, st, nil)
|
||||
t.Cleanup(func() { pool.Close() })
|
||||
return srv, pool
|
||||
}
|
||||
|
||||
94
store/adapter.go
Normal file
94
store/adapter.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ItemReader is the read-path contract every projax UI handler, the
|
||||
// internal/aggregate fan-out engine, and the MCP read tools depend on.
|
||||
// Pure projax-shaped structs in/out; the slice-B mBrian-backed
|
||||
// implementation translates mBrian nodes/edges into the same shape
|
||||
// without leaking mBrian types to consumers.
|
||||
//
|
||||
// Phase 6 Slice B (live impl) — see store/mbrian.go for the MBrianReader
|
||||
// implementation against the migrated mbrian.* schema, and
|
||||
// docs/plans/slice-b-adapter-contract.md for the consumer inventory +
|
||||
// per-method semantics.
|
||||
//
|
||||
// Two satisfiers ship:
|
||||
// *Store — pgx-backed against projax.items (today; legacy).
|
||||
// *MBrianReader — pgx-backed against mbrian.{nodes,edges} (slice B).
|
||||
//
|
||||
// Selection between them is wired at Server-construction time via
|
||||
// PROJAX_BACKEND=store|mbrian (defaults to "store" until slice B is
|
||||
// rolled to production).
|
||||
type ItemReader interface {
|
||||
// --- item lookups ---
|
||||
ListAll(ctx context.Context) ([]*Item, error)
|
||||
GetByID(ctx context.Context, id string) (*Item, error)
|
||||
GetByPath(ctx context.Context, path string) (*Item, error)
|
||||
GetByPathOrSlug(ctx context.Context, key string) (*Item, error)
|
||||
Roots(ctx context.Context) ([]*Item, error)
|
||||
MaiOrphans(ctx context.Context) ([]*Item, error)
|
||||
ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error)
|
||||
Search(ctx context.Context, q string, limit int) ([]*Item, error)
|
||||
ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error)
|
||||
AllTags(ctx context.Context) ([]string, error)
|
||||
|
||||
// --- link lookups ---
|
||||
LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error)
|
||||
LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error)
|
||||
DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error)
|
||||
DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error)
|
||||
RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error)
|
||||
}
|
||||
|
||||
// Compile-time assertion that the existing pgx-backed *Store satisfies
|
||||
// ItemReader. Drops in cleanly because every method in the interface is
|
||||
// already part of *Store's public surface. If a future refactor removes
|
||||
// or reshapes one of these methods on *Store, the compiler points at
|
||||
// this line first.
|
||||
var _ ItemReader = (*Store)(nil)
|
||||
|
||||
// ItemWriter is the write-path contract every projax UI write handler, the
|
||||
// /admin/bulk apply path, and the MCP write tools depend on. Pure
|
||||
// projax-shaped structs in/out; the slice-C mBrian-backed implementation
|
||||
// (*MBrianWriter) translates each call into the scoped mBrian HTTP write
|
||||
// API without leaking mBrian/HTTP types to consumers.
|
||||
//
|
||||
// Phase 6 Slice C — see store/mbrian_writer.go for the MBrianWriter
|
||||
// implementation (an HTTP client against mBrian's /api/projax/* surface)
|
||||
// and docs/plans/slice-c-writepath-contract.md for the per-method
|
||||
// semantics + the read→write→read round-trip the slice-B cutover missed.
|
||||
//
|
||||
// Two satisfiers ship:
|
||||
// *Store — pgx-backed against projax.items (today; legacy).
|
||||
// *MBrianWriter — HTTP client against mBrian's scoped write API (slice C).
|
||||
//
|
||||
// Selection between them is wired at Server-construction time via
|
||||
// PROJAX_BACKEND=store|mbrian. The flag MUST flip BOTH the reader
|
||||
// (Server.Items) and the writer (Server.Writes) to the same backend —
|
||||
// the slice-B bug was a half-flip (reads on mBrian, writes on
|
||||
// projax.items), so a read-then-write round-trip rejected the freshly
|
||||
// read id. main.go sets both atomically.
|
||||
type ItemWriter interface {
|
||||
// --- item writes ---
|
||||
Create(ctx context.Context, in CreateInput) (*Item, error)
|
||||
Update(ctx context.Context, id string, in UpdateInput) (*Item, error)
|
||||
Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error)
|
||||
AddParent(ctx context.Context, id, parentID string) (*Item, error)
|
||||
SetPublic(ctx context.Context, ids []string, public bool) error
|
||||
SetPinned(ctx context.Context, ids []string, pinned bool) error
|
||||
SoftDelete(ctx context.Context, id string) error
|
||||
SoftDeleteCascade(ctx context.Context, id string, cascade bool) error
|
||||
|
||||
// --- link writes ---
|
||||
AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error)
|
||||
AddLinkDated(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error)
|
||||
DeleteLink(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// Compile-time assertion that the existing pgx-backed *Store satisfies
|
||||
// ItemWriter. Every method is already part of *Store's public surface.
|
||||
var _ ItemWriter = (*Store)(nil)
|
||||
1039
store/mbrian.go
Normal file
1039
store/mbrian.go
Normal file
File diff suppressed because it is too large
Load Diff
251
store/mbrian_parity_test.go
Normal file
251
store/mbrian_parity_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package store_test
|
||||
|
||||
// Phase 6 Slice B — parity test between the legacy pgx-against-projax-
|
||||
// items *Store and the new pgx-against-mbrian *MBrianReader.
|
||||
//
|
||||
// Skipped without SUPABASE_DATABASE_URL set. When run against the live
|
||||
// post-migration database, every comparison should hold: the adapter
|
||||
// must be a faithful translator of the migrated graph for projax UI
|
||||
// consumers.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
func newPair(t *testing.T) (*store.Store, *store.MBrianReader, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
url := os.Getenv("SUPABASE_DATABASE_URL")
|
||||
if url == "" {
|
||||
url = os.Getenv("PROJAX_DB_URL")
|
||||
}
|
||||
if url == "" {
|
||||
t.Skip("set SUPABASE_DATABASE_URL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
t.Skipf("DB unreachable: %v", err)
|
||||
}
|
||||
return store.New(pool), store.NewMBrianReader(pool), pool
|
||||
}
|
||||
|
||||
// TestParityListAll: both readers return the same set of items by slug.
|
||||
// Field-by-field equality is asserted for slug/title/status/tags/management/
|
||||
// public/parent count/paths — the consumer-facing surface.
|
||||
func TestParityListAll(t *testing.T) {
|
||||
s, r, pool := newPair(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
leg, err := s.ListAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("store ListAll: %v", err)
|
||||
}
|
||||
mb, err := r.ListAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("mbrian ListAll: %v", err)
|
||||
}
|
||||
if len(leg) != len(mb) {
|
||||
t.Fatalf("count mismatch: store=%d mbrian=%d", len(leg), len(mb))
|
||||
}
|
||||
// Per the migration brief, two projax-side squatter slugs were
|
||||
// renamed-aside (not deleted) so mBrian could take the canonical
|
||||
// 'work' (area) + 'dania' (project) slugs. Compare every slug that
|
||||
// resolves in BOTH sets; the squatters surface as legacy-only.
|
||||
skip := map[string]bool{"work": true, "dania": true}
|
||||
legBySlug := bySlug(leg)
|
||||
mbBySlug := bySlug(mb)
|
||||
for slug, l := range legBySlug {
|
||||
if skip[slug] {
|
||||
continue
|
||||
}
|
||||
m, ok := mbBySlug[slug]
|
||||
if !ok {
|
||||
t.Errorf("slug %q missing in mBrian set", slug)
|
||||
continue
|
||||
}
|
||||
if l.Title != m.Title {
|
||||
t.Errorf("%s title: store=%q mbrian=%q", slug, l.Title, m.Title)
|
||||
}
|
||||
if l.Status != m.Status {
|
||||
t.Errorf("%s status: store=%q mbrian=%q", slug, l.Status, m.Status)
|
||||
}
|
||||
if !sameSet(l.Tags, m.Tags) {
|
||||
t.Errorf("%s tags: store=%v mbrian=%v", slug, l.Tags, m.Tags)
|
||||
}
|
||||
if !sameSet(l.Management, m.Management) {
|
||||
t.Errorf("%s management: store=%v mbrian=%v", slug, l.Management, m.Management)
|
||||
}
|
||||
if l.Public != m.Public {
|
||||
t.Errorf("%s public: store=%v mbrian=%v", slug, l.Public, m.Public)
|
||||
}
|
||||
if len(l.ParentIDs) != len(m.ParentIDs) {
|
||||
t.Errorf("%s parent count: store=%d mbrian=%d",
|
||||
slug, len(l.ParentIDs), len(m.ParentIDs))
|
||||
}
|
||||
if !sameSet(l.Paths, m.Paths) {
|
||||
t.Errorf("%s paths: store=%v mbrian=%v", slug, l.Paths, m.Paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParitySpotChecks asserts the 5 spot-check items resolve identically
|
||||
// through both readers — root area / single-parent / multi-parent /
|
||||
// caldav-linked / public-listing populated.
|
||||
func TestParitySpotChecks(t *testing.T) {
|
||||
s, r, pool := newPair(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
for _, slug := range []string{"dev", "work", "paliad", "services", "mhome", "fdbck", "dania"} {
|
||||
l, lerr := s.GetByPathOrSlug(ctx, slug)
|
||||
m, merr := r.GetByPathOrSlug(ctx, slug)
|
||||
if lerr != nil && merr != nil {
|
||||
// Both 404 — consistent.
|
||||
continue
|
||||
}
|
||||
if lerr != nil {
|
||||
t.Errorf("%s: store err=%v but mbrian found", slug, lerr)
|
||||
continue
|
||||
}
|
||||
if merr != nil {
|
||||
t.Errorf("%s: mbrian err=%v but store found", slug, merr)
|
||||
continue
|
||||
}
|
||||
if l.Slug != m.Slug || l.Title != m.Title {
|
||||
t.Errorf("%s: shape mismatch store=%+v mbrian=%+v",
|
||||
slug, l.Slug+"/"+l.Title, m.Slug+"/"+m.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParityCalDAVLinks: the single caldav-list link must round-trip.
|
||||
func TestParityCalDAVLinks(t *testing.T) {
|
||||
s, r, pool := newPair(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
leg, err := s.LinksByRefType(ctx, "caldav-list")
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
mb, err := r.LinksByRefType(ctx, "caldav-list")
|
||||
if err != nil {
|
||||
t.Fatalf("mbrian: %v", err)
|
||||
}
|
||||
if len(leg) != len(mb) {
|
||||
t.Errorf("count: store=%d mbrian=%d", len(leg), len(mb))
|
||||
}
|
||||
// The URLs must round-trip identically. Match by ref_id.
|
||||
legByRef := map[string]*store.ItemLink{}
|
||||
for _, l := range leg {
|
||||
legByRef[l.RefID] = l
|
||||
}
|
||||
for _, m := range mb {
|
||||
l, ok := legByRef[m.RefID]
|
||||
if !ok {
|
||||
t.Errorf("mbrian caldav RefID %q not in store set", m.RefID)
|
||||
continue
|
||||
}
|
||||
if l.Rel != m.Rel {
|
||||
t.Errorf("caldav %s rel: store=%q mbrian=%q", m.RefID, l.Rel, m.Rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParityGiteaRepoLinks — same parity check for the 37 gitea-repo edges.
|
||||
func TestParityGiteaRepoLinks(t *testing.T) {
|
||||
s, r, pool := newPair(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
leg, err := s.LinksByRefType(ctx, "gitea-repo")
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
mb, err := r.LinksByRefType(ctx, "gitea-repo")
|
||||
if err != nil {
|
||||
t.Fatalf("mbrian: %v", err)
|
||||
}
|
||||
if len(leg) != len(mb) {
|
||||
t.Errorf("count: store=%d mbrian=%d", len(leg), len(mb))
|
||||
}
|
||||
legSeen := map[string]bool{}
|
||||
for _, l := range leg {
|
||||
legSeen[l.RefID] = true
|
||||
}
|
||||
for _, m := range mb {
|
||||
if !legSeen[m.RefID] {
|
||||
t.Errorf("mbrian gitea-repo RefID %q not in store set", m.RefID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParityAllTags: tag union must match (modulo ordering).
|
||||
func TestParityAllTags(t *testing.T) {
|
||||
s, r, pool := newPair(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
leg, err := s.AllTags(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
mb, err := r.AllTags(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("mbrian: %v", err)
|
||||
}
|
||||
if !sameSet(leg, mb) {
|
||||
t.Errorf("AllTags mismatch:\n store=%v\n mbrian=%v", leg, mb)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParityNotFound: an unknown slug must 404 from both.
|
||||
func TestParityNotFound(t *testing.T) {
|
||||
s, r, pool := newPair(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
_, le := s.GetByPathOrSlug(ctx, "definitely-not-a-real-slug-zzzzz")
|
||||
_, me := r.GetByPathOrSlug(ctx, "definitely-not-a-real-slug-zzzzz")
|
||||
if !errors.Is(le, store.ErrNotFound) {
|
||||
t.Errorf("store should ErrNotFound, got %v", le)
|
||||
}
|
||||
if !errors.Is(me, store.ErrNotFound) {
|
||||
t.Errorf("mbrian should ErrNotFound, got %v", me)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func bySlug(items []*store.Item) map[string]*store.Item {
|
||||
out := map[string]*store.Item{}
|
||||
for _, it := range items {
|
||||
out[it.Slug] = it
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sameSet(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
ac := append([]string{}, a...)
|
||||
bc := append([]string{}, b...)
|
||||
sort.Strings(ac)
|
||||
sort.Strings(bc)
|
||||
for i := range ac {
|
||||
if ac[i] != bc[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
79
store/mbrian_reader_coalesce_test.go
Normal file
79
store/mbrian_reader_coalesce_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package store
|
||||
|
||||
import "testing"
|
||||
|
||||
// Phase 6 Slice C reader fixes (G3 + G2): the reader reads pinned/archived
|
||||
// and link note from metadata (where MBrianWriter stows them, since the
|
||||
// scoped HTTP API can't set those columns/fields) with a fallback to the
|
||||
// node column / edge.note. These exercise itemFromNode + linkFromEdge as
|
||||
// pure functions — no DB.
|
||||
|
||||
func TestItemFromNodePinnedArchivedCoalesce(t *testing.T) {
|
||||
// metadata.projax wins when present.
|
||||
r := &nodeRow{
|
||||
ID: "n1", Slug: "x", Title: "X",
|
||||
Pinned: false, Archived: false, // column defaults
|
||||
Metadata: map[string]any{
|
||||
"projax": map[string]any{"pinned": true, "archived": true},
|
||||
},
|
||||
}
|
||||
it := itemFromNode(r)
|
||||
if !it.Pinned || !it.Archived {
|
||||
t.Errorf("metadata.projax pinned/archived should override columns: pinned=%v archived=%v", it.Pinned, it.Archived)
|
||||
}
|
||||
|
||||
// Absent in metadata → fall back to the column values (migrated data).
|
||||
r2 := &nodeRow{
|
||||
ID: "n2", Slug: "y", Title: "Y",
|
||||
Pinned: true, Archived: true, // column values from migration
|
||||
Metadata: map[string]any{"projax": map[string]any{}},
|
||||
}
|
||||
it2 := itemFromNode(r2)
|
||||
if !it2.Pinned || !it2.Archived {
|
||||
t.Errorf("column pinned/archived should survive when metadata is silent: pinned=%v archived=%v", it2.Pinned, it2.Archived)
|
||||
}
|
||||
|
||||
// metadata false explicitly overrides a true column.
|
||||
r3 := &nodeRow{
|
||||
ID: "n3", Slug: "z", Title: "Z",
|
||||
Pinned: true,
|
||||
Metadata: map[string]any{"projax": map[string]any{"pinned": false}},
|
||||
}
|
||||
if itemFromNode(r3).Pinned {
|
||||
t.Error("explicit metadata.projax.pinned=false should override column pinned=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinkFromEdgeNoteCoalesce(t *testing.T) {
|
||||
// edge.note column wins.
|
||||
col := "from column"
|
||||
er := &edgeRow{
|
||||
ID: "e1", SourceID: "i1", Rel: "projax-doc",
|
||||
Note: &col,
|
||||
Metadata: map[string]any{"note": "from metadata", "url": "/d.pdf"},
|
||||
}
|
||||
l := linkFromEdge(er)
|
||||
if l.Note == nil || *l.Note != "from column" {
|
||||
t.Errorf("edge.note column should win: %v", l.Note)
|
||||
}
|
||||
if _, dup := l.Metadata["note"]; dup {
|
||||
t.Error("note must not also appear in consumer metadata (parity with *Store)")
|
||||
}
|
||||
|
||||
// No column note → fall back to metadata.note (post-cutover writes).
|
||||
er2 := &edgeRow{
|
||||
ID: "e2", SourceID: "i1", Rel: "projax-doc",
|
||||
Note: nil,
|
||||
Metadata: map[string]any{"note": "filed brief", "url": "/d.pdf"},
|
||||
}
|
||||
l2 := linkFromEdge(er2)
|
||||
if l2.Note == nil || *l2.Note != "filed brief" {
|
||||
t.Errorf("metadata.note should surface when edge.note is empty: %v", l2.Note)
|
||||
}
|
||||
|
||||
// Neither → nil note, no panic.
|
||||
er3 := &edgeRow{ID: "e3", SourceID: "i1", Rel: "projax-url", Metadata: map[string]any{"url": "https://x"}}
|
||||
if l3 := linkFromEdge(er3); l3.Note != nil {
|
||||
t.Errorf("no note anywhere → nil, got %v", *l3.Note)
|
||||
}
|
||||
}
|
||||
76
store/mbrian_tasks.go
Normal file
76
store/mbrian_tasks.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Phase 7 — mBrian-native task READ path. Task nodes are mbrian.nodes with
|
||||
// type containing 'task', carrying metadata.projax_origin (so the shared
|
||||
// reader scoping already includes them) plus metadata.projax.{status,due}.
|
||||
// They attach to their parent project via a child_of edge, exactly like a
|
||||
// sub-project — the only structural difference is the type, which is why the
|
||||
// list-producing reader methods filter them out (nodeIsTask) and this file
|
||||
// surfaces them through the dedicated Task shape instead.
|
||||
|
||||
// Compile-time witness: MBrianReader satisfies TaskReader.
|
||||
var _ TaskReader = (*MBrianReader)(nil)
|
||||
|
||||
// nodeIsTask reports whether a node's type marks it a projax task. Tasks are
|
||||
// excluded from every project-list surface (ListAll/Roots/MaiOrphans/Search/
|
||||
// AllTags) so they never clutter the project DAG; they surface only through
|
||||
// TasksForItem (and remain individually resolvable via GetByID/GetByPath).
|
||||
func nodeIsTask(r *nodeRow) bool { return containsString(r.Type, "task") }
|
||||
|
||||
// TasksForItem returns the mBrian-native tasks attached to itemID via a
|
||||
// child_of edge, in created-at order (Q5 — created order, no manual reorder).
|
||||
// One graph build; cheap at m's scale. Each task materialises into the
|
||||
// uniform Task shape with Source=mbrian.
|
||||
func (r *MBrianReader) TasksForItem(ctx context.Context, itemID string) ([]*Task, error) {
|
||||
gc, err := loadAllProjaxNodes(ctx, r.pool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []*Task{}
|
||||
for _, childID := range gc.childrenOf[itemID] {
|
||||
n, ok := gc.nodeByID[childID]
|
||||
if !ok || !nodeIsTask(n) {
|
||||
continue
|
||||
}
|
||||
out = append(out, taskFromNode(n, itemID))
|
||||
}
|
||||
// Created-at order; stable tiebreak on slug so equal stamps don't churn.
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||
return out[i].Slug < out[j].Slug
|
||||
}
|
||||
return out[i].CreatedAt.Before(out[j].CreatedAt)
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// taskFromNode hoists a task node row into the uniform Task shape, unpacking
|
||||
// metadata.projax.{status,due}. parentID is the item the caller resolved this
|
||||
// task under (the child_of target driving TasksForItem).
|
||||
func taskFromNode(n *nodeRow, parentID string) *Task {
|
||||
t := &Task{
|
||||
ID: n.ID,
|
||||
Title: n.Title,
|
||||
Source: TaskSourceMBrian,
|
||||
Status: "active",
|
||||
ParentItemID: parentID,
|
||||
CreatedAt: n.CreatedAt,
|
||||
NodeID: n.ID,
|
||||
Slug: n.Slug,
|
||||
}
|
||||
if pm, ok := n.Metadata["projax"].(map[string]any); ok {
|
||||
if v, ok := pm["status"].(string); ok && v != "" {
|
||||
t.Status = v
|
||||
}
|
||||
if due := parseTimeAny(pm["due"]); due != nil {
|
||||
t.Due = due
|
||||
}
|
||||
}
|
||||
t.Done = t.Status == "done"
|
||||
return t
|
||||
}
|
||||
104
store/mbrian_tasks_test.go
Normal file
104
store/mbrian_tasks_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNodeIsTask(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
typ []string
|
||||
want bool
|
||||
}{
|
||||
{"plain task", []string{"task"}, true},
|
||||
{"task co-typed mai-managed", []string{"task", "mai-managed"}, true},
|
||||
{"project", []string{"project"}, false},
|
||||
{"project co-typed", []string{"project", "mai-managed"}, false},
|
||||
{"empty", []string{}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := nodeIsTask(&nodeRow{Type: c.typ}); got != c.want {
|
||||
t.Fatalf("nodeIsTask(%v) = %v, want %v", c.typ, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskFromNode(t *testing.T) {
|
||||
created := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
n := &nodeRow{
|
||||
ID: "task-uuid",
|
||||
Type: []string{"task"},
|
||||
Title: "Buy cement",
|
||||
Slug: "buy-cement",
|
||||
CreatedAt: created,
|
||||
Metadata: map[string]any{
|
||||
"projax": map[string]any{
|
||||
"status": "done",
|
||||
"due": "2026-06-15",
|
||||
},
|
||||
},
|
||||
}
|
||||
got := taskFromNode(n, "parent-uuid")
|
||||
if got.ID != "task-uuid" || got.NodeID != "task-uuid" {
|
||||
t.Fatalf("id/nodeID = %q/%q", got.ID, got.NodeID)
|
||||
}
|
||||
if got.Title != "Buy cement" || got.Slug != "buy-cement" {
|
||||
t.Fatalf("title/slug = %q/%q", got.Title, got.Slug)
|
||||
}
|
||||
if got.Source != TaskSourceMBrian {
|
||||
t.Fatalf("source = %q, want %q", got.Source, TaskSourceMBrian)
|
||||
}
|
||||
if got.Status != "done" || !got.Done {
|
||||
t.Fatalf("status/done = %q/%v, want done/true", got.Status, got.Done)
|
||||
}
|
||||
if got.ParentItemID != "parent-uuid" {
|
||||
t.Fatalf("parent = %q", got.ParentItemID)
|
||||
}
|
||||
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-06-15" {
|
||||
t.Fatalf("due = %v, want 2026-06-15", got.Due)
|
||||
}
|
||||
if !got.CreatedAt.Equal(created) {
|
||||
t.Fatalf("createdAt = %v, want %v", got.CreatedAt, created)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskFromNodeDefaults(t *testing.T) {
|
||||
// No projax metadata → active, not done, no due.
|
||||
n := &nodeRow{ID: "t", Type: []string{"task"}, Title: "x", Slug: "x"}
|
||||
got := taskFromNode(n, "p")
|
||||
if got.Status != "active" || got.Done {
|
||||
t.Fatalf("default status/done = %q/%v, want active/false", got.Status, got.Done)
|
||||
}
|
||||
if got.Due != nil {
|
||||
t.Fatalf("default due = %v, want nil", got.Due)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueToJSON(t *testing.T) {
|
||||
if got := dueToJSON(nil); got != "" {
|
||||
t.Fatalf("nil due = %q, want empty", got)
|
||||
}
|
||||
dateOnly := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
if got := dueToJSON(&dateOnly); got != "2026-06-15" {
|
||||
t.Fatalf("date-only = %q, want 2026-06-15", got)
|
||||
}
|
||||
withClock := time.Date(2026, 6, 15, 14, 30, 0, 0, time.UTC)
|
||||
if got := dueToJSON(&withClock); got != "2026-06-15T14:30:00Z" {
|
||||
t.Fatalf("with-clock = %q, want RFC3339", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemRendersChecklist(t *testing.T) {
|
||||
if (&Item{Render: "checklist"}).RendersChecklist() != true {
|
||||
t.Fatal("checklist render not detected")
|
||||
}
|
||||
if (&Item{Render: ""}).RendersChecklist() != false {
|
||||
t.Fatal("empty render should be false")
|
||||
}
|
||||
if (&Item{Render: "card"}).RendersChecklist() != false {
|
||||
t.Fatal("non-checklist render should be false")
|
||||
}
|
||||
}
|
||||
744
store/mbrian_writer.go
Normal file
744
store/mbrian_writer.go
Normal file
@@ -0,0 +1,744 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Phase 6 Slice C — MBrianWriter is the live write-path adapter against
|
||||
// mBrian's scoped HTTP write API. Per mbrian/head's mechanism call
|
||||
// (option (c), 2026-06-01, m/mBrian#73): projax writes go through an HTTP
|
||||
// API that reuses mBrian's db.ts slug-generation / collision-resolution /
|
||||
// singleton logic, NOT raw SQL and NOT SECURITY DEFINER functions —
|
||||
// reimplementing that logic in SQL would drift.
|
||||
//
|
||||
// So this is an HTTP CLIENT, not a pgx writer. Reads stay direct-DB
|
||||
// (MBrianReader, slice B). The asymmetry is deliberate: reads are pure
|
||||
// projections; writes must funnel through db.ts so projax-created nodes
|
||||
// are byte-identical to UI / MCP / migration-script nodes.
|
||||
//
|
||||
// The scoped API enforces projax-ownership server-side (every node it
|
||||
// touches must carry metadata.projax_origin) so a projax bug can never
|
||||
// corrupt non-projax mBrian nodes (m's journal / contacts / health).
|
||||
//
|
||||
// Final surface (m/mBrian#73 issuecomment-10720):
|
||||
// POST /api/projax/nodes {title, content_md?, aliases?, mai_managed?, projax_origin, projax:{...}} → 201 {id, slug}
|
||||
// PATCH /api/projax/nodes/{id} {title?, content_md?, aliases?, projax?:{...partial}} → 200 {id, slug}
|
||||
// DELETE /api/projax/nodes/{id} → 204 (soft-delete)
|
||||
// POST /api/projax/edges {source, target, rel, metadata?} → 201|200 {id}
|
||||
// DELETE /api/projax/edges {source, target, rel} → 204
|
||||
// Bearer-token auth; base URL + token from PROJAX_MBRIAN_API_URL /
|
||||
// PROJAX_MBRIAN_API_TOKEN (projax-side env names; the mBrian server reads
|
||||
// the same secret under PROJAX_WRITE_TOKEN). Errors: 400 malformed,
|
||||
// 401 bad token, 403 not projax-owned, 404 missing, 500 db.ts error,
|
||||
// 503 token not configured server-side (fail-closed: backend not ready).
|
||||
//
|
||||
// KNOWN API GAPS flagged to head (m/mBrian#73), reconcile before relying
|
||||
// on them in production:
|
||||
// G1 (latent) — edges key on (source,target,rel) only. POST is
|
||||
// idempotent on that tuple and DELETE removes by it, so an item
|
||||
// cannot hold two links of the same ref_type (e.g. two dated docs,
|
||||
// multiple gitea-issues, multiple calendars). Current data has zero
|
||||
// such cases (verified), so this is latent, not an active break.
|
||||
// G2 — POST /edges has no `note` field; AddLinkDated's note is captured
|
||||
// in edge metadata.note instead of the edge.note column the reader
|
||||
// reads. Notes added post-cutover won't surface as ItemLink.Note
|
||||
// until the reader also reads metadata.note (or the API gains note).
|
||||
// G3 (active) — PATCH exposes no pinned/archived; the reader reads those
|
||||
// from node columns. This writer captures them in metadata.projax.
|
||||
// {pinned,archived} so intent isn't lost, but a star/archive toggle
|
||||
// won't round-trip until the reader falls back to metadata.projax
|
||||
// (recommended — keeps pinned/archived alongside status/tags) or the
|
||||
// API exposes the columns.
|
||||
// G4 (minor) — Create has no arbitrary top-level metadata passthrough;
|
||||
// CreateInput.Metadata keys outside the projax bundle are dropped
|
||||
// (no current caller sets them).
|
||||
|
||||
// MBrianWriter satisfies store.ItemWriter by calling mBrian's scoped
|
||||
// /api/projax/* HTTP write API. It holds a direct pool for the narrow
|
||||
// read-backs the write path needs (materialising the created/updated
|
||||
// Item, resolving an edge's source/target/rel from its id before a
|
||||
// DELETE, diffing child_of edges on reparent) — reads stay direct-DB.
|
||||
type MBrianWriter struct {
|
||||
baseURL string
|
||||
token string
|
||||
http *http.Client
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewMBrianWriter wires the writer to mBrian's HTTP write API. baseURL is
|
||||
// the scheme+host (e.g. https://mbrian.x.msbls.de); token is the shared
|
||||
// bearer. pool is the same msupabase pool the reader uses, for the
|
||||
// read-backs.
|
||||
func NewMBrianWriter(baseURL, token string, pool *pgxpool.Pool) *MBrianWriter {
|
||||
return &MBrianWriter{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
token: token,
|
||||
http: &http.Client{Timeout: 15 * time.Second},
|
||||
pool: pool,
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time witness: MBrianWriter satisfies ItemWriter.
|
||||
var _ ItemWriter = (*MBrianWriter)(nil)
|
||||
|
||||
// reader builds a read adapter over the same pool for write-path
|
||||
// read-backs (materialise created/updated items, etc.).
|
||||
func (w *MBrianWriter) reader() *MBrianReader { return NewMBrianReader(w.pool) }
|
||||
|
||||
// ====================================================================
|
||||
// HTTP plumbing
|
||||
// ====================================================================
|
||||
|
||||
// APIError is the typed error returned for any non-2xx response from the
|
||||
// mBrian write API, carrying the HTTP status + server message so handlers
|
||||
// can render it without substring-matching.
|
||||
type APIError struct {
|
||||
Status int
|
||||
Message string
|
||||
Op string // e.g. "POST /api/projax/nodes"
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
msg := e.Message
|
||||
if msg == "" {
|
||||
msg = http.StatusText(e.Status)
|
||||
}
|
||||
switch e.Status {
|
||||
case http.StatusUnauthorized:
|
||||
return fmt.Sprintf("mBrian write API %s: unauthorized (token missing or wrong)", e.Op)
|
||||
case http.StatusForbidden:
|
||||
return fmt.Sprintf("mBrian write API %s: target node is not projax-owned", e.Op)
|
||||
case http.StatusServiceUnavailable:
|
||||
return fmt.Sprintf("mBrian write API %s: write backend not ready (token not configured)", e.Op)
|
||||
}
|
||||
return fmt.Sprintf("mBrian write API %s: %d %s", e.Op, e.Status, msg)
|
||||
}
|
||||
|
||||
// do issues one JSON request to the mBrian write API and decodes a JSON
|
||||
// response into out (when out != nil and the response carries a body). A
|
||||
// non-2xx status becomes an *APIError; a 404 additionally wraps
|
||||
// ErrNotFound so callers branching on it keep working.
|
||||
func (w *MBrianWriter) do(ctx context.Context, method, path string, body, out any) error {
|
||||
op := method + " " + path
|
||||
if w.baseURL == "" {
|
||||
return &APIError{Status: http.StatusServiceUnavailable, Message: "PROJAX_MBRIAN_API_URL not set", Op: op}
|
||||
}
|
||||
if w.token == "" {
|
||||
// Never send an empty Bearer — fail closed and legibly.
|
||||
return &APIError{Status: http.StatusServiceUnavailable, Message: "PROJAX_MBRIAN_API_TOKEN not set", Op: op}
|
||||
}
|
||||
|
||||
var reqBody io.Reader
|
||||
if body != nil {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: marshal body: %w", op, err)
|
||||
}
|
||||
reqBody = bytes.NewReader(buf)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, w.baseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: build request: %w", op, err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+w.token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := w.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", op, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
apiErr := &APIError{Status: resp.StatusCode, Message: extractAPIMessage(raw), Op: op}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("%w: %w", ErrNotFound, apiErr)
|
||||
}
|
||||
return apiErr
|
||||
}
|
||||
if out != nil && len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("%s: decode response: %w", op, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractAPIMessage pulls the {"error": "..."} message the API returns on
|
||||
// every non-2xx; falls back to the raw body when it isn't that shape.
|
||||
func extractAPIMessage(raw []byte) string {
|
||||
var env struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if json.Unmarshal(raw, &env) == nil && env.Error != "" {
|
||||
return env.Error
|
||||
}
|
||||
return strings.TrimSpace(string(raw))
|
||||
}
|
||||
|
||||
// mapSlugWriteErr promotes the slug-relevant statuses on a node
|
||||
// create/rename into typed sentinels (ErrSlugTaken / ErrInvalidSlug) that
|
||||
// web handlers + MCP tools branch on to render a clean message, while
|
||||
// keeping the server's *APIError (with its message) in the chain. Only
|
||||
// node create/PATCH carry a slug, so 409/400 here are slug outcomes;
|
||||
// edge ops never route through this.
|
||||
func mapSlugWriteErr(err error) error {
|
||||
var apiErr *APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
switch apiErr.Status {
|
||||
case http.StatusConflict: // 409
|
||||
return fmt.Errorf("%w: %w", ErrSlugTaken, apiErr)
|
||||
case http.StatusBadRequest: // 400
|
||||
return fmt.Errorf("%w: %w", ErrInvalidSlug, apiErr)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// nodeWriteResponse is the {id, slug} shape POST/PATCH /nodes return.
|
||||
type nodeWriteResponse struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Item writes
|
||||
// ====================================================================
|
||||
|
||||
// Create POSTs a new projax node, then writes its child_of parent edges,
|
||||
// then materialises the result via the reader so the returned Item.ID is
|
||||
// the live mBrian uuid + its derived path — the exact round-trip the
|
||||
// slice-B half-flip broke (create-child against an mBrian-read parent).
|
||||
func (w *MBrianWriter) Create(ctx context.Context, in CreateInput) (*Item, error) {
|
||||
if len(in.Kind) == 0 {
|
||||
return nil, errors.New("kind required")
|
||||
}
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
return nil, errors.New("title required")
|
||||
}
|
||||
// New (non-migrated) nodes need a non-empty projax_origin so the
|
||||
// server's ownership gate accepts later PATCH/DELETE/edge ops. Mint a
|
||||
// fresh uuid — the audit marker for projax-born nodes.
|
||||
origin, err := newUUIDv4()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mint projax_origin: %w", err)
|
||||
}
|
||||
body := map[string]any{
|
||||
"title": in.Title,
|
||||
"content_md": in.ContentMD,
|
||||
"mai_managed": containsString(in.Kind, "mai-managed"),
|
||||
"projax_origin": origin,
|
||||
"projax": projaxBundleForCreate(in),
|
||||
}
|
||||
// Honor an explicit slug (the create form / MCP slug arg). Absent →
|
||||
// mBrian title-derives + auto-suffixes. Slug is required on the create
|
||||
// paths, so this is normally always set.
|
||||
if slug := strings.TrimSpace(in.Slug); slug != "" {
|
||||
body["slug"] = slug
|
||||
}
|
||||
var resp nodeWriteResponse
|
||||
if err := w.do(ctx, http.MethodPost, "/api/projax/nodes", body, &resp); err != nil {
|
||||
return nil, mapSlugWriteErr(err)
|
||||
}
|
||||
// Parent links are child_of edges. Idempotent POST per parent.
|
||||
for _, pid := range dedupe(in.ParentIDs) {
|
||||
if pid == "" {
|
||||
continue
|
||||
}
|
||||
if err := w.postEdge(ctx, resp.ID, pid, "child_of", nil); err != nil {
|
||||
return nil, fmt.Errorf("create %s: add parent %s: %w", resp.ID, pid, err)
|
||||
}
|
||||
}
|
||||
return w.reader().GetByID(ctx, resp.ID)
|
||||
}
|
||||
|
||||
// Update PATCHes the node's editable fields, then reconciles its child_of
|
||||
// edges to match in.ParentIDs (the detail-edit form ships parent_ids in
|
||||
// the same submit), then materialises the updated Item via the reader.
|
||||
func (w *MBrianWriter) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
|
||||
body := map[string]any{
|
||||
"title": in.Title,
|
||||
"content_md": in.ContentMD,
|
||||
"projax": projaxBundleForUpdate(in),
|
||||
}
|
||||
// Send slug only on a GENUINE rename. The detail-edit form and the bulk
|
||||
// path both carry the current slug in UpdateInput; sending an unchanged
|
||||
// slug would trip mBrian's rename-cascade (wikilink rewrite + alias
|
||||
// append) for no reason on every edit. Read the current slug and include
|
||||
// it only when it actually changed. A read failure → skip the rename
|
||||
// (safe: no spurious slug write).
|
||||
if newSlug := strings.TrimSpace(in.Slug); newSlug != "" {
|
||||
if cur, err := w.reader().GetByID(ctx, id); err == nil && cur.Slug != newSlug {
|
||||
body["slug"] = newSlug
|
||||
}
|
||||
}
|
||||
var resp nodeWriteResponse
|
||||
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, &resp); err != nil {
|
||||
return nil, mapSlugWriteErr(err)
|
||||
}
|
||||
if err := w.syncParents(ctx, id, in.ParentIDs); err != nil {
|
||||
return nil, fmt.Errorf("update %s: sync parents: %w", id, err)
|
||||
}
|
||||
return w.reader().GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Reparent replaces the node's child_of parent set entirely.
|
||||
func (w *MBrianWriter) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {
|
||||
if err := w.syncParents(ctx, id, parentIDs); err != nil {
|
||||
return nil, fmt.Errorf("reparent %s: %w", id, err)
|
||||
}
|
||||
return w.reader().GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// AddParent appends one child_of edge without disturbing existing ones.
|
||||
// POST /edges is idempotent, so a duplicate parent is a no-op.
|
||||
func (w *MBrianWriter) AddParent(ctx context.Context, id, parentID string) (*Item, error) {
|
||||
if err := w.postEdge(ctx, id, parentID, "child_of", nil); err != nil {
|
||||
return nil, fmt.Errorf("add parent %s→%s: %w", id, parentID, err)
|
||||
}
|
||||
return w.reader().GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// SetPublic flips public.enabled on each id. public lives nested under
|
||||
// metadata.projax.public and PATCH replaces the whole `public` sub-object
|
||||
// (shallow merge is at the projax-key level), so we read each item and
|
||||
// re-send its full public bundle with enabled toggled — never clobber the
|
||||
// description / urls / screenshots.
|
||||
func (w *MBrianWriter) SetPublic(ctx context.Context, ids []string, public bool) error {
|
||||
rd := w.reader()
|
||||
for _, id := range ids {
|
||||
it, err := rd.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set public: load %s: %w", id, err)
|
||||
}
|
||||
body := map[string]any{
|
||||
"projax": map[string]any{
|
||||
"public": publicBundle(public, it.PublicDescription, it.PublicLiveURL, it.PublicSourceURL, it.PublicScreenshots),
|
||||
},
|
||||
}
|
||||
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPinned flips pinned on each id. GAP G3: pinned is a node column the
|
||||
// PATCH surface doesn't expose, so we capture it in metadata.projax.pinned
|
||||
// (best-effort, won't round-trip through the column-reading reader until
|
||||
// that's reconciled — see the file header).
|
||||
func (w *MBrianWriter) SetPinned(ctx context.Context, ids []string, pinned bool) error {
|
||||
for _, id := range ids {
|
||||
body := map[string]any{
|
||||
"projax": map[string]any{"pinned": pinned},
|
||||
}
|
||||
if err := w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+id, body, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SoftDelete soft-deletes one node (sets deleted_at; edges preserved).
|
||||
func (w *MBrianWriter) SoftDelete(ctx context.Context, id string) error {
|
||||
return w.do(ctx, http.MethodDelete, "/api/projax/nodes/"+id, nil, nil)
|
||||
}
|
||||
|
||||
// SoftDeleteCascade soft-deletes the node and, when cascade is true, every
|
||||
// descendant. Without cascade it refuses if any live descendant exists —
|
||||
// same contract as *Store.SoftDeleteCascade. Descendants are resolved
|
||||
// projax-side via the reader's derived paths (cycle-safe, depth-capped),
|
||||
// then soft-deleted one node at a time (the HTTP API is single-node).
|
||||
func (w *MBrianWriter) SoftDeleteCascade(ctx context.Context, id string, cascade bool) error {
|
||||
rd := w.reader()
|
||||
it, err := rd.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := rd.ListAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
descendants := descendantsOf(it, all)
|
||||
if len(descendants) > 0 && !cascade {
|
||||
return ErrHasLiveChildren
|
||||
}
|
||||
for _, d := range descendants {
|
||||
if err := w.SoftDelete(ctx, d.ID); err != nil {
|
||||
return fmt.Errorf("cascade soft-delete %s: %w", d.ID, err)
|
||||
}
|
||||
}
|
||||
return w.SoftDelete(ctx, id)
|
||||
}
|
||||
|
||||
// descendantsOf returns every item in all whose path sits strictly under
|
||||
// one of target's primary paths, plus any direct child naming target as a
|
||||
// parent. Mirrors *Store.SoftDeleteCascade's predicate without SQL.
|
||||
func descendantsOf(target *Item, all []*Item) []*Item {
|
||||
prefixes := make([]string, 0, len(target.Paths))
|
||||
for _, p := range target.Paths {
|
||||
prefixes = append(prefixes, p+".")
|
||||
}
|
||||
out := []*Item{}
|
||||
for _, it := range all {
|
||||
if it.ID == target.ID {
|
||||
continue
|
||||
}
|
||||
hit := containsString(it.ParentIDs, target.ID)
|
||||
if !hit {
|
||||
for _, p := range it.Paths {
|
||||
for _, pfx := range prefixes {
|
||||
if strings.HasPrefix(p, pfx) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Link writes — projax-* self-edges
|
||||
// ====================================================================
|
||||
|
||||
// AddLink writes an external-reference self-edge (source==target==item,
|
||||
// rel='projax-<refType>') carrying the typed payload in edge metadata so
|
||||
// the reader's linkFromEdge can decode it. GAP G1: POST /edges is
|
||||
// idempotent on (source,target,rel), so a second link of the same
|
||||
// ref_type on one item returns the existing edge instead of a new one.
|
||||
func (w *MBrianWriter) AddLink(ctx context.Context, itemID, refType, refID, rel string, metadata map[string]any) (*ItemLink, error) {
|
||||
return w.addLink(ctx, itemID, refType, refID, rel, nil, nil, metadata)
|
||||
}
|
||||
|
||||
// AddLinkDated is AddLink with an event_date + explicit note. GAP G2: the
|
||||
// note rides in edge metadata (the API has no note field), so it won't
|
||||
// surface as ItemLink.Note via the column-reading reader yet.
|
||||
func (w *MBrianWriter) AddLinkDated(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
|
||||
return w.addLink(ctx, itemID, refType, refID, rel, note, eventDate, metadata)
|
||||
}
|
||||
|
||||
func (w *MBrianWriter) addLink(ctx context.Context, itemID, refType, refID, rel string, note *string, eventDate *time.Time, metadata map[string]any) (*ItemLink, error) {
|
||||
if rel == "" {
|
||||
rel = "contains"
|
||||
}
|
||||
meta := edgeMetadataForLink(refType, refID, rel, note, eventDate, metadata)
|
||||
edgeID, err := w.postEdgeReturningID(ctx, itemID, itemID, "projax-"+refType, meta)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add link %s on %s: %w", refType, itemID, err)
|
||||
}
|
||||
l := &ItemLink{
|
||||
ID: edgeID,
|
||||
ItemID: itemID,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
Rel: rel,
|
||||
Note: note,
|
||||
Metadata: map[string]any{},
|
||||
EventDate: eventDate,
|
||||
CreatedAt: time.Time{}, // server-assigned; re-read via the reader if needed
|
||||
}
|
||||
maps.Copy(l.Metadata, metadata)
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// DeleteLink removes a link self-edge by its edge id. The HTTP API deletes
|
||||
// by (source,target,rel), so we resolve those from the edge id via a
|
||||
// direct read-back. GAP G1 guard: if more than one edge shares that
|
||||
// (source,target,rel) — only possible for >1 same-ref_type links on an
|
||||
// item, which the current data never has — we refuse rather than delete
|
||||
// all of them, surfacing the limitation instead of losing siblings.
|
||||
func (w *MBrianWriter) DeleteLink(ctx context.Context, id string) error {
|
||||
var source, target, rel string
|
||||
err := w.pool.QueryRow(ctx,
|
||||
`SELECT source_id::text, target_id::text, rel FROM mbrian.edges WHERE id = $1`, id).
|
||||
Scan(&source, &target, &rel)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return fmt.Errorf("delete link %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return fmt.Errorf("delete link %s: resolve edge: %w", id, err)
|
||||
}
|
||||
var siblings int
|
||||
if err := w.pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM mbrian.edges WHERE source_id = $1 AND target_id = $2 AND rel = $3`,
|
||||
source, target, rel).Scan(&siblings); err != nil {
|
||||
return fmt.Errorf("delete link %s: count siblings: %w", id, err)
|
||||
}
|
||||
if siblings > 1 {
|
||||
return fmt.Errorf("delete link %s: %d edges share (source,target,rel=%s); the mBrian edge API cannot delete a single one (gap G1)", id, siblings, rel)
|
||||
}
|
||||
return w.deleteEdge(ctx, source, target, rel)
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Edge helpers
|
||||
// ====================================================================
|
||||
|
||||
func (w *MBrianWriter) postEdge(ctx context.Context, source, target, rel string, metadata map[string]any) error {
|
||||
_, err := w.postEdgeReturningID(ctx, source, target, rel, metadata)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *MBrianWriter) postEdgeReturningID(ctx context.Context, source, target, rel string, metadata map[string]any) (string, error) {
|
||||
body := map[string]any{"source": source, "target": target, "rel": rel}
|
||||
if metadata != nil {
|
||||
body["metadata"] = metadata
|
||||
}
|
||||
var resp struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := w.do(ctx, http.MethodPost, "/api/projax/edges", body, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.ID, nil
|
||||
}
|
||||
|
||||
func (w *MBrianWriter) deleteEdge(ctx context.Context, source, target, rel string) error {
|
||||
body := map[string]any{"source": source, "target": target, "rel": rel}
|
||||
return w.do(ctx, http.MethodDelete, "/api/projax/edges", body, nil)
|
||||
}
|
||||
|
||||
// childOfTargets returns the current child_of parent ids for a node,
|
||||
// read direct-DB (a write-path read-back, scoped to projax-managed nodes).
|
||||
func (w *MBrianWriter) childOfTargets(ctx context.Context, id string) ([]string, error) {
|
||||
rows, err := w.pool.Query(ctx,
|
||||
`SELECT target_id::text FROM mbrian.edges WHERE source_id = $1 AND rel = 'child_of'`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// syncParents diffs the node's current child_of edges against the desired
|
||||
// parent set, deleting removed edges and adding new ones. Reparent and the
|
||||
// parent-changing part of Update both route through here.
|
||||
func (w *MBrianWriter) syncParents(ctx context.Context, id string, desired []string) error {
|
||||
want := map[string]bool{}
|
||||
for _, p := range dedupe(desired) {
|
||||
if p != "" {
|
||||
want[p] = true
|
||||
}
|
||||
}
|
||||
current, err := w.childOfTargets(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, p := range current {
|
||||
have[p] = true
|
||||
}
|
||||
for p := range have {
|
||||
if !want[p] {
|
||||
if err := w.deleteEdge(ctx, id, p, "child_of"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for p := range want {
|
||||
if !have[p] {
|
||||
if err := w.postEdge(ctx, id, p, "child_of", nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Payload shaping
|
||||
// ====================================================================
|
||||
|
||||
// projaxBundleForCreate builds the metadata.projax object for a new node.
|
||||
// The server stores it verbatim under metadata.projax. pinned is captured
|
||||
// here per GAP G3.
|
||||
func projaxBundleForCreate(in CreateInput) map[string]any {
|
||||
kind := "project"
|
||||
if containsString(in.Kind, "area") {
|
||||
kind = "area"
|
||||
}
|
||||
status := in.Status
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
b := map[string]any{
|
||||
"kind": kind,
|
||||
"status": status,
|
||||
"tags": orEmpty(in.Tags),
|
||||
"management": orEmpty(in.Management),
|
||||
"public": map[string]any{},
|
||||
"timeline_exclude": []string{},
|
||||
"start_time": timePtrToJSON(in.StartTime),
|
||||
"end_time": timePtrToJSON(in.EndTime),
|
||||
}
|
||||
if in.Pinned {
|
||||
b["pinned"] = true
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// projaxBundleForUpdate builds the partial metadata.projax object for a
|
||||
// PATCH. PATCH shallow-merges these keys into the existing projax bundle,
|
||||
// so we send the full set the edit form owns (status/tags/management/
|
||||
// public/timeline_exclude/start/end) + pinned/archived (GAP G3).
|
||||
func projaxBundleForUpdate(in UpdateInput) map[string]any {
|
||||
return map[string]any{
|
||||
"status": in.Status,
|
||||
"tags": orEmpty(in.Tags),
|
||||
"management": orEmpty(in.Management),
|
||||
"public": publicBundle(in.Public, in.PublicDescription, in.PublicLiveURL, in.PublicSourceURL, in.PublicScreenshots),
|
||||
"timeline_exclude": orEmpty(in.TimelineExclude),
|
||||
"start_time": timePtrToJSON(in.StartTime),
|
||||
"end_time": timePtrToJSON(in.EndTime),
|
||||
"pinned": in.Pinned,
|
||||
"archived": in.Archived,
|
||||
// Phase 7 checklist render hint (Q1). Shallow-merged into
|
||||
// metadata.projax; "" turns the compact render off.
|
||||
"render": in.Render,
|
||||
}
|
||||
}
|
||||
|
||||
// publicBundle mirrors the reader's metadata.projax.public.* shape.
|
||||
func publicBundle(enabled bool, description, liveURL, sourceURL string, screenshots []string) map[string]any {
|
||||
return map[string]any{
|
||||
"enabled": enabled,
|
||||
"description": description,
|
||||
"live_url": liveURL,
|
||||
"source_url": sourceURL,
|
||||
"screenshots": orEmpty(screenshots),
|
||||
}
|
||||
}
|
||||
|
||||
// edgeMetadataForLink produces the edge metadata the reader's linkFromEdge
|
||||
// can decode back into an ItemLink: the typed per-ref_type payload, the
|
||||
// free-form rel under projax_rel, the canonical ref_id, an optional
|
||||
// event_date, the note (GAP G2), and any caller-supplied extras.
|
||||
func edgeMetadataForLink(refType, refID, rel string, note *string, eventDate *time.Time, extra map[string]any) map[string]any {
|
||||
m := map[string]any{}
|
||||
maps.Copy(m, extra)
|
||||
m["projax_rel"] = rel
|
||||
m["ref_id"] = refID
|
||||
switch refType {
|
||||
case "caldav-list":
|
||||
m["url"] = refID
|
||||
case "gitea-repo":
|
||||
if owner, repo, ok := splitOwnerRepo(refID); ok {
|
||||
m["owner"], m["repo"] = owner, repo
|
||||
}
|
||||
case "gitea-issue":
|
||||
if owner, repo, num, ok := splitOwnerRepoIssue(refID); ok {
|
||||
m["owner"], m["repo"], m["number"] = owner, repo, num
|
||||
}
|
||||
case "mai-project":
|
||||
m["mai_project_id"] = refID
|
||||
case "url", "doc", "document", "note":
|
||||
m["url"] = refID
|
||||
}
|
||||
if eventDate != nil {
|
||||
m["event_date"] = eventDate.Format("2006-01-02")
|
||||
}
|
||||
if note != nil && *note != "" {
|
||||
m["note"] = *note
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func splitOwnerRepo(s string) (string, string, bool) {
|
||||
parts := strings.SplitN(s, "/", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
|
||||
func splitOwnerRepoIssue(s string) (string, string, int, bool) {
|
||||
hash := strings.LastIndex(s, "#")
|
||||
if hash < 0 {
|
||||
return "", "", 0, false
|
||||
}
|
||||
owner, repo, ok := splitOwnerRepo(s[:hash])
|
||||
if !ok {
|
||||
return "", "", 0, false
|
||||
}
|
||||
var num int
|
||||
if _, err := fmt.Sscanf(s[hash+1:], "%d", &num); err != nil || num <= 0 {
|
||||
return "", "", 0, false
|
||||
}
|
||||
return owner, repo, num, true
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Small utilities
|
||||
// ====================================================================
|
||||
|
||||
// newUUIDv4 mints a random RFC-4122 v4 uuid without pulling a uuid
|
||||
// dependency — projax only needs the projax_origin audit marker to be a
|
||||
// non-empty, unique-enough string.
|
||||
func newUUIDv4() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
|
||||
}
|
||||
|
||||
func dedupe(in []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
if seen[s] {
|
||||
continue
|
||||
}
|
||||
seen[s] = true
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func orEmpty(in []string) []string {
|
||||
if in == nil {
|
||||
return []string{}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func timePtrToJSON(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
118
store/mbrian_writer_tasks.go
Normal file
118
store/mbrian_writer_tasks.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phase 7 — mBrian-native task WRITE path. Tasks funnel through the same
|
||||
// scoped /api/projax HTTP surface the slice-C MBrianWriter already uses, so a
|
||||
// projax-created task node is byte-identical to a UI/MCP/migration node. The
|
||||
// only new server-side capability this relies on is the `type` field on
|
||||
// POST /api/projax/nodes (allowlist {project,task}); everything else
|
||||
// (PATCH-projax partial, POST /edges child_of, DELETE node) already exists.
|
||||
|
||||
// Compile-time witness: MBrianWriter satisfies TaskWriter.
|
||||
var _ TaskWriter = (*MBrianWriter)(nil)
|
||||
|
||||
// CreateTask POSTs a type=['task'] node (slug honored, metadata.projax
|
||||
// carrying status + optional due), attaches it to ParentItemID via a child_of
|
||||
// edge, and returns the materialised Task. The returned Task is built from the
|
||||
// known create inputs + the server-assigned id — no read-back round-trip
|
||||
// needed (the next page render re-reads via TasksForItem).
|
||||
func (w *MBrianWriter) CreateTask(ctx context.Context, in TaskCreateInput) (*Task, error) {
|
||||
if strings.TrimSpace(in.Title) == "" {
|
||||
return nil, errors.New("task title required")
|
||||
}
|
||||
if strings.TrimSpace(in.ParentItemID) == "" {
|
||||
return nil, errors.New("task parent required")
|
||||
}
|
||||
origin, err := newUUIDv4()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mint projax_origin: %w", err)
|
||||
}
|
||||
projax := map[string]any{"status": "active"}
|
||||
if in.Due != nil {
|
||||
projax["due"] = dueToJSON(in.Due)
|
||||
}
|
||||
body := map[string]any{
|
||||
"title": in.Title,
|
||||
"type": "task",
|
||||
"content_md": "",
|
||||
"mai_managed": false,
|
||||
"projax_origin": origin,
|
||||
"projax": projax,
|
||||
}
|
||||
if slug := strings.TrimSpace(in.Slug); slug != "" {
|
||||
body["slug"] = slug
|
||||
}
|
||||
var resp nodeWriteResponse
|
||||
if err := w.do(ctx, http.MethodPost, "/api/projax/nodes", body, &resp); err != nil {
|
||||
return nil, mapSlugWriteErr(err)
|
||||
}
|
||||
if err := w.postEdge(ctx, resp.ID, in.ParentItemID, "child_of", nil); err != nil {
|
||||
return nil, fmt.Errorf("create task %s: attach to %s: %w", resp.ID, in.ParentItemID, err)
|
||||
}
|
||||
slug := strings.TrimSpace(in.Slug)
|
||||
if resp.Slug != "" {
|
||||
slug = resp.Slug // server-resolved (auto-suffix etc.) wins
|
||||
}
|
||||
return &Task{
|
||||
ID: resp.ID,
|
||||
Title: in.Title,
|
||||
Done: false,
|
||||
Due: in.Due,
|
||||
Source: TaskSourceMBrian,
|
||||
Status: "active",
|
||||
ParentItemID: in.ParentItemID,
|
||||
NodeID: resp.ID,
|
||||
Slug: slug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetTaskStatus PATCHes metadata.projax.status. Task done-state reuses the
|
||||
// existing lifecycle (Q2): done = status "done", reopen = status "active".
|
||||
func (w *MBrianWriter) SetTaskStatus(ctx context.Context, nodeID, status string) error {
|
||||
body := map[string]any{"projax": map[string]any{"status": status}}
|
||||
return w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+nodeID, body, nil)
|
||||
}
|
||||
|
||||
// SetTaskDue PATCHes metadata.projax.due. A nil due writes "" — the reader's
|
||||
// parseTimeAny treats an empty string as no-due, so this clears it
|
||||
// deterministically without depending on null-key-delete semantics in the
|
||||
// shallow-merge PATCH.
|
||||
func (w *MBrianWriter) SetTaskDue(ctx context.Context, nodeID string, due *time.Time) error {
|
||||
val := ""
|
||||
if due != nil {
|
||||
val = dueToJSON(due)
|
||||
}
|
||||
body := map[string]any{"projax": map[string]any{"due": val}}
|
||||
return w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+nodeID, body, nil)
|
||||
}
|
||||
|
||||
// EditTaskTitle PATCHes the node title.
|
||||
func (w *MBrianWriter) EditTaskTitle(ctx context.Context, nodeID, title string) error {
|
||||
body := map[string]any{"title": title}
|
||||
return w.do(ctx, http.MethodPatch, "/api/projax/nodes/"+nodeID, body, nil)
|
||||
}
|
||||
|
||||
// DeleteTask soft-deletes the task node (reuses the node DELETE endpoint).
|
||||
func (w *MBrianWriter) DeleteTask(ctx context.Context, nodeID string) error {
|
||||
return w.SoftDelete(ctx, nodeID)
|
||||
}
|
||||
|
||||
// dueToJSON renders a due date for metadata.projax.due. Date-only (clock at
|
||||
// midnight) → "2006-01-02"; otherwise RFC3339. parseTimeAny reads both back.
|
||||
func dueToJSON(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
return t.Format(time.RFC3339)
|
||||
}
|
||||
151
store/mbrian_writer_tasks_integration_test.go
Normal file
151
store/mbrian_writer_tasks_integration_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// TestMBrianTaskRoundTrip is the Phase 7b end-to-end verification: it drives a
|
||||
// full mBrian-native task lifecycle (create → read → done → due → delete)
|
||||
// against the LIVE mBrian write API + read DB, then asserts the task never
|
||||
// leaks into the project list surface. It is the deploy-verification gate for
|
||||
// "tasks live end-to-end" and is skipped unless all three live endpoints are
|
||||
// configured:
|
||||
//
|
||||
// PROJAX_MBRIAN_API_URL e.g. https://mbrian.x.msbls.de
|
||||
// PROJAX_MBRIAN_API_TOKEN the shared bearer (mBrian-side PROJAX_WRITE_TOKEN)
|
||||
// SUPABASE_DATABASE_URL the msupabase pool the reader uses
|
||||
//
|
||||
// Run with all three set (head/CI):
|
||||
//
|
||||
// go test ./store/ -run TestMBrianTaskRoundTrip -v
|
||||
//
|
||||
// It is fully self-cleaning: it creates a throwaway parent project, attaches a
|
||||
// task, and soft-deletes both at the end.
|
||||
func TestMBrianTaskRoundTrip(t *testing.T) {
|
||||
apiURL := os.Getenv("PROJAX_MBRIAN_API_URL")
|
||||
apiToken := os.Getenv("PROJAX_MBRIAN_API_TOKEN")
|
||||
dbURL := os.Getenv("SUPABASE_DATABASE_URL")
|
||||
if apiURL == "" || apiToken == "" || dbURL == "" {
|
||||
t.Skip("set PROJAX_MBRIAN_API_URL + PROJAX_MBRIAN_API_TOKEN + SUPABASE_DATABASE_URL to run the live task round-trip")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
w := NewMBrianWriter(apiURL, apiToken, pool)
|
||||
rd := NewMBrianReader(pool)
|
||||
|
||||
// Unique-ish suffix without Math.rand/time.Now in the slug logic; the
|
||||
// nanosecond stamp keeps reruns from colliding on the throwaway slugs.
|
||||
suffix := time.Now().UTC().Format("20060102t150405.000000000")
|
||||
|
||||
// 1. Throwaway parent project.
|
||||
parent, err := w.Create(ctx, CreateInput{
|
||||
Kind: []string{"project"},
|
||||
Title: "phase7b-itest-parent " + suffix,
|
||||
Slug: "phase7b-itest-parent-" + sanitizeSlugStamp(suffix),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create parent: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = w.SoftDelete(context.Background(), parent.ID) })
|
||||
|
||||
// 2. Create a task under it (slug auto-derived from title).
|
||||
due := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC)
|
||||
task, err := w.CreateTask(ctx, TaskCreateInput{
|
||||
Title: "phase7b-itest-task " + suffix,
|
||||
ParentItemID: parent.ID,
|
||||
Due: &due,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = w.DeleteTask(context.Background(), task.ID) })
|
||||
if task.Source != TaskSourceMBrian || task.NodeID == "" {
|
||||
t.Fatalf("created task shape wrong: %+v", task)
|
||||
}
|
||||
|
||||
// 3. Read it back via TasksForItem — present, open, due preserved.
|
||||
tasks, err := rd.TasksForItem(ctx, parent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("TasksForItem: %v", err)
|
||||
}
|
||||
got := findTask(tasks, task.ID)
|
||||
if got == nil {
|
||||
t.Fatalf("created task %s not returned by TasksForItem", task.ID)
|
||||
}
|
||||
if got.Done {
|
||||
t.Fatal("fresh task should be open")
|
||||
}
|
||||
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-06-30" {
|
||||
t.Fatalf("due not preserved: %v", got.Due)
|
||||
}
|
||||
|
||||
// 4. The task must NOT leak into the project list surface (Q6 exclusion).
|
||||
all, err := rd.ListAll(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAll: %v", err)
|
||||
}
|
||||
for _, it := range all {
|
||||
if it.ID == task.ID {
|
||||
t.Fatalf("task %s leaked into ListAll (project surface)", task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Mark done → reads back done.
|
||||
if err := w.SetTaskStatus(ctx, task.ID, "done"); err != nil {
|
||||
t.Fatalf("set done: %v", err)
|
||||
}
|
||||
tasks, _ = rd.TasksForItem(ctx, parent.ID)
|
||||
if got = findTask(tasks, task.ID); got == nil || !got.Done {
|
||||
t.Fatalf("task not done after SetTaskStatus: %+v", got)
|
||||
}
|
||||
|
||||
// 6. Clear the due date → reads back nil.
|
||||
if err := w.SetTaskDue(ctx, task.ID, nil); err != nil {
|
||||
t.Fatalf("clear due: %v", err)
|
||||
}
|
||||
tasks, _ = rd.TasksForItem(ctx, parent.ID)
|
||||
if got = findTask(tasks, task.ID); got == nil || got.Due != nil {
|
||||
t.Fatalf("due not cleared: %+v", got)
|
||||
}
|
||||
|
||||
// 7. Delete → gone from TasksForItem.
|
||||
if err := w.DeleteTask(ctx, task.ID); err != nil {
|
||||
t.Fatalf("delete task: %v", err)
|
||||
}
|
||||
tasks, _ = rd.TasksForItem(ctx, parent.ID)
|
||||
if findTask(tasks, task.ID) != nil {
|
||||
t.Fatalf("task %s still present after delete", task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func findTask(tasks []*Task, id string) *Task {
|
||||
for _, t := range tasks {
|
||||
if t.ID == id {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sanitizeSlugStamp strips the dot from the nanosecond stamp so the throwaway
|
||||
// slug stays dot-free (the projax slug invariant).
|
||||
func sanitizeSlugStamp(s string) string {
|
||||
out := make([]rune, 0, len(s))
|
||||
for _, r := range s {
|
||||
if r == '.' {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
323
store/mbrian_writer_test.go
Normal file
323
store/mbrian_writer_test.go
Normal file
@@ -0,0 +1,323 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newTestWriter points an MBrianWriter at an httptest server. pool is nil:
|
||||
// the HTTP-only paths under test (do, postEdge, deleteEdge, AddLink) never
|
||||
// touch it. The pool-backed read-backs (Create/Update/Reparent/SetPublic/
|
||||
// DeleteLink) are exercised by the live cutover round-trip + the reader
|
||||
// parity tests, not here.
|
||||
func newTestWriter(baseURL, token string) *MBrianWriter {
|
||||
return NewMBrianWriter(baseURL, token, nil)
|
||||
}
|
||||
|
||||
func TestMBrianWriterErrorMapping(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
body string
|
||||
wantNotFn bool // expect errors.Is(err, ErrNotFound)
|
||||
wantText string
|
||||
}{
|
||||
{http.StatusUnauthorized, `{"error":"bad token"}`, false, "unauthorized"},
|
||||
{http.StatusForbidden, `{"error":"not projax-owned"}`, false, "not projax-owned"},
|
||||
{http.StatusNotFound, `{"error":"missing"}`, true, ""},
|
||||
{http.StatusServiceUnavailable, `{"error":"token not configured"}`, false, "write backend not ready"},
|
||||
{http.StatusInternalServerError, `{"error":"db boom"}`, false, "db boom"},
|
||||
{http.StatusBadRequest, `{"error":"disallowed rel"}`, false, "disallowed rel"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(c.status)
|
||||
io.WriteString(w, c.body)
|
||||
}))
|
||||
w := newTestWriter(srv.URL, "tok")
|
||||
err := w.do(context.Background(), http.MethodDelete, "/api/projax/nodes/x", nil, nil)
|
||||
srv.Close()
|
||||
if err == nil {
|
||||
t.Fatalf("status %d: expected error, got nil", c.status)
|
||||
}
|
||||
if c.wantNotFn && !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("status %d: expected ErrNotFound wrap, got %v", c.status, err)
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("status %d: expected *APIError in chain, got %v", c.status, err)
|
||||
continue
|
||||
}
|
||||
if apiErr.Status != c.status {
|
||||
t.Errorf("status %d: APIError.Status = %d", c.status, apiErr.Status)
|
||||
}
|
||||
if c.wantText != "" && !strings.Contains(err.Error(), c.wantText) {
|
||||
t.Errorf("status %d: error %q missing %q", c.status, err.Error(), c.wantText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterFailsClosedWithoutToken(t *testing.T) {
|
||||
// No token → must not fire a request with an empty Bearer.
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := newTestWriter(srv.URL, "")
|
||||
err := w.do(context.Background(), http.MethodPost, "/api/projax/nodes", map[string]any{"x": 1}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected fail-closed error with empty token")
|
||||
}
|
||||
if called {
|
||||
t.Error("request was sent despite empty token — must fail closed")
|
||||
}
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503-style APIError, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterFailsClosedWithoutURL(t *testing.T) {
|
||||
w := newTestWriter("", "tok")
|
||||
err := w.do(context.Background(), http.MethodPost, "/api/projax/nodes", nil, nil)
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) || apiErr.Status != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503-style APIError for empty base URL, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterSendsBearerAndJSON(t *testing.T) {
|
||||
var gotAuth, gotCT, gotMethod, gotPath string
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotCT = r.Header.Get("Content-Type")
|
||||
gotMethod = r.Method
|
||||
gotPath = r.URL.Path
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{"id":"e1"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := newTestWriter(srv.URL, "sekrit")
|
||||
var out struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := w.do(context.Background(), http.MethodPost, "/api/projax/edges", map[string]any{"rel": "child_of"}, &out); err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
if gotAuth != "Bearer sekrit" {
|
||||
t.Errorf("Authorization = %q, want Bearer sekrit", gotAuth)
|
||||
}
|
||||
if gotCT != "application/json" {
|
||||
t.Errorf("Content-Type = %q", gotCT)
|
||||
}
|
||||
if gotMethod != http.MethodPost || gotPath != "/api/projax/edges" {
|
||||
t.Errorf("method/path = %s %s", gotMethod, gotPath)
|
||||
}
|
||||
if gotBody["rel"] != "child_of" {
|
||||
t.Errorf("body rel = %v", gotBody["rel"])
|
||||
}
|
||||
if out.ID != "e1" {
|
||||
t.Errorf("decoded id = %q", out.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterAddLinkConstructsSelfEdge(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/projax/edges" || r.Method != http.MethodPost {
|
||||
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{"id":"edge-123"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := newTestWriter(srv.URL, "tok")
|
||||
|
||||
link, err := w.AddLink(context.Background(), "item-1", "caldav-list", "https://dav/cal", "contains",
|
||||
map[string]any{"display_name": "Work"})
|
||||
if err != nil {
|
||||
t.Fatalf("AddLink: %v", err)
|
||||
}
|
||||
// Self-edge: source == target == item, rel namespaced.
|
||||
if gotBody["source"] != "item-1" || gotBody["target"] != "item-1" {
|
||||
t.Errorf("self-edge source/target = %v/%v", gotBody["source"], gotBody["target"])
|
||||
}
|
||||
if gotBody["rel"] != "projax-caldav-list" {
|
||||
t.Errorf("rel = %v, want projax-caldav-list", gotBody["rel"])
|
||||
}
|
||||
meta, _ := gotBody["metadata"].(map[string]any)
|
||||
if meta["url"] != "https://dav/cal" {
|
||||
t.Errorf("metadata.url = %v (reader decodes caldav RefID from here)", meta["url"])
|
||||
}
|
||||
if meta["ref_id"] != "https://dav/cal" {
|
||||
t.Errorf("metadata.ref_id = %v", meta["ref_id"])
|
||||
}
|
||||
if meta["projax_rel"] != "contains" {
|
||||
t.Errorf("metadata.projax_rel = %v", meta["projax_rel"])
|
||||
}
|
||||
if meta["display_name"] != "Work" {
|
||||
t.Errorf("caller metadata not merged: %v", meta["display_name"])
|
||||
}
|
||||
if link.ID != "edge-123" || link.ItemID != "item-1" || link.RefType != "caldav-list" {
|
||||
t.Errorf("returned link = %+v", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterAddLinkDatedCarriesDateAndNote(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
io.WriteString(w, `{"id":"e9"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := newTestWriter(srv.URL, "tok")
|
||||
note := "filed brief"
|
||||
d := time.Date(2026, 3, 14, 0, 0, 0, 0, time.UTC)
|
||||
_, err := w.AddLinkDated(context.Background(), "i1", "doc", "/docs/brief.pdf", "source", ¬e, &d, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddLinkDated: %v", err)
|
||||
}
|
||||
meta, _ := gotBody["metadata"].(map[string]any)
|
||||
if meta["event_date"] != "2026-03-14" {
|
||||
t.Errorf("event_date = %v", meta["event_date"])
|
||||
}
|
||||
if meta["note"] != "filed brief" {
|
||||
t.Errorf("note = %v (gap G2: rides in metadata, not edge.note)", meta["note"])
|
||||
}
|
||||
if meta["url"] != "/docs/brief.pdf" {
|
||||
t.Errorf("doc url = %v", meta["url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeMetadataForLinkPerRefType(t *testing.T) {
|
||||
mGitRepo := edgeMetadataForLink("gitea-repo", "m/projax", "contains", nil, nil, nil)
|
||||
if mGitRepo["owner"] != "m" || mGitRepo["repo"] != "projax" {
|
||||
t.Errorf("gitea-repo owner/repo = %v/%v", mGitRepo["owner"], mGitRepo["repo"])
|
||||
}
|
||||
mIssue := edgeMetadataForLink("gitea-issue", "m/projax#5", "contains", nil, nil, nil)
|
||||
if mIssue["owner"] != "m" || mIssue["repo"] != "projax" || mIssue["number"] != 5 {
|
||||
t.Errorf("gitea-issue parse = %v/%v/#%v", mIssue["owner"], mIssue["repo"], mIssue["number"])
|
||||
}
|
||||
mMai := edgeMetadataForLink("mai-project", "proj-uuid", "contains", nil, nil, nil)
|
||||
if mMai["mai_project_id"] != "proj-uuid" {
|
||||
t.Errorf("mai-project id = %v", mMai["mai_project_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjaxBundleForCreateDefaults(t *testing.T) {
|
||||
b := projaxBundleForCreate(CreateInput{Kind: []string{"project"}, Title: "x"})
|
||||
if b["kind"] != "project" {
|
||||
t.Errorf("kind = %v", b["kind"])
|
||||
}
|
||||
if b["status"] != "active" {
|
||||
t.Errorf("status default = %v, want active", b["status"])
|
||||
}
|
||||
// area co-kind
|
||||
ba := projaxBundleForCreate(CreateInput{Kind: []string{"project", "area"}, Title: "x", Status: "done"})
|
||||
if ba["kind"] != "area" {
|
||||
t.Errorf("area kind = %v", ba["kind"])
|
||||
}
|
||||
if ba["status"] != "done" {
|
||||
t.Errorf("explicit status = %v", ba["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjaxBundleForUpdateNestsPublic(t *testing.T) {
|
||||
b := projaxBundleForUpdate(UpdateInput{
|
||||
Status: "active", Public: true, PublicDescription: "desc", PublicLiveURL: "https://x",
|
||||
})
|
||||
pub, ok := b["public"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("public not a nested object: %T", b["public"])
|
||||
}
|
||||
if pub["enabled"] != true || pub["description"] != "desc" || pub["live_url"] != "https://x" {
|
||||
t.Errorf("public bundle = %v (must match reader's projax.public.* shape)", pub)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBrianWriterCreateSendsSlugAndMaps409(t *testing.T) {
|
||||
// The server asserts the POST body carries the explicit slug, then
|
||||
// answers 409 — which returns before Create's pool-backed read-back, so
|
||||
// no DB is needed. Proves both "slug is sent" and "409 → ErrSlugTaken".
|
||||
var gotSlug any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
gotSlug = body["slug"]
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
io.WriteString(w, `{"error":"slug 'paliad' already exists"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := newTestWriter(srv.URL, "tok")
|
||||
_, err := w.Create(context.Background(), CreateInput{Kind: []string{"project"}, Title: "Paliad", Slug: "paliad"})
|
||||
if gotSlug != "paliad" {
|
||||
t.Errorf("create body slug = %v, want paliad (explicit slug must be sent, not title-derived)", gotSlug)
|
||||
}
|
||||
if !errors.Is(err, ErrSlugTaken) {
|
||||
t.Errorf("409 should map to ErrSlugTaken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapSlugWriteErr(t *testing.T) {
|
||||
cases := []struct {
|
||||
status int
|
||||
want error // sentinel expected via errors.Is, nil = passthrough (no slug sentinel)
|
||||
}{
|
||||
{http.StatusConflict, ErrSlugTaken},
|
||||
{http.StatusBadRequest, ErrInvalidSlug},
|
||||
{http.StatusForbidden, nil},
|
||||
{http.StatusInternalServerError, nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
in := &APIError{Status: c.status, Message: "x", Op: "POST /api/projax/nodes"}
|
||||
got := mapSlugWriteErr(in)
|
||||
if c.want != nil && !errors.Is(got, c.want) {
|
||||
t.Errorf("status %d: expected %v, got %v", c.status, c.want, got)
|
||||
}
|
||||
if c.want == nil && (errors.Is(got, ErrSlugTaken) || errors.Is(got, ErrInvalidSlug)) {
|
||||
t.Errorf("status %d: should not map to a slug sentinel, got %v", c.status, got)
|
||||
}
|
||||
// The original *APIError must stay in the chain either way.
|
||||
var apiErr *APIError
|
||||
if !errors.As(got, &apiErr) || apiErr.Status != c.status {
|
||||
t.Errorf("status %d: lost the *APIError in the chain: %v", c.status, got)
|
||||
}
|
||||
}
|
||||
// Non-APIError passes through untouched.
|
||||
plain := errors.New("network down")
|
||||
if got := mapSlugWriteErr(plain); got != plain {
|
||||
t.Errorf("non-APIError should pass through unchanged, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
var uuidV4Re = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
|
||||
func TestNewUUIDv4Format(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for range 100 {
|
||||
u, err := newUUIDv4()
|
||||
if err != nil {
|
||||
t.Fatalf("newUUIDv4: %v", err)
|
||||
}
|
||||
if !uuidV4Re.MatchString(u) {
|
||||
t.Fatalf("not a v4 uuid: %q", u)
|
||||
}
|
||||
if seen[u] {
|
||||
t.Fatalf("duplicate uuid %q", u)
|
||||
}
|
||||
seen[u] = true
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,20 @@ type Item struct {
|
||||
// /timeline aggregation. Values: 'todos' | 'events' | 'docs' | 'creation'.
|
||||
// Empty array (default) = nothing excluded = current behaviour.
|
||||
TimelineExclude []string
|
||||
// Phase 7 render hint. When "checklist", this container's child tasks
|
||||
// render in compact checklist mode (design Q1 — a "tasklist"/"checklist"
|
||||
// is any container carrying this hint, not a new type). Empty = the
|
||||
// default roomy task rows. mBrian-backed only (metadata.projax.render);
|
||||
// the legacy *Store leaves it "".
|
||||
Render string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// RendersChecklist reports whether this container should render its child
|
||||
// tasks as a compact checklist (Phase 7, Q1).
|
||||
func (it *Item) RendersChecklist() bool { return it.Render == "checklist" }
|
||||
|
||||
// ExcludesTimelineKind reports whether this item's timeline_exclude array
|
||||
// names the given kind. The aggregator uses the singular form ("todo",
|
||||
// "event", "doc", "creation"); the persisted values use the plural form
|
||||
@@ -123,6 +133,17 @@ func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
||||
|
||||
var ErrNotFound = errors.New("projax: item not found")
|
||||
|
||||
// ErrSlugTaken is returned when a create or rename hits a slug collision —
|
||||
// mBrian's write API answers 409. Covers both a live node and a
|
||||
// soft-deleted tombstone squatting on the slug (the latter the projax-side
|
||||
// validator can't see, since it scopes to non-deleted nodes). Handlers +
|
||||
// MCP branch on it via errors.Is to surface a clean "slug taken" message.
|
||||
var ErrSlugTaken = errors.New("projax: slug already taken")
|
||||
|
||||
// ErrInvalidSlug is returned when the write API rejects a slug as
|
||||
// malformed or empty (400).
|
||||
var ErrInvalidSlug = errors.New("projax: invalid slug")
|
||||
|
||||
const itemsUnifiedCols = `id, kind, title, slug, paths, parent_ids, content_md, aliases,
|
||||
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
|
||||
tags, management, public, public_description, public_live_url, public_source_url,
|
||||
@@ -312,6 +333,10 @@ type UpdateInput struct {
|
||||
// Phase 4f timeline-exclude. Full-replace; values 'todos' / 'events' /
|
||||
// 'docs' / 'creation'.
|
||||
TimelineExclude []string
|
||||
// Phase 7 checklist render hint. "checklist" → child tasks render
|
||||
// compact; "" → default. mBrian-backed only (the legacy *Store.Update
|
||||
// ignores it — there is no projax.items column for it).
|
||||
Render string
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
|
||||
|
||||
91
store/task.go
Normal file
91
store/task.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Phase 7 — Task is the uniform view-shape for a unit of work attached to a
|
||||
// projax item, materialised from EITHER a CalDAV VTODO or an mBrian
|
||||
// type=['task'] node. One shape, two sources; writes dispatch on Source
|
||||
// (design §3.3, the slice-B "one Item shape, two backends" pattern applied
|
||||
// to tasks). Consumers (the detail Tasks section, future dashboard/timeline
|
||||
// rollups) render the uniform shape and don't care which backend produced it.
|
||||
type Task struct {
|
||||
// ID is a stable per-source identifier: the mBrian node uuid for an
|
||||
// mBrian-native task, the VTODO UID for a CalDAV task. Unique within a
|
||||
// source; templates key rows on it.
|
||||
ID string
|
||||
Title string
|
||||
Done bool
|
||||
Due *time.Time
|
||||
// Source is "mbrian" or "caldav" — write handlers dispatch on it.
|
||||
Source string
|
||||
// Status is the raw lifecycle status for mBrian tasks (active|done|
|
||||
// archived). For CalDAV tasks it carries the VTODO STATUS verbatim
|
||||
// (NEEDS-ACTION|IN-PROCESS|COMPLETED|CANCELLED) for callers that want it;
|
||||
// Done is the normalised boolean either way.
|
||||
Status string
|
||||
// ParentItemID is the projax item this task hangs under.
|
||||
ParentItemID string
|
||||
CreatedAt time.Time
|
||||
|
||||
// --- mBrian-source handle (Source == TaskSourceMBrian) ---
|
||||
// NodeID is the mBrian node uuid (== ID); the write API targets it.
|
||||
NodeID string
|
||||
Slug string
|
||||
|
||||
// --- CalDAV-source handle (Source == TaskSourceCalDAV) ---
|
||||
// CalendarURL + UID address the VTODO for ETag-guarded writeback via the
|
||||
// existing caldav write path.
|
||||
CalendarURL string
|
||||
UID string
|
||||
}
|
||||
|
||||
// Task source discriminators.
|
||||
const (
|
||||
TaskSourceMBrian = "mbrian"
|
||||
TaskSourceCalDAV = "caldav"
|
||||
)
|
||||
|
||||
// TaskCreateInput captures the editable surface of a new mBrian-native task.
|
||||
// CalDAV tasks are created through the existing CalDAV write path (VTODO PUT),
|
||||
// not this shape — only the mBrian backend creates task nodes.
|
||||
type TaskCreateInput struct {
|
||||
Title string
|
||||
Slug string
|
||||
ParentItemID string // the project (or task) this task attaches to via child_of
|
||||
Due *time.Time // optional
|
||||
}
|
||||
|
||||
// TaskReader is the read-path contract for mBrian-native task nodes. It is a
|
||||
// capability SEPARATE from ItemReader: only the mBrian backend has task nodes,
|
||||
// so the legacy *Store does not implement it. Web handlers obtain it via a
|
||||
// type-assertion on the active Items backend (see Server.taskBackend).
|
||||
type TaskReader interface {
|
||||
// TasksForItem returns the mBrian-native tasks (type=['task'] nodes)
|
||||
// attached to itemID via a child_of edge, in created-at order (Q5 —
|
||||
// created order only; no manual reorder in v1).
|
||||
TasksForItem(ctx context.Context, itemID string) ([]*Task, error)
|
||||
}
|
||||
|
||||
// TaskWriter is the write-path contract for mBrian-native task nodes. Twin of
|
||||
// TaskReader; implemented only by the mBrian backend (*MBrianWriter). Writes
|
||||
// funnel through mBrian's scoped /api/projax HTTP surface so projax-created
|
||||
// task nodes are byte-identical to UI/MCP/migration nodes (the slice-C
|
||||
// discipline). Delete reuses the node soft-delete the API already exposes.
|
||||
type TaskWriter interface {
|
||||
// CreateTask POSTs a type=['task'] node (slug honored, metadata.projax
|
||||
// carrying status/due) then attaches it to ParentItemID via a child_of
|
||||
// edge, and returns the materialised Task.
|
||||
CreateTask(ctx context.Context, in TaskCreateInput) (*Task, error)
|
||||
// SetTaskStatus PATCHes metadata.projax.status (done|active|archived) —
|
||||
// task done-state reuses the existing lifecycle (Q2), no separate field.
|
||||
SetTaskStatus(ctx context.Context, nodeID, status string) error
|
||||
// SetTaskDue PATCHes metadata.projax.due; a nil due clears it.
|
||||
SetTaskDue(ctx context.Context, nodeID string, due *time.Time) error
|
||||
// EditTaskTitle PATCHes the node title.
|
||||
EditTaskTitle(ctx context.Context, nodeID, title string) error
|
||||
// DeleteTask soft-deletes the task node.
|
||||
DeleteTask(ctx context.Context, nodeID string) error
|
||||
}
|
||||
366
store/views.go
366
store/views.go
@@ -5,58 +5,108 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// View is one row in projax.views. Phase 5i Slice D — saved views.
|
||||
//
|
||||
// FilterJSON carries the persisted filter state as raw JSON so callers can
|
||||
// freely round-trip into their TreeFilter or another future filter type
|
||||
// without forcing the store package to depend on web/.
|
||||
// View is one row in projax.views — a first-class /views/{slug} page.
|
||||
// Phase 5j paliad-shape: the slug is the user-facing key; URLs and the
|
||||
// sidebar both index by it. The uuid id stays because it's cheap and
|
||||
// surfaces in future MCP integrations, but it is NOT exposed in URLs.
|
||||
type View struct {
|
||||
ID string
|
||||
Name string
|
||||
Description string
|
||||
FilterJSON []byte // raw jsonb payload
|
||||
ViewType string
|
||||
SortField *string
|
||||
SortDir *string
|
||||
GroupBy *string
|
||||
Pinned bool
|
||||
IsDefaultFor *string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string
|
||||
Slug string
|
||||
Name string
|
||||
Icon *string
|
||||
FilterJSON []byte // raw jsonb payload — includes view_type per m's Q2
|
||||
SortField *string
|
||||
SortDir *string
|
||||
GroupBy *string
|
||||
SortOrder int
|
||||
ShowCount bool
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// ErrViewNotFound surfaces from GetView / SoftDeleteView when no row matches.
|
||||
// ErrViewNotFound surfaces from Get*/Update*/Delete when no row matches.
|
||||
var ErrViewNotFound = errors.New("view not found")
|
||||
|
||||
// ViewInput is the writeable subset of View used by Create / Update.
|
||||
type ViewInput struct {
|
||||
Name string
|
||||
Description string
|
||||
FilterJSON []byte
|
||||
ViewType string
|
||||
SortField string
|
||||
SortDir string
|
||||
GroupBy string
|
||||
Pinned bool
|
||||
IsDefaultFor string // "" → clear default
|
||||
// ErrViewSlugTaken is returned by Create / Update when the slug already
|
||||
// belongs to another view. Web handlers map this to 409.
|
||||
var ErrViewSlugTaken = errors.New("view slug already exists")
|
||||
|
||||
// ErrViewSlugReserved is returned when the caller picks a slug that
|
||||
// shadows a system slug or a top-level URL segment. Web handlers map
|
||||
// this to 400 with a friendly message.
|
||||
var ErrViewSlugReserved = errors.New("view slug is reserved")
|
||||
|
||||
// ErrViewSlugFormat is returned when the slug doesn't match the format
|
||||
// regex. Same mapping as reserved.
|
||||
var ErrViewSlugFormat = errors.New("view slug must match ^[a-z0-9][a-z0-9-]{0,62}$")
|
||||
|
||||
// slugRE is the format guard. Mirrors the SQL CHECK constraint so callers
|
||||
// get a friendly error before round-tripping to the DB.
|
||||
var slugRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`)
|
||||
|
||||
// reservedViewSlugs is the static list of slugs the validator rejects.
|
||||
// Combines system-view slugs (slice C wires them) with top-level route
|
||||
// segments the application owns.
|
||||
var reservedViewSlugs = map[string]struct{}{
|
||||
// System views (slice C):
|
||||
"tree": {}, "dashboard": {}, "calendar": {}, "timeline": {}, "graph": {},
|
||||
// /views sub-routes:
|
||||
"new": {}, "edit": {},
|
||||
// Top-level application URLs:
|
||||
"admin": {}, "login": {}, "logout": {}, "healthz": {}, "mcp": {},
|
||||
"static": {}, "i": {}, "views": {},
|
||||
}
|
||||
|
||||
// ListViews returns every non-deleted view ordered by pinned-first, then name.
|
||||
// IsReservedViewSlug reports whether the slug shadows a system slug or a
|
||||
// top-level URL segment. Exported for the editor's slug-derivation
|
||||
// helper.
|
||||
func IsReservedViewSlug(slug string) bool {
|
||||
_, ok := reservedViewSlugs[strings.ToLower(slug)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ValidateSlug runs format + reserved checks. Returns nil for valid slugs.
|
||||
func ValidateSlug(slug string) error {
|
||||
if !slugRE.MatchString(slug) {
|
||||
return ErrViewSlugFormat
|
||||
}
|
||||
if IsReservedViewSlug(slug) {
|
||||
return ErrViewSlugReserved
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ViewInput is the writeable subset for Create / Update. Defaults
|
||||
// applied: nil FilterJSON → {}; SortOrder is server-assigned on Create.
|
||||
type ViewInput struct {
|
||||
Slug string
|
||||
Name string
|
||||
Icon *string
|
||||
FilterJSON []byte
|
||||
SortField string
|
||||
SortDir string
|
||||
GroupBy string
|
||||
ShowCount bool
|
||||
}
|
||||
|
||||
// ListViews returns every view ordered by sort_order ASC then name —
|
||||
// matches the sidebar rendering order.
|
||||
func (s *Store) ListViews(ctx context.Context) ([]*View, error) {
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, name, coalesce(description,''), filter_json, view_type,
|
||||
sort_field, sort_dir, group_by, pinned, is_default_for,
|
||||
SELECT id, slug, name, icon, filter_json,
|
||||
sort_field, sort_dir, group_by,
|
||||
sort_order, show_count, last_used_at,
|
||||
created_at, updated_at
|
||||
FROM projax.views
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY pinned DESC, lower(name) ASC`)
|
||||
ORDER BY sort_order ASC, name ASC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list views: %w", err)
|
||||
}
|
||||
@@ -72,14 +122,25 @@ ORDER BY pinned DESC, lower(name) ASC`)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetView returns one view by id. ErrViewNotFound when missing or soft-deleted.
|
||||
func (s *Store) GetView(ctx context.Context, id string) (*View, error) {
|
||||
// GetView returns one view by slug. ErrViewNotFound when missing.
|
||||
func (s *Store) GetView(ctx context.Context, slug string) (*View, error) {
|
||||
return s.getView(ctx, `slug = $1`, slug)
|
||||
}
|
||||
|
||||
// GetViewByID returns one view by uuid id. Used by the legacy
|
||||
// `?view=<uuid>` 302-redirect path during the 5i → 5j cutover.
|
||||
func (s *Store) GetViewByID(ctx context.Context, id string) (*View, error) {
|
||||
return s.getView(ctx, `id = $1`, id)
|
||||
}
|
||||
|
||||
func (s *Store) getView(ctx context.Context, where, arg string) (*View, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, coalesce(description,''), filter_json, view_type,
|
||||
sort_field, sort_dir, group_by, pinned, is_default_for,
|
||||
SELECT id, slug, name, icon, filter_json,
|
||||
sort_field, sort_dir, group_by,
|
||||
sort_order, show_count, last_used_at,
|
||||
created_at, updated_at
|
||||
FROM projax.views
|
||||
WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||
WHERE `+where, arg)
|
||||
v, err := scanView(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrViewNotFound
|
||||
@@ -87,9 +148,29 @@ WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// CreateView inserts a row. When IsDefaultFor is set, the prior default for
|
||||
// that page is cleared in the same transaction so the partial unique index
|
||||
// can't fire after a Postgres rewrite.
|
||||
// MostRecentView returns the view with the most recent last_used_at. nil
|
||||
// when no view has been touched yet (or none exist). Drives the /views
|
||||
// landing redirect.
|
||||
func (s *Store) MostRecentView(ctx context.Context) (*View, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, slug, name, icon, filter_json,
|
||||
sort_field, sort_dir, group_by,
|
||||
sort_order, show_count, last_used_at,
|
||||
created_at, updated_at
|
||||
FROM projax.views
|
||||
WHERE last_used_at IS NOT NULL
|
||||
ORDER BY last_used_at DESC
|
||||
LIMIT 1`)
|
||||
v, err := scanView(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// CreateView inserts a new view. SortOrder is server-assigned to
|
||||
// MAX(existing)+1 inside the same tx so two parallel creates don't
|
||||
// collide on the index.
|
||||
func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
|
||||
if err := validateViewInput(in); err != nil {
|
||||
return nil, err
|
||||
@@ -97,95 +178,81 @@ func (s *Store) CreateView(ctx context.Context, in ViewInput) (*View, error) {
|
||||
if in.FilterJSON == nil {
|
||||
in.FilterJSON = []byte("{}")
|
||||
}
|
||||
var id string
|
||||
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if in.IsDefaultFor != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE projax.views
|
||||
SET is_default_for = NULL
|
||||
WHERE is_default_for = $1 AND deleted_at IS NULL`, in.IsDefaultFor); err != nil {
|
||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
||||
}
|
||||
var nextOrder int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT COALESCE(MAX(sort_order), -1) + 1 FROM projax.views`,
|
||||
).Scan(&nextOrder); err != nil {
|
||||
return nil, fmt.Errorf("compute next sort_order: %w", err)
|
||||
}
|
||||
var id string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO projax.views
|
||||
(name, description, filter_json, view_type, sort_field, sort_dir, group_by, pinned, is_default_for)
|
||||
(slug, name, icon, filter_json, sort_field, sort_dir, group_by, sort_order, show_count)
|
||||
VALUES
|
||||
($1, NULLIF($2,''), $3::jsonb, $4, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, NULLIF($9,''))
|
||||
($1, $2, $3, $4::jsonb, NULLIF($5,''), NULLIF($6,''), NULLIF($7,''), $8, $9)
|
||||
RETURNING id`,
|
||||
in.Name, in.Description, in.FilterJSON, in.ViewType,
|
||||
in.SortField, in.SortDir, in.GroupBy, in.Pinned, in.IsDefaultFor,
|
||||
in.Slug, in.Name, in.Icon, in.FilterJSON,
|
||||
in.SortField, in.SortDir, in.GroupBy, nextOrder, in.ShowCount,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
if isUniqueSlugViolation(err) {
|
||||
return nil, ErrViewSlugTaken
|
||||
}
|
||||
return nil, fmt.Errorf("insert view: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return s.GetView(ctx, id)
|
||||
return s.GetView(ctx, in.Slug)
|
||||
}
|
||||
|
||||
// UpdateView replaces every writeable field. Same default-clearing semantics
|
||||
// as CreateView.
|
||||
func (s *Store) UpdateView(ctx context.Context, id string, in ViewInput) (*View, error) {
|
||||
// UpdateView replaces every writeable field on the row matching `slug`.
|
||||
// To rename, pass the desired new slug in `in.Slug`; if it collides with
|
||||
// another row, ErrViewSlugTaken surfaces.
|
||||
func (s *Store) UpdateView(ctx context.Context, slug string, in ViewInput) (*View, error) {
|
||||
if err := validateViewInput(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.FilterJSON == nil {
|
||||
in.FilterJSON = []byte("{}")
|
||||
}
|
||||
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
if in.IsDefaultFor != "" {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
UPDATE projax.views
|
||||
SET is_default_for = NULL
|
||||
WHERE is_default_for = $1 AND id <> $2 AND deleted_at IS NULL`,
|
||||
in.IsDefaultFor, id); err != nil {
|
||||
return nil, fmt.Errorf("clear prior default: %w", err)
|
||||
}
|
||||
}
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE projax.views
|
||||
SET name = $2,
|
||||
description = NULLIF($3,''),
|
||||
filter_json = $4::jsonb,
|
||||
view_type = $5,
|
||||
sort_field = NULLIF($6,''),
|
||||
sort_dir = NULLIF($7,''),
|
||||
group_by = NULLIF($8,''),
|
||||
pinned = $9,
|
||||
is_default_for = NULLIF($10,'')
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
id, in.Name, in.Description, in.FilterJSON, in.ViewType,
|
||||
in.SortField, in.SortDir, in.GroupBy, in.Pinned, in.IsDefaultFor,
|
||||
SET slug = $2,
|
||||
name = $3,
|
||||
icon = $4,
|
||||
filter_json = $5::jsonb,
|
||||
sort_field = NULLIF($6,''),
|
||||
sort_dir = NULLIF($7,''),
|
||||
group_by = NULLIF($8,''),
|
||||
show_count = $9
|
||||
WHERE slug = $1`,
|
||||
slug, in.Slug, in.Name, in.Icon, in.FilterJSON,
|
||||
in.SortField, in.SortDir, in.GroupBy, in.ShowCount,
|
||||
)
|
||||
if err != nil {
|
||||
if isUniqueSlugViolation(err) {
|
||||
return nil, ErrViewSlugTaken
|
||||
}
|
||||
return nil, fmt.Errorf("update view: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return nil, ErrViewNotFound
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return s.GetView(ctx, id)
|
||||
return s.GetView(ctx, in.Slug)
|
||||
}
|
||||
|
||||
// SoftDeleteView sets deleted_at on the row. Idempotent (returns ErrViewNotFound
|
||||
// only when the row never existed; subsequent calls on a soft-deleted row
|
||||
// silently succeed since deleted_at is just refreshed).
|
||||
func (s *Store) SoftDeleteView(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
UPDATE projax.views SET deleted_at = now()
|
||||
WHERE id = $1`, id)
|
||||
// DeleteView removes a view by slug. Hard delete (no soft-delete column
|
||||
// in the redesign — single-user, no audit obligation). Idempotent only
|
||||
// on the second call; first call against a non-existent row returns
|
||||
// ErrViewNotFound.
|
||||
func (s *Store) DeleteView(ctx context.Context, slug string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete view: %w", err)
|
||||
}
|
||||
@@ -195,79 +262,100 @@ WHERE id = $1`, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultViewFor returns the view that should auto-apply on the named page,
|
||||
// or nil if none is set.
|
||||
func (s *Store) DefaultViewFor(ctx context.Context, page string) (*View, error) {
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
SELECT id, name, coalesce(description,''), filter_json, view_type,
|
||||
sort_field, sort_dir, group_by, pinned, is_default_for,
|
||||
created_at, updated_at
|
||||
FROM projax.views
|
||||
WHERE is_default_for = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`, page)
|
||||
v, err := scanView(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
// TouchView bumps last_used_at to now(). Fire-and-forget from the render
|
||||
// handler — failures are logged but never block the page.
|
||||
func (s *Store) TouchView(ctx context.Context, slug string) error {
|
||||
tag, err := s.Pool.Exec(ctx,
|
||||
`UPDATE projax.views SET last_used_at = now() WHERE slug = $1`, slug)
|
||||
if err != nil {
|
||||
return fmt.Errorf("touch view: %w", err)
|
||||
}
|
||||
return v, err
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrViewNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateViewInput runs the Go-side guards. The DB CHECK constraints provide
|
||||
// the durable contract; these checks let handlers surface a friendlier error.
|
||||
// ReorderViews applies a sort_order rewrite where the provided slugs map
|
||||
// to ascending sort_order values starting at 0. Slugs not present in the
|
||||
// input keep their existing sort_order. Drives slice G's drag-reorder UI.
|
||||
func (s *Store) ReorderViews(ctx context.Context, slugs []string) error {
|
||||
if len(slugs) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
for i, slug := range slugs {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE projax.views SET sort_order = $1 WHERE slug = $2`,
|
||||
i, slug,
|
||||
); err != nil {
|
||||
return fmt.Errorf("reorder %q: %w", slug, err)
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// validateViewInput runs Go-side guards. The DB CHECK constraints are the
|
||||
// durable contract; these checks let handlers surface friendlier errors.
|
||||
func validateViewInput(in ViewInput) error {
|
||||
if err := ValidateSlug(in.Slug); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return errors.New("view name is required")
|
||||
}
|
||||
switch in.ViewType {
|
||||
case "card", "list", "calendar", "kanban", "timeline":
|
||||
default:
|
||||
return fmt.Errorf("invalid view_type %q (allowed: card list calendar kanban timeline)", in.ViewType)
|
||||
}
|
||||
if in.SortDir != "" && in.SortDir != "asc" && in.SortDir != "desc" {
|
||||
return fmt.Errorf("invalid sort_dir %q", in.SortDir)
|
||||
}
|
||||
if in.ViewType == "kanban" && strings.TrimSpace(in.GroupBy) == "" {
|
||||
return errors.New("kanban view_type requires group_by")
|
||||
}
|
||||
if in.IsDefaultFor != "" {
|
||||
switch in.IsDefaultFor {
|
||||
case "tree", "dashboard", "calendar", "timeline":
|
||||
default:
|
||||
return fmt.Errorf("invalid is_default_for %q", in.IsDefaultFor)
|
||||
}
|
||||
if in.Icon != nil && len(*in.Icon) > 64 {
|
||||
return errors.New("icon key exceeds 64 characters")
|
||||
}
|
||||
if len(in.FilterJSON) > 0 {
|
||||
var dummy any
|
||||
if err := json.Unmarshal(in.FilterJSON, &dummy); err != nil {
|
||||
var probe any
|
||||
if err := json.Unmarshal(in.FilterJSON, &probe); err != nil {
|
||||
return fmt.Errorf("filter_json is not valid JSON: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isUniqueSlugViolation matches the postgres unique_violation SQLSTATE
|
||||
// (23505) on the views_slug_uniq index. We don't import pgconn here to
|
||||
// avoid widening the package's dep surface; substring match on the
|
||||
// pgx-formatted error covers both the wire-level codes pgx surfaces.
|
||||
func isUniqueSlugViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "views_slug_uniq") ||
|
||||
(strings.Contains(s, "SQLSTATE 23505") && strings.Contains(s, "slug"))
|
||||
}
|
||||
|
||||
type viewScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanView(s viewScanner) (*View, error) {
|
||||
v := &View{}
|
||||
var sortField, sortDir, groupBy, isDefaultFor *string
|
||||
var icon, sortField, sortDir, groupBy *string
|
||||
var lastUsedAt *time.Time
|
||||
if err := s.Scan(
|
||||
&v.ID, &v.Name, &v.Description, &v.FilterJSON, &v.ViewType,
|
||||
&sortField, &sortDir, &groupBy, &v.Pinned, &isDefaultFor,
|
||||
&v.ID, &v.Slug, &v.Name, &icon, &v.FilterJSON,
|
||||
&sortField, &sortDir, &groupBy,
|
||||
&v.SortOrder, &v.ShowCount, &lastUsedAt,
|
||||
&v.CreatedAt, &v.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v.Icon = icon
|
||||
v.SortField = sortField
|
||||
v.SortDir = sortDir
|
||||
v.GroupBy = groupBy
|
||||
v.IsDefaultFor = isDefaultFor
|
||||
v.LastUsedAt = lastUsedAt
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// pgxRowsCompat keeps the linter quiet about importing pgxpool only for
|
||||
// type assertions inside views.go. The Pool method on Store already pulls
|
||||
// pgxpool into the package; nothing to do here, but the unused-import
|
||||
// shadow doesn't bite.
|
||||
var _ = pgxpool.Pool{}
|
||||
|
||||
246
store/views_test.go
Normal file
246
store/views_test.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// connect mirrors db_test's connect helper. The store package owns its own
|
||||
// integration tests (Phase 5j Slice A introduced this file alongside the
|
||||
// schema redesign); it shares the same env-var convention to skip when no
|
||||
// DB is wired up.
|
||||
func connect(t *testing.T) (*pgxpool.Pool, *store.Store) {
|
||||
t.Helper()
|
||||
url := os.Getenv("PROJAX_DB_URL")
|
||||
if url == "" {
|
||||
url = os.Getenv("SUPABASE_DATABASE_URL")
|
||||
}
|
||||
if url == "" {
|
||||
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping integration test")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
t.Skipf("DB unreachable: %v", err)
|
||||
}
|
||||
return pool, store.New(pool)
|
||||
}
|
||||
|
||||
// uniqueSlug suffixes a base slug with a timestamp so parallel test runs
|
||||
// don't collide on the views_slug_uniq index.
|
||||
func uniqueSlug(prefix string) string {
|
||||
return prefix + "-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
}
|
||||
|
||||
func TestViewSlugCRUD(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
slug := uniqueSlug("p5j-a-crud")
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug LIKE 'p5j-a-crud-%' OR slug LIKE 'p5j-a-renamed-%'`)
|
||||
|
||||
// Create.
|
||||
created, err := s.CreateView(ctx, store.ViewInput{
|
||||
Slug: slug,
|
||||
Name: "Slice A CRUD",
|
||||
FilterJSON: []byte(`{"view_type":"list","tags":["work"]}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if created.Slug != slug {
|
||||
t.Errorf("slug = %q, want %q", created.Slug, slug)
|
||||
}
|
||||
if created.ID == "" {
|
||||
t.Error("ID should be populated on create")
|
||||
}
|
||||
if created.SortOrder < 0 {
|
||||
t.Errorf("sort_order should be >= 0 (server-assigned), got %d", created.SortOrder)
|
||||
}
|
||||
|
||||
// GetView by slug.
|
||||
got, err := s.GetView(ctx, slug)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if string(got.FilterJSON) != `{"view_type": "list", "tags": ["work"]}` && string(got.FilterJSON) != `{"tags": ["work"], "view_type": "list"}` {
|
||||
// Postgres jsonb normalises key order — accept either ordering.
|
||||
// Verify it round-trips structurally.
|
||||
if !strings.Contains(string(got.FilterJSON), `"view_type"`) || !strings.Contains(string(got.FilterJSON), `"tags"`) {
|
||||
t.Errorf("filter_json did not round-trip view_type+tags: %s", got.FilterJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// GetViewByID (legacy 5i 302-redirect path uses this).
|
||||
byID, err := s.GetViewByID(ctx, created.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if byID.Slug != slug {
|
||||
t.Errorf("by-id lookup returned wrong slug: %q", byID.Slug)
|
||||
}
|
||||
|
||||
// Update — rename slug + change filter.
|
||||
renamed := uniqueSlug("p5j-a-renamed")
|
||||
updated, err := s.UpdateView(ctx, slug, store.ViewInput{
|
||||
Slug: renamed,
|
||||
Name: "Renamed",
|
||||
FilterJSON: []byte(`{"view_type":"card"}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
if updated.Slug != renamed {
|
||||
t.Errorf("renamed slug = %q, want %q", updated.Slug, renamed)
|
||||
}
|
||||
if _, err := s.GetView(ctx, slug); !errors.Is(err, store.ErrViewNotFound) {
|
||||
t.Errorf("old slug should be ErrViewNotFound after rename, got %v", err)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
if err := s.DeleteView(ctx, renamed); err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
if _, err := s.GetView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
|
||||
t.Errorf("post-delete get should be ErrViewNotFound, got %v", err)
|
||||
}
|
||||
if err := s.DeleteView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
|
||||
t.Errorf("second delete should be ErrViewNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSlugFormatRejected(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
bad := []string{
|
||||
"", // empty
|
||||
"UPPER", // uppercase
|
||||
"under_score", // underscore
|
||||
"-leading-dash", // leading dash
|
||||
"a." + strings.Repeat("x", 100), // too long + invalid char
|
||||
strings.Repeat("a", 64), // length cap is 63 (1 + 62)
|
||||
}
|
||||
for _, slug := range bad {
|
||||
_, err := s.CreateView(ctx, store.ViewInput{
|
||||
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
|
||||
})
|
||||
if !errors.Is(err, store.ErrViewSlugFormat) {
|
||||
t.Errorf("slug=%q expected ErrViewSlugFormat, got %v", slug, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewReservedSlugRejected(t *testing.T) {
|
||||
_, s := connect(t)
|
||||
ctx := context.Background()
|
||||
for _, slug := range []string{"tree", "dashboard", "calendar", "timeline", "graph", "new", "edit", "admin", "views"} {
|
||||
_, err := s.CreateView(ctx, store.ViewInput{
|
||||
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
|
||||
})
|
||||
if !errors.Is(err, store.ErrViewSlugReserved) {
|
||||
t.Errorf("reserved slug %q should be rejected, got %v", slug, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewSlugCollision(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
slug := uniqueSlug("p5j-a-collision")
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "First"}); err != nil {
|
||||
t.Fatalf("first create: %v", err)
|
||||
}
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "Second"}); !errors.Is(err, store.ErrViewSlugTaken) {
|
||||
t.Errorf("duplicate slug should be ErrViewSlugTaken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewMRU(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
a := uniqueSlug("p5j-a-mru-a")
|
||||
b := uniqueSlug("p5j-a-mru-b")
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2)`, a, b)
|
||||
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: a, Name: "A"}); err != nil {
|
||||
t.Fatalf("create a: %v", err)
|
||||
}
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: b, Name: "B"}); err != nil {
|
||||
t.Fatalf("create b: %v", err)
|
||||
}
|
||||
|
||||
// MostRecentView with no touches yet — when no view in the table has
|
||||
// last_used_at set, MRU returns nil. (Other tests may have left their
|
||||
// own touched views, so we only assert on the slugs we control.)
|
||||
if err := s.TouchView(ctx, a); err != nil {
|
||||
t.Fatalf("touch a: %v", err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
if err := s.TouchView(ctx, b); err != nil {
|
||||
t.Fatalf("touch b: %v", err)
|
||||
}
|
||||
|
||||
mru, err := s.MostRecentView(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("mru: %v", err)
|
||||
}
|
||||
// Other tests' touched views may rank higher; we only assert that
|
||||
// when MRU is one of OURS, the most-recently-touched (b) wins over a.
|
||||
// To guarantee this test's signal even with contention from other
|
||||
// suites, check b's last_used_at > a's last_used_at directly.
|
||||
aV, _ := s.GetView(ctx, a)
|
||||
bV, _ := s.GetView(ctx, b)
|
||||
if aV.LastUsedAt == nil || bV.LastUsedAt == nil {
|
||||
t.Fatal("both views should have last_used_at after touch")
|
||||
}
|
||||
if !bV.LastUsedAt.After(*aV.LastUsedAt) {
|
||||
t.Errorf("b.last_used_at should be after a.last_used_at; a=%v b=%v", aV.LastUsedAt, bV.LastUsedAt)
|
||||
}
|
||||
if mru == nil {
|
||||
t.Error("MostRecentView returned nil even though touches landed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewReorder(t *testing.T) {
|
||||
pool, s := connect(t)
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
a := uniqueSlug("p5j-a-reorder-a")
|
||||
b := uniqueSlug("p5j-a-reorder-b")
|
||||
c := uniqueSlug("p5j-a-reorder-c")
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2, $3)`, a, b, c)
|
||||
|
||||
for _, slug := range []string{a, b, c} {
|
||||
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: slug}); err != nil {
|
||||
t.Fatalf("create %s: %v", slug, err)
|
||||
}
|
||||
}
|
||||
// Reorder c → b → a.
|
||||
if err := s.ReorderViews(ctx, []string{c, b, a}); err != nil {
|
||||
t.Fatalf("reorder: %v", err)
|
||||
}
|
||||
cV, _ := s.GetView(ctx, c)
|
||||
bV, _ := s.GetView(ctx, b)
|
||||
aV, _ := s.GetView(ctx, a)
|
||||
if cV.SortOrder != 0 || bV.SortOrder != 1 || aV.SortOrder != 2 {
|
||||
t.Errorf("reorder yielded sort_orders c=%d b=%d a=%d, want 0,1,2",
|
||||
cV.SortOrder, bV.SortOrder, aV.SortOrder)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func TestLayoutHasAdminNavLink(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
for _, path := range []string{"/", "/dashboard", "/graph", "/admin/bulk", "/admin/classify"} {
|
||||
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/graph", "/admin/bulk", "/admin/classify"} {
|
||||
_, body := get(t, h, path)
|
||||
if !strings.Contains(body, `href="/admin"`) {
|
||||
t.Errorf("GET %s: nav missing /admin link", path)
|
||||
|
||||
175
web/bulk.go
175
web/bulk.go
@@ -5,11 +5,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/m/projax/internal/itemwrite"
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
@@ -19,7 +18,7 @@ import (
|
||||
// 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())
|
||||
items, err := s.Items.ListAll(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -29,7 +28,7 @@ func (s *Server) handleBulk(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
allTags, err := s.Store.AllTags(r.Context())
|
||||
allTags, err := s.Items.AllTags(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -263,83 +262,107 @@ func (s *Server) handleBulkApply(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
// 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 {
|
||||
tx, err := s.Store.Pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin bulk tx: %w", err)
|
||||
switch a.SetPublic {
|
||||
case "make_public":
|
||||
return s.Writes.SetPublic(ctx, ids, true)
|
||||
case "make_private":
|
||||
return s.Writes.SetPublic(ctx, ids, false)
|
||||
}
|
||||
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 {
|
||||
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.
|
||||
_, 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)
|
||||
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)
|
||||
}
|
||||
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 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,
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -377,7 +400,7 @@ func normaliseFormStrings(in []string) []string {
|
||||
// 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())
|
||||
items, err := s.Items.ListAll(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -387,7 +410,7 @@ func (s *Server) renderBulkList(w http.ResponseWriter, r *http.Request, banner s
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
allTags, err := s.Store.AllTags(r.Context())
|
||||
allTags, err := s.Items.AllTags(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -445,7 +468,7 @@ func (s *Server) handleBulkChip(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByID(r.Context(), id)
|
||||
it, err := s.Items.GetByID(r.Context(), id)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
|
||||
@@ -44,11 +44,11 @@ func (s *Server) buildCalDAVOverview(ctx context.Context) (*CalDAVOverview, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("caldav list: %w", err)
|
||||
}
|
||||
items, err := s.Store.ListAll(ctx)
|
||||
items, err := s.Items.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
links, err := s.Store.LinksByRefType(ctx, refTypeCalDAV)
|
||||
links, err := s.Items.LinksByRefType(ctx, refTypeCalDAV)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (s *Server) handleCalDAVLink(w http.ResponseWriter, r *http.Request) {
|
||||
"calendar_color": color,
|
||||
"linked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if _, err := s.Store.AddLink(r.Context(), itemID, refTypeCalDAV, calURL, "contains", meta); err != nil {
|
||||
if _, err := s.Writes.AddLink(r.Context(), itemID, refTypeCalDAV, calURL, "contains", meta); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func (s *Server) handleCalDAVUnlink(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "link_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := s.Store.DeleteLink(r.Context(), linkID); err != nil {
|
||||
if err := s.Writes.DeleteLink(r.Context(), linkID); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func (s *Server) handleCalDAVLinkExisting(w http.ResponseWriter, r *http.Request
|
||||
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -231,7 +231,7 @@ func (s *Server) handleCalDAVLinkExisting(w http.ResponseWriter, r *http.Request
|
||||
"calendar_color": matched.Color,
|
||||
"linked_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
|
||||
if _, err := s.Writes.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -245,7 +245,7 @@ func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path
|
||||
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -260,7 +260,7 @@ func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path
|
||||
if errors.Is(err, caldav.ErrCalendarExists) {
|
||||
// Existing calendar — link instead.
|
||||
meta := map[string]any{"display_name": displayName, "linked_at": time.Now().UTC().Format(time.RFC3339)}
|
||||
if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
|
||||
if _, err := s.Writes.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path
|
||||
"display_name": displayName,
|
||||
"created_at": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if _, err := s.Store.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
|
||||
if _, err := s.Writes.AddLink(r.Context(), it.ID, refTypeCalDAV, calURL, "contains", meta); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func (s *Server) detailTodos(ctx context.Context, item *store.Item) ([]calendarT
|
||||
if s.CalDAV == nil {
|
||||
return nil, nil
|
||||
}
|
||||
links, err := s.Store.LinksByType(ctx, item.ID, refTypeCalDAV)
|
||||
links, err := s.Items.LinksByType(ctx, item.ID, refTypeCalDAV)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -372,7 +372,7 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
|
||||
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -388,7 +388,7 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
// Guard: the calendar URL must be linked to this item — otherwise a
|
||||
// crafted form could route writes to arbitrary calendars.
|
||||
links, err := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
|
||||
links, err := s.Items.LinksByType(r.Context(), it.ID, refTypeCalDAV)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -505,10 +505,12 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
|
||||
if s.timeline != nil {
|
||||
s.timeline.InvalidateAll()
|
||||
}
|
||||
// Always re-render the tasks section so HTMX (or a plain redirect for
|
||||
// non-HTMX clients) sees the post-write state.
|
||||
// Always re-render the unified tasks section so HTMX (or a plain redirect
|
||||
// for non-HTMX clients) sees the post-write state. Phase 7c: CalDAV +
|
||||
// mBrian tasks share ONE section, so a CalDAV write refreshes the merged
|
||||
// list via the same renderer the mBrian path uses.
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
s.renderTasksSection(w, r, it, banner)
|
||||
s.renderUnifiedTasks(w, r, it, banner)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
||||
@@ -527,39 +529,9 @@ func caldavBanner(action string, err error) string {
|
||||
return "Could not " + action + " task: " + err.Error()
|
||||
}
|
||||
|
||||
// renderTasksSection re-runs detailTodos for the item and renders the
|
||||
// tasks-section template fragment with an optional banner. Used by HTMX
|
||||
// responses so swap operations stay in-place.
|
||||
func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) {
|
||||
tasks, err := s.detailTodos(r.Context(), it)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
// HTMX swaps re-render the section in place; the picker needs the same
|
||||
// AvailableCalendars data the full /i/{path} render computes. Errors
|
||||
// here are non-fatal — degrade to an empty picker.
|
||||
var available []caldav.Calendar
|
||||
if s.CalDAV != nil {
|
||||
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
|
||||
if lerr != nil {
|
||||
s.Logger.Warn("tasks-section caldav links", "path", it.PrimaryPath(), "err", lerr)
|
||||
}
|
||||
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
|
||||
if aerr != nil {
|
||||
s.Logger.Warn("tasks-section available caldav", "path", it.PrimaryPath(), "err", aerr)
|
||||
}
|
||||
available = acs
|
||||
}
|
||||
data := map[string]any{
|
||||
"Item": it,
|
||||
"Tasks": tasks,
|
||||
"AvailableCalendars": available,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Banner": banner,
|
||||
}
|
||||
s.render(w, r, "tasks_section", data)
|
||||
}
|
||||
// (renderTasksSection retired in Phase 7c — the CalDAV and mBrian task
|
||||
// handlers now both re-render the merged list via Server.renderUnifiedTasks in
|
||||
// task.go.)
|
||||
|
||||
// parseDueInput accepts an HTML5 date-input value (`YYYY-MM-DD`) or a
|
||||
// datetime-local value (`YYYY-MM-DDTHH:MM`), returning the corresponding UTC
|
||||
|
||||
@@ -205,7 +205,7 @@ func TestDetailLinkExistingCalendar(t *testing.T) {
|
||||
`>Family<`,
|
||||
`>Travel<`,
|
||||
`>Vacations 2026<`,
|
||||
`+ Create new list`,
|
||||
`+ Create new CalDAV list`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("unlinked detail page missing %q", want)
|
||||
@@ -221,17 +221,23 @@ func TestDetailLinkExistingCalendar(t *testing.T) {
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1 and ref_id=$2`, id, pickedURL)
|
||||
|
||||
// Step 5: picker no longer offers Vacations 2026 (already linked);
|
||||
// the tasks section now shows the linked calendar's block.
|
||||
// Step 5: picker no longer offers Vacations 2026 (already linked). Phase 7c
|
||||
// unified the task UI: there's no per-calendar block anymore, so an empty
|
||||
// linked calendar shows no header — instead the project becomes
|
||||
// CalDAV-bound, so the single add-form now targets the linked calendar and
|
||||
// the "create new" affordance disappears.
|
||||
_, body = get(t, h, "/i/"+primary)
|
||||
if strings.Contains(body, `<option value="`+pickedURL+`">Vacations 2026</option>`) {
|
||||
t.Errorf("picker should NOT offer the already-linked Vacations 2026 URL")
|
||||
}
|
||||
if !strings.Contains(body, "Vacations 2026") {
|
||||
t.Errorf("tasks section should display the linked Vacations 2026 list")
|
||||
if strings.Contains(body, `+ Create new CalDAV list`) {
|
||||
t.Errorf("create-new affordance should be gone once a calendar is linked (project is CalDAV-bound)")
|
||||
}
|
||||
if !strings.Contains(body, `data-cal="`+pickedURL+`"`) {
|
||||
t.Errorf("tasks section missing cal-block for the linked URL")
|
||||
if !strings.Contains(body, `hx-post="/i/`+primary+`/caldav/todo/todo-create"`) {
|
||||
t.Errorf("unified add-form should POST to the CalDAV create route on a bound project")
|
||||
}
|
||||
if !strings.Contains(body, `name="calendar_url" value="`+pickedURL+`"`) {
|
||||
t.Errorf("unified add-form should target the linked calendar %q", pickedURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,7 +194,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Projects": projects,
|
||||
"BasePath": "/calendar",
|
||||
"BasePath": "/views/calendar",
|
||||
"ProjectChipTarget": "#calendar-section",
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
@@ -209,7 +209,7 @@ func (s *Server) handleCalendar(w http.ResponseWriter, r *http.Request) {
|
||||
// lead/trail cells), bins them into per-day cells, and caps each cell at
|
||||
// calendarMaxRowsPerCell with the overflow count.
|
||||
func (s *Server) buildCalendar(ctx context.Context, q calendarQuery, now time.Time) (*calendarPayload, error) {
|
||||
items, err := s.Store.ListAll(ctx)
|
||||
items, err := s.Items.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/calendar")
|
||||
code, body := get(t, h, "/views/calendar")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /calendar → %d body=%s", code, body)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func TestCalendarRendersMonthGrid(t *testing.T) {
|
||||
`<th scope="col">Mon</th>`,
|
||||
`<th scope="col">Sun</th>`,
|
||||
`class="calendar-nav"`,
|
||||
`href="/calendar?month=`, // prev/next anchors present
|
||||
`href="/views/calendar?month=`, // prev/next anchors present
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("calendar body missing %q", want)
|
||||
@@ -71,7 +71,7 @@ func TestCalendarSurfacesDatedLink(t *testing.T) {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
|
||||
code, body := get(t, h, "/calendar")
|
||||
code, body := get(t, h, "/views/calendar")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /calendar → %d", code)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
|
||||
}
|
||||
|
||||
// Unfiltered: both notes show.
|
||||
_, all := get(t, h, "/calendar?refresh=1")
|
||||
_, all := get(t, h, "/views/calendar?refresh=1")
|
||||
if !strings.Contains(all, workNote) {
|
||||
t.Errorf("unfiltered calendar missing work note %q", workNote)
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func TestCalendarFilterScopeByTag(t *testing.T) {
|
||||
}
|
||||
|
||||
// Filtered: only work note shows.
|
||||
_, scoped := get(t, h, "/calendar?refresh=1&tag=cal-test-work-"+stamp)
|
||||
_, scoped := get(t, h, "/views/calendar?refresh=1&tag=cal-test-work-"+stamp)
|
||||
if !strings.Contains(scoped, workNote) {
|
||||
t.Errorf("filtered calendar missing work note %q", workNote)
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func TestCalendarAdjacentMonthDays(t *testing.T) {
|
||||
h := srv.Routes()
|
||||
// Pick a month whose first day is NOT a Monday so leading days appear.
|
||||
// May 2026 starts on a Friday; lead = Apr 27/28/29/30.
|
||||
_, body := get(t, h, "/calendar?month=2026-05&refresh=1")
|
||||
_, body := get(t, h, "/views/calendar?month=2026-05&refresh=1")
|
||||
if !strings.Contains(body, "adjacent-month") {
|
||||
t.Errorf("expected adjacent-month class on lead-in cells for May 2026, body did not include it")
|
||||
}
|
||||
@@ -173,11 +173,11 @@ func TestCalendarNavPrevNextLinks(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/calendar?month=2026-05")
|
||||
if !strings.Contains(body, `href="/calendar?month=2026-04"`) {
|
||||
_, body := get(t, h, "/views/calendar?month=2026-05")
|
||||
if !strings.Contains(body, `href="/views/calendar?month=2026-04"`) {
|
||||
t.Errorf("expected prev link to 2026-04, body did not include it")
|
||||
}
|
||||
if !strings.Contains(body, `href="/calendar?month=2026-06"`) {
|
||||
if !strings.Contains(body, `href="/views/calendar?month=2026-06"`) {
|
||||
t.Errorf("expected next link to 2026-06, body did not include it")
|
||||
}
|
||||
}
|
||||
@@ -190,11 +190,11 @@ func TestCalendarFilterChipStripRenders(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/calendar?month=2026-05")
|
||||
_, body := get(t, h, "/views/calendar?month=2026-05")
|
||||
for _, want := range []string{
|
||||
`id="calendar-filterbar"`,
|
||||
`hx-target="#calendar-section"`,
|
||||
`hx-get="/calendar"`,
|
||||
`hx-get="/views/calendar"`,
|
||||
`<input type="hidden" name="month" value="2026-05">`, // preserves month across chip changes
|
||||
`name="kind"`,
|
||||
`name="tag"`,
|
||||
@@ -213,7 +213,7 @@ func TestCalendarHTMXReturnsSectionOnly(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
req := httptest.NewRequest("GET", "/calendar?month=2026-05", nil)
|
||||
req := httptest.NewRequest("GET", "/views/calendar?month=2026-05", nil)
|
||||
req.Header.Set("HX-Request", "true")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
@@ -294,7 +294,7 @@ func TestCalendarFilterMultiValueTagsFromForm(t *testing.T) {
|
||||
}
|
||||
|
||||
// HTMX-style multi-value submission: two `tag=` params, not comma-joined.
|
||||
url := "/calendar?refresh=1&tag=" + tagA + "&tag=" + tagB
|
||||
url := "/views/calendar?refresh=1&tag=" + tagA + "&tag=" + tagB
|
||||
_, body := get(t, h, url)
|
||||
|
||||
// Item AB has BOTH tags — must appear.
|
||||
@@ -322,7 +322,7 @@ func TestCalendarCellCarriesLongLabel(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/calendar?month=2026-05")
|
||||
_, body := get(t, h, "/views/calendar?month=2026-05")
|
||||
// May 4 2026 is a Monday → "Mo., 4. Mai".
|
||||
if !strings.Contains(body, `Mo., 4. Mai`) {
|
||||
t.Errorf("expected long label 'Mo., 4. Mai' for 2026-05-04 cell, body did not include it")
|
||||
|
||||
@@ -201,7 +201,7 @@ func TestFormatMonthLabel(t *testing.T) {
|
||||
// month + all-three.
|
||||
func TestParseCalendarQueryDefaults(t *testing.T) {
|
||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||
r := httptest.NewRequest("GET", "/calendar", nil)
|
||||
r := httptest.NewRequest("GET", "/views/calendar", nil)
|
||||
q := parseCalendarQuery(r, now)
|
||||
if q.Month.Format("2006-01") != "2026-05" {
|
||||
t.Errorf("default month = %s, want 2026-05", q.Month.Format("2006-01"))
|
||||
@@ -223,7 +223,7 @@ func TestParseCalendarQueryDefaults(t *testing.T) {
|
||||
// nav writes to this exact key.
|
||||
func TestParseCalendarQueryMonthParam(t *testing.T) {
|
||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||
r := httptest.NewRequest("GET", "/calendar?month=2026-08", nil)
|
||||
r := httptest.NewRequest("GET", "/views/calendar?month=2026-08", nil)
|
||||
q := parseCalendarQuery(r, now)
|
||||
if q.Month.Format("2006-01") != "2026-08" {
|
||||
t.Errorf("parsed month = %s, want 2026-08", q.Month.Format("2006-01"))
|
||||
@@ -234,7 +234,7 @@ func TestParseCalendarQueryMonthParam(t *testing.T) {
|
||||
// kind set and drops unknown values.
|
||||
func TestParseCalendarQueryKindFilter(t *testing.T) {
|
||||
now := time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)
|
||||
r := httptest.NewRequest("GET", "/calendar?kind=event,doc,junk,creation", nil)
|
||||
r := httptest.NewRequest("GET", "/views/calendar?kind=event,doc,junk,creation", nil)
|
||||
q := parseCalendarQuery(r, now)
|
||||
got := strings.Join(q.activeKinds(), ",")
|
||||
want := "doc,event" // sorted alphabetically; creation is excluded by design, junk dropped
|
||||
|
||||
@@ -233,7 +233,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
refreshQuery += "scope=" + scope
|
||||
}
|
||||
refreshURL := "/dashboard?"
|
||||
refreshURL := "/views/dashboard?"
|
||||
if refreshQuery != "" {
|
||||
refreshURL += refreshQuery + "&"
|
||||
}
|
||||
@@ -256,7 +256,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
"RefreshURL": refreshURL,
|
||||
"FilterActive": filter.Active(),
|
||||
"Projects": projects,
|
||||
"BasePath": "/dashboard",
|
||||
"BasePath": "/views/dashboard",
|
||||
"ProjectChipTarget": "#dashboard-section",
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
@@ -304,9 +304,9 @@ func dashboardScopeToggleURL(view, scope, filterKey string) string {
|
||||
parts = append(parts, "scope="+next)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "/dashboard"
|
||||
return "/views/dashboard"
|
||||
}
|
||||
return "/dashboard?" + strings.Join(parts, "&")
|
||||
return "/views/dashboard?" + strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// dashboardTab is a single entry in the view-switcher strip.
|
||||
@@ -322,7 +322,7 @@ type dashboardTab struct {
|
||||
// scope (current) elide from the URL so the address bar stays clean
|
||||
// on the daily-driver path.
|
||||
func dashboardTabs(active, filterKey, scope string) []dashboardTab {
|
||||
prefix := "/dashboard"
|
||||
prefix := "/views/dashboard"
|
||||
filterQuery := ""
|
||||
if filterKey != "__empty__" && filterKey != "" {
|
||||
filterQuery = filterKey
|
||||
@@ -357,7 +357,7 @@ func dashboardTabs(active, filterKey, scope string) []dashboardTab {
|
||||
// shapes AND the new per-project rollup so the rollup costs zero extra
|
||||
// DAV/Gitea calls.
|
||||
func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashboardPayload, error) {
|
||||
items, err := s.Store.ListAll(ctx)
|
||||
items, err := s.Items.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -422,7 +422,7 @@ func (s *Server) buildDashboard(ctx context.Context, filter TreeFilter) (*dashbo
|
||||
|
||||
// --- Recent documents card ---
|
||||
since := now.AddDate(0, 0, -30)
|
||||
docRows, err := s.Store.RecentDocuments(ctx, since, 200)
|
||||
docRows, err := s.Items.RecentDocuments(ctx, since, 200)
|
||||
if err != nil {
|
||||
s.Logger.Warn("dashboard docs", "err", err)
|
||||
}
|
||||
@@ -486,7 +486,7 @@ func (s *Server) collectStale(ctx context.Context, items []*store.Item, openTask
|
||||
if openTasks[it.ID] > 0 || openIssues[it.ID] > 0 {
|
||||
continue
|
||||
}
|
||||
links, err := s.Store.LinksByType(ctx, it.ID, refTypeGiteaRepo)
|
||||
links, err := s.Items.LinksByType(ctx, it.ID, refTypeGiteaRepo)
|
||||
if err != nil || len(links) == 0 {
|
||||
continue
|
||||
}
|
||||
@@ -868,7 +868,7 @@ func (s *Server) dashboardTaskWrite(w http.ResponseWriter, r *http.Request, acti
|
||||
// pointing at the given URL. Used as the dashboard's write-side ownership
|
||||
// guard.
|
||||
func (s *Server) calendarLinked(ctx context.Context, calURL string) (bool, error) {
|
||||
links, err := s.Store.LinksByRefType(ctx, refTypeCalDAV)
|
||||
links, err := s.Items.LinksByRefType(ctx, refTypeCalDAV)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -70,9 +70,9 @@ END:VCALENDAR`
|
||||
|
||||
h := srv.Routes()
|
||||
// Inline VTODO writeback rows live on the Tasks tab (Phase 5h).
|
||||
code, body := get(t, h, "/dashboard?view=tasks")
|
||||
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
||||
t.Fatalf("GET /views/dashboard?view=tasks → %d", code)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`Edit me please`,
|
||||
|
||||
@@ -70,9 +70,9 @@ func TestDashboardEventsCardSurfacesUpcoming(t *testing.T) {
|
||||
|
||||
h := srv.Routes()
|
||||
// The card-events markup lives on the Tasks tab (Phase 5h).
|
||||
code, body := get(t, h, "/dashboard?view=tasks")
|
||||
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
||||
t.Fatalf("GET /views/dashboard?view=tasks → %d", code)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`card-events`,
|
||||
@@ -106,7 +106,7 @@ func TestDashboardEventsCardCollapsesWhenEmpty(t *testing.T) {
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/", "u", "p")}
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard?view=tasks")
|
||||
_, body := get(t, h, "/views/dashboard?view=tasks")
|
||||
if !strings.Contains(body, "No upcoming events") {
|
||||
t.Errorf("expected collapsed Events card with 'No upcoming events' note")
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func (s *Server) handleDashboardPin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
pinned := parseFormBool(r.FormValue("pin"))
|
||||
if err := s.Store.SetPinned(r.Context(), []string{id}, pinned); err != nil {
|
||||
if err := s.Writes.SetPinned(r.Context(), []string{id}, pinned); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestDashboardPinTogglesItem(t *testing.T) {
|
||||
}
|
||||
|
||||
// The re-render should mark the tile as .tile-pinned.
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
tileIdx := strings.Index(body, `data-item-id="`+id+`"`)
|
||||
if tileIdx < 0 {
|
||||
t.Fatalf("pinned tile not found in re-render")
|
||||
@@ -141,7 +141,7 @@ func TestDashboardPinInvalidatesCache(t *testing.T) {
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
|
||||
// Prime the cache — first GET caches an unpinned tile state.
|
||||
_, primed := get(t, h, "/dashboard")
|
||||
_, primed := get(t, h, "/views/dashboard")
|
||||
tileIdx := strings.Index(primed, `data-item-id="`+id+`"`)
|
||||
if tileIdx < 0 {
|
||||
t.Fatalf("seeded tile missing from primed dashboard")
|
||||
@@ -157,7 +157,7 @@ func TestDashboardPinInvalidatesCache(t *testing.T) {
|
||||
|
||||
// Next GET must reflect the new pinned state — proves the cache
|
||||
// entry for the previous (unpinned) state was invalidated.
|
||||
_, after := get(t, h, "/dashboard")
|
||||
_, after := get(t, h, "/views/dashboard")
|
||||
tileIdx2 := strings.Index(after, `data-item-id="`+id+`"`)
|
||||
if tileIdx2 < 0 {
|
||||
t.Fatalf("tile missing from post-pin dashboard")
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestDashboardRendersWithoutDeps(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/dashboard?view=tasks")
|
||||
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?view=tasks → %d body=%s", code, body)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func TestDashboardRecentDocsSurfacesDatedLinks(t *testing.T) {
|
||||
}
|
||||
|
||||
// The Recent Documents card lives on the Tasks tab (Phase 5h).
|
||||
code, body := get(t, h, "/dashboard?view=tasks")
|
||||
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ func TestDashboardFilterByTagNarrowsCard(t *testing.T) {
|
||||
}()
|
||||
|
||||
// Doc rows surface on the Tasks tab; the filter narrows both views.
|
||||
code, body := get(t, h, "/dashboard?tag=dev&view=tasks")
|
||||
code, body := get(t, h, "/views/dashboard?tag=dev&view=tasks")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?tag=dev&view=tasks → %d", code)
|
||||
}
|
||||
@@ -159,9 +159,9 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
|
||||
h := srv.Routes()
|
||||
|
||||
// Prime the cache.
|
||||
_, _ = get(t, h, "/dashboard")
|
||||
_, _ = get(t, h, "/views/dashboard")
|
||||
// Second hit shows cached label.
|
||||
_, cachedBody := get(t, h, "/dashboard")
|
||||
_, cachedBody := get(t, h, "/views/dashboard")
|
||||
if !strings.Contains(cachedBody, "cached") {
|
||||
n := len(cachedBody)
|
||||
if n > 600 {
|
||||
@@ -170,7 +170,7 @@ func TestDashboardRefreshBustsCache(t *testing.T) {
|
||||
t.Fatalf("setup: second load should be cached, got body:\n%s", cachedBody[:n])
|
||||
}
|
||||
// Third hit with ?refresh=1 should be fresh again.
|
||||
code, body := get(t, h, "/dashboard?refresh=1")
|
||||
code, body := get(t, h, "/views/dashboard?refresh=1")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?refresh=1 → %d", code)
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func TestDashboardCollapsesEmptyCardsWhenNoFilter(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/dashboard?view=tasks")
|
||||
code, body := get(t, h, "/views/dashboard?view=tasks")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?view=tasks → %d", code)
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func TestDashboardFilterKeepsFullCardChrome(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/dashboard?tag=nothing-matches-zzz&view=tasks")
|
||||
code, body := get(t, h, "/views/dashboard?tag=nothing-matches-zzz&view=tasks")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?tag=… → %d", code)
|
||||
}
|
||||
@@ -271,7 +271,7 @@ func TestDashboardStaleCardSurfacesDormantMaiProject(t *testing.T) {
|
||||
h := srv.Routes()
|
||||
// Phase 5h: the Stale card retired. The stale project now appears
|
||||
// inside the Tiles Quiet fold with a tile-stale flag on the tile.
|
||||
code, body := get(t, h, "/dashboard")
|
||||
code, body := get(t, h, "/views/dashboard")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard → %d", code)
|
||||
}
|
||||
@@ -335,7 +335,7 @@ func TestDashboardStaleCardSkipsRecentRepo(t *testing.T) {
|
||||
// Phase 5h: assert the tile for this slug is NOT flagged stale.
|
||||
// Recent repo activity (3d old) puts it solidly inside the activity
|
||||
// window AND fails the staleness probe, so no tile-stale class.
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
// Find the tile for this slug and check its class attribute.
|
||||
marker := `data-item-path="dev.` + slug + `"`
|
||||
idx := strings.Index(body, marker)
|
||||
@@ -362,8 +362,8 @@ func TestDashboardCacheHitOnSecondLoad(t *testing.T) {
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
_, _ = get(t, h, "/dashboard")
|
||||
code, body := get(t, h, "/dashboard")
|
||||
_, _ = get(t, h, "/views/dashboard")
|
||||
code, body := get(t, h, "/views/dashboard")
|
||||
if code != 200 {
|
||||
t.Fatalf("second GET /dashboard → %d", code)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestDashboardDefaultViewIsTiles(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/dashboard")
|
||||
code, body := get(t, h, "/views/dashboard")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard → %d", code)
|
||||
}
|
||||
@@ -36,9 +36,9 @@ func TestDashboardTabsRenderAllThree(t *testing.T) {
|
||||
activeTab string
|
||||
activeLabel string
|
||||
}{
|
||||
{"/dashboard", "tiles", "Tiles"},
|
||||
{"/dashboard?view=tasks", "tasks", "Tasks"},
|
||||
{"/dashboard?view=events", "events", "Events"},
|
||||
{"/views/dashboard", "tiles", "Tiles"},
|
||||
{"/views/dashboard?view=tasks", "tasks", "Tasks"},
|
||||
{"/views/dashboard?view=events", "events", "Events"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.activeTab, func(t *testing.T) {
|
||||
@@ -80,7 +80,7 @@ func TestDashboardTasksViewFallback(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard?view=tasks")
|
||||
_, body := get(t, h, "/views/dashboard?view=tasks")
|
||||
if strings.Contains(body, `class="dash-tiles"`) {
|
||||
t.Errorf("view=tasks should NOT render the Tiles grid")
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func TestDashboardEventsViewRenders(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard?view=events")
|
||||
_, body := get(t, h, "/views/dashboard?view=events")
|
||||
if !strings.Contains(body, `class="dash-events-view"`) {
|
||||
t.Errorf("view=events should render the promoted Events surface")
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func TestDashboardUnknownViewFallsBackToTiles(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/dashboard?view=gibberish")
|
||||
code, body := get(t, h, "/views/dashboard?view=gibberish")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard?view=gibberish → %d", code)
|
||||
}
|
||||
@@ -155,7 +155,7 @@ func TestDashboardTilesViewShowsRollupForSeededItem(t *testing.T) {
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
|
||||
code, body := get(t, h, "/dashboard")
|
||||
code, body := get(t, h, "/views/dashboard")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /dashboard → %d", code)
|
||||
}
|
||||
@@ -172,19 +172,19 @@ func TestDashboardTilesViewShowsRollupForSeededItem(t *testing.T) {
|
||||
// TestDashboardCacheKeySeparatesViews ensures the cache layer keys by
|
||||
// (filter, view): the same filter under different views must hit
|
||||
// independent cache entries. We prove this by priming /dashboard, then
|
||||
// /dashboard?view=tasks, and asserting both report "fresh" on their
|
||||
// /views/dashboard?view=tasks, and asserting both report "fresh" on their
|
||||
// first call (i.e. they don't share a cache slot).
|
||||
func TestDashboardCacheKeySeparatesViews(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body1 := get(t, h, "/dashboard")
|
||||
_, body1 := get(t, h, "/views/dashboard")
|
||||
if !strings.Contains(body1, "fresh") {
|
||||
t.Fatalf("first /dashboard load should be fresh")
|
||||
}
|
||||
_, body2 := get(t, h, "/dashboard?view=tasks")
|
||||
_, body2 := get(t, h, "/views/dashboard?view=tasks")
|
||||
if !strings.Contains(body2, "fresh") {
|
||||
t.Errorf("first /dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
|
||||
t.Errorf("first /views/dashboard?view=tasks load should be fresh — sharing a cache slot with Tiles would mark it cached")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,18 +195,18 @@ func TestDashboardScopeChipRendersOnTilesOnly(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, tiles := get(t, h, "/dashboard")
|
||||
_, tiles := get(t, h, "/views/dashboard")
|
||||
if !strings.Contains(tiles, `class="dash-scope-chip"`) {
|
||||
t.Errorf("Tiles view should render the scope chip")
|
||||
}
|
||||
if !strings.Contains(tiles, "◇ current") {
|
||||
t.Errorf("default scope chip should show '◇ current'")
|
||||
}
|
||||
_, tasks := get(t, h, "/dashboard?view=tasks")
|
||||
_, tasks := get(t, h, "/views/dashboard?view=tasks")
|
||||
if strings.Contains(tasks, `class="dash-scope-chip"`) {
|
||||
t.Errorf("Tasks view should NOT render the scope chip")
|
||||
}
|
||||
_, events := get(t, h, "/dashboard?view=events")
|
||||
_, events := get(t, h, "/views/dashboard?view=events")
|
||||
if strings.Contains(events, `class="dash-scope-chip"`) {
|
||||
t.Errorf("Events view should NOT render the scope chip")
|
||||
}
|
||||
@@ -218,7 +218,7 @@ func TestDashboardScopeAllChipFlipsLabel(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard?scope=all")
|
||||
_, body := get(t, h, "/views/dashboard?scope=all")
|
||||
if !strings.Contains(body, "○ all") {
|
||||
t.Errorf("scope=all should render '○ all' chip label")
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func TestDashboardScopeAllHidesQuietFold(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard?scope=all")
|
||||
_, body := get(t, h, "/views/dashboard?scope=all")
|
||||
if strings.Contains(body, `class="dash-quiet"`) {
|
||||
t.Errorf("scope=all should NOT render the Quiet fold — everything is in the primary grid")
|
||||
}
|
||||
@@ -246,12 +246,12 @@ func TestDashboardScopeChipURLFlips(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, defaultBody := get(t, h, "/dashboard")
|
||||
if !strings.Contains(defaultBody, `href="/dashboard?scope=all"`) {
|
||||
_, defaultBody := get(t, h, "/views/dashboard")
|
||||
if !strings.Contains(defaultBody, `href="/views/dashboard?scope=all"`) {
|
||||
t.Errorf("default scope chip should link to ?scope=all")
|
||||
}
|
||||
_, allBody := get(t, h, "/dashboard?scope=all")
|
||||
if !strings.Contains(allBody, `href="/dashboard"`) {
|
||||
_, allBody := get(t, h, "/views/dashboard?scope=all")
|
||||
if !strings.Contains(allBody, `href="/views/dashboard"`) {
|
||||
t.Errorf("scope=all chip should link back to /dashboard (scope=current is default+elided)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ func (s *Server) detailIssues(ctx context.Context, item *store.Item) ([]repoIssu
|
||||
if s.Gitea == nil {
|
||||
return nil, nil
|
||||
}
|
||||
links, err := s.Store.LinksByType(ctx, item.ID, refTypeGiteaRepo)
|
||||
links, err := s.Items.LinksByType(ctx, item.ID, refTypeGiteaRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func (s *Server) handleIssueAction(w http.ResponseWriter, r *http.Request, path,
|
||||
http.Error(w, "gitea not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -135,7 +135,7 @@ func (s *Server) renderIssuesSection(w http.ResponseWriter, r *http.Request, it
|
||||
// to this item via a gitea-repo item_link. Prevents form-crafted writeback
|
||||
// against unrelated repos.
|
||||
func (s *Server) repoLinkedToItem(ctx context.Context, itemID, repoRef string) bool {
|
||||
links, err := s.Store.LinksByType(ctx, itemID, refTypeGiteaRepo)
|
||||
links, err := s.Items.LinksByType(ctx, itemID, refTypeGiteaRepo)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ type graphPayload struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
|
||||
items, err := s.Store.ListAll(r.Context())
|
||||
items, err := s.Items.ListAll(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -51,7 +51,7 @@ func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
allTags, err := s.Store.AllTags(r.Context())
|
||||
allTags, err := s.Items.AllTags(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestGraphPageRenders(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/graph")
|
||||
code, body := get(t, h, "/views/graph")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /graph → %d body=%s", code, body)
|
||||
}
|
||||
@@ -42,7 +42,7 @@ func TestGraphFilterDimsNonMatching(t *testing.T) {
|
||||
h := srv.Routes()
|
||||
|
||||
// Use a definitely-unused tag to force every node to mismatch.
|
||||
code, body := get(t, h, "/graph?tag=ZZZZ-unused-tag")
|
||||
code, body := get(t, h, "/views/graph?tag=ZZZZ-unused-tag")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /graph?tag=ZZZ → %d", code)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func TestGraphIsolateHidesNonMatching(t *testing.T) {
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
|
||||
code, body := get(t, h, "/graph?tag="+tag+"&isolate=1")
|
||||
code, body := get(t, h, "/views/graph?tag="+tag+"&isolate=1")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /graph?isolate → %d", code)
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func TestGraphSVGDownload(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
req := httptest.NewRequest(http.MethodGet, "/graph?download=svg", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/views/graph?download=svg", nil)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Result().StatusCode != 200 {
|
||||
|
||||
43
web/icons.go
Normal file
43
web/icons.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package web
|
||||
|
||||
import "html/template"
|
||||
|
||||
// Phase 5j slice G — icon registry per m's Q6 pick (2026-05-29). The
|
||||
// curated set of keys mirrors the editor's <select> options so the round-
|
||||
// trip works: editor save persists the key string, layout renders the SVG
|
||||
// at look-up time. Unknown / empty keys fall back to the default folder
|
||||
// glyph.
|
||||
//
|
||||
// Stored as html/template.HTML so layout.tmpl can emit the markup
|
||||
// directly without html-escaping the angle brackets. Each SVG is sized
|
||||
// to 18px square and inherits currentColor like the existing nav-icon
|
||||
// glyphs.
|
||||
|
||||
var iconRegistry = map[string]template.HTML{
|
||||
"folder": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`),
|
||||
"clock": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`),
|
||||
"star": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`),
|
||||
"tag": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>`),
|
||||
"inbox": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>`),
|
||||
"box": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>`),
|
||||
"file-text": template.HTML(`<svg class="nav-icon user-view-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>`),
|
||||
}
|
||||
|
||||
// RenderViewIcon returns the SVG for an icon key, falling back to the
|
||||
// folder default for nil or unknown keys. Template-callable so
|
||||
// layout.tmpl can emit `{{renderIcon .Icon}}`.
|
||||
func RenderViewIcon(icon *string) template.HTML {
|
||||
key := "folder"
|
||||
if icon != nil && *icon != "" {
|
||||
if _, ok := iconRegistry[*icon]; ok {
|
||||
key = *icon
|
||||
}
|
||||
}
|
||||
return iconRegistry[key]
|
||||
}
|
||||
|
||||
// IconRegistryKeys returns the available icon keys in display order, for
|
||||
// the editor's <select>. The first key (folder) is the default.
|
||||
func IconRegistryKeys() []string {
|
||||
return []string{"folder", "clock", "star", "tag", "inbox", "box", "file-text"}
|
||||
}
|
||||
@@ -13,18 +13,18 @@ func TestLayoutSidebarOnDesktop(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
if !strings.Contains(body, `<aside class="projax-sidebar"`) {
|
||||
t.Fatalf("expected <aside class=\"projax-sidebar\"> in body, got: %s", truncate(body, 400))
|
||||
}
|
||||
for _, want := range []struct {
|
||||
href, label string
|
||||
}{
|
||||
{`/`, "Tree"},
|
||||
{`/dashboard`, "Dashboard"},
|
||||
{`/calendar`, "Calendar"},
|
||||
{`/timeline`, "Timeline"},
|
||||
{`/graph`, "Graph"},
|
||||
{`/views/tree`, "Tree"},
|
||||
{`/views/dashboard`, "Dashboard"},
|
||||
{`/views/calendar`, "Calendar"},
|
||||
{`/views/timeline`, "Timeline"},
|
||||
{`/views/graph`, "Graph"},
|
||||
{`/admin`, "Admin"},
|
||||
} {
|
||||
if !strings.Contains(body, `href="`+want.href+`"`) {
|
||||
@@ -43,12 +43,12 @@ func TestLayoutActiveClass(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
// Dashboard item should be active.
|
||||
if !strings.Contains(body, `class="nav-item active" title="Dashboard"`) {
|
||||
t.Errorf("expected Dashboard nav-item to carry .active on /dashboard, body: %s", truncate(body, 400))
|
||||
}
|
||||
// Tree item (href="/") must NOT be active on the /dashboard page.
|
||||
// Tree item (href="/views/tree") must NOT be active on the /dashboard page.
|
||||
// The Tree anchor opens with the exact-path active match; on /dashboard
|
||||
// the substring `class="nav-item" title="Tree"` should be present and
|
||||
// not its `active` sibling.
|
||||
@@ -68,7 +68,7 @@ func TestLayoutCollapseScript(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
// Pre-paint restore script.
|
||||
if !strings.Contains(body, `localStorage.getItem('projax.sidebar.collapsed')`) {
|
||||
t.Errorf("expected pre-paint localStorage restore script in layout")
|
||||
@@ -93,7 +93,7 @@ func TestLayoutNoTopHeader(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
// Slice out the region between <body> and <main> — that's where the
|
||||
// pre-5g top header lived. Inside <main> belongs to content templates.
|
||||
chrome := body
|
||||
@@ -116,17 +116,17 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
if !strings.Contains(body, `<nav class="projax-bottom-nav"`) {
|
||||
t.Fatalf("expected <nav class=\"projax-bottom-nav\"> in body, got: %s", truncate(body, 400))
|
||||
}
|
||||
// 5-slot anchors / details element.
|
||||
for _, want := range []string{
|
||||
`<a href="/" class="bottom-nav-item`,
|
||||
`<a href="/dashboard" class="bottom-nav-item`,
|
||||
`<a href="/views/tree" class="bottom-nav-item`,
|
||||
`<a href="/views/dashboard" class="bottom-nav-item`,
|
||||
`<a href="/new" class="bottom-nav-item capture-btn"`,
|
||||
`class="capture-circle"`,
|
||||
`<a href="/calendar" class="bottom-nav-item`,
|
||||
`<a href="/views/calendar" class="bottom-nav-item`,
|
||||
`<details class="projax-mobile-drawer"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
@@ -135,8 +135,8 @@ func TestLayoutBottomNavMarkup(t *testing.T) {
|
||||
}
|
||||
// Drawer overflow items: Timeline, Graph, Admin, theme toggle, sign-out.
|
||||
for _, want := range []string{
|
||||
`<a href="/timeline" class="drawer-item`,
|
||||
`<a href="/graph" class="drawer-item`,
|
||||
`<a href="/views/timeline" class="drawer-item`,
|
||||
`<a href="/views/graph" class="drawer-item`,
|
||||
`<a href="/admin" class="drawer-item`,
|
||||
`id="theme-toggle-drawer"`,
|
||||
`<form method="post" action="/logout" class="drawer-form">`,
|
||||
@@ -154,11 +154,11 @@ func TestLayoutBottomNavActiveClass(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/calendar")
|
||||
if !strings.Contains(body, `<a href="/calendar" class="bottom-nav-item active"`) {
|
||||
_, body := get(t, h, "/views/calendar")
|
||||
if !strings.Contains(body, `<a href="/views/calendar" class="bottom-nav-item active"`) {
|
||||
t.Errorf("expected Calendar bottom-nav-item to carry .active on /calendar")
|
||||
}
|
||||
if strings.Contains(body, `<a href="/" class="bottom-nav-item active"`) {
|
||||
if strings.Contains(body, `<a href="/views/tree" class="bottom-nav-item active"`) {
|
||||
t.Errorf("Tree bottom-nav-item should NOT be active on /calendar")
|
||||
}
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
// Both buttons present.
|
||||
if !strings.Contains(body, `id="theme-toggle"`) {
|
||||
t.Errorf("sidebar theme-toggle button missing")
|
||||
@@ -185,6 +185,21 @@ func TestLayoutThemeToggleBoundToBothButtons(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLayoutLoadsHTMX guards against the Phase 7c regression: the task / tree
|
||||
// / dashboard / bulk / classify forms drive in-place swaps with hx-* attrs,
|
||||
// which are inert unless htmx is actually loaded. It went unnoticed for many
|
||||
// phases (every hx-post task form silently no-op'd to a GET-to-self). This
|
||||
// test fails the moment the vendored htmx <script> drops out of the layout.
|
||||
func TestLayoutLoadsHTMX(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
if !strings.Contains(body, `src="/static/htmx.min.js"`) {
|
||||
t.Errorf("layout must load vendored htmx (hx-* forms are dead without it); body: %s", truncate(body, 400))
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
|
||||
36
web/links.go
36
web/links.go
@@ -2,7 +2,6 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -15,7 +14,7 @@ import (
|
||||
// note, event_date (YYYY-MM-DD). Anti-forgery isn't a concern at v1 since the
|
||||
// trust model is Tailscale-only + cookie auth.
|
||||
func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path string) {
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -46,7 +45,7 @@ func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path str
|
||||
if noteVal != "" {
|
||||
notePtr = ¬eVal
|
||||
}
|
||||
if _, err := s.Store.AddLinkDated(r.Context(), it.ID, refType, refID, "", notePtr, date, nil); err != nil {
|
||||
if _, err := s.Writes.AddLinkDated(r.Context(), it.ID, refType, refID, "", notePtr, date, nil); err != nil {
|
||||
banner = fmt.Sprintf("Could not add link: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +56,7 @@ func (s *Server) handleLinksAdd(w http.ResponseWriter, r *http.Request, path str
|
||||
|
||||
// handleLinksRemove processes POST /i/{path}/links/remove.
|
||||
func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path string) {
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -80,7 +79,7 @@ func (s *Server) handleLinksRemove(w http.ResponseWriter, r *http.Request, path
|
||||
}
|
||||
if !owns {
|
||||
banner = "Link does not belong to this item."
|
||||
} else if err := s.Store.DeleteLink(r.Context(), linkID); err != nil {
|
||||
} else if err := s.Writes.DeleteLink(r.Context(), linkID); err != nil {
|
||||
banner = fmt.Sprintf("Could not remove link: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +105,7 @@ func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request,
|
||||
http.Redirect(w, r, "/i/"+it.PrimaryPath()+"#documents-section", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
|
||||
docs, err := s.Items.DatedLinks(r.Context(), it.ID)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -120,21 +119,20 @@ func (s *Server) renderDocumentsSection(w http.ResponseWriter, r *http.Request,
|
||||
})
|
||||
}
|
||||
|
||||
// linkBelongsToItem returns true when the link's item_id equals the supplied
|
||||
// item id. Used as an anti-forgery check before delete.
|
||||
// linkBelongsToItem returns true when the given link id is one of the
|
||||
// item's own links. Used as an anti-forgery check before delete. Reads
|
||||
// through the adapter (s.Items) so it resolves against whichever backend
|
||||
// is live — a direct projax.item_links query would miss mBrian-backed
|
||||
// links and reject every delete under PROJAX_BACKEND=mbrian.
|
||||
func (s *Server) linkBelongsToItem(ctx context.Context, linkID, itemID string) (bool, error) {
|
||||
var owner string
|
||||
err := s.Store.Pool.QueryRow(ctx,
|
||||
`select item_id from projax.item_links where id = $1`, linkID).Scan(&owner)
|
||||
links, err := s.Items.LinksByType(ctx, itemID, "") // "" → every ref_type
|
||||
if err != nil {
|
||||
if isNoRows(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return owner == itemID, nil
|
||||
}
|
||||
|
||||
func isNoRows(err error) bool {
|
||||
return err != nil && (errors.Is(err, store.ErrNotFound) || err.Error() == "no rows in result set")
|
||||
for _, l := range links {
|
||||
if l.ID == linkID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -245,14 +245,14 @@ func TestTreeFilterPublicNarrows(t *testing.T) {
|
||||
// filter.
|
||||
pubLink := `href="/i/dev.` + pubSlug + `"`
|
||||
prvLink := `href="/i/dev.` + prvSlug + `"`
|
||||
_, yesBody := get(t, h, "/?public=1")
|
||||
_, yesBody := get(t, h, "/views/tree?public=1")
|
||||
if !strings.Contains(yesBody, pubLink) {
|
||||
t.Errorf("?public=1 should show pub-filt-yes row")
|
||||
}
|
||||
if strings.Contains(yesBody, prvLink) {
|
||||
t.Errorf("?public=1 should hide pub-filt-no row")
|
||||
}
|
||||
_, noBody := get(t, h, "/?public=0")
|
||||
_, noBody := get(t, h, "/views/tree?public=0")
|
||||
if strings.Contains(noBody, pubLink) {
|
||||
t.Errorf("?public=0 should hide pub-filt-yes row")
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestLayoutHasManifestAndAppleTouchIcon(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
for _, want := range []string{
|
||||
`rel="manifest"`,
|
||||
`/static/manifest.webmanifest`,
|
||||
|
||||
303
web/server.go
303
web/server.go
@@ -15,7 +15,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/caldav"
|
||||
"github.com/m/projax/internal/aggregate"
|
||||
"github.com/m/projax/internal/cache"
|
||||
"github.com/m/projax/internal/itemwrite"
|
||||
@@ -34,6 +33,29 @@ func (s *Server) itemWriteFailure(w http.ResponseWriter, r *http.Request, ve *it
|
||||
s.Logger.Warn("itemwrite reject", "path", r.URL.Path, "kind", ve.Kind, "detail", ve.Detail)
|
||||
}
|
||||
|
||||
// writeFailure renders a write error from the adapter. Slug outcomes from
|
||||
// the mBrian backend (409 collision / 400 invalid) surface as the same
|
||||
// friendly itemwrite banners the pre-flight validator uses, so a slug
|
||||
// taken by a soft-deleted tombstone — which the validator can't see —
|
||||
// still reads cleanly instead of dumping a raw API error. Everything else
|
||||
// falls through to the generic failure page.
|
||||
func (s *Server) writeFailure(w http.ResponseWriter, r *http.Request, err error) {
|
||||
switch {
|
||||
case errors.Is(err, store.ErrSlugTaken):
|
||||
s.itemWriteFailure(w, r, &itemwrite.ValidationError{
|
||||
Kind: itemwrite.KindSlugCollision,
|
||||
Detail: "That slug is already taken (possibly by a deleted item) — pick another.",
|
||||
})
|
||||
case errors.Is(err, store.ErrInvalidSlug):
|
||||
s.itemWriteFailure(w, r, &itemwrite.ValidationError{
|
||||
Kind: itemwrite.KindInvalidSlugFormat,
|
||||
Detail: "Invalid slug — use lower-case, no dots or whitespace.",
|
||||
})
|
||||
default:
|
||||
s.fail(w, r, err)
|
||||
}
|
||||
}
|
||||
|
||||
// itemWriteBannerCopy maps a ValidationError.Kind to the human-facing
|
||||
// banner copy. Centralised so web/server.go + web/bulk.go share one
|
||||
// authoritative phrasing.
|
||||
@@ -75,12 +97,25 @@ var staticFS embed.FS
|
||||
|
||||
// Server bundles handlers, templates, and the store.
|
||||
type Server struct {
|
||||
Store *store.Store
|
||||
Store *store.Store
|
||||
// Items is the read-path adapter every UI handler / MCP read tool /
|
||||
// aggregator depends on. Phase 6 Slice B introduces it: today the
|
||||
// concrete *Store satisfies the ItemReader interface (legacy path);
|
||||
// after the mBrian backend rollout PROJAX_BACKEND=mbrian wires
|
||||
// *store.MBrianReader here.
|
||||
Items store.ItemReader
|
||||
// Writes is the write-path adapter every UI write handler, the
|
||||
// /admin/bulk apply path, and the MCP write tools depend on. Phase 6
|
||||
// Slice C introduces it as the twin of Items: today the concrete
|
||||
// *Store satisfies ItemWriter (legacy path); PROJAX_BACKEND=mbrian
|
||||
// wires *store.MBrianWriter here. main.go flips Items + Writes
|
||||
// together — never one without the other (the slice-B half-flip bug).
|
||||
Writes store.ItemWriter
|
||||
pages map[string]*template.Template
|
||||
Logger *slog.Logger
|
||||
Auth *AuthConfig // nil → no auth (local dev / tests)
|
||||
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
|
||||
Gitea *GiteaDeps // nil → Gitea integration disabled
|
||||
Auth *AuthConfig // nil → no auth (local dev / tests)
|
||||
CalDAV *CalDAVDeps // nil → CalDAV integration disabled
|
||||
Gitea *GiteaDeps // nil → Gitea integration disabled
|
||||
MCP http.Handler // nil → /mcp/ routes return 404 (off cleanly)
|
||||
Version string // build-time -ldflags injection; surfaced on /admin
|
||||
dashboard *cache.TTLCache[*dashboardPayload]
|
||||
@@ -133,6 +168,10 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
"addF": func(a, b any) float64 { return toFloat(a) + toFloat(b) },
|
||||
"subF": func(a, b any) float64 { return toFloat(a) - toFloat(b) },
|
||||
"mulF": func(a, b any) float64 { return toFloat(a) * toFloat(b) },
|
||||
// Phase 5j slice G — sidebar icon registry. layout.tmpl calls
|
||||
// `renderIcon .View.Icon` to emit the matching SVG, falling back to
|
||||
// the folder default for nil / unknown keys.
|
||||
"renderIcon": RenderViewIcon,
|
||||
"tagToggleURL": func(active []string, tag string, isActive bool) string {
|
||||
next := []string{}
|
||||
if isActive {
|
||||
@@ -152,7 +191,7 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
},
|
||||
}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views", "view_edit"} {
|
||||
for _, name := range []string{"new", "classify", "caldav_admin", "caldav_disabled", "error", "views_landing", "view_editor"} {
|
||||
t, err := template.New(name).Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
"templates/"+name+".tmpl",
|
||||
@@ -189,6 +228,21 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
return nil, fmt.Errorf("parse tree_section: %w", err)
|
||||
}
|
||||
pages["tree_section"] = treeSection
|
||||
// Phase 5j view-render template bundles the tree-section partials so a
|
||||
// rendered view at /views/{slug} can use the same dispatch (list / card
|
||||
// / kanban via .ViewType).
|
||||
viewRender, err := template.New("view_render").Funcs(funcs).ParseFS(templatesFS,
|
||||
"templates/layout.tmpl",
|
||||
"templates/view_render.tmpl",
|
||||
"templates/tree_section.tmpl",
|
||||
"templates/tree_card.tmpl",
|
||||
"templates/tree_kanban.tmpl",
|
||||
"templates/project_chip.tmpl",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse view_render: %w", err)
|
||||
}
|
||||
pages["view_render"] = viewRender
|
||||
// detail bundles the shared tasks-section + issues-section partials so
|
||||
// HTMX swaps and the initial page render hit the same template definitions.
|
||||
detailTmpl, err := template.New("detail").Funcs(funcs).ParseFS(templatesFS,
|
||||
@@ -202,7 +256,8 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
return nil, fmt.Errorf("parse detail: %w", err)
|
||||
}
|
||||
pages["detail"] = detailTmpl
|
||||
// Standalone tasks-section template for HTMX fragment responses.
|
||||
// Standalone unified-tasks-section template for HTMX fragment responses
|
||||
// (Phase 7c — CalDAV + mBrian task writes re-render the merged list).
|
||||
tasksFragment, err := template.New("tasks_section").Funcs(funcs).ParseFS(templatesFS, "templates/tasks_section.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse tasks_section: %w", err)
|
||||
@@ -348,7 +403,12 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
pages["bulk_chip_mgmt"] = bulkChipMgmt
|
||||
|
||||
return &Server{
|
||||
Store: s,
|
||||
Store: s,
|
||||
// Default Items + Writes satisfier is *Store itself. main.go can
|
||||
// override both post-construction, atomically (PROJAX_BACKEND=mbrian
|
||||
// → MBrianReader + MBrianWriter).
|
||||
Items: s,
|
||||
Writes: s,
|
||||
pages: pages,
|
||||
Logger: logger,
|
||||
dashboard: cache.NewTTL[*dashboardPayload](dashboardCacheTTL),
|
||||
@@ -362,17 +422,26 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET /", s.handleTree)
|
||||
// Phase 5j slice C — full URL migration. The five legacy pages live at
|
||||
// /views/{system-slug} now; the old top-level URLs 301-redirect to
|
||||
// their new home (with the legacy ?view=<uuid> param resolved through
|
||||
// the old uuid → new slug if it still maps to a row).
|
||||
mux.HandleFunc("GET /views/tree", s.handleTree)
|
||||
mux.HandleFunc("GET /views/dashboard", s.handleDashboard)
|
||||
mux.HandleFunc("GET /views/timeline", s.handleTimeline)
|
||||
mux.HandleFunc("GET /views/calendar", s.handleCalendar)
|
||||
mux.HandleFunc("GET /views/graph", s.handleGraph)
|
||||
mux.HandleFunc("GET /", s.legacyRedirect("tree"))
|
||||
mux.HandleFunc("GET /dashboard", s.legacyRedirect("dashboard"))
|
||||
mux.HandleFunc("GET /timeline", s.legacyRedirect("timeline"))
|
||||
mux.HandleFunc("GET /calendar", s.legacyRedirect("calendar"))
|
||||
mux.HandleFunc("GET /graph", s.legacyRedirect("graph"))
|
||||
mux.HandleFunc("GET /i/", s.handleDetail)
|
||||
mux.HandleFunc("POST /i/", s.handleDetailWrite)
|
||||
mux.HandleFunc("GET /new", s.handleNewForm)
|
||||
mux.HandleFunc("POST /new", s.handleNewSubmit)
|
||||
mux.HandleFunc("GET /admin", s.handleAdminIndex)
|
||||
mux.HandleFunc("GET /admin/classify", s.handleClassify)
|
||||
mux.HandleFunc("GET /dashboard", s.handleDashboard)
|
||||
mux.HandleFunc("GET /timeline", s.handleTimeline)
|
||||
mux.HandleFunc("GET /calendar", s.handleCalendar)
|
||||
mux.HandleFunc("GET /graph", s.handleGraph)
|
||||
mux.HandleFunc("POST /dashboard/task/done", s.handleDashboardTaskDone)
|
||||
mux.HandleFunc("POST /dashboard/task/edit", s.handleDashboardTaskEdit)
|
||||
mux.HandleFunc("POST /dashboard/task/delete", s.handleDashboardTaskDelete)
|
||||
@@ -383,11 +452,18 @@ func (s *Server) Routes() http.Handler {
|
||||
mux.HandleFunc("GET /admin/caldav", s.handleCalDAVAdmin)
|
||||
mux.HandleFunc("POST /admin/caldav/link", s.handleCalDAVLink)
|
||||
mux.HandleFunc("POST /admin/caldav/unlink", s.handleCalDAVUnlink)
|
||||
mux.HandleFunc("GET /views", s.handleViewsIndex)
|
||||
// Phase 5j paliad-shape views routes (slice B). /views = MRU landing
|
||||
// or onboarding shell; /views/{slug} = render the saved view as its
|
||||
// own page; /views/new + /views/{slug}/edit = editor. POST CRUD
|
||||
// rounds out the family; reorder is wired now for slice G's drag UI.
|
||||
mux.HandleFunc("GET /views", s.handleViewsLanding)
|
||||
mux.HandleFunc("POST /views", s.handleViewCreate)
|
||||
mux.HandleFunc("GET /views/{id}/edit", s.handleViewEdit)
|
||||
mux.HandleFunc("GET /views/", s.handleViewRedirect)
|
||||
mux.HandleFunc("POST /views/", s.handleViewWrite)
|
||||
mux.HandleFunc("POST /views/reorder", s.handleViewReorder)
|
||||
mux.HandleFunc("GET /views/new", s.handleViewEditor)
|
||||
mux.HandleFunc("GET /views/{slug}", s.handleViewRender)
|
||||
mux.HandleFunc("GET /views/{slug}/edit", s.handleViewEditor)
|
||||
mux.HandleFunc("POST /views/{slug}", s.handleViewUpdate)
|
||||
mux.HandleFunc("POST /views/{slug}/delete", s.handleViewDelete)
|
||||
mux.HandleFunc("GET /login", s.handleLoginForm)
|
||||
mux.HandleFunc("POST /login", s.handleLoginSubmit)
|
||||
mux.HandleFunc("POST /logout", s.handleLogout)
|
||||
@@ -430,16 +506,15 @@ type treeNode struct {
|
||||
}
|
||||
|
||||
func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
items, err := s.Store.ListAll(r.Context())
|
||||
// Phase 5j slice C: handleTree is reached at /views/tree (system view)
|
||||
// only. The legacy / route 301-redirects via legacyRedirect — see
|
||||
// Routes(). Any 404-on-unknown-path responsibility moved with it.
|
||||
items, err := s.Items.ListAll(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
tags, err := s.Store.AllTags(r.Context())
|
||||
tags, err := s.Items.AllTags(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -452,32 +527,16 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
filter := ParseTreeFilter(r.URL.Query())
|
||||
viewSet := PageViewTypes("/")
|
||||
view := ParseViewType(r.URL.Query(), viewSet)
|
||||
var defaultBanner *store.View
|
||||
// Phase 5i Slice D: ?view=<uuid> resolves a saved view's filter +
|
||||
// view_type into the current request, overriding URL-only chip state.
|
||||
// Resolution failure (deleted view, malformed payload) is logged and
|
||||
// silently falls back to the URL-derived filter — the page stays
|
||||
// renderable rather than 500ing.
|
||||
if saved, err := s.applySavedView(r, &filter, &view); err == nil && saved != nil {
|
||||
// Re-validate view_type against the route catalog so a saved
|
||||
// kanban-view URL opened on / (before slice C ships kanban) lands on
|
||||
// the default with the chip showing the wanted view as locked.
|
||||
view = viewSet.Resolve(view)
|
||||
} else if err != nil {
|
||||
s.Logger.Warn("applySavedView", "id", r.URL.Query().Get("view"), "err", err)
|
||||
} else {
|
||||
// Phase 5i Slice E: no explicit ?view= → check for a page default.
|
||||
// applyDefaultView returns nil unless the URL is "clean" (no chip
|
||||
// state) AND a default exists for this page.
|
||||
if def, err := s.applyDefaultView(r, "tree", &filter, &view); err == nil && def != nil {
|
||||
view = viewSet.Resolve(view)
|
||||
defaultBanner = def
|
||||
} else if err != nil {
|
||||
s.Logger.Warn("applyDefaultView", "page", "tree", "err", err)
|
||||
}
|
||||
}
|
||||
// Phase 5j: ?view= overlay + is_default_for resolution deleted with the
|
||||
// 5i shape. /views/{slug} (slice B+) renders saved views as their own
|
||||
// pages; legacy ?view=<uuid> URLs are 302-redirected from a dedicated
|
||||
// handler (slice C). handleTree stays focused on the tree-as-tree
|
||||
// surface and no longer hijacks itself based on a query param.
|
||||
roots, orphans, total, orphanN, matched := applyTreeFilter(items, filter, linkKinds)
|
||||
counts := computeChipCounts(items, filter, linkKinds, tags)
|
||||
// Phase 5j slice C: tree lives at /views/tree now. Chip URLs need to
|
||||
// anchor on the new base so chip clicks stay on this page.
|
||||
const treeBase = "/views/tree"
|
||||
counts := computeChipCounts(items, filter, linkKinds, tags, treeBase)
|
||||
// Phase 5i Slice B: the card view renders a flat grid of matched items
|
||||
// (no tree structure). Build from items + filter directly rather than
|
||||
// reusing the post-prune `roots` (which still keeps ancestors).
|
||||
@@ -485,7 +544,7 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
// Phase 5i Slice C: kanban groups the same matched set into columns.
|
||||
groupBy := ParseGroupBy(r.URL.Query())
|
||||
kanban := BuildKanbanBoard(cardItems, groupBy)
|
||||
groupByChips := GroupByChips("/", filter, groupBy)
|
||||
groupByChips := GroupByChips(treeBase, filter, groupBy)
|
||||
data := map[string]any{
|
||||
"Title": "tree",
|
||||
"Roots": roots,
|
||||
@@ -497,15 +556,14 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
"Filter": filter,
|
||||
"Counts": counts,
|
||||
"Projects": parentOptionsFromItems(items),
|
||||
"BasePath": "/",
|
||||
"BasePath": treeBase,
|
||||
"ProjectChipTarget": "#tree-section",
|
||||
"ViewType": view,
|
||||
"ViewTypeChips": ViewTypeChips("/", filter, view),
|
||||
"ViewTypeChips": ViewTypeChips(treeBase, filter, view),
|
||||
"CardItems": cardItems,
|
||||
"Kanban": kanban,
|
||||
"GroupBy": groupBy,
|
||||
"GroupByChips": groupByChips,
|
||||
"DefaultBanner": defaultBanner,
|
||||
// ActiveTags kept for backwards-compat with the old template path; removed
|
||||
// after the template migrates fully.
|
||||
"ActiveTags": filter.Tags,
|
||||
@@ -526,7 +584,7 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) linkKindsByItem(ctx context.Context) (map[string]map[string]struct{}, error) {
|
||||
out := map[string]map[string]struct{}{}
|
||||
for _, t := range []string{"caldav-list", "gitea-repo"} {
|
||||
links, err := s.Store.LinksByRefType(ctx, t)
|
||||
links, err := s.Items.LinksByRefType(ctx, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -551,11 +609,11 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// PER URL resolution: try the full path first; if it 404s and the trailing
|
||||
// segment looks like YYMMDD, retry against the shorter path and surface
|
||||
// the date as a render hint to scroll/highlight the matching row.
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
var highlight *time.Time
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
if base, d := parsePER(path); d != nil {
|
||||
if it2, err2 := s.Store.GetByPath(r.Context(), base); err2 == nil {
|
||||
if it2, err2 := s.Items.GetByPath(r.Context(), base); err2 == nil {
|
||||
it, err, highlight = it2, nil, d
|
||||
}
|
||||
}
|
||||
@@ -569,26 +627,6 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
tasks, err := s.detailTodos(r.Context(), it)
|
||||
if err != nil {
|
||||
s.Logger.Warn("detail tasks", "path", it.PrimaryPath(), "err", err)
|
||||
}
|
||||
// Phase 5j: pre-load discoverable CalDAV calendars (minus the ones
|
||||
// already linked) so the per-item Tasks section can offer a "Link
|
||||
// existing list" picker alongside the create-new affordance. Errors
|
||||
// are non-fatal — the section falls back to its pre-5j shape.
|
||||
var availableCalendars []caldav.Calendar
|
||||
if s.CalDAV != nil {
|
||||
caldavLinks, lerr := s.Store.LinksByType(r.Context(), it.ID, refTypeCalDAV)
|
||||
if lerr != nil {
|
||||
s.Logger.Warn("detail caldav links", "path", it.PrimaryPath(), "err", lerr)
|
||||
}
|
||||
acs, aerr := s.availableCalendarsForItem(r.Context(), caldavLinks)
|
||||
if aerr != nil {
|
||||
s.Logger.Warn("detail available caldav", "path", it.PrimaryPath(), "err", aerr)
|
||||
}
|
||||
availableCalendars = acs
|
||||
}
|
||||
issues, err := s.detailIssues(r.Context(), it)
|
||||
if err != nil {
|
||||
s.Logger.Warn("detail issues", "path", it.PrimaryPath(), "err", err)
|
||||
@@ -597,24 +635,32 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
for _, ri := range issues {
|
||||
openTotal += ri.OpenCount
|
||||
}
|
||||
docs, err := s.Store.DatedLinks(r.Context(), it.ID)
|
||||
docs, err := s.Items.DatedLinks(r.Context(), it.ID)
|
||||
if err != nil {
|
||||
s.Logger.Warn("detail docs", "path", it.PrimaryPath(), "err", err)
|
||||
}
|
||||
documents := computePERs(it.PrimaryPath(), docs)
|
||||
// Phase 7c — ONE unified task list per project: mBrian-native tasks +
|
||||
// CalDAV VTODOs merged into a single sorted list, each row tagged by
|
||||
// source, actions dispatched by source. The section shows whenever a task
|
||||
// backend is available (CalDAV configured or the mBrian task backend live).
|
||||
unified := s.buildUnifiedTasks(r.Context(), it)
|
||||
showTasks := unified.CalDAVOn || unified.MBrianOn
|
||||
tasksOpen := len(unified.Open) > 0
|
||||
tasksData := unifiedTasksData(it, unified, "")
|
||||
s.render(w, r, "detail", map[string]any{
|
||||
"Title": it.Title,
|
||||
"Item": it,
|
||||
"ParentOptions": parents,
|
||||
"StatusOptions": []string{"active", "done", "archived"},
|
||||
"Tasks": tasks,
|
||||
"AvailableCalendars": availableCalendars,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Issues": issues,
|
||||
"IssuesOpenTotal": openTotal,
|
||||
"GiteaOn": s.Gitea != nil,
|
||||
"Documents": documents,
|
||||
"HighlightDate": highlight,
|
||||
"Title": it.Title,
|
||||
"Item": it,
|
||||
"ParentOptions": parents,
|
||||
"StatusOptions": []string{"active", "done", "archived"},
|
||||
"ShowTasks": showTasks,
|
||||
"TasksOpen": tasksOpen,
|
||||
"TasksData": tasksData,
|
||||
"Issues": issues,
|
||||
"IssuesOpenTotal": openTotal,
|
||||
"GiteaOn": s.Gitea != nil,
|
||||
"Documents": documents,
|
||||
"HighlightDate": highlight,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -638,6 +684,13 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Phase 7 — mBrian-native task actions.
|
||||
for _, action := range []string{"create", "done", "reopen", "edit", "due", "delete"} {
|
||||
if base, ok := strings.CutSuffix(path, "/task/"+action); ok {
|
||||
s.handleTaskAction(w, r, base, action)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, action := range []string{"close", "reopen", "comment", "create"} {
|
||||
if base, ok := strings.CutSuffix(path, "/issues/"+action); ok {
|
||||
s.handleIssueAction(w, r, base, action)
|
||||
@@ -652,7 +705,7 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleLinksRemove(w, r, base)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -678,7 +731,7 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
s.itemWriteFailure(w, r, ve)
|
||||
return
|
||||
}
|
||||
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
|
||||
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Items, itemwrite.Input{
|
||||
ID: it.ID, Title: title, Slug: slug, Status: status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
||||
}); ve != nil {
|
||||
s.itemWriteFailure(w, r, ve)
|
||||
@@ -706,11 +759,14 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
// Phase 4f: timeline-exclude form field is a multi-value checkbox set
|
||||
// (`name="timeline_exclude" value="todos"`, …). parseTimelineExcludeList
|
||||
// keeps only the known kinds so a stray value can't poison the array.
|
||||
TimelineExclude: parseTimelineExcludeList(r.Form["timeline_exclude"]),
|
||||
TimelineExclude: parseTimelineExcludeList(r.Form["timeline_exclude"]),
|
||||
// Phase 7 checklist render hint (Q1): the flags checkbox maps to
|
||||
// metadata.projax.render="checklist"; unchecked clears it ("").
|
||||
Render: renderHint(r.FormValue("render_checklist") == "1"),
|
||||
}
|
||||
updated, err := s.Store.Update(r.Context(), it.ID, in)
|
||||
updated, err := s.Writes.Update(r.Context(), it.ID, in)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
s.writeFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+updated.PrimaryPath(), http.StatusSeeOther)
|
||||
@@ -720,7 +776,7 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
// a root mai-managed item under a chosen parent without touching other fields.
|
||||
// HTMX-friendly: returns a fragment when HX-Request is set.
|
||||
func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path string) {
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -749,13 +805,13 @@ func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path str
|
||||
s.itemWriteFailure(w, r, ve)
|
||||
return
|
||||
}
|
||||
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
|
||||
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Items, itemwrite.Input{
|
||||
ID: it.ID, Title: it.Title, Slug: it.Slug, Status: it.Status, ParentIDs: parentIDs, Path: it.PrimaryPath(),
|
||||
}); ve != nil {
|
||||
s.itemWriteFailure(w, r, ve)
|
||||
return
|
||||
}
|
||||
moved, err := s.Store.Reparent(r.Context(), it.ID, parentIDs)
|
||||
moved, err := s.Writes.Reparent(r.Context(), it.ID, parentIDs)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -875,7 +931,7 @@ func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
parentPath := r.URL.Query().Get("parent")
|
||||
var parent *store.Item
|
||||
if parentPath != "" {
|
||||
p, err := s.Store.GetByPath(r.Context(), parentPath)
|
||||
p, err := s.Items.GetByPath(r.Context(), parentPath)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -925,7 +981,7 @@ func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
s.itemWriteFailure(w, r, ve)
|
||||
return
|
||||
}
|
||||
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Store, itemwrite.Input{
|
||||
if ve := itemwrite.ValidateAgainstStore(r.Context(), s.Items, itemwrite.Input{
|
||||
Title: title, Slug: slug, Status: status, ParentIDs: parentIDs,
|
||||
}); ve != nil {
|
||||
s.itemWriteFailure(w, r, ve)
|
||||
@@ -941,16 +997,16 @@ func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
Tags: parseCSV(r.FormValue("tags")),
|
||||
Management: parseCSV(r.FormValue("management")),
|
||||
}
|
||||
it, err := s.Store.Create(r.Context(), in)
|
||||
it, err := s.Writes.Create(r.Context(), in)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
s.writeFailure(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
||||
orphans, err := s.Store.MaiOrphans(r.Context())
|
||||
orphans, err := s.Items.MaiOrphans(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
@@ -976,7 +1032,7 @@ type ParentOption struct {
|
||||
}
|
||||
|
||||
func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
|
||||
items, err := s.Store.ListAll(ctx)
|
||||
items, err := s.Items.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1026,6 +1082,49 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, name string, dat
|
||||
if _, set := data["Path"]; !set {
|
||||
data["Path"] = r.URL.Path
|
||||
}
|
||||
// Phase 5j slice E: layout's "Views" sidebar section lists every
|
||||
// user view. Lookup is one indexed query per render — at m's scale
|
||||
// (≤30 saved views) the cost is negligible and dwarfed by the
|
||||
// dashboard/timeline aggregation cards. The login page bypasses the
|
||||
// layout entirely so we don't fetch for it; stub servers without a
|
||||
// configured store also skip cleanly.
|
||||
if _, set := data["UserViews"]; !set && name != "login" && s.Store != nil {
|
||||
if uv, err := s.Store.ListViews(r.Context()); err == nil {
|
||||
data["UserViews"] = uv
|
||||
// Phase 5j slice G — show_count badges. For every view with
|
||||
// ShowCount=true, run its persisted filter against ListAll and
|
||||
// pass a slug→count map to the template. Caching is one
|
||||
// ListAll per render shared across all show-count views.
|
||||
counts := map[string]int{}
|
||||
needsCount := false
|
||||
for _, v := range uv {
|
||||
if v.ShowCount {
|
||||
needsCount = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsCount {
|
||||
items, err := s.Items.ListAll(r.Context())
|
||||
if err == nil {
|
||||
linkKinds, _ := s.linkKindsByItem(r.Context())
|
||||
for _, v := range uv {
|
||||
if !v.ShowCount {
|
||||
continue
|
||||
}
|
||||
f, _, _ := decodeViewSpec(v.FilterJSON)
|
||||
n := 0
|
||||
for _, it := range items {
|
||||
if f.Matches(it, linkKinds[it.ID]) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
counts[v.Slug] = n
|
||||
}
|
||||
}
|
||||
}
|
||||
data["UserViewCounts"] = counts
|
||||
}
|
||||
}
|
||||
entry := "layout"
|
||||
switch name {
|
||||
case "login":
|
||||
|
||||
@@ -81,9 +81,9 @@ func TestTreeRenders(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/")
|
||||
code, body := get(t, h, "/views/tree")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET / status %d body=%s", code, body)
|
||||
t.Fatalf("GET /views/tree status %d body=%s", code, body)
|
||||
}
|
||||
// /admin/classify used to live in the nav; Phase 3o consolidated all
|
||||
// admin links under the new /admin index. Assert /admin instead.
|
||||
@@ -102,7 +102,7 @@ func TestLayoutHasViewportMeta(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
for _, path := range []string{"/", "/dashboard", "/calendar", "/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
|
||||
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/calendar", "/views/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
|
||||
_, body := get(t, h, path)
|
||||
if !strings.Contains(body, `name="viewport"`) {
|
||||
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)
|
||||
@@ -302,7 +302,7 @@ func TestTreeRendersKanbanWhenViewTypeIsKanban(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/?view_type=kanban")
|
||||
_, body := get(t, h, "/views/tree?view_type=kanban")
|
||||
if !strings.Contains(body, `class="kanban-board"`) {
|
||||
t.Error("?view_type=kanban should render the kanban board")
|
||||
}
|
||||
@@ -324,7 +324,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
// List view (default): forest markup expected; tree-card-grid absent.
|
||||
_, listBody := get(t, h, "/")
|
||||
_, listBody := get(t, h, "/views/tree")
|
||||
if !strings.Contains(listBody, `<ul class="forest">`) {
|
||||
t.Error("default GET / should render the tree forest")
|
||||
}
|
||||
@@ -335,7 +335,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
|
||||
t.Error("view-type chip strip should appear on every view")
|
||||
}
|
||||
// Card view: card grid present, forest absent.
|
||||
_, cardBody := get(t, h, "/?view_type=card")
|
||||
_, cardBody := get(t, h, "/views/tree?view_type=card")
|
||||
if !strings.Contains(cardBody, `class="tree-card-grid"`) {
|
||||
t.Error("GET /?view_type=card should render the card grid")
|
||||
}
|
||||
@@ -343,7 +343,7 @@ func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
|
||||
t.Error("GET /?view_type=card should not render the tree forest")
|
||||
}
|
||||
// Unknown view_type falls back to list.
|
||||
_, unknownBody := get(t, h, "/?view_type=junk")
|
||||
_, unknownBody := get(t, h, "/views/tree?view_type=junk")
|
||||
if !strings.Contains(unknownBody, `<ul class="forest">`) {
|
||||
t.Error("unknown view_type should fall back to list")
|
||||
}
|
||||
@@ -393,7 +393,7 @@ func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
|
||||
siblingLink := `href="/i/dev.` + siblingSlug + `"`
|
||||
|
||||
// Descendants on (default): parent + child visible, sibling hidden.
|
||||
_, withDesc := get(t, h, "/?project="+parentPath)
|
||||
_, withDesc := get(t, h, "/views/tree?project="+parentPath)
|
||||
if !strings.Contains(withDesc, parentLink) {
|
||||
t.Errorf("?project=%s should show parent row", parentPath)
|
||||
}
|
||||
@@ -405,7 +405,7 @@ func TestProjectFilterScopesTreeToDescendants(t *testing.T) {
|
||||
}
|
||||
|
||||
// Descendants off: only the picked item, no children.
|
||||
_, noDesc := get(t, h, "/?project="+parentPath+"&project_descendants=0")
|
||||
_, noDesc := get(t, h, "/views/tree?project="+parentPath+"&project_descendants=0")
|
||||
if !strings.Contains(noDesc, parentLink) {
|
||||
t.Errorf("?project_descendants=0 should still show the picked parent row")
|
||||
}
|
||||
|
||||
1
web/static/htmx.min.js
vendored
Normal file
1
web/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -1211,6 +1211,23 @@ html[data-sidebar-collapsed="true"] .projax-sidebar .brand-label {
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 14px;
|
||||
}
|
||||
/* Phase 5j slice E — Views sub-section: user-view entries sit below the
|
||||
main nav items, slightly indented + smaller, so the system rows stay
|
||||
visually anchored. The Views section header (the "Views" main entry)
|
||||
is unchanged; this just styles the per-saved-view rows. */
|
||||
.projax-sidebar .sidebar-user-views { display: flex; flex-direction: column; gap: 2px; padding: 4px 0; }
|
||||
.projax-sidebar .nav-item-user-view { font-size: 0.92em; padding-left: 24px; }
|
||||
.projax-sidebar .nav-item-user-view.active { padding-left: 22px; }
|
||||
.projax-sidebar .user-view-icon { width: 1em; text-align: center; }
|
||||
.projax-sidebar .nav-item-new-view { color: var(--muted); }
|
||||
.projax-sidebar .nav-badge {
|
||||
margin-left: auto; font-size: 0.78em; color: var(--muted);
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 0 6px;
|
||||
}
|
||||
.projax-sidebar .nav-item-user-view.active .nav-badge {
|
||||
color: var(--accent); border-color: var(--accent);
|
||||
}
|
||||
.projax-sidebar .nav-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
|
||||
@@ -12,9 +12,12 @@
|
||||
// real PWA + keep static assets warm. Mutations (CalDAV / Gitea writeback)
|
||||
// still require connectivity.
|
||||
|
||||
const CACHE_NAME = 'projax-shell-v1';
|
||||
// v2: htmx.min.js joins the shell so the HTMX-driven forms work offline too
|
||||
// (the cache name bump purges the v1 asset set on activate).
|
||||
const CACHE_NAME = 'projax-shell-v2';
|
||||
const SHELL_ASSETS = [
|
||||
'/static/style.css',
|
||||
'/static/htmx.min.js',
|
||||
'/static/manifest.webmanifest',
|
||||
'/static/icon-192.png',
|
||||
'/static/icon-512.png',
|
||||
|
||||
87
web/system_views.go
Normal file
87
web/system_views.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Phase 5j Slice C — system views. Per m's Q1 pick (b) (2026-05-29):
|
||||
// FULL MIGRATION of the legacy pages into the /views/{slug} family.
|
||||
// /, /dashboard, /calendar, /timeline, /graph all 301-redirect to their
|
||||
// /views/{system-slug} counterparts; the handlers stay (now reachable
|
||||
// under the new URL).
|
||||
//
|
||||
// System views are code-resident — they never appear as rows in
|
||||
// projax.views. Their slugs are reserved at the validator level (see
|
||||
// store.IsReservedViewSlug) so user-created views can't shadow them.
|
||||
|
||||
// SystemView is a code-resident view definition. The sidebar's Views
|
||||
// section (slice E) lists every entry returned by AllSystemViews
|
||||
// alongside user views. The render path for system slugs goes directly
|
||||
// to the legacy handler (handleTree / handleDashboard / …); the struct
|
||||
// here is metadata for navigation, not a render spec.
|
||||
type SystemView struct {
|
||||
Slug string
|
||||
Name string
|
||||
Icon string
|
||||
URL string // /views/{slug}
|
||||
}
|
||||
|
||||
// AllSystemViews returns every code-resident view in display order. Used
|
||||
// by the sidebar (slice E) and the reserved-slug validation (slice A
|
||||
// already pre-seeded the same slugs in store.IsReservedViewSlug — keep
|
||||
// in sync with this list).
|
||||
func AllSystemViews() []SystemView {
|
||||
return []SystemView{
|
||||
{Slug: "tree", Name: "Tree", Icon: "tree", URL: "/views/tree"},
|
||||
{Slug: "dashboard", Name: "Dashboard", Icon: "dashboard", URL: "/views/dashboard"},
|
||||
{Slug: "calendar", Name: "Calendar", Icon: "calendar", URL: "/views/calendar"},
|
||||
{Slug: "timeline", Name: "Timeline", Icon: "clock", URL: "/views/timeline"},
|
||||
{Slug: "graph", Name: "Graph", Icon: "graph", URL: "/views/graph"},
|
||||
}
|
||||
}
|
||||
|
||||
// LookupSystemView returns the SystemView matching slug, or nil. Used by
|
||||
// handleViewRender's fallback path and by tests that need to assert
|
||||
// metadata.
|
||||
func LookupSystemView(slug string) *SystemView {
|
||||
for _, sv := range AllSystemViews() {
|
||||
if sv.Slug == slug {
|
||||
s := sv
|
||||
return &s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// legacyRedirect returns a handler that 301s the legacy URL onto its
|
||||
// /views/{system-slug} counterpart. Per m's Q3 pick (b): when the
|
||||
// request carries a legacy `?view=<uuid>` param (the 5i overlay scheme)
|
||||
// the redirect resolves the uuid → current slug so old bookmarks land
|
||||
// on the user view they pointed at. A miss falls through to the system
|
||||
// slug.
|
||||
func (s *Server) legacyRedirect(systemSlug string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// / is a path-prefix in Go's mux; only redirect when the request
|
||||
// path is exactly "/". Any other root-relative path that fell
|
||||
// through to GET / (e.g. "/some-unknown") gets a 404.
|
||||
if systemSlug == "tree" && r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
target := "/views/" + systemSlug
|
||||
if id := strings.TrimSpace(r.URL.Query().Get("view")); id != "" {
|
||||
if v, err := s.Store.GetViewByID(r.Context(), id); err == nil && v != nil {
|
||||
target = "/views/" + v.Slug
|
||||
}
|
||||
}
|
||||
// Preserve any non-`view` query params so existing bookmarks
|
||||
// carrying ?tag=… etc. still narrow the redirected view.
|
||||
q := r.URL.Query()
|
||||
q.Del("view")
|
||||
if encoded := q.Encode(); encoded != "" {
|
||||
target += "?" + encoded
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
}
|
||||
}
|
||||
175
web/system_views_test.go
Normal file
175
web/system_views_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/web"
|
||||
)
|
||||
|
||||
// TestSystemViewLookup verifies the code-resident lookup returns the
|
||||
// expected slugs in display order, and that LookupSystemView round-trips
|
||||
// each entry.
|
||||
func TestSystemViewLookup(t *testing.T) {
|
||||
all := web.AllSystemViews()
|
||||
wantSlugs := []string{"tree", "dashboard", "calendar", "timeline", "graph"}
|
||||
if len(all) != len(wantSlugs) {
|
||||
t.Fatalf("AllSystemViews len = %d, want %d", len(all), len(wantSlugs))
|
||||
}
|
||||
for i, sv := range all {
|
||||
if sv.Slug != wantSlugs[i] {
|
||||
t.Errorf("position %d: slug = %q, want %q", i, sv.Slug, wantSlugs[i])
|
||||
}
|
||||
if sv.URL != "/views/"+sv.Slug {
|
||||
t.Errorf("position %d: URL = %q, want /views/%s", i, sv.URL, sv.Slug)
|
||||
}
|
||||
round := web.LookupSystemView(sv.Slug)
|
||||
if round == nil || round.Slug != sv.Slug {
|
||||
t.Errorf("LookupSystemView(%q) round-trip failed", sv.Slug)
|
||||
}
|
||||
}
|
||||
if web.LookupSystemView("not-a-system-slug") != nil {
|
||||
t.Error("LookupSystemView should return nil for unknown slugs")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyRedirects verifies the slice C URL migration: each legacy
|
||||
// route 301-redirects to its /views/{slug} counterpart with chip params
|
||||
// preserved.
|
||||
func TestLegacyRedirects(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
cases := []struct {
|
||||
path, want string
|
||||
}{
|
||||
{"/", "/views/tree"},
|
||||
{"/dashboard", "/views/dashboard"},
|
||||
{"/calendar", "/views/calendar"},
|
||||
{"/timeline", "/views/timeline"},
|
||||
{"/graph", "/views/graph"},
|
||||
// chip params survive the redirect:
|
||||
{"/dashboard?tag=work", "/views/dashboard?tag=work"},
|
||||
{"/timeline?from=2026-05-01", "/views/timeline?from=2026-05-01"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
code, body := get(t, h, tc.path)
|
||||
if code != 301 {
|
||||
t.Errorf("GET %s status=%d body=%q, want 301", tc.path, code, body)
|
||||
}
|
||||
if !strings.Contains(body, `href="`+tc.want+`"`) {
|
||||
t.Errorf("GET %s body=%q, want redirect to %q", tc.path, body, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidebarListsUserViews — slice E: every chrome-bearing page renders
|
||||
// the saved-view list under the main nav. Each entry links to
|
||||
// /views/{slug} with the name as the label. Active state fires when the
|
||||
// current URL matches.
|
||||
func TestSidebarListsUserViews(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-e-sidebar-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO projax.views (slug, name, filter_json)
|
||||
VALUES ($1, 'P5jE Sidebar', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
_, body := get(t, h, "/views/tree")
|
||||
if !strings.Contains(body, `href="/views/`+slug+`"`) {
|
||||
t.Error("sidebar should list saved view as /views/<slug>")
|
||||
}
|
||||
if !strings.Contains(body, "P5jE Sidebar") {
|
||||
t.Error("sidebar should show saved view's display name")
|
||||
}
|
||||
if !strings.Contains(body, `href="/views/new"`) {
|
||||
t.Error("sidebar Views section should include a + New view link")
|
||||
}
|
||||
// Active state when the URL matches.
|
||||
_, onView := get(t, h, "/views/"+slug)
|
||||
if !strings.Contains(onView, `class="nav-item nav-item-user-view active"`) {
|
||||
t.Error("user-view nav-item should carry .active when its URL is current")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidebarShowCountBadge — slice G: a saved view with show_count=true
|
||||
// renders a row-count badge in the sidebar reflecting the filter's match
|
||||
// count against ListAll().
|
||||
func TestSidebarShowCountBadge(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-g-badge-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
// Seed a view scoped to dev → its count = count of items under dev that
|
||||
// match status=active (default).
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO projax.views (slug, name, filter_json, show_count)
|
||||
VALUES ($1, 'P5jG Badge', '{"view_type":"list","project_path":"dev"}'::jsonb, true)`,
|
||||
slug); err != nil {
|
||||
t.Fatalf("seed view: %v", err)
|
||||
}
|
||||
_, body := get(t, h, "/views/tree")
|
||||
if !strings.Contains(body, `class="nav-badge"`) {
|
||||
t.Error("show_count view should render a nav-badge in the sidebar")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSidebarIconRenders — slice G: a view with an icon key emits the
|
||||
// SVG from the registry; missing key falls back to folder default.
|
||||
func TestSidebarIconRenders(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-g-icon-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO projax.views (slug, name, filter_json, icon)
|
||||
VALUES ($1, 'P5jG Icon', '{"view_type":"list"}'::jsonb, 'star')`, slug); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
_, body := get(t, h, "/views/tree")
|
||||
// The star icon's SVG path includes its distinctive 5-point polygon.
|
||||
if !strings.Contains(body, `polygon points="12 2 15.09 8.26`) {
|
||||
t.Error("sidebar should render the star icon SVG for icon=star")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLegacyViewUUIDRedirect — when a legacy URL carries the 5i overlay
|
||||
// `?view=<uuid>` param, the redirect resolves the uuid to the current
|
||||
// slug (per m's Q3 pick), so old bookmarks land on the right user view.
|
||||
func TestLegacyViewUUIDRedirect(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-c-legacy-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projax.views (slug, name, filter_json)
|
||||
VALUES ($1, 'Legacy', '{"view_type":"list"}'::jsonb)
|
||||
RETURNING id`, slug).Scan(&id); err != nil {
|
||||
t.Fatalf("seed view: %v", err)
|
||||
}
|
||||
// Old-style URL: /?view=<uuid>
|
||||
code, body := get(t, h, "/?view="+id)
|
||||
if code != 301 {
|
||||
t.Fatalf("GET /?view=<uuid> status=%d body=%q want 301", code, body)
|
||||
}
|
||||
if !strings.Contains(body, "/views/"+slug) {
|
||||
t.Errorf("redirect should resolve uuid → slug; got body=%q", body)
|
||||
}
|
||||
}
|
||||
410
web/task.go
Normal file
410
web/task.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/m/projax/caldav"
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// Phase 7c — UNIFIED task surface. The detail page renders ONE task list per
|
||||
// project that merges mBrian-native tasks (type=['task'] child nodes) AND
|
||||
// CalDAV tasks (VTODOs from linked calendars) into a single sorted list. Each
|
||||
// row is subtly tagged by Source so m can tell where a task lives, but they
|
||||
// read as one list. Actions dispatch to the right backend by Source: CalDAV
|
||||
// rows POST to /caldav/todo/{action}; mBrian rows POST to /task/{action}.
|
||||
//
|
||||
// New tasks default by the §3.1 selector: a CalDAV-bound project (has a
|
||||
// caldav-list link) creates VTODOs on its linked calendar; an unbound project
|
||||
// creates mBrian-native task nodes. (Supersedes the Phase 7b two-section split
|
||||
// per m's request to "collect from mBrian AS WELL AS CalDAV and display
|
||||
// together".)
|
||||
|
||||
// taskBackend returns the task-capable reader + writer when the active backend
|
||||
// supports mBrian-native tasks (PROJAX_BACKEND=mbrian). The legacy *Store
|
||||
// backend has no task nodes, so both asserts fail and only CalDAV tasks show.
|
||||
func (s *Server) taskBackend() (store.TaskReader, store.TaskWriter, bool) {
|
||||
tr, rok := s.Items.(store.TaskReader)
|
||||
tw, wok := s.Writes.(store.TaskWriter)
|
||||
return tr, tw, rok && wok
|
||||
}
|
||||
|
||||
// taskRow wraps a uniform store.Task with a render-only source label (the
|
||||
// calendar display name for CalDAV tasks, "projax" for mBrian tasks). Kept out
|
||||
// of store.Task so the store shape stays a pure data view.
|
||||
type taskRow struct {
|
||||
*store.Task
|
||||
SourceLabel string
|
||||
}
|
||||
|
||||
// unifiedTasks is the assembled per-project task surface the section template
|
||||
// renders: open tasks up top, done below, plus the CalDAV management
|
||||
// affordances (link/create) and the add-form routing inputs.
|
||||
type unifiedTasks struct {
|
||||
Open []taskRow
|
||||
Done []taskRow
|
||||
// CalDAVOn reports whether CalDAV integration is configured at all.
|
||||
CalDAVOn bool
|
||||
// CalDAVBound reports whether THIS project has ≥1 caldav-list link — the
|
||||
// §3.1 selector that routes new tasks to CalDAV vs mBrian.
|
||||
CalDAVBound bool
|
||||
// LinkedCalendars are this project's bound calendars (url + display name),
|
||||
// for the add-form target (a <select> when >1) and the row labels.
|
||||
LinkedCalendars []caldav.Calendar
|
||||
// AvailableCalendars feeds the "link existing list" picker (discoverable
|
||||
// minus already-linked). Best-effort; empty on discovery error.
|
||||
AvailableCalendars []caldav.Calendar
|
||||
// MBrianOn reports whether the mBrian task backend is available (so the
|
||||
// add-form can create native tasks on an unbound project).
|
||||
MBrianOn bool
|
||||
Banner string
|
||||
}
|
||||
|
||||
// buildUnifiedTasks gathers mBrian-native + CalDAV tasks for the item, merges
|
||||
// them into one sorted open/done split, and collects the CalDAV management
|
||||
// data the section needs. Per-source failures degrade to a banner rather than
|
||||
// blanking the whole list.
|
||||
func (s *Server) buildUnifiedTasks(ctx context.Context, item *store.Item) unifiedTasks {
|
||||
u := unifiedTasks{CalDAVOn: s.CalDAV != nil}
|
||||
banners := []string{}
|
||||
|
||||
// --- CalDAV side ---
|
||||
var linkedLinks []*store.ItemLink
|
||||
if s.CalDAV != nil {
|
||||
links, err := s.Items.LinksByType(ctx, item.ID, refTypeCalDAV)
|
||||
if err != nil {
|
||||
s.Logger.Warn("unified tasks caldav links", "item", item.ID, "err", err)
|
||||
}
|
||||
linkedLinks = links
|
||||
u.CalDAVBound = len(links) > 0
|
||||
calName := map[string]string{}
|
||||
for _, l := range links {
|
||||
name := linkDisplay(l)
|
||||
u.LinkedCalendars = append(u.LinkedCalendars, caldav.Calendar{URL: l.RefID, DisplayName: name})
|
||||
calName[l.RefID] = name
|
||||
}
|
||||
sort.Slice(u.LinkedCalendars, func(i, j int) bool {
|
||||
return u.LinkedCalendars[i].DisplayName < u.LinkedCalendars[j].DisplayName
|
||||
})
|
||||
cts, err := s.detailTodos(ctx, item)
|
||||
if err != nil {
|
||||
s.Logger.Warn("unified tasks detailTodos", "item", item.ID, "err", err)
|
||||
banners = append(banners, "Could not load CalDAV tasks: "+err.Error())
|
||||
}
|
||||
for _, ct := range cts {
|
||||
if ct.Error != "" {
|
||||
banners = append(banners, ct.DisplayName+": "+ct.Error)
|
||||
}
|
||||
label := calName[ct.CalendarURL]
|
||||
if label == "" {
|
||||
label = "CalDAV"
|
||||
}
|
||||
for _, td := range ct.Open {
|
||||
u.Open = append(u.Open, taskRow{Task: taskFromTodo(td, ct.CalendarURL, item.ID), SourceLabel: label})
|
||||
}
|
||||
for _, td := range ct.DoneRecent {
|
||||
u.Done = append(u.Done, taskRow{Task: taskFromTodo(td, ct.CalendarURL, item.ID), SourceLabel: label})
|
||||
}
|
||||
}
|
||||
if acs, aerr := s.availableCalendarsForItem(ctx, linkedLinks); aerr != nil {
|
||||
s.Logger.Warn("unified tasks available caldav", "item", item.ID, "err", aerr)
|
||||
} else {
|
||||
u.AvailableCalendars = acs
|
||||
}
|
||||
}
|
||||
|
||||
// --- mBrian side ---
|
||||
if tr, _, ok := s.taskBackend(); ok {
|
||||
u.MBrianOn = true
|
||||
tasks, err := tr.TasksForItem(ctx, item.ID)
|
||||
if err != nil {
|
||||
s.Logger.Warn("unified tasks mbrian", "item", item.ID, "err", err)
|
||||
banners = append(banners, "Could not load projax tasks: "+err.Error())
|
||||
}
|
||||
for _, t := range tasks {
|
||||
row := taskRow{Task: t, SourceLabel: "projax"}
|
||||
switch {
|
||||
case t.Done:
|
||||
u.Done = append(u.Done, row)
|
||||
case t.Status == "archived":
|
||||
// hidden
|
||||
default:
|
||||
u.Open = append(u.Open, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sortTaskRows(u.Open)
|
||||
sortTaskRows(u.Done)
|
||||
u.Banner = strings.Join(banners, " · ")
|
||||
return u
|
||||
}
|
||||
|
||||
// sortTaskRows orders a task slice: earlier due first (undated last), then
|
||||
// created-at, then title — a stable, sensible merge order across both sources.
|
||||
func sortTaskRows(rows []taskRow) {
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
// Due: dated before undated; earlier due first.
|
||||
switch {
|
||||
case a.Due != nil && b.Due != nil:
|
||||
if !a.Due.Equal(*b.Due) {
|
||||
return a.Due.Before(*b.Due)
|
||||
}
|
||||
case a.Due != nil && b.Due == nil:
|
||||
return true
|
||||
case a.Due == nil && b.Due != nil:
|
||||
return false
|
||||
}
|
||||
if !a.CreatedAt.Equal(b.CreatedAt) {
|
||||
return a.CreatedAt.Before(b.CreatedAt)
|
||||
}
|
||||
return a.Title < b.Title
|
||||
})
|
||||
}
|
||||
|
||||
// addTarget describes where the single add-form creates a new task.
|
||||
type addTarget struct {
|
||||
// Mode is "caldav" or "mbrian".
|
||||
Mode string
|
||||
// CalendarURL is the default target calendar (CalDAV mode, single
|
||||
// calendar). Empty when the form shows a calendar <select> (>1 calendar).
|
||||
CalendarURL string
|
||||
}
|
||||
|
||||
// addTargetFor decides the new-task backend per §3.1: CalDAV-bound → CalDAV
|
||||
// (default to the sole calendar, or let the form pick when several); unbound →
|
||||
// mBrian-native (when the backend supports it).
|
||||
func (u unifiedTasks) AddTarget() addTarget {
|
||||
if u.CalDAVBound {
|
||||
t := addTarget{Mode: "caldav"}
|
||||
if len(u.LinkedCalendars) == 1 {
|
||||
t.CalendarURL = u.LinkedCalendars[0].URL
|
||||
}
|
||||
return t
|
||||
}
|
||||
if u.MBrianOn {
|
||||
return addTarget{Mode: "mbrian"}
|
||||
}
|
||||
return addTarget{} // no add affordance
|
||||
}
|
||||
|
||||
// unifiedTasksData builds the template payload for the unified section.
|
||||
func unifiedTasksData(item *store.Item, u unifiedTasks, banner string) map[string]any {
|
||||
if banner != "" && u.Banner != "" {
|
||||
banner = banner + " · " + u.Banner
|
||||
} else if banner == "" {
|
||||
banner = u.Banner
|
||||
}
|
||||
return map[string]any{
|
||||
"Item": item,
|
||||
"Open": u.Open,
|
||||
"Done": u.Done,
|
||||
"CalDAVOn": u.CalDAVOn,
|
||||
"CalDAVBound": u.CalDAVBound,
|
||||
"LinkedCalendars": u.LinkedCalendars,
|
||||
"AvailableCalendars": u.AvailableCalendars,
|
||||
"MBrianOn": u.MBrianOn,
|
||||
"Checklist": item.RendersChecklist(),
|
||||
"AddTarget": u.AddTarget(),
|
||||
"Banner": banner,
|
||||
}
|
||||
}
|
||||
|
||||
// itemIsCalDAVBound reports whether the item has ≥1 caldav-list link — the
|
||||
// §3.1 backend selector. Errors degrade to false (treat as mBrian-native).
|
||||
func (s *Server) itemIsCalDAVBound(ctx context.Context, itemID string) bool {
|
||||
links, err := s.Items.LinksByType(ctx, itemID, refTypeCalDAV)
|
||||
if err != nil {
|
||||
s.Logger.Warn("caldav-bound check", "item", itemID, "err", err)
|
||||
return false
|
||||
}
|
||||
return len(links) > 0
|
||||
}
|
||||
|
||||
// renderUnifiedTasks re-renders the unified tasks fragment for HTMX swaps —
|
||||
// shared by BOTH the CalDAV and mBrian task action handlers so a write from
|
||||
// either backend refreshes the same merged list.
|
||||
func (s *Server) renderUnifiedTasks(w http.ResponseWriter, r *http.Request, it *store.Item, banner string) {
|
||||
u := s.buildUnifiedTasks(r.Context(), it)
|
||||
s.render(w, r, "tasks_section", unifiedTasksData(it, u, banner))
|
||||
}
|
||||
|
||||
// handleTaskAction dispatches POST /i/{path}/task/{action} for mBrian-native
|
||||
// tasks. action ∈ {create, done, reopen, edit, due, delete}. Every mutating
|
||||
// action on an EXISTING task verifies the node belongs to this item (guards
|
||||
// against a crafted form targeting an arbitrary node), then writes via the
|
||||
// TaskWriter and re-renders the unified section.
|
||||
func (s *Server) handleTaskAction(w http.ResponseWriter, r *http.Request, path, action string) {
|
||||
tr, tw, ok := s.taskBackend()
|
||||
if !ok {
|
||||
http.Error(w, "tasks not supported on this backend", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
it, err := s.Items.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
banner := ""
|
||||
switch action {
|
||||
case "create":
|
||||
// New tasks are created mBrian-native only when the project isn't
|
||||
// CalDAV-bound (§3.1). A crafted create against a CalDAV-bound project
|
||||
// is refused so the backend selector can't be bypassed.
|
||||
if s.itemIsCalDAVBound(r.Context(), it.ID) {
|
||||
http.Error(w, "project is CalDAV-bound — create tasks via the CalDAV list", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
if title == "" {
|
||||
banner = "Cannot create a task with an empty title."
|
||||
break
|
||||
}
|
||||
in := store.TaskCreateInput{Title: title, ParentItemID: it.ID}
|
||||
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
|
||||
if t, ok := parseDueInput(dueStr); ok {
|
||||
in.Due = &t
|
||||
}
|
||||
}
|
||||
if _, err := tw.CreateTask(r.Context(), in); err != nil {
|
||||
banner = taskBanner("create", err)
|
||||
}
|
||||
case "done", "reopen", "edit", "due", "delete":
|
||||
nodeID := strings.TrimSpace(r.FormValue("node_id"))
|
||||
if nodeID == "" {
|
||||
http.Error(w, "node_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if !taskBelongsTo(r.Context(), tr, it.ID, nodeID) {
|
||||
http.Error(w, "task not attached to this item", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "done":
|
||||
if err := tw.SetTaskStatus(r.Context(), nodeID, "done"); err != nil {
|
||||
banner = taskBanner("complete", err)
|
||||
}
|
||||
case "reopen":
|
||||
if err := tw.SetTaskStatus(r.Context(), nodeID, "active"); err != nil {
|
||||
banner = taskBanner("reopen", err)
|
||||
}
|
||||
case "edit":
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
if title == "" {
|
||||
banner = "Task title cannot be empty."
|
||||
break
|
||||
}
|
||||
if err := tw.EditTaskTitle(r.Context(), nodeID, title); err != nil {
|
||||
banner = taskBanner("edit", err)
|
||||
break
|
||||
}
|
||||
// The edit form also carries the due field; apply it in the same
|
||||
// submit so a single Save persists both.
|
||||
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
|
||||
if t, ok := parseDueInput(dueStr); ok {
|
||||
if err := tw.SetTaskDue(r.Context(), nodeID, &t); err != nil {
|
||||
banner = taskBanner("edit", err)
|
||||
}
|
||||
}
|
||||
} else if _, present := r.Form["due"]; present {
|
||||
// Field submitted but blank → user cleared it.
|
||||
if err := tw.SetTaskDue(r.Context(), nodeID, nil); err != nil {
|
||||
banner = taskBanner("edit", err)
|
||||
}
|
||||
}
|
||||
case "due":
|
||||
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
|
||||
if t, ok := parseDueInput(dueStr); ok {
|
||||
if err := tw.SetTaskDue(r.Context(), nodeID, &t); err != nil {
|
||||
banner = taskBanner("set due on", err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := tw.SetTaskDue(r.Context(), nodeID, nil); err != nil {
|
||||
banner = taskBanner("clear due on", err)
|
||||
}
|
||||
}
|
||||
case "delete":
|
||||
if err := tw.DeleteTask(r.Context(), nodeID); err != nil {
|
||||
banner = taskBanner("delete", err)
|
||||
}
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unknown task action: "+action, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// A task write can move it on/off the dashboard + timeline rollups, so
|
||||
// bust those caches like the CalDAV path does.
|
||||
if s.dashboard != nil {
|
||||
s.dashboard.InvalidateAll()
|
||||
}
|
||||
if s.timeline != nil {
|
||||
s.timeline.InvalidateAll()
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
s.renderUnifiedTasks(w, r, it, banner)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// renderHint maps the detail form's "render as checklist" checkbox to the
|
||||
// metadata.projax.render value: "checklist" when on, "" when off.
|
||||
func renderHint(checklist bool) string {
|
||||
if checklist {
|
||||
return "checklist"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// taskBelongsTo verifies nodeID is one of itemID's mBrian-native tasks — the
|
||||
// authorisation guard for every existing-task mutation.
|
||||
func taskBelongsTo(ctx context.Context, tr store.TaskReader, itemID, nodeID string) bool {
|
||||
tasks, err := tr.TasksForItem(ctx, itemID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, t := range tasks {
|
||||
if t.NodeID == nodeID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// taskBanner formats a user-facing banner for a task write error.
|
||||
func taskBanner(action string, err error) string {
|
||||
return "Could not " + action + " task: " + err.Error()
|
||||
}
|
||||
|
||||
// taskFromTodo maps a CalDAV VTODO to the uniform store.Task shape (design
|
||||
// §3.3 — one shape, two sources). Used by the unified list so CalDAV + mBrian
|
||||
// tasks render through a single row template.
|
||||
func taskFromTodo(td caldav.Todo, calURL, parentItemID string) *store.Task {
|
||||
done := td.Status == "COMPLETED" || td.Status == "CANCELLED"
|
||||
t := &store.Task{
|
||||
ID: td.UID,
|
||||
Title: td.Summary,
|
||||
Done: done,
|
||||
Due: td.Due,
|
||||
Source: store.TaskSourceCalDAV,
|
||||
Status: td.Status,
|
||||
ParentItemID: parentItemID,
|
||||
CalendarURL: calURL,
|
||||
UID: td.UID,
|
||||
}
|
||||
if td.LastModified != nil {
|
||||
t.CreatedAt = *td.LastModified
|
||||
}
|
||||
return t
|
||||
}
|
||||
120
web/task_test.go
Normal file
120
web/task_test.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/m/projax/caldav"
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
func TestTaskFromTodo(t *testing.T) {
|
||||
due := time.Date(2026, 6, 20, 0, 0, 0, 0, time.UTC)
|
||||
mod := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||
td := caldav.Todo{
|
||||
UID: "vtodo-123",
|
||||
Summary: "Pour foundation",
|
||||
Status: "NEEDS-ACTION",
|
||||
Due: &due,
|
||||
LastModified: &mod,
|
||||
}
|
||||
got := taskFromTodo(td, "https://dav/cal/", "item-uuid")
|
||||
if got.Source != store.TaskSourceCalDAV {
|
||||
t.Fatalf("source = %q, want caldav", got.Source)
|
||||
}
|
||||
if got.ID != "vtodo-123" || got.UID != "vtodo-123" {
|
||||
t.Fatalf("id/uid = %q/%q", got.ID, got.UID)
|
||||
}
|
||||
if got.Title != "Pour foundation" {
|
||||
t.Fatalf("title = %q", got.Title)
|
||||
}
|
||||
if got.Done {
|
||||
t.Fatal("NEEDS-ACTION should not be done")
|
||||
}
|
||||
if got.CalendarURL != "https://dav/cal/" {
|
||||
t.Fatalf("calURL = %q", got.CalendarURL)
|
||||
}
|
||||
if got.ParentItemID != "item-uuid" {
|
||||
t.Fatalf("parent = %q", got.ParentItemID)
|
||||
}
|
||||
if got.Due == nil || !got.Due.Equal(due) {
|
||||
t.Fatalf("due = %v, want %v", got.Due, due)
|
||||
}
|
||||
if !got.CreatedAt.Equal(mod) {
|
||||
t.Fatalf("createdAt = %v, want last-modified %v", got.CreatedAt, mod)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskFromTodoDoneStates(t *testing.T) {
|
||||
for _, st := range []string{"COMPLETED", "CANCELLED"} {
|
||||
got := taskFromTodo(caldav.Todo{UID: "x", Status: st}, "c", "i")
|
||||
if !got.Done {
|
||||
t.Fatalf("status %q should map to done", st)
|
||||
}
|
||||
}
|
||||
for _, st := range []string{"NEEDS-ACTION", "IN-PROCESS", ""} {
|
||||
got := taskFromTodo(caldav.Todo{UID: "x", Status: st}, "c", "i")
|
||||
if got.Done {
|
||||
t.Fatalf("status %q should NOT map to done", st)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHint(t *testing.T) {
|
||||
if renderHint(true) != "checklist" {
|
||||
t.Fatal("true should map to checklist")
|
||||
}
|
||||
if renderHint(false) != "" {
|
||||
t.Fatal("false should map to empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortTaskRows(t *testing.T) {
|
||||
d := func(s string) *time.Time {
|
||||
tm, _ := time.Parse("2006-01-02", s)
|
||||
return &tm
|
||||
}
|
||||
mk := func(title string, due *time.Time, created string) taskRow {
|
||||
c, _ := time.Parse("2006-01-02", created)
|
||||
return taskRow{Task: &store.Task{Title: title, Due: due, CreatedAt: c}}
|
||||
}
|
||||
rows := []taskRow{
|
||||
mk("undated-late", nil, "2026-06-03"),
|
||||
mk("due-later", d("2026-06-20"), "2026-06-01"),
|
||||
mk("undated-early", nil, "2026-06-02"),
|
||||
mk("due-soon", d("2026-06-10"), "2026-06-05"),
|
||||
}
|
||||
sortTaskRows(rows)
|
||||
got := []string{rows[0].Title, rows[1].Title, rows[2].Title, rows[3].Title}
|
||||
want := []string{"due-soon", "due-later", "undated-early", "undated-late"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("sort order = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddTarget(t *testing.T) {
|
||||
cal := func(url string) caldav.Calendar { return caldav.Calendar{URL: url, DisplayName: url} }
|
||||
|
||||
// CalDAV-bound, single calendar → caldav + that URL.
|
||||
u := unifiedTasks{CalDAVBound: true, LinkedCalendars: []caldav.Calendar{cal("https://c1/")}}
|
||||
if at := u.AddTarget(); at.Mode != "caldav" || at.CalendarURL != "https://c1/" {
|
||||
t.Fatalf("single-cal bound = %+v, want caldav+https://c1/", at)
|
||||
}
|
||||
// CalDAV-bound, multiple calendars → caldav + empty URL (form shows select).
|
||||
u = unifiedTasks{CalDAVBound: true, LinkedCalendars: []caldav.Calendar{cal("https://c1/"), cal("https://c2/")}}
|
||||
if at := u.AddTarget(); at.Mode != "caldav" || at.CalendarURL != "" {
|
||||
t.Fatalf("multi-cal bound = %+v, want caldav+empty", at)
|
||||
}
|
||||
// Unbound + mBrian backend → mbrian.
|
||||
u = unifiedTasks{CalDAVBound: false, MBrianOn: true}
|
||||
if at := u.AddTarget(); at.Mode != "mbrian" {
|
||||
t.Fatalf("unbound+mbrian = %+v, want mbrian", at)
|
||||
}
|
||||
// Unbound + no mBrian backend → no add affordance.
|
||||
u = unifiedTasks{CalDAVBound: false, MBrianOn: false}
|
||||
if at := u.AddTarget(); at.Mode != "" {
|
||||
t.Fatalf("unbound+no-backend = %+v, want empty mode", at)
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,9 @@
|
||||
<header class="calendar-header">
|
||||
<h1>{{.P.MonthLabel}}</h1>
|
||||
<nav class="calendar-nav" aria-label="Monatsnavigation">
|
||||
<a class="prev" href="/calendar?month={{.P.PrevMonth}}{{with .Filter.QueryString}}&{{.}}{{end}}">< {{.P.PrevMonth}}</a>
|
||||
<a class="today" href="/calendar{{with .Filter.QueryString}}?{{.}}{{end}}">heute</a>
|
||||
<a class="next" href="/calendar?month={{.P.NextMonth}}{{with .Filter.QueryString}}&{{.}}{{end}}">{{.P.NextMonth}} ></a>
|
||||
<a class="prev" href="/views/calendar?month={{.P.PrevMonth}}{{with .Filter.QueryString}}&{{.}}{{end}}">< {{.P.PrevMonth}}</a>
|
||||
<a class="today" href="/views/calendar{{with .Filter.QueryString}}?{{.}}{{end}}">heute</a>
|
||||
<a class="next" href="/views/calendar?month={{.P.NextMonth}}{{with .Filter.QueryString}}&{{.}}{{end}}">{{.P.NextMonth}} ></a>
|
||||
</nav>
|
||||
</header>
|
||||
{{template "calendar-section" .}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<section class="tagbar" id="calendar-filterbar">
|
||||
<form id="calendar-filter" class="search"
|
||||
hx-get="/calendar"
|
||||
hx-get="/views/calendar"
|
||||
hx-target="#calendar-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change from:select"
|
||||
@@ -39,7 +39,7 @@
|
||||
</label>
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/views/calendar?month={{.P.MonthKey}}">clear filters</a>{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
@@ -83,7 +83,7 @@
|
||||
</ul>
|
||||
{{end}}
|
||||
{{if gt .ExtraCount 0}}
|
||||
<a class="cell-more muted" href="/timeline?from={{.DateKey}}&to={{.DateKey}}">+{{.ExtraCount}} more</a>
|
||||
<a class="cell-more muted" href="/views/timeline?from={{.DateKey}}&to={{.DateKey}}">+{{.ExtraCount}} more</a>
|
||||
{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<section class="tagbar" id="dashboard-filterbar">
|
||||
<form id="dashboard-filter" class="search"
|
||||
hx-get="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
|
||||
hx-get="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}"
|
||||
hx-target="#dashboard-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change from:select"
|
||||
@@ -32,7 +32,7 @@
|
||||
{{if ne .View "tiles"}}<input type="hidden" name="view" value="{{.View}}">{{end}}
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/views/dashboard{{if ne .View "tiles"}}?view={{.View}}{{end}}">clear filters</a>{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="render_checklist" value="1" {{if .Item.RendersChecklist}}checked{{end}}> render tasks as checklist
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="form-group" aria-labelledby="hdr-content">
|
||||
@@ -122,7 +125,7 @@
|
||||
<summary class="proj-section-summary">Timeline behaviour {{if .Item.TimelineExclude}}<small class="muted">(hiding {{len .Item.TimelineExclude}})</small>{{end}}</summary>
|
||||
<fieldset class="timeline-exclude">
|
||||
<legend class="visually-hidden">Timeline behaviour</legend>
|
||||
<p class="muted">Check a kind to hide it from <a href="/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
|
||||
<p class="muted">Check a kind to hide it from <a href="/views/timeline">/timeline</a>. Items remain visible on this detail page either way; the toggle only affects the aggregated chronological spine. Use <a href="/views/timeline?include_excluded=1">?include_excluded=1</a> to peek at everything anyway.</p>
|
||||
{{$ex := .Item.TimelineExclude}}
|
||||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="todos" {{if contains $ex "todos"}}checked{{end}}> exclude todos (VTODOs from linked calendars)</label>
|
||||
<label class="checkbox"><input type="checkbox" name="timeline_exclude" value="events" {{if contains $ex "events"}}checked{{end}}> exclude events (VEVENTs from linked calendars)</label>
|
||||
@@ -133,7 +136,7 @@
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
<a class="cancel" href="/">Cancel</a>
|
||||
<a class="cancel" href="/views/tree">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -142,14 +145,13 @@
|
||||
<section class="aux-sections" aria-labelledby="hdr-aux">
|
||||
<h2 id="hdr-aux" class="aux-heading">Related</h2>
|
||||
|
||||
{{if .CalDAVOn}}
|
||||
{{/* Tasks section opens by default when any linked calendar has at least
|
||||
one open VTODO. */}}
|
||||
{{$tasksOpen := false}}
|
||||
{{range .Tasks}}{{if .Open}}{{$tasksOpen = true}}{{end}}{{end}}
|
||||
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if $tasksOpen}} open{{end}}>
|
||||
<summary class="proj-section-summary">Tasks {{if $tasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
|
||||
{{template "tasks-section" .}}
|
||||
{{/* Phase 7c: ONE unified task list per project — mBrian-native tasks +
|
||||
CalDAV VTODOs merged and displayed together (m's request). Opens by
|
||||
default when there's at least one open task from either source. */}}
|
||||
{{if .ShowTasks}}
|
||||
<details class="proj-section" data-section="tasks" data-item-id="{{$itemID}}"{{if .TasksOpen}} open{{end}}>
|
||||
<summary class="proj-section-summary">Tasks {{if .TasksOpen}}<small class="muted">(open)</small>{{end}}</summary>
|
||||
{{template "tasks-section" .TasksData}}
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "content"}}
|
||||
<h1>Error</h1>
|
||||
<p class="error">{{.Message}}</p>
|
||||
<p><a href="/">Back to tree</a></p>
|
||||
<p><a href="/views/tree">Back to tree</a></p>
|
||||
{{end}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<section class="tagbar" id="graph-filterbar">
|
||||
<form id="graph-filter" class="search"
|
||||
hx-get="/graph"
|
||||
hx-get="/views/graph"
|
||||
hx-target="main"
|
||||
hx-select="main"
|
||||
hx-swap="outerHTML"
|
||||
@@ -29,8 +29,8 @@
|
||||
<input type="checkbox" name="isolate" value="1" {{if .Isolate}}checked{{end}}>
|
||||
isolate (hide non-matches)
|
||||
</label>
|
||||
{{if .Filter.Active}}<a class="clear" href="/graph">clear filters</a>{{end}}
|
||||
<a class="download" href="/graph?download=svg">download SVG</a>
|
||||
{{if .Filter.Active}}<a class="clear" href="/views/graph">clear filters</a>{{end}}
|
||||
<a class="download" href="/views/graph?download=svg">download SVG</a>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/static/icon-192.png">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/static/icon-512.png">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<!-- HTMX powers the in-place fragment swaps on the task / tree / dashboard /
|
||||
bulk / classify forms (hx-post/hx-get/hx-target/hx-swap). Vendored (not
|
||||
CDN) — projax is Tailscale-only and ships its assets via go:embed. Loaded
|
||||
deferred so it executes after parse but before DOMContentLoaded, where
|
||||
htmx wires every hx-* element. Plain method=post forms are untouched. -->
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
<script>
|
||||
// Phase 5g — restore sidebar collapsed state BEFORE first paint so the
|
||||
// main-content margin doesn't flash from 220px→56px on every navigation.
|
||||
@@ -42,38 +48,38 @@
|
||||
{{$path := .Path}}
|
||||
<aside class="projax-sidebar" aria-label="Primary navigation">
|
||||
<div class="sidebar-top">
|
||||
<a href="/" class="brand" title="projax">
|
||||
<a href="/views/tree" class="brand" title="projax">
|
||||
<span class="brand-icon" aria-hidden="true">▦</span>
|
||||
<strong class="brand-label">projax</strong>
|
||||
</a>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/" class="nav-item{{if eq $path "/"}} active{{end}}" title="Tree">
|
||||
<a href="/views/tree" class="nav-item{{if eq $path "/views/tree"}} active{{end}}" title="Tree">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
<span class="nav-label">Tree</span>
|
||||
</a>
|
||||
<a href="/dashboard" class="nav-item{{if eq $path "/dashboard"}} active{{end}}" title="Dashboard">
|
||||
<a href="/views/dashboard" class="nav-item{{if eq $path "/views/dashboard"}} active{{end}}" title="Dashboard">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
|
||||
</svg>
|
||||
<span class="nav-label">Dashboard</span>
|
||||
</a>
|
||||
<a href="/calendar" class="nav-item{{if eq $path "/calendar"}} active{{end}}" title="Calendar">
|
||||
<a href="/views/calendar" class="nav-item{{if eq $path "/views/calendar"}} active{{end}}" title="Calendar">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<span class="nav-label">Calendar</span>
|
||||
</a>
|
||||
<a href="/timeline" class="nav-item{{if eq $path "/timeline"}} active{{end}}" title="Timeline">
|
||||
<a href="/views/timeline" class="nav-item{{if eq $path "/views/timeline"}} active{{end}}" title="Timeline">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span class="nav-label">Timeline</span>
|
||||
</a>
|
||||
<a href="/graph" class="nav-item{{if eq $path "/graph"}} active{{end}}" title="Graph">
|
||||
<a href="/views/graph" class="nav-item{{if eq $path "/views/graph"}} active{{end}}" title="Graph">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
@@ -89,6 +95,25 @@
|
||||
</svg>
|
||||
<span class="nav-label">Views</span>
|
||||
</a>
|
||||
{{if .UserViews}}
|
||||
{{$counts := .UserViewCounts}}
|
||||
<div class="sidebar-user-views" aria-label="Saved views">
|
||||
{{range .UserViews}}
|
||||
{{$slug := .Slug}}
|
||||
<a href="/views/{{.Slug}}"
|
||||
class="nav-item nav-item-user-view{{if eq $path (printf "/views/%s" .Slug)}} active{{end}}"
|
||||
title="{{.Name}}">
|
||||
{{renderIcon .Icon}}
|
||||
<span class="nav-label">{{.Name}}</span>
|
||||
{{if .ShowCount}}<span class="nav-badge" aria-label="Item count">{{index $counts $slug}}</span>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a href="/views/new" class="nav-item nav-item-user-view nav-item-new-view" title="New view">
|
||||
<span class="nav-icon" aria-hidden="true">+</span>
|
||||
<span class="nav-label">New view</span>
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
<a href="/admin" class="nav-item{{if eq $path "/admin"}} active{{end}}" title="Admin">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
@@ -124,14 +149,14 @@
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
<nav class="projax-bottom-nav" aria-label="Mobile navigation">
|
||||
<a href="/" class="bottom-nav-item{{if eq $path "/"}} active{{end}}" aria-label="Tree">
|
||||
<a href="/views/tree" class="bottom-nav-item{{if eq $path "/views/tree"}} active{{end}}" aria-label="Tree">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
||||
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
||||
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
||||
</svg>
|
||||
<span>Tree</span>
|
||||
</a>
|
||||
<a href="/dashboard" class="bottom-nav-item{{if eq $path "/dashboard"}} active{{end}}" aria-label="Dashboard">
|
||||
<a href="/views/dashboard" class="bottom-nav-item{{if eq $path "/views/dashboard"}} active{{end}}" aria-label="Dashboard">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
||||
<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>
|
||||
</svg>
|
||||
@@ -144,7 +169,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
</a>
|
||||
<a href="/calendar" class="bottom-nav-item{{if eq $path "/calendar"}} active{{end}}" aria-label="Calendar">
|
||||
<a href="/views/calendar" class="bottom-nav-item{{if eq $path "/views/calendar"}} active{{end}}" aria-label="Calendar">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="22" height="22" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
@@ -158,13 +183,13 @@
|
||||
<span>Menu</span>
|
||||
</summary>
|
||||
<div class="drawer-sheet" role="menu">
|
||||
<a href="/timeline" class="drawer-item{{if eq $path "/timeline"}} active{{end}}" role="menuitem">
|
||||
<a href="/views/timeline" class="drawer-item{{if eq $path "/views/timeline"}} active{{end}}" role="menuitem">
|
||||
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<span>Timeline</span>
|
||||
</a>
|
||||
<a href="/graph" class="drawer-item{{if eq $path "/graph"}} active{{end}}" role="menuitem">
|
||||
<a href="/views/graph" class="drawer-item{{if eq $path "/views/graph"}} active{{end}}" role="menuitem">
|
||||
<svg class="drawer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
||||
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
||||
|
||||
@@ -1,106 +1,128 @@
|
||||
{{define "tasks-section"}}
|
||||
<section class="tasks" id="tasks-section">
|
||||
<section class="tasks unified{{if .Checklist}} checklist{{end}}" id="tasks-section">
|
||||
<h2>Tasks</h2>
|
||||
{{if .Banner}}<p class="banner warn" role="alert">{{.Banner}}</p>{{end}}
|
||||
{{if .Tasks}}
|
||||
{{$root := .}}
|
||||
{{range .Tasks}}
|
||||
{{$calURL := .CalendarURL}}
|
||||
<div class="cal-block" data-cal="{{$calURL}}">
|
||||
<h3>{{.DisplayName}}</h3>
|
||||
{{if .Error}}<p class="banner warn">{{.Error}}</p>{{end}}
|
||||
|
||||
<form class="todo-create"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/todo-create"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="text" name="summary" placeholder="Add a task…" required>
|
||||
<input type="date" name="due" title="due date (optional)">
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
{{$root := .}}
|
||||
|
||||
{{if .Open}}
|
||||
<ul class="todo open">
|
||||
{{range .Open}}
|
||||
<li class="todo-row" data-uid="{{.UID}}">
|
||||
<form class="todo-complete inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/complete"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
|
||||
</form>
|
||||
|
||||
<form class="todo-edit inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/edit"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<input type="text" name="summary" value="{{.Summary}}" required>
|
||||
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
|
||||
<button type="submit" title="Save edits">Save</button>
|
||||
</form>
|
||||
|
||||
<form class="todo-delete inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this task? This cannot be undone.">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{/* Single add-form. Backend chosen by the §3.1 selector: a CalDAV-bound
|
||||
project creates VTODOs on its linked calendar; an unbound project
|
||||
creates mBrian-native task nodes. */}}
|
||||
{{with .AddTarget}}
|
||||
{{if eq .Mode "caldav"}}
|
||||
<form class="todo-create"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/todo-create"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML">
|
||||
{{if .CalendarURL}}
|
||||
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
|
||||
{{else}}
|
||||
<p class="muted">No open tasks.</p>
|
||||
<select name="calendar_url" required title="calendar">
|
||||
{{range $root.LinkedCalendars}}<option value="{{.URL}}">{{.DisplayName}}</option>{{end}}
|
||||
</select>
|
||||
{{end}}
|
||||
|
||||
{{if .DoneRecent}}
|
||||
<details>
|
||||
<summary class="muted">{{len .DoneRecent}} completed in last 30 days</summary>
|
||||
<ul class="todo done">
|
||||
{{range .DoneRecent}}
|
||||
<li class="todo-row" data-uid="{{.UID}}">
|
||||
<form class="todo-reopen inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/reopen"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML"
|
||||
title="Reopen">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="check" aria-label="Reopen">☑</button>
|
||||
</form>
|
||||
<span class="summary">{{.Summary}}</span>
|
||||
<form class="todo-delete inline"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Delete this task? This cannot be undone.">
|
||||
<input type="hidden" name="calendar_url" value="{{$calURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
<input type="text" name="summary" placeholder="Add a task…" required>
|
||||
<input type="date" name="due" title="due date (optional)">
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
{{else if eq .Mode "mbrian"}}
|
||||
<form class="todo-create"
|
||||
hx-post="/i/{{$root.Item.PrimaryPath}}/task/create"
|
||||
hx-target="#tasks-section"
|
||||
hx-swap="outerHTML">
|
||||
<input type="text" name="title" placeholder="Add a task…" required>
|
||||
<input type="date" name="due" title="due date (optional)">
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted">No CalDAV list linked.</p>
|
||||
{{end}}
|
||||
|
||||
{{/* Phase 5j: per-item picker for sharing an existing list across
|
||||
multiple projax items (e.g. one "Vacations 2026" list under
|
||||
several admin.vacations sub-items). Renders in BOTH states:
|
||||
unlinked items see it next to Create-new; already-linked items
|
||||
see it as "+ link another" for the multi-list flow. */}}
|
||||
{{if .Open}}
|
||||
<ul class="todo open">
|
||||
{{range .Open}}
|
||||
<li class="todo-row" data-src="{{.Source}}">
|
||||
{{/* complete */}}
|
||||
{{if eq .Source "caldav"}}
|
||||
<form class="todo-complete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/complete" hx-target="#tasks-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
|
||||
</form>
|
||||
<form class="todo-edit inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/edit" hx-target="#tasks-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<input type="text" name="summary" value="{{.Title}}" required>
|
||||
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
|
||||
<button type="submit" title="Save edits">Save</button>
|
||||
</form>
|
||||
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
|
||||
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form class="todo-complete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/done" hx-target="#tasks-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="node_id" value="{{.NodeID}}">
|
||||
<button type="submit" class="check" title="Mark complete" aria-label="Mark complete">☐</button>
|
||||
</form>
|
||||
<form class="todo-edit inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/edit" hx-target="#tasks-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="node_id" value="{{.NodeID}}">
|
||||
<input type="text" name="title" value="{{.Title}}" required>
|
||||
<input type="date" name="due" value="{{if .Due}}{{.Due.Format "2006-01-02"}}{{end}}">
|
||||
<button type="submit" title="Save edits">Save</button>
|
||||
</form>
|
||||
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
|
||||
<input type="hidden" name="node_id" value="{{.NodeID}}">
|
||||
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<span class="task-src" title="{{if eq .Source "caldav"}}CalDAV — {{end}}{{.SourceLabel}}">{{.SourceLabel}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="muted">No open tasks.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Done}}
|
||||
<details>
|
||||
<summary class="muted">{{len .Done}} done</summary>
|
||||
<ul class="todo done">
|
||||
{{range .Done}}
|
||||
<li class="todo-row" data-src="{{.Source}}">
|
||||
{{if eq .Source "caldav"}}
|
||||
<form class="todo-reopen inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/reopen" hx-target="#tasks-section" hx-swap="outerHTML" title="Reopen">
|
||||
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="check" aria-label="Reopen">☑</button>
|
||||
</form>
|
||||
<span class="summary">{{.Title}}</span>
|
||||
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/caldav/todo/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
|
||||
<input type="hidden" name="calendar_url" value="{{.CalendarURL}}">
|
||||
<input type="hidden" name="uid" value="{{.UID}}">
|
||||
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form class="todo-reopen inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/reopen" hx-target="#tasks-section" hx-swap="outerHTML" title="Reopen">
|
||||
<input type="hidden" name="node_id" value="{{.NodeID}}">
|
||||
<button type="submit" class="check" aria-label="Reopen">☑</button>
|
||||
</form>
|
||||
<span class="summary">{{.Title}}</span>
|
||||
<form class="todo-delete inline" hx-post="/i/{{$root.Item.PrimaryPath}}/task/delete" hx-target="#tasks-section" hx-swap="outerHTML" hx-confirm="Delete this task? This cannot be undone.">
|
||||
<input type="hidden" name="node_id" value="{{.NodeID}}">
|
||||
<button type="submit" class="x" title="Delete" aria-label="Delete">×</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<span class="task-src" title="{{if eq .Source "caldav"}}CalDAV — {{end}}{{.SourceLabel}}">{{.SourceLabel}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
{{/* Project-level CalDAV management: link an existing list, or create a new
|
||||
one (only when none is bound yet). Unchanged from the pre-7c flow. */}}
|
||||
{{if .CalDAVOn}}
|
||||
<div class="caldav-actions">
|
||||
{{if .AvailableCalendars}}
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/link-existing" class="caldav-link-existing inline">
|
||||
@@ -112,11 +134,12 @@
|
||||
<button type="submit">Link</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Tasks}}
|
||||
{{if not .CalDAVBound}}
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||||
<button type="submit">+ Create new list</button>
|
||||
<button type="submit">+ Create new CalDAV list</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<section class="tagbar" id="timeline-filterbar">
|
||||
<form id="timeline-filter" class="search"
|
||||
hx-get="/timeline"
|
||||
hx-get="/views/timeline"
|
||||
hx-target="#timeline-section"
|
||||
hx-swap="outerHTML"
|
||||
hx-trigger="change from:select"
|
||||
@@ -45,7 +45,7 @@
|
||||
</label>
|
||||
{{if .Filter.ProjectPath}}<input type="hidden" name="project" value="{{.Filter.ProjectPath}}">{{end}}
|
||||
{{if and .Filter.ProjectPath (not .Filter.IncludeDescendants)}}<input type="hidden" name="project_descendants" value="0">{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/timeline">clear filters</a>{{end}}
|
||||
{{if .Filter.Active}}<a class="clear" href="/views/timeline">clear filters</a>{{end}}
|
||||
</form>
|
||||
|
||||
{{template "view-project-chip" .}}
|
||||
@@ -62,7 +62,7 @@
|
||||
<li class="spine-day{{if .Sticky}} sticky-{{.Sticky}}{{end}}" data-date="{{.DateKey}}">
|
||||
<header class="day-header">
|
||||
{{if .Sticky}}<span class="sticky-pill">{{.Sticky}}</span>{{end}}
|
||||
<h2><a class="muted" href="/timeline?from={{.DateKey}}&to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
|
||||
<h2><a class="muted" href="/views/timeline?from={{.DateKey}}&to={{.DateKey}}">{{.Label}}</a> <small class="muted">({{len .Rows}})</small></h2>
|
||||
</header>
|
||||
<ul class="day-rows">
|
||||
{{range .Rows}}
|
||||
|
||||
@@ -22,7 +22,7 @@ the visual difference is layout, not data shape.
|
||||
</article>
|
||||
{{else}}
|
||||
<div class="tree-card-empty">
|
||||
<em>No items match. Try fewer filters or <a href="/">clear all</a>.</em>
|
||||
<em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ set surfaces a friendly empty-state message.
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="kanban-empty muted">
|
||||
<em>No items match. Try fewer filters or <a href="/?view_type=kanban">clear filters</a>.</em>
|
||||
<em>No items match. Try fewer filters or <a href="/views/tree?view_type=kanban">clear filters</a>.</em>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
{{define "tree-section"}}
|
||||
<section id="tree-section" class="tree-section">
|
||||
{{if .DefaultBanner}}
|
||||
<p class="default-banner muted">
|
||||
Showing default view: <strong>{{.DefaultBanner.Name}}</strong> ·
|
||||
<a href="/?nodefault=1"
|
||||
hx-get="/?nodefault=1" hx-target="#tree-section" hx-swap="outerHTML" hx-push-url="true">clear</a>
|
||||
</p>
|
||||
{{end}}
|
||||
<p class="counts">
|
||||
<strong>{{.Matched}}</strong> / <strong>{{.Total}}</strong> items match
|
||||
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
|
||||
{{if .Filter.Active}} · <a class="clear" href="/">clear filters</a>{{end}}
|
||||
{{if .Filter.Active}} · <a class="clear" href="/views/tree">clear filters</a>{{end}}
|
||||
</p>
|
||||
|
||||
<section class="tagbar" id="tree-filterbar">
|
||||
@@ -100,7 +93,7 @@
|
||||
{{template "children" .}}
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="empty"><em>No items match. Try fewer filters or <a href="/">clear all</a>.</em></li>
|
||||
<li class="empty"><em>No items match. Try fewer filters or <a href="/views/tree">clear all</a>.</em></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
{{define "content"}}
|
||||
<h1>Edit view</h1>
|
||||
<p class="muted"><a href="/views">← back to views</a></p>
|
||||
|
||||
<section class="views-create">
|
||||
<form method="post" action="/views/{{.View.ID}}">
|
||||
<label>Name <input type="text" name="name" required maxlength="80" value="{{.View.Name}}"></label>
|
||||
<label>Description <input type="text" name="description" maxlength="200" value="{{.View.Description}}"></label>
|
||||
<label>View type
|
||||
<select name="view_type" required>
|
||||
{{$cur := .View.ViewType}}
|
||||
{{range .AllViewTypes}}<option value="{{.}}"{{if eq . $cur}} selected{{end}}>{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Default for
|
||||
<select name="is_default_for">
|
||||
{{$d := deref .View.IsDefaultFor}}
|
||||
{{range .DefaultForOptions}}<option value="{{.}}"{{if eq . $d}} selected{{end}}>{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Group by
|
||||
<select name="group_by">
|
||||
{{$g := deref .View.GroupBy}}
|
||||
{{range .GroupByOptions}}<option value="{{.}}"{{if eq . $g}} selected{{end}}>{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Sort field <input type="text" name="sort_field" placeholder="title / updated_at / start_time" maxlength="40" value="{{deref .View.SortField}}"></label>
|
||||
<label>Sort dir
|
||||
<select name="sort_dir">
|
||||
{{$sd := deref .View.SortDir}}
|
||||
{{range .SortDirOptions}}<option value="{{.}}"{{if eq . $sd}} selected{{end}}>{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><input type="checkbox" name="pinned" value="1"{{if .View.Pinned}} checked{{end}}> Pinned</label>
|
||||
<label>Filter (URL query form)
|
||||
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.FilterQuery}}">
|
||||
</label>
|
||||
<button type="submit">Save changes</button>
|
||||
<a class="muted" href="/views">cancel</a>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
49
web/templates/view_editor.tmpl
Normal file
49
web/templates/view_editor.tmpl
Normal file
@@ -0,0 +1,49 @@
|
||||
{{define "content"}}
|
||||
<h1>{{if .View}}Edit {{.View.Name}}{{else}}New view{{end}}</h1>
|
||||
<p class="muted"><a href="/views">← back to views</a></p>
|
||||
|
||||
<form class="view-editor"
|
||||
method="post"
|
||||
action="{{if .View}}/views/{{.View.Slug}}{{else}}/views{{end}}">
|
||||
<label>Name <input type="text" name="name" required maxlength="80" value="{{if .View}}{{.View.Name}}{{end}}"></label>
|
||||
<label>Slug
|
||||
<input type="text" name="slug" required maxlength="63"
|
||||
pattern="^[a-z0-9][a-z0-9-]{0,62}$"
|
||||
value="{{if .View}}{{.View.Slug}}{{end}}">
|
||||
<small class="muted">lowercase letters, digits, dashes. No reserved system slugs.</small>
|
||||
</label>
|
||||
<label>Icon
|
||||
<select name="icon">
|
||||
{{$cur := ""}}
|
||||
{{if and .View .View.Icon}}{{$cur = deref .View.Icon}}{{end}}
|
||||
{{range .IconKeys}}
|
||||
<option value="{{.}}"{{if eq . $cur}} selected{{end}}>{{.}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset class="view-type-radios">
|
||||
<legend>View type</legend>
|
||||
{{range .ViewTypes}}
|
||||
<label><input type="radio" name="view_type" value="{{.}}" {{if eq . $.CurrentVT}}checked{{end}}> {{.}}</label>
|
||||
{{end}}
|
||||
</fieldset>
|
||||
<label>Group by
|
||||
<select name="group_by">
|
||||
{{range .GroupByOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Sort field <input type="text" name="sort_field" placeholder="title / updated_at" maxlength="40"></label>
|
||||
<label>Sort dir
|
||||
<select name="sort_dir">
|
||||
{{range .SortDirOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><input type="checkbox" name="show_count" value="1"
|
||||
{{if and .View .View.ShowCount}}checked{{end}}> Show row-count badge in sidebar</label>
|
||||
<label>Filter (URL query form)
|
||||
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.FilterQuery}}">
|
||||
</label>
|
||||
<button type="submit">{{if .View}}Save changes{{else}}Create view{{end}}</button>
|
||||
<a class="muted" href="/views">cancel</a>
|
||||
</form>
|
||||
{{end}}
|
||||
14
web/templates/view_render.tmpl
Normal file
14
web/templates/view_render.tmpl
Normal file
@@ -0,0 +1,14 @@
|
||||
{{define "content"}}
|
||||
<section class="view-header">
|
||||
<h1>{{.View.Name}}</h1>
|
||||
<p class="muted view-meta">
|
||||
<code>/views/{{.View.Slug}}</code> ·
|
||||
<a href="/views/{{.View.Slug}}/edit">edit</a> ·
|
||||
<form method="post" action="/views/{{.View.Slug}}/delete" style="display:inline">
|
||||
<button type="submit" class="link-button" onclick="return confirm('Delete view {{.View.Name}}?')">delete</button>
|
||||
</form>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{{template "tree-section" .}}
|
||||
{{end}}
|
||||
@@ -1,70 +0,0 @@
|
||||
{{define "content"}}
|
||||
<h1>Views</h1>
|
||||
|
||||
<p class="muted">Saved bundles of (filter + view_type + sort + group_by). Page-agnostic — open one to render the saved set on the matching page.</p>
|
||||
|
||||
<section class="views-list">
|
||||
{{if .Views}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>★</th><th>Name</th><th>Type</th><th>Default for</th><th>Group by</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Views}}
|
||||
<tr>
|
||||
<td>{{if .Pinned}}★{{end}}</td>
|
||||
<td><a href="/views/{{.ID}}">{{.Name}}</a>{{if .Description}}<br><small class="muted">{{.Description}}</small>{{end}}</td>
|
||||
<td>{{.ViewType}}</td>
|
||||
<td>{{if .IsDefaultFor}}{{deref .IsDefaultFor}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td>{{if .GroupBy}}{{deref .GroupBy}}{{else}}<span class="muted">—</span>{{end}}</td>
|
||||
<td>
|
||||
<a href="/views/{{.ID}}/edit">edit</a>
|
||||
<form method="post" action="/views/{{.ID}}/delete" style="display:inline">
|
||||
<button type="submit" class="link-button" onclick="return confirm('Delete view {{.Name}}?')">delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<p class="empty muted"><em>No saved views yet. Create one with the form below or via the "Save view…" link on any Views-supporting page.</em></p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section class="views-create">
|
||||
<h2>New view</h2>
|
||||
<form method="post" action="/views">
|
||||
<label>Name <input type="text" name="name" required maxlength="80"></label>
|
||||
<label>Description <input type="text" name="description" maxlength="200"></label>
|
||||
<label>View type
|
||||
<select name="view_type" required>
|
||||
{{range .AllViewTypes}}<option value="{{.}}">{{.}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Default for
|
||||
<select name="is_default_for">
|
||||
{{range .DefaultForOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Group by
|
||||
<select name="group_by">
|
||||
{{range .GroupByOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Sort field <input type="text" name="sort_field" placeholder="title / updated_at / start_time" maxlength="40"></label>
|
||||
<label>Sort dir
|
||||
<select name="sort_dir">
|
||||
{{range .SortDirOptions}}<option value="{{.}}">{{if eq . ""}}—{{else}}{{.}}{{end}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label><input type="checkbox" name="pinned" value="1"> Pinned</label>
|
||||
<label>Filter (URL query form, e.g. <code>tag=work&mgmt=mai</code>)
|
||||
<input type="text" name="filter_query" placeholder="tag=work&mgmt=mai" value="{{.Prefill.filter}}">
|
||||
</label>
|
||||
<button type="submit">Create view</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
26
web/templates/views_landing.tmpl
Normal file
26
web/templates/views_landing.tmpl
Normal file
@@ -0,0 +1,26 @@
|
||||
{{define "content"}}
|
||||
<h1>Views</h1>
|
||||
|
||||
<p class="muted">First-class saved pages. Each view has its own URL and renders on its own.</p>
|
||||
|
||||
{{if .Views}}
|
||||
<section class="views-list">
|
||||
<ul class="views-list-grid">
|
||||
{{range .Views}}
|
||||
<li>
|
||||
<a class="view-card" href="/views/{{.Slug}}">
|
||||
<span class="view-card-name">{{.Name}}</span>
|
||||
<span class="view-card-slug muted">/views/{{.Slug}}</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{else}}
|
||||
<section class="views-empty">
|
||||
<p class="muted"><em>No saved views yet.</em></p>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<p><a class="view-create-link" href="/views/new">+ New view</a></p>
|
||||
{{end}}
|
||||
@@ -14,9 +14,9 @@ func TestThemeDefaultIsDark(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/")
|
||||
code, body := get(t, h, "/views/tree")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET / → %d", code)
|
||||
t.Fatalf("GET /views/tree → %d", code)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`<html lang="en" data-theme="dark">`,
|
||||
@@ -42,7 +42,7 @@ func TestThemeCookieRoundTrips(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/views/tree", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
@@ -66,7 +66,7 @@ func TestThemeCookieUnknownFallsBackToDark(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/views/tree", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "neon-puke"})
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
@@ -94,7 +94,7 @@ func TestThemeTogglePagesShareSameTheme(t *testing.T) {
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
return string(body)
|
||||
}
|
||||
for _, path := range []string{"/", "/dashboard", "/timeline", "/graph", "/admin", "/admin/bulk", "/admin/classify"} {
|
||||
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/timeline", "/views/graph", "/admin", "/admin/bulk", "/admin/classify"} {
|
||||
dark := probe(path, "")
|
||||
light := probe(path, "light")
|
||||
if !strings.Contains(dark, `data-theme="dark"`) {
|
||||
@@ -112,7 +112,7 @@ func TestThemeToggleScriptPresent(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/dashboard")
|
||||
_, body := get(t, h, "/views/dashboard")
|
||||
for _, want := range []string{
|
||||
"document.cookie = 'projax_theme=",
|
||||
`getElementById('theme-toggle')`,
|
||||
@@ -132,7 +132,7 @@ func TestThemeColorMetaHelper(t *testing.T) {
|
||||
defer pool.Close()
|
||||
// Indirect: render a fragment with a Theme override to confirm injection
|
||||
// does not double-write the meta when caller already populates it.
|
||||
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/views/dashboard", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "projax_theme", Value: "light"})
|
||||
w := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, req)
|
||||
|
||||
@@ -199,7 +199,7 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
"Query": q,
|
||||
"Now": now,
|
||||
"Projects": projects,
|
||||
"BasePath": "/timeline",
|
||||
"BasePath": "/views/timeline",
|
||||
"ProjectChipTarget": "#timeline-section",
|
||||
}
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
@@ -212,7 +212,7 @@ func (s *Server) handleTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
// buildTimeline gathers every dated source, applies the kind/filter narrowing,
|
||||
// and groups rows by day in the requested order.
|
||||
func (s *Server) buildTimeline(ctx context.Context, q TimelineQuery, now time.Time) (*TimelinePayload, error) {
|
||||
items, err := s.Store.ListAll(ctx)
|
||||
items, err := s.Items.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -108,13 +108,13 @@ END:VCALENDAR`
|
||||
}
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/timeline")
|
||||
_, body := get(t, h, "/views/timeline")
|
||||
if strings.Contains(body, "Shopping list item") {
|
||||
t.Errorf("/timeline should NOT include excluded todo summary; body contained it")
|
||||
}
|
||||
|
||||
// Override: ?include_excluded=1 brings it back.
|
||||
_, peekBody := get(t, h, "/timeline?include_excluded=1")
|
||||
_, peekBody := get(t, h, "/views/timeline?include_excluded=1")
|
||||
if !strings.Contains(peekBody, "Shopping list item") {
|
||||
t.Errorf("?include_excluded=1 should surface the excluded todo; body lacked it")
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ func TestTimelineRendersEmpty(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/timeline")
|
||||
code, body := get(t, h, "/views/timeline")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /timeline → %d body=%s", code, body)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestTimelineSurfacesDatedDocs(t *testing.T) {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
|
||||
code, body := get(t, h, "/timeline")
|
||||
code, body := get(t, h, "/views/timeline")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /timeline → %d", code)
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func TestTimelineFilterByKindNarrowsRows(t *testing.T) {
|
||||
}
|
||||
|
||||
// Unfiltered: both the creation marker and the dated doc should be present.
|
||||
_, allBody := get(t, h, "/timeline")
|
||||
_, allBody := get(t, h, "/views/timeline")
|
||||
if !strings.Contains(allBody, "added <a class=\"proj\" href=\"/i/dev."+slug) {
|
||||
t.Errorf("expected creation marker in unfiltered timeline body")
|
||||
}
|
||||
@@ -124,7 +124,7 @@ func TestTimelineFilterByKindNarrowsRows(t *testing.T) {
|
||||
}
|
||||
|
||||
// kind=doc only: the doc row stays; the creation marker drops.
|
||||
_, docOnly := get(t, h, "/timeline?kind=doc")
|
||||
_, docOnly := get(t, h, "/views/timeline?kind=doc")
|
||||
if strings.Contains(docOnly, "added <a class=\"proj\" href=\"/i/dev."+slug) {
|
||||
t.Errorf("kind=doc should hide creation marker")
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func TestTimelineOrderToggleReversesDays(t *testing.T) {
|
||||
older := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, -3).Format("060102")
|
||||
newer := "dev." + slug + "." + time.Now().UTC().AddDate(0, 0, 5).Format("060102")
|
||||
|
||||
_, desc := get(t, h, "/timeline")
|
||||
_, desc := get(t, h, "/views/timeline")
|
||||
idxNewerDesc := strings.Index(desc, newer)
|
||||
idxOlderDesc := strings.Index(desc, older)
|
||||
if idxNewerDesc < 0 || idxOlderDesc < 0 {
|
||||
@@ -181,7 +181,7 @@ func TestTimelineOrderToggleReversesDays(t *testing.T) {
|
||||
t.Errorf("default order should be desc (newest first); newer at %d, older at %d", idxNewerDesc, idxOlderDesc)
|
||||
}
|
||||
|
||||
_, asc := get(t, h, "/timeline?order=asc")
|
||||
_, asc := get(t, h, "/views/timeline?order=asc")
|
||||
idxNewerAsc := strings.Index(asc, newer)
|
||||
idxOlderAsc := strings.Index(asc, older)
|
||||
if !(idxOlderAsc < idxNewerAsc) {
|
||||
@@ -277,7 +277,7 @@ END:VCALENDAR`
|
||||
}
|
||||
|
||||
h := srv.Routes()
|
||||
code, body := get(t, h, "/timeline")
|
||||
code, body := get(t, h, "/views/timeline")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /timeline → %d", code)
|
||||
}
|
||||
@@ -337,7 +337,7 @@ func TestTimelineFilterByTagAppliesAcrossKinds(t *testing.T) {
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, devID, homeID)
|
||||
|
||||
tag := "tl-tag-work-" + stamp
|
||||
_, body := get(t, h, "/timeline?tag="+tag)
|
||||
_, body := get(t, h, "/views/timeline?tag="+tag)
|
||||
// Phase 5i Slice A: the project picker renders every item path as a
|
||||
// <select> option, so a naive substring match also sees filtered-out
|
||||
// items inside the dropdown. Anchor on the timeline-row link instead.
|
||||
|
||||
@@ -26,12 +26,6 @@ type TreeFilter struct {
|
||||
// exposes an explicit on/off toggle.
|
||||
ProjectPath string
|
||||
IncludeDescendants bool
|
||||
// Phase 5i fix-shift — saved-view anchor. When set, the URL was
|
||||
// `?view=<uuid>`; chip clicks need to round-trip the value so the user
|
||||
// stays inside the saved view while narrowing further. Not a "filter"
|
||||
// dimension in the matching sense — Matches ignores it — but it lives
|
||||
// in the URL state and on the struct so QueryString preserves it.
|
||||
ViewID string
|
||||
}
|
||||
|
||||
// Active reports whether any filter dimension is set to something other than
|
||||
@@ -70,7 +64,6 @@ func ParseTreeFilter(q url.Values) TreeFilter {
|
||||
ShowArchived: q.Get("show-archived") == "1",
|
||||
ProjectPath: strings.TrimSpace(q.Get("project")),
|
||||
IncludeDescendants: true,
|
||||
ViewID: strings.TrimSpace(q.Get("view")),
|
||||
}
|
||||
if v := strings.TrimSpace(q.Get("public")); v != "" {
|
||||
// Treat 1/true/yes/on as true; 0/false/no/off as false; anything else nil.
|
||||
@@ -132,9 +125,6 @@ func (f TreeFilter) QueryString() string {
|
||||
v.Set("project_descendants", "0")
|
||||
}
|
||||
}
|
||||
if f.ViewID != "" {
|
||||
v.Set("view", f.ViewID)
|
||||
}
|
||||
return v.Encode()
|
||||
}
|
||||
|
||||
@@ -489,7 +479,10 @@ type ChipCounts struct {
|
||||
// see what they're filtered down to). For an inactive chip the count is what
|
||||
// they'd get if they added it. At m's scale (≤100 items × ≤30 chips) this is
|
||||
// trivially cheap; no caching needed.
|
||||
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string) ChipCounts {
|
||||
func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[string]map[string]struct{}, allTags []string, base string) ChipCounts {
|
||||
if base == "" {
|
||||
base = "/"
|
||||
}
|
||||
count := func(f TreeFilter) int {
|
||||
// Branch-keep semantics aren't relevant for chip counts — we want a
|
||||
// raw "how many items match this filter directly" so the chip number
|
||||
@@ -507,7 +500,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
||||
next := current.ToggleTag(tag)
|
||||
out.Tags = append(out.Tags, ChipCount{
|
||||
Label: tag,
|
||||
URL: next.URL(),
|
||||
URL: next.URLOn(base),
|
||||
Count: count(next),
|
||||
Active: contains(current.Tags, tag),
|
||||
})
|
||||
@@ -516,7 +509,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
||||
next := current.ToggleManagement(mode)
|
||||
out.Management = append(out.Management, ChipCount{
|
||||
Label: mode,
|
||||
URL: next.URL(),
|
||||
URL: next.URLOn(base),
|
||||
Count: count(next),
|
||||
Active: contains(current.Management, mode),
|
||||
})
|
||||
@@ -525,7 +518,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
||||
next := current.ToggleStatus(st)
|
||||
out.Status = append(out.Status, ChipCount{
|
||||
Label: st,
|
||||
URL: next.URL(),
|
||||
URL: next.URLOn(base),
|
||||
Count: count(next),
|
||||
Active: contains(current.Status, st),
|
||||
})
|
||||
@@ -534,7 +527,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
||||
next := current.ToggleHas(h)
|
||||
out.Has = append(out.Has, ChipCount{
|
||||
Label: h,
|
||||
URL: next.URL(),
|
||||
URL: next.URLOn(base),
|
||||
Count: count(next),
|
||||
Active: contains(current.HasLinks, h),
|
||||
})
|
||||
@@ -543,7 +536,7 @@ func computeChipCounts(items []*store.Item, current TreeFilter, linkKinds map[st
|
||||
next := current.ToggleShowArchived()
|
||||
out.ShowArchived = ChipCount{
|
||||
Label: "show archived",
|
||||
URL: next.URL(),
|
||||
URL: next.URLOn(base),
|
||||
Count: count(next),
|
||||
Active: current.ShowArchived,
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func TestComputeChipCountsTagCounts(t *testing.T) {
|
||||
both := &store.Item{ID: "x", Slug: "x", Title: "X", Tags: []string{"work", "dev"}, Status: "active"}
|
||||
items := []*store.Item{work, dev, both}
|
||||
f := TreeFilter{Status: []string{"active"}}
|
||||
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"})
|
||||
counts := computeChipCounts(items, f, map[string]map[string]struct{}{}, []string{"work", "dev"}, "/views/tree")
|
||||
if len(counts.Tags) != 2 {
|
||||
t.Fatalf("expected 2 tag chips, got %d", len(counts.Tags))
|
||||
}
|
||||
|
||||
@@ -63,13 +63,13 @@ func (s ViewTypeSet) Resolve(vt string) string {
|
||||
// this. The narrow tree/dashboard set is the seed; slices C–E grow it.
|
||||
func PageViewTypes(route string) ViewTypeSet {
|
||||
switch route {
|
||||
case "/", "tree":
|
||||
case "/", "/views/tree", "tree":
|
||||
return ViewTypeSet{
|
||||
Default: ViewTypeList,
|
||||
// Slice B: list + card. Slice C: kanban joins.
|
||||
Allowed: []string{ViewTypeList, ViewTypeCard, ViewTypeKanban},
|
||||
}
|
||||
case "/dashboard", "dashboard":
|
||||
case "/dashboard", "/views/dashboard", "dashboard":
|
||||
// Dashboard is locked to its Phase 5h tabbed-tiles surface in slice B.
|
||||
// The view_type chip is informational only here; switching templates
|
||||
// for card vs list on /dashboard is a follow-up slice (the tabbed
|
||||
@@ -79,12 +79,12 @@ func PageViewTypes(route string) ViewTypeSet {
|
||||
Default: ViewTypeCard,
|
||||
Allowed: []string{ViewTypeCard},
|
||||
}
|
||||
case "/timeline", "timeline":
|
||||
case "/timeline", "/views/timeline", "timeline":
|
||||
return ViewTypeSet{
|
||||
Default: ViewTypeTimeline,
|
||||
Allowed: []string{ViewTypeTimeline},
|
||||
}
|
||||
case "/calendar", "calendar":
|
||||
case "/calendar", "/views/calendar", "calendar":
|
||||
return ViewTypeSet{
|
||||
Default: ViewTypeCalendar,
|
||||
Allowed: []string{ViewTypeCalendar},
|
||||
|
||||
@@ -18,10 +18,10 @@ func TestParseViewTypeFallsBackOnUnknown(t *testing.T) {
|
||||
{"/", "list", ViewTypeList}, // explicit default
|
||||
{"/", "kanban", ViewTypeKanban}, // unlocked in slice C
|
||||
{"/", "junk", ViewTypeList}, // unknown → default
|
||||
{"/dashboard", "", ViewTypeCard}, // default for dashboard
|
||||
{"/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
|
||||
{"/timeline", "card", ViewTypeTimeline}, // locked
|
||||
{"/calendar", "kanban", ViewTypeCalendar}, // locked
|
||||
{"/views/dashboard", "", ViewTypeCard}, // default for dashboard
|
||||
{"/views/dashboard", "list", ViewTypeCard}, // not allowed in slice B → default
|
||||
{"/views/timeline", "card", ViewTypeTimeline}, // locked
|
||||
{"/views/calendar", "kanban", ViewTypeCalendar}, // locked
|
||||
}
|
||||
for _, tc := range cases {
|
||||
set := PageViewTypes(tc.route)
|
||||
|
||||
596
web/views.go
596
web/views.go
@@ -11,39 +11,197 @@ import (
|
||||
"github.com/m/projax/store"
|
||||
)
|
||||
|
||||
// Phase 5i Slice D — saved views handlers. Page-agnostic: a view bundles a
|
||||
// filter + view_type + sort/group_by and renders on any page that supports
|
||||
// that view_type. The sidebar in layout.tmpl lists every saved view; the
|
||||
// /views index lets m manage them.
|
||||
// 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)
|
||||
|
||||
// handleViewsIndex renders the list + create-form page.
|
||||
func (s *Server) handleViewsIndex(w http.ResponseWriter, r *http.Request) {
|
||||
// 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
|
||||
}
|
||||
// Prefill: a save-from-page link can pass ?prefill_filter=<encoded TreeFilter
|
||||
// URL query>&prefill_view_type=<vt>&prefill_page=<route> so the form opens
|
||||
// with the user's current state already typed in.
|
||||
prefill := map[string]string{
|
||||
"filter": r.URL.Query().Get("prefill_filter"),
|
||||
"view_type": r.URL.Query().Get("prefill_view_type"),
|
||||
"page": r.URL.Query().Get("prefill_page"),
|
||||
}
|
||||
s.render(w, r, "views", map[string]any{
|
||||
"Title": "views",
|
||||
"Views": views,
|
||||
"Prefill": prefill,
|
||||
// Catalog of selectable values for the form selects.
|
||||
"AllViewTypes": allViewTypes,
|
||||
"DefaultForOptions": []string{"", "tree", "dashboard", "calendar", "timeline"},
|
||||
"SortDirOptions": []string{"", "asc", "desc"},
|
||||
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
|
||||
s.render(w, r, "views_landing", map[string]any{
|
||||
"Title": "views",
|
||||
"Views": views,
|
||||
})
|
||||
}
|
||||
|
||||
// handleViewCreate accepts the create-view form POST.
|
||||
// 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)
|
||||
@@ -56,69 +214,15 @@ func (s *Server) handleViewCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
v, err := s.Store.CreateView(r.Context(), in)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
s.writeViewError(w, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/views/"+v.ID, http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/views/"+v.Slug, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleViewEdit renders the edit form for an existing view, pre-populated
|
||||
// with the row's current values. Submit posts back to /views/<id>.
|
||||
func (s *Server) handleViewEdit(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/views/"), "/edit")
|
||||
if id == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
v, err := s.Store.GetView(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrViewNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
filterQuery, err := filterJSONToQuery(v.FilterJSON)
|
||||
if err != nil {
|
||||
s.Logger.Warn("filterJSONToQuery", "id", id, "err", err)
|
||||
}
|
||||
s.render(w, r, "view_edit", map[string]any{
|
||||
"Title": "edit view",
|
||||
"View": v,
|
||||
"FilterQuery": filterQuery,
|
||||
"AllViewTypes": allViewTypes,
|
||||
"DefaultForOptions": []string{"", "tree", "dashboard", "calendar", "timeline"},
|
||||
"SortDirOptions": []string{"", "asc", "desc"},
|
||||
"GroupByOptions": []string{"", "status", "area", "tag", "management"},
|
||||
})
|
||||
}
|
||||
|
||||
// filterJSONToQuery rebuilds a URL-query representation of a stored
|
||||
// filter_json so the edit form can pre-populate the `filter_query` input
|
||||
// field. Inverse of filterQueryToJSON.
|
||||
func filterJSONToQuery(filterJSON []byte) (string, error) {
|
||||
if len(filterJSON) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
payload := map[string]any{}
|
||||
if err := json.Unmarshal(filterJSON, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
f := filterFromJSONPayload(payload)
|
||||
// QueryString re-emits the canonical URL query form; that's exactly the
|
||||
// shape the form's `filter_query` input expects on round-trip.
|
||||
return f.QueryString(), nil
|
||||
}
|
||||
|
||||
// handleViewWrite dispatches the /views/<id> POST routes: bare path is
|
||||
// update; /views/<id>/delete is soft-delete.
|
||||
func (s *Server) handleViewWrite(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/views/")
|
||||
if base, ok := strings.CutSuffix(path, "/delete"); ok {
|
||||
s.handleViewDelete(w, r, base)
|
||||
return
|
||||
}
|
||||
// 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
|
||||
@@ -128,48 +232,23 @@ func (s *Server) handleViewWrite(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := s.Store.UpdateView(r.Context(), path, in); err != nil {
|
||||
if errors.Is(err, store.ErrViewNotFound) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/views", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleViewDelete soft-deletes by id.
|
||||
func (s *Server) handleViewDelete(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := s.Store.SoftDeleteView(r.Context(), id); 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)
|
||||
}
|
||||
|
||||
// handleViewRedirect resolves /views/<uuid> GET into a redirect to the
|
||||
// appropriate Views-supporting page with ?view=<uuid> appended. The target
|
||||
// page resolves the saved filter+view_type at render time via
|
||||
// applySavedView. /views/<id>/edit is dispatched separately via the more
|
||||
// specific route registered first; this handler ignores the edit suffix
|
||||
// defensively when the routing pattern doesn't match for some reason.
|
||||
func (s *Server) handleViewRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/views/")
|
||||
if id == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasSuffix(id, "/edit") {
|
||||
s.handleViewEdit(w, r)
|
||||
return
|
||||
}
|
||||
v, err := s.Store.GetView(r.Context(), id)
|
||||
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
|
||||
@@ -177,64 +256,89 @@ func (s *Server) handleViewRedirect(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
target := targetRouteForViewType(v.ViewType)
|
||||
q := url.Values{}
|
||||
q.Set("view", v.ID)
|
||||
http.Redirect(w, r, target+"?"+q.Encode(), http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/views", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// targetRouteForViewType picks a sensible landing route given the view's
|
||||
// view_type. card/list/kanban land on /; calendar on /calendar; timeline on
|
||||
// /timeline. Slice E will let `is_default_for` override.
|
||||
func targetRouteForViewType(vt string) string {
|
||||
switch vt {
|
||||
case ViewTypeCalendar:
|
||||
return "/calendar"
|
||||
case ViewTypeTimeline:
|
||||
return "/timeline"
|
||||
case ViewTypeCard, ViewTypeList, ViewTypeKanban:
|
||||
return "/"
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// viewInputFromForm decodes the create/update form. filter_json is accepted
|
||||
// as either raw JSON (textarea) OR as an encoded query string under
|
||||
// `filter_query` so the save-from-page workflow can prefill from a TreeFilter
|
||||
// the user assembled via chips.
|
||||
// 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{
|
||||
Name: strings.TrimSpace(form.Get("name")),
|
||||
Description: strings.TrimSpace(form.Get("description")),
|
||||
ViewType: strings.TrimSpace(form.Get("view_type")),
|
||||
SortField: strings.TrimSpace(form.Get("sort_field")),
|
||||
SortDir: strings.TrimSpace(form.Get("sort_dir")),
|
||||
GroupBy: strings.TrimSpace(form.Get("group_by")),
|
||||
Pinned: form.Get("pinned") == "1",
|
||||
IsDefaultFor: strings.TrimSpace(form.Get("is_default_for")),
|
||||
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",
|
||||
}
|
||||
// Prefer filter_query when present; otherwise fall back to filter_json.
|
||||
if fq := strings.TrimSpace(form.Get("filter_query")); fq != "" {
|
||||
filterJSON, err := filterQueryToJSON(fq)
|
||||
if err != nil {
|
||||
return in, fmt.Errorf("filter_query: %w", err)
|
||||
}
|
||||
in.FilterJSON = filterJSON
|
||||
} else if fj := strings.TrimSpace(form.Get("filter_json")); fj != "" {
|
||||
in.FilterJSON = []byte(fj)
|
||||
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
|
||||
}
|
||||
|
||||
// filterQueryToJSON parses a TreeFilter URL query and returns the canonical
|
||||
// JSON shape stored in `filter_json`. Mirrors the design doc §2 keys.
|
||||
func filterQueryToJSON(query string) ([]byte, error) {
|
||||
// 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{}
|
||||
payload := map[string]any{
|
||||
"view_type": viewType,
|
||||
}
|
||||
if f.Q != "" {
|
||||
payload["q"] = f.Q
|
||||
}
|
||||
@@ -265,82 +369,65 @@ func filterQueryToJSON(query string) ([]byte, error) {
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
|
||||
// applyDefaultView resolves the saved view marked is_default_for=<page>
|
||||
// when the request URL carries no filter/view-specific params and the user
|
||||
// has not opted out via ?nodefault=1. Returns the applied view (for banner
|
||||
// labelling) or nil when no default exists / was applied.
|
||||
//
|
||||
// Per design.md §7 Slice E: defaults are a polish layer. They only kick in
|
||||
// on a "clean" landing — the moment the user types a chip click, the URL
|
||||
// gains a filter param and the default no longer auto-applies. Same with
|
||||
// an explicit ?view=<uuid>.
|
||||
func (s *Server) applyDefaultView(r *http.Request, page string, filter *TreeFilter, viewType *string) (*store.View, error) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("nodefault") == "1" {
|
||||
return nil, nil
|
||||
// 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,
|
||||
}
|
||||
// Any filter-affecting param means "user is driving" — skip the default.
|
||||
for _, key := range []string{"q", "tag", "mgmt", "status", "has", "show-archived", "public", "project", "project_id", "project_descendants", "view", "view_type", "group_by"} {
|
||||
if q.Get(key) != "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
v, err := s.Store.DefaultViewFor(r.Context(), page)
|
||||
if err != nil || v == nil {
|
||||
return v, err
|
||||
viewType := ""
|
||||
groupBy := ""
|
||||
if len(filterJSON) == 0 {
|
||||
return f, viewType, groupBy
|
||||
}
|
||||
payload := map[string]any{}
|
||||
if len(v.FilterJSON) > 0 {
|
||||
if err := json.Unmarshal(v.FilterJSON, &payload); err != nil {
|
||||
return v, fmt.Errorf("decode default filter_json: %w", err)
|
||||
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"}
|
||||
}
|
||||
}
|
||||
*filter = filterFromJSONPayload(payload)
|
||||
*viewType = v.ViewType
|
||||
return v, nil
|
||||
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
|
||||
}
|
||||
|
||||
// applySavedView resolves a `?view=<uuid>` reference and folds the persisted
|
||||
// filter + view_type back into the supplied TreeFilter + view-type slot.
|
||||
// URL chip params OVERLAY the saved filter — a saved view scoped to
|
||||
// `dev` with `?tag=work` added narrows further. Transient overlays don't
|
||||
// auto-save back to the view (the URL is bookmarkable, but to persist the
|
||||
// drift the user opens /views/<id>/edit).
|
||||
//
|
||||
// Returns the saved view (for chip labelling) or nil when no `?view=` was
|
||||
// given. Errors are logged + returned (handlers can choose to ignore).
|
||||
func (s *Server) applySavedView(r *http.Request, filter *TreeFilter, viewType *string) (*store.View, error) {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("view"))
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
v, err := s.Store.GetView(r.Context(), id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := map[string]any{}
|
||||
if len(v.FilterJSON) > 0 {
|
||||
if err := json.Unmarshal(v.FilterJSON, &payload); err != nil {
|
||||
return v, fmt.Errorf("decode filter_json: %w", err)
|
||||
}
|
||||
}
|
||||
saved := filterFromJSONPayload(payload)
|
||||
saved.ViewID = id
|
||||
q := r.URL.Query()
|
||||
overlayURLFields(&saved, *filter, q)
|
||||
*filter = saved
|
||||
// view_type: URL wins when explicitly set, otherwise the saved value.
|
||||
if strings.TrimSpace(q.Get("view_type")) == "" {
|
||||
*viewType = v.ViewType
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// overlayURLFields lets URL-provided chip values override the saved-view
|
||||
// baseline. The URL filter is the parsed-from-query TreeFilter; q is the
|
||||
// raw url.Values so we can detect "field was set in the URL" distinct from
|
||||
// "field's value happens to equal the zero value".
|
||||
func overlayURLFields(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
|
||||
// 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
|
||||
}
|
||||
@@ -370,47 +457,6 @@ func overlayURLFields(base *TreeFilter, urlFilter TreeFilter, q url.Values) {
|
||||
}
|
||||
}
|
||||
|
||||
// filterFromJSONPayload is the inverse of filterQueryToJSON. Keys absent
|
||||
// from the payload land at their TreeFilter zero value (Status defaults to
|
||||
// ["active"] to match ParseTreeFilter).
|
||||
func filterFromJSONPayload(p map[string]any) TreeFilter {
|
||||
f := TreeFilter{
|
||||
Status: []string{"active"},
|
||||
IncludeDescendants: true,
|
||||
}
|
||||
if v, ok := p["q"].(string); ok {
|
||||
f.Q = v
|
||||
}
|
||||
if v, ok := p["tags"].([]any); ok {
|
||||
f.Tags = anySliceToStrings(v)
|
||||
}
|
||||
if v, ok := p["management"].([]any); ok {
|
||||
f.Management = anySliceToStrings(v)
|
||||
}
|
||||
if v, ok := p["status"].([]any); ok {
|
||||
f.Status = anySliceToStrings(v)
|
||||
if len(f.Status) == 0 {
|
||||
f.Status = []string{"active"}
|
||||
}
|
||||
}
|
||||
if v, ok := p["has_links"].([]any); ok {
|
||||
f.HasLinks = anySliceToStrings(v)
|
||||
}
|
||||
if v, ok := p["public"].(bool); ok {
|
||||
f.Public = &v
|
||||
}
|
||||
if v, ok := p["show_archived"].(bool); ok && v {
|
||||
f.ShowArchived = true
|
||||
}
|
||||
if v, ok := p["project_path"].(string); ok {
|
||||
f.ProjectPath = v
|
||||
}
|
||||
if v, ok := p["include_descendants"].(bool); ok {
|
||||
f.IncludeDescendants = v
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func anySliceToStrings(in []any) []string {
|
||||
out := make([]string, 0, len(in))
|
||||
for _, v := range in {
|
||||
|
||||
@@ -2,183 +2,152 @@ package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestViewsCRUDRoundTrip covers create → list → open (redirect to scoped page) →
|
||||
// delete, end-to-end. Requires DB. Slice D — projax.views table CRUD.
|
||||
func TestViewsCRUDRoundTrip(t *testing.T) {
|
||||
// TestViewsLandingOnboarding asserts that GET /views with no views and no
|
||||
// MRU renders the onboarding shell ("No saved views yet" + "+ New view").
|
||||
func TestViewsLandingOnboarding(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
name := "p5i-D-view-" + stamp
|
||||
|
||||
defer pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
|
||||
|
||||
// Create.
|
||||
form := url.Values{}
|
||||
form.Set("name", name)
|
||||
form.Set("view_type", "card")
|
||||
form.Set("filter_query", "tag=work&mgmt=mai")
|
||||
code, _ := post(t, h, "/views", form)
|
||||
if code != 303 {
|
||||
t.Fatalf("POST /views status=%d, want 303", code)
|
||||
// Clear any leftover touched views from prior runs so the MRU 302
|
||||
// doesn't fire and steal the response.
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET last_used_at = NULL`); err != nil {
|
||||
t.Fatalf("reset mru: %v", err)
|
||||
}
|
||||
// Also clear ALL views so the onboarding shell renders (othewise the
|
||||
// landing still ListViews-displays them).
|
||||
if _, err := pool.Exec(context.Background(), `DELETE FROM projax.views`); err != nil {
|
||||
t.Fatalf("clear views: %v", err)
|
||||
}
|
||||
|
||||
// List page lists the new view.
|
||||
code, body := get(t, h, "/views")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /views status=%d", code)
|
||||
t.Fatalf("GET /views status=%d body=%q", code, body)
|
||||
}
|
||||
if !strings.Contains(body, name) {
|
||||
t.Errorf("GET /views body missing %q", name)
|
||||
if !strings.Contains(body, "No saved views yet") {
|
||||
t.Error("onboarding shell should surface the no-views nudge")
|
||||
}
|
||||
|
||||
// Fetch row to grab the id (and validate filter_json round-trip).
|
||||
var (
|
||||
id string
|
||||
filterJSON []byte
|
||||
viewType string
|
||||
)
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT id, filter_json, view_type FROM projax.views WHERE name=$1 AND deleted_at IS NULL`,
|
||||
name,
|
||||
).Scan(&id, &filterJSON, &viewType); err != nil {
|
||||
t.Fatalf("fetch row: %v", err)
|
||||
}
|
||||
if viewType != "card" {
|
||||
t.Errorf("view_type = %q, want 'card'", viewType)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(filterJSON, &payload); err != nil {
|
||||
t.Fatalf("filter_json unmarshal: %v", err)
|
||||
}
|
||||
if got, _ := payload["tags"].([]any); len(got) != 1 || got[0] != "work" {
|
||||
t.Errorf("filter_json tags = %v, want [work]", payload["tags"])
|
||||
}
|
||||
if got, _ := payload["management"].([]any); len(got) != 1 || got[0] != "mai" {
|
||||
t.Errorf("filter_json management = %v, want [mai]", payload["management"])
|
||||
}
|
||||
|
||||
// GET /views/<id> redirects to the right page with ?view=<id>.
|
||||
code, _ = get(t, h, "/views/"+id)
|
||||
if code != 303 {
|
||||
t.Errorf("GET /views/<id> status=%d, want 303 redirect", code)
|
||||
}
|
||||
|
||||
// Soft delete.
|
||||
code, _ = post(t, h, "/views/"+id+"/delete", url.Values{})
|
||||
if code != 303 {
|
||||
t.Errorf("POST delete status=%d, want 303", code)
|
||||
}
|
||||
var deletedAt *time.Time
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT deleted_at FROM projax.views WHERE id=$1`, id,
|
||||
).Scan(&deletedAt); err != nil {
|
||||
t.Fatalf("post-delete read: %v", err)
|
||||
}
|
||||
if deletedAt == nil {
|
||||
t.Error("expected deleted_at to be set after POST /views/<id>/delete")
|
||||
if !strings.Contains(body, `href="/views/new"`) {
|
||||
t.Error("onboarding shell should link to /views/new")
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewEditFlow exercises the fix for m's bug "we cant edit views yet".
|
||||
// GET /views/<id>/edit renders the pre-filled form; POST /views/<id> updates
|
||||
// the row in place. Verifies name + view_type + filter_json round-trip.
|
||||
func TestViewEditFlow(t *testing.T) {
|
||||
// TestViewsLandingMRURedirects asserts that GET /views 302s to the most
|
||||
// recently used view when one exists.
|
||||
func TestViewsLandingMRURedirects(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
name := "p5i-fix-edit-" + stamp
|
||||
defer pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL OR name = $2`,
|
||||
name, name+"-renamed")
|
||||
slug := "p5j-b-landing-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
// Seed + touch.
|
||||
if _, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO projax.views (slug, name, filter_json, last_used_at)
|
||||
VALUES ($1, 'P5j B Landing', '{"view_type":"list"}'::jsonb, now())`, slug); err != nil {
|
||||
t.Fatalf("seed view: %v", err)
|
||||
}
|
||||
code, body := get(t, h, "/views")
|
||||
if code != 302 {
|
||||
t.Errorf("GET /views status=%d (want 302 to MRU); body=%q", code, body)
|
||||
}
|
||||
}
|
||||
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projax.views (name, view_type, filter_json)
|
||||
VALUES ($1, 'list', $2::jsonb)
|
||||
RETURNING id`, name, []byte(`{"tags":["dev"]}`)).Scan(&id); err != nil {
|
||||
// TestViewRenderShowsSavedView asserts that GET /views/{slug} renders the
|
||||
// view's name + slug in the header and the tree-section body.
|
||||
func TestViewRenderShowsSavedView(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-b-render-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
if _, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO projax.views (slug, name, filter_json)
|
||||
VALUES ($1, 'P5j B Render', '{"view_type":"card"}'::jsonb)`, slug); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// GET /views/<id>/edit renders the pre-filled form (not the redirect).
|
||||
code, body := get(t, h, "/views/"+id+"/edit")
|
||||
code, body := get(t, h, "/views/"+slug)
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /views/<id>/edit status=%d, want 200", code)
|
||||
t.Fatalf("GET /views/<slug> status=%d body=%q", code, body)
|
||||
}
|
||||
if !strings.Contains(body, `value="`+name+`"`) {
|
||||
t.Error("edit form should pre-fill the name input")
|
||||
if !strings.Contains(body, "P5j B Render") {
|
||||
t.Error("render should surface the view's name")
|
||||
}
|
||||
if !strings.Contains(body, `value="tag=dev"`) {
|
||||
t.Error("edit form should pre-fill filter_query from filter_json")
|
||||
if !strings.Contains(body, `/views/`+slug) {
|
||||
t.Error("render should surface the view's slug in the header")
|
||||
}
|
||||
|
||||
// Index page now shows an edit link per row.
|
||||
_, idx := get(t, h, "/views")
|
||||
if !strings.Contains(idx, `/views/`+id+`/edit`) {
|
||||
t.Error("/views should expose an edit link per row")
|
||||
}
|
||||
|
||||
// POST /views/<id> updates the row.
|
||||
form := url.Values{}
|
||||
form.Set("name", name+"-renamed")
|
||||
form.Set("view_type", "card")
|
||||
form.Set("filter_query", "tag=work&mgmt=mai")
|
||||
code, _ = post(t, h, "/views/"+id, form)
|
||||
if code != 303 {
|
||||
t.Fatalf("POST /views/<id> status=%d, want 303", code)
|
||||
}
|
||||
|
||||
var newName, newType string
|
||||
var newFilter []byte
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT name, view_type, filter_json FROM projax.views WHERE id = $1`, id,
|
||||
).Scan(&newName, &newType, &newFilter); err != nil {
|
||||
t.Fatalf("post-update read: %v", err)
|
||||
}
|
||||
if newName != name+"-renamed" {
|
||||
t.Errorf("name = %q, want %q", newName, name+"-renamed")
|
||||
}
|
||||
if newType != "card" {
|
||||
t.Errorf("view_type = %q, want 'card'", newType)
|
||||
}
|
||||
payload := map[string]any{}
|
||||
_ = json.Unmarshal(newFilter, &payload)
|
||||
tags, _ := payload["tags"].([]any)
|
||||
if len(tags) != 1 || tags[0] != "work" {
|
||||
t.Errorf("filter_json tags = %v, want [work] post-update", payload["tags"])
|
||||
if !strings.Contains(body, `class="tree-card-grid"`) {
|
||||
t.Error("view_type=card should render the card grid")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavedViewPageFilterApply exercises the fix for m's bug "the filters on
|
||||
// custom views dont seem to work". A request to /?view=<id>&tag=work narrows
|
||||
// the saved view further by overlaying the URL chip onto the persisted
|
||||
// filter_json. Previously the saved filter clobbered the URL chips
|
||||
// wholesale.
|
||||
func TestSavedViewPageFilterApply(t *testing.T) {
|
||||
// TestViewRender404OnUnknownSlug — an unknown slug returns 404, not a
|
||||
// silent fallback to the tree.
|
||||
func TestViewRender404OnUnknownSlug(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
code, _ := get(t, h, "/views/this-slug-does-not-exist-anywhere-9876")
|
||||
if code != 404 {
|
||||
t.Errorf("unknown slug should 404, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestViewCreateAndDelete — POST /views creates; POST /views/<slug>/delete
|
||||
// removes. Verifies the slug-format error path too.
|
||||
func TestViewCreateAndDelete(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
slug := "p5j-b-crud-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("slug", slug)
|
||||
form.Set("name", "P5j B CRUD")
|
||||
form.Set("view_type", "list")
|
||||
form.Set("filter_query", "tag=work")
|
||||
code, _ := post(t, h, "/views", form)
|
||||
if code != 303 {
|
||||
t.Fatalf("create status=%d want 303", code)
|
||||
}
|
||||
|
||||
// Reserved-slug 400.
|
||||
form2 := url.Values{}
|
||||
form2.Set("slug", "dashboard")
|
||||
form2.Set("name", "Should be rejected")
|
||||
form2.Set("view_type", "list")
|
||||
code, body := post(t, h, "/views", form2)
|
||||
if code != 400 {
|
||||
t.Errorf("reserved-slug create should 400, got %d body=%q", code, body)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
code, _ = post(t, h, "/views/"+slug+"/delete", url.Values{})
|
||||
if code != 303 {
|
||||
t.Errorf("delete status=%d want 303", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavedViewFilterOverlay — chip params on /views/<slug>?tag=x narrow
|
||||
// the saved filter. Verifies the slice B render-path overlay.
|
||||
func TestSavedViewFilterOverlay(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
name := "p5i-fix-overlay-" + stamp
|
||||
devSlug := "p5i-fix-overlay-d-" + stamp
|
||||
homeSlug := "p5i-fix-overlay-h-" + stamp
|
||||
|
||||
defer pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
|
||||
slug := "p5j-b-overlay-" + stamp
|
||||
devSlug := "p5j-b-overlay-d-" + stamp
|
||||
homeSlug := "p5j-b-overlay-h-" + stamp
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
||||
|
||||
var dev, home string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
@@ -190,138 +159,39 @@ func TestSavedViewPageFilterApply(t *testing.T) {
|
||||
var devID, homeID string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
|
||||
VALUES (array['project']::text[], 'Fix Dev', $1, ARRAY[$2]::uuid[], ARRAY['work'])
|
||||
VALUES (array['project']::text[], 'P5jB Dev', $1, ARRAY[$2]::uuid[], ARRAY['work'])
|
||||
RETURNING id`, devSlug, dev).Scan(&devID); err != nil {
|
||||
t.Fatalf("seed dev item: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
|
||||
VALUES (array['project']::text[], 'Fix Home', $1, ARRAY[$2]::uuid[], ARRAY['home'])
|
||||
VALUES (array['project']::text[], 'P5jB Home', $1, ARRAY[$2]::uuid[], ARRAY['home'])
|
||||
RETURNING id`, homeSlug, home).Scan(&homeID); err != nil {
|
||||
t.Fatalf("seed home item: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1,$2)`, devID, homeID)
|
||||
defer pool.Exec(context.Background(), `DELETE FROM projax.items WHERE id IN ($1,$2)`, devID, homeID)
|
||||
|
||||
// Saved view with view_type=list and NO tag filter — both items should pass.
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projax.views (name, view_type, filter_json)
|
||||
VALUES ($1, 'list', '{}'::jsonb)
|
||||
RETURNING id`, name).Scan(&id); err != nil {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO projax.views (slug, name, filter_json)
|
||||
VALUES ($1, 'P5jB Overlay', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
|
||||
t.Fatalf("seed view: %v", err)
|
||||
}
|
||||
|
||||
devLink := `href="/i/dev.` + devSlug + `"`
|
||||
homeLink := `href="/i/home.` + homeSlug + `"`
|
||||
|
||||
// Open view alone — both rows should appear.
|
||||
_, baseBody := get(t, h, "/?view="+id)
|
||||
if !strings.Contains(baseBody, devLink) {
|
||||
t.Error("saved view without tag filter should show dev row")
|
||||
_, base := get(t, h, "/views/"+slug)
|
||||
if !strings.Contains(base, devLink) {
|
||||
t.Error("saved view without tag should show dev row")
|
||||
}
|
||||
if !strings.Contains(baseBody, homeLink) {
|
||||
t.Error("saved view without tag filter should show home row")
|
||||
if !strings.Contains(base, homeLink) {
|
||||
t.Error("saved view without tag should show home row")
|
||||
}
|
||||
|
||||
// Overlay ?tag=work — home row should disappear; dev should remain.
|
||||
_, narrowedBody := get(t, h, "/?view="+id+"&tag=work")
|
||||
if !strings.Contains(narrowedBody, devLink) {
|
||||
t.Error("?view=<id>&tag=work should still show dev row (work-tagged)")
|
||||
_, narrowed := get(t, h, "/views/"+slug+"?tag=work")
|
||||
if !strings.Contains(narrowed, devLink) {
|
||||
t.Error("URL chip tag=work should keep dev (work-tagged)")
|
||||
}
|
||||
if strings.Contains(narrowedBody, homeLink) {
|
||||
t.Error("?view=<id>&tag=work should hide home row — URL chip must overlay saved filter")
|
||||
}
|
||||
|
||||
// Chip URLs inside the saved view must round-trip the view= param so
|
||||
// chip clicks don't strip the saved view.
|
||||
if !strings.Contains(narrowedBody, "view="+id) {
|
||||
t.Error("chip URLs inside a saved view should carry view=<id> forward")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultViewAppliedOnCleanURL verifies the Slice E behaviour: when /
|
||||
// is requested with no chip params and a default view exists for the page,
|
||||
// the saved filter + view_type apply and a "Showing default view: …"
|
||||
// banner renders. Adding any chip param (?tag=…) bypasses the default.
|
||||
// ?nodefault=1 is the explicit opt-out.
|
||||
func TestDefaultViewAppliedOnCleanURL(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
name := "p5i-E-default-" + stamp
|
||||
defer pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO projax.views (name, view_type, filter_json, is_default_for)
|
||||
VALUES ($1, 'card', $2::jsonb, 'tree')`,
|
||||
name, []byte(`{"tags":["work"]}`)); err != nil {
|
||||
t.Fatalf("seed default view: %v", err)
|
||||
}
|
||||
|
||||
// Clean URL: default applies → card view + banner.
|
||||
_, body := get(t, h, "/")
|
||||
if !strings.Contains(body, `class="tree-card-grid"`) {
|
||||
t.Error("clean / should auto-apply default view (card grid expected)")
|
||||
}
|
||||
if !strings.Contains(body, `default-banner`) {
|
||||
t.Error("default-banner should render when a default applies")
|
||||
}
|
||||
if !strings.Contains(body, name) {
|
||||
t.Error("banner should name the applied default view")
|
||||
}
|
||||
|
||||
// Any chip param bypasses the default → list view (no banner).
|
||||
_, withChip := get(t, h, "/?tag=dev")
|
||||
if strings.Contains(withChip, `default-banner`) {
|
||||
t.Error("default banner should disappear once user types a chip")
|
||||
}
|
||||
if !strings.Contains(withChip, `<ul class="forest">`) {
|
||||
t.Error("?tag=dev should render the forest (default not applied)")
|
||||
}
|
||||
|
||||
// Explicit opt-out via ?nodefault=1.
|
||||
_, optOut := get(t, h, "/?nodefault=1")
|
||||
if strings.Contains(optOut, `default-banner`) {
|
||||
t.Error("?nodefault=1 should suppress the default banner")
|
||||
}
|
||||
if !strings.Contains(optOut, `<ul class="forest">`) {
|
||||
t.Error("?nodefault=1 should render the forest (default suppressed)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSavedViewAppliedOnQueryParam verifies that opening / with ?view=<uuid>
|
||||
// re-applies the saved filter+view_type. We seed a view tagged work=patents
|
||||
// and assert the rendered tree has the right ProjectChip / chip-on state.
|
||||
func TestSavedViewAppliedOnQueryParam(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx := context.Background()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
name := "p5i-D-saved-" + stamp
|
||||
defer pool.Exec(context.Background(),
|
||||
`UPDATE projax.views SET deleted_at = now() WHERE name = $1 AND deleted_at IS NULL`, name)
|
||||
|
||||
// Seed directly via SQL so the assertion focuses on the resolver, not the
|
||||
// form flow tested above.
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO projax.views (name, view_type, filter_json)
|
||||
VALUES ($1, 'card', $2::jsonb)
|
||||
RETURNING id`, name, []byte(`{"project_path":"dev","include_descendants":true}`)).Scan(&id); err != nil {
|
||||
t.Fatalf("seed view: %v", err)
|
||||
}
|
||||
|
||||
_, body := get(t, h, "/?view="+id)
|
||||
if !strings.Contains(body, `class="tree-card-grid"`) {
|
||||
t.Error("?view= should override view_type → card view should render")
|
||||
}
|
||||
if !strings.Contains(body, `class="proj-chip chip-on"`) {
|
||||
t.Error("?view= should apply project filter chip → proj-chip should be on")
|
||||
if strings.Contains(narrowed, homeLink) {
|
||||
t.Error("URL chip tag=work should hide home")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user