rspace-online/modules/rnotes/local-first-client.ts

210 lines
5.8 KiB
TypeScript

/**
* rNotes Local-First Client
*
* Wraps the shared local-first stack (DocSyncManager + EncryptedDocStore)
* into a notes-specific API. This replaces the manual WebSocket + REST
* approach in folk-notes-app with proper offline support and encryption.
*
* Usage:
* const client = new NotesLocalFirstClient(space);
* await client.init();
* const notebooks = client.listNotebooks();
* client.onChange(docId, (doc) => { ... });
* client.disconnect();
*/
import * as Automerge from '@automerge/automerge';
import { DocumentManager } from '../../shared/local-first/document';
import type { DocumentId } from '../../shared/local-first/document';
import { EncryptedDocStore } from '../../shared/local-first/storage';
import { DocSyncManager } from '../../shared/local-first/sync';
import { DocCrypto } from '../../shared/local-first/crypto';
import { notebookSchema, notebookDocId } from './schemas';
import type { NotebookDoc, NoteItem, NotebookMeta } from './schemas';
export class NotesLocalFirstClient {
#space: string;
#documents: DocumentManager;
#store: EncryptedDocStore;
#sync: DocSyncManager;
#initialized = false;
constructor(space: string, docCrypto?: DocCrypto) {
this.#space = space;
this.#documents = new DocumentManager();
this.#store = new EncryptedDocStore(space, docCrypto);
this.#sync = new DocSyncManager({
documents: this.#documents,
store: this.#store,
});
// Register the notebook schema
this.#documents.registerSchema(notebookSchema);
}
get isConnected(): boolean { return this.#sync.isConnected; }
get isInitialized(): boolean { return this.#initialized; }
/**
* Initialize: open IndexedDB, load cached docs, connect to sync server.
*/
async init(): Promise<void> {
if (this.#initialized) return;
// Open IndexedDB store
await this.#store.open();
// Load any cached notebook docs from IndexedDB
const cachedIds = await this.#store.listByModule('notes', 'notebooks');
for (const docId of cachedIds) {
const binary = await this.#store.load(docId);
if (binary) {
this.#documents.open<NotebookDoc>(docId, notebookSchema, binary);
}
}
// Connect to sync server
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${proto}//${location.host}/ws/${this.#space}`;
try {
await this.#sync.connect(wsUrl, this.#space);
} catch {
console.warn('[NotesClient] WebSocket connection failed, working offline');
}
this.#initialized = true;
}
/**
* Subscribe to a specific notebook doc for real-time sync.
*/
async subscribeNotebook(notebookId: string): Promise<NotebookDoc | null> {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
// Open or get existing doc
let doc = this.#documents.get<NotebookDoc>(docId);
if (!doc) {
// Try loading from IndexedDB
const binary = await this.#store.load(docId);
if (binary) {
doc = this.#documents.open<NotebookDoc>(docId, notebookSchema, binary);
} else {
// Create empty placeholder — server will fill via sync
doc = this.#documents.open<NotebookDoc>(docId, notebookSchema);
}
}
// Subscribe for sync
await this.#sync.subscribe([docId]);
return doc ?? null;
}
/**
* Unsubscribe from a notebook's sync.
*/
unsubscribeNotebook(notebookId: string): void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
this.#sync.unsubscribe([docId]);
}
/**
* Get a notebook doc (already opened).
*/
getNotebook(notebookId: string): NotebookDoc | undefined {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
return this.#documents.get<NotebookDoc>(docId);
}
/**
* List all notebook IDs for this space.
*/
listNotebookIds(): string[] {
return this.#documents.list(this.#space, 'notes');
}
/**
* Update a note within a notebook (creates if it doesn't exist).
*/
updateNote(notebookId: string, noteId: string, changes: Partial<NoteItem>): void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
this.#sync.change<NotebookDoc>(docId, `Update note ${noteId}`, (d) => {
if (!d.items[noteId]) {
d.items[noteId] = {
id: noteId,
notebookId,
authorId: null,
title: '',
content: '',
contentPlain: '',
type: 'NOTE',
url: null,
language: null,
fileUrl: null,
mimeType: null,
fileSize: null,
duration: null,
isPinned: false,
sortOrder: 0,
tags: [],
createdAt: Date.now(),
updatedAt: Date.now(),
...changes,
};
} else {
const item = d.items[noteId];
Object.assign(item, changes);
item.updatedAt = Date.now();
}
});
}
/**
* Delete a note from a notebook.
*/
deleteNote(notebookId: string, noteId: string): void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
this.#sync.change<NotebookDoc>(docId, `Delete note ${noteId}`, (d) => {
delete d.items[noteId];
});
}
/**
* Update notebook metadata.
*/
updateNotebook(notebookId: string, changes: Partial<NotebookMeta>): void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
this.#sync.change<NotebookDoc>(docId, 'Update notebook', (d) => {
Object.assign(d.notebook, changes);
d.notebook.updatedAt = Date.now();
});
}
/**
* Listen for changes to a notebook doc.
*/
onChange(notebookId: string, cb: (doc: NotebookDoc) => void): () => void {
const docId = notebookDocId(this.#space, notebookId) as DocumentId;
return this.#sync.onChange(docId, cb as (doc: any) => void);
}
/**
* Listen for connection/disconnection events.
*/
onConnect(cb: () => void): () => void {
return this.#sync.onConnect(cb);
}
onDisconnect(cb: () => void): () => void {
return this.#sync.onDisconnect(cb);
}
/**
* Flush all pending saves to IndexedDB and disconnect.
*/
async disconnect(): Promise<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}