@civilization/dsh-drawio 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -6
- package/lib/client.js +361 -41
- package/package.json +2 -1
- package/skills/drawio-operation/SKILL.md +80 -0
- package/src/client/index.tsx +381 -46
- package/src/index.js +64 -13
- package/src/xml.js +119 -14
package/src/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { createReadStream } from 'node:fs'
|
|
2
|
-
import { stat } from 'node:fs/promises'
|
|
2
|
+
import { stat, readFile } from 'node:fs/promises'
|
|
3
3
|
import { extname, join, normalize, relative, sep } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
6
6
|
import { editDrawio, inspectDrawio, normalizeDrawio } from './xml.js'
|
|
7
7
|
|
|
8
8
|
export const name = 'dsh-drawio'
|
|
9
|
-
export const inject = ['tools', 'fs', 'webServer']
|
|
9
|
+
export const inject = ['tools', 'fs', 'webServer', 'skills']
|
|
10
10
|
|
|
11
11
|
const runtimeRoot = fileURLToPath(new URL('../vendor/drawio/', import.meta.url))
|
|
12
12
|
const MAX_DIAGRAM_BYTES = 10 * 1024 * 1024
|
|
@@ -17,7 +17,20 @@ const output = {
|
|
|
17
17
|
}
|
|
18
18
|
const pathParameter = { type: 'string', required: true, description: 'Path to a .drawio file, relative to the current DSH workspace or absolute within the allowed workspace.' }
|
|
19
19
|
|
|
20
|
-
export function apply(ctx) {
|
|
20
|
+
export async function apply(ctx) {
|
|
21
|
+
try {
|
|
22
|
+
const skillPath = new URL('../skills/drawio-operation/SKILL.md', import.meta.url)
|
|
23
|
+
const skillText = await readFile(skillPath, 'utf8')
|
|
24
|
+
const content = skillText.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '').trim()
|
|
25
|
+
ctx.effect(() => ctx.skills?.register({
|
|
26
|
+
name: 'drawio-operation',
|
|
27
|
+
description: 'Inspect, edit, and create Draw.io diagrams with the local canvas and model tools.',
|
|
28
|
+
whenToUse: 'Use for creating, reading, or modifying architecture diagrams, flowcharts, or Draw.io XML files.',
|
|
29
|
+
source: 'bundled',
|
|
30
|
+
content,
|
|
31
|
+
}))
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
21
34
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-drawio/runtime', handler: serveRuntime }))
|
|
22
35
|
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-drawio/api', handler: (req, res) => serveCanvasApi(ctx, req, res) }))
|
|
23
36
|
register(ctx, defineTool({
|
|
@@ -33,7 +46,7 @@ export function apply(ctx) {
|
|
|
33
46
|
}))
|
|
34
47
|
register(ctx, defineTool({
|
|
35
48
|
name: 'drawio_edit',
|
|
36
|
-
description: 'Apply a batch of semantic edits to an existing .drawio file. Supported operations
|
|
49
|
+
description: 'Apply a batch of semantic edits to an existing .drawio file. Supported operations: add_node, add_edge, update, delete, add_page, delete_page, rename_page. Cell ids must be stable and unique. Inspect the file first when modifying an existing diagram.',
|
|
37
50
|
parameters: {
|
|
38
51
|
path: pathParameter,
|
|
39
52
|
page: { oneOf: [{ type: 'string' }, { type: 'number' }] },
|
|
@@ -42,8 +55,9 @@ export function apply(ctx) {
|
|
|
42
55
|
items: {
|
|
43
56
|
type: 'object', additionalProperties: false,
|
|
44
57
|
properties: {
|
|
45
|
-
type: { type: 'string', enum: ['add_node', 'add_edge', 'update', 'delete'], required: true },
|
|
46
|
-
id: { type: 'string'
|
|
58
|
+
type: { type: 'string', enum: ['add_node', 'add_edge', 'update', 'delete', 'add_page', 'delete_page', 'rename_page'], required: true },
|
|
59
|
+
id: { type: 'string' }, label: { type: 'string' }, name: { type: 'string' }, page: { oneOf: [{ type: 'string' }, { type: 'number' }] },
|
|
60
|
+
style: { type: 'string' }, stylePatch: { type: 'object', additionalProperties: true }, parent: { type: 'string' },
|
|
47
61
|
source: { type: 'string' }, target: { type: 'string' },
|
|
48
62
|
x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' },
|
|
49
63
|
},
|
|
@@ -87,31 +101,68 @@ async function serveCanvasApi(ctx, req, res) {
|
|
|
87
101
|
try {
|
|
88
102
|
const pathname = new URL(req.url || '/', 'http://dsh.internal').pathname
|
|
89
103
|
const method = pathname.slice('/dsh-drawio/api/'.length)
|
|
90
|
-
if (!['read', 'write'].includes(method)) return respondJson(res, 404, { ok: false, error: { message: 'Unknown Draw.io operation.' } })
|
|
104
|
+
if (!['stat', 'read', 'write', 'save-image'].includes(method)) return respondJson(res, 404, { ok: false, error: { message: 'Unknown Draw.io operation.' } })
|
|
91
105
|
const payload = await readJsonBody(req)
|
|
106
|
+
|
|
107
|
+
if (method === 'save-image') {
|
|
108
|
+
if (typeof payload.data !== 'string' || !payload.data.trim()) throw new Error('Image data is required.')
|
|
109
|
+
const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path, true)
|
|
110
|
+
let buffer
|
|
111
|
+
if (payload.data.startsWith('data:')) {
|
|
112
|
+
const comma = payload.data.indexOf(',')
|
|
113
|
+
const base64 = comma >= 0 ? payload.data.slice(comma + 1) : payload.data
|
|
114
|
+
buffer = Buffer.from(base64, 'base64')
|
|
115
|
+
} else if (payload.data.startsWith('<svg') || payload.data.startsWith('<?xml')) {
|
|
116
|
+
buffer = Buffer.from(payload.data, 'utf8')
|
|
117
|
+
} else {
|
|
118
|
+
buffer = Buffer.from(payload.data, 'base64')
|
|
119
|
+
}
|
|
120
|
+
if (buffer.length > MAX_REQUEST_BYTES) throw new Error('Image data is too large to save safely.')
|
|
121
|
+
const sandboxPolicy = { mode: 'workspace-write', workspaceRoot: payload.cwd }
|
|
122
|
+
const outcome = typeof ctx.fs.writeFile === 'function'
|
|
123
|
+
? await ctx.fs.writeFile(target, buffer, { kind: 'replace' }, undefined, sandboxPolicy)
|
|
124
|
+
: typeof ctx.fs.writeBinary === 'function'
|
|
125
|
+
? await ctx.fs.writeBinary(target, buffer, { kind: 'replace' }, undefined, sandboxPolicy)
|
|
126
|
+
: await ctx.fs.writeText(target, payload.path.endsWith('.svg') ? buffer.toString('utf8') : buffer.toString('base64'), { kind: 'replace' }, undefined, sandboxPolicy)
|
|
127
|
+
ctx.emit?.('fs/observed', target, { kind: 'present', version: outcome?.version }, undefined)
|
|
128
|
+
return respondJson(res, 200, { ok: true, value: { path: target.displayPath ?? payload.path, bytes: buffer.length } })
|
|
129
|
+
}
|
|
130
|
+
|
|
92
131
|
const target = await resolveCanvasTarget(ctx, payload.cwd, payload.path)
|
|
93
132
|
const info = await ctx.fs.stat(target)
|
|
94
133
|
if (!info || info.type !== 'file') throw new Error('Draw.io file was not found.')
|
|
95
134
|
if (Number(info.size) > MAX_DIAGRAM_BYTES) throw new Error('Draw.io file is too large to open safely.')
|
|
96
|
-
if (method === '
|
|
135
|
+
if (method === 'stat') return respondJson(res, 200, { ok: true, value: { version: info.version, size: Number(info.size), mtime: info.mtime } })
|
|
136
|
+
if (method === 'read') return respondJson(res, 200, { ok: true, value: { content: await ctx.fs.readText(target), version: info.version } })
|
|
97
137
|
if (typeof payload.content !== 'string' || Buffer.byteLength(payload.content) > MAX_DIAGRAM_BYTES) throw new Error('Draw.io content is too large to save safely.')
|
|
138
|
+
if (payload.version !== undefined && info.version !== undefined && String(payload.version) !== String(info.version)) {
|
|
139
|
+
return respondJson(res, 409, { ok: false, conflict: true, error: { message: 'File has been modified by an external process or agent.' } })
|
|
140
|
+
}
|
|
98
141
|
const xml = normalizeDrawio(payload.content)
|
|
99
142
|
const sandboxPolicy = { mode: 'workspace-write', workspaceRoot: payload.cwd }
|
|
100
|
-
await ctx.fs.writeText(target, xml, { kind: 'replaceIfVersion', version: info.version }, undefined, sandboxPolicy)
|
|
101
|
-
return respondJson(res, 200, { ok: true, value: { ok: true } })
|
|
143
|
+
const outcome = await ctx.fs.writeText(target, xml, { kind: 'replaceIfVersion', version: payload.version ?? info.version }, undefined, sandboxPolicy)
|
|
144
|
+
return respondJson(res, 200, { ok: true, value: { ok: true, version: outcome?.version ?? info.version } })
|
|
102
145
|
} catch (error) {
|
|
103
146
|
return respondJson(res, 400, { ok: false, error: { message: String(error?.message || error).slice(0, 500) } })
|
|
104
147
|
}
|
|
105
148
|
}
|
|
106
149
|
|
|
107
|
-
async function resolveCanvasTarget(ctx, cwd, path) {
|
|
150
|
+
async function resolveCanvasTarget(ctx, cwd, path, allowImage = false) {
|
|
108
151
|
if (typeof cwd !== 'string' || !cwd.trim()) throw new Error('A workspace is required.')
|
|
109
|
-
|
|
152
|
+
if (allowImage) {
|
|
153
|
+
assertExportImagePath(path)
|
|
154
|
+
} else {
|
|
155
|
+
assertDrawioPath(path)
|
|
156
|
+
}
|
|
110
157
|
const [root, target] = await Promise.all([ctx.fs.resolve(cwd), ctx.fs.resolve(path, { cwd })])
|
|
111
|
-
if (!ctx.fs.contains(root, target)) throw new Error('
|
|
158
|
+
if (!ctx.fs.contains(root, target)) throw new Error('File must stay inside the current workspace.')
|
|
112
159
|
return target
|
|
113
160
|
}
|
|
114
161
|
|
|
162
|
+
function assertExportImagePath(value) {
|
|
163
|
+
if (typeof value !== 'string' || !/\.(png|svg|xml\.png)$/i.test(value)) throw new Error('path must end with .png or .svg')
|
|
164
|
+
}
|
|
165
|
+
|
|
115
166
|
async function readJsonBody(req) {
|
|
116
167
|
const declared = Number(req.headers?.['content-length'])
|
|
117
168
|
if (Number.isFinite(declared) && declared > MAX_REQUEST_BYTES) throw new Error('Request body is too large.')
|
package/src/xml.js
CHANGED
|
@@ -10,18 +10,42 @@ export function inspectDrawio(source, includeXml = false) {
|
|
|
10
10
|
const pages = pageNodes(document).map((page, index) => {
|
|
11
11
|
const model = modelForPage(page)
|
|
12
12
|
const cells = Array.from(model.getElementsByTagName('mxCell'))
|
|
13
|
+
const nodeCells = cells.filter(cell => cell.getAttribute('vertex') === '1')
|
|
14
|
+
const edgeCells = cells.filter(cell => cell.getAttribute('edge') === '1')
|
|
15
|
+
const childMap = new Map()
|
|
16
|
+
for (const cell of nodeCells) {
|
|
17
|
+
childMap.set(cell.getAttribute('id'), [])
|
|
18
|
+
}
|
|
19
|
+
for (const cell of nodeCells) {
|
|
20
|
+
const parentId = cell.getAttribute('parent')
|
|
21
|
+
if (parentId && childMap.has(parentId)) {
|
|
22
|
+
childMap.get(parentId).push(cell.getAttribute('id'))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
13
25
|
return {
|
|
14
26
|
index,
|
|
15
27
|
id: page.getAttribute('id') || `page-${index + 1}`,
|
|
16
28
|
name: page.getAttribute('name') || `Page-${index + 1}`,
|
|
17
|
-
nodes:
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
nodes: nodeCells.map(cell => {
|
|
30
|
+
const id = cell.getAttribute('id')
|
|
31
|
+
const rawValue = cell.getAttribute('value') || ''
|
|
32
|
+
return {
|
|
33
|
+
...cellSummary(cell),
|
|
34
|
+
plainText: stripHtml(rawValue),
|
|
35
|
+
children: childMap.get(id) || [],
|
|
36
|
+
}
|
|
37
|
+
}),
|
|
38
|
+
edges: edgeCells.map(cell => {
|
|
39
|
+
const rawValue = cell.getAttribute('value') || ''
|
|
40
|
+
return {
|
|
41
|
+
id: cell.getAttribute('id'),
|
|
42
|
+
label: rawValue,
|
|
43
|
+
plainText: stripHtml(rawValue),
|
|
44
|
+
source: cell.getAttribute('source') || '',
|
|
45
|
+
target: cell.getAttribute('target') || '',
|
|
46
|
+
style: cell.getAttribute('style') || '',
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
25
49
|
}
|
|
26
50
|
})
|
|
27
51
|
return { pages, ...(includeXml ? { xml: serializer.serializeToString(document) } : {}) }
|
|
@@ -29,14 +53,51 @@ export function inspectDrawio(source, includeXml = false) {
|
|
|
29
53
|
|
|
30
54
|
export function editDrawio(source, operations, pageSelector) {
|
|
31
55
|
const document = parseDrawio(source || EMPTY_DRAWIO)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
56
|
+
let pages = pageNodes(document)
|
|
57
|
+
let currentPage = selectPage(pages, pageSelector)
|
|
58
|
+
let model = modelForPage(currentPage)
|
|
59
|
+
let root = model.getElementsByTagName('root')[0]
|
|
36
60
|
if (!root) throw new Error('The selected page has no mxGraphModel/root element.')
|
|
37
61
|
|
|
38
|
-
for (const operation of operations)
|
|
39
|
-
|
|
62
|
+
for (const operation of operations) {
|
|
63
|
+
if (!operation || typeof operation !== 'object') throw new Error('Every operation must be an object.')
|
|
64
|
+
const { type } = operation
|
|
65
|
+
if (type === 'add_page') {
|
|
66
|
+
const newPageId = operation.id || `page-${Date.now().toString(36)}`
|
|
67
|
+
const newPageName = operation.name || `Page-${pages.length + 1}`
|
|
68
|
+
const newDiagram = document.createElement('diagram')
|
|
69
|
+
newDiagram.setAttribute('id', newPageId)
|
|
70
|
+
newDiagram.setAttribute('name', newPageName)
|
|
71
|
+
const templateDoc = parseDrawio(EMPTY_DRAWIO)
|
|
72
|
+
const templateModel = modelForPage(pageNodes(templateDoc)[0])
|
|
73
|
+
newDiagram.appendChild(document.importNode(templateModel, true))
|
|
74
|
+
document.documentElement.appendChild(newDiagram)
|
|
75
|
+
pages = pageNodes(document)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (type === 'delete_page') {
|
|
79
|
+
if (pages.length <= 1) throw new Error('Cannot delete the only remaining diagram page.')
|
|
80
|
+
const targetPage = selectPage(pages, operation.page)
|
|
81
|
+
document.documentElement.removeChild(targetPage)
|
|
82
|
+
pages = pageNodes(document)
|
|
83
|
+
if (currentPage === targetPage) {
|
|
84
|
+
currentPage = pages[0]
|
|
85
|
+
model = modelForPage(currentPage)
|
|
86
|
+
root = model.getElementsByTagName('root')[0]
|
|
87
|
+
}
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (type === 'rename_page') {
|
|
91
|
+
const targetPage = operation.page !== undefined ? selectPage(pages, operation.page) : currentPage
|
|
92
|
+
const newName = required(operation.name, 'rename_page.name')
|
|
93
|
+
targetPage.setAttribute('name', newName)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
applyOperation(document, root, operation)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
replacePageModel(document, currentPage, model)
|
|
40
101
|
document.documentElement.setAttribute('compressed', 'false')
|
|
41
102
|
document.documentElement.setAttribute('modified', new Date().toISOString())
|
|
42
103
|
return serializer.serializeToString(document)
|
|
@@ -49,6 +110,46 @@ export function normalizeDrawio(source) {
|
|
|
49
110
|
return serializer.serializeToString(document)
|
|
50
111
|
}
|
|
51
112
|
|
|
113
|
+
export function stripHtml(input) {
|
|
114
|
+
if (!input || typeof input !== 'string') return ''
|
|
115
|
+
return input
|
|
116
|
+
.replace(/<[^>]+>/g, ' ')
|
|
117
|
+
.replace(/ /g, ' ')
|
|
118
|
+
.replace(/&/g, '&')
|
|
119
|
+
.replace(/</g, '<')
|
|
120
|
+
.replace(/>/g, '>')
|
|
121
|
+
.replace(/"/g, '"')
|
|
122
|
+
.replace(/'/g, "'")
|
|
123
|
+
.replace(/\s+/g, ' ')
|
|
124
|
+
.trim()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function patchStyle(currentStyle = '', patch = {}) {
|
|
128
|
+
const parts = currentStyle.split(';').map(s => s.trim()).filter(Boolean)
|
|
129
|
+
const map = new Map()
|
|
130
|
+
const bareKeys = []
|
|
131
|
+
for (const part of parts) {
|
|
132
|
+
const eqIdx = part.indexOf('=')
|
|
133
|
+
if (eqIdx >= 0) {
|
|
134
|
+
map.set(part.slice(0, eqIdx), part.slice(eqIdx + 1))
|
|
135
|
+
} else {
|
|
136
|
+
bareKeys.push(part)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const [key, val] of Object.entries(patch)) {
|
|
140
|
+
if (val === null || val === undefined || val === '') {
|
|
141
|
+
map.delete(key)
|
|
142
|
+
} else {
|
|
143
|
+
map.set(key, String(val))
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const serialized = [
|
|
147
|
+
...bareKeys,
|
|
148
|
+
...Array.from(map.entries()).map(([k, v]) => `${k}=${v}`),
|
|
149
|
+
].join(';')
|
|
150
|
+
return serialized ? `${serialized};` : ''
|
|
151
|
+
}
|
|
152
|
+
|
|
52
153
|
function applyOperation(document, root, operation) {
|
|
53
154
|
if (!operation || typeof operation !== 'object') throw new Error('Every operation must be an object.')
|
|
54
155
|
const type = operation.type
|
|
@@ -97,6 +198,10 @@ function applyOperation(document, root, operation) {
|
|
|
97
198
|
const cell = assertCell(root, required(operation.id, 'update.id'))
|
|
98
199
|
if (operation.label !== undefined) cell.setAttribute('value', stringValue(operation.label))
|
|
99
200
|
if (operation.style !== undefined) cell.setAttribute('style', stringValue(operation.style))
|
|
201
|
+
if (operation.stylePatch && typeof operation.stylePatch === 'object') {
|
|
202
|
+
const current = cell.getAttribute('style') || ''
|
|
203
|
+
cell.setAttribute('style', patchStyle(current, operation.stylePatch))
|
|
204
|
+
}
|
|
100
205
|
if (operation.parent !== undefined) cell.setAttribute('parent', stringValue(operation.parent))
|
|
101
206
|
const geometryKeys = ['x', 'y', 'width', 'height']
|
|
102
207
|
if (geometryKeys.some(key => operation[key] !== undefined)) {
|