@diagrammo/dgmo 0.2.19 → 0.2.21

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/er/layout.ts CHANGED
@@ -153,17 +153,59 @@ export function layoutERDiagram(parsed: ParsedERDiagram): ERLayoutResult {
153
153
  };
154
154
  });
155
155
 
156
- // Compute total dimensions
157
- let totalWidth = 0;
158
- let totalHeight = 0;
156
+ // Compute total dimensions from nodes, edge points, and labels
157
+ let minX = Infinity;
158
+ let minY = Infinity;
159
+ let maxX = 0;
160
+ let maxY = 0;
159
161
  for (const node of layoutNodes) {
162
+ const left = node.x - node.width / 2;
160
163
  const right = node.x + node.width / 2;
164
+ const top = node.y - node.height / 2;
161
165
  const bottom = node.y + node.height / 2;
162
- if (right > totalWidth) totalWidth = right;
163
- if (bottom > totalHeight) totalHeight = bottom;
166
+ if (left < minX) minX = left;
167
+ if (right > maxX) maxX = right;
168
+ if (top < minY) minY = top;
169
+ if (bottom > maxY) maxY = bottom;
164
170
  }
165
- totalWidth += 40;
166
- totalHeight += 40;
171
+ for (const edge of layoutEdges) {
172
+ for (const pt of edge.points) {
173
+ if (pt.x < minX) minX = pt.x;
174
+ if (pt.x > maxX) maxX = pt.x;
175
+ if (pt.y < minY) minY = pt.y;
176
+ if (pt.y > maxY) maxY = pt.y;
177
+ }
178
+ // Edge labels extend ~50px from midpoint
179
+ if (edge.label && edge.points.length > 0) {
180
+ const midPt = edge.points[Math.floor(edge.points.length / 2)];
181
+ const labelHalfW = (edge.label.length * 7 + 8) / 2;
182
+ if (midPt.x + labelHalfW > maxX) maxX = midPt.x + labelHalfW;
183
+ if (midPt.x - labelHalfW < minX) minX = midPt.x - labelHalfW;
184
+ }
185
+ }
186
+
187
+ // Padding for cardinality markers (~25px) + edge labels
188
+ const EDGE_MARGIN = 60;
189
+ const HALF_MARGIN = EDGE_MARGIN / 2;
190
+
191
+ // Shift all nodes and edges so content starts at HALF_MARGIN (breathing room on all sides)
192
+ const shiftX = -minX + HALF_MARGIN;
193
+ const shiftY = -minY + HALF_MARGIN;
194
+ for (const node of layoutNodes) {
195
+ node.x += shiftX;
196
+ node.y += shiftY;
197
+ }
198
+ for (const edge of layoutEdges) {
199
+ for (const pt of edge.points) {
200
+ pt.x += shiftX;
201
+ pt.y += shiftY;
202
+ }
203
+ }
204
+ maxX += shiftX;
205
+ maxY += shiftY;
206
+
207
+ const totalWidth = maxX + HALF_MARGIN;
208
+ const totalHeight = maxY + HALF_MARGIN;
167
209
 
168
210
  return {
169
211
  nodes: layoutNodes,
package/src/er/parser.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
3
4
  import type {
4
5
  ParsedERDiagram,
5
6
  ERTable,
@@ -155,6 +156,14 @@ export function parseERDiagram(
155
156
  options: {},
156
157
  tables: [],
157
158
  relationships: [],
159
+ diagnostics: [],
160
+ };
161
+
162
+ const fail = (line: number, message: string): ParsedERDiagram => {
163
+ const diag = makeDgmoError(line, message);
164
+ result.diagnostics.push(diag);
165
+ result.error = formatDgmoError(diag);
166
+ return result;
158
167
  };
159
168
 
160
169
  const tableMap = new Map<string, ERTable>();
@@ -200,8 +209,11 @@ export function parseERDiagram(
200
209
 
201
210
  if (key === 'chart') {
202
211
  if (value.toLowerCase() !== 'er') {
203
- result.error = `Line ${lineNumber}: Expected chart type "er", got "${value}"`;
204
- return result;
212
+ const allTypes = ['er', 'class', 'flowchart', 'sequence', 'org', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
213
+ let msg = `Expected chart type "er", got "${value}"`;
214
+ const hint = suggest(value.toLowerCase(), allTypes);
215
+ if (hint) msg += `. ${hint}`;
216
+ return fail(lineNumber, msg);
205
217
  }
206
218
  continue;
207
219
  }
@@ -278,8 +290,23 @@ export function parseERDiagram(
278
290
 
279
291
  // Validation
280
292
  if (result.tables.length === 0 && !result.error) {
281
- result.error =
282
- 'No tables found. Add table declarations like "users" or "orders (blue)".';
293
+ const diag = makeDgmoError(1, 'No tables found. Add table declarations like "users" or "orders (blue)".');
294
+ result.diagnostics.push(diag);
295
+ result.error = formatDgmoError(diag);
296
+ }
297
+
298
+ // Warn about isolated tables (not in any relationship)
299
+ if (result.tables.length >= 2 && result.relationships.length >= 1 && !result.error) {
300
+ const connectedIds = new Set<string>();
301
+ for (const rel of result.relationships) {
302
+ connectedIds.add(rel.source);
303
+ connectedIds.add(rel.target);
304
+ }
305
+ for (const table of result.tables) {
306
+ if (!connectedIds.has(table.id)) {
307
+ result.diagnostics.push(makeDgmoError(table.lineNumber, `Table "${table.name}" is not connected to any other table`, 'warning'));
308
+ }
309
+ }
283
310
  }
284
311
 
285
312
  return result;
@@ -17,6 +17,7 @@ import { layoutERDiagram } from './layout';
17
17
  // ============================================================
18
18
 
19
19
  const DIAGRAM_PADDING = 20;
20
+ const MAX_SCALE = 3;
20
21
  const TABLE_FONT_SIZE = 13;
21
22
  const COLUMN_FONT_SIZE = 11;
22
23
  const EDGE_LABEL_FONT_SIZE = 11;
@@ -227,7 +228,7 @@ export function renderERDiagram(
227
228
  const availH = height - titleHeight;
228
229
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
229
230
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
230
- const scale = Math.min(1, scaleX, scaleY);
231
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
231
232
 
232
233
  const scaledW = diagramW * scale;
233
234
  const scaledH = diagramH * scale;
package/src/er/types.ts CHANGED
@@ -29,6 +29,8 @@ export interface ERRelationship {
29
29
  lineNumber: number;
30
30
  }
31
31
 
32
+ import type { DgmoError } from '../diagnostics';
33
+
32
34
  export interface ParsedERDiagram {
33
35
  type: 'er';
34
36
  title?: string;
@@ -36,5 +38,6 @@ export interface ParsedERDiagram {
36
38
  options: Record<string, string>;
37
39
  tables: ERTable[];
38
40
  relationships: ERRelationship[];
41
+ diagnostics: DgmoError[];
39
42
  error?: string;
40
43
  }
@@ -1,5 +1,6 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
3
4
  import type {
4
5
  ParsedGraph,
5
6
  GraphNode,
@@ -236,6 +237,15 @@ export function parseFlowchart(
236
237
  direction: 'TB',
237
238
  nodes: [],
238
239
  edges: [],
240
+ options: {},
241
+ diagnostics: [],
242
+ };
243
+
244
+ const fail = (line: number, message: string): ParsedGraph => {
245
+ const diag = makeDgmoError(line, message);
246
+ result.diagnostics.push(diag);
247
+ result.error = formatDgmoError(diag);
248
+ return result;
239
249
  };
240
250
 
241
251
  const nodeMap = new Map<string, GraphNode>();
@@ -428,8 +438,11 @@ export function parseFlowchart(
428
438
 
429
439
  if (key === 'chart') {
430
440
  if (value.toLowerCase() !== 'flowchart') {
431
- result.error = `Line ${lineNumber}: Expected chart type "flowchart", got "${value}"`;
432
- return result;
441
+ const allTypes = ['flowchart', 'sequence', 'class', 'er', 'org', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
442
+ let msg = `Expected chart type "flowchart", got "${value}"`;
443
+ const hint = suggest(value.toLowerCase(), allTypes);
444
+ if (hint) msg += `. ${hint}`;
445
+ return fail(lineNumber, msg);
433
446
  }
434
447
  continue;
435
448
  }
@@ -448,7 +461,8 @@ export function parseFlowchart(
448
461
  continue;
449
462
  }
450
463
 
451
- // Unknown metadata skip
464
+ // Store other options (e.g., color: off)
465
+ result.options[key] = value;
452
466
  continue;
453
467
  }
454
468
 
@@ -460,7 +474,23 @@ export function parseFlowchart(
460
474
 
461
475
  // Validation: no nodes found
462
476
  if (result.nodes.length === 0 && !result.error) {
463
- result.error = 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).';
477
+ const diag = makeDgmoError(1, 'No nodes found. Add flowchart content with shape syntax like [Process] or (Start).');
478
+ result.diagnostics.push(diag);
479
+ result.error = formatDgmoError(diag);
480
+ }
481
+
482
+ // Warn about orphaned nodes (not referenced in any edge)
483
+ if (result.nodes.length >= 2 && result.edges.length >= 1 && !result.error) {
484
+ const connectedIds = new Set<string>();
485
+ for (const edge of result.edges) {
486
+ connectedIds.add(edge.source);
487
+ connectedIds.add(edge.target);
488
+ }
489
+ for (const node of result.nodes) {
490
+ if (!connectedIds.has(node.id)) {
491
+ result.diagnostics.push(makeDgmoError(node.lineNumber, `Node "${node.label}" is not connected to any other node`, 'warning'));
492
+ }
493
+ }
464
494
  }
465
495
 
466
496
  return result;
@@ -16,6 +16,7 @@ import { layoutGraph } from './layout';
16
16
  // ============================================================
17
17
 
18
18
  const DIAGRAM_PADDING = 20;
19
+ const MAX_SCALE = 3;
19
20
  const NODE_FONT_SIZE = 13;
20
21
  const EDGE_LABEL_FONT_SIZE = 11;
21
22
  const GROUP_LABEL_FONT_SIZE = 11;
@@ -43,7 +44,8 @@ function mix(a: string, b: string, pct: number): string {
43
44
  return `#${c(ar,br)}${c(ag,bg)}${c(ab,bb)}`;
44
45
  }
45
46
 
46
- function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTerminal?: boolean): string {
47
+ function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTerminal?: boolean, colorOff?: boolean): string {
48
+ if (colorOff) return palette.textMuted;
47
49
  switch (shape) {
48
50
  case 'terminal': return isEndTerminal ? palette.colors.red : palette.colors.green;
49
51
  case 'process': return palette.colors.blue;
@@ -54,13 +56,13 @@ function shapeDefaultColor(shape: GraphShape, palette: PaletteColors, isEndTermi
54
56
  }
55
57
  }
56
58
 
57
- function nodeFill(palette: PaletteColors, isDark: boolean, shape: GraphShape, nodeColor?: string, isEndTerminal?: boolean): string {
58
- const color = nodeColor ?? shapeDefaultColor(shape, palette, isEndTerminal);
59
+ function nodeFill(palette: PaletteColors, isDark: boolean, shape: GraphShape, nodeColor?: string, isEndTerminal?: boolean, colorOff?: boolean): string {
60
+ const color = nodeColor ?? shapeDefaultColor(shape, palette, isEndTerminal, colorOff);
59
61
  return mix(color, isDark ? palette.surface : palette.bg, 25);
60
62
  }
61
63
 
62
- function nodeStroke(palette: PaletteColors, shape: GraphShape, nodeColor?: string, isEndTerminal?: boolean): string {
63
- return nodeColor ?? shapeDefaultColor(shape, palette, isEndTerminal);
64
+ function nodeStroke(palette: PaletteColors, shape: GraphShape, nodeColor?: string, isEndTerminal?: boolean, colorOff?: boolean): string {
65
+ return nodeColor ?? shapeDefaultColor(shape, palette, isEndTerminal, colorOff);
64
66
  }
65
67
 
66
68
  // ============================================================
@@ -69,7 +71,7 @@ function nodeStroke(palette: PaletteColors, shape: GraphShape, nodeColor?: strin
69
71
 
70
72
  type GSelection = d3Selection.Selection<SVGGElement, unknown, null, undefined>;
71
73
 
72
- function renderTerminal(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, isEnd: boolean): void {
74
+ function renderTerminal(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, isEnd: boolean, colorOff?: boolean): void {
73
75
  const w = node.width;
74
76
  const h = node.height;
75
77
  const rx = h / 2;
@@ -80,12 +82,12 @@ function renderTerminal(g: GSelection, node: LayoutNode, palette: PaletteColors,
80
82
  .attr('height', h)
81
83
  .attr('rx', rx)
82
84
  .attr('ry', rx)
83
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color, isEnd))
84
- .attr('stroke', nodeStroke(palette, node.shape, node.color, isEnd))
85
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, isEnd, colorOff))
86
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, isEnd, colorOff))
85
87
  .attr('stroke-width', NODE_STROKE_WIDTH);
86
88
  }
87
89
 
88
- function renderProcess(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
90
+ function renderProcess(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
89
91
  const w = node.width;
90
92
  const h = node.height;
91
93
  g.append('rect')
@@ -95,12 +97,12 @@ function renderProcess(g: GSelection, node: LayoutNode, palette: PaletteColors,
95
97
  .attr('height', h)
96
98
  .attr('rx', 3)
97
99
  .attr('ry', 3)
98
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
99
- .attr('stroke', nodeStroke(palette, node.shape, node.color))
100
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
101
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, undefined, colorOff))
100
102
  .attr('stroke-width', NODE_STROKE_WIDTH);
101
103
  }
102
104
 
103
- function renderDecision(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
105
+ function renderDecision(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
104
106
  const w = node.width / 2;
105
107
  const h = node.height / 2;
106
108
  const points = [
@@ -111,12 +113,12 @@ function renderDecision(g: GSelection, node: LayoutNode, palette: PaletteColors,
111
113
  ].join(' ');
112
114
  g.append('polygon')
113
115
  .attr('points', points)
114
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
115
- .attr('stroke', nodeStroke(palette, node.shape, node.color))
116
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
117
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, undefined, colorOff))
116
118
  .attr('stroke-width', NODE_STROKE_WIDTH);
117
119
  }
118
120
 
119
- function renderIO(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
121
+ function renderIO(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
120
122
  const w = node.width / 2;
121
123
  const h = node.height / 2;
122
124
  const sk = IO_SKEW;
@@ -128,15 +130,15 @@ function renderIO(g: GSelection, node: LayoutNode, palette: PaletteColors, isDar
128
130
  ].join(' ');
129
131
  g.append('polygon')
130
132
  .attr('points', points)
131
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
132
- .attr('stroke', nodeStroke(palette, node.shape, node.color))
133
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
134
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, undefined, colorOff))
133
135
  .attr('stroke-width', NODE_STROKE_WIDTH);
134
136
  }
135
137
 
136
- function renderSubroutine(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
138
+ function renderSubroutine(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
137
139
  const w = node.width;
138
140
  const h = node.height;
139
- const s = nodeStroke(palette, node.shape, node.color);
141
+ const s = nodeStroke(palette, node.shape, node.color, undefined, colorOff);
140
142
  // Outer rectangle
141
143
  g.append('rect')
142
144
  .attr('x', -w / 2)
@@ -145,7 +147,7 @@ function renderSubroutine(g: GSelection, node: LayoutNode, palette: PaletteColor
145
147
  .attr('height', h)
146
148
  .attr('rx', 3)
147
149
  .attr('ry', 3)
148
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
150
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
149
151
  .attr('stroke', s)
150
152
  .attr('stroke-width', NODE_STROKE_WIDTH);
151
153
  // Left inner border
@@ -166,7 +168,7 @@ function renderSubroutine(g: GSelection, node: LayoutNode, palette: PaletteColor
166
168
  .attr('stroke-width', NODE_STROKE_WIDTH);
167
169
  }
168
170
 
169
- function renderDocument(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean): void {
171
+ function renderDocument(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, colorOff?: boolean): void {
170
172
  const w = node.width;
171
173
  const h = node.height;
172
174
  const waveH = DOC_WAVE_HEIGHT;
@@ -186,30 +188,30 @@ function renderDocument(g: GSelection, node: LayoutNode, palette: PaletteColors,
186
188
 
187
189
  g.append('path')
188
190
  .attr('d', d)
189
- .attr('fill', nodeFill(palette, isDark, node.shape, node.color))
190
- .attr('stroke', nodeStroke(palette, node.shape, node.color))
191
+ .attr('fill', nodeFill(palette, isDark, node.shape, node.color, undefined, colorOff))
192
+ .attr('stroke', nodeStroke(palette, node.shape, node.color, undefined, colorOff))
191
193
  .attr('stroke-width', NODE_STROKE_WIDTH);
192
194
  }
193
195
 
194
- function renderNodeShape(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, endTerminalIds: Set<string>): void {
196
+ function renderNodeShape(g: GSelection, node: LayoutNode, palette: PaletteColors, isDark: boolean, endTerminalIds: Set<string>, colorOff?: boolean): void {
195
197
  switch (node.shape) {
196
198
  case 'terminal':
197
- renderTerminal(g, node, palette, isDark, endTerminalIds.has(node.id));
199
+ renderTerminal(g, node, palette, isDark, endTerminalIds.has(node.id), colorOff);
198
200
  break;
199
201
  case 'process':
200
- renderProcess(g, node, palette, isDark);
202
+ renderProcess(g, node, palette, isDark, colorOff);
201
203
  break;
202
204
  case 'decision':
203
- renderDecision(g, node, palette, isDark);
205
+ renderDecision(g, node, palette, isDark, colorOff);
204
206
  break;
205
207
  case 'io':
206
- renderIO(g, node, palette, isDark);
208
+ renderIO(g, node, palette, isDark, colorOff);
207
209
  break;
208
210
  case 'subroutine':
209
- renderSubroutine(g, node, palette, isDark);
211
+ renderSubroutine(g, node, palette, isDark, colorOff);
210
212
  break;
211
213
  case 'document':
212
- renderDocument(g, node, palette, isDark);
214
+ renderDocument(g, node, palette, isDark, colorOff);
213
215
  break;
214
216
  }
215
217
  }
@@ -251,7 +253,7 @@ export function renderFlowchart(
251
253
  const availH = height - titleHeight;
252
254
  const scaleX = (width - DIAGRAM_PADDING * 2) / diagramW;
253
255
  const scaleY = (availH - DIAGRAM_PADDING * 2) / diagramH;
254
- const scale = Math.min(1, scaleX, scaleY);
256
+ const scale = Math.min(MAX_SCALE, scaleX, scaleY);
255
257
 
256
258
  // Center the diagram in the area below the title
257
259
  const scaledW = diagramW * scale;
@@ -443,6 +445,7 @@ export function renderFlowchart(
443
445
  }
444
446
 
445
447
  // Render nodes (top layer)
448
+ const colorOff = graph.options?.color === 'off';
446
449
  for (const node of layout.nodes) {
447
450
  const nodeG = contentG
448
451
  .append('g')
@@ -458,7 +461,7 @@ export function renderFlowchart(
458
461
  }
459
462
 
460
463
  // Shape
461
- renderNodeShape(nodeG as GSelection, node, palette, isDark, endTerminalIds);
464
+ renderNodeShape(nodeG as GSelection, node, palette, isDark, endTerminalIds, colorOff);
462
465
 
463
466
  // Label
464
467
  nodeG
@@ -33,6 +33,8 @@ export interface GraphGroup {
33
33
  lineNumber: number;
34
34
  }
35
35
 
36
+ import type { DgmoError } from '../diagnostics';
37
+
36
38
  export interface ParsedGraph {
37
39
  type: 'flowchart';
38
40
  title?: string;
@@ -41,5 +43,7 @@ export interface ParsedGraph {
41
43
  nodes: GraphNode[];
42
44
  edges: GraphEdge[];
43
45
  groups?: GraphGroup[];
46
+ options: Record<string, string>;
47
+ diagnostics: DgmoError[];
44
48
  error?: string;
45
49
  }
package/src/index.ts CHANGED
@@ -1,3 +1,10 @@
1
+ // ============================================================
2
+ // Diagnostics
3
+ // ============================================================
4
+
5
+ export { makeDgmoError, formatDgmoError } from './diagnostics';
6
+ export type { DgmoError, DgmoSeverity } from './diagnostics';
7
+
1
8
  // ============================================================
2
9
  // Unified API
3
10
  // ============================================================
@@ -11,6 +18,7 @@ export { render } from './render';
11
18
  export {
12
19
  parseDgmoChartType,
13
20
  getDgmoFramework,
21
+ parseDgmo,
14
22
  DGMO_CHART_TYPE_MAP,
15
23
  } from './dgmo-router';
16
24
  export type { DgmoFramework } from './dgmo-router';
@@ -136,9 +144,23 @@ export type {
136
144
 
137
145
  export { renderOrg, renderOrgForExport } from './org/renderer';
138
146
 
147
+ export { parseKanban } from './kanban/parser';
148
+ export type {
149
+ ParsedKanban,
150
+ KanbanColumn,
151
+ KanbanCard,
152
+ KanbanTagGroup,
153
+ KanbanTagEntry,
154
+ } from './kanban/types';
155
+ export { computeCardMove, computeCardArchive, isArchiveColumn } from './kanban/mutations';
156
+ export { renderKanban, renderKanbanForExport } from './kanban/renderer';
157
+
139
158
  export { collapseOrgTree } from './org/collapse';
140
159
  export type { CollapsedOrgResult } from './org/collapse';
141
160
 
161
+ export { resolveOrgImports } from './org/resolver';
162
+ export type { ReadFileFn, ResolveImportsResult } from './org/resolver';
163
+
142
164
  export { layoutGraph } from './graph/layout';
143
165
  export type {
144
166
  LayoutResult,
@@ -0,0 +1,183 @@
1
+ import type { ParsedKanban, KanbanCard, KanbanColumn } from './types';
2
+
3
+ const ARCHIVE_COLUMN_NAME = 'archive';
4
+
5
+ // ============================================================
6
+ // computeCardMove — pure function for source text mutation
7
+ // ============================================================
8
+
9
+ /**
10
+ * Compute new file content after moving a card to a different position.
11
+ *
12
+ * @param content - original file content string
13
+ * @param parsed - parsed kanban board
14
+ * @param cardId - id of the card to move
15
+ * @param targetColumnId - id of the destination column
16
+ * @param targetIndex - position within target column (0 = first card)
17
+ * @returns new content string, or null if move is invalid
18
+ */
19
+ export function computeCardMove(
20
+ content: string,
21
+ parsed: ParsedKanban,
22
+ cardId: string,
23
+ targetColumnId: string,
24
+ targetIndex: number
25
+ ): string | null {
26
+ // Find source card and column
27
+ let sourceCard: KanbanCard | null = null;
28
+ let sourceColumn: KanbanColumn | null = null;
29
+
30
+ for (const col of parsed.columns) {
31
+ for (const card of col.cards) {
32
+ if (card.id === cardId) {
33
+ sourceCard = card;
34
+ sourceColumn = col;
35
+ break;
36
+ }
37
+ }
38
+ if (sourceCard) break;
39
+ }
40
+
41
+ if (!sourceCard || !sourceColumn) return null;
42
+
43
+ const targetColumn = parsed.columns.find((c) => c.id === targetColumnId);
44
+ if (!targetColumn) return null;
45
+
46
+ const lines = content.split('\n');
47
+
48
+ // Extract the card's lines (0-based indices)
49
+ const startIdx = sourceCard.lineNumber - 1;
50
+ const endIdx = sourceCard.endLineNumber - 1;
51
+ const cardLines = lines.slice(startIdx, endIdx + 1);
52
+
53
+ // Remove the card lines from content
54
+ const withoutCard = [
55
+ ...lines.slice(0, startIdx),
56
+ ...lines.slice(endIdx + 1),
57
+ ];
58
+
59
+ // Compute insertion point (0-based index in withoutCard)
60
+ let insertIdx: number;
61
+
62
+ // Adjust target column and card line numbers after removal
63
+ // Lines after the removed range shift up by the number of removed lines
64
+ const removedCount = endIdx - startIdx + 1;
65
+ const adjustLine = (ln: number): number => {
66
+ // ln is 1-based
67
+ if (ln > endIdx + 1) return ln - removedCount;
68
+ return ln;
69
+ };
70
+
71
+ if (targetIndex === 0) {
72
+ // Insert right after column header line
73
+ const adjColLine = adjustLine(targetColumn.lineNumber);
74
+ insertIdx = adjColLine; // 0-based: insert after the column header line
75
+ } else {
76
+ // Insert after the preceding card's last line
77
+ // Get the cards in the target column, excluding the moved card
78
+ const targetCards = targetColumn.cards.filter((c) => c.id !== cardId);
79
+ const clampedIdx = Math.min(targetIndex, targetCards.length);
80
+ const precedingCard = targetCards[clampedIdx - 1];
81
+ if (!precedingCard) {
82
+ // Fallback: after column header
83
+ const adjColLine = adjustLine(targetColumn.lineNumber);
84
+ insertIdx = adjColLine;
85
+ } else {
86
+ const adjEndLine = adjustLine(precedingCard.endLineNumber);
87
+ insertIdx = adjEndLine; // 0-based: insert after this line
88
+ }
89
+ }
90
+
91
+ // Splice the card lines into the new position
92
+ const result = [
93
+ ...withoutCard.slice(0, insertIdx),
94
+ ...cardLines,
95
+ ...withoutCard.slice(insertIdx),
96
+ ];
97
+
98
+ return result.join('\n');
99
+ }
100
+
101
+ // ============================================================
102
+ // computeCardArchive — move card to an Archive section
103
+ // ============================================================
104
+
105
+ /**
106
+ * Move a card to the Archive section at the end of the file.
107
+ * Creates `== Archive ==` if it doesn't exist.
108
+ *
109
+ * @returns new content string, or null if the card is not found
110
+ */
111
+ export function computeCardArchive(
112
+ content: string,
113
+ parsed: ParsedKanban,
114
+ cardId: string
115
+ ): string | null {
116
+ let sourceCard: KanbanCard | null = null;
117
+ for (const col of parsed.columns) {
118
+ for (const card of col.cards) {
119
+ if (card.id === cardId) {
120
+ sourceCard = card;
121
+ break;
122
+ }
123
+ }
124
+ if (sourceCard) break;
125
+ }
126
+ if (!sourceCard) return null;
127
+
128
+ const lines = content.split('\n');
129
+
130
+ // Extract card lines
131
+ const startIdx = sourceCard.lineNumber - 1;
132
+ const endIdx = sourceCard.endLineNumber - 1;
133
+ const cardLines = lines.slice(startIdx, endIdx + 1);
134
+
135
+ // Remove card from its current position
136
+ const withoutCard = [
137
+ ...lines.slice(0, startIdx),
138
+ ...lines.slice(endIdx + 1),
139
+ ];
140
+
141
+ // Check if an Archive column already exists
142
+ const archiveCol = parsed.columns.find(
143
+ (c) => c.name.toLowerCase() === ARCHIVE_COLUMN_NAME
144
+ );
145
+
146
+ if (archiveCol) {
147
+ // Append to existing archive column
148
+ // Find the last line of the archive column (after removal adjustment)
149
+ const removedCount = endIdx - startIdx + 1;
150
+ let archiveEndLine = archiveCol.lineNumber;
151
+ if (archiveCol.cards.length > 0) {
152
+ const lastCard = archiveCol.cards[archiveCol.cards.length - 1];
153
+ archiveEndLine = lastCard.endLineNumber;
154
+ }
155
+ // Adjust for removed lines
156
+ if (archiveEndLine > endIdx + 1) {
157
+ archiveEndLine -= removedCount;
158
+ }
159
+ // Insert after the archive end (0-based in withoutCard)
160
+ const insertIdx = archiveEndLine; // archiveEndLine is 1-based, so this is after that line
161
+ return [
162
+ ...withoutCard.slice(0, insertIdx),
163
+ ...cardLines,
164
+ ...withoutCard.slice(insertIdx),
165
+ ].join('\n');
166
+ } else {
167
+ // Create archive section at end of file
168
+ // Ensure trailing newline before the new section
169
+ const trimmedEnd = withoutCard.length > 0 && withoutCard[withoutCard.length - 1].trim() === ''
170
+ ? withoutCard
171
+ : [...withoutCard, ''];
172
+ return [
173
+ ...trimmedEnd,
174
+ '== Archive ==',
175
+ ...cardLines,
176
+ ].join('\n');
177
+ }
178
+ }
179
+
180
+ /** Check if a column name is the archive column (case-insensitive). */
181
+ export function isArchiveColumn(name: string): boolean {
182
+ return name.toLowerCase() === ARCHIVE_COLUMN_NAME;
183
+ }