/**
* LinkManager — Wiki-link parsing, backlink index, and link graph
*
* Link syntax:
* [[note-title]] — same notebook, match by title
* [[notebook/note-title]] — cross-notebook, match by title
*
* Backlink index stored in Hyperbee registry:
* backlink:{targetNotebook}/{targetId}:{sourceNotebook}/{sourceId} → { ... }
*
* Link records stored per-note:
* links:{notebook}/{noteId} → [{ target, notebook, title, resolved }]
*/
const LINK_RE = /\[\[([^\]]+)\]\]/g
const BACKLINK_PREFIX = 'backlink:'
const LINKS_PREFIX = 'links:'
export class LinkManager {
constructor (registry, noteManager) {
this.registry = registry // Hyperbee
this.noteManager = noteManager
}
/**
* Parse [[wiki-links]] from markdown content.
* Returns array of { raw, notebook, title } objects.
*
* [[My Note]] → { raw: 'My Note', notebook: null, title: 'My Note' }
* [[work/My Note]] → { raw: 'work/My Note', notebook: 'work', title: 'My Note' }
*/
parseLinks (content) {
if (!content) return []
const links = []
let match
// Reset lastIndex for global regex
LINK_RE.lastIndex = 0
while ((match = LINK_RE.exec(content)) !== null) {
const raw = match[1].trim()
const slashIdx = raw.indexOf('/')
if (slashIdx > 0) {
links.push({
raw,
notebook: raw.substring(0, slashIdx).trim(),
title: raw.substring(slashIdx + 1).trim()
})
} else {
links.push({
raw,
notebook: null,
title: raw
})
}
}
return links
}
/**
* Resolve a parsed link to a note ID by matching title.
* Returns { notebook, id, title } or null if not found.
*/
async resolveLink (link, sourceNotebook) {
const targetNotebook = link.notebook || sourceNotebook
const targetTitle = link.title.toLowerCase()
const notes = await this.noteManager.listNotes(targetNotebook)
for (const note of notes) {
if (note.title && note.title.toLowerCase() === targetTitle) {
return { notebook: targetNotebook, id: note.id, title: note.title }
}
}
return null
}
/**
* Extract links from a note's content, resolve them, and update the
* backlink index in the registry. Called on every note save.
*
* @param {string} notebook - Source notebook slug
* @param {string} noteId - Source note ID
* @param {string} content - Note content (plaintext)
*/
async updateLinks (notebook, noteId, content) {
const parsed = this.parseLinks(content)
const sourceKey = `${notebook}/${noteId}`
// Remove old backlinks from this source
await this._removeBacklinksFrom(sourceKey)
// Resolve and store new links
const resolved = []
for (const link of parsed) {
const target = await this.resolveLink(link, notebook)
if (target) {
const targetKey = `${target.notebook}/${target.id}`
// Store backlink: target ← source
await this.registry.put(`${BACKLINK_PREFIX}${targetKey}:${sourceKey}`, {
sourceNotebook: notebook,
sourceId: noteId,
targetNotebook: target.notebook,
targetId: target.id,
targetTitle: target.title,
updatedAt: Date.now()
})
resolved.push({ ...link, resolved: true, targetId: target.id, targetNotebook: target.notebook })
} else {
resolved.push({ ...link, resolved: false })
}
}
// Store outgoing links for this note
await this.registry.put(`${LINKS_PREFIX}${sourceKey}`, resolved)
return resolved
}
/**
* Remove all backlinks originating from a source note.
*/
async _removeBacklinksFrom (sourceKey) {
// Scan all backlinks to find ones from this source
const toDelete = []
const suffix = ':' + sourceKey
for await (const entry of this.registry.createReadStream({
gt: BACKLINK_PREFIX,
lt: BACKLINK_PREFIX + '\xff'
})) {
if (entry.key.endsWith(suffix)) {
toDelete.push(entry.key)
}
}
for (const key of toDelete) {
await this.registry.del(key)
}
}
/**
* Get outgoing links from a note.
*/
async getLinks (notebook, noteId) {
const key = `${LINKS_PREFIX}${notebook}/${noteId}`
const entry = await this.registry.get(key)
return entry ? entry.value : []
}
/**
* Get backlinks — notes that link TO this note.
*/
async getBacklinks (notebook, noteId) {
const targetKey = `${notebook}/${noteId}`
const prefix = `${BACKLINK_PREFIX}${targetKey}:`
const backlinks = []
for await (const entry of this.registry.createReadStream({
gt: prefix,
lt: prefix + '\xff'
})) {
backlinks.push(entry.value)
}
return backlinks
}
/**
* Remove all links and backlinks for a note (called on delete).
*/
async removeAllLinks (notebook, noteId) {
const sourceKey = `${notebook}/${noteId}`
// Remove outgoing links record
await this.registry.del(`${LINKS_PREFIX}${sourceKey}`)
// Remove backlinks FROM this note
await this._removeBacklinksFrom(sourceKey)
// Remove backlinks TO this note
const targetPrefix = `${BACKLINK_PREFIX}${sourceKey}:`
const toDelete = []
for await (const entry of this.registry.createReadStream({
gt: targetPrefix,
lt: targetPrefix + '\xff'
})) {
toDelete.push(entry.key)
}
for (const key of toDelete) {
await this.registry.del(key)
}
}
/**
* Build a link graph for one or all notebooks.
* Returns { nodes: [...], edges: [...] }
*/
async getGraph (notebook) {
const nodes = new Map()
const edges = []
const slugs = notebook
? [notebook]
: await this.noteManager._listNotebookSlugs()
// Collect nodes
for (const slug of slugs) {
let notes
try {
notes = await this.noteManager.listNotes(slug)
} catch {
continue
}
for (const note of notes) {
const key = `${slug}/${note.id}`
nodes.set(key, {
id: key,
notebook: slug,
noteId: note.id,
title: note.title
})
}
}
// Collect edges from links records
for (const [nodeKey] of nodes) {
const links = await this.getLinks(
nodeKey.split('/')[0],
nodeKey.split('/')[1]
)
for (const link of links) {
if (link.resolved && link.targetId) {
const targetKey = `${link.targetNotebook}/${link.targetId}`
if (nodes.has(targetKey)) {
edges.push({
source: nodeKey,
target: targetKey,
title: link.title
})
}
}
}
}
return {
nodes: [...nodes.values()],
edges
}
}
}
export default LinkManager