Compare commits
19 Commits
mai/kahn/p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fdeca8269 | |||
| 9607d4b307 | |||
| 38182df651 | |||
| 2702c699d1 | |||
| a5b0971b9d | |||
| b3e7183478 | |||
| a44edf3917 | |||
| df83ab7255 | |||
| 4918f48b51 | |||
| 0ad610d018 | |||
| a9f062a67e | |||
| 731f443569 | |||
| 157c4e659b | |||
| 547d6f77f6 | |||
| 788479c6cb | |||
| a0d6217ebf | |||
| 311cf943bc | |||
| abb329a686 | |||
| b15c222727 |
@@ -55,9 +55,14 @@ type Todo struct {
|
||||
Due *time.Time
|
||||
Priority int
|
||||
LastModified *time.Time
|
||||
URL string // absolute URL of the .ics resource on the server
|
||||
ETag string // server-issued ETag; pass to PutTodo/DeleteTodo as If-Match
|
||||
Raw string // raw VCALENDAR ICS as returned by the server, preserved for in-place edits
|
||||
// Categories carries the RFC 5545 CATEGORIES property as a flat
|
||||
// slice (already comma-split, trimmed). Phase 5j uses entries
|
||||
// prefixed `projax:<primary-path>` to tag VTODOs to projax items —
|
||||
// see HasProjaxTag + ProjaxCategoryFor in this package.
|
||||
Categories []string
|
||||
URL string // absolute URL of the .ics resource on the server
|
||||
ETag string // server-issued ETag; pass to PutTodo/DeleteTodo as If-Match
|
||||
Raw string // raw VCALENDAR ICS as returned by the server, preserved for in-place edits
|
||||
}
|
||||
|
||||
// Event is one VEVENT returned by ListEvents. Phase 3l: read-only, no
|
||||
|
||||
@@ -54,11 +54,70 @@ func parseVTodos(ics string) []Todo {
|
||||
if t, ok := parseICalTime(val); ok {
|
||||
cur.LastModified = &t
|
||||
}
|
||||
case "CATEGORIES":
|
||||
// CATEGORIES is comma-separated per RFC 5545. Some clients emit
|
||||
// multiple CATEGORIES lines; we merge by appending. The unescape
|
||||
// is per-entry because commas inside a category value MUST be
|
||||
// escaped (`\,`), so we split on bare commas only after unescape.
|
||||
for _, raw := range strings.Split(val, ",") {
|
||||
t := strings.TrimSpace(unescapeText(raw))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
cur.Categories = append(cur.Categories, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ProjaxCategoryFor returns the projax-namespaced CATEGORIES entry for
|
||||
// the given primary-path (e.g. "projax:admin.vacations.greece"). Used by
|
||||
// both the write side (tag-on-create) and the read side (per-item filter).
|
||||
func ProjaxCategoryFor(primaryPath string) string {
|
||||
return "projax:" + primaryPath
|
||||
}
|
||||
|
||||
// HasProjaxTag reports whether the VTODO carries any `projax:` category.
|
||||
// Used to decide whether the per-item filter kicks in: a list with at
|
||||
// least one projax: tag is "managed" by projax and the detail page only
|
||||
// shows todos matching THIS item's path; a list with zero projax: tags
|
||||
// is a legacy/unmanaged list and the detail page shows everything.
|
||||
func HasProjaxTag(t Todo) bool {
|
||||
for _, c := range t.Categories {
|
||||
if strings.HasPrefix(c, "projax:") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasProjaxTagFor reports whether the VTODO carries the specific
|
||||
// `projax:<primaryPath>` category. A todo can carry multiple projax: tags
|
||||
// (when it belongs to multiple projax items) — any match returns true.
|
||||
func HasProjaxTagFor(t Todo, primaryPath string) bool {
|
||||
want := ProjaxCategoryFor(primaryPath)
|
||||
for _, c := range t.Categories {
|
||||
if c == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AnyTodoHasProjaxTag reports whether the slice contains at least one
|
||||
// projax-tagged VTODO. The detail page uses this to decide between the
|
||||
// projax-managed filter (show only matching) and the legacy unmanaged
|
||||
// path (show all).
|
||||
func AnyTodoHasProjaxTag(todos []Todo) bool {
|
||||
for _, t := range todos {
|
||||
if HasProjaxTag(t) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseVEvents extracts every VEVENT block from a calendar-data string.
|
||||
// Mirrors parseVTodos but for read-only event listing (no writeback). DTSTART
|
||||
// with VALUE=DATE marks the event all-day; the parser inspects the raw line
|
||||
@@ -296,6 +355,13 @@ type VTodoEdit struct {
|
||||
Due *time.Time
|
||||
ClearDue bool
|
||||
Priority *int
|
||||
// Categories: optional CATEGORIES list. BuildVTodoICS writes them
|
||||
// directly on a fresh VTODO. ApplyVTodoEdit intentionally ignores
|
||||
// this field — existing categories pass through unchanged via the
|
||||
// unknown-property preserve path, which is what every edit/complete/
|
||||
// delete flow wants. Tag-on-create is the only write path that
|
||||
// uses it.
|
||||
Categories []string
|
||||
}
|
||||
|
||||
// BuildVTodoICS serialises a fresh VTODO as a complete VCALENDAR document,
|
||||
@@ -336,6 +402,15 @@ func BuildVTodoICS(uid string, e VTodoEdit) string {
|
||||
if e.Priority != nil {
|
||||
lines = append(lines, fmt.Sprintf("PRIORITY:%d", *e.Priority))
|
||||
}
|
||||
if len(e.Categories) > 0 {
|
||||
// RFC 5545 CATEGORIES — comma-separated, single line. Escape commas
|
||||
// inside individual entries so the round-trip survives parseVTodos.
|
||||
escaped := make([]string, 0, len(e.Categories))
|
||||
for _, c := range e.Categories {
|
||||
escaped = append(escaped, escapeText(c))
|
||||
}
|
||||
lines = append(lines, "CATEGORIES:"+strings.Join(escaped, ","))
|
||||
}
|
||||
lines = append(lines, "END:VTODO", "END:VCALENDAR")
|
||||
return joinICS(lines)
|
||||
}
|
||||
|
||||
114
caldav/projax_tags_test.go
Normal file
114
caldav/projax_tags_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package caldav
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestProjaxCategoryFor pins the tag string format. The format is part
|
||||
// of the projax↔CalDAV contract — `projax:<primary-path>` — and other
|
||||
// tooling (admin triage, future migration scripts) will rely on the
|
||||
// prefix. A typo here silently breaks the per-item filter.
|
||||
func TestProjaxCategoryFor(t *testing.T) {
|
||||
got := ProjaxCategoryFor("admin.vacations.greece")
|
||||
want := "projax:admin.vacations.greece"
|
||||
if got != want {
|
||||
t.Errorf("ProjaxCategoryFor = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHasProjaxTagAndFor exercises the two read-side helpers that drive
|
||||
// the per-item filter on the detail page: HasProjaxTag (any projax: tag
|
||||
// at all) and HasProjaxTagFor (matches THIS path).
|
||||
func TestHasProjaxTagAndFor(t *testing.T) {
|
||||
tagged := Todo{Categories: []string{"home", "projax:admin.vacations.greece", "errands"}}
|
||||
if !HasProjaxTag(tagged) {
|
||||
t.Errorf("HasProjaxTag should fire for any projax: category")
|
||||
}
|
||||
if !HasProjaxTagFor(tagged, "admin.vacations.greece") {
|
||||
t.Errorf("HasProjaxTagFor should match exact projax:<path>")
|
||||
}
|
||||
if HasProjaxTagFor(tagged, "admin.vacations.spain") {
|
||||
t.Errorf("HasProjaxTagFor should NOT match a different path")
|
||||
}
|
||||
|
||||
multi := Todo{Categories: []string{"projax:work.proj1", "projax:work.proj2"}}
|
||||
if !HasProjaxTagFor(multi, "work.proj1") {
|
||||
t.Errorf("multi-tag todo should match first projax: tag")
|
||||
}
|
||||
if !HasProjaxTagFor(multi, "work.proj2") {
|
||||
t.Errorf("multi-tag todo should match second projax: tag")
|
||||
}
|
||||
|
||||
untagged := Todo{Categories: []string{"home", "errands"}}
|
||||
if HasProjaxTag(untagged) {
|
||||
t.Errorf("HasProjaxTag should be false on a no-projax: list")
|
||||
}
|
||||
if HasProjaxTagFor(untagged, "anything") {
|
||||
t.Errorf("HasProjaxTagFor must be false when no projax: tag exists")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnyTodoHasProjaxTag drives the list-level managed-vs-legacy
|
||||
// decision in detailTodos. Untagged lists keep their pre-5j show-all
|
||||
// behaviour; one tagged todo flips the entire list into managed mode.
|
||||
func TestAnyTodoHasProjaxTag(t *testing.T) {
|
||||
none := []Todo{
|
||||
{Categories: []string{"home"}},
|
||||
{Categories: nil},
|
||||
}
|
||||
if AnyTodoHasProjaxTag(none) {
|
||||
t.Errorf("untagged list should NOT be projax-managed")
|
||||
}
|
||||
mixed := []Todo{
|
||||
{Categories: []string{"home"}},
|
||||
{Categories: []string{"projax:admin.vacations.greece"}},
|
||||
}
|
||||
if !AnyTodoHasProjaxTag(mixed) {
|
||||
t.Errorf("list with one projax-tagged todo should be projax-managed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildVTodoICSEmitsCategories proves the tag-on-create path. The
|
||||
// Phase 5j write side passes Categories into VTodoEdit; BuildVTodoICS
|
||||
// must render the CATEGORIES line so the server-side round-trip
|
||||
// (parseVTodos picks it back up) carries the tag through.
|
||||
func TestBuildVTodoICSEmitsCategories(t *testing.T) {
|
||||
summary := "Buy gear"
|
||||
ics := BuildVTodoICS("uid-tagged", VTodoEdit{
|
||||
Summary: &summary,
|
||||
Categories: []string{"projax:admin.vacations.greece"},
|
||||
})
|
||||
if !strings.Contains(ics, "CATEGORIES:projax:admin.vacations.greece") {
|
||||
t.Errorf("BuildVTodoICS should emit CATEGORIES line, got:\n%s", ics)
|
||||
}
|
||||
// Round-trip: parse it back, the Categories slice must be populated.
|
||||
todos := parseVTodos(ics)
|
||||
if len(todos) != 1 {
|
||||
t.Fatalf("parseVTodos round-trip expected 1 todo, got %d", len(todos))
|
||||
}
|
||||
if !HasProjaxTagFor(todos[0], "admin.vacations.greece") {
|
||||
t.Errorf("round-trip lost CATEGORIES: %#v", todos[0].Categories)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseVTodosMultiCategory proves the parser handles RFC 5545
|
||||
// comma-separated CATEGORIES correctly (a single CATEGORIES line with
|
||||
// multiple values, not multiple CATEGORIES lines). This is the wire
|
||||
// shape Apple Calendar + Thunderbird + Radicale all emit.
|
||||
func TestParseVTodosMultiCategory(t *testing.T) {
|
||||
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:multi\r\nSUMMARY:Multi\r\nSTATUS:NEEDS-ACTION\r\nCATEGORIES:home,projax:admin.vacations.greece,projax:work.someproj,errands\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
||||
todos := parseVTodos(ics)
|
||||
if len(todos) != 1 {
|
||||
t.Fatalf("expected 1 todo, got %d", len(todos))
|
||||
}
|
||||
want := []string{"home", "projax:admin.vacations.greece", "projax:work.someproj", "errands"}
|
||||
if len(todos[0].Categories) != len(want) {
|
||||
t.Fatalf("Categories = %v, want %v", todos[0].Categories, want)
|
||||
}
|
||||
for i, c := range todos[0].Categories {
|
||||
if c != want[i] {
|
||||
t.Errorf("Categories[%d] = %q, want %q", i, c, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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`).
|
||||
158
store/adapter.go
Normal file
158
store/adapter.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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 prep — see docs/plans/slice-b-adapter-contract.md.
|
||||
// The existing *Store already satisfies this interface (the compile-time
|
||||
// assertion below pins that). Slice B impl ships a second satisfier
|
||||
// (MBrianReader) once m/mBrian#73's migration completes and hands the
|
||||
// uuid map over.
|
||||
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)
|
||||
|
||||
// errNotImplementedSliceB is the placeholder return from every method on
|
||||
// the slice-B-prep stub. Slice B's impl replaces each return with the
|
||||
// real mBrian-backed body.
|
||||
var errNotImplementedSliceB = errors.New("not implemented: Phase 6 Slice B (mBrian-backed reader) — waits on m/mBrian#73 migration")
|
||||
|
||||
// MBrianReader is the slice-B implementation target. Every method body
|
||||
// returns errNotImplementedSliceB during prep; slice B's coder fills
|
||||
// each in once the migration completes and the uuid map lands. The type
|
||||
// holds no mBrian client today — the client decision (MCP-over-stdio /
|
||||
// direct pgxpool against mbrian.* / in-process submodule) is the first
|
||||
// thing slice B's impl chooses, then this struct grows the
|
||||
// corresponding field.
|
||||
//
|
||||
// Per-method comments name the §3 gaps in the contract doc each method
|
||||
// will need to resolve at impl time.
|
||||
type MBrianReader struct {
|
||||
// Reserved for slice-B's mBrian client. Empty struct today so the
|
||||
// compile-time assertion below stays meaningful.
|
||||
}
|
||||
|
||||
// Compile-time assertion that MBrianReader satisfies ItemReader. Keeps
|
||||
// the stubs in lockstep with the interface as slice B grows methods.
|
||||
var _ ItemReader = (*MBrianReader)(nil)
|
||||
|
||||
// --- item lookups ---
|
||||
|
||||
// ListAll: §2.1 — edge-walk for Item.Paths + Item.ParentIDs per item;
|
||||
// §2.2 — metadata-unpack across all returned items; §3 — Item.Source
|
||||
// constant.
|
||||
func (*MBrianReader) ListAll(ctx context.Context) ([]*Item, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// GetByID: §2.2 metadata-unpack.
|
||||
func (*MBrianReader) GetByID(ctx context.Context, id string) (*Item, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// GetByPath: §2.1 — walks child_of edges from path's root segment to
|
||||
// resolve a leaf node. Per-request cache reduces N+1.
|
||||
func (*MBrianReader) GetByPath(ctx context.Context, path string) (*Item, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// GetByPathOrSlug: GetByPath, fall back to slug lookup.
|
||||
func (*MBrianReader) GetByPathOrSlug(ctx context.Context, key string) (*Item, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// Roots: §2.1 — "no outbound child_of edge" predicate.
|
||||
func (*MBrianReader) Roots(ctx context.Context) ([]*Item, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// MaiOrphans: §2.1 — Roots ∩ metadata.projax.management ⊇ {'mai'}.
|
||||
func (*MBrianReader) MaiOrphans(ctx context.Context) ([]*Item, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// ListByFilters: structured search; status/management/has-link/paths-prefix
|
||||
// dimensions map to metadata.projax.* predicates + edge existence checks.
|
||||
func (*MBrianReader) ListByFilters(ctx context.Context, f SearchFilters) ([]*Item, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// Search: mBrian already has trigram + FTS on title + content_md
|
||||
// (idx_nodes_fts). Adapter narrows to projax-managed nodes.
|
||||
func (*MBrianReader) Search(ctx context.Context, q string, limit int) ([]*Item, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// ItemsCreatedInRange: direct over nodes.created_at, scoped to
|
||||
// metadata.projax_origin IS NOT NULL (or whatever projax-managed marker
|
||||
// the migration settles on).
|
||||
func (*MBrianReader) ItemsCreatedInRange(ctx context.Context, from, to time.Time) ([]*Item, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// AllTags: §3 — union over metadata.projax.tags[]. Full-scan at m's
|
||||
// scale; tag-graph deferred to Phase 7 (m's Q8).
|
||||
func (*MBrianReader) AllTags(ctx context.Context) ([]string, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// --- link lookups ---
|
||||
|
||||
// LinksByType: §2.3 — WHERE source_id=$1 AND rel='projax-'||$2.
|
||||
func (*MBrianReader) LinksByType(ctx context.Context, itemID, refType string) ([]*ItemLink, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// LinksByRefType: §2.3 — WHERE rel='projax-'||$1.
|
||||
func (*MBrianReader) LinksByRefType(ctx context.Context, refType string) ([]*ItemLink, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// DatedLinks: §2.3 — one item's edges with metadata ? 'event_date'.
|
||||
func (*MBrianReader) DatedLinks(ctx context.Context, itemID string) ([]*ItemLink, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// DatedLinksRange: §2.3 — metadata->>'event_date' BETWEEN $1 AND $2,
|
||||
// joined with source node for the ItemLinkWithItem shape.
|
||||
func (*MBrianReader) DatedLinksRange(ctx context.Context, from, to time.Time) ([]*ItemLinkWithItem, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
|
||||
// RecentDocuments: dated links since $1 ORDER BY event_date DESC LIMIT $2.
|
||||
func (*MBrianReader) RecentDocuments(ctx context.Context, since time.Time, limit int) ([]*ItemLinkWithItem, error) {
|
||||
return nil, errNotImplementedSliceB
|
||||
}
|
||||
23
web/bulk.go
23
web/bulk.go
@@ -118,6 +118,29 @@ func bulkMatches(f TreeFilter, it *store.Item, itemLinkKinds map[string]struct{}
|
||||
return false
|
||||
}
|
||||
}
|
||||
// Phase 5i Slice A: project scope. Same predicate as TreeFilter.Matches —
|
||||
// at least one of the item's paths must equal ProjectPath, with the
|
||||
// IncludeDescendants toggle gating the prefix-match for the subtree.
|
||||
// bulkMatches was a near-clone of Matches() that wasn't updated when
|
||||
// the project dim landed, so /admin/bulk silently ignored ?project=…
|
||||
// (and the chip's hidden-input round-trip too).
|
||||
if f.ProjectPath != "" {
|
||||
prefix := f.ProjectPath + "."
|
||||
hit := false
|
||||
for _, p := range it.Paths {
|
||||
if p == f.ProjectPath {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
if f.IncludeDescendants && strings.HasPrefix(p, prefix) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Q != "" {
|
||||
q := strings.ToLower(f.Q)
|
||||
hit := strings.Contains(strings.ToLower(it.Title), q) ||
|
||||
|
||||
136
web/caldav.go
136
web/caldav.go
@@ -151,6 +151,93 @@ func (s *Server) handleCalDAVUnlink(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin/caldav", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// availableCalendarsForItem returns the discoverable CalDAV calendars
|
||||
// minus the ones already linked to this item — feeds the per-item
|
||||
// "Link existing list" picker on the detail page. Errors during
|
||||
// discovery (network, auth, parse) are surfaced to the caller; callers
|
||||
// downgrade to an empty list so the rest of the page still renders.
|
||||
//
|
||||
// "Already linked" is computed by the caller's `links` slice rather
|
||||
// than a fresh fetch, since handleDetail/renderTasksSection already
|
||||
// loaded the per-item caldav-list links inside detailTodos and we
|
||||
// avoid a second LinksByType round-trip.
|
||||
func (s *Server) availableCalendarsForItem(ctx context.Context, links []*store.ItemLink) ([]caldav.Calendar, error) {
|
||||
if s.CalDAV == nil {
|
||||
return nil, nil
|
||||
}
|
||||
cals, err := s.CalDAV.Client.ListCalendars(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
linkedURLs := map[string]struct{}{}
|
||||
for _, l := range links {
|
||||
linkedURLs[l.RefID] = struct{}{}
|
||||
}
|
||||
out := make([]caldav.Calendar, 0, len(cals))
|
||||
for _, c := range cals {
|
||||
if _, already := linkedURLs[c.URL]; already {
|
||||
continue
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].DisplayName < out[j].DisplayName })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// handleCalDAVLinkExisting handles POST /i/{path}/caldav/link-existing —
|
||||
// the per-item picker for sharing an existing CalDAV list across
|
||||
// multiple projax items. Re-runs ListCalendars to validate that the
|
||||
// submitted URL is genuinely discoverable (defence against a crafted
|
||||
// form pointing at an arbitrary URL), then inserts the item_link.
|
||||
func (s *Server) handleCalDAVLinkExisting(w http.ResponseWriter, r *http.Request, path string) {
|
||||
if s.CalDAV == nil {
|
||||
http.Error(w, "caldav not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.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
|
||||
}
|
||||
calURL := strings.TrimSpace(r.FormValue("calendar_url"))
|
||||
if calURL == "" {
|
||||
http.Error(w, "calendar_url required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Validate the URL is in the discoverable set — a malicious form must
|
||||
// not be able to seed an item_link pointing at arbitrary HTTP servers.
|
||||
cals, err := s.CalDAV.Client.ListCalendars(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
var matched *caldav.Calendar
|
||||
for i := range cals {
|
||||
if cals[i].URL == calURL {
|
||||
matched = &cals[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == nil {
|
||||
http.Error(w, "calendar not in discoverable set", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
meta := map[string]any{
|
||||
"display_name": matched.DisplayName,
|
||||
"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 {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleCalDAVCreate handles POST /i/{path}/caldav/create — MKCALENDAR on
|
||||
// dav.msbls.de derived from the item slug, then the item_link insert.
|
||||
func (s *Server) handleCalDAVCreate(w http.ResponseWriter, r *http.Request, path string) {
|
||||
@@ -231,6 +318,22 @@ func (s *Server) detailTodos(ctx context.Context, item *store.Item) ([]calendarT
|
||||
s.Logger.Warn("caldav todos", "calendar", l.RefID, "err", err)
|
||||
continue
|
||||
}
|
||||
// Phase 5j per-item filter: when the linked list contains ANY
|
||||
// projax-tagged VTODO it's a managed list — narrow to entries
|
||||
// carrying this item's `projax:<path>` tag. A list with zero
|
||||
// projax tags is a legacy/unmanaged list and renders unfiltered
|
||||
// (existing pre-5j behaviour, untouched). The cutoff still
|
||||
// applies to DoneRecent on the post-filter slice.
|
||||
if caldav.AnyTodoHasProjaxTag(todos) {
|
||||
want := item.PrimaryPath()
|
||||
filtered := todos[:0:0]
|
||||
for _, td := range todos {
|
||||
if caldav.HasProjaxTagFor(td, want) {
|
||||
filtered = append(filtered, td)
|
||||
}
|
||||
}
|
||||
todos = filtered
|
||||
}
|
||||
ct := calendarTasks{
|
||||
CalendarURL: l.RefID,
|
||||
DisplayName: linkDisplay(l),
|
||||
@@ -310,7 +413,14 @@ func (s *Server) handleCalDAVTodoAction(w http.ResponseWriter, r *http.Request,
|
||||
banner = "Cannot create task with empty summary."
|
||||
break
|
||||
}
|
||||
edit := caldav.VTodoEdit{Summary: &summary}
|
||||
// Phase 5j tag-on-create: every VTODO created from a per-item Add
|
||||
// form gets `projax:<primary-path>` in CATEGORIES so multiple
|
||||
// projax items can share one CalDAV list and the per-item filter
|
||||
// only surfaces the right ones.
|
||||
edit := caldav.VTodoEdit{
|
||||
Summary: &summary,
|
||||
Categories: []string{caldav.ProjaxCategoryFor(it.PrimaryPath())},
|
||||
}
|
||||
if dueStr := strings.TrimSpace(r.FormValue("due")); dueStr != "" {
|
||||
if t, ok := parseDueInput(dueStr); ok {
|
||||
edit.Due = &t
|
||||
@@ -426,11 +536,27 @@ func (s *Server) renderTasksSection(w http.ResponseWriter, r *http.Request, it *
|
||||
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,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Banner": banner,
|
||||
"Item": it,
|
||||
"Tasks": tasks,
|
||||
"AvailableCalendars": available,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Banner": banner,
|
||||
}
|
||||
s.render(w, r, "tasks_section", data)
|
||||
}
|
||||
|
||||
419
web/caldav_link_existing_test.go
Normal file
419
web/caldav_link_existing_test.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/m/projax/caldav"
|
||||
"github.com/m/projax/web"
|
||||
)
|
||||
|
||||
// fakeCalDAVServer is a minimal in-memory CalDAV server: a PROPFIND on
|
||||
// /dav/calendars/m/ returns a fixed two-calendar list, REPORT on each
|
||||
// calendar returns whichever VTODOs the test seeded into todos[url],
|
||||
// and PUT to a calendar URL captures the body so the test can assert
|
||||
// on what projax wrote. Mirrors the pattern in dashboard_events_test.go
|
||||
// but tailored to the Phase 5j flows.
|
||||
type fakeCalDAVServer struct {
|
||||
mu sync.Mutex
|
||||
srv *httptest.Server
|
||||
calendars []caldav.Calendar
|
||||
todos map[string][]string // calendarURL → list of VTODO ICS docs
|
||||
puts map[string]string // url → body of the latest PUT to that url
|
||||
}
|
||||
|
||||
func newFakeCalDAVServer(t *testing.T, cals []caldav.Calendar) *fakeCalDAVServer {
|
||||
t.Helper()
|
||||
f := &fakeCalDAVServer{
|
||||
todos: map[string][]string{},
|
||||
puts: map[string]string{},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "PROPFIND" {
|
||||
f.mu.Lock()
|
||||
cs := f.calendars
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(207)
|
||||
_, _ = io.WriteString(w, propfindMultistatus(cs))
|
||||
return
|
||||
}
|
||||
http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed)
|
||||
})
|
||||
// Per-calendar handler. Keyed by URL PATH so both the registration
|
||||
// loop and the test's seed lookup (`fake.todos[calURL]`) resolve to
|
||||
// the same map entry regardless of how the httptest host gets baked
|
||||
// into the full URL.
|
||||
for _, c := range cals {
|
||||
path := urlPathOf(c.URL)
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case "REPORT":
|
||||
f.mu.Lock()
|
||||
body := buildReportMultistatus(path, f.todos[path])
|
||||
f.mu.Unlock()
|
||||
w.WriteHeader(207)
|
||||
_, _ = io.WriteString(w, body)
|
||||
case "PUT":
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
f.mu.Lock()
|
||||
f.puts[r.URL.String()] = string(body)
|
||||
f.todos[path] = append(f.todos[path], string(body))
|
||||
f.mu.Unlock()
|
||||
w.Header().Set("ETag", `"fresh"`)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
default:
|
||||
http.Error(w, "method "+r.Method, http.StatusMethodNotAllowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
f.srv = httptest.NewServer(mux)
|
||||
f.calendars = make([]caldav.Calendar, len(cals))
|
||||
// Rewrite URLs to point at the httptest server's host.
|
||||
for i, c := range cals {
|
||||
f.calendars[i] = caldav.Calendar{
|
||||
URL: f.srv.URL + urlPathOf(c.URL),
|
||||
HRef: urlPathOf(c.URL),
|
||||
DisplayName: c.DisplayName,
|
||||
Color: c.Color,
|
||||
}
|
||||
}
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func urlPathOf(absURL string) string {
|
||||
u, _ := url.Parse(absURL)
|
||||
return u.Path
|
||||
}
|
||||
|
||||
// propfindMultistatus builds the PROPFIND response for the slice of
|
||||
// calendars. Includes the collection itself + each calendar entry, plus
|
||||
// an "inbox" non-calendar that ListCalendars must filter out.
|
||||
func propfindMultistatus(cals []caldav.Calendar) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
||||
b.WriteString(`<d:response><d:href>/dav/calendars/m/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
||||
for _, c := range cals {
|
||||
b.WriteString(`<d:response><d:href>` + urlPathOf(c.URL) + `</d:href><d:propstat><d:prop><d:displayname>` + c.DisplayName + `</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
||||
}
|
||||
b.WriteString(`</d:multistatus>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildReportMultistatus wraps a slice of VTODO ICS docs into a REPORT
|
||||
// multistatus body, one <d:response> per VTODO.
|
||||
func buildReportMultistatus(calPath string, vtodos []string) string {
|
||||
if len(vtodos) == 0 {
|
||||
return `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav"></d:multistatus>`
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
|
||||
for i, ics := range vtodos {
|
||||
b.WriteString(`<d:response><d:href>` + calPath + "t" + itoa(i) + `.ics</d:href><d:propstat><d:prop><d:getetag>"e` + itoa(i) + `"</d:getetag><cal:calendar-data>`)
|
||||
b.WriteString(ics)
|
||||
b.WriteString(`</cal:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`)
|
||||
}
|
||||
b.WriteString(`</d:multistatus>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
neg := false
|
||||
if n < 0 {
|
||||
neg = true
|
||||
n = -n
|
||||
}
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// seedItemUnderDev inserts a fresh projax item under dev and returns
|
||||
// its id + primary path. Callers defer cleanup.
|
||||
func seedItemUnderDev(t *testing.T, pool *pgxpool.Pool, slug, title string) (id, primaryPath string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var dev string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
|
||||
returning id`,
|
||||
title, slug, dev,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed item: %v", err)
|
||||
}
|
||||
return id, "dev." + slug
|
||||
}
|
||||
|
||||
// TestDetailLinkExistingCalendar walks the original ask end-to-end:
|
||||
// 1. Fake CalDAV server exposes 3 calendars + zero VTODOs.
|
||||
// 2. Seed an unlinked projax item under dev.
|
||||
// 3. GET /i/{path} — assert the "link existing" <select> renders with
|
||||
// all 3 calendars.
|
||||
// 4. POST /i/{path}/caldav/link-existing with one URL.
|
||||
// 5. GET /i/{path} again — assert the linked URL is gone from the
|
||||
// picker (already linked) but appears in the tasks section.
|
||||
func TestDetailLinkExistingCalendar(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
|
||||
cals := []caldav.Calendar{
|
||||
{URL: "https://dav.test/dav/calendars/m/Family/", DisplayName: "Family"},
|
||||
{URL: "https://dav.test/dav/calendars/m/Travel/", DisplayName: "Travel"},
|
||||
{URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"},
|
||||
}
|
||||
fake := newFakeCalDAVServer(t, cals)
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
slug := "caldav-link-" + stamp
|
||||
id, primary := seedItemUnderDev(t, pool, slug, "Caldav link test")
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
|
||||
h := srv.Routes()
|
||||
|
||||
// Step 3: picker renders with three calendars.
|
||||
_, body := get(t, h, "/i/"+primary)
|
||||
for _, want := range []string{
|
||||
`action="/i/` + primary + `/caldav/link-existing"`,
|
||||
`>Family<`,
|
||||
`>Travel<`,
|
||||
`>Vacations 2026<`,
|
||||
`+ Create new list`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("unlinked detail page missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: POST link-existing. Pick the Vacations 2026 calendar.
|
||||
pickedURL := fake.calendars[2].URL
|
||||
form := url.Values{"calendar_url": {pickedURL}}
|
||||
resp, _ := post(t, h, "/i/"+primary+"/caldav/link-existing", form)
|
||||
if resp != http.StatusSeeOther {
|
||||
t.Fatalf("link-existing POST → %d, want 303", resp)
|
||||
}
|
||||
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.
|
||||
_, 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, `data-cal="`+pickedURL+`"`) {
|
||||
t.Errorf("tasks section missing cal-block for the linked URL")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVTodoCreateAttachesProjaxCategory exercises the tag-on-create
|
||||
// half of Phase 5j. Posting the Add-task form from /i/{path} must send
|
||||
// a VTODO whose CATEGORIES contains `projax:<path>` so a shared list
|
||||
// can later be filtered per-item.
|
||||
func TestVTodoCreateAttachesProjaxCategory(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
|
||||
cals := []caldav.Calendar{
|
||||
{URL: "https://dav.test/dav/calendars/m/Shared/", DisplayName: "Shared"},
|
||||
}
|
||||
fake := newFakeCalDAVServer(t, cals)
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
slug := "caldav-tag-" + stamp
|
||||
id, primary := seedItemUnderDev(t, pool, slug, "Tag-on-create test")
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
calURL := fake.calendars[0].URL
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'caldav-list', $2, 'contains')`,
|
||||
id, calURL,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id)
|
||||
|
||||
h := srv.Routes()
|
||||
form := url.Values{
|
||||
"calendar_url": {calURL},
|
||||
"summary": {"Buy travel gear"},
|
||||
}
|
||||
resp, _ := post(t, h, "/i/"+primary+"/caldav/todo/todo-create", form)
|
||||
if resp != http.StatusSeeOther && resp != http.StatusOK {
|
||||
t.Fatalf("todo-create POST → %d", resp)
|
||||
}
|
||||
|
||||
// Inspect what the fake CalDAV server received.
|
||||
fake.mu.Lock()
|
||||
defer fake.mu.Unlock()
|
||||
if len(fake.puts) == 0 {
|
||||
t.Fatalf("expected at least one PUT to the fake CalDAV server")
|
||||
}
|
||||
var got string
|
||||
for _, body := range fake.puts {
|
||||
got = body
|
||||
break
|
||||
}
|
||||
wantTag := "projax:" + primary
|
||||
if !strings.Contains(got, "CATEGORIES:"+wantTag) {
|
||||
t.Errorf("PUT body missing CATEGORIES tag %q. Body:\n%s", wantTag, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailFilterByProjaxCategory exercises the read-side filter:
|
||||
// when the linked list has ANY projax: tag, the detail page only shows
|
||||
// the VTODOs whose CATEGORIES include THIS item's tag. VTODOs tagged
|
||||
// for OTHER items must NOT leak through.
|
||||
func TestDetailFilterByProjaxCategory(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
|
||||
cals := []caldav.Calendar{
|
||||
{URL: "https://dav.test/dav/calendars/m/Vacations-2026/", DisplayName: "Vacations 2026"},
|
||||
}
|
||||
fake := newFakeCalDAVServer(t, cals)
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
||||
calURL := fake.calendars[0].URL
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
idA, primaryA := seedItemUnderDev(t, pool, "trip-a-"+stamp, "Trip A")
|
||||
idB, primaryB := seedItemUnderDev(t, pool, "trip-b-"+stamp, "Trip B")
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2)`, idA, idB)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
for _, id := range []string{idA, idB} {
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'caldav-list', $2, 'contains')`,
|
||||
id, calURL,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where ref_id=$1`, calURL)
|
||||
|
||||
// Three VTODOs on the SHARED list: one tagged for A, one for B, one
|
||||
// for both.
|
||||
tagA := "projax:" + primaryA
|
||||
tagB := "projax:" + primaryB
|
||||
fake.mu.Lock()
|
||||
fake.todos[urlPathOf(calURL)] = []string{
|
||||
todoICS("uid-only-a", "Book flight A", []string{tagA}),
|
||||
todoICS("uid-only-b", "Book flight B", []string{tagB}),
|
||||
todoICS("uid-shared", "Travel insurance", []string{tagA, tagB}),
|
||||
}
|
||||
fake.mu.Unlock()
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/i/"+primaryA)
|
||||
if !strings.Contains(body, "Book flight A") {
|
||||
t.Errorf("Trip A detail missing tagged-A summary")
|
||||
}
|
||||
if strings.Contains(body, "Book flight B") {
|
||||
t.Errorf("Trip A detail leaked tagged-B summary — filter broken")
|
||||
}
|
||||
if !strings.Contains(body, "Travel insurance") {
|
||||
t.Errorf("Trip A detail missing dual-tagged summary (multi-tag contract)")
|
||||
}
|
||||
|
||||
// Trip B sees the mirror image: B + shared, not A.
|
||||
_, body = get(t, h, "/i/"+primaryB)
|
||||
if strings.Contains(body, "Book flight A") {
|
||||
t.Errorf("Trip B detail leaked tagged-A summary")
|
||||
}
|
||||
if !strings.Contains(body, "Book flight B") {
|
||||
t.Errorf("Trip B detail missing tagged-B summary")
|
||||
}
|
||||
if !strings.Contains(body, "Travel insurance") {
|
||||
t.Errorf("Trip B detail missing dual-tagged summary")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetailUntaggedListShowsAll proves the legacy fallback: a linked
|
||||
// list with ZERO projax: tags is treated as unmanaged — every VTODO
|
||||
// renders, untouched. Without this users with pre-5j lists would see
|
||||
// the detail page suddenly hide all their existing tasks.
|
||||
func TestDetailUntaggedListShowsAll(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
|
||||
cals := []caldav.Calendar{
|
||||
{URL: "https://dav.test/dav/calendars/m/Home/", DisplayName: "Home"},
|
||||
}
|
||||
fake := newFakeCalDAVServer(t, cals)
|
||||
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.srv.URL+"/dav/calendars/m/", "u", "p")}
|
||||
calURL := fake.calendars[0].URL
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
id, primary := seedItemUnderDev(t, pool, "home-legacy-"+stamp, "Home legacy")
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'caldav-list', $2, 'contains')`,
|
||||
id, calURL,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id=$1`, id)
|
||||
|
||||
fake.mu.Lock()
|
||||
fake.todos[urlPathOf(calURL)] = []string{
|
||||
todoICS("legacy-1", "Pick up bread", nil),
|
||||
todoICS("legacy-2", "Call dentist", []string{"home", "errands"}),
|
||||
}
|
||||
fake.mu.Unlock()
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/i/"+primary)
|
||||
if !strings.Contains(body, "Pick up bread") {
|
||||
t.Errorf("untagged-list detail missing legacy todo 'Pick up bread'")
|
||||
}
|
||||
if !strings.Contains(body, "Call dentist") {
|
||||
t.Errorf("untagged-list detail missing legacy todo with non-projax categories")
|
||||
}
|
||||
}
|
||||
|
||||
// todoICS builds a minimal VTODO ICS doc with optional CATEGORIES.
|
||||
func todoICS(uid, summary string, categories []string) string {
|
||||
cat := ""
|
||||
if len(categories) > 0 {
|
||||
cat = "CATEGORIES:" + strings.Join(categories, ",") + "\r\n"
|
||||
}
|
||||
return "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:" + uid + "\r\nSUMMARY:" + summary + "\r\nSTATUS:NEEDS-ACTION\r\n" + cat + "END:VTODO\r\nEND:VCALENDAR"
|
||||
}
|
||||
123
web/new_form_test.go
Normal file
123
web/new_form_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewFormPreselectsParent reproduces m's bug report: GET /new?parent=admin
|
||||
// must render the Parents <select> populated with the full project list AND
|
||||
// pre-select the option whose value matches admin's item id. Pre-fix the
|
||||
// handler passed no ParentOptions to the template, so the <select> was empty
|
||||
// and there was nothing to pre-select.
|
||||
func TestNewFormPreselectsParent(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
code, body := get(t, h, "/new?parent=admin")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /new?parent=admin → %d body=%s", code, body)
|
||||
}
|
||||
|
||||
// The Parents <select> must be populated. admin is a root area present
|
||||
// in every projax instance — its option should be there.
|
||||
if !strings.Contains(body, `<option value="`) {
|
||||
t.Fatalf("Parents <select> is empty — no <option> rendered. Body excerpt: %s",
|
||||
body[strings.Index(body, "parent_ids"):min(len(body), strings.Index(body, "parent_ids")+800)])
|
||||
}
|
||||
if !strings.Contains(body, `>admin</option>`) {
|
||||
t.Errorf("expected an <option>...>admin</option> in the Parents <select>")
|
||||
}
|
||||
|
||||
// The admin option must be the selected one — that's the prefill contract.
|
||||
// We anchor on the path (rendered as the option label) since the id is a
|
||||
// uuid we'd otherwise have to look up.
|
||||
adminIdx := strings.Index(body, `>admin</option>`)
|
||||
if adminIdx < 0 {
|
||||
t.Fatalf("admin option not found in rendered Parents select")
|
||||
}
|
||||
// Look back ~200 chars to the <option ... selected> opening tag.
|
||||
from := adminIdx - 200
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
openingTag := body[from:adminIdx]
|
||||
if !strings.Contains(openingTag, "selected") {
|
||||
t.Errorf("admin <option> not marked selected; opening tag was: %s", openingTag)
|
||||
}
|
||||
|
||||
// And other unrelated options must NOT be selected. Pick `dev` (another
|
||||
// root area) as the counter-anchor.
|
||||
devIdx := strings.Index(body, `>dev</option>`)
|
||||
if devIdx >= 0 {
|
||||
from := devIdx - 200
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
devTag := body[from:devIdx]
|
||||
if strings.Contains(devTag, "selected") {
|
||||
t.Errorf("dev <option> should NOT be selected when ?parent=admin; opening tag was: %s", devTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFormHasSlugSuggestScript pins the Phase 5k slug auto-suggest:
|
||||
// the new-item template ships an inline <script> that derives a
|
||||
// kebab-case slug from the title as the user types and stops syncing
|
||||
// once the slug is edited manually. Without this guard a future
|
||||
// template refactor could silently strip the script.
|
||||
func TestNewFormHasSlugSuggestScript(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/new")
|
||||
for _, want := range []string{
|
||||
`id="new-title"`,
|
||||
`id="new-slug"`,
|
||||
// Algorithm signatures we don't want a "harmless cleanup" pass
|
||||
// to drop quietly.
|
||||
"normalize('NFD')",
|
||||
"replace(/ß/g, 'ss')",
|
||||
"replace(/[^a-z0-9]+/g, '-')",
|
||||
"slice(0, 63)",
|
||||
"dataset.userEdited",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("new-item template missing slug-suggest fragment %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewFormNoParentParamRendersAllOptions confirms the Parents <select>
|
||||
// is populated even when no ?parent= is supplied — clicking "+ New" from the
|
||||
// nav should still let the user pick any parent.
|
||||
func TestNewFormNoParentParamRendersAllOptions(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
code, body := get(t, h, "/new")
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /new → %d", code)
|
||||
}
|
||||
// At least one option exists.
|
||||
if !strings.Contains(body, `<option value="`) {
|
||||
t.Fatalf("Parents <select> is empty on /new (no ?parent= param)")
|
||||
}
|
||||
// Nothing pre-selected.
|
||||
if strings.Contains(body, `<option value="`) && strings.Contains(body, `" selected>`) {
|
||||
// Make sure no Parents <select> option is selected — Status options
|
||||
// might use selected for the default, so anchor on parent_ids context.
|
||||
pIdx := strings.Index(body, `name="parent_ids"`)
|
||||
if pIdx >= 0 {
|
||||
selectClose := strings.Index(body[pIdx:], `</select>`)
|
||||
if selectClose > 0 {
|
||||
parentBlock := body[pIdx : pIdx+selectClose]
|
||||
if strings.Contains(parentBlock, "selected") {
|
||||
t.Errorf("no Parents option should be selected on bare /new, but block contains 'selected': %s", parentBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
266
web/project_filter_test.go
Normal file
266
web/project_filter_test.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// projectFixture seeds a subtree shaped:
|
||||
//
|
||||
// dev/ (existing)
|
||||
// <stamp>-root (root of the test subtree)
|
||||
// <stamp>-child (descendant of root)
|
||||
// <stamp>-outside (sibling of root under dev — NOT a descendant)
|
||||
//
|
||||
// Returns the slugs + primary paths. Callers defer the row cleanup.
|
||||
type projectFixture struct {
|
||||
rootSlug, childSlug, outsideSlug string
|
||||
rootPath, childPath, outsidePath string
|
||||
rootID, childID, outsideID string
|
||||
}
|
||||
|
||||
func seedProjectFixture(t *testing.T, pool *pgxpool.Pool) projectFixture {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var dev string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
fx := projectFixture{
|
||||
rootSlug: "proj-root-" + stamp,
|
||||
childSlug: "proj-child-" + stamp,
|
||||
outsideSlug: "proj-outside-" + stamp,
|
||||
}
|
||||
// root + outside both live directly under dev.
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
fx.rootSlug, dev,
|
||||
).Scan(&fx.rootID); err != nil {
|
||||
t.Fatalf("seed root: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
fx.outsideSlug, dev,
|
||||
).Scan(&fx.outsideID); err != nil {
|
||||
t.Fatalf("seed outside: %v", err)
|
||||
}
|
||||
// child lives under root.
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[])
|
||||
returning id`,
|
||||
fx.childSlug, fx.rootID,
|
||||
).Scan(&fx.childID); err != nil {
|
||||
t.Fatalf("seed child: %v", err)
|
||||
}
|
||||
fx.rootPath = "dev." + fx.rootSlug
|
||||
fx.childPath = fx.rootPath + "." + fx.childSlug
|
||||
fx.outsidePath = "dev." + fx.outsideSlug
|
||||
return fx
|
||||
}
|
||||
|
||||
func cleanupProjectFixture(pool *pgxpool.Pool, fx projectFixture) {
|
||||
pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsTree exercises the / (tree) handler — applyTreeFilter
|
||||
// passes the project filter through TreeFilter.Matches, so ?project=<root>
|
||||
// must show only root + descendants.
|
||||
func TestProjectFilterNarrowsTree(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/?project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("tree ?project=<root> missing root path %q", fx.rootPath)
|
||||
}
|
||||
if !strings.Contains(body, fx.childPath) {
|
||||
t.Errorf("tree ?project=<root> missing child path %q (descendants default ON)", fx.childPath)
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("tree ?project=<root> leaked outside path %q", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsTimeline — buildTimeline funnels items via
|
||||
// q.Filter.Matches before fan-out, so ?project=<root> must drop the
|
||||
// creation row for the outside sibling but keep root + child.
|
||||
func TestProjectFilterNarrowsTimeline(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/timeline?refresh=1&project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("timeline ?project=<root> missing root creation row")
|
||||
}
|
||||
if !strings.Contains(body, fx.childPath) {
|
||||
t.Errorf("timeline ?project=<root> missing child creation row")
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("timeline ?project=<root> leaked outside creation row %q", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsCalendar — buildCalendar funnels items via
|
||||
// q.Filter.Matches; rows surface from dated item_links. Seed a dated link
|
||||
// on each fixture item, then verify scoping by ?project=<root>.
|
||||
func TestProjectFilterNarrowsCalendar(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
rootNote := "cal-root-" + fx.rootSlug
|
||||
childNote := "cal-child-" + fx.childSlug
|
||||
outsideNote := "cal-outside-" + fx.outsideSlug
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
||||
values ($1, 'document', $2, 'contains', $3, current_date),
|
||||
($4, 'document', $5, 'contains', $6, current_date),
|
||||
($7, 'document', $8, 'contains', $9, current_date)`,
|
||||
fx.rootID, "https://example.com/cal-root", rootNote,
|
||||
fx.childID, "https://example.com/cal-child", childNote,
|
||||
fx.outsideID, "https://example.com/cal-outside", outsideNote,
|
||||
); err != nil {
|
||||
t.Fatalf("seed dated links: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.item_links where item_id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID)
|
||||
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/calendar?refresh=1&project="+fx.rootPath)
|
||||
if !strings.Contains(body, rootNote) {
|
||||
t.Errorf("calendar ?project=<root> missing root note %q", rootNote)
|
||||
}
|
||||
if !strings.Contains(body, childNote) {
|
||||
t.Errorf("calendar ?project=<root> missing child note %q (descendants ON)", childNote)
|
||||
}
|
||||
if strings.Contains(body, outsideNote) {
|
||||
t.Errorf("calendar ?project=<root> leaked outside note %q", outsideNote)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsDashboard — dashboard filters items via Matches
|
||||
// when q.Filter.Active() is true. The Stale-projects card is the most
|
||||
// reliable surface to verify since it iterates the full item set on
|
||||
// every render.
|
||||
func TestProjectFilterNarrowsDashboard(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/dashboard?project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("dashboard ?project=<root> missing root path %q", fx.rootPath)
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("dashboard ?project=<root> leaked outside path %q", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterNarrowsBulk reproduces the actual bug: /admin/bulk's
|
||||
// bulkMatches was a near-clone of TreeFilter.Matches that never picked up
|
||||
// the Phase 5i Slice A ProjectPath block, so ?project=<root> silently
|
||||
// ignored the filter. Pre-fix the outside item leaked into the bulk list.
|
||||
func TestProjectFilterNarrowsBulk(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
_, body := get(t, h, "/admin/bulk?project="+fx.rootPath)
|
||||
if !strings.Contains(body, fx.rootPath) {
|
||||
t.Errorf("bulk ?project=<root> missing root path %q", fx.rootPath)
|
||||
}
|
||||
if !strings.Contains(body, fx.childPath) {
|
||||
t.Errorf("bulk ?project=<root> missing child path %q (descendants ON)", fx.childPath)
|
||||
}
|
||||
if strings.Contains(body, fx.outsidePath) {
|
||||
t.Errorf("BUG: /admin/bulk ?project=<root> leaked outside path %q — bulkMatches missing the ProjectPath gate", fx.outsidePath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectFilterDescendantsToggle pins m's Q5 pick: the toggle is
|
||||
// exposed explicitly. With project_descendants=0 the filter narrows to
|
||||
// the single root item only — the child path must drop out.
|
||||
func TestProjectFilterDescendantsToggle(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
fx := seedProjectFixture(t, pool)
|
||||
defer cleanupProjectFixture(pool, fx)
|
||||
h := srv.Routes()
|
||||
|
||||
// Default (descendants on) — child included.
|
||||
_, on := get(t, h, "/?project="+fx.rootPath)
|
||||
if !strings.Contains(on, fx.childPath) {
|
||||
t.Errorf("descendants=on should include child path %q", fx.childPath)
|
||||
}
|
||||
|
||||
// Toggled off — child dropped, root still in.
|
||||
_, off := get(t, h, "/?project="+fx.rootPath+"&project_descendants=0")
|
||||
if !strings.Contains(off, fx.rootPath) {
|
||||
t.Errorf("descendants=off should still include root %q", fx.rootPath)
|
||||
}
|
||||
if strings.Contains(off, fx.childPath) {
|
||||
t.Errorf("descendants=off leaked child path %q — IncludeDescendants gate not honoured", fx.childPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineKindMultiValueSurvives mirrors the earlier calendar-filter
|
||||
// fix: <select multiple> chip submission emits `?kind=event&kind=doc`,
|
||||
// and the timeline's previous q.Get("kind") + comma-split dropped every
|
||||
// value past the first. parseValues threads BOTH URL shapes through.
|
||||
func TestTimelineKindMultiValueSurvives(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
// We probe at the parse level via the page render: a `?kind=event&kind=doc`
|
||||
// URL must round-trip both kinds into q.Kinds, so the kind multi-select
|
||||
// in the rendered form preserves BOTH as selected options.
|
||||
_, body := get(t, h, "/timeline?kind=event&kind=doc")
|
||||
// The timeline's chip-strip <select> emits `<option value="x" selected>`
|
||||
// only when q.Kinds contains "x". Pre-fix only the first value
|
||||
// survived, so the second option lost its selected attr. The template
|
||||
// has whitespace padding between value and selected so we anchor on
|
||||
// the `value="X"` + `selected` pair within a small window — the
|
||||
// `</option>` for the same X then closes the option.
|
||||
checkSelected := func(kind string) {
|
||||
idx := strings.Index(body, `<option value="`+kind+`"`)
|
||||
if idx < 0 {
|
||||
t.Errorf("rendered form missing <option value=%q>", kind)
|
||||
return
|
||||
}
|
||||
// Slice until the following </option>; the selected attribute, if
|
||||
// present, lives in that window.
|
||||
end := strings.Index(body[idx:], `</option>`)
|
||||
if end < 0 {
|
||||
t.Errorf("rendered form malformed near <option value=%q>", kind)
|
||||
return
|
||||
}
|
||||
window := body[idx : idx+end]
|
||||
if !strings.Contains(window, "selected") {
|
||||
t.Errorf("?kind=event&kind=doc lost %q selection: window=%q", kind, window)
|
||||
}
|
||||
}
|
||||
checkSelected("event")
|
||||
checkSelected("doc")
|
||||
}
|
||||
@@ -15,6 +15,7 @@ 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"
|
||||
@@ -589,6 +590,22 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
@@ -607,9 +624,10 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
"Item": it,
|
||||
"ParentOptions": parents,
|
||||
"StatusOptions": []string{"active", "done", "archived"},
|
||||
"Tasks": tasks,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Issues": issues,
|
||||
"Tasks": tasks,
|
||||
"AvailableCalendars": availableCalendars,
|
||||
"CalDAVOn": s.CalDAV != nil,
|
||||
"Issues": issues,
|
||||
"IssuesOpenTotal": openTotal,
|
||||
"GiteaOn": s.Gitea != nil,
|
||||
"Documents": documents,
|
||||
@@ -627,6 +645,10 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleCalDAVCreate(w, r, base)
|
||||
return
|
||||
}
|
||||
if base, ok := strings.CutSuffix(path, "/caldav/link-existing"); ok {
|
||||
s.handleCalDAVLinkExisting(w, r, base)
|
||||
return
|
||||
}
|
||||
for _, action := range []string{"complete", "reopen", "edit", "delete", "todo-create"} {
|
||||
if base, ok := strings.CutSuffix(path, "/caldav/todo/"+action); ok {
|
||||
s.handleCalDAVTodoAction(w, r, base, action)
|
||||
@@ -877,9 +899,18 @@ func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
parent = p
|
||||
}
|
||||
// new.tmpl iterates {{range .ParentOptions}} to render the Parents
|
||||
// <select>. Without this the dropdown was empty and `?parent=admin`
|
||||
// had nothing to pre-select — the symptom m hit.
|
||||
parents, err := s.parentOptions(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
s.render(w, r, "new", map[string]any{
|
||||
"Title": "new",
|
||||
"Parent": parent,
|
||||
"ParentOptions": parents,
|
||||
"StatusOptions": []string{"active", "done", "archived"},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<h1>New item</h1>
|
||||
<p class="meta">Suggested parent: <strong>{{if .Parent}}{{.Parent.PrimaryPath}}{{else}}(root){{end}}</strong></p>
|
||||
|
||||
<form method="post" action="/new" class="edit">
|
||||
<form method="post" action="/new" class="edit" id="new-item-form">
|
||||
<input type="hidden" name="kind" value="project">
|
||||
<label>Title <input name="title" required></label>
|
||||
<label>Slug <input name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
|
||||
<label>Title <input id="new-title" name="title" required></label>
|
||||
<label>Slug <input id="new-slug" name="slug" required pattern="[^.]+" placeholder="lowercase, no dots"></label>
|
||||
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — leave empty for a root item)</small>
|
||||
<select name="parent_ids" multiple size="6">
|
||||
{{range .ParentOptions}}
|
||||
@@ -32,4 +32,38 @@
|
||||
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.PrimaryPath}}{{else}}/{{end}}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
// Phase 5k: auto-suggest a kebab-case slug from Title as the user types.
|
||||
// Strips diacritics (Müller → muller, São → sao), German ß → ss, collapses
|
||||
// any non-alphanumeric run into a single hyphen, trims edge hyphens, caps
|
||||
// at the 63-char limit the itemwrite validator enforces. Once the user
|
||||
// edits the slug manually, the sync stops — typing in Title no longer
|
||||
// clobbers their override. A pre-filled slug also counts as user-edited
|
||||
// (rare for /new but defensive).
|
||||
(function() {
|
||||
var title = document.getElementById('new-title');
|
||||
var slug = document.getElementById('new-slug');
|
||||
if (!title || !slug) return;
|
||||
function kebab(s) {
|
||||
return s
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.slice(0, 63);
|
||||
}
|
||||
if (slug.value && slug.value.length > 0) {
|
||||
slug.dataset.userEdited = '1';
|
||||
}
|
||||
title.addEventListener('input', function() {
|
||||
if (slug.dataset.userEdited === '1') return;
|
||||
slug.value = kebab(title.value);
|
||||
});
|
||||
slug.addEventListener('input', function() {
|
||||
slug.dataset.userEdited = '1';
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
@@ -94,9 +94,29 @@
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted">No CalDAV list linked.</p>
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||||
<button type="submit">Create CalDAV list</button>
|
||||
</form>
|
||||
{{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. */}}
|
||||
<div class="caldav-actions">
|
||||
{{if .AvailableCalendars}}
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/link-existing" class="caldav-link-existing inline">
|
||||
<label class="visually-hidden" for="caldav-link-existing-select">Link existing CalDAV list</label>
|
||||
<select id="caldav-link-existing-select" name="calendar_url" required>
|
||||
<option value="">— link existing list —</option>
|
||||
{{range .AvailableCalendars}}<option value="{{.URL}}">{{.DisplayName}}</option>{{end}}
|
||||
</select>
|
||||
<button type="submit">Link</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if not .Tasks}}
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}/caldav/create" class="inline">
|
||||
<button type="submit">+ Create new list</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
@@ -149,20 +149,19 @@ func parseTimelineQuery(r *http.Request, now time.Time) TimelineQuery {
|
||||
q.From = startOfDay(now)
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(r.URL.Query().Get("kind")); v != "" {
|
||||
seen := map[string]bool{}
|
||||
for _, k := range strings.Split(v, ",") {
|
||||
k = strings.TrimSpace(strings.ToLower(k))
|
||||
switch k {
|
||||
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
|
||||
if !seen[k] {
|
||||
seen[k] = true
|
||||
q.Kinds = append(q.Kinds, k)
|
||||
}
|
||||
}
|
||||
// Accept both `?kind=event,doc` (comma-joined) and
|
||||
// `?kind=event&kind=doc` (HTMX multi-select submission). The earlier
|
||||
// q.Get + comma-split flavour dropped everything past the first value
|
||||
// when the chip strip's <select multiple> submitted — same pre-5d
|
||||
// shape calendar's parser carried before commit 6f0a318. parseValues
|
||||
// (web/server.go) merges both URL styles into a single slice.
|
||||
for _, k := range parseValues(r.URL.Query(), "kind") {
|
||||
switch k {
|
||||
case timelineKindTodo, timelineKindEvent, timelineKindDoc, timelineKindCreation:
|
||||
q.Kinds = append(q.Kinds, k)
|
||||
}
|
||||
sort.Strings(q.Kinds)
|
||||
}
|
||||
sort.Strings(q.Kinds)
|
||||
return q
|
||||
}
|
||||
|
||||
|
||||
142
web/timeline_filter_test.go
Normal file
142
web/timeline_filter_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package web_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestTimelineFilterNarrowsByTag reproduces m's bug report: `/timeline?tag=work`
|
||||
// should narrow the spine to only work-tagged items. Pre-fix the page rendered
|
||||
// every dated row regardless of filter.
|
||||
func TestTimelineFilterNarrowsByTag(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
tagWork := "tl-bug-work-" + stamp
|
||||
tagHome := "tl-bug-home-" + stamp
|
||||
var dev string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
|
||||
type seed struct {
|
||||
slug, note, tag string
|
||||
}
|
||||
seeds := []seed{
|
||||
{slug: "tl-work-" + stamp, note: "tl-work-note-" + stamp, tag: tagWork},
|
||||
{slug: "tl-home-" + stamp, note: "tl-home-note-" + stamp, tag: tagHome},
|
||||
}
|
||||
var ids []string
|
||||
for _, s := range seeds {
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids, tags)
|
||||
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[], ARRAY[$4]::text[])
|
||||
returning id`,
|
||||
s.slug, s.slug, dev, s.tag,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed %s: %v", s.slug, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
||||
values ($1, 'document', $2, 'contains', $3, current_date)`,
|
||||
id, "https://example.com/tl-"+s.slug, s.note,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link %s: %v", s.slug, err)
|
||||
}
|
||||
}
|
||||
for _, id := range ids {
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
}
|
||||
|
||||
// Unfiltered: both notes should show.
|
||||
_, all := get(t, h, "/timeline?refresh=1")
|
||||
if !strings.Contains(all, seeds[0].note) || !strings.Contains(all, seeds[1].note) {
|
||||
t.Fatalf("baseline timeline missing seeded notes; body excerpt: %s", truncate(all, 600))
|
||||
}
|
||||
|
||||
// Filtered: ?tag=tagWork should drop the home note.
|
||||
_, scoped := get(t, h, "/timeline?refresh=1&tag="+tagWork)
|
||||
if !strings.Contains(scoped, seeds[0].note) {
|
||||
t.Errorf("filtered timeline missing work note %q", seeds[0].note)
|
||||
}
|
||||
if strings.Contains(scoped, seeds[1].note) {
|
||||
t.Errorf("BUG: /timeline?tag=%s leaked home note %q — filter didn't narrow",
|
||||
tagWork, seeds[1].note)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineFilterByKindMultiValue exercises the kind chip via HTMX-style
|
||||
// repeated-param submission (?kind=todo&kind=doc). Pre-fix the timeline's
|
||||
// own ?kind parser used q.Get("kind") which dropped everything past the
|
||||
// first value — same root cause as the calendar's pre-5d kind bug.
|
||||
func TestTimelineFilterByKindMultiValue(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
|
||||
var dev string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
slug := "tl-kind-" + stamp
|
||||
noteText := "tl-kind-doc-note-" + stamp
|
||||
var id string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[])
|
||||
returning id`,
|
||||
slug, slug, dev,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("seed item: %v", err)
|
||||
}
|
||||
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date)
|
||||
values ($1, 'document', $2, 'contains', $3, current_date)`,
|
||||
id, "https://example.com/tl-kind-"+stamp, noteText,
|
||||
); err != nil {
|
||||
t.Fatalf("seed link: %v", err)
|
||||
}
|
||||
|
||||
// HTMX-style repeated kind params: doc AND event selected.
|
||||
// The seeded item only produces a doc row; the event slot is empty
|
||||
// for this test (no linked calendar). Both kinds must parse so the
|
||||
// doc row survives.
|
||||
_, body := get(t, h, "/timeline?refresh=1&kind=doc&kind=event")
|
||||
if !strings.Contains(body, noteText) {
|
||||
t.Errorf("expected ?kind=doc&kind=event to include the seeded doc note %q, body excerpt: %s",
|
||||
noteText, truncate(body, 600))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTimelineFilterStripFormHasCorrectTarget asserts the chip strip's HTMX
|
||||
// wiring is intact — `hx-get="/timeline"`, `hx-target="#timeline-section"`,
|
||||
// `hx-trigger="change from:select"`. A future template edit that drops one
|
||||
// of these would silently break in-place chip swapping.
|
||||
func TestTimelineFilterStripFormHasCorrectTarget(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
_, body := get(t, h, "/timeline")
|
||||
for _, want := range []string{
|
||||
`id="timeline-filter"`,
|
||||
`hx-get="/timeline"`,
|
||||
`hx-target="#timeline-section"`,
|
||||
`hx-trigger="change from:select"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("timeline filter form missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user