mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-12 04:29:51 +08:00
scripts/fetch-drawio.mjs downloads the pinned draw.war into a cache (DRAWIO_WAR_CACHE, or DRAWIO_WAR for a local file), checks its SHA-256 and extracts the files named in drawio-files.txt plus a LICENSE with the Apache-2.0 text. scripts/check-drawio-files.mjs drives the embedded editor in headless Chromium from a full copy, records every requested file and checks or rewrites (--update) the list.
475 lines
16 KiB
JavaScript
475 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
import {
|
|
existsSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from "node:fs"
|
|
/**
|
|
* Guard for drawio-files.txt: opens the embedded editor from a FULL draw.io
|
|
* copy in headless Chromium, uses it the way the preview page does (every
|
|
* theme the user can switch to, diagrams with shapes from the icon libraries,
|
|
* the format panel, the shape sidebar, the menus, the page tabs, the export
|
|
* and edit dialogs, exports) and records every file the editor requests.
|
|
*
|
|
* node scripts/check-drawio-files.mjs [--drawio <dir>] [--update]
|
|
*
|
|
* The full copy is --drawio, else the web app's public/drawio. Without
|
|
* --update it fails when the editor requested a file the list does not
|
|
* have; with --update it rewrites the requested section of the list.
|
|
* Needs Playwright from the repository root (npm ci at the root).
|
|
*/
|
|
import http from "node:http"
|
|
import path from "node:path"
|
|
import { fileURLToPath, pathToFileURL } from "node:url"
|
|
import {
|
|
formatFileList,
|
|
parseFileList,
|
|
readFileList,
|
|
} from "./drawio-file-list.mjs"
|
|
|
|
const PKG = path.join(path.dirname(fileURLToPath(import.meta.url)), "..")
|
|
const LIST = path.join(PKG, "drawio-files.txt")
|
|
const args = process.argv.slice(2)
|
|
const update = args.includes("--update")
|
|
const drawioDir = path.resolve(
|
|
args.includes("--drawio")
|
|
? args[args.indexOf("--drawio") + 1]
|
|
: path.join(PKG, "../../public/drawio"),
|
|
)
|
|
if (!existsSync(path.join(drawioDir, "index.html"))) {
|
|
console.error(
|
|
`No draw.io copy at ${drawioDir}. Run "npm run dev" once at the repository root (it downloads one into public/drawio), or pass --drawio <dir>.`,
|
|
)
|
|
process.exit(2)
|
|
}
|
|
const playwrightEntry = path.join(
|
|
PKG,
|
|
"../../node_modules/playwright/index.mjs",
|
|
)
|
|
if (!existsSync(playwrightEntry)) {
|
|
console.error("Playwright is missing: run npm ci at the repository root.")
|
|
process.exit(2)
|
|
}
|
|
const { chromium } = await import(pathToFileURL(playwrightEntry).href)
|
|
|
|
// The themes a user can switch to under Extras > Theme (themes=1)
|
|
const THEMES = ["kennedy", "min", "atlas", "dark", "sketch", "simple"]
|
|
|
|
const MIME = {
|
|
".html": "text/html",
|
|
".js": "text/javascript",
|
|
".css": "text/css",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".gif": "image/gif",
|
|
".json": "application/json",
|
|
".xml": "text/xml",
|
|
".txt": "text/plain",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".ico": "image/x-icon",
|
|
}
|
|
|
|
// The page that embeds the editor, with the preview page's iframe query
|
|
const parentPage = (ui) => `<!doctype html><html><body style="margin:0">
|
|
<iframe id="f" style="width:1280px;height:860px;border:0" src="/drawio/index.html?embed=1&proto=json&configure=1&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&dark=0&ui=${ui}"></iframe>
|
|
<script>
|
|
const f = document.getElementById('f')
|
|
window.exports = 0
|
|
window.addEventListener('message', (e) => {
|
|
let m; try { m = JSON.parse(e.data) } catch { return }
|
|
if (m.event === 'configure') f.contentWindow.postMessage(JSON.stringify({ action: 'configure', config: {} }), '*')
|
|
if (m.event === 'init') window.ready = true
|
|
if (m.event === 'export') window.exports++
|
|
})
|
|
window.send = (msg) => f.contentWindow.postMessage(JSON.stringify(msg), '*')
|
|
</script></body></html>`
|
|
|
|
// Every path the editor asked for, in order; and the ones the full copy lacks
|
|
const requested = []
|
|
const notFound = new Set()
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url, "http://localhost")
|
|
if (url.pathname === "/") {
|
|
res.writeHead(200, { "Content-Type": "text/html" })
|
|
res.end(parentPage(url.searchParams.get("ui") || "kennedy"))
|
|
return
|
|
}
|
|
const rel = path.posix.normalize(
|
|
decodeURIComponent(url.pathname.replace(/^\/drawio\//, "")),
|
|
)
|
|
const file = path.join(drawioDir, rel)
|
|
let stat
|
|
try {
|
|
stat = statSync(file)
|
|
} catch {
|
|
notFound.add(rel)
|
|
res.writeHead(404)
|
|
res.end()
|
|
return
|
|
}
|
|
if (!requested.includes(rel)) requested.push(rel)
|
|
res.writeHead(200, {
|
|
"Content-Type": MIME[path.extname(file)] || "application/octet-stream",
|
|
"Content-Length": stat.size,
|
|
})
|
|
res.end(readFileSync(file))
|
|
})
|
|
await new Promise((r) => server.listen(0, "127.0.0.1", r))
|
|
const port = server.address().port
|
|
|
|
const mb = (n) => (n / 1048576).toFixed(2)
|
|
let seen = 0
|
|
function report(step) {
|
|
const added = requested.slice(seen)
|
|
seen = requested.length
|
|
if (added.length === 0) return
|
|
const bytes = added.reduce(
|
|
(n, f) => n + statSync(path.join(drawioDir, f)).size,
|
|
0,
|
|
)
|
|
const names =
|
|
added.slice(0, 5).join(", ") + (added.length > 5 ? ", ..." : "")
|
|
console.log(
|
|
` ${step}: +${added.length} files, +${mb(bytes)} MB [${names}]`,
|
|
)
|
|
}
|
|
|
|
// Diagrams: a plain shape, one shape from each icon library, and the styles
|
|
// that make draw.io load code on demand (hand-drawn look, math)
|
|
const cell = (id, style, x) =>
|
|
`<mxCell id="${id}" value="${id}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="40" width="60" height="60" as="geometry"/></mxCell>`
|
|
const doc = (cells, attrs = "") =>
|
|
`<mxfile><diagram id="p" name="P"><mxGraphModel ${attrs}><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`
|
|
const LIBS = {
|
|
plain: "rounded=1;whiteSpace=wrap;html=1;",
|
|
aws4: "shape=mxgraph.aws4.resourceIcon;resIcon=mxgraph.aws4.a1_instance;fillColor=#ED7100;strokeColor=#ffffff;",
|
|
gcp2: "shape=mxgraph.gcp2.a7_power;fillColor=#4285F4;strokeColor=none;",
|
|
kubernetes:
|
|
"shape=mxgraph.kubernetes.icon;prIcon=api;fillColor=#326CE5;strokeColor=none;",
|
|
cisco19:
|
|
"shape=mxgraph.cisco19.rect;prIcon=aci;fillColor=#00bceb;strokeColor=none;",
|
|
flowchart:
|
|
"shape=mxgraph.flowchart.annotation_1;fillColor=#d5e8d4;strokeColor=#82b366;",
|
|
webicons:
|
|
"shape=mxgraph.webicons.adfty;fillColor=#3b5998;strokeColor=none;",
|
|
bpmn: "shape=mxgraph.bpmn.shape;symbol=message;outline=throwing;",
|
|
networks:
|
|
"shape=mxgraph.networks.server;fillColor=#29AAE1;strokeColor=none;",
|
|
azure2_image:
|
|
"image;aspect=fixed;image=img/lib/azure2/compute/Virtual_Machine.svg;",
|
|
sketch: "rounded=1;whiteSpace=wrap;html=1;sketch=1;",
|
|
}
|
|
const MATH_DOC = doc(
|
|
cell("math", "rounded=1;whiteSpace=wrap;html=1;", 40).replace(
|
|
'value="math"',
|
|
'value="$$x^2$$"',
|
|
),
|
|
'math="1"',
|
|
)
|
|
|
|
// Dialogs and toggles, by draw.io action name (Actions.js); each is closed
|
|
// again right after it opened. Left out on purpose, to keep the package
|
|
// small: Insert > Template (5.6 MB of templates and previews) and the
|
|
// Insert > Advanced tools that load code when used (Mermaid, PlantUML).
|
|
const ACTIONS = [
|
|
"editData",
|
|
"editDiagram",
|
|
"editStyle",
|
|
"editGeometry",
|
|
"editTooltip",
|
|
"editLink",
|
|
"insertImage",
|
|
"exportPng",
|
|
"exportSvg",
|
|
"exportJpg",
|
|
"exportWebp",
|
|
"exportPdf",
|
|
"exportXml",
|
|
"exportHtml",
|
|
"exportVsdx",
|
|
"embedHtml",
|
|
"embedImage",
|
|
"embedSvg",
|
|
"embedIframe",
|
|
"about",
|
|
"pageSetup",
|
|
"print",
|
|
"findReplace",
|
|
"tags",
|
|
"layers",
|
|
"outline",
|
|
"shapes",
|
|
"scratchpad",
|
|
"plugins",
|
|
"properties",
|
|
"fromText",
|
|
"csv",
|
|
"runLayout",
|
|
"insertFreehand",
|
|
"comments",
|
|
"customZoom",
|
|
"renamePage",
|
|
"format",
|
|
"format",
|
|
"toggleShapes",
|
|
"toggleShapes",
|
|
"darkMode",
|
|
"lightMode",
|
|
"autoMode",
|
|
"highContrast",
|
|
"highContrast",
|
|
"mathematicalTypesetting",
|
|
"mathematicalTypesetting",
|
|
"keyboardShortcuts",
|
|
]
|
|
// Enough for the other themes, whose dialogs are the same code
|
|
const LIGHT_ACTIONS = ["editData", "exportPng", "shapes", "format", "format"]
|
|
|
|
const wait = (page, ms) => page.waitForTimeout(ms)
|
|
|
|
/**
|
|
* Open the editor in one theme and use it. The default theme gets the full
|
|
* tour; the others the parts that differ by theme (chrome, menus, panels).
|
|
*/
|
|
async function drive(browser, ui, full) {
|
|
console.log(`theme ${ui}${full ? "" : " (light)"}`)
|
|
const page = await browser.newPage({
|
|
viewport: { width: 1300, height: 900 },
|
|
})
|
|
page.on("popup", (p) => p.close().catch(() => {}))
|
|
await page.goto(`http://localhost:${port}/?ui=${ui}`)
|
|
await page.waitForFunction(() => window.ready === true, null, {
|
|
timeout: 60000,
|
|
})
|
|
await wait(page, 2000)
|
|
report("editor ready")
|
|
|
|
const load = (xml) =>
|
|
page.evaluate(
|
|
(x) => window.send({ action: "load", xml: x, autosave: 1 }),
|
|
xml,
|
|
)
|
|
let x = 40
|
|
for (const [name, style] of Object.entries(LIBS)) {
|
|
await load(doc(cell(name, style, (x += 100))))
|
|
await wait(page, full ? 1500 : 500)
|
|
report(`load ${name}`)
|
|
if (!full) break
|
|
}
|
|
if (full) {
|
|
await load(MATH_DOC)
|
|
await wait(page, 2500)
|
|
report("load math")
|
|
}
|
|
|
|
// The editor instance: in embed mode draw.io calls a plugin callback
|
|
// registered after startup right away
|
|
const frame = page.frames().find((f) => f.url().includes("/drawio/"))
|
|
await frame.evaluate(() => {
|
|
window.Draw.loadPlugin((u) => {
|
|
window.__ui = u
|
|
})
|
|
window.__ui.editor.graph.selectAll()
|
|
})
|
|
const closeAll = async () => {
|
|
await frame.evaluate(() => {
|
|
const ui = window.__ui
|
|
while (ui.dialogs && ui.dialogs.length > 0) ui.hideDialog()
|
|
ui.hideCurrentMenu?.()
|
|
})
|
|
await page.keyboard.press("Escape")
|
|
await wait(page, 100)
|
|
}
|
|
|
|
let opened = 0
|
|
const actions = full ? ACTIONS : LIGHT_ACTIONS
|
|
for (const name of actions) {
|
|
const result = await frame.evaluate((n) => {
|
|
const ui = window.__ui
|
|
const action = ui.actions.get(n)
|
|
if (!action) return "missing"
|
|
try {
|
|
action.funct()
|
|
} catch (e) {
|
|
return `failed: ${e.message}`
|
|
}
|
|
return ui.dialog || ui.dialogs?.length ? "dialog" : "ok"
|
|
}, name)
|
|
if (result === "dialog") opened++
|
|
if (result !== "ok" && result !== "dialog") {
|
|
console.log(` action ${name}: ${result}`)
|
|
}
|
|
await wait(page, 400)
|
|
report(`action ${name}`)
|
|
await closeAll()
|
|
}
|
|
console.log(` ${opened} of ${actions.length} actions opened a dialog`)
|
|
|
|
// Menus: the visible menubar items, the toolbar buttons that open a menu
|
|
// in the other themes, and every menu rendered with its submenus
|
|
const menubarItems = frame.locator(".geMenubar .geItem:visible")
|
|
const menubarCount = await menubarItems.count()
|
|
for (let i = 0; i < menubarCount; i++) {
|
|
await menubarItems
|
|
.nth(i)
|
|
.click({ timeout: 2000 })
|
|
.catch(() => {})
|
|
await wait(page, 300)
|
|
await closeAll()
|
|
}
|
|
let toolbarCount = 0
|
|
for (const title of [
|
|
"Diagram",
|
|
"Insert",
|
|
"Shapes",
|
|
"Pages",
|
|
"View",
|
|
"Table",
|
|
"Layout",
|
|
]) {
|
|
const button = frame.locator(`a[title="${title}"]:visible`).first()
|
|
if ((await button.count()) === 0) continue
|
|
toolbarCount++
|
|
await button.click({ timeout: 2000 }).catch(() => {})
|
|
await wait(page, 300)
|
|
await closeAll()
|
|
}
|
|
const failed = await frame.evaluate(() => {
|
|
const ui = window.__ui
|
|
const failed = []
|
|
for (const name of Object.keys(ui.menus.menus)) {
|
|
const menu = new window.mxPopupMenu()
|
|
menu.init()
|
|
document.body.appendChild(menu.div)
|
|
try {
|
|
ui.menus.get(name).funct(menu, null)
|
|
} catch {
|
|
failed.push(name)
|
|
}
|
|
menu.destroy()
|
|
}
|
|
return failed
|
|
})
|
|
await wait(page, 500)
|
|
console.log(
|
|
` menus: ${menubarCount} menubar items, ${toolbarCount} toolbar menus clicked` +
|
|
(failed.length ? `; not rendered: ${failed.join(", ")}` : ""),
|
|
)
|
|
report("menus")
|
|
|
|
// Format panel tabs, with a shape selected
|
|
await frame.evaluate(() => window.__ui.editor.graph.selectAll())
|
|
for (const tab of ["Style", "Text", "Arrange", "Diagram"]) {
|
|
const el = frame
|
|
.locator(`.geFormatContainer div:text-is("${tab}")`)
|
|
.first()
|
|
if ((await el.count()) > 0)
|
|
await el.click({ timeout: 2000 }).catch(() => {})
|
|
await wait(page, 200)
|
|
}
|
|
report("format panel tabs")
|
|
|
|
// Shape sidebar: open every palette, then search
|
|
await frame.evaluate(() => {
|
|
const ui = window.__ui
|
|
for (const title of document.querySelectorAll(
|
|
".geSidebarContainer .geTitle",
|
|
)) {
|
|
title.click()
|
|
}
|
|
ui.sidebar.searchShapes?.("server")
|
|
})
|
|
await wait(page, full ? 2500 : 1000)
|
|
report("sidebar palettes and search")
|
|
|
|
// Pages: insert, switch, duplicate, remove, and the tab bar
|
|
for (const name of [
|
|
"insertPage",
|
|
"nextPage",
|
|
"previousPage",
|
|
"duplicatePage",
|
|
"removePage",
|
|
]) {
|
|
await frame.evaluate((n) => window.__ui.actions.get(n)?.funct(), name)
|
|
await wait(page, 150)
|
|
await closeAll()
|
|
}
|
|
for (const title of ["Insert Page", "Pages"]) {
|
|
const el = frame.locator(`a[title="${title}"]:visible`).first()
|
|
if ((await el.count()) > 0)
|
|
await el.click({ timeout: 2000 }).catch(() => {})
|
|
await wait(page, 300)
|
|
await closeAll()
|
|
}
|
|
report("page tabs")
|
|
|
|
if (full) {
|
|
for (const format of ["png", "svg", "xmlsvg", "xml"]) {
|
|
await page.evaluate(
|
|
(f) => window.send({ action: "export", format: f, scale: 2 }),
|
|
format,
|
|
)
|
|
await wait(page, 1500)
|
|
report(`export ${format}`)
|
|
}
|
|
const exports = await page.evaluate(() => window.exports)
|
|
if (exports < 4) console.log(` only ${exports} of 4 exports answered`)
|
|
}
|
|
await page.close()
|
|
}
|
|
|
|
const browser = await chromium.launch()
|
|
try {
|
|
for (const ui of THEMES) await drive(browser, ui, ui === THEMES[0])
|
|
} finally {
|
|
await browser.close()
|
|
server.close()
|
|
}
|
|
|
|
if (notFound.size > 0) {
|
|
console.log(
|
|
`\nNot in the full copy either (ignored): ${[...notFound].join(", ")}`,
|
|
)
|
|
}
|
|
|
|
// Compare with the list
|
|
const list = readFileList(LIST)
|
|
const missing = requested.filter((f) => !list.matches(f)).sort()
|
|
|
|
function* walk(dir) {
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name)
|
|
if (entry.isDirectory()) yield* walk(full)
|
|
else yield path.relative(drawioDir, full).split(path.sep).join("/")
|
|
}
|
|
}
|
|
const summarize = (fileList) => {
|
|
let count = 0
|
|
let bytes = 0
|
|
for (const rel of walk(drawioDir)) {
|
|
if (!fileList.matches(rel)) continue
|
|
count++
|
|
bytes += statSync(path.join(drawioDir, rel)).size
|
|
}
|
|
return `${count} files, ${mb(bytes)} MB unpacked`
|
|
}
|
|
|
|
if (update) {
|
|
// Only the head decides what is already covered by "always include"
|
|
const always = parseFileList(list.head)
|
|
const kept = requested.filter((f) => !always.matches(f))
|
|
writeFileSync(LIST, formatFileList(list.head, kept))
|
|
console.log(`\nWrote ${LIST}: ${summarize(readFileList(LIST))}`)
|
|
} else if (missing.length > 0) {
|
|
console.error(
|
|
`\nThe editor requested ${missing.length} files that drawio-files.txt does not list:\n ${missing.join("\n ")}\nRun with --update to add them.`,
|
|
)
|
|
process.exit(1)
|
|
} else {
|
|
console.log(`\ndrawio-files.txt is complete: ${summarize(list)}`)
|
|
}
|