Files
pad/internal/links/extract.go
T
xarmian 905876af04 feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) (#622)
* 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.
2026-05-24 13:27:40 -04:00

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:]
}