@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/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 are add_node, add_edge, update and delete. Cell ids must be stable and unique. Inspect the file first when modifying an existing diagram.',
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', required: true }, label: { type: 'string' }, style: { type: 'string' }, parent: { 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 === 'read') return respondJson(res, 200, { ok: true, value: { content: await ctx.fs.readText(target) } })
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
- assertDrawioPath(path)
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('Draw.io file must stay inside the current workspace.')
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: cells.filter(cell => cell.getAttribute('vertex') === '1').map(cell => cellSummary(cell)),
18
- edges: cells.filter(cell => cell.getAttribute('edge') === '1').map(cell => ({
19
- id: cell.getAttribute('id'),
20
- label: cell.getAttribute('value') || '',
21
- source: cell.getAttribute('source') || '',
22
- target: cell.getAttribute('target') || '',
23
- style: cell.getAttribute('style') || '',
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
- const pages = pageNodes(document)
33
- const page = selectPage(pages, pageSelector)
34
- const model = modelForPage(page)
35
- const root = model.getElementsByTagName('root')[0]
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) applyOperation(document, root, operation)
39
- replacePageModel(document, page, model)
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(/&nbsp;/g, ' ')
118
+ .replace(/&amp;/g, '&')
119
+ .replace(/&lt;/g, '<')
120
+ .replace(/&gt;/g, '>')
121
+ .replace(/&quot;/g, '"')
122
+ .replace(/&#39;/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)) {