210 lines
5.8 KiB
TypeScript
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();
|
|
}
|
|
}
|