feat(phase 1.5): tags + management + DAG + mai.projects sync
Big task. Five migrations, full store + web rewrite, and a model upgrade
that turns the parent_id tree into a parent_ids[] DAG.
Schema (db/migrations)
- 0006_tags_management_unify: adds tags + management text[] (GIN-indexed),
collapses the area/project distinction (kind keeps the slot but 'area'
is no longer a special value), drops the structural rules from the
path trigger so root projects + non-root projects are both legal.
- 0007_backfill_mai_projects: one-shot, idempotent — for every row in
mai.projects without a 'mai-project' item_link, create a projax.items
row under a heuristic-chosen area (mhealth→health, msports/manjin→
sports, kanzlai/hlckm/work/mworkrepo/paliad or HL/* repo→work,
mhome→home, default→dev), insert the item_link, and tag the row
management=['mai']. Also flips management='mai' on any already-linked
pre-Phase-1.5 promotions.
- 0008_mai_projects_sync: bidirectional triggers. sync_to_mai runs as
projax_admin and writes mai.projects directly (after the operator-run
grant + RLS policy widening — documented in the migration header).
sync_from_mai is SECURITY DEFINER so writes by the mai role fan out
into projax.items. pg_trigger_depth() + projax.in_sync GUC keep the
cycle suppressed. Slug stays the join key for new rows; the
item_link pointer survives renames.
- 0009_items_unified_simplify: view collapses to a thin projection over
projax.items now that mai.projects is a derived projection.
- 0010_multi_parent: parent_id → parent_ids uuid[], path → paths text[].
compute_item_paths walks via parents' precomputed paths (no recursive
CTE in the hot path; cycle detection uses one). New triggers:
items_check_slug_collision (multi-parent uniqueness),
items_after_delete (manual cascade since arrays don't carry FK).
Trigger refresh_item_paths_recursive does parent-first DFS over
descendants, guarded by projax.refreshing_paths GUC.
Go store + handlers
- Item gains ParentIDs []string + Paths []string. PrimaryPath /
OtherPaths helpers feed the detail breadcrumb. Source always
'projax' now; SourceRefDeref still surfaces the mai-id pointer.
- Update / Reparent / Create take ParentIDs []string. AddParent helper
for the multi-parent UI's "also list under" action.
- GetByPath uses '$1 = any(paths)' so /i/work.paliad and /i/dev.paliad
resolve to the same row.
- buildForest renders a multi-parent item under each of its parents
(duplicated nodes in distinct branches). Tag-filter prune is
branch-preserving.
Templates
- detail.tmpl: multi-select parents, tags + management chip inputs,
"Also at: …" breadcrumb for multi-parent items.
- new.tmpl: same multi-select + chip inputs.
- tree.tmpl: tag-filter chip bar, "×N" badge on multi-parent rows,
management chips visible on every row.
- classify.tmpl: re-parent workflow (no more promote-to-projax — the
bidirectional sync removed the dichotomy).
Tests (DB + HTTP, all skip without env)
- TestMultiParentResolvesBothPaths inserts an item with two parents,
asserts both inherited paths.
- TestSlugCollisionUnderCommonParent refuses a sibling clash.
- TestMultiParentBothPathsRouteToSameRow HTTP-level: /i/dev.X and
/i/work.X both 200, same row.
- TestReparentRoundTrip rewritten for parent_ids[] semantics.
- TestPathTriggerNestAndRename / Reparent rewritten to query paths[].
Docs (docs/design.md)
- §2 rewritten: items in a DAG, no area/project distinction.
- §3 schema: parent_ids + paths + tags + management + indices.
- §3.1 path-trigger overhaul incl. cycle detection via recursive CTE
and slug-collision-under-common-parent guard.
- §3.2 view simplified.
- §3.4 NEW: mai.projects bidirectional sync, including the manual
prereq.
- §4.1 + §4.2: classify becomes re-parent, tags+management UI section.
mai head start / mai hire / mai status / mai instruct keep working
because mai.projects retains its FK-target shape; the projax sync just
mirrors the row in lock-step.
This commit is contained in:
@@ -78,46 +78,48 @@ func TestPathTriggerNestAndRename(t *testing.T) {
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Get the 'home' area id.
|
||||
var homeID string
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&homeID); err != nil {
|
||||
t.Fatalf("read home: %v", err)
|
||||
}
|
||||
|
||||
// Insert child project under home.
|
||||
var parentPath string
|
||||
var parentPaths []string
|
||||
if err := tx.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], $1, $2, $3) returning path`,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], $1, $2, ARRAY[$3]::uuid[]) returning paths`,
|
||||
"Spring clean", "spring-clean", homeID,
|
||||
).Scan(&parentPath); err != nil {
|
||||
).Scan(&parentPaths); err != nil {
|
||||
t.Fatalf("insert spring-clean: %v", err)
|
||||
}
|
||||
if parentPath != "home.spring-clean" {
|
||||
t.Fatalf("expected path 'home.spring-clean', got %q", parentPath)
|
||||
if len(parentPaths) != 1 || parentPaths[0] != "home.spring-clean" {
|
||||
t.Fatalf("expected paths ['home.spring-clean'], got %v", parentPaths)
|
||||
}
|
||||
|
||||
// Insert grandchild.
|
||||
var childPath string
|
||||
var childPaths []string
|
||||
if err := tx.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_id)
|
||||
select array['project']::text[], 'Bathroom', 'bathroom', id from projax.items where path='home.spring-clean'
|
||||
returning path`).Scan(&childPath); err != nil {
|
||||
`insert into projax.items (kind, title, slug, parent_ids)
|
||||
select array['project']::text[], 'Bathroom', 'bathroom', ARRAY[id]::uuid[]
|
||||
from projax.items where 'home.spring-clean' = any(paths)
|
||||
returning paths`).Scan(&childPaths); err != nil {
|
||||
t.Fatalf("insert bathroom: %v", err)
|
||||
}
|
||||
if childPath != "home.spring-clean.bathroom" {
|
||||
t.Fatalf("expected path 'home.spring-clean.bathroom', got %q", childPath)
|
||||
if len(childPaths) != 1 || childPaths[0] != "home.spring-clean.bathroom" {
|
||||
t.Fatalf("expected paths ['home.spring-clean.bathroom'], got %v", childPaths)
|
||||
}
|
||||
|
||||
// Rename middle: descendants must be rewritten.
|
||||
if _, err := tx.Exec(ctx, `update projax.items set slug='big-clean' where path='home.spring-clean'`); err != nil {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update projax.items set slug='big-clean' where 'home.spring-clean' = any(paths)`,
|
||||
); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
var renamedChild string
|
||||
if err := tx.QueryRow(ctx, `select path from projax.items where slug='bathroom' and parent_id=(select id from projax.items where slug='big-clean')`).Scan(&renamedChild); err != nil {
|
||||
var renamedChildPaths []string
|
||||
if err := tx.QueryRow(ctx,
|
||||
`select paths from projax.items where slug='bathroom'`,
|
||||
).Scan(&renamedChildPaths); err != nil {
|
||||
t.Fatalf("read child after rename: %v", err)
|
||||
}
|
||||
if renamedChild != "home.big-clean.bathroom" {
|
||||
t.Fatalf("expected child path 'home.big-clean.bathroom', got %q", renamedChild)
|
||||
if len(renamedChildPaths) != 1 || renamedChildPaths[0] != "home.big-clean.bathroom" {
|
||||
t.Fatalf("expected ['home.big-clean.bathroom'], got %v", renamedChildPaths)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,79 +136,82 @@ func TestPathTriggerReparent(t *testing.T) {
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var homeID, devID string
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&homeID); err != nil {
|
||||
t.Fatalf("home: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&devID); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
|
||||
// Create project under home, then move it to dev.
|
||||
var pid string
|
||||
if err := tx.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X', 'mover', $1) returning id`, homeID,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'X', 'mover', ARRAY[$1]::uuid[]) returning id`,
|
||||
homeID,
|
||||
).Scan(&pid); err != nil {
|
||||
t.Fatalf("insert mover: %v", err)
|
||||
}
|
||||
// Child of mover.
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'X.child', 'child', $1)`, pid,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'X.child', 'child', ARRAY[$1]::uuid[])`,
|
||||
pid,
|
||||
); err != nil {
|
||||
t.Fatalf("insert child: %v", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, devID, pid); err != nil {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`update projax.items set parent_ids=ARRAY[$1]::uuid[] where id=$2`, devID, pid,
|
||||
); err != nil {
|
||||
t.Fatalf("reparent: %v", err)
|
||||
}
|
||||
|
||||
var p1, p2 string
|
||||
if err := tx.QueryRow(ctx, `select path from projax.items where id=$1`, pid).Scan(&p1); err != nil {
|
||||
t.Fatalf("read mover path: %v", err)
|
||||
var moverPaths, childPaths []string
|
||||
if err := tx.QueryRow(ctx, `select paths from projax.items where id=$1`, pid).Scan(&moverPaths); err != nil {
|
||||
t.Fatalf("read mover paths: %v", err)
|
||||
}
|
||||
if p1 != "dev.mover" {
|
||||
t.Fatalf("mover path = %q, want dev.mover", p1)
|
||||
if len(moverPaths) != 1 || moverPaths[0] != "dev.mover" {
|
||||
t.Fatalf("mover paths = %v, want [dev.mover]", moverPaths)
|
||||
}
|
||||
if err := tx.QueryRow(ctx, `select path from projax.items where parent_id=$1`, pid).Scan(&p2); err != nil {
|
||||
t.Fatalf("read child path: %v", err)
|
||||
if err := tx.QueryRow(ctx,
|
||||
`select paths from projax.items where $1::uuid = any(parent_ids)`, pid,
|
||||
).Scan(&childPaths); err != nil {
|
||||
t.Fatalf("read child paths: %v", err)
|
||||
}
|
||||
if p2 != "dev.mover.child" {
|
||||
t.Fatalf("child path = %q, want dev.mover.child", p2)
|
||||
if len(childPaths) != 1 || childPaths[0] != "dev.mover.child" {
|
||||
t.Fatalf("child paths = %v, want [dev.mover.child]", childPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuralRules(t *testing.T) {
|
||||
// Phase 1.5 collapsed the area/project distinction. Both shapes are now
|
||||
// allowed: projects at root (former areas) and any-kind under any-parent.
|
||||
// The only structural rule left is "no cycles", covered by TestCycleRejected.
|
||||
func TestRootAndChildBothAllowed(t *testing.T) {
|
||||
pool := connect(t)
|
||||
defer pool.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
sql string
|
||||
args []any
|
||||
}{
|
||||
{
|
||||
name: "area with parent rejected",
|
||||
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['area']::text[], 'bad', $1, (select id from projax.items where slug='home' and parent_id is null))`,
|
||||
args: []any{fmt.Sprintf("bad-area-%d", time.Now().UnixNano())},
|
||||
},
|
||||
{
|
||||
name: "project at root rejected",
|
||||
sql: `insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'orphan', $1, null)`,
|
||||
args: []any{fmt.Sprintf("orphan-%d", time.Now().UnixNano())},
|
||||
},
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, tc.sql, tc.args...); err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
})
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
rootSlug := fmt.Sprintf("root-proj-%d", time.Now().UnixNano())
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into projax.items (kind, title, slug) values (array['project']::text[], 'Root proj', $1)`,
|
||||
rootSlug,
|
||||
); err != nil {
|
||||
t.Fatalf("root project should be allowed: %v", err)
|
||||
}
|
||||
|
||||
var rootID string
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug=$1`, rootSlug).Scan(&rootID); err != nil {
|
||||
t.Fatalf("root id: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Child', $1, ARRAY[$2]::uuid[])`,
|
||||
fmt.Sprintf("child-%d", time.Now().UnixNano()), rootID,
|
||||
); err != nil {
|
||||
t.Fatalf("child of root project should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,27 +228,100 @@ func TestCycleRejected(t *testing.T) {
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var homeID string
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and parent_id is null`).Scan(&homeID); err != nil {
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&homeID); err != nil {
|
||||
t.Fatalf("home: %v", err)
|
||||
}
|
||||
var aID, bID string
|
||||
if err := tx.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'A', 'cyc-a', $1) returning id`, homeID,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'A', 'cyc-a', ARRAY[$1]::uuid[]) returning id`, homeID,
|
||||
).Scan(&aID); err != nil {
|
||||
t.Fatalf("a: %v", err)
|
||||
}
|
||||
if err := tx.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], 'B', 'cyc-b', $1) returning id`, aID,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'B', 'cyc-b', ARRAY[$1]::uuid[]) returning id`, aID,
|
||||
).Scan(&bID); err != nil {
|
||||
t.Fatalf("b: %v", err)
|
||||
}
|
||||
// Now try to make A a child of B -> cycle.
|
||||
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$2`, bID, aID); err == nil {
|
||||
if _, err := tx.Exec(ctx, `update projax.items set parent_ids=ARRAY[$1]::uuid[] where id=$2`, bID, aID); err == nil {
|
||||
t.Fatalf("expected cycle rejection, got nil error")
|
||||
}
|
||||
|
||||
// Also: self-parent.
|
||||
if _, err := tx.Exec(ctx, `update projax.items set parent_id=$1 where id=$1`, aID); err == nil {
|
||||
// Self-parent.
|
||||
if _, err := tx.Exec(ctx, `update projax.items set parent_ids=ARRAY[$1]::uuid[] where id=$1`, aID); err == nil {
|
||||
t.Fatalf("expected self-parent rejection, got nil error")
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-parent specific tests.
|
||||
|
||||
func TestMultiParentResolvesBothPaths(t *testing.T) {
|
||||
pool := connect(t)
|
||||
defer pool.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var dev, work string
|
||||
if err := tx.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 := tx.QueryRow(ctx, `select id from projax.items where slug='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
|
||||
t.Fatalf("work: %v", err)
|
||||
}
|
||||
slug := fmt.Sprintf("multi-%d", time.Now().UnixNano())
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Multi', $1, ARRAY[$2,$3]::uuid[])`,
|
||||
slug, dev, work,
|
||||
); err != nil {
|
||||
t.Fatalf("insert multi-parent: %v", err)
|
||||
}
|
||||
var paths []string
|
||||
if err := tx.QueryRow(ctx, `select paths from projax.items where slug=$1`, slug).Scan(&paths); err != nil {
|
||||
t.Fatalf("read paths: %v", err)
|
||||
}
|
||||
want := map[string]bool{"dev." + slug: true, "work." + slug: true}
|
||||
got := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
got[p] = true
|
||||
}
|
||||
for k := range want {
|
||||
if !got[k] {
|
||||
t.Fatalf("expected path %q in %v", k, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugCollisionUnderCommonParent(t *testing.T) {
|
||||
pool := connect(t)
|
||||
defer pool.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var dev string
|
||||
if err := tx.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 := fmt.Sprintf("collide-%d", time.Now().UnixNano())
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'A', $1, ARRAY[$2]::uuid[])`,
|
||||
slug, dev,
|
||||
); err != nil {
|
||||
t.Fatalf("first insert: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'B', $1, ARRAY[$2]::uuid[])`,
|
||||
slug, dev,
|
||||
); err == nil {
|
||||
t.Fatalf("expected slug-collision rejection under shared parent dev")
|
||||
}
|
||||
}
|
||||
|
||||
50
db/migrations/0006_tags_management_unify.sql
Normal file
50
db/migrations/0006_tags_management_unify.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- 0006_tags_management_unify.sql
|
||||
-- Phase 1.5 schema additions:
|
||||
-- * tags text[] — free-vocabulary cross-cutting labels
|
||||
-- * management text[] — how the project is run (self | mai | external | ...)
|
||||
-- Plus the area/project structural collapse: 'area' is no longer a special kind.
|
||||
-- Existing root rows that had kind=['area'] flip to kind=['project'], same shape.
|
||||
|
||||
-- ORDER MATTERS: replace the BEFORE trigger first so the subsequent kind-flip
|
||||
-- UPDATE does not trip the old structural rule ("project must have non-null
|
||||
-- parent_id"). After this CREATE OR REPLACE only the cycle check + path-walk
|
||||
-- remain.
|
||||
CREATE OR REPLACE FUNCTION projax.items_before_write()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF tg_op = 'UPDATE' AND new.parent_id IS NOT NULL AND new.parent_id = new.id THEN
|
||||
RAISE EXCEPTION 'projax.items: parent_id cannot equal id'
|
||||
USING errcode = 'check_violation';
|
||||
END IF;
|
||||
|
||||
new.path := projax.compute_item_path(
|
||||
new.parent_id,
|
||||
new.slug,
|
||||
CASE WHEN tg_op = 'UPDATE' THEN new.id ELSE NULL END
|
||||
);
|
||||
|
||||
RETURN new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
ALTER TABLE projax.items
|
||||
ADD COLUMN IF NOT EXISTS tags text[] NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS management text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS items_tags_idx ON projax.items USING gin (tags);
|
||||
CREATE INDEX IF NOT EXISTS items_management_idx ON projax.items USING gin (management);
|
||||
|
||||
-- Collapse 'area' into 'project'. Older trigger required area at root only and
|
||||
-- forbade project at root, which is no longer true. We rewrite the kind array
|
||||
-- in two passes:
|
||||
-- 1. swap 'area' for 'project'
|
||||
-- 2. dedupe so kind never carries a redundant entry
|
||||
UPDATE projax.items
|
||||
SET kind = array_remove(array_append(array_remove(kind, 'area'), 'project'), NULL)
|
||||
WHERE 'area' = ANY(kind);
|
||||
|
||||
UPDATE projax.items
|
||||
SET kind = ARRAY(SELECT DISTINCT unnest(kind))
|
||||
WHERE cardinality(kind) > 1;
|
||||
100
db/migrations/0007_backfill_mai_projects.sql
Normal file
100
db/migrations/0007_backfill_mai_projects.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
-- 0007_backfill_mai_projects.sql
|
||||
-- One-time backfill: for every row in mai.projects that has not already been
|
||||
-- promoted into projax (no item_links row with ref_type='mai-project' yet),
|
||||
-- create a projax-native item under the heuristic-chosen area, link it back
|
||||
-- to mai.projects via item_links, and (if the source row has a repo) record a
|
||||
-- gitea-repo pointer.
|
||||
--
|
||||
-- Idempotent — re-running adds nothing because the existence guard checks the
|
||||
-- item_links pointer.
|
||||
|
||||
CREATE OR REPLACE FUNCTION projax.guess_parent_area(p_slug text, p_repo text)
|
||||
RETURNS uuid
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $$
|
||||
-- Pull the seven seeded areas once; choose by heuristic.
|
||||
WITH area AS (
|
||||
SELECT slug, id FROM projax.items WHERE parent_id IS NULL
|
||||
)
|
||||
SELECT id FROM area WHERE slug = (
|
||||
CASE
|
||||
WHEN p_slug = 'mhealth' THEN 'health'
|
||||
WHEN p_slug IN ('msports', 'manjin') THEN 'sports'
|
||||
WHEN p_slug IN ('kanzlai', 'hlckm', 'work', 'mworkrepo', 'paliad')
|
||||
OR (p_repo IS NOT NULL AND p_repo LIKE 'HL/%') THEN 'work'
|
||||
WHEN p_slug = 'mhome' THEN 'home'
|
||||
ELSE 'dev'
|
||||
END
|
||||
);
|
||||
$$;
|
||||
|
||||
DO $backfill$
|
||||
DECLARE
|
||||
rec record;
|
||||
parent_id uuid;
|
||||
new_id uuid;
|
||||
proj_status text;
|
||||
BEGIN
|
||||
FOR rec IN
|
||||
SELECT p.id, p.name, p.repo, p.path, p.memory_group, p.status, p.goal, p.metadata
|
||||
FROM mai.projects p
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM projax.item_links l
|
||||
WHERE l.ref_type = 'mai-project' AND l.ref_id = p.id
|
||||
)
|
||||
LOOP
|
||||
parent_id := projax.guess_parent_area(rec.id, rec.repo);
|
||||
|
||||
proj_status := CASE rec.status
|
||||
WHEN 'active' THEN 'active'
|
||||
WHEN 'sleeping' THEN 'archived'
|
||||
WHEN 'archived' THEN 'archived'
|
||||
WHEN 'done' THEN 'done'
|
||||
ELSE 'active'
|
||||
END;
|
||||
|
||||
INSERT INTO projax.items
|
||||
(kind, title, slug, parent_id, content_md, metadata, status, management, tags)
|
||||
VALUES (
|
||||
ARRAY['project']::text[],
|
||||
rec.name,
|
||||
rec.id,
|
||||
parent_id,
|
||||
COALESCE(rec.goal, ''),
|
||||
jsonb_build_object(
|
||||
'repo', COALESCE(rec.repo, ''),
|
||||
'path', COALESCE(rec.path, ''),
|
||||
'memory_group', COALESCE(rec.memory_group, '')
|
||||
) || COALESCE(rec.metadata, '{}'::jsonb),
|
||||
proj_status,
|
||||
ARRAY['mai']::text[],
|
||||
'{}'::text[]
|
||||
)
|
||||
RETURNING id INTO new_id;
|
||||
|
||||
INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
VALUES (new_id, 'mai-project', rec.id, 'derived-from')
|
||||
ON CONFLICT (item_id, ref_type, ref_id, rel) DO NOTHING;
|
||||
|
||||
IF rec.repo IS NOT NULL AND rec.repo <> '' THEN
|
||||
INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel, metadata)
|
||||
VALUES (
|
||||
new_id,
|
||||
CASE WHEN rec.repo LIKE 'github.com/%' THEN 'github-repo' ELSE 'gitea-repo' END,
|
||||
rec.repo,
|
||||
'source',
|
||||
'{}'::jsonb
|
||||
)
|
||||
ON CONFLICT (item_id, ref_type, ref_id, rel) DO NOTHING;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END
|
||||
$backfill$;
|
||||
|
||||
-- Any item linked back to mai.projects must carry 'mai' in management, even
|
||||
-- if it was promoted before Phase 1.5 (when the column didn't exist).
|
||||
UPDATE projax.items
|
||||
SET management = array_append(management, 'mai')
|
||||
WHERE NOT ('mai' = ANY(management))
|
||||
AND id IN (SELECT item_id FROM projax.item_links WHERE ref_type = 'mai-project');
|
||||
239
db/migrations/0008_mai_projects_sync.sql
Normal file
239
db/migrations/0008_mai_projects_sync.sql
Normal file
@@ -0,0 +1,239 @@
|
||||
-- 0008_mai_projects_sync.sql
|
||||
-- Bidirectional sync between projax.items and mai.projects so projax becomes
|
||||
-- the single source of truth for project identity/metadata while mai keeps a
|
||||
-- FK-compatible table for its workers/tasks/sessions/messages/metrics.
|
||||
--
|
||||
-- Manual prereq (operator runs once on msupabase as superuser, NOT in a
|
||||
-- migration — RLS+grant ownership lives outside projax_admin's reach):
|
||||
--
|
||||
-- GRANT INSERT, UPDATE, DELETE, TRIGGER ON mai.projects TO projax_admin;
|
||||
-- DROP POLICY IF EXISTS projax_write ON mai.projects;
|
||||
-- CREATE POLICY projax_write ON mai.projects FOR ALL TO projax_admin
|
||||
-- USING (true) WITH CHECK (true);
|
||||
--
|
||||
-- Trust model: projax_admin is intentionally widened on mai.projects only
|
||||
-- (single-tenant fleet, m only). Other mai.* tables remain off-limits.
|
||||
--
|
||||
-- Cycle prevention is twofold:
|
||||
-- * pg_trigger_depth() > 1 skip — handles the natural recursion case where
|
||||
-- the forward trigger's UPDATE on mai.projects fires the reverse trigger
|
||||
-- within the same call stack.
|
||||
-- * projax.in_sync GUC — belt-and-braces; set on entry, cleared on exit.
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Forward: projax.items -> mai.projects (runs as caller, projax_admin)
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION projax.sync_to_mai()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = projax, mai, public
|
||||
AS $$
|
||||
DECLARE
|
||||
was_mai bool;
|
||||
is_mai bool;
|
||||
mai_id text;
|
||||
mapped_status text;
|
||||
BEGIN
|
||||
IF pg_trigger_depth() > 1 THEN RETURN COALESCE(NEW, OLD); END IF;
|
||||
IF current_setting('projax.in_sync', true) = 'on' THEN RETURN COALESCE(NEW, OLD); END IF;
|
||||
PERFORM set_config('projax.in_sync', 'on', true);
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
IF 'mai' = ANY(NEW.management) AND NEW.deleted_at IS NULL THEN
|
||||
mapped_status := CASE NEW.status WHEN 'done' THEN 'archived' ELSE NEW.status END;
|
||||
INSERT INTO mai.projects (id, name, goal, repo, path, memory_group, status, metadata)
|
||||
VALUES (
|
||||
NEW.slug,
|
||||
NEW.title,
|
||||
NEW.content_md,
|
||||
COALESCE(NEW.metadata->>'repo', ''),
|
||||
COALESCE(NEW.metadata->>'path', ''),
|
||||
COALESCE(NEW.metadata->>'memory_group', ''),
|
||||
mapped_status,
|
||||
COALESCE(NEW.metadata, '{}'::jsonb)
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
goal = EXCLUDED.goal,
|
||||
repo = EXCLUDED.repo,
|
||||
path = EXCLUDED.path,
|
||||
memory_group = EXCLUDED.memory_group,
|
||||
status = EXCLUDED.status,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = now();
|
||||
INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
VALUES (NEW.id, 'mai-project', NEW.slug, 'derived-from')
|
||||
ON CONFLICT (item_id, ref_type, ref_id, rel) DO NOTHING;
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
was_mai := 'mai' = ANY(OLD.management) AND OLD.deleted_at IS NULL;
|
||||
is_mai := 'mai' = ANY(NEW.management) AND NEW.deleted_at IS NULL;
|
||||
|
||||
SELECT ref_id INTO mai_id
|
||||
FROM projax.item_links
|
||||
WHERE item_id = NEW.id AND ref_type = 'mai-project'
|
||||
LIMIT 1;
|
||||
|
||||
IF is_mai AND NOT was_mai THEN
|
||||
mapped_status := CASE NEW.status WHEN 'done' THEN 'archived' ELSE NEW.status END;
|
||||
INSERT INTO mai.projects (id, name, goal, repo, path, memory_group, status, metadata)
|
||||
VALUES (
|
||||
COALESCE(mai_id, NEW.slug),
|
||||
NEW.title,
|
||||
NEW.content_md,
|
||||
COALESCE(NEW.metadata->>'repo', ''),
|
||||
COALESCE(NEW.metadata->>'path', ''),
|
||||
COALESCE(NEW.metadata->>'memory_group', ''),
|
||||
mapped_status,
|
||||
COALESCE(NEW.metadata, '{}'::jsonb)
|
||||
)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
goal = EXCLUDED.goal,
|
||||
repo = EXCLUDED.repo,
|
||||
path = EXCLUDED.path,
|
||||
memory_group = EXCLUDED.memory_group,
|
||||
status = EXCLUDED.status,
|
||||
metadata = EXCLUDED.metadata,
|
||||
updated_at = now();
|
||||
INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
VALUES (NEW.id, 'mai-project', COALESCE(mai_id, NEW.slug), 'derived-from')
|
||||
ON CONFLICT (item_id, ref_type, ref_id, rel) DO NOTHING;
|
||||
|
||||
ELSIF was_mai AND NOT is_mai AND mai_id IS NOT NULL THEN
|
||||
-- mai management dropped → delete mai row + the pointer. FK-bound deletes
|
||||
-- (workers/tasks/sessions/messages/metrics) will fail and roll back the
|
||||
-- projax UPDATE — that is the intended safety net.
|
||||
DELETE FROM mai.projects WHERE id = mai_id;
|
||||
DELETE FROM projax.item_links
|
||||
WHERE item_id = NEW.id AND ref_type = 'mai-project';
|
||||
|
||||
ELSIF is_mai AND mai_id IS NOT NULL THEN
|
||||
-- Both states managed by mai → propagate editable fields. We DO NOT
|
||||
-- change mai.projects.id (FK targets cannot be renamed), so projax slug
|
||||
-- and mai.id may drift after a rename — tolerated because the pointer
|
||||
-- in item_links remains stable.
|
||||
mapped_status := CASE NEW.status WHEN 'done' THEN 'archived' ELSE NEW.status END;
|
||||
UPDATE mai.projects SET
|
||||
name = NEW.title,
|
||||
goal = NEW.content_md,
|
||||
repo = COALESCE(NEW.metadata->>'repo', ''),
|
||||
path = COALESCE(NEW.metadata->>'path', ''),
|
||||
memory_group = COALESCE(NEW.metadata->>'memory_group', ''),
|
||||
status = mapped_status,
|
||||
metadata = COALESCE(NEW.metadata, '{}'::jsonb),
|
||||
updated_at = now()
|
||||
WHERE id = mai_id;
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
IF 'mai' = ANY(OLD.management) THEN
|
||||
SELECT ref_id INTO mai_id
|
||||
FROM projax.item_links
|
||||
WHERE item_id = OLD.id AND ref_type = 'mai-project'
|
||||
LIMIT 1;
|
||||
IF mai_id IS NULL THEN
|
||||
mai_id := OLD.slug;
|
||||
END IF;
|
||||
DELETE FROM mai.projects WHERE id = mai_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
PERFORM set_config('projax.in_sync', 'off', true);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
PERFORM set_config('projax.in_sync', 'off', true);
|
||||
RAISE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS items_sync_to_mai ON projax.items;
|
||||
CREATE TRIGGER items_sync_to_mai
|
||||
AFTER INSERT OR UPDATE OR DELETE ON projax.items
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.sync_to_mai();
|
||||
|
||||
-- --------------------------------------------------------------------------
|
||||
-- Reverse: mai.projects -> projax.items (SECURITY DEFINER so writes by the
|
||||
-- `mai` role can fan out into projax.items, which projax_admin owns)
|
||||
-- --------------------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION projax.sync_from_mai()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = projax, mai, public
|
||||
AS $$
|
||||
DECLARE
|
||||
pid uuid;
|
||||
mapped_status text;
|
||||
BEGIN
|
||||
IF pg_trigger_depth() > 1 THEN RETURN COALESCE(NEW, OLD); END IF;
|
||||
IF current_setting('projax.in_sync', true) = 'on' THEN RETURN COALESCE(NEW, OLD); END IF;
|
||||
PERFORM set_config('projax.in_sync', 'on', true);
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
SELECT i.id INTO pid
|
||||
FROM projax.items i
|
||||
JOIN projax.item_links l ON l.item_id = i.id
|
||||
WHERE l.ref_type = 'mai-project' AND l.ref_id = NEW.id
|
||||
LIMIT 1;
|
||||
IF pid IS NULL THEN
|
||||
mapped_status := CASE NEW.status WHEN 'sleeping' THEN 'archived' ELSE NEW.status END;
|
||||
INSERT INTO projax.items
|
||||
(kind, title, slug, parent_ids, content_md, metadata, status, management)
|
||||
VALUES (
|
||||
ARRAY['project']::text[],
|
||||
NEW.name,
|
||||
NEW.id,
|
||||
'{}'::uuid[], -- root; /admin/classify will surface it for re-parenting
|
||||
COALESCE(NEW.goal, ''),
|
||||
jsonb_build_object(
|
||||
'repo', COALESCE(NEW.repo, ''),
|
||||
'path', COALESCE(NEW.path, ''),
|
||||
'memory_group', COALESCE(NEW.memory_group, '')
|
||||
) || COALESCE(NEW.metadata, '{}'::jsonb),
|
||||
mapped_status,
|
||||
ARRAY['mai']::text[]
|
||||
)
|
||||
RETURNING id INTO pid;
|
||||
INSERT INTO projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
VALUES (pid, 'mai-project', NEW.id, 'derived-from');
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
mapped_status := CASE NEW.status WHEN 'sleeping' THEN 'archived' ELSE NEW.status END;
|
||||
UPDATE projax.items i SET
|
||||
title = NEW.name,
|
||||
content_md = COALESCE(NEW.goal, ''),
|
||||
status = mapped_status,
|
||||
metadata = jsonb_build_object(
|
||||
'repo', COALESCE(NEW.repo, ''),
|
||||
'path', COALESCE(NEW.path, ''),
|
||||
'memory_group', COALESCE(NEW.memory_group, '')
|
||||
) || COALESCE(NEW.metadata, '{}'::jsonb)
|
||||
FROM projax.item_links l
|
||||
WHERE l.item_id = i.id
|
||||
AND l.ref_type = 'mai-project'
|
||||
AND l.ref_id = NEW.id;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE projax.items i SET deleted_at = now()
|
||||
FROM projax.item_links l
|
||||
WHERE l.item_id = i.id
|
||||
AND l.ref_type = 'mai-project'
|
||||
AND l.ref_id = OLD.id
|
||||
AND i.deleted_at IS NULL;
|
||||
END IF;
|
||||
|
||||
PERFORM set_config('projax.in_sync', 'off', true);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
PERFORM set_config('projax.in_sync', 'off', true);
|
||||
RAISE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS mai_projects_sync_to_projax ON mai.projects;
|
||||
CREATE TRIGGER mai_projects_sync_to_projax
|
||||
AFTER INSERT OR UPDATE OR DELETE ON mai.projects
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.sync_from_mai();
|
||||
46
db/migrations/0009_items_unified_simplify.sql
Normal file
46
db/migrations/0009_items_unified_simplify.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
-- 0009_items_unified_simplify.sql
|
||||
-- After the bidirectional sync triggers (0008), mai.projects is a derived
|
||||
-- projection of projax.items rather than an independent source. The union
|
||||
-- form would double-count every mai-managed item, so we collapse the view to
|
||||
-- a thin projection over projax.items and expose the tags+management columns
|
||||
-- the store now reads.
|
||||
--
|
||||
-- DROP+CREATE rather than CREATE OR REPLACE because Postgres refuses to add
|
||||
-- columns via the latter even when only appending.
|
||||
|
||||
DROP VIEW IF EXISTS projax.items_unified;
|
||||
CREATE VIEW projax.items_unified AS
|
||||
SELECT
|
||||
i.id,
|
||||
i.kind,
|
||||
i.title,
|
||||
i.slug,
|
||||
i.path,
|
||||
i.parent_id,
|
||||
i.content_md,
|
||||
i.aliases,
|
||||
i.metadata,
|
||||
i.status,
|
||||
i.pinned,
|
||||
i.archived,
|
||||
i.start_time,
|
||||
i.end_time,
|
||||
'projax'::text AS source,
|
||||
(
|
||||
SELECT l.ref_id FROM projax.item_links l
|
||||
WHERE l.item_id = i.id AND l.ref_type = 'mai-project'
|
||||
LIMIT 1
|
||||
) AS source_ref_id,
|
||||
i.tags,
|
||||
i.management,
|
||||
i.created_at,
|
||||
i.updated_at
|
||||
FROM projax.items i
|
||||
WHERE i.deleted_at IS NULL;
|
||||
|
||||
DO $own$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN
|
||||
EXECUTE 'ALTER VIEW projax.items_unified OWNER TO projax_admin';
|
||||
EXECUTE 'GRANT SELECT ON projax.items_unified TO projax_admin';
|
||||
END IF;
|
||||
END $own$;
|
||||
298
db/migrations/0010_multi_parent.sql
Normal file
298
db/migrations/0010_multi_parent.sql
Normal file
@@ -0,0 +1,298 @@
|
||||
-- 0010_multi_parent.sql
|
||||
-- Items become a DAG: each item has zero or more parents. The same row can
|
||||
-- surface at multiple paths (work.paliad and dev.paliad → same record).
|
||||
--
|
||||
-- Schema delta:
|
||||
-- parent_id uuid → parent_ids uuid[] NOT NULL DEFAULT '{}'
|
||||
-- path text → paths text[] NOT NULL DEFAULT '{}'
|
||||
--
|
||||
-- Uniqueness:
|
||||
-- * Partial unique on (slug) WHERE cardinality(parent_ids) = 0 — root slugs.
|
||||
-- * BEFORE trigger items_check_slug_collision enforces "no two items share
|
||||
-- a slug under any common parent" (the multi-parent generalisation that
|
||||
-- a column constraint cannot express).
|
||||
--
|
||||
-- Triggers:
|
||||
-- items_before_write BEFORE INS/UPD of slug/parent_ids — cycle
|
||||
-- guard + maintains paths[] via compute_item_paths
|
||||
-- items_after_reparent AFTER UPD of slug/parent_ids — cascades
|
||||
-- paths recompute to descendants
|
||||
-- items_check_slug_collision BEFORE INS/UPD — enforces multi-parent slug
|
||||
-- uniqueness
|
||||
-- items_after_delete AFTER DEL — scrubs deleted id from every
|
||||
-- descendant's parent_ids (we lose FK integrity
|
||||
-- on array elements; this is the manual cascade)
|
||||
|
||||
-- 0. Disable the legacy triggers so backfill UPDATEs don't fire them while
|
||||
-- the column shape is in flux.
|
||||
DROP TRIGGER IF EXISTS items_before_write ON projax.items;
|
||||
DROP TRIGGER IF EXISTS items_after_reparent ON projax.items;
|
||||
|
||||
-- The view depends on `path`, so it has to go first; we'll recreate at the
|
||||
-- end with paths[].
|
||||
DROP VIEW IF EXISTS projax.items_unified;
|
||||
|
||||
-- 1. New columns. Defaults keep existing rows intact during the add.
|
||||
ALTER TABLE projax.items
|
||||
ADD COLUMN IF NOT EXISTS parent_ids uuid[] NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN IF NOT EXISTS paths text[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- 2. Backfill from the legacy single-parent columns.
|
||||
UPDATE projax.items
|
||||
SET parent_ids = ARRAY[parent_id]
|
||||
WHERE parent_id IS NOT NULL AND cardinality(parent_ids) = 0;
|
||||
|
||||
UPDATE projax.items
|
||||
SET paths = ARRAY[path]
|
||||
WHERE path IS NOT NULL AND path <> '' AND cardinality(paths) = 0;
|
||||
|
||||
-- For root rows the legacy path column equalled the slug; the backfill above
|
||||
-- already captured that. Be defensive for any row that still has empty paths:
|
||||
UPDATE projax.items
|
||||
SET paths = ARRAY[slug]
|
||||
WHERE cardinality(paths) = 0;
|
||||
|
||||
-- 3. Path computation for the DAG. Each parent's paths are already correct
|
||||
-- (the trigger maintains them on every write; the backfill above seeded
|
||||
-- them); we append our slug to each parent path. Cycle detection uses a
|
||||
-- recursive CTE over parent_ids to compute the ancestor closure.
|
||||
CREATE OR REPLACE FUNCTION projax.compute_item_paths(p_parent_ids uuid[], p_slug text, p_self_id uuid)
|
||||
RETURNS text[]
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
AS $$
|
||||
DECLARE
|
||||
parent_paths text[];
|
||||
result text[];
|
||||
cycle_hit bool;
|
||||
BEGIN
|
||||
IF p_parent_ids IS NULL OR cardinality(p_parent_ids) = 0 THEN
|
||||
RETURN ARRAY[p_slug];
|
||||
END IF;
|
||||
|
||||
-- Direct cycle: self in parent set.
|
||||
IF p_self_id IS NOT NULL AND p_self_id = ANY(p_parent_ids) THEN
|
||||
RAISE EXCEPTION 'projax.items: cycle — item % cannot be its own parent', p_self_id
|
||||
USING errcode = 'check_violation';
|
||||
END IF;
|
||||
|
||||
-- Transitive cycle: walk the ancestor closure and look for self.
|
||||
IF p_self_id IS NOT NULL THEN
|
||||
WITH RECURSIVE closure AS (
|
||||
SELECT i.id, i.parent_ids, 1 AS depth
|
||||
FROM projax.items i WHERE i.id = ANY(p_parent_ids)
|
||||
UNION
|
||||
SELECT i.id, i.parent_ids, c.depth + 1
|
||||
FROM closure c
|
||||
JOIN projax.items i ON i.id = ANY(c.parent_ids)
|
||||
WHERE c.depth < 64
|
||||
)
|
||||
SELECT EXISTS (SELECT 1 FROM closure WHERE id = p_self_id) INTO cycle_hit;
|
||||
IF cycle_hit THEN
|
||||
RAISE EXCEPTION 'projax.items: cycle detected (item % appears in its own ancestor closure)', p_self_id
|
||||
USING errcode = 'check_violation';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Gather every parent's path and prefix to our slug.
|
||||
SELECT COALESCE(array_agg(DISTINCT pp), '{}'::text[]) INTO parent_paths
|
||||
FROM (SELECT unnest(paths) AS pp FROM projax.items WHERE id = ANY(p_parent_ids)) sub;
|
||||
|
||||
IF parent_paths IS NULL OR cardinality(parent_paths) = 0 THEN
|
||||
-- A parent referenced an unknown / pathless id — keep the slug visible
|
||||
-- rather than failing hard; the caller's INSERT will fail elsewhere if
|
||||
-- truly broken.
|
||||
RETURN ARRAY[p_slug];
|
||||
END IF;
|
||||
|
||||
SELECT array_agg(DISTINCT (pp || '.' || p_slug) ORDER BY (pp || '.' || p_slug)) INTO result
|
||||
FROM unnest(parent_paths) pp;
|
||||
|
||||
RETURN result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 4. BEFORE trigger: cycle guard + compute paths from parent_ids.
|
||||
CREATE OR REPLACE FUNCTION projax.items_before_write()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Self-parent immediate rejection.
|
||||
IF tg_op = 'UPDATE' AND new.parent_ids IS NOT NULL AND new.id = ANY(new.parent_ids) THEN
|
||||
RAISE EXCEPTION 'projax.items: parent_ids must not contain self'
|
||||
USING errcode = 'check_violation';
|
||||
END IF;
|
||||
|
||||
new.paths := projax.compute_item_paths(
|
||||
new.parent_ids,
|
||||
new.slug,
|
||||
CASE WHEN tg_op = 'UPDATE' THEN new.id ELSE NULL END
|
||||
);
|
||||
|
||||
RETURN new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS items_before_write ON projax.items;
|
||||
CREATE TRIGGER items_before_write
|
||||
BEFORE INSERT OR UPDATE OF slug, parent_ids, kind ON projax.items
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.items_before_write();
|
||||
|
||||
-- 5. Recursive descendant refresh helper. Parent-first ordering is guaranteed
|
||||
-- because we update self before iterating direct children.
|
||||
CREATE OR REPLACE FUNCTION projax.refresh_item_paths_recursive(p_id uuid)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE child_id uuid;
|
||||
BEGIN
|
||||
UPDATE projax.items
|
||||
SET paths = projax.compute_item_paths(parent_ids, slug, id)
|
||||
WHERE id = p_id;
|
||||
FOR child_id IN SELECT id FROM projax.items WHERE p_id = ANY(parent_ids) LOOP
|
||||
PERFORM projax.refresh_item_paths_recursive(child_id);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 6. AFTER trigger: when slug or parent_ids change, cascade the paths
|
||||
-- recompute to every descendant. A session GUC short-circuits the recursive
|
||||
-- UPDATEs so the cascade fires exactly once at the top.
|
||||
CREATE OR REPLACE FUNCTION projax.items_after_reparent()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE child_id uuid;
|
||||
BEGIN
|
||||
IF current_setting('projax.refreshing_paths', true) = 'on' THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
IF tg_op = 'UPDATE'
|
||||
AND (old.slug IS DISTINCT FROM new.slug OR old.parent_ids IS DISTINCT FROM new.parent_ids) THEN
|
||||
PERFORM set_config('projax.refreshing_paths', 'on', true);
|
||||
BEGIN
|
||||
FOR child_id IN SELECT id FROM projax.items WHERE new.id = ANY(parent_ids) LOOP
|
||||
PERFORM projax.refresh_item_paths_recursive(child_id);
|
||||
END LOOP;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
PERFORM set_config('projax.refreshing_paths', 'off', true);
|
||||
RAISE;
|
||||
END;
|
||||
PERFORM set_config('projax.refreshing_paths', 'off', true);
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS items_after_reparent ON projax.items;
|
||||
CREATE TRIGGER items_after_reparent
|
||||
AFTER UPDATE OF slug, parent_ids ON projax.items
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.items_after_reparent();
|
||||
|
||||
-- 7. Slug-collision under any common parent. A single (parent_id, slug)
|
||||
-- column constraint cannot express this; we check per-parent in trigger.
|
||||
CREATE OR REPLACE FUNCTION projax.items_check_slug_collision()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
pid uuid;
|
||||
collide_id uuid;
|
||||
BEGIN
|
||||
IF new.parent_ids IS NULL OR cardinality(new.parent_ids) = 0 THEN
|
||||
RETURN new; -- root collisions are caught by the partial unique index
|
||||
END IF;
|
||||
FOREACH pid IN ARRAY new.parent_ids LOOP
|
||||
SELECT id INTO collide_id
|
||||
FROM projax.items
|
||||
WHERE pid = ANY(parent_ids)
|
||||
AND slug = new.slug
|
||||
AND id <> new.id
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
IF collide_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'projax.items: slug % already exists under parent %', new.slug, pid
|
||||
USING errcode = 'unique_violation';
|
||||
END IF;
|
||||
END LOOP;
|
||||
RETURN new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS items_check_slug_collision ON projax.items;
|
||||
CREATE TRIGGER items_check_slug_collision
|
||||
BEFORE INSERT OR UPDATE OF slug, parent_ids ON projax.items
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.items_check_slug_collision();
|
||||
|
||||
-- 8. When an item is deleted, scrub its id from every descendant's
|
||||
-- parent_ids array (we have no FK integrity on array elements). The UPDATE
|
||||
-- fires items_before_write/items_after_reparent which recompute child paths.
|
||||
CREATE OR REPLACE FUNCTION projax.items_after_delete()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE projax.items
|
||||
SET parent_ids = array_remove(parent_ids, OLD.id)
|
||||
WHERE OLD.id = ANY(parent_ids);
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS items_after_delete ON projax.items;
|
||||
CREATE TRIGGER items_after_delete
|
||||
AFTER DELETE ON projax.items
|
||||
FOR EACH ROW EXECUTE FUNCTION projax.items_after_delete();
|
||||
|
||||
-- 9. Drop legacy columns + indexes + constraints. The (parent_id, slug)
|
||||
-- unique constraint and items_root_slug_uniq partial index both ride on
|
||||
-- parent_id; DROP COLUMN ... CASCADE clears them. items_path_idx rides on
|
||||
-- path; same.
|
||||
ALTER TABLE projax.items DROP COLUMN IF EXISTS parent_id CASCADE;
|
||||
ALTER TABLE projax.items DROP COLUMN IF EXISTS path CASCADE;
|
||||
DROP INDEX IF EXISTS projax.items_path_idx;
|
||||
DROP INDEX IF EXISTS projax.items_parent_idx;
|
||||
|
||||
-- 10. New indexes for the multi-parent / multi-path columns.
|
||||
CREATE INDEX IF NOT EXISTS items_parent_ids_idx ON projax.items USING gin (parent_ids);
|
||||
CREATE INDEX IF NOT EXISTS items_paths_idx ON projax.items USING gin (paths);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS items_root_slug_uniq
|
||||
ON projax.items (slug) WHERE cardinality(parent_ids) = 0;
|
||||
|
||||
-- 11. Drop the legacy single-parent path function (replaced by compute_item_paths).
|
||||
DROP FUNCTION IF EXISTS projax.compute_item_path(uuid, text, uuid);
|
||||
|
||||
-- 12. Recreate the unified view with the new paths[] / parent_ids[] columns.
|
||||
CREATE VIEW projax.items_unified AS
|
||||
SELECT
|
||||
i.id,
|
||||
i.kind,
|
||||
i.title,
|
||||
i.slug,
|
||||
i.paths,
|
||||
i.parent_ids,
|
||||
i.content_md,
|
||||
i.aliases,
|
||||
i.metadata,
|
||||
i.status,
|
||||
i.pinned,
|
||||
i.archived,
|
||||
i.start_time,
|
||||
i.end_time,
|
||||
'projax'::text AS source,
|
||||
(SELECT l.ref_id FROM projax.item_links l
|
||||
WHERE l.item_id = i.id AND l.ref_type = 'mai-project' LIMIT 1) AS source_ref_id,
|
||||
i.tags,
|
||||
i.management,
|
||||
i.created_at,
|
||||
i.updated_at
|
||||
FROM projax.items i
|
||||
WHERE i.deleted_at IS NULL;
|
||||
|
||||
DO $own$ BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'projax_admin') THEN
|
||||
EXECUTE 'ALTER VIEW projax.items_unified OWNER TO projax_admin';
|
||||
EXECUTE 'GRANT SELECT ON projax.items_unified TO projax_admin';
|
||||
END IF;
|
||||
END $own$;
|
||||
@@ -6,94 +6,71 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestItemsUnifiedHasBothSources(t *testing.T) {
|
||||
func TestItemsUnifiedShape(t *testing.T) {
|
||||
pool := connect(t)
|
||||
defer pool.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var projaxN, maiN int
|
||||
// After Phase 1.5 the view collapses to a thin projection over projax.items.
|
||||
// Source is always 'projax'; there are no 'mai.projects' rows in the view.
|
||||
var total, projaxN, otherN int
|
||||
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified`).Scan(&total); err != nil {
|
||||
t.Fatalf("total: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified where source = 'projax'`).Scan(&projaxN); err != nil {
|
||||
t.Fatalf("projax count: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified where source = 'mai.projects'`).Scan(&maiN); err != nil {
|
||||
t.Fatalf("mai count: %v", err)
|
||||
if err := pool.QueryRow(ctx, `select count(*) from projax.items_unified where source <> 'projax'`).Scan(&otherN); err != nil {
|
||||
t.Fatalf("other count: %v", err)
|
||||
}
|
||||
if projaxN < 7 {
|
||||
t.Fatalf("expected at least 7 projax-native rows in items_unified, got %d", projaxN)
|
||||
if projaxN != total {
|
||||
t.Fatalf("expected all %d rows to have source='projax', got %d projax + %d other", total, projaxN, otherN)
|
||||
}
|
||||
if maiN < 1 {
|
||||
t.Fatalf("expected at least 1 mai.projects row in items_unified, got %d", maiN)
|
||||
if total < 7 {
|
||||
t.Fatalf("expected at least the 7 seeded roots in items_unified, got %d", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemsUnifiedHidesPromotedMaiProjects(t *testing.T) {
|
||||
func TestItemsUnifiedSurfacesMaiPointer(t *testing.T) {
|
||||
pool := connect(t)
|
||||
defer pool.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
// Any item with a 'mai-project' item_link should surface source_ref_id.
|
||||
var linked, withRef int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`select count(*) from projax.item_links where ref_type='mai-project'`,
|
||||
).Scan(&linked); err != nil {
|
||||
t.Fatalf("count links: %v", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// Pick any mai.projects row currently visible in items_unified.
|
||||
var maiID string
|
||||
if err := tx.QueryRow(ctx,
|
||||
`select source_ref_id from projax.items_unified where source = 'mai.projects' limit 1`,
|
||||
).Scan(&maiID); err != nil {
|
||||
t.Fatalf("pick mai project: %v", err)
|
||||
if err := pool.QueryRow(ctx,
|
||||
`select count(*) from projax.items_unified where source_ref_id is not null`,
|
||||
).Scan(&withRef); err != nil {
|
||||
t.Fatalf("count source_ref_id: %v", err)
|
||||
}
|
||||
|
||||
// Pre-promotion: it should be visible.
|
||||
var n int
|
||||
if err := tx.QueryRow(ctx,
|
||||
`select count(*) from projax.items_unified where source_ref_id = $1 and source = 'mai.projects'`, maiID,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("pre count: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("pre-promotion count = %d, want 1", n)
|
||||
}
|
||||
|
||||
// Promote: create a projax-native item under 'dev' linked to the mai.projects row.
|
||||
var devID string
|
||||
if err := tx.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
|
||||
t.Fatalf("read dev: %v", err)
|
||||
}
|
||||
var newID string
|
||||
if err := tx.QueryRow(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_id) values (array['project']::text[], $1, $2, $3) returning id`,
|
||||
"Promoted "+maiID, "promo-"+maiID, devID,
|
||||
).Scan(&newID); err != nil {
|
||||
t.Fatalf("insert promoted: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`insert into projax.item_links (item_id, ref_type, ref_id, rel) values ($1, 'mai-project', $2, 'derived-from')`,
|
||||
newID, maiID,
|
||||
); err != nil {
|
||||
t.Fatalf("insert link: %v", err)
|
||||
}
|
||||
|
||||
// Post-promotion: the mai.projects row should drop out of the unified view.
|
||||
if err := tx.QueryRow(ctx,
|
||||
`select count(*) from projax.items_unified where source_ref_id = $1 and source = 'mai.projects'`, maiID,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("post count: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("post-promotion mai row count = %d, want 0", n)
|
||||
}
|
||||
|
||||
// And the projax-native row is there.
|
||||
if err := tx.QueryRow(ctx,
|
||||
`select count(*) from projax.items_unified where id = $1`, newID,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("post projax count: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("post-promotion projax row count = %d, want 1", n)
|
||||
if linked != withRef {
|
||||
t.Fatalf("source_ref_id surfacing diverged from item_links: %d link rows vs %d view rows with ref", linked, withRef)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhase15ColumnsPresent(t *testing.T) {
|
||||
pool := connect(t)
|
||||
defer pool.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Sanity-check tags + management + the multi-parent paths/parent_ids columns.
|
||||
row := pool.QueryRow(ctx,
|
||||
`select tags, management, paths, parent_ids from projax.items_unified
|
||||
where cardinality(parent_ids) = 0 limit 1`)
|
||||
var tags, mgmt, paths []string
|
||||
var parentIDs []string
|
||||
if err := row.Scan(&tags, &mgmt, &paths, &parentIDs); err != nil {
|
||||
t.Fatalf("scan tags/management/paths/parent_ids: %v", err)
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
t.Fatalf("expected at least one path on a root item")
|
||||
}
|
||||
}
|
||||
|
||||
182
docs/design.md
182
docs/design.md
@@ -12,40 +12,34 @@ projax is m's personal data backbone for self-management — areas of life, proj
|
||||
|
||||
## 2. Model
|
||||
|
||||
### 2.1 Two kinds, freely nestable
|
||||
### 2.1 Items in a DAG
|
||||
|
||||
```
|
||||
area ─┐
|
||||
├─ project ─┐
|
||||
│ ├─ project ─┐ (sub-projects allowed, any depth)
|
||||
│ │ └─ …
|
||||
└─ task (external ref)
|
||||
```
|
||||
Phase 1.5 collapsed the area/project structural distinction. Every node is an `item`; the `kind` array is kept for forward-compatibility (future: milestone, event, person, …) but `area` is no longer a special value. The seven seeded roots are just items with `parent_ids = '{}'`.
|
||||
|
||||
- **Area** — a container without start/end. Long-running domain of life. Areas live at the root (`parent_id = NULL`). Examples: `dev`, `sports`, `home`, `work`, `health`, `finances`, `social`.
|
||||
- **Project** — a bounded effort with (usually) a start and an end. Lives under an area, or under another project. Sub-projects nest to arbitrary depth (`home.spring-clean.bathroom.tiles` is fine if that's how m thinks). Examples: `home.spring-clean`, `dev.prjx`, `sports.giro-okt`.
|
||||
The hierarchy is a **directed acyclic graph**, not a tree: each item has zero or more parents, and the same item can surface under multiple branches. `work.paliad` and `dev.paliad` resolve to the same row. PER citations can use any valid path.
|
||||
|
||||
- **Item** — a node in the DAG. Examples: `dev`, `home.spring-clean`, `work.paliad`, `paliad.note`. The `kind` column carries `['project']` today; we may layer other types as needs arise.
|
||||
- **Task** — atomic work item. **Lives outside projax** (CalDAV todos, Gitea issues, `mai.tasks`). projax references and aggregates them; it does not own them.
|
||||
|
||||
Areas and projects share one table (`projax.items`) distinguished by the `kind` array column. The tree (`parent_id`) is unconstrained on depth; the only structural rules are:
|
||||
Structural rules:
|
||||
|
||||
- An area's `parent_id` must be `NULL` (areas are roots).
|
||||
- A project's `parent_id` must point to an area or another project (no project at root).
|
||||
- No cycles (enforced by the path trigger).
|
||||
|
||||
Tasks are referenced via `projax.item_links`.
|
||||
- No cycles (enforced by `items_before_write` + `compute_item_paths` recursive-CTE ancestor closure).
|
||||
- An item's slug must be unique among its siblings under any common parent (enforced by `items_check_slug_collision` BEFORE trigger, with the partial unique index `items_root_slug_uniq` covering the root case).
|
||||
- Soft delete via `deleted_at`. Hard delete cascades through `items_after_delete`, which scrubs the deleted id from every descendant's `parent_ids` array.
|
||||
|
||||
### 2.2 Identity & naming
|
||||
|
||||
- `id uuid` — canonical, immutable.
|
||||
- `slug text` — local-only segment (no dots). Renameable freely. Examples: `prjx`, `spring-clean`, `upc.deadlines` would be split as parent slug `upc` + child slug `deadlines`.
|
||||
- `path text` — full dot-joined path computed from parent walk. Cached column maintained by trigger; not the source of truth.
|
||||
- Slug convention: lowercase, vowel-elided where natural (`prjx`, `mai`, `mbrn` if m wishes), kebab-allowed for multi-word leaves (`spring-clean`).
|
||||
- `slug text` — local-only segment (no dots). Renameable freely. Examples: `prjx`, `spring-clean`. Multi-word leaves use kebab-case.
|
||||
- `parent_ids uuid[]` — zero or more parent ids. Root items have `parent_ids = '{}'`.
|
||||
- `paths text[]` — full dot-joined paths, one per ancestor lineage. Trigger-maintained from `parent_ids` + `slug`. Lookup via `'<path>' = ANY(paths)`.
|
||||
- Slug convention: lowercase, vowel-elided where natural (`prjx`, `mai`, `mbrn`), kebab-allowed for multi-word leaves (`spring-clean`).
|
||||
- Aliases: `aliases text[]` keeps old slugs searchable after rename.
|
||||
- `tags text[]` — free-vocabulary cross-cutting labels (`work`, `dev`, `home`, `tech`, …). GIN-indexed. No fixed vocabulary.
|
||||
- `management text[]` — how the project is run: `self`, `mai`, `external`. An item can carry multiple modes. Empty array means "no specific management mode declared".
|
||||
|
||||
### 2.3 Lifecycle (thin)
|
||||
|
||||
For projects only — areas don't have lifecycle.
|
||||
|
||||
```
|
||||
active → done → archived
|
||||
```
|
||||
@@ -54,8 +48,8 @@ That's it. Free-text in `content_md` covers the nuance ("waiting on Brian," "pau
|
||||
|
||||
### 2.4 Relationships
|
||||
|
||||
- **Tree** (parent/child within projax): `items.parent_id uuid` self-FK. Areas have `parent_id = NULL`. Projects point at their area or another project. Arbitrary nesting depth.
|
||||
- **External refs** (`projax.item_links`): each row links an `item_id` to a typed external resource — caldav-todo, gitea-issue, mai-task, mai-project, mbrian-node, etc. Used both for aggregating tasks and for soft cross-references.
|
||||
- **Tree-as-DAG** (parent/child within projax): `items.parent_ids uuid[]`. Root items have `parent_ids = '{}'`. Any item may name multiple parents — the same row then appears under each branch with paths inherited from each lineage.
|
||||
- **External refs** (`projax.item_links`): each row links an `item_id` to a typed external resource — `caldav-todo`, `gitea-issue`, `github-repo`, `mai-task`, `mai-project`, `mbrian-node`, `url`, etc. Used both for aggregating tasks and for soft cross-references.
|
||||
|
||||
## 3. Schema (Postgres, msupabase, schema `projax`)
|
||||
|
||||
@@ -70,27 +64,34 @@ create table projax.items (
|
||||
path text not null, -- computed, e.g. 'home.spring-clean'
|
||||
parent_id uuid references projax.items(id) on delete restrict,
|
||||
content_md text default '',
|
||||
aliases text[] default '{}',
|
||||
aliases text[] not null default '{}',
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
status text not null default 'active', -- active | done | archived (projects only)
|
||||
status text not null default 'active', -- active | done | archived
|
||||
pinned boolean not null default false,
|
||||
archived boolean not null default false,
|
||||
start_time timestamptz,
|
||||
end_time timestamptz,
|
||||
parent_ids uuid[] not null default '{}',
|
||||
paths text[] not null default '{}',
|
||||
tags text[] not null default '{}',
|
||||
management text[] not null default '{}',
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
deleted_at timestamptz,
|
||||
unique (parent_id, slug)
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
create index items_path_idx on projax.items (path);
|
||||
create index items_kind_idx on projax.items using gin (kind);
|
||||
create index items_parent_idx on projax.items (parent_id);
|
||||
create index items_paths_idx on projax.items using gin (paths);
|
||||
create index items_parent_ids_idx on projax.items using gin (parent_ids);
|
||||
create index items_kind_idx on projax.items using gin (kind);
|
||||
create index items_tags_idx on projax.items using gin (tags);
|
||||
create index items_management_idx on projax.items using gin (management);
|
||||
create unique index items_root_slug_uniq
|
||||
on projax.items (slug) where cardinality(parent_ids) = 0;
|
||||
|
||||
create table projax.item_links (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
item_id uuid not null references projax.items(id) on delete cascade,
|
||||
ref_type text not null, -- 'caldav-todo' | 'gitea-issue' | 'mai-task' | 'mai-project' | 'mbrian-node' | 'url' | ...
|
||||
ref_type text not null, -- 'caldav-todo' | 'gitea-issue' | 'github-repo' | 'mai-task' | 'mai-project' | 'mbrian-node' | 'url' | ...
|
||||
ref_id text not null, -- opaque external identifier
|
||||
rel text not null default 'contains', -- 'contains' | 'related' | 'blocked-by' | 'derived-from'
|
||||
note text,
|
||||
@@ -103,81 +104,92 @@ create index item_links_item_idx on projax.item_links (item_id);
|
||||
create index item_links_ref_idx on projax.item_links (ref_type, ref_id);
|
||||
```
|
||||
|
||||
### 3.1 The `path` trigger
|
||||
### 3.1 Path triggers (multi-parent)
|
||||
|
||||
`path` is maintained by trigger on insert/update: walks `parent_id` to the root, joins slugs with `.`. Recomputed for the subtree when a parent is renamed or re-parented. Keeps queries cheap.
|
||||
`paths` is a `text[]` maintained by `compute_item_paths(parent_ids, slug, self_id)`:
|
||||
|
||||
### 3.2 The mai.projects adapter view
|
||||
- For root items (`parent_ids = '{}'`), `paths = [slug]`.
|
||||
- Otherwise, look up every parent's `paths`, append `.<slug>` to each, dedupe and sort. The recursion is implicit — parents' paths are kept up to date by the same trigger, so children just consume the precomputed prefixes.
|
||||
- Cycle detection: the function rejects when `self_id` appears anywhere in the recursive ancestor closure (`WITH RECURSIVE closure ...`). Plus a defensive direct-self-parent check.
|
||||
|
||||
mai.projects stays untouched. projax surfaces it in the unified item stream via a read-only view:
|
||||
Two BEFORE triggers and two AFTER triggers cooperate:
|
||||
|
||||
- `items_before_write` (BEFORE INSERT/UPDATE) — cycle guard + `new.paths := compute_item_paths(...)`.
|
||||
- `items_check_slug_collision` (BEFORE INSERT/UPDATE) — for each parent in `new.parent_ids`, refuse if another row already uses `new.slug` under that parent.
|
||||
- `items_after_reparent` (AFTER UPDATE of slug/parent_ids) — DFS over descendants via `refresh_item_paths_recursive`, parent-first ordering. A session GUC `projax.refreshing_paths` short-circuits the inner UPDATEs so the cascade fires exactly once.
|
||||
- `items_after_delete` (AFTER DELETE) — scrubs the deleted id from every other row's `parent_ids` array (we have no FK integrity on array elements; this is the manual cascade).
|
||||
|
||||
### 3.2 The `items_unified` view
|
||||
|
||||
After Phase 1.5 mai.projects is a derived projection (see §3.4), so the view collapses to a thin projection over `projax.items`:
|
||||
|
||||
```sql
|
||||
create or replace view projax.items_unified as
|
||||
create view projax.items_unified as
|
||||
select
|
||||
id,
|
||||
kind,
|
||||
title,
|
||||
slug,
|
||||
path,
|
||||
parent_id,
|
||||
content_md,
|
||||
status,
|
||||
pinned,
|
||||
archived,
|
||||
start_time,
|
||||
end_time,
|
||||
i.id, i.kind, i.title, i.slug, i.paths, i.parent_ids, i.content_md,
|
||||
i.aliases, i.metadata, i.status, i.pinned, i.archived,
|
||||
i.start_time, i.end_time,
|
||||
'projax'::text as source,
|
||||
created_at,
|
||||
updated_at
|
||||
from projax.items
|
||||
where deleted_at is null
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
('00000000-0000-0000-0000-' || substr(md5(p.id), 1, 12))::uuid as id, -- deterministic placeholder
|
||||
array['project']::text[] as kind,
|
||||
p.name as title,
|
||||
p.id as slug,
|
||||
'mai.' || p.id as path,
|
||||
null::uuid as parent_id,
|
||||
coalesce(p.goal, '') as content_md,
|
||||
case p.status
|
||||
when 'active' then 'active'
|
||||
when 'sleeping' then 'archived'
|
||||
when 'archived' then 'archived'
|
||||
else 'active'
|
||||
end as status,
|
||||
false as pinned,
|
||||
(p.status = 'archived') as archived,
|
||||
null::timestamptz as start_time,
|
||||
null::timestamptz as end_time,
|
||||
'mai.projects'::text as source,
|
||||
p.created_at,
|
||||
p.updated_at
|
||||
from mai.projects p;
|
||||
(select l.ref_id from projax.item_links l
|
||||
where l.item_id = i.id and l.ref_type = 'mai-project' limit 1) as source_ref_id,
|
||||
i.tags, i.management, i.created_at, i.updated_at
|
||||
from projax.items i
|
||||
where i.deleted_at is null;
|
||||
```
|
||||
|
||||
UI reads `items_unified`. Writes target `projax.items` only. Once m wants to fully migrate, the `mai.projects` half is dropped from the view and rows are copied across with real UUIDs + proper parent assignment.
|
||||
`source` is always `'projax'` (kept for forward compat); `source_ref_id` surfaces the `mai-project` pointer when one exists so the UI can show "mai id: foo".
|
||||
|
||||
### 3.3 Classification overlay
|
||||
|
||||
For each mai.projects row, m can later promote it into projax-native (assigning area parent, real slug, kind tweak). Until promoted it appears as a top-level orphan project tagged `source=mai.projects`. An admin page surfaces the unmapped set and lets m one-click classify.
|
||||
Items can land at root in two ways:
|
||||
|
||||
- The backfill in migration 0007 heuristic-assigned every existing mai.projects row to one of the seven seeded areas (`dev`, `sports`, `work`, `home`, `health`, `finances`, `social`). None ended up at root in this pass.
|
||||
- The reverse sync trigger (§3.4) drops every NEW mai.projects row at root with `management = ['mai']`, leaving m to classify it via `/admin/classify`.
|
||||
|
||||
`/admin/classify` surfaces items where `cardinality(parent_ids) = 0 AND 'mai' = ANY(management)`. The inline form posts to `/i/{path}/reparent` to move the item under a chosen parent without touching its other fields.
|
||||
|
||||
### 3.4 mai.projects bidirectional sync (Phase 1.5)
|
||||
|
||||
`mai.workers`, `mai.tasks`, `mai.sessions`, `mai.messages`, `mai.metrics` and `mai.pwa_head_pins` hold FKs into `mai.projects(id)`, so the table cannot be replaced by a view. Instead it becomes a **derived projection** kept in sync by triggers:
|
||||
|
||||
- **Forward** (`projax.sync_to_mai`, AFTER INSERT/UPDATE/DELETE on `projax.items`) — when an item has `'mai' = ANY(management)`, upsert/update/delete the matching `mai.projects` row. Slug stays the join key (`mai.projects.id = projax.items.slug` at creation), but FK targets cannot be renamed, so projax slug and mai id may drift after a rename; the cross-system pointer in `item_links(ref_type='mai-project')` remains stable.
|
||||
- **Reverse** (`projax.sync_from_mai`, AFTER INSERT/UPDATE/DELETE on `mai.projects`, `SECURITY DEFINER` so `mai` role writes can fan out into `projax.items` which projax_admin owns) — mirror the change into a `projax.items` row, dropping new rows at root with `management = ['mai']` so `/admin/classify` can pick them up.
|
||||
- **Cycle prevention** — both functions short-circuit when `pg_trigger_depth() > 1` (the natural recursion case) and additionally honour a `projax.in_sync` session GUC as belt-and-braces.
|
||||
|
||||
The mai.projects → projax.items reverse trigger requires manual prereqs on msupabase:
|
||||
|
||||
```sql
|
||||
GRANT INSERT, UPDATE, DELETE, TRIGGER ON mai.projects TO projax_admin;
|
||||
DROP POLICY IF EXISTS projax_write ON mai.projects;
|
||||
CREATE POLICY projax_write ON mai.projects FOR ALL TO projax_admin
|
||||
USING (true) WITH CHECK (true);
|
||||
```
|
||||
|
||||
(documented in 0008's header and the README).
|
||||
|
||||
## 4. Interfaces
|
||||
|
||||
### 4.1 Phase 1 — MVP (this build)
|
||||
### 4.1 Phase 1 + 1.5 — Web frontend (this build)
|
||||
|
||||
**Web frontend at `https://projax.msbls.de`**, single binary, served by the same Go process that talks to msupabase.
|
||||
|
||||
Pages:
|
||||
|
||||
1. **Tree view** (`/`) — collapsible tree of areas → projects, reads `items_unified`. Status badges, search bar (slug, title, alias, content_md).
|
||||
2. **Item detail** (`/i/{path}`) — full editor for projax-native items (title, slug, parent, kind, status, start/end, content_md). Read-only view for mai.projects-sourced rows with a "Promote to projax" button.
|
||||
3. **New item** (`/new?parent={path}`) — small form, prefilled with parent.
|
||||
4. **Classify orphans** (`/admin/classify`) — list of unmapped mai.projects rows, inline assign-to-area control.
|
||||
1. **Tree view** (`/`) — DAG rendering: every item appears under each of its parents. Status / management / tag chips per row. Tag filter chips at the top toggle a `?tag=<csv>` filter (an item is shown when it or any descendant carries every active tag). `×N` badge on multi-parent items shows how many paths they live at.
|
||||
2. **Item detail** (`/i/{path}`) — `{path}` matches any entry in `paths`; both `work.paliad` and `dev.paliad` resolve to the same row. The page shows the primary path plus an "Also at: …" breadcrumb for the others. Edit form supports title, slug, multi-select parents, status, tags, management, pinned/archived, content. Save POSTs to `/i/{path}`.
|
||||
3. **New item** (`/new?parent={path}`) — same form shape; the `parent` query pre-selects one parent option, m can pick more.
|
||||
4. **Classify** (`/admin/classify`) — surfaces items at root with `'mai' = ANY(management)`. Inline HTMX form sets the first parent. POSTs to `/i/{path}/reparent`.
|
||||
5. **Auth** — projax's own `/login` (mBrian pattern). Same Supabase backend, per-host cookies (no `Domain` attribute).
|
||||
|
||||
Auth: shared msupabase login (matches flexsiebels precedent), single-user m.
|
||||
### 4.2 Tags + management
|
||||
|
||||
- **Tags** (`projax.items.tags text[]`, GIN-indexed) — free vocabulary, no fixed list. Cross-cutting labels for "work-y dev things" (`['work', 'dev']`), "health priorities" (`['health']`), etc. Filter chips at `/` reveal which tag-flavoured slices exist.
|
||||
- **Management** (`projax.items.management text[]`, GIN-indexed) — declarative mode flags:
|
||||
- `mai` — bidirectional sync with `mai.projects`. Adding/removing `mai` toggles the mai.projects mirror on/off (with FK safety: removal fails if workers/tasks still reference the project).
|
||||
- `self` — m runs this manually; otto does not orchestrate.
|
||||
- `external` — owned by a third party; projax mirrors metadata only.
|
||||
|
||||
Mai.projects backfilled rows arrive with `management = ['mai']`. m can layer `self` on top without dropping mai sync.
|
||||
|
||||
### 4.2 Phase 2 — task aggregation
|
||||
|
||||
|
||||
249
store/store.go
249
store/store.go
@@ -11,15 +11,18 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Item is the unified row shape: projax-native items and mai.projects rows
|
||||
// both render through this struct.
|
||||
// Item is the unified row shape served by projax.items_unified. Phase 1.5
|
||||
// collapsed the area/project distinction (kind keeps the slot for future
|
||||
// types but 'area' is no longer a special value) and extended the tree to
|
||||
// a DAG: an item can have zero or more parents and surface under multiple
|
||||
// paths simultaneously.
|
||||
type Item struct {
|
||||
ID string
|
||||
Kind []string
|
||||
Title string
|
||||
Slug string
|
||||
Path string
|
||||
ParentID *string
|
||||
Paths []string // sorted, deduped — one entry per ancestor lineage
|
||||
ParentIDs []string
|
||||
ContentMD string
|
||||
Aliases []string
|
||||
Metadata map[string]any
|
||||
@@ -28,18 +31,45 @@ type Item struct {
|
||||
Archived bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
Source string // "projax" or "mai.projects"
|
||||
SourceRefID *string // mai.projects.id when Source = "mai.projects"
|
||||
Source string // always "projax" after Phase 1.5; kept for forward-compat
|
||||
SourceRefID *string // mai.projects.id when a 'mai-project' item_links row exists
|
||||
Tags []string
|
||||
Management []string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// IsArea reports whether this item should be treated as a top-level container.
|
||||
func (it *Item) IsArea() bool { return slices.Contains(it.Kind, "area") }
|
||||
// IsRoot reports whether this item sits at the top of the DAG (no parents).
|
||||
func (it *Item) IsRoot() bool { return len(it.ParentIDs) == 0 }
|
||||
|
||||
// Editable reports whether the UI may edit this row directly.
|
||||
// mai.projects rows are read-only — they must be promoted first.
|
||||
func (it *Item) Editable() bool { return it.Source == "projax" }
|
||||
// PrimaryPath returns the first path (alphabetically) for routing & display.
|
||||
// Empty string when paths is empty (defensive — every persisted row has at
|
||||
// least one path).
|
||||
func (it *Item) PrimaryPath() string {
|
||||
if len(it.Paths) == 0 {
|
||||
return ""
|
||||
}
|
||||
return it.Paths[0]
|
||||
}
|
||||
|
||||
// OtherPaths returns all paths except the primary one, for the "also at: …"
|
||||
// breadcrumb on the detail page.
|
||||
func (it *Item) OtherPaths() []string {
|
||||
if len(it.Paths) <= 1 {
|
||||
return nil
|
||||
}
|
||||
return it.Paths[1:]
|
||||
}
|
||||
|
||||
// HasManagement reports whether the given mode (e.g. "mai") is set on the item.
|
||||
func (it *Item) HasManagement(mode string) bool { return slices.Contains(it.Management, mode) }
|
||||
|
||||
// HasTag reports whether the item carries the given tag.
|
||||
func (it *Item) HasTag(tag string) bool { return slices.Contains(it.Tags, tag) }
|
||||
|
||||
// Editable is preserved for template forward-compat. All rows are editable
|
||||
// in projax after the mai.projects unification.
|
||||
func (it *Item) Editable() bool { return true }
|
||||
|
||||
// SourceRefDeref returns the source ref id (empty string if nil) for templates.
|
||||
func (it *Item) SourceRefDeref() string {
|
||||
@@ -58,16 +88,17 @@ func New(pool *pgxpool.Pool) *Store { return &Store{Pool: pool} }
|
||||
|
||||
var ErrNotFound = errors.New("projax: item not found")
|
||||
|
||||
const itemsUnifiedCols = `id, kind, title, slug, path, parent_id, content_md, aliases,
|
||||
const itemsUnifiedCols = `id, kind, title, slug, paths, parent_ids, content_md, aliases,
|
||||
metadata, status, pinned, archived, start_time, end_time, source, source_ref_id,
|
||||
created_at, updated_at`
|
||||
tags, management, created_at, updated_at`
|
||||
|
||||
func scanItem(row pgx.Row) (*Item, error) {
|
||||
var it Item
|
||||
if err := row.Scan(
|
||||
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
|
||||
&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.Source, &it.SourceRefID,
|
||||
&it.Tags, &it.Management,
|
||||
&it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -81,9 +112,10 @@ func scanItems(rows pgx.Rows) ([]*Item, error) {
|
||||
for rows.Next() {
|
||||
var it Item
|
||||
if err := rows.Scan(
|
||||
&it.ID, &it.Kind, &it.Title, &it.Slug, &it.Path, &it.ParentID, &it.ContentMD,
|
||||
&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.Source, &it.SourceRefID,
|
||||
&it.Tags, &it.Management,
|
||||
&it.CreatedAt, &it.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -96,21 +128,18 @@ func scanItems(rows pgx.Rows) ([]*Item, error) {
|
||||
// ListAll returns every visible row from items_unified. Caller groups by tree.
|
||||
func (s *Store) ListAll(ctx context.Context) ([]*Item, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
order by source = 'projax' desc, path`)
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified order by paths[1]`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// GetByPath looks up a single item by path. Projax-native wins on collision.
|
||||
// GetByPath looks up a single item by any of its paths. Multi-parent items
|
||||
// can be accessed via /i/work.paliad or /i/dev.paliad interchangeably.
|
||||
func (s *Store) GetByPath(ctx context.Context, path string) (*Item, error) {
|
||||
row := s.Pool.QueryRow(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
where path = $1
|
||||
order by source = 'projax' desc
|
||||
limit 1`, path)
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified where $1 = any(paths) limit 1`, path)
|
||||
it, err := scanItem(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -135,11 +164,11 @@ func (s *Store) GetByID(ctx context.Context, id string) (*Item, error) {
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// Areas lists all root-level area items, ordered by slug.
|
||||
func (s *Store) Areas(ctx context.Context) ([]*Item, error) {
|
||||
// Roots returns the top-level items (no parents), ordered by slug.
|
||||
func (s *Store) Roots(ctx context.Context) ([]*Item, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
where source = 'projax' and parent_id is null and 'area' = any(kind)
|
||||
where cardinality(parent_ids) = 0
|
||||
order by slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -147,13 +176,15 @@ func (s *Store) Areas(ctx context.Context) ([]*Item, error) {
|
||||
return scanItems(rows)
|
||||
}
|
||||
|
||||
// MaiOrphans lists rows that originated in mai.projects and have not been
|
||||
// promoted yet. Used by /admin/classify.
|
||||
// MaiOrphans lists mai-managed items that landed at root and need m to
|
||||
// re-parent them. This includes both backfilled items that the heuristic
|
||||
// misplaced and brand-new mai.projects rows created by mai code (which the
|
||||
// reverse sync trigger drops at root by design).
|
||||
func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select `+itemsUnifiedCols+` from projax.items_unified
|
||||
where source = 'mai.projects'
|
||||
order by path`)
|
||||
where cardinality(parent_ids) = 0 and 'mai' = any(management)
|
||||
order by slug`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -162,15 +193,18 @@ func (s *Store) MaiOrphans(ctx context.Context) ([]*Item, error) {
|
||||
|
||||
// CreateInput captures the editable surface of a projax-native item.
|
||||
type CreateInput struct {
|
||||
Kind []string
|
||||
Title string
|
||||
Slug string
|
||||
ParentID *string
|
||||
ContentMD string
|
||||
Status string
|
||||
Pinned bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
Kind []string
|
||||
Title string
|
||||
Slug string
|
||||
ParentIDs []string
|
||||
ContentMD string
|
||||
Status string
|
||||
Pinned bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
Tags []string
|
||||
Management []string
|
||||
Metadata map[string]any
|
||||
}
|
||||
|
||||
func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
|
||||
@@ -186,13 +220,28 @@ func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
|
||||
if in.Status == "" {
|
||||
in.Status = "active"
|
||||
}
|
||||
if in.Tags == nil {
|
||||
in.Tags = []string{}
|
||||
}
|
||||
if in.Management == nil {
|
||||
in.Management = []string{}
|
||||
}
|
||||
if in.ParentIDs == nil {
|
||||
in.ParentIDs = []string{}
|
||||
}
|
||||
metadata := in.Metadata
|
||||
if metadata == nil {
|
||||
metadata = map[string]any{}
|
||||
}
|
||||
var id string
|
||||
err := s.Pool.QueryRow(ctx, `
|
||||
insert into projax.items
|
||||
(kind, title, slug, parent_id, content_md, status, pinned, start_time, end_time)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
(kind, title, slug, parent_ids, content_md, status, pinned, start_time, end_time,
|
||||
tags, management, metadata)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
returning id`,
|
||||
in.Kind, in.Title, in.Slug, in.ParentID, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
|
||||
in.Kind, in.Title, in.Slug, in.ParentIDs, in.ContentMD, in.Status, in.Pinned, in.StartTime, in.EndTime,
|
||||
in.Tags, in.Management, metadata,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert: %w", err)
|
||||
@@ -202,25 +251,38 @@ func (s *Store) Create(ctx context.Context, in CreateInput) (*Item, error) {
|
||||
|
||||
// UpdateInput captures the editable surface of an existing projax-native item.
|
||||
type UpdateInput struct {
|
||||
Title string
|
||||
Slug string
|
||||
ParentID *string
|
||||
ContentMD string
|
||||
Status string
|
||||
Pinned bool
|
||||
Archived bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
Title string
|
||||
Slug string
|
||||
ParentIDs []string
|
||||
ContentMD string
|
||||
Status string
|
||||
Pinned bool
|
||||
Archived bool
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
Tags []string
|
||||
Management []string
|
||||
}
|
||||
|
||||
func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, error) {
|
||||
if in.Tags == nil {
|
||||
in.Tags = []string{}
|
||||
}
|
||||
if in.Management == nil {
|
||||
in.Management = []string{}
|
||||
}
|
||||
if in.ParentIDs == nil {
|
||||
in.ParentIDs = []string{}
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
update projax.items
|
||||
set title=$2, slug=$3, parent_id=$4, content_md=$5,
|
||||
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10
|
||||
set title=$2, slug=$3, parent_ids=$4, content_md=$5,
|
||||
status=$6, pinned=$7, archived=$8, start_time=$9, end_time=$10,
|
||||
tags=$11, management=$12
|
||||
where id=$1 and deleted_at is null`,
|
||||
id, in.Title, in.Slug, in.ParentID, in.ContentMD,
|
||||
id, in.Title, in.Slug, in.ParentIDs, in.ContentMD,
|
||||
in.Status, in.Pinned, in.Archived, in.StartTime, in.EndTime,
|
||||
in.Tags, in.Management,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update: %w", err)
|
||||
@@ -228,43 +290,58 @@ func (s *Store) Update(ctx context.Context, id string, in UpdateInput) (*Item, e
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// Promote turns a mai.projects orphan into a projax-native project under the
|
||||
// chosen parent. The promotion link causes items_unified to hide the source.
|
||||
func (s *Store) Promote(ctx context.Context, maiID, parentID, slug, title string) (*Item, error) {
|
||||
tx, err := s.Pool.Begin(ctx)
|
||||
// Reparent replaces parent_ids entirely with the given set. Used by the
|
||||
// classify page's inline form and any "move to under X" action.
|
||||
func (s *Store) Reparent(ctx context.Context, id string, parentIDs []string) (*Item, error) {
|
||||
if parentIDs == nil {
|
||||
parentIDs = []string{}
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx,
|
||||
`update projax.items set parent_ids = $2 where id = $1 and deleted_at is null`,
|
||||
id, parentIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reparent: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// AddParent appends a parent without disturbing existing ones — used by the
|
||||
// multi-parent UI to surface a project under a second branch.
|
||||
func (s *Store) AddParent(ctx context.Context, id, parentID string) (*Item, error) {
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
update projax.items
|
||||
set parent_ids = case
|
||||
when $2::uuid = any(parent_ids) then parent_ids
|
||||
else array_append(parent_ids, $2::uuid)
|
||||
end
|
||||
where id = $1 and deleted_at is null`,
|
||||
id, parentID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add parent: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// AllTags returns the deduplicated tag vocabulary in alphabetical order.
|
||||
// Used by the tree page filter chips.
|
||||
func (s *Store) AllTags(ctx context.Context) ([]string, error) {
|
||||
rows, err := s.Pool.Query(ctx,
|
||||
`select distinct unnest(tags) as tag from projax.items where deleted_at is null order by tag`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var newID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
insert into projax.items (kind, title, slug, parent_id, content_md, metadata, status)
|
||||
select array['project']::text[], $1, $2, $3,
|
||||
coalesce(goal, ''),
|
||||
coalesce(metadata, '{}'::jsonb),
|
||||
case status
|
||||
when 'sleeping' then 'archived'
|
||||
when 'archived' then 'archived'
|
||||
when 'done' then 'done'
|
||||
else 'active' end
|
||||
from mai.projects where id = $4
|
||||
returning id`,
|
||||
title, slug, parentID, maiID,
|
||||
).Scan(&newID); err != nil {
|
||||
return nil, fmt.Errorf("promote insert: %w", err)
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var t string
|
||||
if err := rows.Scan(&t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
insert into projax.item_links (item_id, ref_type, ref_id, rel)
|
||||
values ($1, 'mai-project', $2, 'derived-from')`,
|
||||
newID, maiID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("promote link: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetByID(ctx, newID)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SoftDelete marks a projax-native item deleted_at = now().
|
||||
|
||||
286
web/server.go
286
web/server.go
@@ -44,6 +44,32 @@ func New(s *store.Store, logger *slog.Logger) (*Server, error) {
|
||||
}
|
||||
return *p
|
||||
},
|
||||
"join": func(sep string, parts []string) string { return strings.Join(parts, sep) },
|
||||
"contains": func(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
"tagToggleURL": func(active []string, tag string, isActive bool) string {
|
||||
next := []string{}
|
||||
if isActive {
|
||||
for _, t := range active {
|
||||
if t != tag {
|
||||
next = append(next, t)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next = append(next, active...)
|
||||
next = append(next, tag)
|
||||
}
|
||||
if len(next) == 0 {
|
||||
return "/"
|
||||
}
|
||||
return "/?tag=" + strings.Join(next, ",")
|
||||
},
|
||||
}
|
||||
pages := map[string]*template.Template{}
|
||||
for _, name := range []string{"tree", "detail", "new", "classify", "error"} {
|
||||
@@ -112,13 +138,21 @@ func (s *Server) handleTree(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
areas, orphans, projaxN, maiN := buildForest(items)
|
||||
tags, err := s.Store.AllTags(r.Context())
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
activeTags := parseCSV(r.URL.Query().Get("tag"))
|
||||
roots, orphans, total, orphanN := buildForest(items, activeTags)
|
||||
s.render(w, "tree", map[string]any{
|
||||
"Title": "tree",
|
||||
"Areas": areas,
|
||||
"Orphans": orphans,
|
||||
"ProjaxCount": projaxN,
|
||||
"MaiCount": maiN,
|
||||
"Title": "tree",
|
||||
"Roots": roots,
|
||||
"Orphans": orphans,
|
||||
"Total": total,
|
||||
"OrphanN": orphanN,
|
||||
"AllTags": tags,
|
||||
"ActiveTags": activeTags,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -148,8 +182,8 @@ func (s *Server) handleDetail(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/i/")
|
||||
if base, ok := strings.CutSuffix(path, "/promote"); ok {
|
||||
s.handlePromote(w, r, base)
|
||||
if base, ok := strings.CutSuffix(path, "/reparent"); ok {
|
||||
s.handleReparent(w, r, base)
|
||||
return
|
||||
}
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
@@ -157,68 +191,115 @@ func (s *Server) handleDetailWrite(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if !it.Editable() {
|
||||
http.Error(w, "read-only source — use /promote", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
var parentID *string
|
||||
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
||||
parentID = &v
|
||||
parentIDs := r.Form["parent_ids"]
|
||||
if len(parentIDs) == 0 {
|
||||
// Legacy single-value field for the classify HTMX action.
|
||||
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
||||
parentIDs = []string{v}
|
||||
}
|
||||
}
|
||||
parentIDs = dedupeStrings(parentIDs)
|
||||
in := store.UpdateInput{
|
||||
Title: strings.TrimSpace(r.FormValue("title")),
|
||||
Slug: strings.TrimSpace(r.FormValue("slug")),
|
||||
ParentID: parentID,
|
||||
ContentMD: r.FormValue("content_md"),
|
||||
Status: strings.TrimSpace(r.FormValue("status")),
|
||||
Pinned: r.FormValue("pinned") == "1",
|
||||
Archived: r.FormValue("archived") == "1",
|
||||
Title: strings.TrimSpace(r.FormValue("title")),
|
||||
Slug: strings.TrimSpace(r.FormValue("slug")),
|
||||
ParentIDs: parentIDs,
|
||||
ContentMD: r.FormValue("content_md"),
|
||||
Status: strings.TrimSpace(r.FormValue("status")),
|
||||
Pinned: r.FormValue("pinned") == "1",
|
||||
Archived: r.FormValue("archived") == "1",
|
||||
Tags: parseCSV(r.FormValue("tags")),
|
||||
Management: parseCSV(r.FormValue("management")),
|
||||
}
|
||||
updated, err := s.Store.Update(r.Context(), it.ID, in)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+updated.Path, http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/i/"+updated.PrimaryPath(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request, path string) {
|
||||
// handleReparent replaces parent_ids. /admin/classify uses this to move
|
||||
// a root mai-managed item under a chosen parent without touching other fields.
|
||||
// HTMX-friendly: returns a fragment when HX-Request is set.
|
||||
func (s *Server) handleReparent(w http.ResponseWriter, r *http.Request, path string) {
|
||||
it, err := s.Store.GetByPath(r.Context(), path)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if it.Source != "mai.projects" || it.SourceRefID == nil {
|
||||
http.Error(w, "promote: not a mai.projects row", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
parentID := strings.TrimSpace(r.FormValue("parent_id"))
|
||||
slug := strings.TrimSpace(r.FormValue("slug"))
|
||||
title := strings.TrimSpace(r.FormValue("title"))
|
||||
if parentID == "" || slug == "" || title == "" {
|
||||
http.Error(w, "promote: parent_id, slug, title required", http.StatusBadRequest)
|
||||
parentIDs := r.Form["parent_ids"]
|
||||
if len(parentIDs) == 0 {
|
||||
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
||||
parentIDs = []string{v}
|
||||
}
|
||||
}
|
||||
parentIDs = dedupeStrings(parentIDs)
|
||||
if len(parentIDs) == 0 {
|
||||
http.Error(w, "reparent: parent_ids required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
newItem, err := s.Store.Promote(r.Context(), *it.SourceRefID, parentID, slug, title)
|
||||
moved, err := s.Store.Reparent(r.Context(), it.ID, parentIDs)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
// HTMX inline-promote on /admin/classify expects a fragment; full-page promote redirects.
|
||||
if r.Header.Get("HX-Request") == "true" {
|
||||
fmt.Fprintf(w, `<tr class="promoted"><td colspan="6">Promoted to <a href="/i/%s">%s</a></td></tr>`,
|
||||
template.HTMLEscapeString(newItem.Path), template.HTMLEscapeString(newItem.Path))
|
||||
fmt.Fprintf(w, `<tr class="classified"><td colspan="5">Moved to <a href="/i/%s">%s</a></td></tr>`,
|
||||
template.HTMLEscapeString(moved.PrimaryPath()), template.HTMLEscapeString(moved.PrimaryPath()))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+newItem.Path, http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/i/"+moved.PrimaryPath(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// dedupeStrings preserves order, drops empties.
|
||||
func dedupeStrings(in []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(in))
|
||||
for _, s := range in {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseCSV splits a comma/space-delimited chip input into a deduplicated,
|
||||
// trimmed lowercase string slice. Empty input → []string{} (nil avoided so
|
||||
// JSON/SQL writes get an explicit empty array).
|
||||
func parseCSV(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return []string{}
|
||||
}
|
||||
seen := map[string]struct{}{}
|
||||
out := []string{}
|
||||
for _, part := range strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n'
|
||||
}) {
|
||||
t := strings.ToLower(strings.TrimSpace(part))
|
||||
if t == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[t]; ok {
|
||||
continue
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -230,10 +311,6 @@ func (s *Server) handleNewForm(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
if !p.Editable() {
|
||||
http.Error(w, "parent must be a projax-native item", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
parent = p
|
||||
}
|
||||
s.render(w, "new", map[string]any{
|
||||
@@ -252,28 +329,29 @@ func (s *Server) handleNewSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if kind == "" {
|
||||
kind = "project"
|
||||
}
|
||||
var parentID *string
|
||||
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
||||
parentID = &v
|
||||
}
|
||||
if kind == "project" && parentID == nil {
|
||||
http.Error(w, "project requires a parent", http.StatusBadRequest)
|
||||
return
|
||||
parentIDs := r.Form["parent_ids"]
|
||||
if len(parentIDs) == 0 {
|
||||
if v := strings.TrimSpace(r.FormValue("parent_id")); v != "" {
|
||||
parentIDs = []string{v}
|
||||
}
|
||||
}
|
||||
parentIDs = dedupeStrings(parentIDs)
|
||||
in := store.CreateInput{
|
||||
Kind: []string{kind},
|
||||
Title: strings.TrimSpace(r.FormValue("title")),
|
||||
Slug: strings.TrimSpace(r.FormValue("slug")),
|
||||
ParentID: parentID,
|
||||
ContentMD: r.FormValue("content_md"),
|
||||
Status: strings.TrimSpace(r.FormValue("status")),
|
||||
Kind: []string{kind},
|
||||
Title: strings.TrimSpace(r.FormValue("title")),
|
||||
Slug: strings.TrimSpace(r.FormValue("slug")),
|
||||
ParentIDs: parentIDs,
|
||||
ContentMD: r.FormValue("content_md"),
|
||||
Status: strings.TrimSpace(r.FormValue("status")),
|
||||
Tags: parseCSV(r.FormValue("tags")),
|
||||
Management: parseCSV(r.FormValue("management")),
|
||||
}
|
||||
it, err := s.Store.Create(r.Context(), in)
|
||||
if err != nil {
|
||||
s.fail(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/i/"+it.Path, http.StatusSeeOther)
|
||||
http.Redirect(w, r, "/i/"+it.PrimaryPath(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -309,42 +387,102 @@ func (s *Server) parentOptions(ctx context.Context) ([]ParentOption, error) {
|
||||
}
|
||||
var out []ParentOption
|
||||
for _, it := range items {
|
||||
if it.Source != "projax" {
|
||||
continue
|
||||
}
|
||||
out = append(out, ParentOption{ID: it.ID, Path: it.Path})
|
||||
// Surface every primary path as a candidate parent — multi-parent
|
||||
// items appear once per parent option using their primary path so the
|
||||
// UI stays unambiguous.
|
||||
out = append(out, ParentOption{ID: it.ID, Path: it.PrimaryPath()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func buildForest(items []*store.Item) (areas []*treeNode, orphans []*store.Item, projaxN, maiN int) {
|
||||
byID := make(map[string]*treeNode, len(items))
|
||||
// buildForest groups items_unified rows by parent into a sortable tree. A
|
||||
// multi-parent item appears under EACH of its parents (duplicated nodes in
|
||||
// distinct branches). When activeTags is non-empty, a branch is kept only
|
||||
// when it (or any descendant) matches every active tag. orphans lists
|
||||
// mai-managed root items so /admin/classify and the tree page can surface
|
||||
// them.
|
||||
func buildForest(items []*store.Item, activeTags []string) (roots []*treeNode, orphans []*store.Item, total, orphanN int) {
|
||||
for _, it := range items {
|
||||
if it.Source == "projax" {
|
||||
projaxN++
|
||||
byID[it.ID] = &treeNode{Item: it}
|
||||
} else {
|
||||
maiN++
|
||||
total++
|
||||
if len(it.ParentIDs) == 0 && it.HasManagement("mai") {
|
||||
orphans = append(orphans, it)
|
||||
orphanN++
|
||||
}
|
||||
}
|
||||
for _, n := range byID {
|
||||
if n.Item.ParentID == nil {
|
||||
areas = append(areas, n)
|
||||
// Build a forest where every parent relationship creates a node — so
|
||||
// multi-parent items are rendered under each parent. Root items get
|
||||
// one node total.
|
||||
nodeFor := func(it *store.Item) *treeNode { return &treeNode{Item: it} }
|
||||
rootNodes := []*treeNode{}
|
||||
childByParent := make(map[string][]*treeNode, len(items))
|
||||
for _, it := range items {
|
||||
if len(it.ParentIDs) == 0 {
|
||||
rootNodes = append(rootNodes, nodeFor(it))
|
||||
continue
|
||||
}
|
||||
if parent, ok := byID[*n.Item.ParentID]; ok {
|
||||
parent.Children = append(parent.Children, n)
|
||||
for _, pid := range it.ParentIDs {
|
||||
childByParent[pid] = append(childByParent[pid], nodeFor(it))
|
||||
}
|
||||
}
|
||||
sort.Slice(areas, func(i, j int) bool { return areas[i].Item.Slug < areas[j].Item.Slug })
|
||||
for _, n := range byID {
|
||||
var attach func(n *treeNode)
|
||||
attach = func(n *treeNode) {
|
||||
n.Children = childByParent[n.Item.ID]
|
||||
for _, c := range n.Children {
|
||||
attach(c)
|
||||
}
|
||||
}
|
||||
for _, r := range rootNodes {
|
||||
attach(r)
|
||||
}
|
||||
roots = rootNodes
|
||||
if len(activeTags) > 0 {
|
||||
var keep func(n *treeNode) bool
|
||||
keep = func(n *treeNode) bool {
|
||||
filtered := n.Children[:0]
|
||||
for _, c := range n.Children {
|
||||
if keep(c) {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
n.Children = filtered
|
||||
if nodeHasAllTags(n.Item, activeTags) {
|
||||
return true
|
||||
}
|
||||
return len(n.Children) > 0
|
||||
}
|
||||
filtered := roots[:0]
|
||||
for _, n := range roots {
|
||||
if keep(n) {
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
}
|
||||
roots = filtered
|
||||
}
|
||||
sort.Slice(roots, func(i, j int) bool { return roots[i].Item.Slug < roots[j].Item.Slug })
|
||||
sort.Slice(orphans, func(i, j int) bool { return orphans[i].Slug < orphans[j].Slug })
|
||||
var sortChildren func(n *treeNode)
|
||||
sortChildren = func(n *treeNode) {
|
||||
sort.Slice(n.Children, func(i, j int) bool { return n.Children[i].Item.Slug < n.Children[j].Item.Slug })
|
||||
for _, c := range n.Children {
|
||||
sortChildren(c)
|
||||
}
|
||||
}
|
||||
for _, r := range roots {
|
||||
sortChildren(r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func nodeHasAllTags(it *store.Item, want []string) bool {
|
||||
for _, t := range want {
|
||||
if !it.HasTag(t) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) render(w http.ResponseWriter, name string, data map[string]any) {
|
||||
t, ok := s.pages[name]
|
||||
if !ok {
|
||||
|
||||
@@ -89,104 +89,150 @@ func TestHealthz(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailProjaxNativeEditable(t *testing.T) {
|
||||
func TestDetailRendersEditableForm(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
code, body := get(t, srv.Routes(), "/i/dev")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d", code)
|
||||
t.Fatalf("status %d body=%s", code, body)
|
||||
}
|
||||
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
|
||||
t.Errorf("editable form missing for /i/dev")
|
||||
t.Errorf("edit form missing for /i/dev")
|
||||
}
|
||||
if !strings.Contains(body, `name="tags"`) {
|
||||
t.Errorf("tags input missing")
|
||||
}
|
||||
if !strings.Contains(body, `name="management"`) {
|
||||
t.Errorf("management input missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetailMaiProjectsReadOnly(t *testing.T) {
|
||||
func TestDetailShowsManagementChips(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
code, body := get(t, srv.Routes(), "/i/mai.dotfiles")
|
||||
// dev.projax is the manually-promoted item from before Phase 1.5 — should
|
||||
// already carry management=['mai'] after the backfill+sync pass.
|
||||
code, body := get(t, srv.Routes(), "/i/dev.projax")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "Promote to projax") {
|
||||
t.Errorf("Promote section missing for mai.projects row")
|
||||
}
|
||||
if !strings.Contains(body, `action="/i/mai.dotfiles/promote"`) {
|
||||
t.Errorf("promote form missing")
|
||||
if !strings.Contains(body, "mgmt-mai") {
|
||||
t.Errorf("expected mgmt-mai chip on /i/dev.projax, body did not include it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyListsOrphans(t *testing.T) {
|
||||
func TestClassifyListsMaiRoots(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
code, body := get(t, srv.Routes(), "/admin/classify")
|
||||
if code != 200 {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
if !strings.Contains(body, "unclassified rows") {
|
||||
t.Errorf("classify missing summary")
|
||||
if !strings.Contains(body, "Classify root mai-managed items") &&
|
||||
!strings.Contains(body, "No unclassified roots") {
|
||||
t.Errorf("classify page body unexpected: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteRoundTrip(t *testing.T) {
|
||||
func TestReparentRoundTrip(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
|
||||
// Pick an orphan to promote.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var maiID, maiPath string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`select source_ref_id, path from projax.items_unified where source='mai.projects' limit 1`,
|
||||
).Scan(&maiID, &maiPath); err != nil {
|
||||
t.Fatalf("pick orphan: %v", err)
|
||||
|
||||
// Create a fresh root mai-managed item via the reverse-sync path so the
|
||||
// test never collides with another project. The sync trigger drops the
|
||||
// mirror at parent_id=NULL — exactly the case /admin/classify handles.
|
||||
maiID := "phase15-test-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
defer func() {
|
||||
_, _ = pool.Exec(context.Background(), `delete from mai.projects where id=$1`, maiID)
|
||||
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, maiID)
|
||||
}()
|
||||
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into mai.projects (id, name, status) values ($1, $2, 'active')`,
|
||||
maiID, "Reparent test "+maiID,
|
||||
); err != nil {
|
||||
t.Fatalf("seed mai.projects: %v", err)
|
||||
}
|
||||
if maiID == "" {
|
||||
t.Skip("no mai.projects orphans available")
|
||||
|
||||
var nParents int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`select cardinality(parent_ids) from projax.items where slug=$1`, maiID,
|
||||
).Scan(&nParents); err != nil {
|
||||
t.Fatalf("read mirror: %v", err)
|
||||
}
|
||||
if nParents != 0 {
|
||||
t.Fatalf("expected mirror at root (no parents), got %d parents", nParents)
|
||||
}
|
||||
|
||||
var devID string
|
||||
if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and parent_id is null`).Scan(&devID); err != nil {
|
||||
if err := pool.QueryRow(ctx,
|
||||
`select id from projax.items where slug='dev' and cardinality(parent_ids) = 0`,
|
||||
).Scan(&devID); err != nil {
|
||||
t.Fatalf("dev: %v", err)
|
||||
}
|
||||
|
||||
promoSlug := "test-promo-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
form := url.Values{}
|
||||
form.Set("parent_id", devID)
|
||||
form.Set("slug", promoSlug)
|
||||
form.Set("title", "Promo "+maiID)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/i/"+maiPath+"/promote", strings.NewReader(form.Encode()))
|
||||
req := httptest.NewRequest(http.MethodPost, "/i/"+maiID+"/reparent", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
if w.Result().StatusCode != http.StatusSeeOther {
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
t.Fatalf("promote status %d body=%s", w.Result().StatusCode, body)
|
||||
t.Fatalf("reparent status %d body=%s", w.Result().StatusCode, body)
|
||||
}
|
||||
loc := w.Result().Header.Get("Location")
|
||||
wantLoc := "/i/dev." + promoSlug
|
||||
if loc != wantLoc {
|
||||
t.Errorf("redirect Location = %q, want %q", loc, wantLoc)
|
||||
if loc := w.Result().Header.Get("Location"); loc != "/i/dev."+maiID {
|
||||
t.Errorf("Location = %q, want /i/dev.%s", loc, maiID)
|
||||
}
|
||||
|
||||
// The mai row should be hidden from items_unified now.
|
||||
var still int
|
||||
var parents []string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`select count(*) from projax.items_unified where source='mai.projects' and source_ref_id=$1`, maiID,
|
||||
).Scan(&still); err != nil {
|
||||
t.Fatalf("post-promote count: %v", err)
|
||||
`select array(select unnest(parent_ids)::text) from projax.items where slug=$1`, maiID,
|
||||
).Scan(&parents); err != nil {
|
||||
t.Fatalf("post-reparent read: %v", err)
|
||||
}
|
||||
if still != 0 {
|
||||
t.Errorf("expected mai source row hidden after promote, got count=%d", still)
|
||||
}
|
||||
|
||||
// Clean up to keep test idempotent.
|
||||
if _, err := pool.Exec(ctx, `delete from projax.item_links where ref_type='mai-project' and ref_id=$1`, maiID); err != nil {
|
||||
t.Fatalf("cleanup link: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `delete from projax.items where slug=$1 and parent_id=$2`, promoSlug, devID); err != nil {
|
||||
t.Fatalf("cleanup item: %v", err)
|
||||
if len(parents) != 1 || parents[0] != devID {
|
||||
t.Errorf("parent_ids after reparent = %v, want [%s]", parents, devID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
|
||||
srv, pool := mustServer(t)
|
||||
defer pool.Close()
|
||||
h := srv.Routes()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
slug := "p15-multi-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
||||
defer func() {
|
||||
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, slug)
|
||||
}()
|
||||
|
||||
var dev, work 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, `select id from projax.items where slug='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
|
||||
t.Fatalf("work: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Multi', $1, ARRAY[$2,$3]::uuid[])`,
|
||||
slug, dev, work,
|
||||
); err != nil {
|
||||
t.Fatalf("insert multi: %v", err)
|
||||
}
|
||||
|
||||
for _, p := range []string{"dev." + slug, "work." + slug} {
|
||||
code, body := get(t, h, "/i/"+p)
|
||||
if code != 200 {
|
||||
t.Fatalf("GET /i/%s → %d", p, code)
|
||||
}
|
||||
if !strings.Contains(body, "Multi") {
|
||||
t.Errorf("body for /i/%s missing item title 'Multi'", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ h2 { font-size: 1.1em; margin: 24px 0 8px; }
|
||||
.status-archived { color: var(--muted); }
|
||||
.source { display: inline-block; font-size: 0.75em; padding: 1px 6px; border-radius: 4px; background: var(--bg-alt); border: 1px solid var(--border); }
|
||||
.source-mai\.projects { color: var(--warn); }
|
||||
.tag { display: inline-block; font-size: 0.72em; padding: 1px 6px; border-radius: 999px; background: var(--bg-alt); border: 1px solid var(--border); margin-left: 4px; color: var(--accent); text-decoration: none; }
|
||||
a.tag:hover { background: var(--accent); color: #fff; }
|
||||
.tag-on { background: var(--accent); color: #fff; }
|
||||
.mgmt { display: inline-block; font-size: 0.72em; padding: 1px 6px; border-radius: 4px; background: #fff; border: 1px solid var(--border); margin-left: 4px; color: var(--muted); }
|
||||
.mgmt-mai { color: var(--warn); border-color: var(--warn); }
|
||||
.mgmt-self { color: var(--ok); border-color: var(--ok); }
|
||||
.tagbar { margin: 12px 0; padding: 8px 0; border-bottom: 1px dotted var(--border); }
|
||||
.tagbar .muted { color: var(--muted); margin-right: 8px; }
|
||||
.tagbar .clear { margin-left: 12px; color: var(--bad); }
|
||||
.muted { color: var(--muted); }
|
||||
.add { margin-left: 6px; color: var(--accent); text-decoration: none; }
|
||||
.add:hover { text-decoration: underline; }
|
||||
.orphans { margin-top: 32px; }
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
{{define "content"}}
|
||||
<h1>Classify mai.projects orphans</h1>
|
||||
<p>{{len .Orphans}} unclassified rows. Pick an area (or any projax item) per row and click Promote — keeps the original mai.projects row untouched and links back via item_links.</p>
|
||||
<h1>Classify root mai-managed items</h1>
|
||||
<p>{{len .Orphans}} mai-managed items at root. Pick a parent — the multi-parent shape lets you set just one for a quick re-home; add more later from the item's detail page.</p>
|
||||
|
||||
<table class="classify">
|
||||
<thead>
|
||||
<tr><th>Mai ID / Title</th><th>Status</th><th>Parent</th><th>Slug</th><th>Title</th><th></th></tr>
|
||||
<tr><th>Slug / title</th><th>Status</th><th>Tags</th><th>Parent</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Orphans}}
|
||||
<tr id="row-{{.SourceRefDeref}}">
|
||||
<tr id="row-{{.Slug}}">
|
||||
<td>
|
||||
<a href="/i/{{.Path}}">{{.Title}}</a>
|
||||
<br><small class="slug">{{.SourceRefDeref}}</small>
|
||||
<a href="/i/{{.PrimaryPath}}">{{.Title}}</a>
|
||||
<br><small class="slug">{{.Slug}}{{with .SourceRefDeref}} · mai id: {{.}}{{end}}</small>
|
||||
</td>
|
||||
<td><span class="status status-{{.Status}}">{{.Status}}</span></td>
|
||||
<td>{{range .Tags}}<span class="tag">{{.}}</span>{{end}}</td>
|
||||
<td>
|
||||
<form
|
||||
hx-post="/i/{{.Path}}/promote"
|
||||
hx-target="#row-{{.SourceRefDeref}}"
|
||||
hx-post="/i/{{.PrimaryPath}}/reparent"
|
||||
hx-target="#row-{{.Slug}}"
|
||||
hx-swap="outerHTML"
|
||||
class="inline-promote">
|
||||
class="inline-classify">
|
||||
<select name="parent_id" required>
|
||||
<option value="">— pick parent —</option>
|
||||
{{range $.ParentOptions}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
|
||||
</select>
|
||||
</td>
|
||||
<td><input name="slug" value="{{.Slug}}" required pattern="[^.]+"></td>
|
||||
<td><input name="title" value="{{.Title}}" required></td>
|
||||
<td><button type="submit">Promote</button></form></td>
|
||||
<td><button type="submit">Move</button></form></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6"><em>No orphans. Everything is classified.</em></td></tr>
|
||||
<tr><td colspan="5"><em>No unclassified roots. Everything has at least one parent.</em></td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,71 +1,53 @@
|
||||
{{define "content"}}
|
||||
<h1>{{.Item.Title}}</h1>
|
||||
<p class="meta">
|
||||
<span class="source source-{{.Item.Source}}">{{.Item.Source}}</span>
|
||||
<span class="slug">{{.Item.Path}}</span>
|
||||
<span class="slug">{{.Item.PrimaryPath}}</span>
|
||||
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
|
||||
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
|
||||
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
|
||||
{{if .Item.Pinned}}<span class="pin">pinned</span>{{end}}
|
||||
{{if .Item.Archived}}<span class="archived">archived</span>{{end}}
|
||||
{{if .Item.SourceRefDeref}}<span class="muted">mai id: {{.Item.SourceRefDeref}}</span>{{end}}
|
||||
</p>
|
||||
{{if .Item.OtherPaths}}
|
||||
<p class="meta muted">Also at: {{range $i, $p := .Item.OtherPaths}}{{if $i}}, {{end}}<a href="/i/{{$p}}">{{$p}}</a>{{end}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Item.Editable}}
|
||||
<form method="post" action="/i/{{.Item.Path}}" class="edit">
|
||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||||
<label>Parent
|
||||
<select name="parent_id">
|
||||
{{if .Item.IsArea}}
|
||||
<option value="" selected>(root area)</option>
|
||||
{{else}}
|
||||
{{range .ParentOptions}}
|
||||
<option value="{{.ID}}" {{if and $.Item.ParentID (eq .ID (deref $.Item.ParentID))}}selected{{end}}>{{.Path}}</option>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{{range $opt := .StatusOptions}}
|
||||
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
|
||||
</label>
|
||||
<label>Content
|
||||
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
<a class="cancel" href="/">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{{else}}
|
||||
<div class="readonly">
|
||||
<p><em>Read-only: this row is sourced from {{.Item.Source}}.</em></p>
|
||||
<pre class="content">{{.Item.ContentMD}}</pre>
|
||||
|
||||
<h2>Promote to projax</h2>
|
||||
<p>Pick the area or project this should live under. mai.projects row stays untouched; the projax item links back to it via item_links.</p>
|
||||
<form method="post" action="/i/{{.Item.Path}}/promote" class="promote">
|
||||
<label>Parent
|
||||
<select name="parent_id" required>
|
||||
{{range .ParentOptions}}
|
||||
<option value="{{.ID}}">{{.Path}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||
<div class="actions">
|
||||
<button type="submit">Promote</button>
|
||||
<a class="cancel" href="/">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="/i/{{.Item.PrimaryPath}}" class="edit">
|
||||
<label>Title <input name="title" value="{{.Item.Title}}" required></label>
|
||||
<label>Slug <input name="slug" value="{{.Item.Slug}}" required pattern="[^.]+"></label>
|
||||
<label>Parents <small class="muted">(hold Ctrl/Cmd to pick multiple — same row can live under several branches)</small>
|
||||
<select name="parent_ids" multiple size="6">
|
||||
{{range .ParentOptions}}
|
||||
<option value="{{.ID}}" {{if contains $.Item.ParentIDs .ID}}selected{{end}}>{{.Path}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{{range $opt := .StatusOptions}}
|
||||
<option value="{{$opt}}" {{if eq $opt $.Item.Status}}selected{{end}}>{{$opt}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Tags
|
||||
<input name="tags" value="{{join "," .Item.Tags}}" placeholder="comma-separated, e.g. work, dev">
|
||||
</label>
|
||||
<label>Management
|
||||
<input name="management" value="{{join "," .Item.Management}}" placeholder="comma-separated: self, mai, external">
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="pinned" value="1" {{if .Item.Pinned}}checked{{end}}> pinned
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="archived" value="1" {{if .Item.Archived}}checked{{end}}> archived
|
||||
</label>
|
||||
<label>Content
|
||||
<textarea name="content_md" rows="14">{{.Item.ContentMD}}</textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Save</button>
|
||||
<a class="cancel" href="/">Cancel</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -1,28 +1,35 @@
|
||||
{{define "content"}}
|
||||
<h1>New item</h1>
|
||||
<p class="meta">Parent: <strong>{{if .Parent}}{{.Parent.Path}}{{else}}(root area){{end}}</strong></p>
|
||||
<p class="meta">Suggested parent: <strong>{{if .Parent}}{{.Parent.PrimaryPath}}{{else}}(root){{end}}</strong></p>
|
||||
|
||||
<form method="post" action="/new" class="edit">
|
||||
{{if .Parent}}<input type="hidden" name="parent_id" value="{{.Parent.ID}}">{{end}}
|
||||
<label>Kind
|
||||
<select name="kind">
|
||||
{{if not .Parent}}<option value="area" selected>area</option>{{end}}
|
||||
<option value="project" {{if .Parent}}selected{{end}}>project</option>
|
||||
</select>
|
||||
</label>
|
||||
<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>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}}
|
||||
<option value="{{.ID}}" {{if and $.Parent (eq .ID $.Parent.ID)}}selected{{end}}>{{.Path}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Status
|
||||
<select name="status">
|
||||
{{range $opt := .StatusOptions}}<option value="{{$opt}}">{{$opt}}</option>{{end}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Tags
|
||||
<input name="tags" placeholder="comma-separated, e.g. work, dev">
|
||||
</label>
|
||||
<label>Management
|
||||
<input name="management" placeholder="comma-separated: self, mai, external">
|
||||
</label>
|
||||
<label>Content
|
||||
<textarea name="content_md" rows="10"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Create</button>
|
||||
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.Path}}{{else}}/{{end}}">Cancel</a>
|
||||
<a class="cancel" href="{{if .Parent}}/i/{{.Parent.PrimaryPath}}{{else}}/{{end}}">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
{{define "content"}}
|
||||
<h1>Tree</h1>
|
||||
<p class="counts">
|
||||
<strong>{{.ProjaxCount}}</strong> projax · <strong>{{.MaiCount}}</strong> mai.projects orphans
|
||||
{{if .MaiCount}}<a href="/admin/classify">→ classify</a>{{end}}
|
||||
<strong>{{.Total}}</strong> items
|
||||
{{if .OrphanN}} · <strong>{{.OrphanN}}</strong> unclassified mai-managed roots <a href="/admin/classify">→ classify</a>{{end}}
|
||||
</p>
|
||||
|
||||
<section class="tree">
|
||||
<h2>Areas + projax items</h2>
|
||||
<ul class="forest">
|
||||
{{range .Areas}}
|
||||
<li class="node area">
|
||||
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
|
||||
<span class="slug">{{.Item.Path}}</span>
|
||||
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
|
||||
{{template "children" .}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{{if .Orphans}}
|
||||
<section class="orphans">
|
||||
<h2>mai.projects orphans <small>(unclassified)</small></h2>
|
||||
<ul class="flat">
|
||||
{{range .Orphans}}
|
||||
<li class="node orphan">
|
||||
<a href="/i/{{.Path}}">{{.Title}}</a>
|
||||
<span class="slug">{{.Path}}</span>
|
||||
<span class="status status-{{.Status}}">{{.Status}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{if .AllTags}}
|
||||
<section class="tagbar">
|
||||
<span class="muted">Filter by tag:</span>
|
||||
{{range .AllTags}}
|
||||
{{$on := false}}{{range $t := $.ActiveTags}}{{if eq $t .}}{{$on = true}}{{end}}{{end}}
|
||||
{{if $on}}<a class="tag tag-on" href="{{tagToggleURL $.ActiveTags . true}}">{{.}}</a>{{else}}<a class="tag" href="{{tagToggleURL $.ActiveTags . false}}">{{.}}</a>{{end}}
|
||||
{{end}}
|
||||
{{if .ActiveTags}}<a class="clear" href="/">clear</a>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<section class="tree">
|
||||
<ul class="forest">
|
||||
{{range .Roots}}
|
||||
<li class="node root">
|
||||
<a href="/i/{{.Item.PrimaryPath}}">{{.Item.Title}}</a>
|
||||
<span class="slug">{{.Item.PrimaryPath}}</span>
|
||||
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
|
||||
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
|
||||
<a class="add" href="/new?parent={{.Item.PrimaryPath}}">+</a>
|
||||
{{template "children" .}}
|
||||
</li>
|
||||
{{else}}
|
||||
<li><em>No items match the active filter.</em></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{define "children"}}
|
||||
@@ -40,10 +39,13 @@
|
||||
<ul>
|
||||
{{range .Children}}
|
||||
<li class="node project">
|
||||
<a href="/i/{{.Item.Path}}">{{.Item.Title}}</a>
|
||||
<span class="slug">{{.Item.Path}}</span>
|
||||
<a href="/i/{{.Item.PrimaryPath}}">{{.Item.Title}}</a>
|
||||
<span class="slug">{{.Item.PrimaryPath}}</span>
|
||||
<span class="status status-{{.Item.Status}}">{{.Item.Status}}</span>
|
||||
<a class="add" href="/new?parent={{.Item.Path}}">+</a>
|
||||
{{if gt (len .Item.Paths) 1}}<span class="muted multi-parent" title="appears under multiple parents">×{{len .Item.Paths}}</span>{{end}}
|
||||
{{range .Item.Management}}<span class="mgmt mgmt-{{.}}">{{.}}</span>{{end}}
|
||||
{{range .Item.Tags}}<span class="tag">{{.}}</span>{{end}}
|
||||
<a class="add" href="/new?parent={{.Item.PrimaryPath}}">+</a>
|
||||
{{template "children" .}}
|
||||
</li>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user