Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,7 @@ playwright-report/
coverage/

docs/*
!docs/screenshots/
!docs/screenshots/

# Local planning + reports — not part of the codebase
plans/
28 changes: 27 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,28 @@ function App(): JSX.Element {
}
}

// Sync the local "last seen" baseline after applying a WS broadcast so
// syncToBackend's delta detection treats those elements as already in sync
// and doesn't re-upload them. Without this, every MCP-driven create fires a
// follow-up sync/v2 POST per element. Phase 2 (deterministic ids) means
// those POSTs are no-ops on the row, but they still bump sync_version and
// generate broadcast traffic. Hygiene, not correctness.
const applyWsBaseline = (
sceneAfter: readonly any[],
upserted: ServerElement[],
deleted: string[],
syncVersion?: number
): void => {
for (const el of upserted) lastSyncedElementsRef.current.set(el.id, el)
for (const id of deleted) lastSyncedElementsRef.current.delete(id)
lastSyncedHashRef.current = computeElementHash(sceneAfter)
if (typeof syncVersion === 'number') {
lastSyncVersionRef.current = syncVersion
// lastReceivedSyncVersionRef is already updated above, before the switch.
localStorage.setItem('excalidraw-last-sync-version', String(syncVersion))
}
}

const handleWebSocketMessage = async (data: WebSocketMessage): Promise<void> => {
// Gap detection (Task 12): if a message carries sync_version, check for gaps
if (data.sync_version !== undefined && typeof data.sync_version === 'number') {
Expand Down Expand Up @@ -418,10 +440,11 @@ function App(): JSX.Element {
}
const scene = api.getSceneElements()
const landed = scene.some(s => s.id === data.element!.id)
applyWsBaseline(scene, [data.element], [], data.sync_version)
sendAck(data.msgId, landed ? 'applied' : 'failed', landed ? 1 : 0, 1)
}
break

case 'element_updated':
if (data.element) {
const cleanedUpdatedElement = cleanElementForExcalidraw(data.element)
Expand All @@ -433,6 +456,7 @@ function App(): JSX.Element {
elements: updatedElements,
captureUpdate: CaptureUpdateAction.NEVER
})
applyWsBaseline(api.getSceneElements(), [data.element], [], data.sync_version)
sendAck(data.msgId, 'applied', 1, 1)
}
break
Expand All @@ -444,6 +468,7 @@ function App(): JSX.Element {
elements: filteredElements,
captureUpdate: CaptureUpdateAction.NEVER
})
applyWsBaseline(api.getSceneElements(), [], [data.elementId], data.sync_version)
sendAck(data.msgId, 'applied', 1, 1)
}
break
Expand Down Expand Up @@ -472,6 +497,7 @@ function App(): JSX.Element {
const expectedIds = data.elements.map((e: ServerElement) => e.id)
const landedCount = expectedIds.filter(id => scene.some(s => s.id === id)).length
const status = landedCount === expectedIds.length ? 'applied' : landedCount > 0 ? 'partial' : 'failed'
applyWsBaseline(scene, data.elements, [], data.sync_version)
sendAck(data.msgId, status, landedCount, expectedIds.length)
}
break
Expand Down
167 changes: 167 additions & 0 deletions scripts/dedupe-elements.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env node
/**
* One-time cleanup of duplicate elements caused by retried batch_create_elements.
*
* Mirrors the server-side fingerprint in src/server.ts (deriveContentId).
* Groups by fingerprint, keeps the lowest sync_version (the original),
* soft-deletes the rest, bumps project sync_version per survivor so connected
* browsers reconcile via delta sync.
*
* Usage:
* node scripts/dedupe-elements.cjs # dry-run, all projects
* node scripts/dedupe-elements.cjs --execute # apply
* node scripts/dedupe-elements.cjs --project foo # restrict
* node scripts/dedupe-elements.cjs --db /path/excalidraw.db
*/

const path = require('node:path');
const crypto = require('node:crypto');

function fingerprint(projectId, type, x, y, w, h, text, startRefId, endRefId) {
const input = [
projectId,
type,
Math.round(x || 0),
Math.round(y || 0),
Math.round(w || 0),
Math.round(h || 0),
text || '',
startRefId || '',
endRefId || ''
].join('|');
return crypto.createHash('sha256').update(input).digest('hex').slice(0, 12);
}

function elementText(el) {
if (el && el.label && el.label.text) return String(el.label.text);
if (el && typeof el.text === 'string') return el.text;
return '';
}

function elementStartRef(el) { return (el && el.start && el.start.id) || ''; }
function elementEndRef(el) { return (el && el.end && el.end.id) || ''; }

// Pure: returns { groups, totalDups }
// groups :: { [fp]: [{id, sync_version, data}, ...] }
function bucketByFingerprint(rows, projectId) {
const groups = {};
for (const row of rows) {
let data;
try { data = JSON.parse(row.data); } catch { continue; }
const fp = fingerprint(
projectId,
data.type || row.type,
data.x, data.y, data.width, data.height,
elementText(data),
elementStartRef(data),
elementEndRef(data)
);
(groups[fp] = groups[fp] || []).push(row);
}
let dups = 0;
for (const fp of Object.keys(groups)) if (groups[fp].length > 1) dups += groups[fp].length - 1;
return { groups, totalDups: dups };
}

function dedupeProject(db, projectId, execute) {
const rows = db.prepare(
'SELECT id, type, data, sync_version FROM elements WHERE project_id = ? AND is_deleted = 0'
).all(projectId);
const { groups, totalDups } = bucketByFingerprint(rows, projectId);
const dupGroups = Object.values(groups).filter(g => g.length > 1);

const plan = [];
for (const group of dupGroups) {
// Keep lowest sync_version (original)
const sorted = group.slice().sort((a, b) => a.sync_version - b.sync_version);
const keep = sorted[0];
const drop = sorted.slice(1);
plan.push({ keepId: keep.id, dropIds: drop.map(r => r.id) });
}

if (execute && plan.length > 0) {
const txn = db.transaction(() => {
const now = new Date().toISOString();
for (const { keepId, dropIds } of plan) {
// Bump global project sync_version once per survivor and stamp it on
// both the survivor and the soft-deleted dups so getChangesSince emits
// both events to clients (delete dups, upsert survivor).
db.prepare('UPDATE projects SET sync_version = sync_version + 1 WHERE id = ?').run(projectId);
const newSv = db.prepare('SELECT sync_version FROM projects WHERE id = ?').get(projectId).sync_version;
for (const id of dropIds) {
db.prepare(
'UPDATE elements SET is_deleted = 1, sync_version = ?, updated_at = ? WHERE id = ?'
).run(newSv, now, id);
}
db.prepare(
'UPDATE elements SET sync_version = ?, updated_at = ? WHERE id = ?'
).run(newSv, now, keepId);
}
});
txn();
}

return {
projectId,
total: rows.length,
dupGroups: dupGroups.length,
softDeleted: plan.reduce((acc, p) => acc + p.dropIds.length, 0),
plan
};
}

function parseArgs(argv) {
const args = { execute: false, db: null, project: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--execute') args.execute = true;
else if (a === '--db') args.db = argv[++i];
else if (a === '--project') args.project = argv[++i];
}
return args;
}

function main() {
const Database = require('better-sqlite3');
const args = parseArgs(process.argv.slice(2));
const dbPath = args.db || process.env.EXCALIDRAW_DB_PATH || path.resolve(process.cwd(), 'data/excalidraw.db');
const db = new Database(dbPath);

const projects = args.project
? db.prepare('SELECT id, name FROM projects WHERE id = ?').all(args.project)
: db.prepare('SELECT id, name FROM projects ORDER BY id').all();

if (projects.length === 0) {
console.log(`No matching project. db=${dbPath}`);
process.exit(1);
}

console.log(`db: ${dbPath}`);
console.log(`mode: ${args.execute ? 'EXECUTE (will mutate)' : 'dry-run (no changes)'}`);
console.log('');

let totalSoftDeleted = 0;
for (const p of projects) {
const res = dedupeProject(db, p.id, args.execute);
console.log(`[${p.id}] "${p.name}": active=${res.total}, dup-groups=${res.dupGroups}, would-soft-delete=${res.softDeleted}`);
if (res.plan.length > 0 && !args.execute) {
for (const { keepId, dropIds } of res.plan.slice(0, 5)) {
console.log(` keep ${keepId} drop ${dropIds.join(',')}`);
}
if (res.plan.length > 5) console.log(` ... +${res.plan.length - 5} more groups`);
}
totalSoftDeleted += res.softDeleted;
}

console.log('');
console.log(`TOTAL: ${args.execute ? 'soft-deleted' : 'would soft-delete'} ${totalSoftDeleted} rows across ${projects.length} project(s)`);
if (!args.execute && totalSoftDeleted > 0) {
console.log('Re-run with --execute to apply.');
}
}

if (require.main === module) {
try { main(); } catch (e) { console.error(e); process.exit(1); }
}

module.exports = { fingerprint, bucketByFingerprint, dedupeProject };
45 changes: 26 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,9 @@ async function syncToCanvas(operation: string, data: any): Promise<SyncResponse
}

// Helper to sync element creation to canvas
async function createElementOnCanvas(elementData: ServerElement): Promise<SyncResponse | null> {
// id is optional: when absent, the server derives a deterministic content-based
// id (see deriveContentId in src/server.ts). Same content → same id → upsert.
async function createElementOnCanvas(elementData: Omit<ServerElement, 'id'> & { id?: string }): Promise<SyncResponse | null> {
const result = await syncToCanvas('create', elementData);
return result ?? null;
}
Expand All @@ -255,7 +257,7 @@ async function deleteElementOnCanvas(elementId: string): Promise<any> {
}

// Helper to sync batch creation to canvas
async function batchCreateElementsOnCanvas(elementsData: ServerElement[]): Promise<SyncResponse | null> {
async function batchCreateElementsOnCanvas(elementsData: Array<Omit<ServerElement, 'id'> & { id?: string }>): Promise<SyncResponse | null> {
const result = await syncToCanvas('batch_create', elementsData);
return result ?? null;
}
Expand Down Expand Up @@ -1060,10 +1062,12 @@ const callToolHandler = async (request: CallToolRequest) => {
logger.info('Creating element via MCP', { type: params.type });

const { startElementId, endElementId, id: customId, ...elementProps } = params;
const id = customId || generateId();
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
const element: ServerElement = {
id,
// No id minted here — server derives a content-based deterministic id
// (see deriveContentId in src/server.ts) so re-issued create with same
// content upserts the existing row instead of duplicating it.
const element: Omit<ServerElement, 'id'> & { id?: string } = {
...(customId ? { id: customId } : {}),
...elementProps,
fontFamily: normalizedFont ?? USER_PREFS.fontFamily,
roughness: elementProps.roughness ?? USER_PREFS.roughness,
Expand All @@ -1083,7 +1087,7 @@ const callToolHandler = async (request: CallToolRequest) => {
}

// Convert text to label format for Excalidraw
const excalidrawElement = convertTextToLabel(element);
const excalidrawElement = convertTextToLabel(element as ServerElement);

// Create element directly on HTTP server (no local storage)
const canvasResponse = await createElementOnCanvas(excalidrawElement);
Expand All @@ -1093,17 +1097,18 @@ const callToolHandler = async (request: CallToolRequest) => {
}

const synced = canvasResponse.syncedToCanvas ?? false;
const assignedId = canvasResponse.element?.id ?? customId ?? '(server-assigned)';
logger.info('Element created via MCP', {
id: excalidrawElement.id,
id: assignedId,
type: excalidrawElement.type,
synced,
canvasStatus: canvasResponse.canvasStatus
});

const statusEmoji = synced ? '✅' : '⚠️';
const statusEmoji = synced ? '✅' : 'ℹ️';
const statusText = synced
? 'Synced to canvas and confirmed by browser'
: `Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`;
? 'Visible on canvas now'
: `Persisted. Canvas browser will pick up on reconnect or refresh (${canvasResponse.canvasStatus?.reason ?? 'no_clients'}).`;

return {
content: [{
Expand Down Expand Up @@ -1148,7 +1153,7 @@ const callToolHandler = async (request: CallToolRequest) => {
return {
content: [{
type: 'text',
text: `Element updated successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${synced ? '✅ Synced to canvas and confirmed' : `⚠️ Canvas sync not confirmed (${canvasResponse.canvasStatus?.reason ?? 'unknown'})`}`
text: `Element updated successfully!\n\n${JSON.stringify(canvasResponse.element ?? excalidrawElement, null, 2)}\n\n${synced ? '✅ Visible on canvas now' : `ℹ️ Persisted. Canvas browser will pick up on reconnect or refresh (${canvasResponse.canvasStatus?.reason ?? 'no_clients'}).`}`
}]
};
}
Expand Down Expand Up @@ -1565,14 +1570,16 @@ const callToolHandler = async (request: CallToolRequest) => {
const params = z.object({ elements: z.array(ElementSchema) }).parse(args);
logger.info('Batch creating elements via MCP', { count: params.elements.length });

const createdElements: ServerElement[] = [];
type CreatePayload = Omit<ServerElement, 'id'> & { id?: string };
const createdElements: CreatePayload[] = [];

for (const elementData of params.elements) {
const { startElementId, endElementId, id: customId, ...elementProps } = elementData;
const id = customId || generateId();
const normalizedFont = normalizeFontFamily(elementProps.fontFamily);
const element: ServerElement = {
id,
// Same as create_element: omit id when caller didn't supply one so
// the server can derive a content-based deterministic id.
const element: CreatePayload = {
...(customId ? { id: customId } : {}),
...elementProps,
fontFamily: normalizedFont ?? USER_PREFS.fontFamily,
roughness: elementProps.roughness ?? USER_PREFS.roughness,
Expand All @@ -1591,7 +1598,7 @@ const callToolHandler = async (request: CallToolRequest) => {
(element as any).points = [[0, 0], [100, 0]];
}

const excalidrawElement = convertTextToLabel(element);
const excalidrawElement = convertTextToLabel(element as ServerElement);
createdElements.push(excalidrawElement);
}

Expand All @@ -1615,10 +1622,10 @@ const callToolHandler = async (request: CallToolRequest) => {
canvasStatus: result.canvasStatus
});

const statusEmoji = result.syncedToCanvas ? '✅' : '⚠️';
const statusEmoji = result.syncedToCanvas ? '✅' : 'ℹ️';
const statusText = result.syncedToCanvas
? 'All elements synced to canvas and confirmed by browser'
: `Canvas sync not confirmed (${result.canvasStatus?.reason ?? 'unknown'})`;
? 'All elements visible on canvas now'
: `Persisted. Canvas browser will pick up on reconnect or refresh (${result.canvasStatus?.reason ?? 'no_clients'}).`;

return {
content: [{
Expand Down
Loading
Loading