OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / note-manager.js

1318 lines · javascript ref:
/**
 * NoteManager - CRUD operations for notebooks and notes
 *
 * Uses DriveManager for per-notebook Hyperdrive access.
 * Each notebook has its own drive with layout:
 *   /metadata.json
 *   /notes/{noteId}.json
 */

import crypto from 'bare-crypto'
import { ValidationError, NotFoundError, ConflictError, ForbiddenError } from './errors.js'

const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/
const MAX_SLUG = 64
const MAX_TITLE = 256

const TOC_START = '<!-- TOC START -->'
const TOC_END = '<!-- TOC END -->'

/**
 * Generate a URL-safe slug from a title string.
 * Lowercase, non-alphanumeric → hyphens, collapse multiples, trim.
 */
export function slugify (title) {
  if (!title || typeof title !== 'string') return ''
  return title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '')
    .substring(0, MAX_SLUG)
}

export class NoteManager {
  constructor (driveManager, { crypto, linkManager, multiwriter } = {}) {
    this.driveManager = driveManager
    this.crypto = crypto || null // NotebookCrypto instance (optional)
    this.linkManager = linkManager || null // LinkManager instance (optional, set after construction)
    this.multiwriter = multiwriter || null // MultiwriterManager instance (optional, set after construction)
    this._keyCache = new Map() // slug → { key: Buffer, unlockedAt: number }
    this._keyCacheTTL = 30 * 60 * 1000 // 30 minutes
    this._keyCacheCleanupInterval = setInterval(() => this._evictExpiredKeys(), 5 * 60 * 1000)
  }

  /**
   * Get drive for a notebook slug, throw if not found.
   */
  async _getDrive (slug) {
    const drive = await this.driveManager.getDrive(slug)
    if (!drive) throw new NotFoundError(`Notebook '${slug}' not found`, 'notebook')
    return drive
  }

  /**
   * Get SharedNotebook for a slug if multiwriter is linked and notebook is migrated.
   * Returns null if not available (falls through to Hyperdrive path).
   */
  _getSharedNotebook (slug) {
    if (!this.multiwriter) return null
    return this.multiwriter.getSharedNotebook(slug)
  }

  /**
   * Guard: throw if notebook is remote (read-only).
   */
  _requireLocal (slug) {
    if (this.driveManager.isRemote(slug)) {
      throw new ForbiddenError('Remote notebooks are read-only', 'write')
    }
  }

  /**
   * Unlock an encrypted notebook for this session.
   * Decrypts the notebook key and caches it in memory.
   */
  async unlockNotebook (slug, password) {
    if (!this.crypto) throw new Error('Encryption not configured')
    const key = await this.crypto.getNotebookKey(slug, password)
    this._keyCache.set(slug, { key, unlockedAt: Date.now() })
    return true
  }

  /**
   * Check if a notebook's key is cached (unlocked) and not expired.
   */
  isUnlocked (slug) {
    const entry = this._keyCache.get(slug)
    if (!entry) return false
    if (Date.now() - entry.unlockedAt > this._keyCacheTTL) {
      this._keyCache.delete(slug)
      return false
    }
    return true
  }

  /**
   * Get the cached key for a slug, or null if expired/missing.
   */
  _getCachedKey (slug) {
    const entry = this._keyCache.get(slug)
    if (!entry) return null
    if (Date.now() - entry.unlockedAt > this._keyCacheTTL) {
      this._keyCache.delete(slug)
      return null
    }
    return entry.key
  }

  /**
   * Set a notebook key directly (e.g. after enableEncryption).
   */
  cacheKey (slug, key) {
    this._keyCache.set(slug, { key, unlockedAt: Date.now() })
  }

  /**
   * Clear a cached key (lock notebook).
   */
  lockNotebook (slug) {
    this._keyCache.delete(slug)
  }

  /**
   * Evict expired keys from the cache.
   */
  _evictExpiredKeys () {
    const now = Date.now()
    for (const [slug, entry] of this._keyCache) {
      if (now - entry.unlockedAt > this._keyCacheTTL) {
        this._keyCache.delete(slug)
      }
    }
  }

  // ==================== SLUG HELPERS ====================

  /**
   * Ensure a note slug is unique within a notebook.
   * If collision, appends -2, -3, etc.
   */
  async _ensureUniqueSlug (notebook, slug, excludeId) {
    if (!slug) slug = 'untitled'
    const notes = await this.listNotes(notebook)
    const existing = new Set()
    for (const n of notes) {
      if (n.id !== excludeId && n.slug) existing.add(n.slug)
    }
    if (!existing.has(slug)) return slug
    let i = 2
    while (existing.has(slug + '-' + i)) i++
    return slug + '-' + i
  }

  /**
   * Look up a note by slug within a notebook.
   * Returns the note object or null.
   */
  async getNoteBySlug (notebook, noteSlug) {
    const notes = await this.listNotes(notebook)
    return notes.find(n => n.slug === noteSlug) || null
  }

  /**
   * Resolve a note identifier — could be a slug or a hex ID.
   * Tries slug first, falls back to hex ID lookup.
   */
  async resolveNote (notebook, identifier) {
    // Try slug lookup first
    const bySlug = await this.getNoteBySlug(notebook, identifier)
    if (bySlug) return bySlug

    // Fall back to hex ID if it looks like one
    if (/^[a-f0-9]{16}$/.test(identifier)) {
      return this.getNote(notebook, identifier)
    }

    return null
  }

  /**
   * Ensure a note has a slug field. If missing, generate from title.
   * Intentionally does NOT persist here — slug is persisted lazily via
   * listNotes() (see needsPersist flag at line ~704) to avoid writes
   * on every single note read. This is a deliberate design choice.
   */
  _ensureSlug (note, notebook) {
    if (note.slug) return note
    const base = note.title ? slugify(note.title) : 'untitled-' + (note.id || '').substring(0, 8)
    note.slug = base || 'untitled'
    return note
  }

  // ==================== README / TOC ====================

  /**
   * Get the README note for a notebook, or null if none exists.
   */
  async getReadme (notebook) {
    const meta = await this.getNotebook(notebook)
    if (!meta || !meta.readmeId) return null
    return this.getNote(notebook, meta.readmeId)
  }

  /**
   * Create a README note for a notebook. Sets readmeId in metadata.
   */
  async createReadme (notebook) {
    const meta = await this.getNotebook(notebook)
    if (!meta) throw new NotFoundError(`Notebook '${notebook}' not found`, 'notebook')

    const title = 'README'
    const content = `# ${meta.name || notebook}\n\n${TOC_START}\n${TOC_END}\n`

    const note = await this.createNote(notebook, { title, content, tags: [] })

    // Save readmeId to metadata
    meta.readmeId = note.id
    meta.updatedAt = Date.now()
    const toStore = { ...meta }
    delete toStore.isLocal
    delete toStore.driveKey
    delete toStore.shared
    delete toStore.autobaseKey
    const drive = await this._getDrive(notebook)
    await drive.put('/metadata.json', Buffer.from(JSON.stringify(toStore, null, 2)))

    return note
  }

  /**
   * Lazy migration: ensure a README exists for a notebook.
   */
  async ensureReadme (notebook) {
    const existing = await this.getReadme(notebook)
    if (existing) return existing

    // Don't create README for remote notebooks
    if (this.driveManager.isRemote(notebook)) return null

    return this.createReadme(notebook)
  }

  /**
   * Regenerate the TOC between markers in the README note.
   * Lists all notes (excluding README) alphabetically with slug links.
   */
  async regenerateToc (notebook) {
    try {
      const meta = await this.getNotebook(notebook)
      if (!meta || !meta.readmeId) return

      const readme = await this.getNote(notebook, meta.readmeId)
      if (!readme) {
        console.error(`[TOC] README note ${meta.readmeId} not found for ${notebook}`)
        return
      }

      const content = readme.content || ''
      const startIdx = content.indexOf(TOC_START)
      const endIdx = content.indexOf(TOC_END)
      if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) return

      // Build TOC entries from all notes except README
      const notes = await this.listNotes(notebook)
      const nonReadme = notes.filter(n => n.id !== meta.readmeId)
      const entries = nonReadme
        .sort((a, b) => (a.title || '').localeCompare(b.title || ''))
        .map(n => `- [${n.title || 'Untitled'}](${n.slug || n.id})`)

      const tocContent = entries.length > 0 ? '\n' + entries.join('\n') + '\n' : '\n'
      const newContent = content.substring(0, startIdx + TOC_START.length) + tocContent + content.substring(endIdx)

      if (newContent !== content) {
        await this.updateNote(notebook, meta.readmeId, { content: newContent })
        console.log(`[TOC] Updated ${notebook} README: ${entries.length} entries`)
      }
    } catch (err) {
      console.error(`[TOC] Failed to regenerate for ${notebook}: ${err.message}`)
    }
  }

  /**
   * Encrypt title + content of a note if the notebook is encrypted.
   */
  _encryptNote (note, slug) {
    const key = this._getCachedKey(slug)
    if (!key || !this.crypto) return note

    return {
      ...note,
      encrypted: true,
      title: this.crypto.encryptField(note.title, key),
      content: this.crypto.encryptField(note.content, key)
    }
  }

  /**
   * Decrypt title + content of a note if it's encrypted and key is available.
   */
  _decryptNote (note, slug) {
    if (!note.encrypted) return note

    const key = this._getCachedKey(slug)
    if (!key || !this.crypto) return note

    return {
      ...note,
      title: this.crypto.decryptField(note.title, key),
      content: this.crypto.decryptField(note.content, key),
      encrypted: false
    }
  }

  // ==================== VALIDATION ====================

  validateSlug (slug) {
    if (!slug || typeof slug !== 'string') {
      throw new ValidationError('Slug is required', 'slug')
    }
    if (slug.length > MAX_SLUG) {
      throw new ValidationError(`Slug must be ${MAX_SLUG} characters or less`, 'slug')
    }
    if (!SLUG_RE.test(slug)) {
      throw new ValidationError('Slug must be lowercase alphanumeric + hyphens, starting with a letter or digit', 'slug')
    }
  }

  validateTitle (title) {
    if (!title || typeof title !== 'string' || title.trim().length === 0) {
      throw new ValidationError('Title is required', 'title')
    }
    if (title.length > MAX_TITLE) {
      throw new ValidationError(`Title must be ${MAX_TITLE} characters or less`, 'title')
    }
  }

  // ==================== NOTEBOOK METHODS ====================

  async createNotebook (slug, { name, description, encrypted, ownerId, visibility } = {}) {
    this.validateSlug(slug)

    // Check if drive already exists
    const existingDrive = await this.driveManager.getDrive(slug)
    if (existingDrive) {
      const existingMeta = await existingDrive.entry('/metadata.json')
      if (existingMeta) {
        throw new ConflictError(`Notebook '${slug}' already exists`, 'notebook')
      }
    }

    const drive = await this.driveManager.createDrive(slug)

    const now = Date.now()
    const metadata = {
      slug,
      name: name || slug,
      description: description || '',
      encrypted: encrypted || false,
      visibility: visibility || 'public',
      ownerId: ownerId || null,
      createdAt: now,
      updatedAt: now,
      noteCount: 0
    }

    await drive.put('/metadata.json', Buffer.from(JSON.stringify(metadata, null, 2)))

    // If linked, create SharedNotebook immediately so it syncs
    if (this.multiwriter && this.multiwriter.registry) {
      try {
        await this.multiwriter.shareNotebook(slug)
      } catch (err) {
        console.error('[NoteManager] Failed to create SharedNotebook for ' + slug + ':', err.message)
      }
    }

    // Create README note for the new notebook
    try {
      await this.createReadme(slug)
    } catch (err) {
      console.error('[NoteManager] Failed to create README for ' + slug + ':', err.message)
    }

    return metadata
  }

  async getNotebook (slug) {
    const drive = await this.driveManager.getDrive(slug)
    if (!drive) return null

    const entry = await drive.entry('/metadata.json')
    if (!entry) return null

    const buf = await drive.get('/metadata.json')
    if (!buf) return null

    const meta = JSON.parse(buf.toString())

    // Add drive info
    const info = await this.driveManager.getDriveInfo(slug)
    if (info) {
      meta.isLocal = info.isLocal
      meta.driveKey = info.driveKey
    }

    // Add shared flag if SharedNotebook exists
    const snb = this._getSharedNotebook(slug)
    if (snb) {
      meta.shared = true
      meta.autobaseKey = snb.bootstrapKey.toString('hex')
    }

    return meta
  }

  async listNotebooks () {
    const entries = await this.driveManager.listDrives()
    const notebooks = []

    for (const entry of entries) {
      const drive = await this.driveManager.getDrive(entry.slug)
      if (!drive) {
        console.error(`[NoteManager] Drive not found for slug: ${entry.slug}`)
        continue
      }

      try {
        const buf = await drive.get('/metadata.json')
        if (buf) {
          const meta = JSON.parse(buf.toString())
          meta.isLocal = entry.isLocal
          meta.driveKey = entry.driveKey
          const snb = this._getSharedNotebook(entry.slug)
          if (snb) {
            meta.shared = true
          }
          notebooks.push(meta)
        } else {
          console.error(`[NoteManager] No metadata.json in drive: ${entry.slug}`)
        }
      } catch (err) {
        console.error(`[NoteManager] Error reading drive ${entry.slug}:`, err.message)
        continue
      }
    }

    notebooks.sort((a, b) => a.name.localeCompare(b.name))
    return notebooks
  }

  async updateNotebook (slug, updates) {
    this._requireLocal(slug)
    const meta = await this.getNotebook(slug)
    if (!meta) {
      throw new NotFoundError(`Notebook '${slug}' not found`, 'notebook')
    }

    if (updates.name !== undefined) meta.name = updates.name
    if (updates.description !== undefined) meta.description = updates.description
    if (updates.encrypted !== undefined) meta.encrypted = updates.encrypted
    if (updates.visibility !== undefined) {
      if (updates.visibility !== 'public' && updates.visibility !== 'private') {
        throw new ValidationError('visibility must be public or private', 'visibility')
      }
      meta.visibility = updates.visibility
    }
    meta.updatedAt = Date.now()

    // Remove transient fields before persisting
    const toStore = { ...meta }
    delete toStore.isLocal
    delete toStore.driveKey

    const drive = await this._getDrive(slug)
    await drive.put('/metadata.json', Buffer.from(JSON.stringify(toStore, null, 2)))
    return meta
  }

  async deleteNotebook (slug) {
    this._requireLocal(slug)
    const meta = await this.getNotebook(slug)
    if (!meta) {
      throw new NotFoundError(`Notebook '${slug}' not found`, 'notebook')
    }

    const drive = await this._getDrive(slug)

    // Delete all notes first
    try {
      const noteFiles = await drive.readdir('/notes')
      for await (const file of noteFiles) {
        await drive.del(`/notes/${file}`)
      }
    } catch {
      // notes dir may not exist
    }

    // Delete metadata
    await drive.del('/metadata.json')

    // Remove drive from registry + cache
    await this.driveManager.deleteDrive(slug)

    return { deleted: true, slug }
  }

  async renameNotebook (oldSlug, newSlug) {
    this._requireLocal(oldSlug)
    this.validateSlug(newSlug)

    const oldMeta = await this.getNotebook(oldSlug)
    if (!oldMeta) {
      throw new NotFoundError(`Notebook '${oldSlug}' not found`, 'notebook')
    }

    const existingNew = await this.getNotebook(newSlug)
    if (existingNew) {
      throw new ConflictError(`Notebook '${newSlug}' already exists`, 'notebook')
    }

    // Create new drive
    const newDrive = await this.driveManager.createDrive(newSlug)

    // Declared here (not inside the try) so it stays in scope for the return.
    const now = Date.now()
    const newMeta = { ...oldMeta, slug: newSlug, updatedAt: now }
    delete newMeta.isLocal
    delete newMeta.driveKey

    // Phase 1 — copy everything into the new drive. Any failure here is safe to
    // roll back by deleting the new drive, because the OLD notebook has not been
    // touched yet and remains fully intact.
    try {
      await newDrive.put('/metadata.json', Buffer.from(JSON.stringify(newMeta, null, 2)))

      const oldDrive = await this._getDrive(oldSlug)
      try {
        const noteFiles = await oldDrive.readdir('/notes')
        for await (const file of noteFiles) {
          const buf = await oldDrive.get(`/notes/${file}`)
          if (buf) await newDrive.put(`/notes/${file}`, buf)
        }
      } catch {
        // no notes dir — nothing to copy
      }

      // Verify the copy landed before committing the destructive delete.
      const verifyMeta = await newDrive.get('/metadata.json')
      if (!verifyMeta) throw new Error('rename verify failed: new metadata missing')
    } catch (err) {
      // Safe rollback — the old notebook was never modified.
      try {
        await this.driveManager.deleteDrive(newSlug)
      } catch (cleanupErr) {
        console.error('[NoteManager] Rollback cleanup failed for ' + newSlug + ':', cleanupErr.message)
      }
      throw err
    }

    // Phase 2 — COMMIT. The new drive is a verified full copy, so now (and only
    // now) remove the old notebook. If THIS fails we must NOT delete the new
    // drive — it is the canonical copy. Leave both intact and surface the error,
    // rather than rolling back into a state where neither is whole.
    try {
      await this.deleteNotebook(oldSlug)
    } catch (err) {
      console.error('[NoteManager] rename: "' + newSlug + '" is complete but removing old "' + oldSlug + '" failed; both notebooks now exist:', err.message)
      throw new Error(`Notebook renamed to '${newSlug}' but removing old '${oldSlug}' failed: ${err.message}. Both notebooks now exist; remove '${oldSlug}' manually.`)
    }

    // Return with transient fields
    newMeta.isLocal = true
    const info = await this.driveManager.getDriveInfo(newSlug)
    if (info) newMeta.driveKey = info.driveKey

    return newMeta
  }

  // ==================== NOTE METHODS ====================

  async createNote (notebook, { title, content, tags, type } = {}) {
    this.validateTitle(title)

    const noteType = (type === 'spreadsheet') ? 'spreadsheet' : 'markdown'

    // Generate slug from title
    const baseSlug = slugify(title) || 'untitled'
    const noteSlug = await this._ensureUniqueSlug(notebook, baseSlug, null)

    // SharedNotebook path (multi-writer)
    const snb = this._getSharedNotebook(notebook)
    if (snb) {
      const { noteId } = await snb.createNote({
        title,
        slug: noteSlug,
        author: 'user',
        tags: Array.isArray(tags) ? tags : [],
        type: noteType
      })
      if (content) {
        if (noteType === 'spreadsheet') {
          await snb.applySpreadsheetUpdate(noteId, content, 'user')
        } else {
          const Y = (await import('yjs'))
          const doc = new Y.Doc()
          doc.getText('content').insert(0, content)
          await snb.applyYjsUpdate(noteId, Y.encodeStateAsUpdate(doc), 'user')
        }
      }
      const result = {
        id: noteId,
        slug: noteSlug,
        title,
        content: content || '',
        tags: Array.isArray(tags) ? tags : [],
        type: noteType,
        createdAt: Date.now(),
        updatedAt: Date.now()
      }
      // Regenerate TOC in background
      this.regenerateToc(notebook)
      return result
    }

    // Existing Hyperdrive path
    this._requireLocal(notebook)

    const meta = await this.getNotebook(notebook)
    if (!meta) {
      throw new NotFoundError(`Notebook '${notebook}' not found`, 'notebook')
    }

    const drive = await this._getDrive(notebook)
    const id = crypto.randomBytes(8).toString('hex')
    const now = Date.now()
    let note = {
      id,
      slug: noteSlug,
      title,
      content: content || '',
      tags: Array.isArray(tags) ? tags : [],
      type: noteType,
      createdAt: now,
      updatedAt: now
    }

    // Encrypt if notebook is encrypted and key is available
    const stored = meta.encrypted ? this._encryptNote(note, notebook) : note

    await drive.put(`/notes/${id}.json`, Buffer.from(JSON.stringify(stored, null, 2)))

    // Bump noteCount
    meta.noteCount = (meta.noteCount || 0) + 1
    meta.updatedAt = now
    const toStore = { ...meta }
    delete toStore.isLocal
    delete toStore.driveKey
    await drive.put('/metadata.json', Buffer.from(JSON.stringify(toStore, null, 2)))

    // Update link index
    if (this.linkManager && note.content) {
      await this.linkManager.updateLinks(notebook, id, note.content)
    }

    // Regenerate TOC in background
    this.regenerateToc(notebook)

    return note // Return plaintext to caller
  }

  async getNote (notebook, noteId) {
    // Path-traversal guard: noteId addresses /notes/<id>.json — it is never a
    // path. Public routes (web.js) pass it raw, so validate at the source.
    if (typeof noteId !== 'string' || !noteId ||
        noteId.includes('/') || noteId.includes('\\') ||
        noteId.includes('..') || noteId.includes('\0')) {
      return null
    }

    // SharedNotebook path (multi-writer)
    const snb = this._getSharedNotebook(notebook)
    if (snb) {
      const note = await snb.getNote(noteId)
      if (!note) return null
      let content
      if (note.type === 'spreadsheet') {
        content = await snb.getSpreadsheetContent(noteId)
      } else {
        const doc = await snb.reconstructDoc(noteId)
        content = doc.getText('content').toString()
      }
      const result = { ...note, id: note.noteId, content }
      if (!result.slug) {
        result.slug = slugify(result.title) || 'untitled-' + noteId.substring(0, 8)
      }
      return result
    }

    // Existing Hyperdrive path
    const drive = await this.driveManager.getDrive(notebook)
    if (!drive) return null

    const notePath = `/notes/${noteId}.json`
    const entry = await drive.entry(notePath)
    if (!entry) return null

    const buf = await drive.get(notePath)
    if (!buf) return null

    const note = JSON.parse(buf.toString())
    const decrypted = this._decryptNote(note, notebook)
    // Lazy slug migration
    if (!decrypted.slug) {
      decrypted.slug = slugify(decrypted.title) || 'untitled-' + (decrypted.id || '').substring(0, 8)
    }
    return decrypted
  }

  async listNotes (notebook, { limit, offset } = {}) {
    // SharedNotebook path (multi-writer)
    const snb = this._getSharedNotebook(notebook)
    if (snb) {
      const notes = await snb.listNotes()
      const enriched = []
      for (const note of notes) {
        try {
          let content
          if (note.type === 'spreadsheet') {
            content = await snb.getSpreadsheetContent(note.noteId)
          } else {
            const doc = await snb.reconstructDoc(note.noteId)
            content = doc.getText('content').toString()
          }
          const n = {
            id: note.noteId,
            slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
            title: note.title,
            content,
            tags: note.tags || [],
            type: note.type || 'markdown',
            createdAt: note.createdAt,
            updatedAt: note.updatedAt
          }
          enriched.push(n)
        } catch {
          enriched.push({
            id: note.noteId,
            slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
            title: note.title,
            content: '',
            tags: note.tags || [],
            type: note.type || 'markdown',
            createdAt: note.createdAt,
            updatedAt: note.updatedAt
          })
        }
      }
      // Deduplicate slugs
      this._deduplicateSlugs(enriched)
      enriched.sort((a, b) => b.updatedAt - a.updatedAt)
      const start = offset || 0
      const end = limit ? start + limit : enriched.length
      return enriched.slice(start, end)
    }

    // Existing Hyperdrive path
    const meta = await this.getNotebook(notebook)
    if (!meta) {
      throw new NotFoundError(`Notebook '${notebook}' not found`, 'notebook')
    }

    const drive = await this._getDrive(notebook)
    const notes = []
    let needsPersist = false

    try {
      const files = await drive.readdir('/notes')
      for await (const file of files) {
        if (!file.endsWith('.json')) continue
        const buf = await drive.get(`/notes/${file}`)
        if (buf) {
          const note = JSON.parse(buf.toString())
          const decrypted = this._decryptNote(note, notebook)
          // Lazy slug migration
          if (!decrypted.slug) {
            decrypted.slug = slugify(decrypted.title) || 'untitled-' + (decrypted.id || '').substring(0, 8)
            needsPersist = true
          }
          notes.push(decrypted)
        }
      }
    } catch {
      return []
    }

    // Deduplicate slugs
    this._deduplicateSlugs(notes)

    // Persist slugs lazily if any were missing (fire-and-forget)
    if (needsPersist && !this.driveManager.isRemote(notebook)) {
      for (const note of notes) {
        const stored = (meta && meta.encrypted) ? this._encryptNote(note, notebook) : note
        drive.put(`/notes/${note.id}.json`, Buffer.from(JSON.stringify(stored, null, 2))).catch(() => {})
      }
    }

    // Sort by updatedAt desc
    notes.sort((a, b) => b.updatedAt - a.updatedAt)

    // Apply offset and limit
    const start = offset || 0
    const end = limit ? start + limit : notes.length
    return notes.slice(start, end)
  }

  /**
   * Deduplicate slugs within a list of notes in-place.
   * Appends -2, -3 etc. to duplicate slugs.
   */
  _deduplicateSlugs (notes) {
    const seen = new Map()
    for (const note of notes) {
      if (!note.slug) continue
      if (seen.has(note.slug)) {
        let i = 2
        while (seen.has(note.slug + '-' + i)) i++
        note.slug = note.slug + '-' + i
      }
      seen.set(note.slug, true)
    }
  }

  async updateNote (notebook, noteId, updates) {
    const titleChanged = updates.title !== undefined

    // SharedNotebook path (multi-writer)
    const snb = this._getSharedNotebook(notebook)
    if (snb) {
      if (titleChanged) {
        this.validateTitle(updates.title)
        await snb.updateNoteTitle(noteId, updates.title, 'user')
      }
      if (updates.content !== undefined) {
        // Check note type to determine storage strategy
        const existingNote = await snb.getNote(noteId)
        if (existingNote && existingNote.type === 'spreadsheet') {
          await snb.applySpreadsheetUpdate(noteId, updates.content, 'user')
        } else {
          const Y = (await import('yjs'))
          const doc = await snb.reconstructDoc(noteId)
          const text = doc.getText('content')
          doc.transact(() => {
            text.delete(0, text.length)
            text.insert(0, updates.content)
          })
          await snb.applyYjsUpdate(noteId, Y.encodeStateAsUpdate(doc), 'user')
        }
      }
      const note = await snb.getNote(noteId)
      if (!note) {
        throw new NotFoundError("Note '" + noteId + "' not found", 'note')
      }
      let content
      if (note.type === 'spreadsheet') {
        content = await snb.getSpreadsheetContent(noteId)
      } else {
        const doc = await snb.reconstructDoc(noteId)
        content = doc.getText('content').toString()
      }
      const result = { ...note, id: note.noteId, content }
      // Regenerate TOC if title changed
      if (titleChanged) this.regenerateToc(notebook)
      return result
    }

    // Existing Hyperdrive path
    this._requireLocal(notebook)
    const note = await this.getNote(notebook, noteId) // Already decrypted
    if (!note) {
      throw new NotFoundError(`Note '${noteId}' not found in '${notebook}'`, 'note')
    }

    if (titleChanged) {
      this.validateTitle(updates.title)
      note.title = updates.title
      // Regenerate slug from new title
      const newBaseSlug = slugify(updates.title) || 'untitled'
      note.slug = await this._ensureUniqueSlug(notebook, newBaseSlug, noteId)
    }
    if (updates.content !== undefined) note.content = updates.content
    if (updates.tags !== undefined) note.tags = Array.isArray(updates.tags) ? updates.tags : []
    note.updatedAt = Date.now()

    // Re-encrypt if notebook is encrypted
    const meta = await this.getNotebook(notebook)
    const stored = (meta && meta.encrypted) ? this._encryptNote(note, notebook) : note

    const drive = await this._getDrive(notebook)
    await drive.put(`/notes/${noteId}.json`, Buffer.from(JSON.stringify(stored, null, 2)))

    // Update link index
    if (this.linkManager && note.content) {
      await this.linkManager.updateLinks(notebook, noteId, note.content)
    }

    // Regenerate TOC if title changed
    if (titleChanged) this.regenerateToc(notebook)

    return note // Return plaintext
  }

  async deleteNote (notebook, noteId) {
    // SharedNotebook path (multi-writer)
    const snb = this._getSharedNotebook(notebook)
    if (snb) {
      await snb.deleteNote(noteId, 'user')
      // Regenerate TOC in background
      this.regenerateToc(notebook)
      return { deleted: true, id: noteId }
    }

    // Existing Hyperdrive path
    this._requireLocal(notebook)
    const note = await this.getNote(notebook, noteId)
    if (!note) {
      throw new NotFoundError(`Note '${noteId}' not found in '${notebook}'`, 'note')
    }

    const drive = await this._getDrive(notebook)
    await drive.del(`/notes/${noteId}.json`)

    // Remove links
    if (this.linkManager) {
      await this.linkManager.removeAllLinks(notebook, noteId)
    }

    // Decrement noteCount and handle README deletion
    const meta = await this.getNotebook(notebook)
    if (meta) {
      meta.noteCount = Math.max(0, (meta.noteCount || 1) - 1)
      meta.updatedAt = Date.now()
      // If deleting the README, clear readmeId
      if (meta.readmeId === noteId) {
        delete meta.readmeId
      }
      const toStore = { ...meta }
      delete toStore.isLocal
      delete toStore.driveKey
      await drive.put('/metadata.json', Buffer.from(JSON.stringify(toStore, null, 2)))
    }

    // Regenerate TOC in background (only if we didn't just delete the README)
    if (!meta || meta.readmeId !== noteId) {
      this.regenerateToc(notebook)
    }

    return { deleted: true, id: noteId }
  }

  async moveNote (fromNotebook, noteId, toNotebook) {
    this._requireLocal(toNotebook)
    const note = await this.getNote(fromNotebook, noteId) // Already decrypted
    if (!note) {
      throw new NotFoundError(`Note '${noteId}' not found in '${fromNotebook}'`, 'note')
    }

    const toMeta = await this.getNotebook(toNotebook)
    if (!toMeta) {
      throw new NotFoundError(`Notebook '${toNotebook}' not found`, 'notebook')
    }

    // Write to new location (re-encrypt if target is encrypted)
    const now = Date.now()
    note.updatedAt = now
    const stored = toMeta.encrypted ? this._encryptNote(note, toNotebook) : note

    const toDrive = await this._getDrive(toNotebook)
    await toDrive.put(`/notes/${noteId}.json`, Buffer.from(JSON.stringify(stored, null, 2)))

    // Delete from old location
    const fromDrive = await this._getDrive(fromNotebook)
    await fromDrive.del(`/notes/${noteId}.json`)

    // Update note counts
    const fromMeta = await this.getNotebook(fromNotebook)
    if (fromMeta) {
      fromMeta.noteCount = Math.max(0, (fromMeta.noteCount || 1) - 1)
      fromMeta.updatedAt = now
      const toStoreFrom = { ...fromMeta }
      delete toStoreFrom.isLocal
      delete toStoreFrom.driveKey
      await fromDrive.put('/metadata.json', Buffer.from(JSON.stringify(toStoreFrom, null, 2)))
    }

    toMeta.noteCount = (toMeta.noteCount || 0) + 1
    toMeta.updatedAt = now
    const toStoreTo = { ...toMeta }
    delete toStoreTo.isLocal
    delete toStoreTo.driveKey
    await toDrive.put('/metadata.json', Buffer.from(JSON.stringify(toStoreTo, null, 2)))

    return note
  }

  // ==================== MERGE & SPLIT ====================

  async mergeNotes (notebook, noteId1, noteId2, { title } = {}) {
    const note1 = await this.getNote(notebook, noteId1)
    if (!note1) {
      throw new NotFoundError(`Note '${noteId1}' not found in '${notebook}'`, 'note')
    }

    const note2 = await this.getNote(notebook, noteId2)
    if (!note2) {
      throw new NotFoundError(`Note '${noteId2}' not found in '${notebook}'`, 'note')
    }

    // Combine content with separator
    const mergedContent = note1.content + '\n\n---\n\n' + note2.content

    // Merge tags (union, deduplicated)
    const mergedTags = [...new Set([...(note1.tags || []), ...(note2.tags || [])])]

    // Update note1 with merged content
    const updates = {
      content: mergedContent,
      tags: mergedTags
    }
    if (title) updates.title = title

    const merged = await this.updateNote(notebook, noteId1, updates)

    // Delete note2
    await this.deleteNote(notebook, noteId2)

    return merged
  }

  async splitNote (notebook, noteId, { delimiter } = {}) {
    const note = await this.getNote(notebook, noteId)
    if (!note) {
      throw new NotFoundError(`Note '${noteId}' not found in '${notebook}'`, 'note')
    }

    const splitPattern = delimiter || '## '
    const lines = note.content.split('\n')

    // Find split points (lines starting with the delimiter)
    const sections = []
    let currentLines = []
    let currentTitle = null

    for (const line of lines) {
      if (line.startsWith(splitPattern) && currentLines.length > 0) {
        sections.push({
          title: currentTitle,
          content: currentLines.join('\n').trim()
        })
        currentTitle = line.replace(/^#+\s*/, '').trim()
        currentLines = [line]
      } else {
        if (currentTitle === null && line.startsWith(splitPattern)) {
          currentTitle = line.replace(/^#+\s*/, '').trim()
        }
        currentLines.push(line)
      }
    }

    // Push last section
    if (currentLines.length > 0) {
      sections.push({
        title: currentTitle,
        content: currentLines.join('\n').trim()
      })
    }

    // If only one section, nothing to split
    if (sections.length <= 1) {
      return []
    }

    // Keep first section in original note
    const first = sections[0]
    await this.updateNote(notebook, noteId, {
      content: first.content,
      title: first.title || note.title
    })

    // Create new notes for remaining sections
    const created = []
    for (let i = 1; i < sections.length; i++) {
      const section = sections[i]
      const newNote = await this.createNote(notebook, {
        title: section.title || `${note.title} (part ${i + 1})`,
        content: section.content,
        tags: [...(note.tags || [])]
      })
      created.push(newNote)
    }

    return created
  }

  // ==================== RECENT NOTES (FEED) ====================

  /**
   * Get recent notes across all public notebooks
   * Returns notes with notebook metadata attached
   */
  // TODO: streaming pagination — currently loads all notes into memory
  async recentNotes ({ limit = 20 } = {}) {
    const allNotes = []
    const notebooks = await this.listNotebooks()
    // Collect at most limit*2 notes per notebook to reduce memory usage
    const perNotebookCap = limit * 2

    for (const nb of notebooks) {
      // Skip private and encrypted notebooks
      if (nb.visibility === 'private' || nb.encrypted) continue

      // Try SharedNotebook first
      const snb = this._getSharedNotebook(nb.slug)
      if (snb) {
        try {
          const notes = await snb.listNotes()
          let count = 0
          for (const note of notes) {
            if (count >= perNotebookCap) break
            try {
              let content
              if (note.type === 'spreadsheet') {
                content = await snb.getSpreadsheetContent(note.noteId)
              } else {
                const doc = await snb.reconstructDoc(note.noteId)
                content = doc.getText('content').toString()
              }
              allNotes.push({
                id: note.noteId,
                slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
                title: note.title,
                content,
                tags: note.tags || [],
                type: note.type || 'markdown',
                createdAt: note.createdAt,
                updatedAt: note.updatedAt,
                notebook: nb.slug,
                notebookName: nb.name,
                owner: nb.ownerId || null
              })
              count++
            } catch {}
          }
        } catch {}
        continue // Skip Hyperdrive path for this notebook
      }

      const drive = await this.driveManager.getDrive(nb.slug)
      if (!drive) continue

      let files
      try {
        files = await drive.readdir('/notes')
      } catch {
        continue
      }

      let count = 0
      for await (const file of files) {
        if (count >= perNotebookCap) break
        if (!file.endsWith('.json')) continue
        const buf = await drive.get(`/notes/${file}`)
        if (!buf) continue

        const note = JSON.parse(buf.toString())
        allNotes.push({
          id: note.id,
          slug: note.slug || slugify(note.title) || 'untitled-' + (note.id || '').substring(0, 8),
          title: note.title,
          content: note.content,
          tags: note.tags || [],
          type: note.type || 'markdown',
          createdAt: note.createdAt,
          updatedAt: note.updatedAt,
          notebook: nb.slug,
          notebookName: nb.name,
          owner: nb.ownerId || null
        })
        count++
      }
    }

    allNotes.sort((a, b) => b.updatedAt - a.updatedAt)
    return allNotes.slice(0, limit)
  }

  // ==================== SEARCH ====================

  // TODO: streaming pagination — currently loads all matching notes into memory
  async searchNotes (query, { notebook, maxResults = 100 } = {}) {
    if (!query || typeof query !== 'string') {
      throw new ValidationError('Search query is required', 'query')
    }

    const q = query.toLowerCase()
    const results = []
    const notebooks = notebook ? [notebook] : await this._listNotebookSlugs()

    for (const slug of notebooks) {
      if (results.length >= maxResults) break

      const drive = await this.driveManager.getDrive(slug)
      if (!drive) continue

      // Try SharedNotebook first
      const snb = this._getSharedNotebook(slug)
      if (snb) {
        try {
          const notes = await snb.listNotes()
          for (const note of notes) {
            if (results.length >= maxResults) break
            try {
              let content
              if (note.type === 'spreadsheet') {
                content = await snb.getSpreadsheetContent(note.noteId)
              } else {
                const doc = await snb.reconstructDoc(note.noteId)
                content = doc.getText('content').toString()
              }
              const titleMatch = note.title && note.title.toLowerCase().includes(q)
              const contentMatch = content && content.toLowerCase().includes(q)
              if (titleMatch || contentMatch) {
                const searchIn = contentMatch ? content : note.title
                const idx = searchIn.toLowerCase().indexOf(q)
                const start = Math.max(0, idx - 40)
                const end = Math.min(searchIn.length, idx + query.length + 40)
                const snippet = (start > 0 ? '...' : '') + searchIn.slice(start, end) + (end < searchIn.length ? '...' : '')
                results.push({
                  notebook: slug,
                  id: note.noteId,
                  slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
                  title: note.title,
                  snippet,
                  updatedAt: note.updatedAt
                })
              }
            } catch {}
          }
        } catch {}
        continue // Skip Hyperdrive path
      }

      let files
      try {
        files = await drive.readdir('/notes')
      } catch {
        continue
      }

      for await (const file of files) {
        if (results.length >= maxResults) break
        if (!file.endsWith('.json')) continue
        const buf = await drive.get(`/notes/${file}`)
        if (!buf) continue

        const raw = JSON.parse(buf.toString())

        // For encrypted notebooks without unlocked key, only search tags
        if (raw.encrypted && !this.isUnlocked(slug)) {
          const tagMatch = raw.tags && raw.tags.some(t => t.toLowerCase().includes(q))
          if (tagMatch) {
            results.push({
              notebook: slug,
              id: raw.id,
              slug: raw.slug || 'encrypted-' + (raw.id || '').substring(0, 8),
              title: '(encrypted)',
              snippet: `Tags: ${raw.tags.join(', ')}`,
              updatedAt: raw.updatedAt
            })
          }
          continue
        }

        const note = this._decryptNote(raw, slug)
        const titleMatch = note.title && note.title.toLowerCase().includes(q)
        const contentMatch = note.content && note.content.toLowerCase().includes(q)

        if (titleMatch || contentMatch) {
          let snippet = ''
          const searchIn = contentMatch ? note.content : note.title
          const idx = searchIn.toLowerCase().indexOf(q)
          const start = Math.max(0, idx - 40)
          const end = Math.min(searchIn.length, idx + query.length + 40)
          snippet = (start > 0 ? '...' : '') + searchIn.slice(start, end) + (end < searchIn.length ? '...' : '')

          results.push({
            notebook: slug,
            id: note.id,
            slug: note.slug || slugify(note.title) || 'untitled-' + (note.id || '').substring(0, 8),
            title: note.title,
            snippet,
            updatedAt: note.updatedAt
          })
        }
      }
    }

    results.sort((a, b) => b.updatedAt - a.updatedAt)
    return results
  }

  async _listNotebookSlugs () {
    const entries = await this.driveManager.listDrives()
    return entries.map(e => e.slug)
  }
}

export default NoteManager