mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
905876af04
* feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) Phase 2b of PLAN-1593 (TASK-1597). Completes the wiki-link reverse index by indexing and surfacing `[[workspace::REF]]` cross-workspace references. Builds on Phase 2a's title work (PR #621). Phase 3 (TASK-1596) owns the UI/MCP/CLI rendering changes. What changed - internal/store/backlinks_visibility.go (new): request-independent ACL helper `Store.ResolveBacklinksVisibility(userID, workspaceID, includeDeletedItems)`. Mirrors the role-determination + collection- merge logic from server.guestResourceFilterCore but doesn't depend on a request context, so cross-ws traversal can compute per-source- workspace ACLs without a `workspaceRole(r)` lookup. The Codex planning-round review caught the prior plan reusing the request- scoped helper as a hidden architectural cost; this is the resolution. - internal/server/server.go: guestResourceFilterCore refactored to delegate to the new store helper. Keeps the request-scoped wrapper signature stable for all existing handler call sites; only the internals move. - internal/links/extract.go: lift the Phase-2a workspace_ref emit gate. WikiLinkKindWorkspaceRef now flows through ExtractWikiLinks alongside ref and title kinds. parseBody recognition was already in place from earlier rounds. - internal/store/wiki_links.go: WikiLinkKindWorkspaceRef branch in replaceWikiLinks stores (target_workspace_id, target_ref) verbatim, resolving the slug→ID via new resolveWorkspaceSlugTx (with per-call cache so repeated `[[ws::X]]` in one body don't re-query). Unknown slugs persist with target_workspace_id=NULL — broken-link semantics, identical to existing ref/title patterns. - internal/store/wiki_links.go: new `Store.GetCrossWorkspaceBacklinks` enumerates accessible workspaces via Store.GetUserWorkspaces (which includes guest-only access — broader than membership query), then per-workspace computes visibility via ResolveBacklinksVisibility and runs the SQL backlinks query with the per-ws (FullCollectionIDs, GrantedItemIDs) predicate inline. Results sorted by updated_at DESC in Go, paginated globally. Per-workspace safety cap (offset+limit) prevents one workspace from dominating the global slice. - internal/store/wiki_links.go: new `Store.CountBacklinks` for same-ws pagination boundary detection. Needed so the handler knows where the cross-ws tier begins for pages 2+. - internal/models/backlink.go: new `SourceWorkspaceSlug string` (omitempty) field. Populated only by cross-ws rows; same-ws rows leave it empty so the existing wire shape is preserved. - internal/server/handlers_backlinks.go: union pagination across same-ws and cross-ws tiers. Same-ws first (matches the renderer's UI mental model — your own workspace's links at the top of the panel). Count-based slice math handles pages 2+ correctly when same-ws is exhausted. Tests - internal/links/extract_test.go: workspace_ref forms emit correctly (bare, display alias, mixed case, invalid-slug fallback to title). - internal/store/wiki_links_xws_test.go (new): six cross-ws scenarios plus a role-matrix test: - end-to-end cross-ws index + query - non-member sees nothing - guest with collection grant sees only that collection - guest with item grant sees only the granted item - unknown workspace slug → broken row, no query results - same-ws rows leave SourceWorkspaceSlug empty - ResolveBacklinksVisibility role matrix (admin/full member/guest with grants/non-member non-grant) Out of scope (Phase 3 / TASK-1596) UI rendering of cross-ws backlinks (workspace badge + workspace- prefixed ref), MCP `pad_item.action: backlinks` cross-ws fields, CLI display tweaks. PLAN-1593 / TASK-1597. * fix(backlinks): admin enumeration + cross-prefix ref fallback + unbounded perWsCap (Codex round 1) Three P2 findings from Codex round 1 against PR #622: Finding 1 — admin users miss cross-ws backlinks. `GetUserWorkspaces` returns only memberships + grant-only guest workspaces, but RequireWorkspaceAccess (middleware_auth.go:481) gives admins implicit access to every workspace. An admin querying for backlinks would silently miss links from workspaces they're not explicitly a member of. Fix: in GetCrossWorkspaceBacklinks, branch on user.Role: - admin → s.ListWorkspaces() (every non-deleted workspace) - non-admin → s.GetUserWorkspaces (memberships + grants) Stale user IDs return empty result rather than erroring. Finding 2 — cross-ws ref matching doesn't handle cross-prefix moves. Same-ws is immune because target_item_id is resolved at parse time and survives renames/moves; cross-ws resolves at query time, so a `[[other-ws::OLD-42]]` row written before the target moved from OLD→NEW collection wouldn't match a query under the NEW ref. Fix: in queryCrossWorkspaceBacklinksForWorkspace, dual ref-match clause: exact `LOWER(wl.target_ref) = LOWER(?)` OR `LOWER(wl.target_ref) LIKE LOWER('%-N')` where N is the item_number from the target ref. Pad prefixes are alphanumeric with no internal `-`, so trailing `-N` uniquely identifies the number suffix — no false positives like "TASK-142" matching "%-42" (LIKE anchors to the trailing literal). Finding 3 — per-workspace cap of 1000 silently broke pagination beyond offset>=1000. The 1000 ceiling was defensive paranoia; the correct math is offset+limit per workspace (worst case all rows come from one workspace and the global slice still needs that many). Fix: drop the 1000 ceiling. perWsCap = offset+limit unconditionally. For runaway offsets the per-workspace transfer cost is proportional; documented as a known characteristic (callers shouldn't be paging past offset=10000 anyway). Regression tests: - TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces: admin sees cross-ws backlink without being a workspace member. - TestWikiLinks_CrossWorkspaceRefNumberFallback: move target to new collection, query under new ref, old-ref-stored row still surfaces. PLAN-1593 / TASK-1597. * fix(backlinks): honor OAuth/MCP token workspace allow-list (Codex round 2) Codex round 2 P1: cross-workspace backlinks bypassed the OAuth/MCP token's workspace allow-list (TASK-952). A token consented for workspace A but with the underlying user having access to B would still surface source rows from B via the cross-ws query — leaking data outside the token's consent scope. Fix: thread `allowedWorkspaceSlugs []string` through GetCrossWorkspaceBacklinks. Handler populates it from TokenAllowedWorkspacesFromContext(r.Context()): - nil → no token gate (PAT or pre-TASK-952 token, allow all) - "*" wildcard → allow all - explicit list → strict slug membership Workspace enumeration skips any source workspace whose slug isn't in the allowlist. The same-ws path is unchanged because RequireWorkspaceAccess already gated the target workspace against the allow-list (so we only reach this handler when the target IS in the list). Regression test in wiki_links_xws_test.go covers four shapes: nil, wildcard, target-only (blocks cross-ws), explicit source-workspace (allows cross-ws). PLAN-1593 / TASK-1597. * fix(backlinks): normalize limit at handler boundary (Codex round 3) Codex round 3 P2: the backlinks handler parsed ?limit=N but didn't normalize it before computing the same-ws/cross-ws pagination split. GetBacklinks and GetCrossWorkspaceBacklinks each clamp >300 internally, but the handler's 'remaining := limit - len(sameWs)' used the original (potentially huge) value. With ?limit=301 and more than 50 same-ws backlinks, the first page would mix cross-ws in before same-ws was exhausted, violating the documented tier order. Fix: clamp 'limit' to <=300 at the handler boundary, before any pagination math runs. PLAN-1593 / TASK-1597. * fix(backlinks): normalize same-workspace [[ws::REF]] to ref-kind (Codex round 4) Codex round 4 P2: `[[<current-ws>::TASK-1]]` was being indexed as a workspace_ref row with target_workspace_id = current workspace. But the same-ws GetBacklinks query requires target_item_id (workspace_ref rows leave it NULL), AND GetCrossWorkspaceBacklinks explicitly skips the target workspace — so the link rendered and navigated correctly in the UI but no backlink ever surfaced. The renderer's L307 short-circuits same-workspace fully-qualified form to behave identically to `[[REF]]`; the index must follow. Fix: in replaceWikiLinks, normalize a workspace_ref link to ref-kind when its slug resolves to the current workspace. The promotion canonicalizes the ref (via new links.CanonicalizeRef exported alias) so `[[ws::task-5]]` stores the same canonical shape as `[[TASK-5]]`. Tests: - TestWikiLinks_CrossWorkspaceSameWorkspaceQualifiedNormalized: same-ws fully-qualified `[[ws::REF]]` surfaces in same-ws backlinks and is absent from cross-ws backlinks. PLAN-1593 / TASK-1597. * fix(backlinks): same-ws qualified ref miss doesn't title-fallback (Codex round 5) Codex round 5 P2: my round-4 normalization was too aggressive. It promoted `[[<current-ws>::REF]]` to ref-kind and let the regular ref branch handle it — including the title-fallback path that runs on ref miss. But the renderer's same-ws qualified branch (markdown.ts:472-481) does NOT title-fallback: a ref miss in that path returns the wiki-link verbatim (broken). Only the bare `[[REF]]` path (markdown.ts:513) falls through to title lookup. So my normalization could create ghost backlinks for source bodies like `[[ws::ISO-9001]]` when an item titled "ISO-9001" exists but no ISO collection — the renderer renders broken text, but the index would point at the title-matching item. Fix: handle same-ws qualified refs inline at the top of the loop, BEFORE the switch dispatches. Insert as ref-kind row (resolved or NULL) and `continue` past the switch. Bypasses the title-fallback path entirely, mirroring the renderer's behavior. Regression test in wiki_links_xws_test.go pairs same-ws qualified miss (must NOT title-fallback) with bare ref miss (SHOULD title-fallback) to lock the asymmetry in. PLAN-1593 / TASK-1597.
678 lines
24 KiB
Go
678 lines
24 KiB
Go
package links
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// WikiLinkKind discriminates the five [[...]] forms the renderer
|
|
// supports (see web/src/lib/utils/markdown.ts::renderMarkdown). Phase 1
|
|
// of PLAN-1593 only extracts WikiLinkKindRef; the title and
|
|
// workspace_ref kinds are reserved for Phase 2 and present here so
|
|
// downstream consumers can switch on the full vocabulary now and
|
|
// the parser can grow into the remaining forms without breaking
|
|
// callers.
|
|
type WikiLinkKind string
|
|
|
|
const (
|
|
// WikiLinkKindRef is [[REF-N]] or [[REF-N|Display]] — the
|
|
// dominant modern form. Stable across title renames.
|
|
WikiLinkKindRef WikiLinkKind = "ref"
|
|
|
|
// WikiLinkKindTitle is the legacy [[Title]] / [[collection/Title]]
|
|
// form. Title renames must trigger re-resolution. Phase 2.
|
|
WikiLinkKindTitle WikiLinkKind = "title"
|
|
|
|
// WikiLinkKindWorkspaceRef is [[workspace::REF]] /
|
|
// [[workspace::REF|Display]] — points across a workspace
|
|
// boundary. Resolution against the foreign workspace happens at
|
|
// query time, not parse time. Phase 2.
|
|
WikiLinkKindWorkspaceRef WikiLinkKind = "workspace_ref"
|
|
)
|
|
|
|
// WikiLinkRef is one extracted [[...]] occurrence. Position is the
|
|
// byte offset of the OPENING `[[` in the ORIGINAL content (not the
|
|
// code-stripped scratch buffer). The store uses this offset both for
|
|
// stable ordering when an item links to the same target multiple
|
|
// times AND for the ~80-char snippet the backlinks handler returns.
|
|
type WikiLinkRef struct {
|
|
Kind WikiLinkKind
|
|
|
|
// WorkspaceSlug is set only for WikiLinkKindWorkspaceRef (Phase 2).
|
|
WorkspaceSlug string
|
|
|
|
// Ref is the literal ref string (e.g. "TASK-5"). Set for
|
|
// WikiLinkKindRef and WikiLinkKindWorkspaceRef. Empty for
|
|
// title-kind rows.
|
|
Ref string
|
|
|
|
// Title is the literal title text. Set only for
|
|
// WikiLinkKindTitle (Phase 2). Empty for ref/workspace_ref.
|
|
Title string
|
|
|
|
// Display is the [[X|Display]] override. Stored verbatim
|
|
// (no trimming, no escape stripping beyond `\]`/`\|`/`\\`)
|
|
// because the renderer is responsible for HTML-escaping at
|
|
// display time — same convention items.title follows. Pair
|
|
// with HasDisplay to distinguish "no pipe" from "pipe with
|
|
// empty display."
|
|
Display string
|
|
|
|
// HasDisplay distinguishes `[[REF]]` (no pipe → HasDisplay=false)
|
|
// from `[[REF|]]` (pipe with empty display → HasDisplay=true,
|
|
// Display==""). The client renderer uses `displayOverride ?? title`
|
|
// (JS nullish coalescing — empty-string IS preserved), so an
|
|
// explicit empty display override renders as an empty link. We
|
|
// preserve that distinction in storage so the backlinks panel can
|
|
// reproduce it. Codex round-12 P3.
|
|
HasDisplay bool
|
|
|
|
// Position is the byte offset of the opening `[[` in the
|
|
// source content. Always points into the ORIGINAL content,
|
|
// not the code-stripped buffer the parser used to find
|
|
// outside-code matches.
|
|
Position int
|
|
|
|
// RawKey is the untrimmed unescaped key segment (everything
|
|
// before the first unescaped pipe, or the whole body if no
|
|
// pipe). Populated for `WikiLinkKindRef` so the ref→title
|
|
// fallback in the store layer can mirror the renderer's
|
|
// untrimmed title lookup at web/src/lib/utils/markdown.ts:541-543
|
|
// — an item literally titled `" TASK-5 "` (with surrounding
|
|
// whitespace) resolves via the renderer's untrimmed key but
|
|
// would miss a canonical-trimmed `"TASK-5"` lookup. Codex
|
|
// round 10 P2.
|
|
//
|
|
// Empty for other kinds — title kind already preserves the
|
|
// untrimmed body in Title, and workspace_ref kinds are
|
|
// whitespace-free by construction.
|
|
RawKey string
|
|
}
|
|
|
|
// REF_PATTERN matches a Pad item ref like TASK-5 or BUG-585. Mirrors
|
|
// the renderer's REF_PATTERN constant in web/src/lib/utils/markdown.ts
|
|
// — case-insensitive so `[[task-5]]` parses the same way the renderer
|
|
// resolves it. Without this parity the renderer would render a mixed-
|
|
// case ref as a clickable link while the index silently dropped it
|
|
// (Codex round-1 P2). The Display segment is parsed separately so
|
|
// case-only differences in the prefix collapse to the canonical
|
|
// uppercase form at storage time — see parseBody.
|
|
var refPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9]*-\d+$`)
|
|
|
|
// wikiLinkPattern matches `[[...]]` while allowing the body to
|
|
// contain escaped chars (`\]`, `\|`, `\\`). Mirrors the editor's
|
|
// `wikiLinksToMarkdown` grammar in web/src/lib/utils/markdown.ts:461
|
|
// (`(?:\\.|[^\]\\])+`), so any link the editor saves can be indexed
|
|
// — even if its display text contains a literal `]` or `|`.
|
|
//
|
|
// renderMarkdown at markdown.ts:300 uses a simpler regex (`[^\]]+`)
|
|
// that REJECTS escaped-bracket bodies, so escaped links don't
|
|
// currently render as clickable links in the UI. That's a
|
|
// pre-existing inconsistency in the editor pipeline; matching the
|
|
// permissive grammar here makes the index forward-compatible with a
|
|
// renderer fix without leaving a gap when one lands. Codex rounds
|
|
// 4/7/10 P2.
|
|
var wikiLinkPattern = regexp.MustCompile(`\[\[((?:\\.|[^\]\\])+)\]\]`)
|
|
|
|
// fencedCodeRanges returns half-open `[start, end)` byte ranges that
|
|
// cover every fenced (triple-backtick) code block in `content`,
|
|
// including the opening and closing fences themselves. We walk
|
|
// the string rather than relying on a regex because:
|
|
//
|
|
// 1. A bare `regexp.FindAllStringIndex` of ```...``` mis-counts
|
|
// content containing `````` (four+ backticks) — markdown lets
|
|
// the fence length vary.
|
|
// 2. We need to distinguish opener vs closer to handle the case
|
|
// where an unclosed fence runs to EOF (a real edge case in
|
|
// drafts the user hasn't finished typing).
|
|
//
|
|
// Behavior matches the markdown spec: a fence is `\n` + “ ``` “ +
|
|
// optional language tag + `\n`, and the matching closer is `\n` +
|
|
// the same number of backticks. We're permissive about the leading
|
|
// newline at file start (no preceding `\n` required) for the
|
|
// content-starts-with-fence case.
|
|
func fencedCodeRanges(content string) [][2]int {
|
|
var ranges [][2]int
|
|
i := 0
|
|
n := len(content)
|
|
for i < n {
|
|
// Find the next fence opener at a line boundary
|
|
// (either start-of-content or after a newline).
|
|
lineStart := i
|
|
if lineStart > 0 && content[lineStart-1] != '\n' {
|
|
// Advance to next newline; fences must start a line.
|
|
nl := strings.IndexByte(content[i:], '\n')
|
|
if nl < 0 {
|
|
return ranges
|
|
}
|
|
i += nl + 1
|
|
continue
|
|
}
|
|
// At a line start. CommonMark allows 0-3 leading spaces of
|
|
// indentation before a fence opener (4+ spaces makes it an
|
|
// indented code block, which is a different construct). Skip
|
|
// up to 3 leading spaces but bail if we hit a 4th — the
|
|
// renderer would treat that line as code, not a fence opener,
|
|
// and we'd risk false-positive on a wiki-link inside an
|
|
// indented-code paragraph. Codex round-5 finding #2.
|
|
fenceLineStart := i
|
|
spaces := 0
|
|
for spaces < 4 && fenceLineStart < n && content[fenceLineStart] == ' ' {
|
|
fenceLineStart++
|
|
spaces++
|
|
}
|
|
if spaces >= 4 {
|
|
// Indented code, not a fence. Skip the line.
|
|
nl := strings.IndexByte(content[i:], '\n')
|
|
if nl < 0 {
|
|
return ranges
|
|
}
|
|
i += nl + 1
|
|
continue
|
|
}
|
|
// Determine the fence character. CommonMark / marked accept
|
|
// both backtick (`) and tilde (~) fences. The closer must
|
|
// use the same char as the opener and have a matching
|
|
// minimum run length. Codex round-6 finding #1.
|
|
fenceChar := byte(0)
|
|
if fenceLineStart < n {
|
|
switch content[fenceLineStart] {
|
|
case '`', '~':
|
|
fenceChar = content[fenceLineStart]
|
|
}
|
|
}
|
|
if fenceChar == 0 {
|
|
// Not a fence opener of any kind; skip to next newline.
|
|
nl := strings.IndexByte(content[i:], '\n')
|
|
if nl < 0 {
|
|
return ranges
|
|
}
|
|
i += nl + 1
|
|
continue
|
|
}
|
|
// Count fence chars starting at fenceLineStart.
|
|
tickStart := fenceLineStart
|
|
j := fenceLineStart
|
|
for j < n && content[j] == fenceChar {
|
|
j++
|
|
}
|
|
tickCount := j - tickStart
|
|
if tickCount < 3 {
|
|
// Not a fence opener; skip to next newline.
|
|
nl := strings.IndexByte(content[i:], '\n')
|
|
if nl < 0 {
|
|
return ranges
|
|
}
|
|
i += nl + 1
|
|
continue
|
|
}
|
|
// Backtick fences (but NOT tilde fences) reject an info
|
|
// string containing an unescaped backtick — that would
|
|
// otherwise let `` `not a fence ` `` be misread as a fence
|
|
// opener. CommonMark §4.5. Cheap check: if the rest of the
|
|
// opener line contains a backtick, this isn't a real fence.
|
|
if fenceChar == '`' {
|
|
restEnd := strings.IndexByte(content[j:], '\n')
|
|
restLimit := n
|
|
if restEnd >= 0 {
|
|
restLimit = j + restEnd
|
|
}
|
|
if strings.IndexByte(content[j:restLimit], '`') >= 0 {
|
|
// False opener. Skip the line.
|
|
nl := strings.IndexByte(content[i:], '\n')
|
|
if nl < 0 {
|
|
return ranges
|
|
}
|
|
i += nl + 1
|
|
continue
|
|
}
|
|
}
|
|
i = j
|
|
// Found an opener. Find the closing fence using the same
|
|
// char + minimum run length (allowing matching 0-3 space
|
|
// indent — see findFenceCloser). If no closer exists, the
|
|
// fence runs to EOF (covers the rest of the content).
|
|
closer := findFenceCloser(content, i, tickCount, fenceChar)
|
|
if closer < 0 {
|
|
ranges = append(ranges, [2]int{tickStart, n})
|
|
return ranges
|
|
}
|
|
// closer points at the start of the closing fence-char run;
|
|
// advance past it (and any extra fence chars) to find the
|
|
// end of the block.
|
|
k := closer
|
|
for k < n && content[k] == fenceChar {
|
|
k++
|
|
}
|
|
ranges = append(ranges, [2]int{tickStart, k})
|
|
i = k
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
// findFenceCloser scans forward from `start` looking for a line that
|
|
// begins with at least `tickCount` consecutive `fenceChar` characters
|
|
// (after up to three leading spaces of optional indentation, matching
|
|
// CommonMark fenced-code semantics), with NOTHING but spaces after
|
|
// the closing fence run on the same line. Returns the index of the
|
|
// first fence char of the closer, or -1 if none exists.
|
|
//
|
|
// CommonMark §4.5 requires that the closing fence line contain only
|
|
// the fence + optional trailing spaces — a line like ```not-closed
|
|
// inside a still-open fence is NOT a valid closer. Without that
|
|
// strictness, the extractor would prematurely terminate the code
|
|
// range and leak later refs inside the still-rendered code block.
|
|
// Codex round-6 finding #2.
|
|
func findFenceCloser(content string, start, tickCount int, fenceChar byte) int {
|
|
i := start
|
|
n := len(content)
|
|
for i < n {
|
|
// Skip to next line.
|
|
nl := strings.IndexByte(content[i:], '\n')
|
|
if nl < 0 {
|
|
return -1
|
|
}
|
|
lineStart := i + nl + 1
|
|
if lineStart >= n {
|
|
return -1
|
|
}
|
|
// CommonMark allows the closing fence to be indented 0-3
|
|
// spaces, independent of the opener's indentation. 4+ spaces
|
|
// would be an indented-code line, not a closer.
|
|
closerStart := lineStart
|
|
spaces := 0
|
|
for spaces < 4 && closerStart < n && content[closerStart] == ' ' {
|
|
closerStart++
|
|
spaces++
|
|
}
|
|
if spaces >= 4 {
|
|
i = lineStart
|
|
continue
|
|
}
|
|
// Count fence chars at this position.
|
|
j := closerStart
|
|
for j < n && content[j] == fenceChar {
|
|
j++
|
|
}
|
|
if j-closerStart >= tickCount {
|
|
// Strictness: after the fence-char run, the rest of
|
|
// the line must be only spaces (then newline or EOF).
|
|
// Anything else (info string, more chars) disqualifies
|
|
// this as a closer. CommonMark §4.5.
|
|
rest := j
|
|
for rest < n && content[rest] != '\n' {
|
|
if content[rest] != ' ' {
|
|
// Not a valid closer; keep scanning later
|
|
// lines for the real closer.
|
|
break
|
|
}
|
|
rest++
|
|
}
|
|
if rest >= n || content[rest] == '\n' {
|
|
return closerStart
|
|
}
|
|
}
|
|
i = lineStart
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// inlineCodeRanges returns half-open `[start, end)` byte ranges that
|
|
// cover every inline-code span in `content`, skipping over the fenced
|
|
// regions caller has already identified.
|
|
//
|
|
// CommonMark §6.1: an inline-code span is opened by a run of N
|
|
// consecutive backticks and CLOSED by the next run of EXACTLY N
|
|
// consecutive backticks on the same line. Backtick runs of any
|
|
// length other than N are part of the code text — they don't close
|
|
// the span. This matters for our purpose: a body like
|
|
// “ “has ` inside [[X-1]]“ “ is a single span whose code text
|
|
// includes a stray single backtick AND the wiki-link, and the link
|
|
// must NOT be indexed because the renderer shows it as code.
|
|
//
|
|
// Without matching run lengths (Codex round-7 finding #2), the
|
|
// parser would treat the stray single backtick as a closer, end
|
|
// the range early, and false-positive on `[[X-1]]`.
|
|
//
|
|
// Span doesn't cross newlines: an unclosed backtick at end-of-line
|
|
// is treated as literal text, not the start of a multi-line span.
|
|
// Matches CommonMark behavior closely enough for our use.
|
|
func inlineCodeRanges(content string, fenced [][2]int) [][2]int {
|
|
var ranges [][2]int
|
|
i := 0
|
|
n := len(content)
|
|
fi := 0 // cursor into fenced ranges
|
|
for i < n {
|
|
// Skip ahead past any fenced range that contains or
|
|
// precedes our cursor.
|
|
for fi < len(fenced) && fenced[fi][1] <= i {
|
|
fi++
|
|
}
|
|
if fi < len(fenced) && fenced[fi][0] <= i {
|
|
i = fenced[fi][1]
|
|
fi++
|
|
continue
|
|
}
|
|
// Look for the next backtick.
|
|
b := strings.IndexByte(content[i:], '`')
|
|
if b < 0 {
|
|
return ranges
|
|
}
|
|
openStart := i + b
|
|
// If the backtick is inside a fenced range, skip past it.
|
|
if fi < len(fenced) && fenced[fi][0] <= openStart && openStart < fenced[fi][1] {
|
|
i = fenced[fi][1]
|
|
fi++
|
|
continue
|
|
}
|
|
// Count the opener's backtick run.
|
|
j := openStart + 1
|
|
for j < n && content[j] == '`' {
|
|
j++
|
|
}
|
|
openLen := j - openStart
|
|
// Scan for a closing backtick RUN of EXACTLY `openLen`
|
|
// backticks. CommonMark §6.1 allows code spans to cross
|
|
// single newlines BUT a blank line (a line containing only
|
|
// whitespace) ends the enclosing paragraph and therefore
|
|
// terminates the span. We allow single-line wraps but break
|
|
// on blank lines — Codex round-9 P1.
|
|
closerStart := -1
|
|
closerEnd := -1
|
|
k := j
|
|
for k < n {
|
|
if content[k] == '\n' {
|
|
// Look at the next line: if it's blank
|
|
// (only whitespace before the next newline
|
|
// or EOF), the span ends here unmatched.
|
|
if isBlankLineAt(content, k+1, n) {
|
|
break
|
|
}
|
|
k++
|
|
continue
|
|
}
|
|
if content[k] != '`' {
|
|
k++
|
|
continue
|
|
}
|
|
runStart := k
|
|
for k < n && content[k] == '`' {
|
|
k++
|
|
}
|
|
runLen := k - runStart
|
|
if runLen == openLen {
|
|
closerStart = runStart
|
|
closerEnd = k
|
|
break
|
|
}
|
|
// Wrong-length run; consumed by the loop, keep scanning.
|
|
}
|
|
if closerStart < 0 {
|
|
// Unclosed (or only mismatched runs to scope end) —
|
|
// treat opener as literal text and resume one byte
|
|
// past it.
|
|
i = openStart + 1
|
|
continue
|
|
}
|
|
ranges = append(ranges, [2]int{openStart, closerEnd})
|
|
i = closerEnd
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
// isBlankLineAt returns true if the line starting at byte position
|
|
// `pos` contains only whitespace (space or tab) before its newline,
|
|
// or runs to EOF without any non-whitespace. Per CommonMark a blank
|
|
// line is one with no chars or only whitespace; this helper matches
|
|
// that definition for the purpose of bounding multi-line inline code
|
|
// spans (inline code spans don't cross blank lines).
|
|
func isBlankLineAt(content string, pos, n int) bool {
|
|
for i := pos; i < n; i++ {
|
|
c := content[i]
|
|
if c == '\n' {
|
|
return true // line had no non-whitespace chars
|
|
}
|
|
if c != ' ' && c != '\t' {
|
|
return false
|
|
}
|
|
}
|
|
return true // EOF with only whitespace counts as blank
|
|
}
|
|
|
|
// isInRanges returns true if `pos` falls inside any half-open
|
|
// `[start, end)` interval in `ranges`. `ranges` must be sorted by
|
|
// start (which both fencedCodeRanges and inlineCodeRanges produce
|
|
// naturally by their forward scan). O(log N) binary search would
|
|
// be tighter but ranges per item are bounded enough (most bodies
|
|
// have under 20 code spans) that linear scan is fine and easier
|
|
// to audit.
|
|
func isInRanges(pos int, ranges [][2]int) bool {
|
|
for _, r := range ranges {
|
|
if pos < r[0] {
|
|
return false
|
|
}
|
|
if pos < r[1] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ExtractWikiLinks scans `content` for [[...]] occurrences OUTSIDE
|
|
// any fenced or inline code region, parses each into a WikiLinkRef,
|
|
// and returns them in source order.
|
|
//
|
|
// Phase 2b of PLAN-1593 (TASK-1597): emits all three kinds — ref
|
|
// (`[[REF-N]]`), title (`[[Title]]` / `[[collection/Title]]`), AND
|
|
// workspace_ref (`[[workspace::REF]]` / `[[workspace::REF|Display]]`).
|
|
// The request-independent ACL helper (Store.ResolveBacklinksVisibility)
|
|
// that TASK-1597 also adds lets the cross-workspace inbound query
|
|
// honor per-source-workspace visibility correctly, so emitting these
|
|
// rows is now safe.
|
|
//
|
|
// Returns an empty slice on empty input. Never returns an error —
|
|
// any bracket sequence that fails to parse is silently skipped
|
|
// (the renderer's fallback behavior is the same: unresolved
|
|
// `[[X]]` renders as a broken link in the body, not an error).
|
|
func ExtractWikiLinks(content string) []WikiLinkRef {
|
|
if content == "" {
|
|
return nil
|
|
}
|
|
fenced := fencedCodeRanges(content)
|
|
inline := inlineCodeRanges(content, fenced)
|
|
|
|
var out []WikiLinkRef
|
|
matches := wikiLinkPattern.FindAllStringSubmatchIndex(content, -1)
|
|
for _, m := range matches {
|
|
// m[0]=start of [[, m[1]=end of ]], m[2]=start of body, m[3]=end of body
|
|
linkStart := m[0]
|
|
if isInRanges(linkStart, fenced) || isInRanges(linkStart, inline) {
|
|
continue
|
|
}
|
|
body := content[m[2]:m[3]]
|
|
ref := parseBody(body)
|
|
if ref == nil {
|
|
continue
|
|
}
|
|
ref.Position = linkStart
|
|
// Phase 2b (TASK-1597): all three kinds emit. The
|
|
// workspace_ref gate is lifted now that the cross-workspace
|
|
// query path (Store.GetCrossWorkspaceBacklinks) and the
|
|
// request-independent ACL helper
|
|
// (Store.ResolveBacklinksVisibility) are in place to honor
|
|
// per-source-workspace visibility correctly.
|
|
out = append(out, *ref)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// parseBody decodes the inside of a `[[...]]`. Returns nil if the
|
|
// body doesn't match any of the five recognized forms. Mirrors the
|
|
// editor's body-parsing logic in markdown.ts so server-side and
|
|
// client-side extraction stay in lockstep — including the editor's
|
|
// `\]`, `\|`, `\\` escape sequences (Codex round-10 P2).
|
|
func parseBody(body string) *WikiLinkRef {
|
|
// Split on the FIRST UNESCAPED `|`. `\|` is part of the key or
|
|
// display text (depending on which side of the split it's on)
|
|
// and must NOT cleave the body. Mirrors splitWikiBody at
|
|
// markdown.ts:664. The display side is preserved verbatim
|
|
// (post-unescape) — the renderer doesn't trim it, and the
|
|
// WikiLinkRef.Display doc comment promises verbatim storage.
|
|
// Trimming would silently differ from client behavior on
|
|
// padded display text like `[[TASK-1| spaces ]]`. Codex
|
|
// round-11 P3.
|
|
var display string
|
|
hasDisplay := false
|
|
if key, suffix, ok := splitOnUnescapedPipe(body); ok {
|
|
display = unescapeWikiBody(suffix)
|
|
hasDisplay = true
|
|
body = key
|
|
}
|
|
// Unescape the post-split key. KEEP UNTRIMMED for the title-kind
|
|
// fallthrough so the index mirrors the renderer's whitespace-
|
|
// sensitive title resolution: the renderer compares items.title
|
|
// against `key` directly with no implicit trim
|
|
// (web/src/lib/utils/markdown.ts:541-543). If we trimmed here,
|
|
// `[[ Foo ]]` would index a backlink to item "Foo" that the UI
|
|
// renders as broken, creating ghost entries in the backlinks
|
|
// panel. Codex round 9 P2.
|
|
bodyUnescaped := unescapeWikiBody(body)
|
|
if bodyUnescaped == "" {
|
|
return nil
|
|
}
|
|
// Trimmed copy for ref / workspace_ref shape detection only.
|
|
// Refs are whitespace-free by construction (`REF-N`), so this
|
|
// is forgiveness for `[[ TASK-5 ]]` typed by hand — matches
|
|
// the renderer's `key.trim()` at L503/L506. The trimmed value
|
|
// is NEVER stored as target_title.
|
|
trimmed := strings.TrimSpace(bodyUnescaped)
|
|
|
|
// Cross-workspace form: `workspace-slug::REF`. The `::`
|
|
// separator is unambiguous; if it's present, the workspace +
|
|
// ref must each match their patterns or the whole thing falls
|
|
// back to title.
|
|
if sep := strings.Index(trimmed, "::"); sep >= 0 {
|
|
ws := strings.TrimSpace(trimmed[:sep])
|
|
rest := strings.TrimSpace(trimmed[sep+2:])
|
|
if isWorkspaceSlug(ws) && refPattern.MatchString(rest) {
|
|
return &WikiLinkRef{
|
|
Kind: WikiLinkKindWorkspaceRef,
|
|
WorkspaceSlug: ws,
|
|
Ref: rest,
|
|
Display: display,
|
|
HasDisplay: hasDisplay,
|
|
}
|
|
}
|
|
// Fall through to title — the renderer's fallback policy.
|
|
}
|
|
|
|
// Ref form: a bare REF-N pattern (trimmed-shape check). Normalize
|
|
// the prefix to upper-case at this single chokepoint —
|
|
// collection prefixes are canonically uppercase in
|
|
// `collections.prefix`, and the resolver/backlinks queries
|
|
// compare against that column. The renderer accepts mixed case
|
|
// for input convenience; we store the canonical form so the
|
|
// index has one shape per (workspace, prefix, number) and
|
|
// downstream callers don't need to be case-aware (Codex
|
|
// round-1 P2).
|
|
if refPattern.MatchString(trimmed) {
|
|
return &WikiLinkRef{
|
|
Kind: WikiLinkKindRef,
|
|
Ref: canonicalizeRef(trimmed),
|
|
RawKey: bodyUnescaped, // untrimmed, for ref→title fallback
|
|
Display: display,
|
|
HasDisplay: hasDisplay,
|
|
}
|
|
}
|
|
|
|
// Legacy collection-qualified title: `collection/Title`. We
|
|
// treat the whole UNTRIMMED body as the title for storage; the
|
|
// resolver in Phase 2 will split on `/` to bias the lookup.
|
|
// Plain legacy title — also untrimmed. Whitespace in the body
|
|
// is preserved verbatim per the renderer.
|
|
return &WikiLinkRef{
|
|
Kind: WikiLinkKindTitle,
|
|
Title: bodyUnescaped,
|
|
Display: display,
|
|
HasDisplay: hasDisplay,
|
|
}
|
|
}
|
|
|
|
// workspaceSlugPattern is a conservative subset that mirrors the
|
|
// renderer's WORKSPACE_SLUG_PATTERN. Letter/digit-led, hyphen-allowed,
|
|
// no trailing hyphen.
|
|
var workspaceSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$`)
|
|
|
|
func isWorkspaceSlug(s string) bool {
|
|
return workspaceSlugPattern.MatchString(s)
|
|
}
|
|
|
|
// splitOnUnescapedPipe scans `body` for the first `|` that isn't
|
|
// preceded by an unescaped `\`, splitting the body into (key,
|
|
// display, found). Mirrors splitWikiBody at markdown.ts:664. A `\`
|
|
// always consumes the following byte (even if it's not a recognized
|
|
// escape) so the algorithm can't get desynced by stray backslashes.
|
|
func splitOnUnescapedPipe(body string) (key, suffix string, found bool) {
|
|
i := 0
|
|
for i < len(body) {
|
|
if body[i] == '\\' && i+1 < len(body) {
|
|
i += 2
|
|
continue
|
|
}
|
|
if body[i] == '|' {
|
|
return body[:i], body[i+1:], true
|
|
}
|
|
i++
|
|
}
|
|
return body, "", false
|
|
}
|
|
|
|
// unescapeWikiBody undoes the editor's body-escape sequences:
|
|
// `\]` → `]`, `\|` → `|`, `\\` → `\`. Other backslash sequences are
|
|
// left as-is (the renderer does the same — see unescapeWikiBody at
|
|
// markdown.ts:657). Idempotent on already-unescaped strings.
|
|
func unescapeWikiBody(s string) string {
|
|
if !strings.ContainsRune(s, '\\') {
|
|
return s
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\\' && i+1 < len(s) {
|
|
next := s[i+1]
|
|
if next == '\\' || next == ']' || next == '|' {
|
|
b.WriteByte(next)
|
|
i++
|
|
continue
|
|
}
|
|
}
|
|
b.WriteByte(s[i])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// canonicalizeRef uppercases the prefix portion of a "PREFIX-N" ref
|
|
// so the index has a single canonical shape per (workspace, prefix,
|
|
// number) regardless of how the author cased the source. The number
|
|
// segment is unchanged (it can only contain digits per refPattern).
|
|
//
|
|
// `[[task-5]]` → "TASK-5". `[[Task-5]]` → "TASK-5". `[[TASK-5]]` →
|
|
// "TASK-5" (no-op). Inputs that don't contain a hyphen pass through
|
|
// untouched (refPattern would have rejected them anyway; callers
|
|
// invariantly hold to "matches refPattern" before calling).
|
|
func canonicalizeRef(ref string) string {
|
|
return CanonicalizeRef(ref)
|
|
}
|
|
|
|
// CanonicalizeRef is the exported alias for canonicalizeRef so the
|
|
// store layer's same-workspace workspace_ref normalization (Codex
|
|
// round 4 P2 of TASK-1597) can use the same canonicalization without
|
|
// reimplementing the prefix-uppercase logic.
|
|
func CanonicalizeRef(ref string) string {
|
|
dash := strings.LastIndexByte(ref, '-')
|
|
if dash < 0 {
|
|
return strings.ToUpper(ref)
|
|
}
|
|
return strings.ToUpper(ref[:dash]) + ref[dash:]
|
|
}
|