mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-08 02:37:46 +08:00
Found by the second PR review: - get_diagram with a page selector, or a rejected edit's error, counted the whole document as seen, so an edit on another page could overwrite the user's change there. A one-page view now counts for all pages only if the others are unchanged; otherwise the reply says to get them. - add_page accepted shapes with the root cell ids "0" and "1" and renamed them, breaking their edges. The check also missed ids on UserObject wrappers and ids written with spaces around the "=". - Root cells written over two lines were kept as an extra layer, cells with id = "a" did not count as cells, and CDATA text before a page's model passed the check although draw.io cannot open the page. - Auto-save cleanup deleted the user's own files that start with mcp-. Only names in the session id format are removed now. - Restoring a history entry dropped edits made in the browser since the last entry. They are added to history first. - A session whose state expired showed a blank page, and the next change overwrote its auto-save file. The saved file is loaded instead. - An edit on a page export's one-page projection, made before the real document was back, replaced the whole document. - A late sync reply could overwrite a newer edit: each sync export is numbered, and the server ignores replies older than the current state. - screenshot_diagram could return another session's image after start_session ran during its retries.
120 lines
3.9 KiB
TypeScript
120 lines
3.9 KiB
TypeScript
/**
|
|
* Auto-save of each session's latest diagram as a plain .drawio file, so a
|
|
* diagram survives the MCP process (hosts start a new one when a
|
|
* conversation is resumed). Like the web app's IndexedDB sessions
|
|
* (lib/session-storage.ts): saved 1 second after the last change, at most
|
|
* 50 kept. History is not saved.
|
|
*/
|
|
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
statSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs"
|
|
import { homedir } from "node:os"
|
|
import { join } from "node:path"
|
|
import { log } from "./logger.ts"
|
|
import { hasCells } from "./pages.ts"
|
|
|
|
const DELAY_MS = 1000
|
|
const MAX_FILES = 50
|
|
|
|
/** Expand a leading ~ to the home directory (shells do this, MCP hosts don't). */
|
|
export function expandHome(p: string): string {
|
|
if (p === "~") return homedir()
|
|
if (p.startsWith("~/") || p.startsWith("~\\")) return homedir() + p.slice(1)
|
|
return p
|
|
}
|
|
|
|
/** DRAWIO_DATA_DIR, default ~/.next-ai-drawio; "off" disables saving. */
|
|
export function defaultDataDir(): string | null {
|
|
const dir = process.env.DRAWIO_DATA_DIR
|
|
if (dir === "off") return null
|
|
return dir ? expandHome(dir) : join(homedir(), ".next-ai-drawio")
|
|
}
|
|
|
|
export class Autosaver {
|
|
private pending = new Map<
|
|
string,
|
|
{ xml: string; timer: ReturnType<typeof setTimeout> }
|
|
>()
|
|
|
|
constructor(
|
|
private dir: string | null,
|
|
private delayMs = DELAY_MS,
|
|
private maxFiles = MAX_FILES,
|
|
) {}
|
|
|
|
/** Path of a session's file, or null when saving is off. */
|
|
pathFor(sessionId: string): string | null {
|
|
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
|
|
}
|
|
|
|
/** The session's saved diagram, or null. */
|
|
load(sessionId: string): string | null {
|
|
const path = this.pathFor(sessionId)
|
|
if (!path || !existsSync(path)) return null
|
|
try {
|
|
return readFileSync(path, "utf-8")
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
schedule(sessionId: string, xml: string): void {
|
|
if (!this.dir) return
|
|
const previous = this.pending.get(sessionId)
|
|
if (previous) clearTimeout(previous.timer)
|
|
const timer = setTimeout(() => this.write(sessionId), this.delayMs)
|
|
timer.unref?.()
|
|
this.pending.set(sessionId, { xml, timer })
|
|
}
|
|
|
|
/** Write every pending save now (on shutdown). */
|
|
flush(): void {
|
|
for (const [sessionId, { timer }] of this.pending) {
|
|
clearTimeout(timer)
|
|
this.write(sessionId)
|
|
}
|
|
}
|
|
|
|
private write(sessionId: string): void {
|
|
const entry = this.pending.get(sessionId)
|
|
this.pending.delete(sessionId)
|
|
const path = this.pathFor(sessionId)
|
|
if (!entry || !this.dir || !path) return
|
|
try {
|
|
const isNew = !existsSync(path)
|
|
// A blank page the browser shows before any drawing: nothing to keep
|
|
if (isNew && !hasCells(entry.xml)) return
|
|
mkdirSync(this.dir, { recursive: true })
|
|
// Write to a temporary file first so a crash never leaves half a file
|
|
writeFileSync(`${path}.tmp`, entry.xml, "utf-8")
|
|
renameSync(`${path}.tmp`, path)
|
|
if (isNew) this.removeOldest()
|
|
} catch (error) {
|
|
log.warn(`Auto-save failed for ${path}: ${error}`)
|
|
}
|
|
}
|
|
|
|
private removeOldest(): void {
|
|
if (!this.dir) return
|
|
const dir = this.dir
|
|
// Only our own session files (mcp-<time in base 36>-<random>, made
|
|
// by start_session): DRAWIO_DATA_DIR may be a folder that also holds
|
|
// the user's diagrams
|
|
const files = readdirSync(dir)
|
|
.filter((f) => /^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/.test(f))
|
|
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
|
|
.sort((a, b) => b.mtime - a.mtime)
|
|
for (const { f } of files.slice(this.maxFiles)) {
|
|
unlinkSync(join(dir, f))
|
|
}
|
|
}
|
|
}
|