mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-13 04:59:51 +08:00
fix(mcp-server): recover sessions in one place, and more fixes from the third review
- A session whose state expired was recovered from its auto-save file only for the preview page; the tools built on their older copy and then overwrote the file. They now recover it first (restoreSavedSession). - A preview tab that missed the last AI write pushed its older diagram over the recovered one after a restart. It now shows the recovered diagram and keeps its own copy in History. - An empty record of what the model has seen (after load_diagram or a page tool on unseen changes) no longer lets one page of a multi-page document count for all, and get_diagram counts a page only once found. - History: a thumbnail goes only to the entry it shows, the cached image never belongs to an older diagram, a re-serialized copy adds no entry, and a cleared document with its own pages is kept before a restore. - The root cell id check reads attributes one by one: rack-id="1" or id text inside a label no longer counts. - A compressed page counts as having cells; a saved file that could not be read is never written over.
This commit is contained in:
@@ -120,10 +120,19 @@ export function markPageSeen(
|
|||||||
liveXml: string,
|
liveXml: string,
|
||||||
selector: PageSelector,
|
selector: PageSelector,
|
||||||
): string {
|
): string {
|
||||||
if (!lastSeenXml) return liveXml
|
const parse = (xml: string) => {
|
||||||
const otherPages = (xml: string) => {
|
|
||||||
const normalized = normalizeToMxfile(xml)
|
const normalized = normalizeToMxfile(xml)
|
||||||
const doc = normalized ? parseMxfile(normalized) : null
|
return normalized ? parseMxfile(normalized) : null
|
||||||
|
}
|
||||||
|
// An empty record also follows load_diagram and page tools that wrote
|
||||||
|
// over unseen changes, when the model may remember older pages: only
|
||||||
|
// a one-page document is then fully seen
|
||||||
|
if (!lastSeenXml) {
|
||||||
|
const pages = parse(liveXml)?.querySelectorAll("diagram").length ?? 1
|
||||||
|
return pages <= 1 ? liveXml : lastSeenXml
|
||||||
|
}
|
||||||
|
const otherPages = (xml: string) => {
|
||||||
|
const doc = parse(xml)
|
||||||
if (!doc) return null
|
if (!doc) return null
|
||||||
findPageElement(doc, selector)?.element.remove()
|
findPageElement(doc, selector)?.element.remove()
|
||||||
return contentFingerprint(serializeMxfile(doc))
|
return contentFingerprint(serializeMxfile(doc))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Stores {xml, svg} entries in a circular buffer
|
* Stores {xml, svg} entries in a circular buffer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { contentFingerprint } from "./edit-gate.ts"
|
||||||
import { log } from "./logger.ts"
|
import { log } from "./logger.ts"
|
||||||
|
|
||||||
const MAX_HISTORY = 20
|
const MAX_HISTORY = 20
|
||||||
@@ -16,6 +17,14 @@ interface HistoryEntry {
|
|||||||
let nextEntryId = 0
|
let nextEntryId = 0
|
||||||
const historyStore = new Map<string, HistoryEntry[]>()
|
const historyStore = new Map<string, HistoryEntry[]>()
|
||||||
|
|
||||||
|
// The same pages and cells; a document without pages has an empty
|
||||||
|
// fingerprint and is compared as text only
|
||||||
|
function sameDiagram(a: string, b: string): boolean {
|
||||||
|
if (a === b) return true
|
||||||
|
const fingerprint = contentFingerprint(a)
|
||||||
|
return fingerprint !== "" && fingerprint === contentFingerprint(b)
|
||||||
|
}
|
||||||
|
|
||||||
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
||||||
let history = historyStore.get(sessionId)
|
let history = historyStore.get(sessionId)
|
||||||
if (!history) {
|
if (!history) {
|
||||||
@@ -23,9 +32,11 @@ export function addHistory(sessionId: string, xml: string, svg = ""): number {
|
|||||||
historyStore.set(sessionId, history)
|
historyStore.set(sessionId, history)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dedupe: skip if same as last entry
|
// Dedupe: skip if same as last entry, also when only re-serialized
|
||||||
|
// (the browser's copy of the same diagram)
|
||||||
const last = history[history.length - 1]
|
const last = history[history.length - 1]
|
||||||
if (last?.xml === xml) {
|
if (last && sameDiagram(last.xml, xml)) {
|
||||||
|
if (svg && !last.svg) last.svg = svg
|
||||||
return history.length - 1
|
return history.length - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,11 +67,19 @@ export function clearHistory(sessionId: string): void {
|
|||||||
historyStore.delete(sessionId)
|
historyStore.delete(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateLastHistorySvg(sessionId: string, svg: string): boolean {
|
/**
|
||||||
|
* Give the last entry the image the browser took of shownXml, the diagram
|
||||||
|
* it just loaded, when that entry is this diagram
|
||||||
|
*/
|
||||||
|
export function updateLastHistorySvg(
|
||||||
|
sessionId: string,
|
||||||
|
svg: string,
|
||||||
|
shownXml: string,
|
||||||
|
): boolean {
|
||||||
const history = historyStore.get(sessionId)
|
const history = historyStore.get(sessionId)
|
||||||
if (!history || history.length === 0) return false
|
if (!history || history.length === 0) return false
|
||||||
const last = history[history.length - 1]
|
const last = history[history.length - 1]
|
||||||
if (!last.svg) {
|
if (!last.svg && sameDiagram(last.xml, shownXml)) {
|
||||||
last.svg = svg
|
last.svg = svg
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function readBody(
|
|||||||
req.on("end", () => cb(Buffer.concat(chunks).toString("utf8")))
|
req.on("end", () => cb(Buffer.concat(chunks).toString("utf8")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { contentFingerprint } from "./edit-gate.ts"
|
||||||
import {
|
import {
|
||||||
addHistory,
|
addHistory,
|
||||||
clearHistory,
|
clearHistory,
|
||||||
@@ -40,7 +41,7 @@ import {
|
|||||||
updateLastHistorySvg,
|
updateLastHistorySvg,
|
||||||
} from "./history.ts"
|
} from "./history.ts"
|
||||||
import { log } from "./logger.ts"
|
import { log } from "./logger.ts"
|
||||||
import { BLANK_MXFILE, hasCells } from "./pages.ts"
|
import { BLANK_MXFILE } from "./pages.ts"
|
||||||
|
|
||||||
// Configurable draw.io embed URL for private deployments
|
// Configurable draw.io embed URL for private deployments
|
||||||
const DRAWIO_BASE_URL =
|
const DRAWIO_BASE_URL =
|
||||||
@@ -82,17 +83,28 @@ function getMostRecentSessionId(): string | null {
|
|||||||
return mostRecent?.id || null
|
return mostRecent?.id || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a session whose state is gone (it expired, or the MCP process
|
||||||
|
* restarted) its auto-saved diagram back. The MCP tools call this before
|
||||||
|
* they read the state, so they never build on an older copy and then
|
||||||
|
* overwrite the file. Not a change worth saving again.
|
||||||
|
*/
|
||||||
|
export function restoreSavedSession(sessionId: string): void {
|
||||||
|
if (stateStore.has(sessionId) || !isValidSessionId(sessionId)) return
|
||||||
|
const saved = savedStateLoader?.(sessionId)
|
||||||
|
if (saved) setState(sessionId, saved, undefined, false, false)
|
||||||
|
}
|
||||||
|
|
||||||
function ensureSessionStateInitialized(sessionId: string): void {
|
function ensureSessionStateInitialized(sessionId: string): void {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
if (!isValidSessionId(sessionId)) return
|
if (!isValidSessionId(sessionId)) return
|
||||||
|
restoreSavedSession(sessionId)
|
||||||
if (stateStore.has(sessionId)) return
|
if (stateStore.has(sessionId)) return
|
||||||
|
|
||||||
// The session's saved diagram, so a blank page never replaces that file.
|
|
||||||
// Not a change worth saving: the browser fills it on its next push
|
// Not a change worth saving: the browser fills it on its next push
|
||||||
// A blank diagram keeps the draw.io spinner (spin=1) from waiting
|
// A blank diagram keeps the draw.io spinner (spin=1) from waiting
|
||||||
// forever when no load(xml) is ever sent
|
// forever when no load(xml) is ever sent
|
||||||
const saved = savedStateLoader?.(sessionId)
|
setState(sessionId, BLANK_MXFILE, undefined, false, false)
|
||||||
setState(sessionId, saved || BLANK_MXFILE, undefined, false, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
@@ -169,7 +181,9 @@ export function setState(
|
|||||||
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
|
serverVersion: fromBrowser ? existing?.serverVersion : newVersion,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
lastPolled: existing?.lastPolled,
|
lastPolled: existing?.lastPolled,
|
||||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
// The image of this XML, never an older one's: a write without an
|
||||||
|
// image (AI write, sync reply) leaves none until the browser sends it
|
||||||
|
svg: svg || undefined,
|
||||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
exportFormat: existing?.exportFormat, // Preserve pending export request
|
||||||
exportXml: existing?.exportXml, // Preserve pending projection
|
exportXml: existing?.exportXml, // Preserve pending projection
|
||||||
@@ -584,8 +598,14 @@ function handleRestoreApi(
|
|||||||
|
|
||||||
// Edits in the browser since the last entry are not in history
|
// Edits in the browser since the last entry are not in history
|
||||||
// yet: keep them, so the restore can be undone
|
// yet: keep them, so the restore can be undone
|
||||||
|
// (any state besides a blank page; a cleared document with its
|
||||||
|
// own pages counts)
|
||||||
const current = stateStore.get(sessionId)
|
const current = stateStore.get(sessionId)
|
||||||
if (current && hasCells(current.xml)) {
|
if (
|
||||||
|
current &&
|
||||||
|
contentFingerprint(current.xml) !==
|
||||||
|
contentFingerprint(BLANK_MXFILE)
|
||||||
|
) {
|
||||||
addHistory(sessionId, current.xml, current.svg)
|
addHistory(sessionId, current.xml, current.svg)
|
||||||
}
|
}
|
||||||
const newVersion = setState(sessionId, entry.xml)
|
const newVersion = setState(sessionId, entry.xml)
|
||||||
@@ -621,7 +641,12 @@ function handleHistorySvgApi(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
updateLastHistorySvg(sessionId, svg)
|
// The browser took it of the diagram it just loaded: the state
|
||||||
|
const state = stateStore.get(sessionId)
|
||||||
|
if (state) {
|
||||||
|
updateLastHistorySvg(sessionId, svg, state.xml)
|
||||||
|
state.svg = svg
|
||||||
|
}
|
||||||
res.writeHead(200, { "Content-Type": "application/json" })
|
res.writeHead(200, { "Content-Type": "application/json" })
|
||||||
res.end(JSON.stringify({ success: true }))
|
res.end(JSON.stringify({ success: true }))
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
onStateChange,
|
onStateChange,
|
||||||
requestExport,
|
requestExport,
|
||||||
requestSync,
|
requestSync,
|
||||||
|
restoreSavedSession,
|
||||||
setState,
|
setState,
|
||||||
shutdown,
|
shutdown,
|
||||||
startHttpServer,
|
startHttpServer,
|
||||||
@@ -78,6 +79,21 @@ const autosaver = new Autosaver(defaultDataDir())
|
|||||||
onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml))
|
onStateChange((sessionId, xml) => autosaver.schedule(sessionId, xml))
|
||||||
onSessionRecreate((sessionId) => autosaver.load(sessionId))
|
onSessionRecreate((sessionId) => autosaver.load(sessionId))
|
||||||
|
|
||||||
|
// A one-page view that does not count for the whole document (edit-gate.ts)
|
||||||
|
const OTHER_PAGES_UNSEEN =
|
||||||
|
"You have not seen the other pages in their current state."
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The browser's state of a session. After it expired (or the process
|
||||||
|
* restarted) the saved file comes back first, so a tool never builds on an
|
||||||
|
* older copy and then overwrites the file. Call it before requestSync or
|
||||||
|
* requestExport, which need the state.
|
||||||
|
*/
|
||||||
|
function sessionState(sessionId: string) {
|
||||||
|
restoreSavedSession(sessionId)
|
||||||
|
return getState(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
// Session state (single session for simplicity)
|
// Session state (single session for simplicity)
|
||||||
let currentSession: {
|
let currentSession: {
|
||||||
id: string
|
id: string
|
||||||
@@ -353,7 +369,7 @@ Rules: cells are siblings (never nested), ids are unique per page and start from
|
|||||||
log.info(`Setting diagram content, ${xml.length} chars`)
|
log.info(`Setting diagram content, ${xml.length} chars`)
|
||||||
|
|
||||||
// Sync from browser state first
|
// Sync from browser state first
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = sessionState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
currentSession.xml = browserState.xml
|
currentSession.xml = browserState.xml
|
||||||
}
|
}
|
||||||
@@ -481,7 +497,7 @@ server.registerTool(
|
|||||||
|
|
||||||
// Save the user's current state before replacing (same flow as
|
// Save the user's current state before replacing (same flow as
|
||||||
// create_new_diagram).
|
// create_new_diagram).
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = sessionState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
currentSession.xml = browserState.xml
|
currentSession.xml = browserState.xml
|
||||||
}
|
}
|
||||||
@@ -605,7 +621,7 @@ server.registerTool(
|
|||||||
// embed/sync path can hand back a bare <mxGraphModel>, and adopting
|
// embed/sync path can hand back a bare <mxGraphModel>, and adopting
|
||||||
// it verbatim would silently strip a multi-page document down to
|
// it verbatim would silently strip a multi-page document down to
|
||||||
// one page on the next write.
|
// one page on the next write.
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = sessionState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
currentSession.xml =
|
currentSession.xml =
|
||||||
normalizeToMxfile(browserState.xml) ?? browserState.xml
|
normalizeToMxfile(browserState.xml) ?? browserState.xml
|
||||||
@@ -660,7 +676,7 @@ server.registerTool(
|
|||||||
const next =
|
const next =
|
||||||
currentSession.lastSeenXml === liveXml
|
currentSession.lastSeenXml === liveXml
|
||||||
? "Build your operations on this XML and retry."
|
? "Build your operations on this XML and retry."
|
||||||
: "Other pages changed too: call get_diagram without a page selector, then retry."
|
: `${OTHER_PAGES_UNSEEN} Call get_diagram without a page selector, then retry.`
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -775,8 +791,10 @@ server.registerTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request browser to push fresh state and wait for it
|
// Request browser to push fresh state and wait for it (an
|
||||||
|
// expired session first gets its saved file back to sync)
|
||||||
let staleNote = ""
|
let staleNote = ""
|
||||||
|
restoreSavedSession(currentSession.id)
|
||||||
const syncRequested = requestSync(currentSession.id)
|
const syncRequested = requestSync(currentSession.id)
|
||||||
if (syncRequested) {
|
if (syncRequested) {
|
||||||
const synced = await waitForSync(currentSession.id)
|
const synced = await waitForSync(currentSession.id)
|
||||||
@@ -790,7 +808,7 @@ server.registerTool(
|
|||||||
// Fetch latest state from browser, re-normalising to mxfile so a
|
// Fetch latest state from browser, re-normalising to mxfile so a
|
||||||
// bare <mxGraphModel> pushed back by the embed/sync path doesn't
|
// bare <mxGraphModel> pushed back by the embed/sync path doesn't
|
||||||
// strip page structure (see edit_diagram for the same guard).
|
// strip page structure (see edit_diagram for the same guard).
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = sessionState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
currentSession.xml =
|
currentSession.xml =
|
||||||
normalizeToMxfile(browserState.xml) ?? browserState.xml
|
normalizeToMxfile(browserState.xml) ?? browserState.xml
|
||||||
@@ -816,19 +834,7 @@ server.registerTool(
|
|||||||
// The model is now looking at the current state. Record the raw
|
// The model is now looking at the current state. Record the raw
|
||||||
// store value — the gate's fast path is plain string equality
|
// store value — the gate's fast path is plain string equality
|
||||||
// against the store, with a structural comparison as fallback.
|
// against the store, with a structural comparison as fallback.
|
||||||
// One page shown counts for all only if the others are unchanged.
|
|
||||||
const liveXml = browserState?.xml || currentSession.xml
|
const liveXml = browserState?.xml || currentSession.xml
|
||||||
currentSession.lastSeenXml = hasPageSelector(pageSelector)
|
|
||||||
? markPageSeen(
|
|
||||||
currentSession.lastSeenXml,
|
|
||||||
liveXml,
|
|
||||||
pageSelector,
|
|
||||||
)
|
|
||||||
: liveXml
|
|
||||||
const otherPagesNote =
|
|
||||||
currentSession.lastSeenXml === liveXml
|
|
||||||
? ""
|
|
||||||
: "\n\nNote: other pages changed since you last saw them. Call get_diagram without a page selector before editing."
|
|
||||||
const doc = parseMxfile(currentSession.xml)
|
const doc = parseMxfile(currentSession.xml)
|
||||||
const pages = doc ? listPagesFromDoc(doc) : []
|
const pages = doc ? listPagesFromDoc(doc) : []
|
||||||
const pageList = pages.length
|
const pageList = pages.length
|
||||||
@@ -837,6 +843,7 @@ server.registerTool(
|
|||||||
|
|
||||||
// No selector → return full mxfile
|
// No selector → return full mxfile
|
||||||
if (!hasPageSelector(pageSelector)) {
|
if (!hasPageSelector(pageSelector)) {
|
||||||
|
currentSession.lastSeenXml = liveXml
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -863,6 +870,17 @@ server.registerTool(
|
|||||||
isError: true,
|
isError: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// One page shown counts for all only if the others are as the
|
||||||
|
// model saw them last
|
||||||
|
currentSession.lastSeenXml = markPageSeen(
|
||||||
|
currentSession.lastSeenXml,
|
||||||
|
liveXml,
|
||||||
|
pageSelector,
|
||||||
|
)
|
||||||
|
const otherPagesNote =
|
||||||
|
currentSession.lastSeenXml === liveXml
|
||||||
|
? ""
|
||||||
|
: `\n\nNote: ${OTHER_PAGES_UNSEEN} Call get_diagram without a page selector before editing.`
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -1002,7 +1020,8 @@ server.registerTool(
|
|||||||
if (previewStalled(currentSession.id)) {
|
if (previewStalled(currentSession.id)) {
|
||||||
return previewStalledError(currentSession.id)
|
return previewStalledError(currentSession.id)
|
||||||
}
|
}
|
||||||
const xml = getState(currentSession.id)?.xml || currentSession.xml
|
const xml =
|
||||||
|
sessionState(currentSession.id)?.xml || currentSession.xml
|
||||||
if (!hasCells(xml)) {
|
if (!hasCells(xml)) {
|
||||||
return {
|
return {
|
||||||
content: [{ type: "text", text: "The diagram is empty." }],
|
content: [{ type: "text", text: "The diagram is empty." }],
|
||||||
@@ -1131,7 +1150,7 @@ server.registerTool(
|
|||||||
|
|
||||||
// Fetch latest state, re-normalised to mxfile so a page
|
// Fetch latest state, re-normalised to mxfile so a page
|
||||||
// selector works on a bare <mxGraphModel> pushed by the browser
|
// selector works on a bare <mxGraphModel> pushed by the browser
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = sessionState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
currentSession.xml =
|
currentSession.xml =
|
||||||
normalizeToMxfile(browserState.xml) ?? browserState.xml
|
normalizeToMxfile(browserState.xml) ?? browserState.xml
|
||||||
@@ -1229,7 +1248,7 @@ server.registerTool(
|
|||||||
const browserFormat =
|
const browserFormat =
|
||||||
detectedFormat === "drawio.svg" ? "xmlsvg" : detectedFormat
|
detectedFormat === "drawio.svg" ? "xmlsvg" : detectedFormat
|
||||||
|
|
||||||
const state = getState(currentSession.id)
|
const state = sessionState(currentSession.id)
|
||||||
if (!state) {
|
if (!state) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -1363,7 +1382,7 @@ async function loadMxfileForMutation(): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Pull latest from browser so we don't clobber autosaved changes.
|
// Pull latest from browser so we don't clobber autosaved changes.
|
||||||
const browserState = getState(currentSession.id)
|
const browserState = sessionState(currentSession.id)
|
||||||
if (browserState?.xml) {
|
if (browserState?.xml) {
|
||||||
currentSession.xml = browserState.xml
|
currentSession.xml = browserState.xml
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,31 @@ export type NewDiagram =
|
|||||||
* or null.
|
* or null.
|
||||||
*/
|
*/
|
||||||
export function reservedIdError(input: string): string | null {
|
export function reservedIdError(input: string): string | null {
|
||||||
const ID = String.raw`\bid\s*=\s*["'][01]["']`
|
if (/<(mxGraphModel|mxfile)\b/.test(input)) return null
|
||||||
const shape = new RegExp(
|
// Each opening tag with its attributes; quoted values are read as a
|
||||||
String.raw`<mxCell\b(?=[^>]*${ID})(?=[^>]*\b(?:vertex|edge)\s*=\s*["']1["'])|<(?:UserObject|object)\b[^>]*${ID}`,
|
// whole, so text such as label="id='1'" is not an attribute
|
||||||
|
const tags = input.matchAll(
|
||||||
|
/<(mxCell|UserObject|object)\b((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*\/?>/g,
|
||||||
)
|
)
|
||||||
if (/<(mxGraphModel|mxfile)\b/.test(input) || !shape.test(input)) {
|
for (const [, tag, attrText] of tags) {
|
||||||
return null
|
const attrs = new Map<string, string>()
|
||||||
|
for (const [, name, double, single] of attrText.matchAll(
|
||||||
|
/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
|
||||||
|
)) {
|
||||||
|
attrs.set(name, double ?? single)
|
||||||
|
}
|
||||||
|
const id = attrs.get("id")
|
||||||
|
if (id !== "0" && id !== "1") continue
|
||||||
|
// A wrapper's id is its cell's; an mxCell counts as a shape or edge
|
||||||
|
if (
|
||||||
|
tag !== "mxCell" ||
|
||||||
|
attrs.get("vertex") === "1" ||
|
||||||
|
attrs.get("edge") === "1"
|
||||||
|
) {
|
||||||
|
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -52,9 +52,12 @@ export function generatePageId(): string {
|
|||||||
return `${a}-${b}`
|
return `${a}-${b}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Any cell besides the root cells "0" and "1" */
|
/**
|
||||||
|
* Any cell besides the root cells "0" and "1", or a page in draw.io's
|
||||||
|
* compressed format (text instead of a model), which is not checked further
|
||||||
|
*/
|
||||||
export const hasCells = (xml: string) =>
|
export const hasCells = (xml: string) =>
|
||||||
/<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)/.test(
|
/<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)|<diagram\b[^>]*>\s*[^\s<]/.test(
|
||||||
xml,
|
xml,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -55,13 +55,19 @@ export class Autosaver {
|
|||||||
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
|
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Saved files that could not be read back: never written over, since
|
||||||
|
// the session then shows something else than what they hold
|
||||||
|
private unreadable = new Set<string>()
|
||||||
|
|
||||||
/** The session's saved diagram, or null. */
|
/** The session's saved diagram, or null. */
|
||||||
load(sessionId: string): string | null {
|
load(sessionId: string): string | null {
|
||||||
const path = this.pathFor(sessionId)
|
const path = this.pathFor(sessionId)
|
||||||
if (!path || !existsSync(path)) return null
|
if (!path || !existsSync(path)) return null
|
||||||
try {
|
try {
|
||||||
return readFileSync(path, "utf-8")
|
return readFileSync(path, "utf-8")
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
log.warn(`Could not read the saved diagram ${path}: ${error}`)
|
||||||
|
this.unreadable.add(path)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +93,7 @@ export class Autosaver {
|
|||||||
const entry = this.pending.get(sessionId)
|
const entry = this.pending.get(sessionId)
|
||||||
this.pending.delete(sessionId)
|
this.pending.delete(sessionId)
|
||||||
const path = this.pathFor(sessionId)
|
const path = this.pathFor(sessionId)
|
||||||
if (!entry || !this.dir || !path) return
|
if (!entry || !this.dir || !path || this.unreadable.has(path)) return
|
||||||
try {
|
try {
|
||||||
const isNew = !existsSync(path)
|
const isNew = !existsSync(path)
|
||||||
// A blank page the browser shows before any drawing: nothing to keep
|
// A blank page the browser shows before any drawing: nothing to keep
|
||||||
|
|||||||
@@ -144,7 +144,14 @@ function showNotice(text) {
|
|||||||
noticeTimer = setTimeout(() => el.classList.remove('open'), 8000);
|
noticeTimer = setTimeout(() => el.classList.remove('open'), 8000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// source is 'sync' for replies to a server sync request, else 'edit'
|
// Same rule as hasCells in pages.ts: a cell besides the root cells, or a
|
||||||
|
// compressed page
|
||||||
|
function hasCells(xml) {
|
||||||
|
return /<(mxCell\b[^>]*\bid\s*=\s*["'](?![01]["'])|UserObject\b|object\b)|<diagram\b[^>]*>\s*[^\s<]/.test(xml || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// source is 'sync' for replies to a server sync request, 'recover' for the
|
||||||
|
// tab's copy after the server recovered the session, else 'edit'
|
||||||
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit') {
|
async function pushState(xml, svg = '', baseVersion = currentVersion, source = 'edit') {
|
||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
try {
|
try {
|
||||||
@@ -157,7 +164,11 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
|
|||||||
// 409: the AI wrote a newer version; load it now
|
// 409: the AI wrote a newer version; load it now
|
||||||
else if (r.status === 409) {
|
else if (r.status === 409) {
|
||||||
const d = await r.json().catch(() => ({}));
|
const d = await r.json().catch(() => ({}));
|
||||||
if (d.savedToHistory) showNotice('The AI changed the diagram while you were editing. Your last change was saved in History.');
|
if (d.savedToHistory) {
|
||||||
|
showNotice(source === 'recover'
|
||||||
|
? 'The diagram was restored from its saved file. What this tab showed before is in History.'
|
||||||
|
: 'The AI changed the diagram while you were editing. Your last change was saved in History.');
|
||||||
|
}
|
||||||
poll();
|
poll();
|
||||||
}
|
}
|
||||||
} catch (e) { console.error('Push failed:', e); }
|
} catch (e) { console.error('Push failed:', e); }
|
||||||
@@ -174,10 +185,18 @@ async function poll() {
|
|||||||
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
|
||||||
if (!r.ok) return;
|
if (!r.ok) return;
|
||||||
const s = await r.json();
|
const s = await r.json();
|
||||||
// The server lost this session (e.g. it expired) and rebuilt it
|
// The server lost this session (it expired, or the MCP process
|
||||||
// with a blank diagram: push back what the browser shows.
|
// restarted) and rebuilt it. Blank: push back what the browser
|
||||||
|
// shows. From the auto-save file, which can hold an AI write this
|
||||||
|
// tab never loaded: show that, and keep this tab's copy in History
|
||||||
|
// (a push based on version 0 is refused and saved there).
|
||||||
if (s.version < knownVersion && lastXml) {
|
if (s.version < knownVersion && lastXml) {
|
||||||
pushState(lastXml);
|
if (!hasCells(s.xml)) {
|
||||||
|
pushState(lastXml);
|
||||||
|
} else {
|
||||||
|
currentVersion = 0;
|
||||||
|
if (s.xml !== lastXml) pushState(lastXml, '', 0, 'recover');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Load new diagram from server (before export, so we export latest).
|
// Load new diagram from server (before export, so we export latest).
|
||||||
// While a page-targeted projection is on screen, only the restore
|
// While a page-targeted projection is on screen, only the restore
|
||||||
|
|||||||
@@ -156,9 +156,15 @@ describe("markPageSeen", () => {
|
|||||||
expect(checkEditGate(marked, live).ok).toBe(false)
|
expect(checkEditGate(marked, live).ok).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("counts everything as seen when the model saw nothing before", () => {
|
it("does not count other pages when the record of what was seen is empty", () => {
|
||||||
// It has no old copy of the other pages to edit from
|
// Empty also after load_diagram or a page tool on unseen changes,
|
||||||
|
// when the model may still remember an older copy of the pages
|
||||||
const live = doc("a1", "b1")
|
const live = doc("a1", "b1")
|
||||||
|
expect(markPageSeen("", live, { page_id: "A" })).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("counts a one-page document as seen from its only page", () => {
|
||||||
|
const live = `<mxfile>${page("A", "a1")}</mxfile>`
|
||||||
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
|
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||||
|
import { installDomPolyfill } from "../src/dom.ts"
|
||||||
import { addHistory, getHistory } from "../src/history.ts"
|
import { addHistory, getHistory } from "../src/history.ts"
|
||||||
import {
|
import {
|
||||||
getState,
|
getState,
|
||||||
@@ -23,6 +24,8 @@ import {
|
|||||||
let port = 0
|
let port = 0
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
// XML parsing, as the server installs it at startup
|
||||||
|
installDomPolyfill()
|
||||||
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
|
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -350,6 +353,67 @@ describe("history restore", () => {
|
|||||||
expect(getState(id)?.xml).toBe("<mxfile>5</mxfile>")
|
expect(getState(id)?.xml).toBe("<mxfile>5</mxfile>")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const page = (cellId: string) =>
|
||||||
|
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
|
||||||
|
|
||||||
|
it("gives a thumbnail only to the entry it shows", async () => {
|
||||||
|
const id = "mcp-history-thumb"
|
||||||
|
setState(id, page("shown"))
|
||||||
|
// The last entry is another diagram (a tab's copy kept on recovery)
|
||||||
|
addHistory(id, page("other"))
|
||||||
|
await postJson("/api/history-svg", {
|
||||||
|
sessionId: id,
|
||||||
|
svg: "SVG-OF-SHOWN",
|
||||||
|
})
|
||||||
|
expect(getHistory(id).at(-1)?.svg).toBe("")
|
||||||
|
addHistory(id, page("shown"))
|
||||||
|
await postJson("/api/history-svg", {
|
||||||
|
sessionId: id,
|
||||||
|
svg: "SVG-OF-SHOWN",
|
||||||
|
})
|
||||||
|
expect(getHistory(id).at(-1)?.svg).toBe("SVG-OF-SHOWN")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("never pairs the image of an older diagram with a newer one", async () => {
|
||||||
|
const id = "mcp-history-stale-svg"
|
||||||
|
const version = setState(id, page("user"))
|
||||||
|
await postJson("/api/state", {
|
||||||
|
sessionId: id,
|
||||||
|
xml: page("user2"),
|
||||||
|
svg: "SVG-OF-USER2",
|
||||||
|
baseVersion: version,
|
||||||
|
})
|
||||||
|
// An AI write without an image of its own
|
||||||
|
setState(id, page("ai"))
|
||||||
|
addHistory(id, page("older"))
|
||||||
|
const [entry] = getHistory(id)
|
||||||
|
await postJson("/api/restore", { sessionId: id, id: entry.id })
|
||||||
|
const kept = getHistory(id).find((e) => e.xml === page("ai"))
|
||||||
|
expect(kept?.svg).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps a cleared document with renamed pages before restoring", async () => {
|
||||||
|
const id = "mcp-history-empty-pages"
|
||||||
|
const emptyPage = (name: string) =>
|
||||||
|
`<diagram id="${name}" name="${name}"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram>`
|
||||||
|
const cleared = `<mxfile>${emptyPage("Planning")}${emptyPage("Notes")}</mxfile>`
|
||||||
|
addHistory(id, page("before"))
|
||||||
|
setState(id, cleared, undefined, true)
|
||||||
|
const [entry] = getHistory(id)
|
||||||
|
await postJson("/api/restore", { sessionId: id, id: entry.id })
|
||||||
|
expect(getHistory(id).map((e) => e.xml)).toContain(cleared)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds no entry for a re-serialized copy of the last one", () => {
|
||||||
|
const id = "mcp-history-dedupe"
|
||||||
|
addHistory(id, page("same"))
|
||||||
|
addHistory(
|
||||||
|
id,
|
||||||
|
page("same").replace("<mxGraphModel>", '<mxGraphModel dx="10">'),
|
||||||
|
)
|
||||||
|
expect(getHistory(id)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
it("keeps manual edits in history before restoring", async () => {
|
it("keeps manual edits in history before restoring", async () => {
|
||||||
const id = "mcp-history-manual"
|
const id = "mcp-history-manual"
|
||||||
const doc = (cellId: string) =>
|
const doc = (cellId: string) =>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
chmodSync,
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
readdirSync,
|
readdirSync,
|
||||||
@@ -84,6 +85,23 @@ describe("Autosaver", () => {
|
|||||||
expect(new Autosaver(null).load("mcp-back")).toBeNull()
|
expect(new Autosaver(null).load("mcp-back")).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("never replaces a saved file it could not read", () => {
|
||||||
|
const saver = new Autosaver(tempDir(), 10)
|
||||||
|
saver.schedule("mcp-locked", DIAGRAM)
|
||||||
|
saver.flush()
|
||||||
|
const path = saver.pathFor("mcp-locked") as string
|
||||||
|
chmodSync(path, 0o000)
|
||||||
|
try {
|
||||||
|
expect(saver.load("mcp-locked")).toBeNull()
|
||||||
|
// The blank page shown instead must not overwrite the diagram
|
||||||
|
saver.schedule("mcp-locked", BLANK)
|
||||||
|
saver.flush()
|
||||||
|
} finally {
|
||||||
|
chmodSync(path, 0o644)
|
||||||
|
}
|
||||||
|
expect(readFileSync(path, "utf-8")).toBe(DIAGRAM)
|
||||||
|
})
|
||||||
|
|
||||||
it("does nothing when saving is off", () => {
|
it("does nothing when saving is off", () => {
|
||||||
const saver = new Autosaver(null)
|
const saver = new Autosaver(null)
|
||||||
expect(saver.pathFor("mcp-x")).toBeNull()
|
expect(saver.pathFor("mcp-x")).toBeNull()
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ describe("hasCells", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("counts a compressed page as having cells", () => {
|
||||||
|
// draw.io's compressed format: the page's model is text
|
||||||
|
expect(
|
||||||
|
hasCells(
|
||||||
|
`<mxfile><diagram id="p" name="P">dZHBDoIwDIafhjtsGPWM6MkTB8/LVmBxrGQMQZ/eLRuIUS/bv/VfmybF</diagram></mxfile>`,
|
||||||
|
),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it("counts cells with spaces around the =", () => {
|
it("counts cells with spaces around the =", () => {
|
||||||
expect(hasCells(`<mxCell id = "a" vertex="1" parent="1"/>`)).toBe(true)
|
expect(hasCells(`<mxCell id = "a" vertex="1" parent="1"/>`)).toBe(true)
|
||||||
expect(
|
expect(
|
||||||
@@ -106,6 +115,27 @@ describe("reservedIdError", () => {
|
|||||||
`<UserObject id="1" label="Docs"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`,
|
`<UserObject id="1" label="Docs"><mxCell vertex="1" parent="1"><mxGeometry as="geometry"/></mxCell></UserObject>`,
|
||||||
),
|
),
|
||||||
).toMatch(/"0" and "1"/)
|
).toMatch(/"0" and "1"/)
|
||||||
|
// Other attributes ending in id, and id text inside a label
|
||||||
|
expect(
|
||||||
|
reservedIdError(
|
||||||
|
`<UserObject label="Server" rack-id="1" id="7"><mxCell vertex="1" parent="1"/></UserObject>`,
|
||||||
|
),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
reservedIdError(
|
||||||
|
`<UserObject id="u" label="Example: id='1'"><mxCell vertex="1" parent="1"/></UserObject>`,
|
||||||
|
),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
reservedIdError(
|
||||||
|
`<mxCell id="5" data-id="0" vertex="1" parent="1"/>`,
|
||||||
|
),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
reservedIdError(
|
||||||
|
`<mxCell id="5" value="id="1" vertex="1"" vertex="1" parent="1"/>`,
|
||||||
|
),
|
||||||
|
).toBeNull()
|
||||||
// A whole model has its own root cells
|
// A whole model has its own root cells
|
||||||
expect(
|
expect(
|
||||||
reservedIdError(
|
reservedIdError(
|
||||||
|
|||||||
Reference in New Issue
Block a user