/**
* 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()
}
}