Files
tolaria/src/utils/wikilinks.ts
lucaronin a6b8602fff fix: make backlinks update reactively when wikilinks are added or removed
Backlinks previously relied on scanning allContent (raw markdown), which
was empty in Tauri mode until notes were explicitly saved. Now outgoing
wikilink targets are extracted during Rust vault scan and stored on
VaultEntry. The frontend useBacklinks hook uses this indexed data, and
outgoingLinks update in real-time on content change.

- Add extract_outgoing_links() in Rust parsing + outgoing_links field on VaultEntry
- Add extractOutgoingLinks() TypeScript utility for real-time updates
- Rewrite useBacklinks to use outgoingLinks instead of scanning raw content
- Add cache version invalidation to force rescan on format change
- Extract useEditorSaveWithLinks hook to keep App.tsx under code health threshold

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:07:04 +01:00

131 lines
4.3 KiB
TypeScript

// Wikilink placeholder tokens for markdown round-trip
const WL_START = '\u2039WIKILINK:'
const WL_END = '\u203A'
const WL_RE = new RegExp(`${WL_START.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^${WL_END}]+)${WL_END.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g')
/** Pre-process markdown: replace [[target]] with placeholder tokens */
export function preProcessWikilinks(md: string): string {
return md.replace(/\[\[([^\]]+)\]\]/g, (_m, target) => `${WL_START}${target}${WL_END}`)
}
// Minimal shape of a BlockNote block for wikilink processing
interface BlockLike {
content?: InlineItem[]
children?: BlockLike[]
[key: string]: unknown
}
interface InlineItem {
type: string
text?: string
props?: Record<string, string>
content?: unknown
[key: string]: unknown
}
type ContentTransform = (content: InlineItem[]) => InlineItem[]
/** Walk blocks recursively, applying a transform to each block's inline content */
function walkBlocks(blocks: unknown[], transform: ContentTransform, clone = false): unknown[] {
return (blocks as BlockLike[]).map(block => {
const b = clone ? { ...block } : block
if (b.content && Array.isArray(b.content)) {
b.content = transform(b.content)
}
if (b.children && Array.isArray(b.children)) {
b.children = walkBlocks(b.children, transform, clone) as BlockLike[]
}
return b
})
}
/** Walk blocks and replace placeholder text with wikilink inline content */
export function injectWikilinks(blocks: unknown[]): unknown[] {
return walkBlocks(blocks, expandWikilinksInContent)
}
/**
* Deep-clone blocks and convert wikilink inline content back to [[target]] text.
* This is the reverse of injectWikilinks — used before blocksToMarkdownLossy
* so that wikilinks survive the markdown round-trip.
*/
export function restoreWikilinksInBlocks(blocks: unknown[]): unknown[] {
return walkBlocks(blocks, collapseWikilinksInContent, true)
}
function expandWikilinksInContent(content: InlineItem[]): InlineItem[] {
const result: InlineItem[] = []
for (const item of content) {
if (item.type !== 'text' || typeof item.text !== 'string' || !item.text.includes(WL_START)) {
result.push(item)
continue
}
const text = item.text as string
let lastIndex = 0
WL_RE.lastIndex = 0
let match
while ((match = WL_RE.exec(text)) !== null) {
if (match.index > lastIndex) {
result.push({ ...item, text: text.slice(lastIndex, match.index) })
}
result.push({
type: 'wikilink',
props: { target: match[1] },
content: undefined,
})
lastIndex = match.index + match[0].length
}
if (lastIndex < text.length) {
result.push({ ...item, text: text.slice(lastIndex) })
}
}
return result
}
function collapseWikilinksInContent(content: InlineItem[]): InlineItem[] {
const result: InlineItem[] = []
for (const item of content) {
if (item.type === 'wikilink' && item.props?.target) {
result.push({ type: 'text', text: `[[${item.props.target}]]` })
} else {
result.push(item)
}
}
return result
}
/** Strip YAML frontmatter from markdown, returning [frontmatter, body] */
export function splitFrontmatter(content: string): [string, string] {
if (!content.startsWith('---')) return ['', content]
const end = content.indexOf('\n---', 3)
if (end === -1) return ['', content]
let to = end + 4
if (content[to] === '\n') to++
return [content.slice(0, to), content.slice(to)]
}
/** Extract all outgoing wikilink targets from content.
* Finds [[target]] and [[target|display]] patterns, returning just the target part.
* Returns a sorted, deduplicated array. */
export function extractOutgoingLinks(content: string): string[] {
const links: string[] = []
const re = /\[\[([^\]]+)\]\]/g
let match
while ((match = re.exec(content)) !== null) {
const inner = match[1]
const pipeIdx = inner.indexOf('|')
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
if (target) links.push(target)
}
return [...new Set(links)].sort()
}
export function countWords(content: string): number {
const [, body] = splitFrontmatter(content)
const withoutTitle = body.replace(/^\s*# [^\n]+\n?/, '')
const withoutWikilinks = withoutTitle.replace(/\[\[[^\]]*\]\]/g, '')
const text = withoutWikilinks.replace(/[#*_[\]`>~\-|]/g, '').trim()
if (!text) return 0
return text.split(/\s+/).filter(Boolean).length
}