OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / lib / multiwriter / notebook-apply.js

157 lines · javascript ref:
/**
 * 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 ![...](/api/notebooks/.../media/IDENTIFIER)
  const urlPattern = /!\[[^\]]*\]\([^)]*\/media\/([a-zA-Z0-9._-]+)\)/g
  let match
  while ((match = urlPattern.exec(content)) !== null) {
    hashes.add(match[1])
  }

  // Match bare hash references ![...](HASH) 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
}