import test from 'brittle'
import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import { DriveManager } from '../../lib/drive-manager.js'
import { NoteManager } from '../../lib/note-manager.js'
import { LinkManager } from '../../lib/link-manager.js'
function tmpSetup () {
const dir = path.join(os.tmpdir(), 'hn-lm-' + Date.now() + '-' + Math.random().toString(36).slice(2))
fs.mkdirSync(dir, { recursive: true })
const store = new Corestore(dir)
const registry = new Hyperbee(store.get({ name: 'registry' }), {
keyEncoding: 'utf-8',
valueEncoding: 'json'
})
const dm = new DriveManager(store, registry)
return { store, registry, dm, dir }
}
function rmrf (dir) {
try {
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f)
const stat = fs.statSync(fp)
if (stat.isDirectory()) rmrf(fp)
else fs.unlinkSync(fp)
}
fs.rmdirSync(dir)
} catch {}
}
// ==================== PARSE LINKS ====================
test('parseLinks — extracts [[wiki-links]] from content', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
const links = lm.parseLinks('Check out [[My Note]] and [[Other Note]].')
t.is(links.length, 2)
t.is(links[0].raw, 'My Note')
t.is(links[0].notebook, null)
t.is(links[0].title, 'My Note')
t.is(links[1].raw, 'Other Note')
await store.close()
rmrf(dir)
})
test('parseLinks — cross-notebook links', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
const links = lm.parseLinks('See [[work/Meeting Notes]] for details.')
t.is(links.length, 1)
t.is(links[0].notebook, 'work')
t.is(links[0].title, 'Meeting Notes')
await store.close()
rmrf(dir)
})
test('parseLinks — empty content returns empty', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
t.is(lm.parseLinks('').length, 0)
t.is(lm.parseLinks(null).length, 0)
t.is(lm.parseLinks('No links here').length, 0)
await store.close()
rmrf(dir)
})
test('parseLinks — mixed local and cross-notebook', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
const links = lm.parseLinks('See [[Local Note]] and [[work/Remote Note]] and [[personal/Diary]].')
t.is(links.length, 3)
t.is(links[0].notebook, null)
t.is(links[1].notebook, 'work')
t.is(links[2].notebook, 'personal')
await store.close()
rmrf(dir)
})
// ==================== RESOLVE LINKS ====================
test('resolveLink — finds note by title', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
await nm.createNotebook('test', { name: 'Test' })
await nm.createNote('test', { title: 'Target Note', content: 'I am the target' })
const result = await lm.resolveLink({ notebook: null, title: 'Target Note' }, 'test')
t.ok(result)
t.is(result.notebook, 'test')
t.is(result.title, 'Target Note')
t.ok(result.id)
await store.close()
rmrf(dir)
})
test('resolveLink — case insensitive match', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
await nm.createNotebook('test', { name: 'Test' })
await nm.createNote('test', { title: 'Meeting Notes', content: 'Agenda' })
const result = await lm.resolveLink({ notebook: null, title: 'meeting notes' }, 'test')
t.ok(result)
t.is(result.title, 'Meeting Notes')
await store.close()
rmrf(dir)
})
test('resolveLink — returns null for unresolved', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
await nm.createNotebook('test', { name: 'Test' })
const result = await lm.resolveLink({ notebook: null, title: 'Nonexistent' }, 'test')
t.is(result, null)
await store.close()
rmrf(dir)
})
// ==================== UPDATE LINKS + BACKLINKS ====================
test('updateLinks — builds backlink index', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
await nm.createNotebook('test', { name: 'Test' })
const target = await nm.createNote('test', { title: 'Target', content: 'I am linked to' })
const source = await nm.createNote('test', { title: 'Source', content: 'See [[Target]]' })
// Manually trigger link update (normally done by NoteManager on save)
await lm.updateLinks('test', source.id, source.content)
// Check outgoing links
const links = await lm.getLinks('test', source.id)
t.is(links.length, 1)
t.is(links[0].title, 'Target')
t.is(links[0].resolved, true)
// Check backlinks
const backlinks = await lm.getBacklinks('test', target.id)
t.is(backlinks.length, 1)
t.is(backlinks[0].sourceId, source.id)
await store.close()
rmrf(dir)
})
test('updateLinks — removes old backlinks on re-index', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
await nm.createNotebook('test', { name: 'Test' })
const target1 = await nm.createNote('test', { title: 'Target1', content: 'First target' })
const target2 = await nm.createNote('test', { title: 'Target2', content: 'Second target' })
const source = await nm.createNote('test', { title: 'Source', content: 'See [[Target1]]' })
await lm.updateLinks('test', source.id, 'See [[Target1]]')
// Source links to Target1
let bl1 = await lm.getBacklinks('test', target1.id)
t.is(bl1.length, 1)
// Update source to link to Target2 instead
await lm.updateLinks('test', source.id, 'Now see [[Target2]]')
// Old backlink removed
bl1 = await lm.getBacklinks('test', target1.id)
t.is(bl1.length, 0, 'old backlink removed')
// New backlink added
const bl2 = await lm.getBacklinks('test', target2.id)
t.is(bl2.length, 1, 'new backlink added')
await store.close()
rmrf(dir)
})
test('updateLinks — cross-notebook backlinks', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
await nm.createNotebook('work', { name: 'Work' })
await nm.createNotebook('personal', { name: 'Personal' })
const target = await nm.createNote('work', { title: 'Project Plan', content: 'The plan' })
const source = await nm.createNote('personal', { title: 'Diary', content: 'Reviewed [[work/Project Plan]] today' })
await lm.updateLinks('personal', source.id, source.content)
const backlinks = await lm.getBacklinks('work', target.id)
t.is(backlinks.length, 1)
t.is(backlinks[0].sourceNotebook, 'personal')
t.is(backlinks[0].sourceId, source.id)
await store.close()
rmrf(dir)
})
test('removeAllLinks — cleans up both directions', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
await nm.createNotebook('test', { name: 'Test' })
const target = await nm.createNote('test', { title: 'Target', content: 'Linked' })
const source = await nm.createNote('test', { title: 'Source', content: 'See [[Target]]' })
await lm.updateLinks('test', source.id, source.content)
// Verify link exists
let bl = await lm.getBacklinks('test', target.id)
t.is(bl.length, 1)
// Remove all links for source
await lm.removeAllLinks('test', source.id)
// Outgoing links gone
const links = await lm.getLinks('test', source.id)
t.is(links.length, 0)
// Backlinks gone
bl = await lm.getBacklinks('test', target.id)
t.is(bl.length, 0)
await store.close()
rmrf(dir)
})
// ==================== GRAPH ====================
test('getGraph — returns nodes and edges', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
await nm.createNotebook('test', { name: 'Test' })
const note1 = await nm.createNote('test', { title: 'Note A', content: 'See [[Note B]]' })
const note2 = await nm.createNote('test', { title: 'Note B', content: 'See [[Note A]]' })
await lm.updateLinks('test', note1.id, note1.content)
await lm.updateLinks('test', note2.id, note2.content)
const graph = await lm.getGraph('test')
t.is(graph.nodes.length, 2)
t.is(graph.edges.length, 2, 'bidirectional links')
// Check edge structure
const edge = graph.edges.find(e => e.source === `test/${note1.id}`)
t.ok(edge)
t.is(edge.target, `test/${note2.id}`)
await store.close()
rmrf(dir)
})
test('getGraph — cross-notebook', async (t) => {
const { store, registry, dm, dir } = tmpSetup()
await registry.ready()
const nm = new NoteManager(dm)
const lm = new LinkManager(registry, nm)
await nm.createNotebook('work', { name: 'Work' })
await nm.createNotebook('personal', { name: 'Personal' })
const workNote = await nm.createNote('work', { title: 'Task', content: 'Work task' })
const personalNote = await nm.createNote('personal', { title: 'Todo', content: 'Do [[work/Task]]' })
await lm.updateLinks('personal', personalNote.id, personalNote.content)
// Graph for all notebooks
const graph = await lm.getGraph(null)
t.is(graph.nodes.length, 2)
t.is(graph.edges.length, 1)
await store.close()
rmrf(dir)
})