- Add express-pouchdb for self-hosted PouchDB sync server - Public databases (/db/public/:db) accessible without auth - Add auth middleware for public/private database access - Simplify share button to copy current URL to clipboard - Move feature config from static JSON to dynamic API endpoint - Add PouchDB sync to PouchData class for real-time collaboration - Fix Express 5 compatibility by patching req.query - Skip express.json() for /pouchdb routes (stream handling) - Remove unused PouchdbPersistenceManager and old share system 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
189 lines
7.3 KiB
TypeScript
189 lines
7.3 KiB
TypeScript
import {Observable} from "@babylonjs/core";
|
|
import {DiagramEntity, DiagramEventType} from "../../diagram/types/diagramEntity";
|
|
import {DiagramManager} from "../../diagram/diagramManager";
|
|
import {DiagramEventObserverMask} from "../../diagram/types/diagramEventObserverMask";
|
|
import log, {Logger} from "loglevel";
|
|
import PouchDB from 'pouchdb';
|
|
import {importDiagramFromJSON, DiagramExport} from "../../util/functions/exportDiagramAsJSON";
|
|
import {isPublicPath, getRemoteDbPath} from "../../util/functions/getPath";
|
|
|
|
export class PouchData {
|
|
public readonly onDBEntityUpdateObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
|
public readonly onDBEntityRemoveObservable: Observable<DiagramEntity> = new Observable<DiagramEntity>();
|
|
private _db: PouchDB;
|
|
private _remote: PouchDB;
|
|
private _diagramManager: DiagramManager;
|
|
private _logger: Logger = log.getLogger('PouchData');
|
|
private _dbName: string;
|
|
private _syncHandler: any;
|
|
|
|
constructor(dbname: string) {
|
|
this._db = new PouchDB(dbname);
|
|
this._dbName = dbname;
|
|
|
|
// Start sync for public databases
|
|
this.initSync();
|
|
}
|
|
|
|
/**
|
|
* Initialize sync with remote express-pouchdb for public databases
|
|
*/
|
|
private initSync() {
|
|
const remoteDbPath = getRemoteDbPath();
|
|
const isPublic = isPublicPath();
|
|
|
|
if (!remoteDbPath || !isPublic) {
|
|
this._logger.debug('[Sync] Not a public path, skipping remote sync');
|
|
return;
|
|
}
|
|
|
|
const remoteUrl = `${window.location.origin}/pouchdb/${remoteDbPath}`;
|
|
this._logger.info(`[Sync] Connecting to remote: ${remoteUrl}`);
|
|
|
|
this._remote = new PouchDB(remoteUrl);
|
|
|
|
// Start live bidirectional sync
|
|
this._syncHandler = this._db.sync(this._remote, { live: true, retry: true })
|
|
.on('change', (info) => {
|
|
this._logger.debug('[Sync] Change:', info.direction, info.change.docs.length, 'docs');
|
|
// Process incoming changes
|
|
if (info.direction === 'pull' && info.change && info.change.docs) {
|
|
info.change.docs.forEach((doc) => {
|
|
if (doc._deleted) {
|
|
this.onDBEntityRemoveObservable.notifyObservers(doc);
|
|
} else if (doc.id && doc.id !== 'metadata') {
|
|
this.onDBEntityUpdateObservable.notifyObservers(doc);
|
|
}
|
|
});
|
|
}
|
|
})
|
|
.on('paused', (info) => {
|
|
this._logger.debug('[Sync] Paused - up to date');
|
|
})
|
|
.on('active', () => {
|
|
this._logger.debug('[Sync] Active - syncing');
|
|
})
|
|
.on('error', (err) => {
|
|
this._logger.error('[Sync] Error:', err);
|
|
});
|
|
}
|
|
public setDiagramManager(diagramManager: DiagramManager) {
|
|
this._diagramManager = diagramManager;
|
|
diagramManager.onDiagramEventObservable.add((evt) => {
|
|
this._logger.debug(evt);
|
|
if (!evt?.entity) {
|
|
this._logger.warn('no entity');
|
|
return;
|
|
}
|
|
if (!evt?.entity?.id) {
|
|
this._logger.warn('no entity id');
|
|
return;
|
|
}
|
|
switch (evt.type) {
|
|
case DiagramEventType.REMOVE:
|
|
this.remove(evt.entity.id);
|
|
break;
|
|
case DiagramEventType.ADD:
|
|
case DiagramEventType.MODIFY:
|
|
case DiagramEventType.DROP:
|
|
this.upsert(evt.entity);
|
|
break;
|
|
default:
|
|
this._logger.warn('unknown diagram event type', evt);
|
|
}
|
|
}, DiagramEventObserverMask.TO_DB);
|
|
|
|
this.onDBEntityUpdateObservable.add((evt) => {
|
|
this._logger.debug(evt);
|
|
if (evt.id != 'metadata') {
|
|
diagramManager.onDiagramEventObservable.notifyObservers({
|
|
type: DiagramEventType.ADD,
|
|
entity: evt
|
|
}, DiagramEventObserverMask.FROM_DB);
|
|
} else {
|
|
|
|
}
|
|
});
|
|
this.onDBEntityRemoveObservable.add((entity) => {
|
|
this._logger.debug(entity);
|
|
diagramManager.onDiagramEventObservable.notifyObservers(
|
|
{type: DiagramEventType.REMOVE, entity: entity}, DiagramEventObserverMask.FROM_DB);
|
|
});
|
|
this._db.allDocs({include_docs: true}).then(async (docs) => {
|
|
// Check if this is the demo database and it's empty
|
|
if (this._dbName === 'demo' && docs.rows.length === 0) {
|
|
this._logger.info('Demo database is empty, loading template...');
|
|
await this.loadDemoTemplate();
|
|
// Re-fetch docs after loading template
|
|
const updatedDocs = await this._db.allDocs({include_docs: true});
|
|
updatedDocs.rows.forEach((row) => {
|
|
if (row.doc.id != 'metadata') {
|
|
diagramManager.onDiagramEventObservable.notifyObservers({
|
|
type: DiagramEventType.ADD,
|
|
entity: row.doc
|
|
}, DiagramEventObserverMask.FROM_DB);
|
|
}
|
|
});
|
|
} else {
|
|
docs.rows.forEach((row) => {
|
|
if (row.doc.id != 'metadata') {
|
|
diagramManager.onDiagramEventObservable.notifyObservers({
|
|
type: DiagramEventType.ADD,
|
|
entity: row.doc
|
|
}, DiagramEventObserverMask.FROM_DB);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private async loadDemoTemplate(): Promise<void> {
|
|
try {
|
|
// Fetch the demo template from public/templates/demo.json
|
|
const response = await fetch('/templates/demo.json');
|
|
if (!response.ok) {
|
|
this._logger.error('Failed to fetch demo template:', response.statusText);
|
|
return;
|
|
}
|
|
const templateData: DiagramExport = await response.json();
|
|
|
|
// Import the template into the current database
|
|
await importDiagramFromJSON(templateData, this._dbName);
|
|
this._logger.info('Demo template loaded successfully');
|
|
} catch (error) {
|
|
this._logger.error('Error loading demo template:', error);
|
|
}
|
|
}
|
|
|
|
public async remove(id: string) {
|
|
if (!id) {
|
|
return;
|
|
}
|
|
try {
|
|
const doc = await this._db.get(id);
|
|
await this._db.remove(doc);
|
|
} catch (err) {
|
|
this._logger.error(err);
|
|
}
|
|
}
|
|
|
|
public async upsert(entity: DiagramEntity) {
|
|
if (!entity) {
|
|
return;
|
|
}
|
|
if (entity.template == '#image-template' && !entity.image) {
|
|
this._logger.error('no image data', entity);
|
|
return;
|
|
}
|
|
let doc = null;
|
|
try {
|
|
doc = await this._db.get(entity.id, {conflicts: true, include_docs: true});
|
|
await this._db.put({_id: doc._id, _rev: doc._rev, ...entity});
|
|
} catch (err) {
|
|
await this._db.put({_id: entity.id, ...entity});
|
|
}
|
|
if (doc && doc._conflicts) {
|
|
this._logger.warn('CONFLICTS!', doc._conflicts);
|
|
}
|
|
}
|
|
} |