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.
197 lines
6.5 KiB
Go
197 lines
6.5 KiB
Go
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
|
|
}
|