OverviewFilesCommitsIssuesDocsReleasesPeers

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

381 lines · javascript ref:
/**
 * SharedNotebook — wraps a per-notebook Autobase with methods for
 * note CRUD, Yjs updates, and media metadata.
 */

import Autobase from 'autobase'
import Hyperbee from 'hyperbee'
import Hyperblobs from 'hyperblobs'
import crypto from 'bare-crypto'
import b4a from 'b4a'
import * as Y from 'yjs'
import { encodeOp, notebookApply } from './notebook-apply.js'

function generateNoteId () {
  return b4a.toString(crypto.randomBytes(8), 'hex')
}

export class SharedNotebook {
  constructor (corestore, slug, opts = {}) {
    this.slug = slug
    this._store = corestore.namespace('shared-notebook-' + slug)
    this._docs = new Map()
    this._closed = false

    const handlers = {
      open (store) {
        return new Hyperbee(store.get('view'), {
          keyEncoding: 'utf-8',
          valueEncoding: 'json'
        })
      },
      apply: notebookApply
    }

    if (opts.bootstrapKey) {
      this._base = new Autobase(this._store, opts.bootstrapKey, handlers)
    } else {
      this._base = new Autobase(this._store, null, handlers)
    }

    // Local blob store — each instance gets its own writable core for media
    this._blobCore = this._store.get({ name: 'local-blobs' })
    this._blobs = new Hyperblobs(this._blobCore)
    this._remoteBlobsCache = new Map() // hex key -> Hyperblobs
  }

  async ready () {
    await this._base.ready()
    await this._blobCore.ready()
  }

  get bootstrapKey () {
    return this._base.key
  }

  get localWriterKey () {
    return this._base.local.key
  }

  get view () {
    return this._base.view
  }

  async update () {
    await this._base.update()
  }

  replicate (stream) {
    return this._store.replicate(stream)
  }

  async addWriter (writerKey) {
    const key = typeof writerKey === 'string'
      ? b4a.from(writerKey, 'hex')
      : writerKey
    await this._base.append(encodeOp({
      type: 'add-writer',
      writerKey: b4a.toString(key, 'hex'),
      timestamp: Date.now()
    }))
  }

  // ── Notes ──

  async createNote ({ title, slug, author, tags, type }) {
    const noteId = generateNoteId()
    await this._base.append(encodeOp({
      type: 'create-note',
      noteId,
      title: title || 'Untitled',
      slug: slug || '',
      author: author || 'unknown',
      tags: tags || [],
      noteType: type || 'markdown',
      timestamp: Date.now()
    }))
    await this._base.update()
    return { noteId }
  }

  async importNote ({ noteId, title, content, tags, author, createdAt, updatedAt }) {
    const existing = await this.getNote(noteId)
    if (existing) return existing

    await this._base.append(encodeOp({
      type: 'create-note',
      noteId,
      title: title || 'Untitled',
      author: author || 'migration',
      tags: tags || [],
      timestamp: createdAt || Date.now()
    }))

    if (content) {
      const doc = new Y.Doc()
      const text = doc.getText('content')
      text.insert(0, content)
      const update = Y.encodeStateAsUpdate(doc)
      const base64 = b4a.toString(b4a.from(update), 'base64')
      await this._base.append(encodeOp({
        type: 'yjs-update',
        noteId,
        update: base64,
        author: author || 'migration',
        timestamp: updatedAt || Date.now()
      }))
      this._docs.set(noteId, doc)
    }

    await this._base.update()
    return { noteId, title, imported: true }
  }

  async getNote (noteId) {
    await this._base.update()
    const entry = await this._base.view.get('note:' + noteId)
    return entry ? entry.value : null
  }

  async listNotes () {
    await this._base.update()
    const notes = []
    const stream = this._base.view.createReadStream({ gte: 'note:', lt: 'note;' })
    for await (const entry of stream) {
      notes.push(entry.value)
    }
    return notes
  }

  async updateNoteTitle (noteId, title, author, slug) {
    await this._base.append(encodeOp({
      type: 'update-note-title',
      noteId,
      title,
      slug: slug || '',
      author: author || 'unknown',
      timestamp: Date.now()
    }))
    await this._base.update()
  }

  async setReadmeId (readmeId) {
    await this._base.append(encodeOp({
      type: 'set-readme',
      readmeId,
      timestamp: Date.now()
    }))
    await this._base.update()
  }

  async getReadmeId () {
    await this._base.update()
    const entry = await this._base.view.get('notebook:readmeId')
    return entry ? entry.value.readmeId : null
  }

  async deleteNote (noteId, author) {
    await this._base.append(encodeOp({
      type: 'delete-note',
      noteId,
      author: author || 'unknown',
      timestamp: Date.now()
    }))
    await this._base.update()
  }

  // ── Yjs ──

  async applyYjsUpdate (noteId, update, author) {
    const base64 = b4a.toString(b4a.from(update), 'base64')
    await this._base.append(encodeOp({
      type: 'yjs-update',
      noteId,
      update: base64,
      author: author || 'unknown',
      timestamp: Date.now()
    }))
    await this._base.update()

    // Apply to cached doc if present
    let doc = this._docs.get(noteId)
    if (!doc) {
      doc = new Y.Doc()
      this._docs.set(noteId, doc)
    }
    Y.applyUpdate(doc, update)
  }

  async reconstructDoc (noteId) {
    await this._base.update()
    const doc = new Y.Doc()

    // Apply snapshot if available
    const snapshotEntry = await this._base.view.get('yjs-snapshot:' + noteId)
    if (snapshotEntry) {
      const snapshotBuf = b4a.from(snapshotEntry.value.state, 'base64')
      Y.applyUpdate(doc, snapshotBuf)
    }

    // Apply sequential updates
    const counterEntry = await this._base.view.get('yjs-counter:' + noteId)
    if (counterEntry) {
      const count = counterEntry.value.seq
      for (let i = 0; i < count; i++) {
        const entry = await this._base.view.get('yjs:' + noteId + ':' + i)
        if (entry) {
          const updateBuf = b4a.from(entry.value.update, 'base64')
          Y.applyUpdate(doc, updateBuf)
        }
      }
    }

    this._docs.set(noteId, doc)
    return doc
  }

  getDoc (noteId) {
    return this._docs.get(noteId) || null
  }

  // ── Spreadsheet (Y.Map-based) ──

  /**
   * Apply a spreadsheet update. Parses JSON content, builds a fresh Y.Doc
   * with cell-level Y.Map entries, and stores the full state. This gives
   * per-cell conflict resolution via Yjs CRDT semantics.
   */
  async applySpreadsheetUpdate (noteId, contentJson, author) {
    let parsed
    try {
      parsed = JSON.parse(contentJson)
    } catch {
      parsed = { data: [[]] }
    }

    // Build a fresh doc with the new spreadsheet state
    const doc = new Y.Doc()
    const cells = doc.getMap('cells')
    const meta = doc.getMap('meta')

    doc.transact(() => {
      const data = parsed.data || [[]]
      for (let r = 0; r < data.length; r++) {
        const row = data[r] || []
        for (let c = 0; c < row.length; c++) {
          const val = row[c] !== undefined && row[c] !== null ? String(row[c]) : ''
          if (val) cells.set(r + ':' + c, val)
        }
      }

      meta.set('rows', data.length)
      meta.set('cols', data.length > 0 ? Math.max(...data.map(r => (r || []).length), 0) : 0)
      if (parsed.columns) meta.set('columns', JSON.stringify(parsed.columns))
      if (parsed.options) meta.set('options', JSON.stringify(parsed.options))
    })

    await this.applyYjsUpdate(noteId, Y.encodeStateAsUpdate(doc), author)
  }

  /**
   * Get spreadsheet content as JSON string by reading the Y.Map cells.
   */
  async getSpreadsheetContent (noteId) {
    const doc = await this.reconstructDoc(noteId)
    const cells = doc.getMap('cells')
    const meta = doc.getMap('meta')

    const rows = meta.get('rows') || 0
    const cols = meta.get('cols') || 0

    const data = []
    for (let r = 0; r < rows; r++) {
      const row = []
      for (let c = 0; c < cols; c++) {
        row.push(cells.get(r + ':' + c) || '')
      }
      data.push(row)
    }

    const result = { data }
    const columnsStr = meta.get('columns')
    if (columnsStr) {
      try { result.columns = JSON.parse(columnsStr) } catch {}
    }
    const optionsStr = meta.get('options')
    if (optionsStr) {
      try { result.options = JSON.parse(optionsStr) } catch {}
    }

    return JSON.stringify(result)
  }

  // ── Media ──

  async addMedia (noteId, { hash, mimeType, filename, size, blobId }, author) {
    await this._base.append(encodeOp({
      type: 'add-media',
      noteId,
      hash,
      mimeType,
      filename,
      size,
      blobId: blobId || null,
      author: author || 'unknown',
      timestamp: Date.now()
    }))
    await this._base.update()
  }

  async getMedia (noteId, hash) {
    await this._base.update()
    const key = 'media:' + (noteId || '_') + ':' + hash
    const entry = await this._base.view.get(key)
    return entry ? entry.value : null
  }

  async storeBlob (buffer) {
    const id = await this._blobs.put(buffer)
    // Include the blob core key so other instances can find this data
    id.coreKey = this._blobCore.key.toString('hex')
    return id
  }

  async getBlob (id) {
    // If blob is on a remote core, reuse cached Hyperblobs session
    if (id.coreKey) {
      const keyHex = typeof id.coreKey === 'string' ? id.coreKey : id.coreKey.toString('hex')
      const keyBuf = typeof id.coreKey === 'string' ? Buffer.from(id.coreKey, 'hex') : id.coreKey
      if (!this._blobCore.key.equals(keyBuf)) {
        let remoteBlobs = this._remoteBlobsCache.get(keyHex)
        if (!remoteBlobs) {
          const remoteCore = this._store.get(keyBuf)
          await remoteCore.ready()
          remoteBlobs = new Hyperblobs(remoteCore)
          this._remoteBlobsCache.set(keyHex, remoteBlobs)
        }
        return remoteBlobs.get(id)
      }
    }
    return this._blobs.get(id)
  }

  async removeMedia (noteId, hash, author) {
    await this._base.append(encodeOp({
      type: 'remove-media',
      noteId,
      hash,
      author: author || 'unknown',
      timestamp: Date.now()
    }))
    await this._base.update()
  }

  async close () {
    this._closed = true
    this._docs.clear()
    this._remoteBlobsCache.clear()
    await this._base.close()
  }
}