/**
* Per-notebook Autobase apply function.
*
* Processes: create-note, update-note-title, delete-note,
* yjs-update, yjs-snapshot, add-media, remove-media,
* add-writer
*/
export function encodeOp (op) {
return Buffer.from(JSON.stringify(op), 'utf-8')
}
export function decodeOp (buf) {
return JSON.parse(buf.toString('utf-8'))
}
export async function notebookApply (batch, view, base) {
const b = view.batch()
const gcEvents = []
for (const node of batch) {
const op = decodeOp(node.value)
switch (op.type) {
case 'add-writer': {
const key = typeof op.writerKey === 'string'
? Buffer.from(op.writerKey, 'hex')
: op.writerKey
await base.addWriter(key, { indexer: true })
break
}
case 'create-note': {
const existing = await view.get(`note:${op.noteId}`)
if (existing) break
await b.put(`note:${op.noteId}`, {
noteId: op.noteId,
title: op.title || 'Untitled',
slug: op.slug || '',
author: op.author,
tags: op.tags || [],
type: op.noteType || 'markdown',
createdAt: op.timestamp,
updatedAt: op.timestamp
})
break
}
case 'update-note-title': {
const note = await view.get(`note:${op.noteId}`)
if (!note) break
await b.put(`note:${op.noteId}`, {
...note.value,
title: op.title,
slug: op.slug || note.value.slug || '',
updatedAt: op.timestamp
})
break
}
case 'set-readme': {
await b.put('notebook:readmeId', { readmeId: op.readmeId, updatedAt: op.timestamp })
break
}
case 'delete-note': {
// Collect noteId for GC — content will be scanned by MediaStore
gcEvents.push({ noteId: op.noteId, type: 'note-deleted' })
await b.del(`note:${op.noteId}`)
break
}
case 'yjs-update': {
const counterEntry = await view.get(`yjs-counter:${op.noteId}`)
const seq = counterEntry ? counterEntry.value.seq : 0
await b.put(`yjs:${op.noteId}:${seq}`, {
update: op.update,
author: op.author,
timestamp: op.timestamp
})
await b.put(`yjs-counter:${op.noteId}`, { seq: seq + 1 })
break
}
case 'yjs-snapshot': {
await b.put(`yjs-snapshot:${op.noteId}`, {
state: op.state,
timestamp: op.timestamp
})
break
}
case 'add-media': {
const key = op.noteId ? `media:${op.noteId}:${op.hash}` : `media:_:${op.hash}`
await b.put(key, {
hash: op.hash,
mimeType: op.mimeType,
filename: op.filename,
size: op.size,
blobId: op.blobId || null,
author: op.author,
addedAt: op.timestamp
})
// Also index by filename for blob lookup
if (op.filename) {
await b.put(`media-file:${op.filename}`, {
hash: op.hash,
noteId: op.noteId || null,
blobId: op.blobId || null,
mimeType: op.mimeType
})
}
break
}
case 'remove-media': {
gcEvents.push({ hash: op.hash, type: 'media-removed' })
await b.del(`media:${op.noteId}:${op.hash}`)
break
}
}
}
await b.flush()
// Emit GC events for push-based garbage collection
if (gcEvents.length > 0 && base.emit) {
try {
base.emit('media-gc', gcEvents)
} catch {}
}
}
/**
* Extract media hashes/identifiers from markdown note content.
* Matches both old-style filenames and new SHA-256 hashes in media URLs.
*/
export function extractMediaHashes (content) {
const hashes = new Set()
if (!content) return hashes
// Match 
const urlPattern = /!\[[^\]]*\]\([^)]*\/media\/([a-zA-Z0-9._-]+)\)/g
let match
while ((match = urlPattern.exec(content)) !== null) {
hashes.add(match[1])
}
// Match bare hash references  where HASH is 64 hex chars
const hashPattern = /!\[[^\]]*\]\(([a-f0-9]{64})\)/g
while ((match = hashPattern.exec(content)) !== null) {
hashes.add(match[1])
}
return hashes
}