OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / test / unit / link-manager.test.js

317 lines · javascript ref:
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)
})