OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / link-manager.js

257 lines · javascript ref:
/**
 * 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