OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note commits / 250c396

Fix: restore full note-manager.js accidentally overwritten
The previous commit staged the entire working-tree version of note-manager.js instead of just the 2-line type field change. This restores all multiwriter paths, slug support, TOC/README, key cache TTL, and shared notebook integration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
250c3965a0a13f700a6cd7767e91c61c0df6d0f2
by plankatron <fleeky@gmail.com> · 2026-03-20T01:23:25.000Z

Changed files (1) +590 -35

lib/note-manager.js +590 -35
……@@ -14,12 +14,32 @@ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/
1414const MAX_SLUG = 64
1515const MAX_TITLE = 256
1616
17const TOC_START = '<!-- TOC START -->'
18const TOC_END = '<!-- TOC END -->'
19
20/**
21 * Generate a URL-safe slug from a title string.
22 * Lowercase, non-alphanumeric → hyphens, collapse multiples, trim.
23 */
24export function slugify (title) {
25 if (!title || typeof title !== 'string') return ''
26 return title
27 .toLowerCase()
28 .replace(/[^a-z0-9]+/g, '-')
29 .replace(/-+/g, '-')
30 .replace(/^-|-$/g, '')
31 .substring(0, MAX_SLUG)
32}
33
1734export class NoteManager {
18 constructor (driveManager, { crypto, linkManager } = {}) {
35 constructor (driveManager, { crypto, linkManager, multiwriter } = {}) {
1936 this.driveManager = driveManager
2037 this.crypto = crypto || null // NotebookCrypto instance (optional)
2138 this.linkManager = linkManager || null // LinkManager instance (optional, set after construction)
22 this._keyCache = new Map() // slug → Buffer (unlocked notebook keys)
39 this.multiwriter = multiwriter || null // MultiwriterManager instance (optional, set after construction)
40 this._keyCache = new Map() // slug → { key: Buffer, unlockedAt: number }
41 this._keyCacheTTL = 30 * 60 * 1000 // 30 minutes
42 this._keyCacheCleanupInterval = setInterval(() => this._evictExpiredKeys(), 5 * 60 * 1000)
2343 }
2444
2545 /**
……@@ -31,6 +51,15 @@ export class NoteManager {
3151 return drive
3252 }
3353
54 /**
55 * Get SharedNotebook for a slug if multiwriter is linked and notebook is migrated.
56 * Returns null if not available (falls through to Hyperdrive path).
57 */
58 _getSharedNotebook (slug) {
59 if (!this.multiwriter) return null
60 return this.multiwriter.getSharedNotebook(slug)
61 }
62
3463 /**
3564 * Guard: throw if notebook is remote (read-only).
3665 */
……@@ -47,22 +76,41 @@ export class NoteManager {
4776 async unlockNotebook (slug, password) {
4877 if (!this.crypto) throw new Error('Encryption not configured')
4978 const key = await this.crypto.getNotebookKey(slug, password)
50 this._keyCache.set(slug, key)
79 this._keyCache.set(slug, { key, unlockedAt: Date.now() })
5180 return true
5281 }
5382
5483 /**
55 * Check if a notebook's key is cached (unlocked).
84 * Check if a notebook's key is cached (unlocked) and not expired.
5685 */
5786 isUnlocked (slug) {
58 return this._keyCache.has(slug)
87 const entry = this._keyCache.get(slug)
88 if (!entry) return false
89 if (Date.now() - entry.unlockedAt > this._keyCacheTTL) {
90 this._keyCache.delete(slug)
91 return false
92 }
93 return true
94 }
95
96 /**
97 * Get the cached key for a slug, or null if expired/missing.
98 */
99 _getCachedKey (slug) {
100 const entry = this._keyCache.get(slug)
101 if (!entry) return null
102 if (Date.now() - entry.unlockedAt > this._keyCacheTTL) {
103 this._keyCache.delete(slug)
104 return null
105 }
106 return entry.key
59107 }
60108
61109 /**
62110 * Set a notebook key directly (e.g. after enableEncryption).
63111 */
64112 cacheKey (slug, key) {
65 this._keyCache.set(slug, key)
113 this._keyCache.set(slug, { key, unlockedAt: Date.now() })
66114 }
67115
68116 /**
……@@ -72,11 +120,170 @@ export class NoteManager {
72120 this._keyCache.delete(slug)
73121 }
74122
123 /**
124 * Evict expired keys from the cache.
125 */
126 _evictExpiredKeys () {
127 const now = Date.now()
128 for (const [slug, entry] of this._keyCache) {
129 if (now - entry.unlockedAt > this._keyCacheTTL) {
130 this._keyCache.delete(slug)
131 }
132 }
133 }
134
135 // ==================== SLUG HELPERS ====================
136
137 /**
138 * Ensure a note slug is unique within a notebook.
139 * If collision, appends -2, -3, etc.
140 */
141 async _ensureUniqueSlug (notebook, slug, excludeId) {
142 if (!slug) slug = 'untitled'
143 const notes = await this.listNotes(notebook)
144 const existing = new Set()
145 for (const n of notes) {
146 if (n.id !== excludeId && n.slug) existing.add(n.slug)
147 }
148 if (!existing.has(slug)) return slug
149 let i = 2
150 while (existing.has(slug + '-' + i)) i++
151 return slug + '-' + i
152 }
153
154 /**
155 * Look up a note by slug within a notebook.
156 * Returns the note object or null.
157 */
158 async getNoteBySlug (notebook, noteSlug) {
159 const notes = await this.listNotes(notebook)
160 return notes.find(n => n.slug === noteSlug) || null
161 }
162
163 /**
164 * Resolve a note identifier — could be a slug or a hex ID.
165 * Tries slug first, falls back to hex ID lookup.
166 */
167 async resolveNote (notebook, identifier) {
168 // Try slug lookup first
169 const bySlug = await this.getNoteBySlug(notebook, identifier)
170 if (bySlug) return bySlug
171
172 // Fall back to hex ID if it looks like one
173 if (/^[a-f0-9]{16}$/.test(identifier)) {
174 return this.getNote(notebook, identifier)
175 }
176
177 return null
178 }
179
180 /**
181 * Ensure a note has a slug field. If missing, generate from title.
182 * Intentionally does NOT persist here — slug is persisted lazily via
183 * listNotes() (see needsPersist flag at line ~704) to avoid writes
184 * on every single note read. This is a deliberate design choice.
185 */
186 _ensureSlug (note, notebook) {
187 if (note.slug) return note
188 const base = note.title ? slugify(note.title) : 'untitled-' + (note.id || '').substring(0, 8)
189 note.slug = base || 'untitled'
190 return note
191 }
192
193 // ==================== README / TOC ====================
194
195 /**
196 * Get the README note for a notebook, or null if none exists.
197 */
198 async getReadme (notebook) {
199 const meta = await this.getNotebook(notebook)
200 if (!meta || !meta.readmeId) return null
201 return this.getNote(notebook, meta.readmeId)
202 }
203
204 /**
205 * Create a README note for a notebook. Sets readmeId in metadata.
206 */
207 async createReadme (notebook) {
208 const meta = await this.getNotebook(notebook)
209 if (!meta) throw new NotFoundError(`Notebook '${notebook}' not found`, 'notebook')
210
211 const title = 'README'
212 const content = `# ${meta.name || notebook}\n\n${TOC_START}\n${TOC_END}\n`
213
214 const note = await this.createNote(notebook, { title, content, tags: [] })
215
216 // Save readmeId to metadata
217 meta.readmeId = note.id
218 meta.updatedAt = Date.now()
219 const toStore = { ...meta }
220 delete toStore.isLocal
221 delete toStore.driveKey
222 delete toStore.shared
223 delete toStore.autobaseKey
224 const drive = await this._getDrive(notebook)
225 await drive.put('/metadata.json', Buffer.from(JSON.stringify(toStore, null, 2)))
226
227 return note
228 }
229
230 /**
231 * Lazy migration: ensure a README exists for a notebook.
232 */
233 async ensureReadme (notebook) {
234 const existing = await this.getReadme(notebook)
235 if (existing) return existing
236
237 // Don't create README for remote notebooks
238 if (this.driveManager.isRemote(notebook)) return null
239
240 return this.createReadme(notebook)
241 }
242
243 /**
244 * Regenerate the TOC between markers in the README note.
245 * Lists all notes (excluding README) alphabetically with slug links.
246 */
247 async regenerateToc (notebook) {
248 try {
249 const meta = await this.getNotebook(notebook)
250 if (!meta || !meta.readmeId) return
251
252 const readme = await this.getNote(notebook, meta.readmeId)
253 if (!readme) {
254 console.error(`[TOC] README note ${meta.readmeId} not found for ${notebook}`)
255 return
256 }
257
258 const content = readme.content || ''
259 const startIdx = content.indexOf(TOC_START)
260 const endIdx = content.indexOf(TOC_END)
261 if (startIdx < 0 || endIdx < 0 || endIdx <= startIdx) return
262
263 // Build TOC entries from all notes except README
264 const notes = await this.listNotes(notebook)
265 const nonReadme = notes.filter(n => n.id !== meta.readmeId)
266 const entries = nonReadme
267 .sort((a, b) => (a.title || '').localeCompare(b.title || ''))
268 .map(n => `- [${n.title || 'Untitled'}](${n.slug || n.id})`)
269
270 const tocContent = entries.length > 0 ? '\n' + entries.join('\n') + '\n' : '\n'
271 const newContent = content.substring(0, startIdx + TOC_START.length) + tocContent + content.substring(endIdx)
272
273 if (newContent !== content) {
274 await this.updateNote(notebook, meta.readmeId, { content: newContent })
275 console.log(`[TOC] Updated ${notebook} README: ${entries.length} entries`)
276 }
277 } catch (err) {
278 console.error(`[TOC] Failed to regenerate for ${notebook}: ${err.message}`)
279 }
280 }
281
75282 /**
76283 * Encrypt title + content of a note if the notebook is encrypted.
77284 */
78285 _encryptNote (note, slug) {
79 const key = this._keyCache.get(slug)
286 const key = this._getCachedKey(slug)
80287 if (!key || !this.crypto) return note
81288
82289 return {
……@@ -93,7 +300,7 @@ export class NoteManager {
93300 _decryptNote (note, slug) {
94301 if (!note.encrypted) return note
95302
96 const key = this._keyCache.get(slug)
303 const key = this._getCachedKey(slug)
97304 if (!key || !this.crypto) return note
98305
99306 return {
……@@ -157,6 +364,23 @@ export class NoteManager {
157364 }
158365
159366 await drive.put('/metadata.json', Buffer.from(JSON.stringify(metadata, null, 2)))
367
368 // If linked, create SharedNotebook immediately so it syncs
369 if (this.multiwriter && this.multiwriter.registry) {
370 try {
371 await this.multiwriter.shareNotebook(slug)
372 } catch (err) {
373 console.error('[NoteManager] Failed to create SharedNotebook for ' + slug + ':', err.message)
374 }
375 }
376
377 // Create README note for the new notebook
378 try {
379 await this.createReadme(slug)
380 } catch (err) {
381 console.error('[NoteManager] Failed to create README for ' + slug + ':', err.message)
382 }
383
160384 return metadata
161385 }
162386
……@@ -179,6 +403,13 @@ export class NoteManager {
179403 meta.driveKey = info.driveKey
180404 }
181405
406 // Add shared flag if SharedNotebook exists
407 const snb = this._getSharedNotebook(slug)
408 if (snb) {
409 meta.shared = true
410 meta.autobaseKey = snb.bootstrapKey.toString('hex')
411 }
412
182413 return meta
183414 }
184415
……@@ -199,6 +430,10 @@ export class NoteManager {
199430 const meta = JSON.parse(buf.toString())
200431 meta.isLocal = entry.isLocal
201432 meta.driveKey = entry.driveKey
433 const snb = this._getSharedNotebook(entry.slug)
434 if (snb) {
435 meta.shared = true
436 }
202437 notebooks.push(meta)
203438 } else {
204439 console.error(`[NoteManager] No metadata.json in drive: ${entry.slug}`)
……@@ -286,29 +521,39 @@ export class NoteManager {
286521 // Create new drive
287522 const newDrive = await this.driveManager.createDrive(newSlug)
288523
289 // Copy metadata with new slug
290 const now = Date.now()
291 const newMeta = { ...oldMeta, slug: newSlug, updatedAt: now }
292 delete newMeta.isLocal
293 delete newMeta.driveKey
294 await newDrive.put('/metadata.json', Buffer.from(JSON.stringify(newMeta, null, 2)))
295
296 // Copy all notes
297 const oldDrive = await this._getDrive(oldSlug)
298524 try {
299 const noteFiles = await oldDrive.readdir('/notes')
300 for await (const file of noteFiles) {
301 const buf = await oldDrive.get(`/notes/${file}`)
302 if (buf) {
303 await newDrive.put(`/notes/${file}`, buf)
525 // Copy metadata with new slug
526 const now = Date.now()
527 const newMeta = { ...oldMeta, slug: newSlug, updatedAt: now }
528 delete newMeta.isLocal
529 delete newMeta.driveKey
530 await newDrive.put('/metadata.json', Buffer.from(JSON.stringify(newMeta, null, 2)))
531
532 // Copy all notes
533 const oldDrive = await this._getDrive(oldSlug)
534 try {
535 const noteFiles = await oldDrive.readdir('/notes')
536 for await (const file of noteFiles) {
537 const buf = await oldDrive.get(`/notes/${file}`)
538 if (buf) {
539 await newDrive.put(`/notes/${file}`, buf)
540 }
304541 }
542 } catch {
543 // no notes dir
305544 }
306 } catch {
307 // no notes dir
308 }
309545
310 // Delete old notebook
311 await this.deleteNotebook(oldSlug)
546 // Delete old notebook
547 await this.deleteNotebook(oldSlug)
548 } catch (err) {
549 // Rollback: clean up the partially-created new drive
550 try {
551 await this.driveManager.deleteDrive(newSlug)
552 } catch (cleanupErr) {
553 console.error('[NoteManager] Rollback cleanup failed for ' + newSlug + ':', cleanupErr.message)
554 }
555 throw err
556 }
312557
313558 // Return with transient fields
314559 newMeta.isLocal = true
……@@ -321,9 +566,52 @@ export class NoteManager {
321566 // ==================== NOTE METHODS ====================
322567
323568 async createNote (notebook, { title, content, tags, type } = {}) {
324 this._requireLocal(notebook)
325569 this.validateTitle(title)
326570
571 const noteType = (type === 'spreadsheet') ? 'spreadsheet' : 'markdown'
572
573 // Generate slug from title
574 const baseSlug = slugify(title) || 'untitled'
575 const noteSlug = await this._ensureUniqueSlug(notebook, baseSlug, null)
576
577 // SharedNotebook path (multi-writer)
578 const snb = this._getSharedNotebook(notebook)
579 if (snb) {
580 const { noteId } = await snb.createNote({
581 title,
582 slug: noteSlug,
583 author: 'user',
584 tags: Array.isArray(tags) ? tags : [],
585 type: noteType
586 })
587 if (content) {
588 if (noteType === 'spreadsheet') {
589 await snb.applySpreadsheetUpdate(noteId, content, 'user')
590 } else {
591 const Y = (await import('yjs'))
592 const doc = new Y.Doc()
593 doc.getText('content').insert(0, content)
594 await snb.applyYjsUpdate(noteId, Y.encodeStateAsUpdate(doc), 'user')
595 }
596 }
597 const result = {
598 id: noteId,
599 slug: noteSlug,
600 title,
601 content: content || '',
602 tags: Array.isArray(tags) ? tags : [],
603 type: noteType,
604 createdAt: Date.now(),
605 updatedAt: Date.now()
606 }
607 // Regenerate TOC in background
608 this.regenerateToc(notebook)
609 return result
610 }
611
612 // Existing Hyperdrive path
613 this._requireLocal(notebook)
614
327615 const meta = await this.getNotebook(notebook)
328616 if (!meta) {
329617 throw new NotFoundError(`Notebook '${notebook}' not found`, 'notebook')
……@@ -334,10 +622,11 @@ export class NoteManager {
334622 const now = Date.now()
335623 let note = {
336624 id,
625 slug: noteSlug,
337626 title,
338627 content: content || '',
339628 tags: Array.isArray(tags) ? tags : [],
340 type: type || 'markdown',
629 type: noteType,
341630 createdAt: now,
342631 updatedAt: now
343632 }
……@@ -360,10 +649,33 @@ export class NoteManager {
360649 await this.linkManager.updateLinks(notebook, id, note.content)
361650 }
362651
652 // Regenerate TOC in background
653 this.regenerateToc(notebook)
654
363655 return note // Return plaintext to caller
364656 }
365657
366658 async getNote (notebook, noteId) {
659 // SharedNotebook path (multi-writer)
660 const snb = this._getSharedNotebook(notebook)
661 if (snb) {
662 const note = await snb.getNote(noteId)
663 if (!note) return null
664 let content
665 if (note.type === 'spreadsheet') {
666 content = await snb.getSpreadsheetContent(noteId)
667 } else {
668 const doc = await snb.reconstructDoc(noteId)
669 content = doc.getText('content').toString()
670 }
671 const result = { ...note, id: note.noteId, content }
672 if (!result.slug) {
673 result.slug = slugify(result.title) || 'untitled-' + noteId.substring(0, 8)
674 }
675 return result
676 }
677
678 // Existing Hyperdrive path
367679 const drive = await this.driveManager.getDrive(notebook)
368680 if (!drive) return null
369681
……@@ -375,10 +687,62 @@ export class NoteManager {
375687 if (!buf) return null
376688
377689 const note = JSON.parse(buf.toString())
378 return this._decryptNote(note, notebook)
690 const decrypted = this._decryptNote(note, notebook)
691 // Lazy slug migration
692 if (!decrypted.slug) {
693 decrypted.slug = slugify(decrypted.title) || 'untitled-' + (decrypted.id || '').substring(0, 8)
694 }
695 return decrypted
379696 }
380697
381698 async listNotes (notebook, { limit, offset } = {}) {
699 // SharedNotebook path (multi-writer)
700 const snb = this._getSharedNotebook(notebook)
701 if (snb) {
702 const notes = await snb.listNotes()
703 const enriched = []
704 for (const note of notes) {
705 try {
706 let content
707 if (note.type === 'spreadsheet') {
708 content = await snb.getSpreadsheetContent(note.noteId)
709 } else {
710 const doc = await snb.reconstructDoc(note.noteId)
711 content = doc.getText('content').toString()
712 }
713 const n = {
714 id: note.noteId,
715 slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
716 title: note.title,
717 content,
718 tags: note.tags || [],
719 type: note.type || 'markdown',
720 createdAt: note.createdAt,
721 updatedAt: note.updatedAt
722 }
723 enriched.push(n)
724 } catch {
725 enriched.push({
726 id: note.noteId,
727 slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
728 title: note.title,
729 content: '',
730 tags: note.tags || [],
731 type: note.type || 'markdown',
732 createdAt: note.createdAt,
733 updatedAt: note.updatedAt
734 })
735 }
736 }
737 // Deduplicate slugs
738 this._deduplicateSlugs(enriched)
739 enriched.sort((a, b) => b.updatedAt - a.updatedAt)
740 const start = offset || 0
741 const end = limit ? start + limit : enriched.length
742 return enriched.slice(start, end)
743 }
744
745 // Existing Hyperdrive path
382746 const meta = await this.getNotebook(notebook)
383747 if (!meta) {
384748 throw new NotFoundError(`Notebook '${notebook}' not found`, 'notebook')
……@@ -386,6 +750,7 @@ export class NoteManager {
386750
387751 const drive = await this._getDrive(notebook)
388752 const notes = []
753 let needsPersist = false
389754
390755 try {
391756 const files = await drive.readdir('/notes')
……@@ -394,13 +759,30 @@ export class NoteManager {
394759 const buf = await drive.get(`/notes/${file}`)
395760 if (buf) {
396761 const note = JSON.parse(buf.toString())
397 notes.push(this._decryptNote(note, notebook))
762 const decrypted = this._decryptNote(note, notebook)
763 // Lazy slug migration
764 if (!decrypted.slug) {
765 decrypted.slug = slugify(decrypted.title) || 'untitled-' + (decrypted.id || '').substring(0, 8)
766 needsPersist = true
767 }
768 notes.push(decrypted)
398769 }
399770 }
400771 } catch {
401772 return []
402773 }
403774
775 // Deduplicate slugs
776 this._deduplicateSlugs(notes)
777
778 // Persist slugs lazily if any were missing (fire-and-forget)
779 if (needsPersist && !this.driveManager.isRemote(notebook)) {
780 for (const note of notes) {
781 const stored = (meta && meta.encrypted) ? this._encryptNote(note, notebook) : note
782 drive.put(`/notes/${note.id}.json`, Buffer.from(JSON.stringify(stored, null, 2))).catch(() => {})
783 }
784 }
785
404786 // Sort by updatedAt desc
405787 notes.sort((a, b) => b.updatedAt - a.updatedAt)
406788
……@@ -410,16 +792,79 @@ export class NoteManager {
410792 return notes.slice(start, end)
411793 }
412794
795 /**
796 * Deduplicate slugs within a list of notes in-place.
797 * Appends -2, -3 etc. to duplicate slugs.
798 */
799 _deduplicateSlugs (notes) {
800 const seen = new Map()
801 for (const note of notes) {
802 if (!note.slug) continue
803 if (seen.has(note.slug)) {
804 let i = 2
805 while (seen.has(note.slug + '-' + i)) i++
806 note.slug = note.slug + '-' + i
807 }
808 seen.set(note.slug, true)
809 }
810 }
811
413812 async updateNote (notebook, noteId, updates) {
813 const titleChanged = updates.title !== undefined
814
815 // SharedNotebook path (multi-writer)
816 const snb = this._getSharedNotebook(notebook)
817 if (snb) {
818 if (titleChanged) {
819 this.validateTitle(updates.title)
820 await snb.updateNoteTitle(noteId, updates.title, 'user')
821 }
822 if (updates.content !== undefined) {
823 // Check note type to determine storage strategy
824 const existingNote = await snb.getNote(noteId)
825 if (existingNote && existingNote.type === 'spreadsheet') {
826 await snb.applySpreadsheetUpdate(noteId, updates.content, 'user')
827 } else {
828 const Y = (await import('yjs'))
829 const doc = await snb.reconstructDoc(noteId)
830 const text = doc.getText('content')
831 doc.transact(() => {
832 text.delete(0, text.length)
833 text.insert(0, updates.content)
834 })
835 await snb.applyYjsUpdate(noteId, Y.encodeStateAsUpdate(doc), 'user')
836 }
837 }
838 const note = await snb.getNote(noteId)
839 if (!note) {
840 throw new NotFoundError("Note '" + noteId + "' not found", 'note')
841 }
842 let content
843 if (note.type === 'spreadsheet') {
844 content = await snb.getSpreadsheetContent(noteId)
845 } else {
846 const doc = await snb.reconstructDoc(noteId)
847 content = doc.getText('content').toString()
848 }
849 const result = { ...note, id: note.noteId, content }
850 // Regenerate TOC if title changed
851 if (titleChanged) this.regenerateToc(notebook)
852 return result
853 }
854
855 // Existing Hyperdrive path
414856 this._requireLocal(notebook)
415857 const note = await this.getNote(notebook, noteId) // Already decrypted
416858 if (!note) {
417859 throw new NotFoundError(`Note '${noteId}' not found in '${notebook}'`, 'note')
418860 }
419861
420 if (updates.title !== undefined) {
862 if (titleChanged) {
421863 this.validateTitle(updates.title)
422864 note.title = updates.title
865 // Regenerate slug from new title
866 const newBaseSlug = slugify(updates.title) || 'untitled'
867 note.slug = await this._ensureUniqueSlug(notebook, newBaseSlug, noteId)
423868 }
424869 if (updates.content !== undefined) note.content = updates.content
425870 if (updates.tags !== undefined) note.tags = Array.isArray(updates.tags) ? updates.tags : []
……@@ -437,10 +882,23 @@ export class NoteManager {
437882 await this.linkManager.updateLinks(notebook, noteId, note.content)
438883 }
439884
885 // Regenerate TOC if title changed
886 if (titleChanged) this.regenerateToc(notebook)
887
440888 return note // Return plaintext
441889 }
442890
443891 async deleteNote (notebook, noteId) {
892 // SharedNotebook path (multi-writer)
893 const snb = this._getSharedNotebook(notebook)
894 if (snb) {
895 await snb.deleteNote(noteId, 'user')
896 // Regenerate TOC in background
897 this.regenerateToc(notebook)
898 return { deleted: true, id: noteId }
899 }
900
901 // Existing Hyperdrive path
444902 this._requireLocal(notebook)
445903 const note = await this.getNote(notebook, noteId)
446904 if (!note) {
……@@ -455,17 +913,26 @@ export class NoteManager {
455913 await this.linkManager.removeAllLinks(notebook, noteId)
456914 }
457915
458 // Decrement noteCount
916 // Decrement noteCount and handle README deletion
459917 const meta = await this.getNotebook(notebook)
460918 if (meta) {
461919 meta.noteCount = Math.max(0, (meta.noteCount || 1) - 1)
462920 meta.updatedAt = Date.now()
921 // If deleting the README, clear readmeId
922 if (meta.readmeId === noteId) {
923 delete meta.readmeId
924 }
463925 const toStore = { ...meta }
464926 delete toStore.isLocal
465927 delete toStore.driveKey
466928 await drive.put('/metadata.json', Buffer.from(JSON.stringify(toStore, null, 2)))
467929 }
468930
931 // Regenerate TOC in background (only if we didn't just delete the README)
932 if (!meta || meta.readmeId !== noteId) {
933 this.regenerateToc(notebook)
934 }
935
469936 return { deleted: true, id: noteId }
470937 }
471938
……@@ -619,14 +1086,53 @@ export class NoteManager {
6191086 * Get recent notes across all public notebooks
6201087 * Returns notes with notebook metadata attached
6211088 */
1089 // TODO: streaming pagination — currently loads all notes into memory
6221090 async recentNotes ({ limit = 20 } = {}) {
6231091 const allNotes = []
6241092 const notebooks = await this.listNotebooks()
1093 // Collect at most limit*2 notes per notebook to reduce memory usage
1094 const perNotebookCap = limit * 2
6251095
6261096 for (const nb of notebooks) {
6271097 // Skip private and encrypted notebooks
6281098 if (nb.visibility === 'private' || nb.encrypted) continue
6291099
1100 // Try SharedNotebook first
1101 const snb = this._getSharedNotebook(nb.slug)
1102 if (snb) {
1103 try {
1104 const notes = await snb.listNotes()
1105 let count = 0
1106 for (const note of notes) {
1107 if (count >= perNotebookCap) break
1108 try {
1109 let content
1110 if (note.type === 'spreadsheet') {
1111 content = await snb.getSpreadsheetContent(note.noteId)
1112 } else {
1113 const doc = await snb.reconstructDoc(note.noteId)
1114 content = doc.getText('content').toString()
1115 }
1116 allNotes.push({
1117 id: note.noteId,
1118 slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
1119 title: note.title,
1120 content,
1121 tags: note.tags || [],
1122 type: note.type || 'markdown',
1123 createdAt: note.createdAt,
1124 updatedAt: note.updatedAt,
1125 notebook: nb.slug,
1126 notebookName: nb.name,
1127 owner: nb.ownerId || null
1128 })
1129 count++
1130 } catch {}
1131 }
1132 } catch {}
1133 continue // Skip Hyperdrive path for this notebook
1134 }
1135
6301136 const drive = await this.driveManager.getDrive(nb.slug)
6311137 if (!drive) continue
6321138
……@@ -637,7 +1143,9 @@ export class NoteManager {
6371143 continue
6381144 }
6391145
1146 let count = 0
6401147 for await (const file of files) {
1148 if (count >= perNotebookCap) break
6411149 if (!file.endsWith('.json')) continue
6421150 const buf = await drive.get(`/notes/${file}`)
6431151 if (!buf) continue
……@@ -645,15 +1153,18 @@ export class NoteManager {
6451153 const note = JSON.parse(buf.toString())
6461154 allNotes.push({
6471155 id: note.id,
1156 slug: note.slug || slugify(note.title) || 'untitled-' + (note.id || '').substring(0, 8),
6481157 title: note.title,
6491158 content: note.content,
6501159 tags: note.tags || [],
1160 type: note.type || 'markdown',
6511161 createdAt: note.createdAt,
6521162 updatedAt: note.updatedAt,
6531163 notebook: nb.slug,
6541164 notebookName: nb.name,
6551165 owner: nb.ownerId || null
6561166 })
1167 count++
6571168 }
6581169 }
6591170
……@@ -663,7 +1174,8 @@ export class NoteManager {
6631174
6641175 // ==================== SEARCH ====================
6651176
666 async searchNotes (query, { notebook } = {}) {
1177 // TODO: streaming pagination — currently loads all matching notes into memory
1178 async searchNotes (query, { notebook, maxResults = 100 } = {}) {
6671179 if (!query || typeof query !== 'string') {
6681180 throw new ValidationError('Search query is required', 'query')
6691181 }
……@@ -673,9 +1185,49 @@ export class NoteManager {
6731185 const notebooks = notebook ? [notebook] : await this._listNotebookSlugs()
6741186
6751187 for (const slug of notebooks) {
1188 if (results.length >= maxResults) break
1189
6761190 const drive = await this.driveManager.getDrive(slug)
6771191 if (!drive) continue
6781192
1193 // Try SharedNotebook first
1194 const snb = this._getSharedNotebook(slug)
1195 if (snb) {
1196 try {
1197 const notes = await snb.listNotes()
1198 for (const note of notes) {
1199 if (results.length >= maxResults) break
1200 try {
1201 let content
1202 if (note.type === 'spreadsheet') {
1203 content = await snb.getSpreadsheetContent(note.noteId)
1204 } else {
1205 const doc = await snb.reconstructDoc(note.noteId)
1206 content = doc.getText('content').toString()
1207 }
1208 const titleMatch = note.title && note.title.toLowerCase().includes(q)
1209 const contentMatch = content && content.toLowerCase().includes(q)
1210 if (titleMatch || contentMatch) {
1211 const searchIn = contentMatch ? content : note.title
1212 const idx = searchIn.toLowerCase().indexOf(q)
1213 const start = Math.max(0, idx - 40)
1214 const end = Math.min(searchIn.length, idx + query.length + 40)
1215 const snippet = (start > 0 ? '...' : '') + searchIn.slice(start, end) + (end < searchIn.length ? '...' : '')
1216 results.push({
1217 notebook: slug,
1218 id: note.noteId,
1219 slug: note.slug || slugify(note.title) || 'untitled-' + note.noteId.substring(0, 8),
1220 title: note.title,
1221 snippet,
1222 updatedAt: note.updatedAt
1223 })
1224 }
1225 } catch {}
1226 }
1227 } catch {}
1228 continue // Skip Hyperdrive path
1229 }
1230
6791231 let files
6801232 try {
6811233 files = await drive.readdir('/notes')
……@@ -684,6 +1236,7 @@ export class NoteManager {
6841236 }
6851237
6861238 for await (const file of files) {
1239 if (results.length >= maxResults) break
6871240 if (!file.endsWith('.json')) continue
6881241 const buf = await drive.get(`/notes/${file}`)
6891242 if (!buf) continue
……@@ -691,12 +1244,13 @@ export class NoteManager {
6911244 const raw = JSON.parse(buf.toString())
6921245
6931246 // For encrypted notebooks without unlocked key, only search tags
694 if (raw.encrypted && !this._keyCache.has(slug)) {
1247 if (raw.encrypted && !this.isUnlocked(slug)) {
6951248 const tagMatch = raw.tags && raw.tags.some(t => t.toLowerCase().includes(q))
6961249 if (tagMatch) {
6971250 results.push({
6981251 notebook: slug,
6991252 id: raw.id,
1253 slug: raw.slug || 'encrypted-' + (raw.id || '').substring(0, 8),
7001254 title: '(encrypted)',
7011255 snippet: `Tags: ${raw.tags.join(', ')}`,
7021256 updatedAt: raw.updatedAt
……@@ -720,6 +1274,7 @@ export class NoteManager {
7201274 results.push({
7211275 notebook: slug,
7221276 id: note.id,
1277 slug: note.slug || slugify(note.title) || 'untitled-' + (note.id || '').substring(0, 8),
7231278 title: note.title,
7241279 snippet,
7251280 updatedAt: note.updatedAt