/**
* 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