Add connected pages: hierarchy, backlinks, and live embeds
release gate / check (push) Failing after 7m7s
release gate / check (push) Failing after 7m7s
Implement Notion-like upgrade phase 3: owner-scoped nested pages with cycle rejection and child promotion on delete; Markdown-derived internal links and backlinks; live collection embeds resolved in the content-save transaction. Add migration 008, API/docs, integration coverage, and page/sidebar UI.
This commit is contained in:
@@ -111,14 +111,34 @@ test-only). CardDAV keeps the configured HTTPS port.
|
|||||||
| PATCH | `/api/kanban/columns/{id}` | Updated column; body `{label?,color?}` |
|
| PATCH | `/api/kanban/columns/{id}` | Updated column; body `{label?,color?}` |
|
||||||
| DELETE | `/api/kanban/columns/{id}` | `{ok:true,movedTo,moved}`; moves cards, never deletes bookmarks; refuses last column |
|
| DELETE | `/api/kanban/columns/{id}` | `{ok:true,movedTo,moved}`; moves cards, never deletes bookmarks; refuses last column |
|
||||||
| PUT | `/api/kanban/cards/{id}` | Moved bookmark; body `{columnId,position,version}`. Reorders the target column (0-based position, clamped at the end) and normalizes every card's `kanbanPosition`. Owner only. `version` is REQUIRED: 400 when missing/invalid, 409 when stale; success bumps the moved card's `version` |
|
| PUT | `/api/kanban/cards/{id}` | Moved bookmark; body `{columnId,position,version}`. Reorders the target column (0-based position, clamped at the end) and normalizes every card's `kanbanPosition`. Owner only. `version` is REQUIRED: 400 when missing/invalid, 409 when stale; success bumps the moved card's `version` |
|
||||||
| GET | `/api/pages?search=...` | Pages, pinned first, with `_count.bookmarks`; title/content substring search treats `%` and `_` literally and uses trigram indexes |
|
| GET | `/api/pages?search=...` | Pages, pinned first, with `parentId` and `_count.{bookmarks,children}`; title/content substring search treats `%` and `_` literally and uses trigram indexes |
|
||||||
| POST | `/api/pages` | 201 page; body `{title?,content?}` |
|
| POST | `/api/pages` | 201 page; body `{title?,content?,parentId?}`. `parentId` must be an owned page; a missing/foreign parent is 400 |
|
||||||
| PUT | `/api/pages` | `{ok:true}`; complete `{order:[id,...]}` |
|
| PUT | `/api/pages` | `{ok:true}`; complete `{order:[id,...]}` |
|
||||||
| GET | `/api/pages/{id}` | Page plus ordered `bookmarks:[{bookmark}]` |
|
| GET | `/api/pages/{id}` | Page plus ordered `bookmarks:[{bookmark}]`, owner-scoped `backlinks:[{id,title,updatedAt}]`, resolved outgoing `links:[{id,title}]`, and `embeds:[{collectionId,viewId,position,collection,view}]` |
|
||||||
| PATCH | `/api/pages/{id}` | Updated page; `{title?,content?,pinned?,icon?,version?,addBookmarkId?,removeBookmarkId?}`. `version` (the base revision from GET) is REQUIRED with `content`: 400 when missing, 409 when stale. Metadata-only patches stay version-free and do not bump the revision |
|
| PATCH | `/api/pages/{id}` | Updated page; `{title?,content?,pinned?,icon?,parentId?,version?,addBookmarkId?,removeBookmarkId?}`. `version` (the base revision from GET) is REQUIRED with `content`: 400 when missing, 409 when stale. Metadata-only patches (including moving a page) stay version-free and do not bump the revision. Moving under itself or a descendant is rejected 400 (no cycles) |
|
||||||
| DELETE | `/api/pages/{id}` | `{ok:true}`; keeps bookmarks |
|
| DELETE | `/api/pages/{id}` | `{ok:true}`; keeps bookmarks and promotes child pages to the top level |
|
||||||
|
| GET | `/api/pages/{id}/backlinks` | Owner-scoped `[{id,title,updatedAt}]` from `PageLink` — the pages whose Markdown links here |
|
||||||
| GET | `/api/pages/{id}/markdown` | Markdown attachment |
|
| GET | `/api/pages/{id}/markdown` | Markdown attachment |
|
||||||
|
|
||||||
|
### Connected pages (phase 3)
|
||||||
|
|
||||||
|
Page hierarchy, internal links and live collection embeds are ordinary
|
||||||
|
Markdown contained in the page body; the server rebuilds the resolved graph
|
||||||
|
in the same transaction as every content save:
|
||||||
|
|
||||||
|
- `[label](computer:page/<pageId>)` links to another page. `/pages?id=<id>`
|
||||||
|
and `page/<id>` are accepted as hand-written aliases. Only owned targets
|
||||||
|
are stored, so a backlink can never reveal another account's page title;
|
||||||
|
self-links are ignored.
|
||||||
|
- `{{collection:<collectionId>}}` embeds the collection's live default view;
|
||||||
|
`{{collection:<collectionId>|view=<viewId>}}` pins a saved view's
|
||||||
|
filters/sorts. Only owned collections are stored, and item reads still go
|
||||||
|
through the normal collection endpoint, so an embed displays data without
|
||||||
|
publishing it or widening bookmark access.
|
||||||
|
- Deleting a page removes its links/embeds, promotes its children, and keeps
|
||||||
|
its bookmarks. Deleting a collection removes its embeds; deleting a view
|
||||||
|
falls the embed back to the collection's default view.
|
||||||
|
|
||||||
## Collections
|
## Collections
|
||||||
|
|
||||||
Contextual workflows over existing bookmarks (phase 2 of the Notion-like
|
Contextual workflows over existing bookmarks (phase 2 of the Notion-like
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ keeping its own passwords.*
|
|||||||
- Collections can't be shared — they're private to their owner.
|
- Collections can't be shared — they're private to their owner.
|
||||||
- No page history (can't see or restore older versions).
|
- No page history (can't see or restore older versions).
|
||||||
- No trash — deletes are permanent.
|
- No trash — deletes are permanent.
|
||||||
- No nested pages or backlinks ("which pages mention this one?").
|
|
||||||
- Calendar and list views for collections are stored but still display as
|
- Calendar and list views for collections are stored but still display as
|
||||||
tables.
|
tables.
|
||||||
- Alerts only show inside the app — nothing is emailed or pushed to you.
|
- Alerts only show inside the app — nothing is emailed or pushed to you.
|
||||||
@@ -171,8 +170,9 @@ maintenance rather than a separate milestone.
|
|||||||
|
|
||||||
- **Page history and trash** — see old versions, restore them; deleted
|
- **Page history and trash** — see old versions, restore them; deleted
|
||||||
things are recoverable.
|
things are recoverable.
|
||||||
- **Nested pages and backlinks** — a real page tree, and "what links here"
|
- **Nested pages and backlinks** — done: a real page tree, "what links here"
|
||||||
that never shows titles you're not allowed to see.
|
that never shows titles you're not allowed to see, and live collection
|
||||||
|
views embedded in pages.
|
||||||
- **Collection sharing** — the big one. Explicit, shown in the UI, and
|
- **Collection sharing** — the big one. Explicit, shown in the UI, and
|
||||||
revocable. Design the permission rules first (a project must never
|
revocable. Design the permission rules first (a project must never
|
||||||
accidentally widen who can see a bookmark), then build it. Once it
|
accidentally widen who can see a bookmark), then build it. Once it
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- 008_connected_pages.sql — Notion-like upgrades phase 3.
|
||||||
|
--
|
||||||
|
-- Nested pages, resolved internal page links (backlinks), and live collection
|
||||||
|
-- views embedded in a page. Links and embeds are derived from Markdown on
|
||||||
|
-- every content save: the page body stays the single source of truth.
|
||||||
|
--
|
||||||
|
-- Access rules:
|
||||||
|
-- * Pages remain owner-scoped. A link to a page the author does not own is
|
||||||
|
-- ignored, so backlinks can never reveal a title across accounts.
|
||||||
|
-- * An embed is stored only when the author owns the collection. The live
|
||||||
|
-- view still re-checks bookmark visibility through the collection item
|
||||||
|
-- endpoints (N-08); embedding never publishes anything.
|
||||||
|
--
|
||||||
|
-- Deleting:
|
||||||
|
-- * Deleting a parent page promotes its children (SET NULL), never deletes
|
||||||
|
-- them silently.
|
||||||
|
-- * Deleting a page cascades its outgoing/incoming links and embeds.
|
||||||
|
-- * Deleting a collection cascades its embeds; deleting a view clears the
|
||||||
|
-- embed's viewId so it falls back to the collection's default view.
|
||||||
|
|
||||||
|
ALTER TABLE "Page" ADD COLUMN IF NOT EXISTS "parentId" text;
|
||||||
|
|
||||||
|
-- The named constraint matches schema.sql so the fresh and upgraded paths
|
||||||
|
-- converge. The guard makes a re-run against an already-current schema a
|
||||||
|
-- no-op (without it, schema.sql's inline FK would collide).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'Page_parentId_fkey' AND conrelid = '"Page"'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "Page" ADD CONSTRAINT "Page_parentId_fkey"
|
||||||
|
FOREIGN KEY ("parentId") REFERENCES "Page"(id) ON DELETE SET NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "Page_parentId_idx" ON "Page"("parentId");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "PageLink" (
|
||||||
|
"sourcePageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
|
||||||
|
"targetPageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("sourcePageId", "targetPageId"),
|
||||||
|
CONSTRAINT "PageLink_no_self" CHECK ("sourcePageId" <> "targetPageId")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "PageLink_targetPageId_idx" ON "PageLink"("targetPageId");
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS "PageEmbed" (
|
||||||
|
"pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
|
||||||
|
"collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE,
|
||||||
|
"viewId" text REFERENCES "CollectionView"(id) ON DELETE SET NULL,
|
||||||
|
position integer NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("pageId", "collectionId")
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS "PageEmbed_collectionId_idx" ON "PageEmbed"("collectionId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "PageEmbed_viewId_idx" ON "PageEmbed"("viewId");
|
||||||
@@ -196,6 +196,10 @@ CREATE TABLE "Page" (
|
|||||||
icon text,
|
icon text,
|
||||||
pinned boolean NOT NULL DEFAULT false,
|
pinned boolean NOT NULL DEFAULT false,
|
||||||
position int NOT NULL DEFAULT 0,
|
position int NOT NULL DEFAULT 0,
|
||||||
|
-- Connected pages (phase 3): a page may live under another page. Deleting
|
||||||
|
-- the parent promotes its children to the top level instead of deleting
|
||||||
|
-- them; cycle rejection is enforced by the API.
|
||||||
|
"parentId" text REFERENCES "Page"(id) ON DELETE SET NULL,
|
||||||
-- Optimistic concurrency revision (review DATA-01): content saves bump it;
|
-- Optimistic concurrency revision (review DATA-01): content saves bump it;
|
||||||
-- a stale base version in PATCH is rejected with 409.
|
-- a stale base version in PATCH is rejected with 409.
|
||||||
version integer NOT NULL DEFAULT 1,
|
version integer NOT NULL DEFAULT 1,
|
||||||
@@ -204,6 +208,7 @@ CREATE TABLE "Page" (
|
|||||||
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX "Page_ownerId_position_idx" ON "Page"("ownerId", position);
|
CREATE INDEX "Page_ownerId_position_idx" ON "Page"("ownerId", position);
|
||||||
|
CREATE INDEX "Page_parentId_idx" ON "Page"("parentId");
|
||||||
-- Index-backed substring search over the two searchable fields.
|
-- Index-backed substring search over the two searchable fields.
|
||||||
CREATE INDEX "Page_title_trgm_idx" ON "Page" USING gin (title gin_trgm_ops);
|
CREATE INDEX "Page_title_trgm_idx" ON "Page" USING gin (title gin_trgm_ops);
|
||||||
CREATE INDEX "Page_content_trgm_idx" ON "Page" USING gin (content gin_trgm_ops);
|
CREATE INDEX "Page_content_trgm_idx" ON "Page" USING gin (content gin_trgm_ops);
|
||||||
@@ -218,6 +223,19 @@ CREATE TABLE "PageBookmark" (
|
|||||||
);
|
);
|
||||||
CREATE INDEX "PageBookmark_bookmarkId_idx" ON "PageBookmark"("bookmarkId");
|
CREATE INDEX "PageBookmark_bookmarkId_idx" ON "PageBookmark"("bookmarkId");
|
||||||
|
|
||||||
|
-- 2.13b PageLink: resolved internal page links, rebuilt from page content on
|
||||||
|
-- every content save. Targets are only stored when the author owns the target
|
||||||
|
-- page, so a backlink can never reveal an inaccessible title. Self-links are
|
||||||
|
-- ignored by the writer and refused here as defense in depth.
|
||||||
|
CREATE TABLE "PageLink" (
|
||||||
|
"sourcePageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
|
||||||
|
"targetPageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("sourcePageId", "targetPageId"),
|
||||||
|
CONSTRAINT "PageLink_no_self" CHECK ("sourcePageId" <> "targetPageId")
|
||||||
|
);
|
||||||
|
CREATE INDEX "PageLink_targetPageId_idx" ON "PageLink"("targetPageId");
|
||||||
|
|
||||||
-- 2.14 SignalSource
|
-- 2.14 SignalSource
|
||||||
CREATE TABLE "SignalSource" (
|
CREATE TABLE "SignalSource" (
|
||||||
id text PRIMARY KEY,
|
id text PRIMARY KEY,
|
||||||
@@ -348,4 +366,21 @@ CREATE TABLE "CollectionView" (
|
|||||||
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
"updatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
CREATE INDEX "CollectionView_collectionId_idx" ON "CollectionView"("collectionId");
|
CREATE INDEX "CollectionView_collectionId_idx" ON "CollectionView"("collectionId");
|
||||||
|
|
||||||
|
-- 2.18 PageEmbed (Notion-like upgrades phase 3): a live collection view
|
||||||
|
-- embedded in a page. The reference is resolved from the page's Markdown
|
||||||
|
-- (`{{collection:<id>|view=<id>}}`) on every content save. Only collections
|
||||||
|
-- owned by the page's author are stored; embedding displays data and never
|
||||||
|
-- publishes it or widens bookmark access. Deleting the collection or the
|
||||||
|
-- chosen view removes/falls back rather than leaving a dangling embed.
|
||||||
|
CREATE TABLE "PageEmbed" (
|
||||||
|
"pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
|
||||||
|
"collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE,
|
||||||
|
"viewId" text REFERENCES "CollectionView"(id) ON DELETE SET NULL,
|
||||||
|
position integer NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("pageId", "collectionId")
|
||||||
|
);
|
||||||
|
CREATE INDEX "PageEmbed_collectionId_idx" ON "PageEmbed"("collectionId");
|
||||||
|
CREATE INDEX "PageEmbed_viewId_idx" ON "PageEmbed"("viewId");
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/andrew/go-computer/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connected pages (Notion-like upgrades phase 3).
|
||||||
|
//
|
||||||
|
// Page hierarchy, internal links and embedded collection views are all
|
||||||
|
// expressed in the page's Markdown, which stays the single source of truth:
|
||||||
|
//
|
||||||
|
// - [Any label](computer:page/<id>) links to another page. /pages?id=<id>
|
||||||
|
// and page/<id> are accepted as aliases so hand-written links work too.
|
||||||
|
// - {{collection:<id>}} embeds a live collection view; an optional
|
||||||
|
// {{collection:<id>|view=<viewId>}} pins a saved view's filters/sorts.
|
||||||
|
//
|
||||||
|
// On every content save the resolved PageLink and PageEmbed rows are rebuilt
|
||||||
|
// inside the same transaction. Only targets owned by the author are stored:
|
||||||
|
// backlinks therefore cannot enumerate or reveal titles from another account.
|
||||||
|
// Embedding displays data through the normal collection endpoints, so the
|
||||||
|
// underlying bookmark-visibility checks still apply and nothing is published.
|
||||||
|
|
||||||
|
var (
|
||||||
|
markdownLinkRE = regexp.MustCompile(`\[[^\]]*\]\(\s*<?([^)\s>]+)(?:\s+"[^"]*")?\s*>?\)`)
|
||||||
|
pageEmbedRE = regexp.MustCompile(`\{\{\s*collection:([A-Za-z0-9_-]{1,128})(?:\s*\|\s*view=([A-Za-z0-9_-]{1,128}))?\s*\}\}`)
|
||||||
|
pageRefRE = regexp.MustCompile(`^[A-Za-z0-9_-]{1,128}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// internalPageID normalizes a Markdown link target to a page id. It returns
|
||||||
|
// false for ordinary web links and malformed ids.
|
||||||
|
func internalPageID(href string) (string, bool) {
|
||||||
|
href = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(href, "<"), ">"))
|
||||||
|
raw := ""
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(href, "computer://page/"):
|
||||||
|
raw = strings.TrimPrefix(href, "computer://page/")
|
||||||
|
case strings.HasPrefix(href, "computer:page/"):
|
||||||
|
raw = strings.TrimPrefix(href, "computer:page/")
|
||||||
|
case strings.HasPrefix(href, "page/"):
|
||||||
|
raw = strings.TrimPrefix(href, "page/")
|
||||||
|
case strings.HasPrefix(href, "/pages/"):
|
||||||
|
raw = strings.TrimPrefix(href, "/pages/")
|
||||||
|
case strings.HasPrefix(href, "/pages?"):
|
||||||
|
if u, err := url.Parse(href); err == nil {
|
||||||
|
raw = u.Query().Get("id")
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(href, "computer:page?id="):
|
||||||
|
if u, err := url.Parse(href); err == nil {
|
||||||
|
raw = u.Query().Get("id")
|
||||||
|
}
|
||||||
|
case strings.HasPrefix(href, "computer://page?id="):
|
||||||
|
if u, err := url.Parse(href); err == nil {
|
||||||
|
raw = u.Query().Get("id")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
raw = strings.TrimSuffix(strings.TrimSpace(raw), "/")
|
||||||
|
if !pageRefRE.MatchString(raw) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return raw, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageEmbedRef struct {
|
||||||
|
collectionID string
|
||||||
|
viewID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageGraph extracts the distinct page links and embeds from Markdown.
|
||||||
|
func pageGraph(content, selfID string) ([]string, []pageEmbedRef) {
|
||||||
|
links := make([]string, 0)
|
||||||
|
seenLinks := map[string]bool{}
|
||||||
|
for _, m := range markdownLinkRE.FindAllStringSubmatchIndex(content, -1) {
|
||||||
|
// Skip images:  is not an internal link.
|
||||||
|
if m[0] > 0 && content[m[0]-1] == '!' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, ok := internalPageID(content[m[2]:m[3]])
|
||||||
|
if !ok || id == selfID || seenLinks[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenLinks[id] = true
|
||||||
|
links = append(links, id)
|
||||||
|
}
|
||||||
|
embeds := make([]pageEmbedRef, 0)
|
||||||
|
seenEmbeds := map[string]bool{}
|
||||||
|
for _, m := range pageEmbedRE.FindAllStringSubmatch(content, -1) {
|
||||||
|
ref := pageEmbedRef{collectionID: m[1], viewID: m[2]}
|
||||||
|
if seenEmbeds[ref.collectionID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenEmbeds[ref.collectionID] = true
|
||||||
|
embeds = append(embeds, ref)
|
||||||
|
}
|
||||||
|
return links, embeds
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncPageGraph replaces one page's resolved links and embeds. It runs inside
|
||||||
|
// the content-save transaction, so the derived rows and the Markdown commit
|
||||||
|
// together (a failed save never leaves stale backlinks).
|
||||||
|
func syncPageGraph(ctx context.Context, tx *sql.Tx, pageID, uid, content string) error {
|
||||||
|
links, embeds := pageGraph(content, pageID)
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM "PageLink" WHERE "sourcePageId"=$1`, pageID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, target := range links {
|
||||||
|
var visible bool
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "Page" WHERE id=$1 AND "ownerId"=$2)`, target, uid).Scan(&visible); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A foreign/missing target is ignored rather than stored. The link
|
||||||
|
// text remains in the author's Markdown, but it can never become a
|
||||||
|
// cross-account backlink.
|
||||||
|
if !visible {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO "PageLink"("sourcePageId","targetPageId") VALUES($1,$2) ON CONFLICT DO NOTHING`, pageID, target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE FROM "PageEmbed" WHERE "pageId"=$1`, pageID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, ref := range embeds {
|
||||||
|
var visible bool
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "Collection" WHERE id=$1 AND "ownerId"=$2)`, ref.collectionID, uid).Scan(&visible); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !visible {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
viewID := any(nil)
|
||||||
|
if ref.viewID != "" {
|
||||||
|
var valid bool
|
||||||
|
if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM "CollectionView" WHERE id=$1 AND "collectionId"=$2)`, ref.viewID, ref.collectionID).Scan(&valid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// An unknown view falls back to the collection's default view
|
||||||
|
// instead of dropping the embed entirely.
|
||||||
|
if valid {
|
||||||
|
viewID = ref.viewID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO "PageEmbed"("pageId","collectionId","viewId",position) VALUES($1,$2,$3,$4)`, pageID, ref.collectionID, viewID, i); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageParent validates a requested parent and rejects cycles. The walk stays
|
||||||
|
// within the owner's pages and is bounded by the seen set, so a corrupt
|
||||||
|
// pre-existing cycle cannot loop forever.
|
||||||
|
func pageParent(ctx context.Context, q db.Queryer, pageID, uid string, raw any) (any, error) {
|
||||||
|
if raw == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
parent, ok := raw.(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, bad("parentId must be a page id or null")
|
||||||
|
}
|
||||||
|
parent = strings.TrimSpace(parent)
|
||||||
|
if parent == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !pageRefRE.MatchString(parent) {
|
||||||
|
return nil, bad("Invalid parentId")
|
||||||
|
}
|
||||||
|
if parent == pageID {
|
||||||
|
return nil, bad("A page cannot be its own parent")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{pageID: true}
|
||||||
|
for current := parent; current != ""; {
|
||||||
|
if seen[current] {
|
||||||
|
return nil, bad("Page hierarchy cannot contain a cycle")
|
||||||
|
}
|
||||||
|
seen[current] = true
|
||||||
|
var next sql.NullString
|
||||||
|
err := q.QueryRowContext(ctx, `SELECT "parentId" FROM "Page" WHERE id=$1 AND "ownerId"=$2`, current, uid).Scan(&next)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, bad("Parent page not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current = next.String
|
||||||
|
}
|
||||||
|
return parent, nil
|
||||||
|
}
|
||||||
@@ -18,7 +18,23 @@ import (
|
|||||||
func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) {
|
func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) {
|
||||||
// Linked bookmarks use the viewer projection (review PRIV-02): a page can
|
// Linked bookmarks use the viewer projection (review PRIV-02): a page can
|
||||||
// link bookmarks shared with its owner, and those arrive narrowed.
|
// link bookmarks shared with its owner, and those arrive narrowed.
|
||||||
return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object('bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb)) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid)
|
//
|
||||||
|
// Connected pages (phase 3): backlinks and embeds are owner-scoped in the
|
||||||
|
// query itself, not trusted from the caller. `links` is the resolved
|
||||||
|
// outgoing edge set (useful for navigation); the Markdown remains the
|
||||||
|
// source of truth and is rebuilt by syncPageGraph on every content save.
|
||||||
|
return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object(
|
||||||
|
'bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb),
|
||||||
|
'backlinks',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") ORDER BY s."updatedAt" DESC,s.id) FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=p.id AND s."ownerId"=p."ownerId"),'[]'::jsonb),
|
||||||
|
'links',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'title',t.title) ORDER BY t.title,t.id) FROM "PageLink" pl JOIN "Page" t ON t.id=pl."targetPageId" WHERE pl."sourcePageId"=p.id AND t."ownerId"=p."ownerId"),'[]'::jsonb),
|
||||||
|
'embeds',COALESCE((SELECT jsonb_agg(jsonb_build_object(
|
||||||
|
'collectionId',e."collectionId",'viewId',e."viewId",'position',e.position,
|
||||||
|
'collection',to_jsonb(c)||jsonb_build_object(
|
||||||
|
'properties',COALESCE((SELECT jsonb_agg(to_jsonb(pr) ORDER BY pr.position,pr.id) FROM "CollectionProperty" pr WHERE pr."collectionId"=c.id),'[]'::jsonb),
|
||||||
|
'views',COALESCE((SELECT jsonb_agg(to_jsonb(v) ORDER BY v.position,v.id) FROM "CollectionView" v WHERE v."collectionId"=c.id),'[]'::jsonb)),
|
||||||
|
'view',(SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=e."viewId"))
|
||||||
|
ORDER BY e.position,e."collectionId") FROM "PageEmbed" e JOIN "Collection" c ON c.id=e."collectionId" WHERE e."pageId"=p.id AND c."ownerId"=p."ownerId"),'[]'::jsonb)
|
||||||
|
) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid)
|
||||||
}
|
}
|
||||||
func page(r *http.Request, id string) (object, error) {
|
func page(r *http.Request, id string) (object, error) {
|
||||||
return pageView(r.Context(), database(r), id, user(r))
|
return pageView(r.Context(), database(r), id, user(r))
|
||||||
@@ -31,12 +47,15 @@ func HandlePagesList(w http.ResponseWriter, r *http.Request) {
|
|||||||
// literal characters, not patterns. The pg_trgm indexes on title/content
|
// literal characters, not patterns. The pg_trgm indexes on title/content
|
||||||
// let Postgres use the same ILIKE predicate without scanning every page.
|
// let Postgres use the same ILIKE predicate without scanning every page.
|
||||||
search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%"
|
search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%"
|
||||||
v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search)
|
v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object(
|
||||||
|
'bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id),
|
||||||
|
'children',(SELECT count(*) FROM "Page" ch WHERE ch."parentId"=p.id AND ch."ownerId"=p."ownerId"))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search)
|
||||||
respond(w, 200, v, e)
|
respond(w, 200, v, e)
|
||||||
}
|
}
|
||||||
func pageData(b object) (object, error) {
|
func pageData(b object) (object, error) {
|
||||||
d := fields(b, "title content icon pinned")
|
d := fields(b, "title content icon pinned parentId")
|
||||||
if e := stringFields(d, "icon", "title content"); e != nil {
|
// parentId is nullable: an explicit null promotes the page to the root.
|
||||||
|
if e := stringFields(d, "icon parentId", "title content"); e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
if e := boolFields(d, "pinned"); e != nil {
|
if e := boolFields(d, "pinned"); e != nil {
|
||||||
@@ -80,6 +99,13 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
if e := lockUser(r, tx); e != nil {
|
if e := lockUser(r, tx); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
if raw, ok := d["parentId"]; ok {
|
||||||
|
parent, e := pageParent(r.Context(), tx, d["id"].(string), user(r), raw)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
d["parentId"] = parent
|
||||||
|
}
|
||||||
var n int
|
var n int
|
||||||
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil {
|
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil {
|
||||||
return e
|
return e
|
||||||
@@ -87,6 +113,17 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
d["position"] = n
|
d["position"] = n
|
||||||
var e error
|
var e error
|
||||||
v, e = db.Insert(r.Context(), tx, "Page", d)
|
v, e = db.Insert(r.Context(), tx, "Page", d)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if content, ok := d["content"].(string); ok {
|
||||||
|
if e := syncPageGraph(r.Context(), tx, d["id"].(string), user(r), content); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Acknowledge the same snapshot the write produced (the update path's
|
||||||
|
// CR-18 discipline), so the 201 already carries links/embeds.
|
||||||
|
v, e = pageView(r.Context(), tx, d["id"].(string), user(r))
|
||||||
return e
|
return e
|
||||||
})
|
})
|
||||||
respond(w, 201, v, e)
|
respond(w, 201, v, e)
|
||||||
@@ -162,13 +199,25 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return conflict("Page was modified elsewhere; reload to see the latest version")
|
return conflict("Page was modified elsewhere; reload to see the latest version")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if raw, ok := d["parentId"]; ok {
|
||||||
|
parent, e := pageParent(r.Context(), tx, id, user(r), raw)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
d["parentId"] = parent
|
||||||
|
}
|
||||||
if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil {
|
if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
if _, ok := d["content"]; ok {
|
if content, ok := d["content"].(string); ok {
|
||||||
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil {
|
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil {
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
// Rebuild resolved links/embeds in the same transaction as the
|
||||||
|
// Markdown they were derived from.
|
||||||
|
if e := syncPageGraph(r.Context(), tx, id, user(r), content); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if bid := str(b, "addBookmarkId"); bid != "" {
|
if bid := str(b, "addBookmarkId"); bid != "" {
|
||||||
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil {
|
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil {
|
||||||
@@ -190,7 +239,22 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
respond(w, 200, v, e)
|
respond(w, 200, v, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandlePagesBacklinks returns the pages that link to this one. It is a
|
||||||
|
// separate read surface for navigation; the same owner-scoped list is
|
||||||
|
// embedded in pageView for the editor.
|
||||||
|
func HandlePagesBacklinks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
if _, e := owned(r, database(r), "Page", id, "ownerId"); e != nil {
|
||||||
|
respond(w, 0, nil, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=$1 AND s."ownerId"=$2 ORDER BY s."updatedAt" DESC,s.id`, id, user(r))
|
||||||
|
respond(w, 200, v, e)
|
||||||
|
}
|
||||||
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
|
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Children are promoted to the top level by the self-FK's SET NULL; a
|
||||||
|
// delete never silently takes nested pages with it.
|
||||||
respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId"))
|
respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId"))
|
||||||
}
|
}
|
||||||
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {
|
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Connected pages (Notion-like upgrades phase 3): a real hierarchy with cycle
|
||||||
|
// rejection, Markdown-derived internal links/backlinks that never cross
|
||||||
|
// accounts, and live collection embeds that stay owner-scoped.
|
||||||
|
|
||||||
|
func pageVersion(t *testing.T, c apiClient, id string) int {
|
||||||
|
t.Helper()
|
||||||
|
v := c.request("GET", "/api/pages/"+id, nil, 200)
|
||||||
|
version, _ := v["version"].(float64)
|
||||||
|
return int(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageHierarchy(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
alice := newAPIUser(t, srv, "pages-tree@computer.test")
|
||||||
|
bob := newAPIUser(t, srv, "pages-tree-other@computer.test")
|
||||||
|
|
||||||
|
root := alice.request("POST", "/api/pages", map[string]any{"title": "Root"}, 201)["id"].(string)
|
||||||
|
child := alice.request("POST", "/api/pages", map[string]any{"title": "Child", "parentId": root}, 201)["id"].(string)
|
||||||
|
grand := alice.request("POST", "/api/pages", map[string]any{"title": "Grandchild", "parentId": child}, 201)["id"].(string)
|
||||||
|
|
||||||
|
listed := map[string]string{}
|
||||||
|
for _, p := range alice.getJSON("/api/pages", 200) {
|
||||||
|
parent, _ := p["parentId"].(string)
|
||||||
|
listed[p["id"].(string)] = parent
|
||||||
|
}
|
||||||
|
if listed[root] != "" || listed[child] != root || listed[grand] != child {
|
||||||
|
t.Fatalf("parentId not preserved in the list: %v", listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A page cannot parent itself, nor may a move create a cycle.
|
||||||
|
alice.request("PATCH", "/api/pages/"+child, map[string]any{"parentId": child}, 400)
|
||||||
|
alice.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": grand}, 400)
|
||||||
|
alice.request("PATCH", "/api/pages/"+child, map[string]any{"parentId": grand}, 400)
|
||||||
|
|
||||||
|
// Foreign pages are not valid parents (and are not disclosed as existing).
|
||||||
|
foreign := bob.request("POST", "/api/pages", map[string]any{"title": "Bob only"}, 201)["id"].(string)
|
||||||
|
alice.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": foreign}, 400)
|
||||||
|
alice.request("POST", "/api/pages", map[string]any{"title": "Hijack", "parentId": foreign}, 400)
|
||||||
|
|
||||||
|
// Moving a page between parents works and is metadata-only (no version
|
||||||
|
// requirement, matching title/pin changes).
|
||||||
|
moved := alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": root}, 200)
|
||||||
|
if moved["parentId"] != root {
|
||||||
|
t.Fatalf("parent move not applied: %v", moved["parentId"])
|
||||||
|
}
|
||||||
|
// Explicit null promotes to the top level.
|
||||||
|
promoted := alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": nil}, 200)
|
||||||
|
if promoted["parentId"] != nil {
|
||||||
|
t.Fatalf("null parent did not promote page: %v", promoted["parentId"])
|
||||||
|
}
|
||||||
|
alice.request("PATCH", "/api/pages/"+grand, map[string]any{"parentId": child}, 200)
|
||||||
|
|
||||||
|
// Deleting a parent promotes its children instead of deleting them.
|
||||||
|
alice.request("DELETE", "/api/pages/"+child, nil, 200)
|
||||||
|
alice.request("GET", "/api/pages/"+child, nil, 404)
|
||||||
|
alice.request("GET", "/api/pages/"+grand, nil, 200)
|
||||||
|
if got := alice.request("GET", "/api/pages/"+grand, nil, 200)["parentId"]; got != nil {
|
||||||
|
t.Fatalf("orphaned child was not promoted after parent delete: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A foreign user can never see or mutate the tree.
|
||||||
|
bob.request("GET", "/api/pages/"+root, nil, 404)
|
||||||
|
bob.request("PATCH", "/api/pages/"+root, map[string]any{"parentId": foreign}, 404)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPageBacklinksAndEmbeds(t *testing.T) {
|
||||||
|
srv := testServer(t)
|
||||||
|
alice := newAPIUser(t, srv, "pages-graph@computer.test")
|
||||||
|
bob := newAPIUser(t, srv, "pages-graph-other@computer.test")
|
||||||
|
|
||||||
|
target := alice.request("POST", "/api/pages", map[string]any{"title": "Target page"}, 201)["id"].(string)
|
||||||
|
bobTarget := bob.request("POST", "/api/pages", map[string]any{"title": "Private title"}, 201)["id"].(string)
|
||||||
|
mine := alice.request("POST", "/api/collections", map[string]any{"name": "Reading list"}, 201)
|
||||||
|
collection := mine["id"].(string)
|
||||||
|
view := mine["views"].([]any)[0].(map[string]any)["id"].(string)
|
||||||
|
foreignCollection := bob.request("POST", "/api/collections", map[string]any{"name": "Bob private"}, 201)["id"].(string)
|
||||||
|
|
||||||
|
content := "# Connected\n\n" +
|
||||||
|
"- [Target](computer:page/" + target + ")\n" +
|
||||||
|
"- [Alias](/pages?id=" + target + ")\n" +
|
||||||
|
"- [Bob's page](computer:page/" + bobTarget + ")\n\n" +
|
||||||
|
"{{collection:" + collection + "|view=" + view + "}}\n" +
|
||||||
|
"{{collection:" + foreignCollection + "}}\n"
|
||||||
|
source := alice.request("POST", "/api/pages", map[string]any{"title": "Source page", "content": content}, 201)["id"].(string)
|
||||||
|
|
||||||
|
// Duplicate aliases resolve to one edge; the foreign page is never stored.
|
||||||
|
tgt := alice.request("GET", "/api/pages/"+target, nil, 200)
|
||||||
|
backlinks := tgt["backlinks"].([]any)
|
||||||
|
if len(backlinks) != 1 || backlinks[0].(map[string]any)["id"] != source || backlinks[0].(map[string]any)["title"] != "Source page" {
|
||||||
|
t.Fatalf("backlinks did not resolve owner-scoped pages: %v", backlinks)
|
||||||
|
}
|
||||||
|
listed := alice.getJSON("/api/pages/"+target+"/backlinks", 200)
|
||||||
|
if len(listed) != 1 || listed[0]["id"] != source {
|
||||||
|
t.Fatalf("backlinks endpoint: %v", listed)
|
||||||
|
}
|
||||||
|
bob.request("GET", "/api/pages/"+target+"/backlinks", nil, 404)
|
||||||
|
|
||||||
|
src := alice.request("GET", "/api/pages/"+source, nil, 200)
|
||||||
|
links := src["links"].([]any)
|
||||||
|
if len(links) != 1 || links[0].(map[string]any)["id"] != target {
|
||||||
|
t.Fatalf("outgoing links should contain only the owned target: %v", links)
|
||||||
|
}
|
||||||
|
embeds := src["embeds"].([]any)
|
||||||
|
if len(embeds) != 1 {
|
||||||
|
t.Fatalf("foreign collection embed was stored: %v", embeds)
|
||||||
|
}
|
||||||
|
embed := embeds[0].(map[string]any)
|
||||||
|
if embed["collectionId"] != collection || embed["viewId"] != view {
|
||||||
|
t.Fatalf("embed reference/view not preserved: %v", embed)
|
||||||
|
}
|
||||||
|
col := embed["collection"].(map[string]any)
|
||||||
|
if col["name"] != "Reading list" {
|
||||||
|
t.Fatalf("live embed is missing its collection projection: %v", col)
|
||||||
|
}
|
||||||
|
if props, ok := col["properties"].([]any); !ok || len(props) != 0 {
|
||||||
|
t.Fatalf("embed projection missing properties array: %v", col["properties"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing the Markdown and re-saving removes both derived rows, proving
|
||||||
|
// they are not append-only caches.
|
||||||
|
alice.request("PATCH", "/api/pages/"+source, map[string]any{
|
||||||
|
"content": "# Disconnected", "version": pageVersion(t, alice, source),
|
||||||
|
}, 200)
|
||||||
|
if b := alice.request("GET", "/api/pages/"+target, nil, 200)["backlinks"].([]any); len(b) != 0 {
|
||||||
|
t.Fatalf("stale backlink survived a content save: %v", b)
|
||||||
|
}
|
||||||
|
src = alice.request("GET", "/api/pages/"+source, nil, 200)
|
||||||
|
if e := src["embeds"].([]any); len(e) != 0 {
|
||||||
|
t.Fatalf("stale embed survived a content save: %v", e)
|
||||||
|
}
|
||||||
|
if l := src["links"].([]any); len(l) != 0 {
|
||||||
|
t.Fatalf("stale outgoing link survived a content save: %v", l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-linking re-establishes the edge; deleting the target removes it.
|
||||||
|
alice.request("PATCH", "/api/pages/"+source, map[string]any{
|
||||||
|
"content": "[Target](computer:page/" + target + ")", "version": pageVersion(t, alice, source),
|
||||||
|
}, 200)
|
||||||
|
if b := alice.request("GET", "/api/pages/"+target, nil, 200)["backlinks"].([]any); len(b) != 1 {
|
||||||
|
t.Fatalf("re-link was not resolved: %v", b)
|
||||||
|
}
|
||||||
|
alice.request("DELETE", "/api/pages/"+target, nil, 200)
|
||||||
|
if l := alice.request("GET", "/api/pages/"+source, nil, 200)["links"].([]any); len(l) != 0 {
|
||||||
|
t.Fatalf("deleted target left a dangling link: %v", l)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-links are ignored (a page is not its own backlink).
|
||||||
|
selfContent := "[Me](computer:page/" + source + ")"
|
||||||
|
alice.request("PATCH", "/api/pages/"+source, map[string]any{
|
||||||
|
"content": selfContent, "version": pageVersion(t, alice, source),
|
||||||
|
}, 200)
|
||||||
|
if b := alice.request("GET", "/api/pages/"+source, nil, 200)["backlinks"].([]any); len(b) != 0 {
|
||||||
|
t.Fatalf("self-link became a backlink: %v", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ func NewServer(cfg *config.Config, database *db.DB) *http.Server {
|
|||||||
mux.HandleFunc("GET /api/pages/{id}", authed(handlers.HandlePagesGet))
|
mux.HandleFunc("GET /api/pages/{id}", authed(handlers.HandlePagesGet))
|
||||||
mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate))
|
mux.HandleFunc("PATCH /api/pages/{id}", authed(handlers.HandlePagesUpdate))
|
||||||
mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete))
|
mux.HandleFunc("DELETE /api/pages/{id}", authed(handlers.HandlePagesDelete))
|
||||||
|
mux.HandleFunc("GET /api/pages/{id}/backlinks", authed(handlers.HandlePagesBacklinks))
|
||||||
mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown))
|
mux.HandleFunc("GET /api/pages/{id}/markdown", authed(handlers.HandlePagesMarkdown))
|
||||||
|
|
||||||
// 4.7 Signals
|
// 4.7 Signals
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from 'react'
|
import { useCallback, useEffect, useRef, useState, type ClipboardEvent } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Code, Download, Eye, FileText, GripVertical, Heading1, Heading2, Heading3,
|
ChevronLeft, ChevronRight, Code, CornerUpLeft, Download, Eye, FileText, GripVertical,
|
||||||
Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff, Plus, Quote, Search, Trash2, Type, X,
|
Heading1, Heading2, Heading3, Layers, Link2, List, ListOrdered, ListTodo, Minus, Pencil, Pin, PinOff,
|
||||||
|
Plus, Quote, Search, Trash2, Type, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -15,7 +17,10 @@ import { Textarea } from '@/components/ui/textarea'
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave'
|
import { PageAutosave, PageConflictError, type PageSaveState } from '@/lib/page-autosave'
|
||||||
import { PAGES_CHANGED, nextUntitledTitle, notifyActivePage, notifyPagesChanged, type PageSummary } from '@/lib/pages'
|
import {
|
||||||
|
PAGES_CHANGED, collectionEmbedMarkdown, descendantPageIds, internalPageId, nextUntitledTitle,
|
||||||
|
notifyActivePage, notifyPagesChanged, pageLinkMarkdown, parsePageEmbed, type PageSummary,
|
||||||
|
} from '@/lib/pages'
|
||||||
import { reorderPages } from '@/lib/page-order'
|
import { reorderPages } from '@/lib/page-order'
|
||||||
import { fetchLinkPreview } from '@/lib/link-preview'
|
import { fetchLinkPreview } from '@/lib/link-preview'
|
||||||
import { LinkPreviewCard } from '@/components/link-preview-card'
|
import { LinkPreviewCard } from '@/components/link-preview-card'
|
||||||
@@ -40,11 +45,118 @@ const SLASH_BLOCKS = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
type LinkedBookmark = { id: string; url: string; title: string | null; favicon: string | null; ogImage: string | null; ogTitle: string | null; ogDescription: string | null; description: string | null }
|
type LinkedBookmark = { id: string; url: string; title: string | null; favicon: string | null; ogImage: string | null; ogTitle: string | null; ogDescription: string | null; description: string | null }
|
||||||
type PageDetail = PageSummary & { content: string; version: number; bookmarks: { bookmark: LinkedBookmark }[] }
|
type PageBacklink = { id: string; title: string; updatedAt: string }
|
||||||
|
type EmbeddedProperty = {
|
||||||
|
id: string; name: string
|
||||||
|
type: 'TEXT' | 'NUMBER' | 'SELECT' | 'MULTI_SELECT' | 'DATE' | 'CHECKBOX' | 'URL'
|
||||||
|
position: number; config: { options?: { id: string; label: string; color?: string }[]; mode?: 'date' | 'datetime' }
|
||||||
|
}
|
||||||
|
type EmbeddedView = { id: string; name: string; type: string; position: number }
|
||||||
|
type PageEmbed = {
|
||||||
|
collectionId: string; viewId: string | null; position: number
|
||||||
|
collection: { id: string; name: string; icon: string | null; description: string | null; properties: EmbeddedProperty[]; views: EmbeddedView[] }
|
||||||
|
view: EmbeddedView | null
|
||||||
|
}
|
||||||
|
type EmbeddedItem = { id: string; propertyValues: Record<string, unknown>; bookmark: LinkedBookmark }
|
||||||
|
type PageDetail = PageSummary & {
|
||||||
|
content: string; version: number
|
||||||
|
bookmarks: { bookmark: LinkedBookmark }[]
|
||||||
|
backlinks: PageBacklink[]
|
||||||
|
links: { id: string; title: string }[]
|
||||||
|
embeds: PageEmbed[]
|
||||||
|
}
|
||||||
|
|
||||||
const notify = notifyPagesChanged
|
const notify = notifyPagesChanged
|
||||||
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
|
const host = (u: string) => { try { return new URL(u).hostname.replace(/^www\./, '') } catch { return u } }
|
||||||
|
|
||||||
|
// Read-only live collection view embedded in a page (phase 3). It fetches the
|
||||||
|
// same authorized item endpoint the collection workspace uses, so a live
|
||||||
|
// embed never exposes a bookmark the viewer cannot already see and the
|
||||||
|
// server's filters/sorts still apply. It is a display, never a publication.
|
||||||
|
function PageEmbedCard({ embed }: { embed: PageEmbed }) {
|
||||||
|
const viewId = embed.viewId ?? embed.collection.views[0]?.id ?? null
|
||||||
|
const embedKey = `${embed.collectionId}|${viewId ?? ''}`
|
||||||
|
// The fetched result carries its embed key so switching collections never
|
||||||
|
// shows another embed's items for a frame; the effect only writes state
|
||||||
|
// asynchronously (no cascading synchronous setState).
|
||||||
|
const [loaded, setLoaded] = useState<{ key: string; items: EmbeddedItem[] | null; failed: boolean }>({ key: '', items: null, failed: false })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const params = new URLSearchParams({ limit: '50' })
|
||||||
|
if (viewId) params.set('viewId', viewId)
|
||||||
|
fetch(`/api/collections/${embed.collectionId}/items?${params}`)
|
||||||
|
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('load failed'))))
|
||||||
|
.then((data: EmbeddedItem[]) => { if (!cancelled) setLoaded({ key: embedKey, items: data, failed: false }) })
|
||||||
|
.catch(() => { if (!cancelled) setLoaded({ key: embedKey, items: null, failed: true }) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [embed.collectionId, embedKey, viewId])
|
||||||
|
|
||||||
|
const current = loaded.key === embedKey ? loaded : { key: embedKey, items: null, failed: false }
|
||||||
|
const items = current.items
|
||||||
|
const failed = current.failed
|
||||||
|
|
||||||
|
const properties = embed.collection.properties.slice(0, 4)
|
||||||
|
const optionLabel = (prop: EmbeddedProperty, value: unknown) => {
|
||||||
|
if (prop.type === 'CHECKBOX') return value === true ? 'Yes' : 'No'
|
||||||
|
if (prop.type === 'SELECT') return prop.config.options?.find((o) => o.id === value)?.label ?? '—'
|
||||||
|
if (prop.type === 'MULTI_SELECT') {
|
||||||
|
if (!Array.isArray(value)) return '—'
|
||||||
|
return value.map((id) => prop.config.options?.find((o) => o.id === id)?.label).filter(Boolean).join(', ') || '—'
|
||||||
|
}
|
||||||
|
if (prop.type === 'DATE' && typeof value === 'string' && value) {
|
||||||
|
if (prop.config.mode === 'date') return value.slice(0, 10)
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString()
|
||||||
|
}
|
||||||
|
if (value === null || value === undefined || value === '') return '—'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
const viewLabel = embed.view?.name ?? embed.collection.views[0]?.name
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="not-prose my-4 overflow-hidden rounded-lg border bg-card">
|
||||||
|
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
<Layers className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<Link href="/collections" className="truncate text-sm font-medium hover:underline">{embed.collection.name}</Link>
|
||||||
|
{viewLabel && <span className="truncate text-xs text-muted-foreground">· {viewLabel}</span>}
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">{items ? `${items.length} item${items.length === 1 ? '' : 's'}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
{failed ? (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground">This collection could not be loaded.</p>
|
||||||
|
) : items === null ? (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground">Loading collection…</p>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground">No items match this view.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-medium">Bookmark</th>
|
||||||
|
{properties.map((p) => <th key={p.id} className="px-3 py-2 font-medium">{p.name}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id} className="border-t">
|
||||||
|
<td className="max-w-[18rem] px-3 py-2">
|
||||||
|
<Link href={`/bookmarks?open=${item.bookmark.id}`} className="block truncate hover:underline">
|
||||||
|
{item.bookmark.title || item.bookmark.ogTitle || item.bookmark.url}
|
||||||
|
</Link>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">{host(item.bookmark.url)}</span>
|
||||||
|
</td>
|
||||||
|
{properties.map((p) => <td key={p.id} className="max-w-[12rem] truncate px-3 py-2 text-muted-foreground">{optionLabel(p, item.propertyValues?.[p.id])}</td>)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const textOf = (nodes: ElementContent[]): string =>
|
const textOf = (nodes: ElementContent[]): string =>
|
||||||
nodes.map((n) => (n.type === 'text' ? n.value : n.type === 'element' ? textOf(n.children) : '')).join('')
|
nodes.map((n) => (n.type === 'text' ? n.value : n.type === 'element' ? textOf(n.children) : '')).join('')
|
||||||
|
|
||||||
@@ -76,6 +188,10 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
const [linkQuery, setLinkQuery] = useState('')
|
const [linkQuery, setLinkQuery] = useState('')
|
||||||
const [results, setResults] = useState<LinkedBookmark[]>([])
|
const [results, setResults] = useState<LinkedBookmark[]>([])
|
||||||
const [linkOpen, setLinkOpen] = useState(false)
|
const [linkOpen, setLinkOpen] = useState(false)
|
||||||
|
const [pageLinkOpen, setPageLinkOpen] = useState(false)
|
||||||
|
const [pageLinkQuery, setPageLinkQuery] = useState('')
|
||||||
|
const [embedOpen, setEmbedOpen] = useState(false)
|
||||||
|
const [collections, setCollections] = useState<{ id: string; name: string; icon: string | null; views?: { id: string; name: string }[] }[]>([])
|
||||||
const [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
|
const [slash, setSlash] = useState<{ query: string; start: number } | null>(null)
|
||||||
const [slashIndex, setSlashIndex] = useState(0)
|
const [slashIndex, setSlashIndex] = useState(0)
|
||||||
const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null)
|
const [tabDrop, setTabDrop] = useState<{ id: string; after: boolean } | null>(null)
|
||||||
@@ -246,6 +362,32 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
autosave.edit(page.id, v)
|
autosave.edit(page.id, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert generated Markdown at the caret (or at the end when the editor is
|
||||||
|
// not focused). The content stays the source of truth: page links and
|
||||||
|
// embeds are resolved by the server from this text.
|
||||||
|
const insertSnippet = (snippet: string) => {
|
||||||
|
if (!page || page.id !== activeId) return
|
||||||
|
const ta = textareaRef.current
|
||||||
|
const caret = mode === 'edit' && ta ? ta.selectionStart : content.length
|
||||||
|
const before = content.slice(0, caret)
|
||||||
|
const after = content.slice(caret)
|
||||||
|
const lead = before && !before.endsWith('\n') ? '\n\n' : ''
|
||||||
|
const trail = after.startsWith('\n') ? '' : '\n'
|
||||||
|
const next = before + lead + snippet + trail + after
|
||||||
|
pendingCaret.current = before.length + lead.length + snippet.length
|
||||||
|
setMode('edit')
|
||||||
|
onContentChange(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setParent = async (value: string) => {
|
||||||
|
if (!page) return
|
||||||
|
const parentId = value || null
|
||||||
|
if (await patch({ parentId })) {
|
||||||
|
setPage((current) => current?.id === page.id ? { ...current, parentId } : current)
|
||||||
|
await loadPages(); notify()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show the block menu only while the caret sits on a bare "/token" line.
|
// Show the block menu only while the caret sits on a bare "/token" line.
|
||||||
const updateSlash = (text: string, caret: number) => {
|
const updateSlash = (text: string, caret: number) => {
|
||||||
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
|
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
|
||||||
@@ -372,6 +514,14 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
else { activePage.current = null; setActiveId(null); router.replace('/pages') }
|
else { activePage.current = null; setActiveId(null); router.replace('/pages') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!embedOpen) return
|
||||||
|
fetch('/api/collections')
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((data) => setCollections(Array.isArray(data) ? data : []))
|
||||||
|
.catch(() => { /* transient; reopening retries */ })
|
||||||
|
}, [embedOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!linkOpen) return
|
if (!linkOpen) return
|
||||||
const q = linkQuery.trim()
|
const q = linkQuery.trim()
|
||||||
@@ -448,6 +598,13 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
|
|
||||||
const visible = pages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()))
|
const visible = pages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()))
|
||||||
const looksLikeUrl = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/i.test(linkQuery.trim())
|
const looksLikeUrl = /^(https?:\/\/)?[\w-]+(\.[\w-]+)+/i.test(linkQuery.trim())
|
||||||
|
// A page may move under any page except itself or one of its descendants
|
||||||
|
// (the server rejects that cycle even if a stale client tries it).
|
||||||
|
const blockedParents = page ? descendantPageIds(pages, page.id) : new Set<string>()
|
||||||
|
const parentOptions = page
|
||||||
|
? pages.filter((p) => p.id !== page.id && !blockedParents.has(p.id))
|
||||||
|
: []
|
||||||
|
const pageLinkOptions = pages.filter((p) => p.id !== activeId && p.title.toLowerCase().includes(pageLinkQuery.toLowerCase()))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
@@ -540,6 +697,51 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
<Button variant="ghost" size="icon-sm" title="Delete page" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
<Button variant="ghost" size="icon-sm" title="Delete page" onClick={remove}><Trash2 className="h-4 w-4 text-destructive" /></Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<label className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
||||||
|
<CornerUpLeft className="h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="shrink-0">Parent</span>
|
||||||
|
<select
|
||||||
|
value={page.parentId ?? ''}
|
||||||
|
onChange={(e) => void setParent(e.target.value)}
|
||||||
|
aria-label="Parent page"
|
||||||
|
className="h-8 max-w-[16rem] rounded-md border bg-background px-2 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
<option value="">Top level</option>
|
||||||
|
{parentOptions.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<Popover open={pageLinkOpen} onOpenChange={(open) => { setPageLinkOpen(open); if (!open) setPageLinkQuery('') }}>
|
||||||
|
<PopoverTrigger asChild><Button size="sm" variant="outline"><FileText className="mr-1 h-3.5 w-3.5" />Link a page</Button></PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-80 p-2">
|
||||||
|
<Input autoFocus value={pageLinkQuery} onChange={(e) => setPageLinkQuery(e.target.value)} placeholder="Find a page to link" className="h-8 text-sm" />
|
||||||
|
<div className="mt-2 max-h-64 overflow-y-auto">
|
||||||
|
{pageLinkOptions.map((p) => (
|
||||||
|
<button key={p.id} onClick={() => { insertSnippet(pageLinkMarkdown(p)); setPageLinkOpen(false) }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted">
|
||||||
|
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{p.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{pageLinkOptions.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No matching pages</p>}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<Popover open={embedOpen} onOpenChange={setEmbedOpen}>
|
||||||
|
<PopoverTrigger asChild><Button size="sm" variant="outline"><Layers className="mr-1 h-3.5 w-3.5" />Embed a collection</Button></PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-80 p-2">
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{collections.map((c) => (
|
||||||
|
<button key={c.id} onClick={() => { insertSnippet(collectionEmbedMarkdown(c.id, c.views?.[0]?.id)); setEmbedOpen(false) }} className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-muted">
|
||||||
|
<Layers className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{c.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{collections.length === 0 && <p className="px-2 py-3 text-center text-xs text-muted-foreground">No collections yet. Create one from Collections first.</p>}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
{saveState === 'conflict' && (
|
{saveState === 'conflict' && (
|
||||||
<div role="alert" className="mb-4 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
<div role="alert" className="mb-4 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
||||||
<p className="font-medium text-foreground">This page was changed elsewhere</p>
|
<p className="font-medium text-foreground">This page was changed elsewhere</p>
|
||||||
@@ -628,6 +830,17 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
p: ({ node, ...props }) => {
|
p: ({ node, ...props }) => {
|
||||||
|
// A paragraph that is exactly an embed marker renders a
|
||||||
|
// live, read-only collection view instead of raw text.
|
||||||
|
const marker = node ? parsePageEmbed(textOf(node.children)) : null
|
||||||
|
if (marker) {
|
||||||
|
const embed = page.embeds.find((e) => e.collectionId === marker.collectionId)
|
||||||
|
return embed ? (
|
||||||
|
<PageEmbedCard embed={embed} />
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">This embedded collection is unavailable or not yours.</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
const link = standaloneLink(node)
|
const link = standaloneLink(node)
|
||||||
return link ? (
|
return link ? (
|
||||||
<LinkPreviewCard url={link.href} label={link.label !== link.href ? link.label : undefined} />
|
<LinkPreviewCard url={link.href} label={link.label !== link.href ? link.label : undefined} />
|
||||||
@@ -635,7 +848,13 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
<p {...props} />
|
<p {...props} />
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
a: ({ href, ...props }) => {
|
||||||
|
const target = href ? internalPageId(href) : null
|
||||||
|
if (target) {
|
||||||
|
return <Link href={`/pages?id=${target}`} className="text-primary underline-offset-2 hover:underline" {...props} />
|
||||||
|
}
|
||||||
|
return <a {...props} href={href} target="_blank" rel="noopener noreferrer" />
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>{content}</ReactMarkdown>
|
>{content}</ReactMarkdown>
|
||||||
) : (
|
) : (
|
||||||
@@ -692,6 +911,27 @@ export function PagesClient({ initialId }: { initialId: string | null }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{page.backlinks.length > 0 && (
|
||||||
|
<section className="mt-8" aria-label="Pages linking here">
|
||||||
|
<h3 className="mb-2 font-display text-lg font-semibold">
|
||||||
|
Linked from <span className="text-sm font-normal text-muted-foreground">({page.backlinks.length})</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{page.backlinks.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void select(b.id)}
|
||||||
|
className="flex max-w-full items-center gap-1.5 rounded-md border bg-card px-2.5 py-1.5 text-sm transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
<CornerUpLeft className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{b.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
|
|||||||
const [renamingId, setRenamingId] = useState<string | null>(null)
|
const [renamingId, setRenamingId] = useState<string | null>(null)
|
||||||
const [renameValue, setRenameValue] = useState('')
|
const [renameValue, setRenameValue] = useState('')
|
||||||
const [pageDrop, setPageDrop] = useState<{ id: string; after: boolean } | null>(null)
|
const [pageDrop, setPageDrop] = useState<{ id: string; after: boolean } | null>(null)
|
||||||
|
const [collapsedPages, setCollapsedPages] = useState<Set<string>>(() => new Set())
|
||||||
const draggedPageRef = useRef<string | null>(null)
|
const draggedPageRef = useRef<string | null>(null)
|
||||||
const activePageId = pathname === '/pages' ? searchParams.get('id') : null
|
const activePageId = pathname === '/pages' ? searchParams.get('id') : null
|
||||||
|
|
||||||
@@ -167,12 +168,16 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
|
|||||||
return () => window.removeEventListener(PAGES_CHANGED, load)
|
return () => window.removeEventListener(PAGES_CHANGED, load)
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
const createPage = async () => {
|
const createPage = async (parentId?: string | null) => {
|
||||||
const title = nextUntitledTitle(pages.map((p) => p.title))
|
const title = nextUntitledTitle(pages.map((p) => p.title))
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/pages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title }) })
|
const res = await fetch('/api/pages', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(parentId ? { title, parentId } : { title }),
|
||||||
|
})
|
||||||
if (!res.ok) { toast.error('Could not create page'); return }
|
if (!res.ok) { toast.error('Could not create page'); return }
|
||||||
const p = await res.json()
|
const p = await res.json()
|
||||||
|
if (parentId) setCollapsedPages((current) => { const next = new Set(current); next.delete(parentId); return next })
|
||||||
notifyPagesChanged()
|
notifyPagesChanged()
|
||||||
router.push(`/pages?id=${p.id}`)
|
router.push(`/pages?id=${p.id}`)
|
||||||
} catch { toast.error('Could not create page') }
|
} catch { toast.error('Could not create page') }
|
||||||
@@ -234,16 +239,56 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
|
|||||||
if (next) void savePageOrder(next)
|
if (next) void savePageOrder(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleCollapsed = (id: string) => {
|
||||||
|
setCollapsedPages((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flatten the owner's page tree for display. The server already orders the
|
||||||
|
// list (pinned first, then position); preserving that order per parent keeps
|
||||||
|
// pinned pages predictable while nested children follow their parent.
|
||||||
|
const pageRows: { page: PageSummary; depth: number; hasChildren: boolean }[] = []
|
||||||
|
{
|
||||||
|
const known = new Set(pages.map((p) => p.id))
|
||||||
|
const byParent = new Map<string | null, PageSummary[]>()
|
||||||
|
for (const p of pages) {
|
||||||
|
const key = p.parentId && known.has(p.parentId) ? p.parentId : null
|
||||||
|
const list = byParent.get(key) ?? []
|
||||||
|
list.push(p)
|
||||||
|
byParent.set(key, list)
|
||||||
|
}
|
||||||
|
const visited = new Set<string>()
|
||||||
|
const visit = (parent: string | null, depth: number) => {
|
||||||
|
for (const p of byParent.get(parent) ?? []) {
|
||||||
|
if (visited.has(p.id)) continue
|
||||||
|
visited.add(p.id)
|
||||||
|
const children = byParent.get(p.id) ?? []
|
||||||
|
pageRows.push({ page: p, depth, hasChildren: children.length > 0 })
|
||||||
|
if (!collapsedPages.has(p.id)) visit(p.id, depth + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(null, 0)
|
||||||
|
// Defensive: a corrupt/cyclic pre-existing hierarchy must still render
|
||||||
|
// every page once rather than disappear from the sidebar.
|
||||||
|
for (const p of pages) {
|
||||||
|
if (!visited.has(p.id)) pageRows.push({ page: p, depth: 0, hasChildren: (byParent.get(p.id) ?? []).length > 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const viewEntries = isAdmin ? [...viewItems, { href: '/admin', label: 'Admin', icon: Shield }] : viewItems
|
const viewEntries = isAdmin ? [...viewItems, { href: '/admin', label: 'Admin', icon: Shield }] : viewItems
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
<nav className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||||
<SectionHeader label="Pages" open={open} expanded={pagesExpanded} onToggle={() => setPagesExpanded(!pagesExpanded)} action={{ label: 'New page', onClick: createPage }} />
|
<SectionHeader label="Pages" open={open} expanded={pagesExpanded} onToggle={() => setPagesExpanded(!pagesExpanded)} action={{ label: 'New page', onClick: () => void createPage() }} />
|
||||||
{(pagesExpanded || !open) && pages.map((p) => {
|
{(pagesExpanded || !open) && pageRows.map(({ page: p, depth, hasChildren }) => {
|
||||||
const isActive = pathname === '/pages' && activePageId === p.id
|
const isActive = pathname === '/pages' && activePageId === p.id
|
||||||
if (renamingId === p.id) {
|
if (renamingId === p.id) {
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="px-1 py-0.5">
|
<div key={p.id} className="px-1 py-0.5" style={{ paddingLeft: depth * 12 }}>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
value={renameValue}
|
value={renameValue}
|
||||||
@@ -278,12 +323,24 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
|
|||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
dropPage(p, e.clientY > rect.top + rect.height / 2)
|
dropPage(p, e.clientY > rect.top + rect.height / 2)
|
||||||
}}
|
}}
|
||||||
|
style={{ paddingLeft: depth * 12 }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/row flex items-center rounded-lg pr-1 transition-colors',
|
'group/row flex items-center rounded-lg pr-1 transition-colors',
|
||||||
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
|
isActive ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50',
|
||||||
pageDrop?.id === p.id && 'ring-1 ring-primary/50'
|
pageDrop?.id === p.id && 'ring-1 ring-primary/50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{open && (hasChildren ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleCollapsed(p.id)}
|
||||||
|
aria-label={collapsedPages.has(p.id) ? `Expand ${p.title}` : `Collapse ${p.title}`}
|
||||||
|
aria-expanded={!collapsedPages.has(p.id)}
|
||||||
|
className="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn('h-3 w-3 transition-transform', collapsedPages.has(p.id) && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
) : <span className="w-4 shrink-0" aria-hidden />)}
|
||||||
{open && (
|
{open && (
|
||||||
<span
|
<span
|
||||||
data-page-drag-handle
|
data-page-drag-handle
|
||||||
@@ -308,9 +365,9 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
|
|||||||
{open && (
|
{open && (
|
||||||
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/row:opacity-100">
|
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/row:opacity-100">
|
||||||
<button
|
<button
|
||||||
onClick={createPage}
|
onClick={() => void createPage(p.id)}
|
||||||
title="New page"
|
title="New subpage"
|
||||||
aria-label="New page"
|
aria-label={`New subpage under ${p.title}`}
|
||||||
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
@@ -330,6 +387,16 @@ function SidebarNav({ open, pathname, isAdmin = false }: { open: boolean; pathna
|
|||||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||||
Rename
|
Rename
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => void createPage(p.id)}>
|
||||||
|
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||||
|
New subpage
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{p.parentId && (
|
||||||
|
<DropdownMenuItem onClick={() => patchPage(p.id, { parentId: null })}>
|
||||||
|
<ChevronRight className="mr-2 h-3.5 w-3.5" />
|
||||||
|
Move to top level
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => removePage(p)}>
|
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={() => removePage(p)}>
|
||||||
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -85,11 +85,17 @@ Composite PK (`tagId`, `userId`), `createdAt`; both Cascade.
|
|||||||
`id`, `label`, `color` (default `#64748b`), `position` int, `key?` (`INBOX`/`TODO`/`IN_PROGRESS`/`DONE` for the default set, null for custom columns), `userId` → User (Cascade). Index (`userId`, `position`).
|
`id`, `label`, `color` (default `#64748b`), `position` int, `key?` (`INBOX`/`TODO`/`IN_PROGRESS`/`DONE` for the default set, null for custom columns), `userId` → User (Cascade). Index (`userId`, `position`).
|
||||||
|
|
||||||
### 2.12 `Page` (Markdown workspace)
|
### 2.12 `Page` (Markdown workspace)
|
||||||
`id`, `title`, `content` (text, default empty), `icon?`, `pinned` bool, `position` int, `version` int (default 1; optimistic-concurrency revision — content saves bump it, stale base versions get 409), `ownerId` → User (Cascade). Index (`ownerId`, `position`).
|
`id`, `title`, `content` (text, default empty), `icon?`, `pinned` bool, `position` int, `parentId?` → Page (SetNull; nested-page tree, cycles rejected by the API), `version` int (default 1; optimistic-concurrency revision — content saves bump it, stale base versions get 409), `ownerId` → User (Cascade). Indexes (`ownerId`, `position`) and (`parentId`).
|
||||||
|
|
||||||
### 2.13 `PageBookmark` (link a bookmark onto a page)
|
### 2.13 `PageBookmark` (link a bookmark onto a page)
|
||||||
Composite PK (`pageId`, `bookmarkId`), `position` int, `createdAt`; both Cascade.
|
Composite PK (`pageId`, `bookmarkId`), `position` int, `createdAt`; both Cascade.
|
||||||
|
|
||||||
|
### 2.13b `PageLink` (resolved internal page link)
|
||||||
|
Composite PK (`sourcePageId`, `targetPageId`), `createdAt`; both Cascade, with a no-self check. Rebuilt from Markdown (`computer:page/<id>`) on every content save and only for pages owned by the author, so backlinks never cross accounts.
|
||||||
|
|
||||||
|
### 2.13c `PageEmbed` (live collection view inside a page)
|
||||||
|
Composite PK (`pageId`, `collectionId`), `viewId?` → CollectionView (SetNull), `position` int, `createdAt`; page and collection Cascade. Rebuilt from `{{collection:<id>|view=<viewId>}}` on every content save; only owned collections are stored. Item reads still go through the collection endpoint, so an embed displays, never publishes.
|
||||||
|
|
||||||
### 2.14 `SignalSource` (a watcher attached to a bookmark)
|
### 2.14 `SignalSource` (a watcher attached to a bookmark)
|
||||||
`id`, `type` (e.g. `email`), `config` (JSON, default `{}`), `enabled` (default true), `lastCheckedAt?`, `lastStatus?`, `bookmarkId` → Bookmark (Cascade), `ownerId` → User (Cascade).
|
`id`, `type` (e.g. `email`), `config` (JSON, default `{}`), `enabled` (default true), `lastCheckedAt?`, `lastStatus?`, `bookmarkId` → Bookmark (Cascade), `ownerId` → User (Cascade).
|
||||||
|
|
||||||
|
|||||||
+64
-1
@@ -9,7 +9,70 @@ export const ACTIVE_PAGE_CHANGED = 'computer:active-page-changed'
|
|||||||
export const notifyActivePage = (title: string | null) =>
|
export const notifyActivePage = (title: string | null) =>
|
||||||
window.dispatchEvent(new CustomEvent<string | null>(ACTIVE_PAGE_CHANGED, { detail: title }))
|
window.dispatchEvent(new CustomEvent<string | null>(ACTIVE_PAGE_CHANGED, { detail: title }))
|
||||||
|
|
||||||
export type PageSummary = { id: string; title: string; pinned: boolean; position: number; _count?: { bookmarks: number } }
|
export type PageSummary = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
pinned: boolean
|
||||||
|
position: number
|
||||||
|
parentId: string | null
|
||||||
|
_count?: { bookmarks: number; children?: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected pages (phase 3): the same link forms the editor understands,
|
||||||
|
// shared by the page client, sidebar and Markdown preview. Page links use
|
||||||
|
// `computer:page/<id>`; /pages?id=<id> is accepted for hand-written links.
|
||||||
|
export function internalPageId(href: string): string | null {
|
||||||
|
const value = href.trim().replace(/^<|>$/g, '')
|
||||||
|
let raw = ''
|
||||||
|
if (value.startsWith('computer://page/')) raw = value.slice('computer://page/'.length)
|
||||||
|
else if (value.startsWith('computer:page/')) raw = value.slice('computer:page/'.length)
|
||||||
|
else if (value.startsWith('page/')) raw = value.slice('page/'.length)
|
||||||
|
else if (value.startsWith('/pages/')) raw = value.slice('/pages/'.length)
|
||||||
|
else if (value.startsWith('/pages?') || value.startsWith('computer:page?id=') || value.startsWith('computer://page?id=')) {
|
||||||
|
try {
|
||||||
|
const id = new URL(value, 'http://localhost').searchParams.get('id')
|
||||||
|
raw = id ?? ''
|
||||||
|
} catch { raw = '' }
|
||||||
|
}
|
||||||
|
raw = raw.replace(/\/$/, '')
|
||||||
|
return /^[A-Za-z0-9_-]{1,128}$/.test(raw) ? raw : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pageLinkMarkdown = (page: Pick<PageSummary, 'id' | 'title'>) =>
|
||||||
|
`[${page.title.replace(/[\[\]\n]/g, ' ')}](computer:page/${page.id})`
|
||||||
|
|
||||||
|
export type PageEmbedMarker = { collectionId: string; viewId: string | null }
|
||||||
|
|
||||||
|
export function parsePageEmbed(value: string): PageEmbedMarker | null {
|
||||||
|
const m = /^\{\{\s*collection:([A-Za-z0-9_-]{1,128})(?:\s*\|\s*view=([A-Za-z0-9_-]{1,128}))?\s*\}\}$/.exec(value.trim())
|
||||||
|
if (!m) return null
|
||||||
|
return { collectionId: m[1], viewId: m[2] ?? null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collectionEmbedMarkdown = (collectionId: string, viewId?: string | null) =>
|
||||||
|
`{{collection:${collectionId}${viewId ? `|view=${viewId}` : ''}}}`
|
||||||
|
|
||||||
|
// Ids below `id` in the current page tree. Used to keep the parent picker
|
||||||
|
// from offering a descendant (the server rejects the cycle regardless).
|
||||||
|
export function descendantPageIds(pages: PageSummary[], id: string): Set<string> {
|
||||||
|
const out = new Set<string>()
|
||||||
|
const children = new Map<string, string[]>()
|
||||||
|
for (const p of pages) {
|
||||||
|
if (!p.parentId) continue
|
||||||
|
const list = children.get(p.parentId) ?? []
|
||||||
|
list.push(p.id)
|
||||||
|
children.set(p.parentId, list)
|
||||||
|
}
|
||||||
|
const walk = (parent: string) => {
|
||||||
|
for (const child of children.get(parent) ?? []) {
|
||||||
|
if (out.has(child)) continue
|
||||||
|
out.add(child)
|
||||||
|
walk(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(id)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// Pages start as "Untitled 1", "Untitled 2", ... until the user renames them.
|
// Pages start as "Untitled 1", "Untitled 2", ... until the user renames them.
|
||||||
// Pick the lowest positive integer not already used by an untitled page.
|
// Pick the lowest positive integer not already used by an untitled page.
|
||||||
|
|||||||
Reference in New Issue
Block a user