structured-context 0.9.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 +348 -0
- package/dist/commands/diagram.d.ts +5 -0
- package/dist/commands/diagram.js +12 -0
- package/dist/commands/docs.d.ts +1 -0
- package/dist/commands/docs.js +67 -0
- package/dist/commands/dump.d.ts +2 -0
- package/dist/commands/dump.js +6 -0
- package/dist/commands/plugins.d.ts +1 -0
- package/dist/commands/plugins.js +23 -0
- package/dist/commands/render.d.ts +6 -0
- package/dist/commands/render.js +35 -0
- package/dist/commands/schemas.d.ts +6 -0
- package/dist/commands/schemas.js +268 -0
- package/dist/commands/show.d.ts +4 -0
- package/dist/commands/show.js +7 -0
- package/dist/commands/spaces.d.ts +1 -0
- package/dist/commands/spaces.js +36 -0
- package/dist/commands/template-sync.d.ts +3 -0
- package/dist/commands/template-sync.js +13 -0
- package/dist/commands/validate-file.d.ts +28 -0
- package/dist/commands/validate-file.js +133 -0
- package/dist/commands/validate.d.ts +16 -0
- package/dist/commands/validate.js +349 -0
- package/dist/config.d.ts +38 -0
- package/dist/config.js +179 -0
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +6 -0
- package/dist/filter/augment-nodes.d.ts +23 -0
- package/dist/filter/augment-nodes.js +95 -0
- package/dist/filter/expand-include.d.ts +62 -0
- package/dist/filter/expand-include.js +181 -0
- package/dist/filter/filter-nodes.d.ts +21 -0
- package/dist/filter/filter-nodes.js +73 -0
- package/dist/filter/parse-expression.d.ts +20 -0
- package/dist/filter/parse-expression.js +60 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +161 -0
- package/dist/integrations/miro/cache.d.ts +21 -0
- package/dist/integrations/miro/cache.js +55 -0
- package/dist/integrations/miro/client.d.ts +99 -0
- package/dist/integrations/miro/client.js +118 -0
- package/dist/integrations/miro/layout.d.ts +28 -0
- package/dist/integrations/miro/layout.js +72 -0
- package/dist/integrations/miro/styles.d.ts +11 -0
- package/dist/integrations/miro/styles.js +65 -0
- package/dist/integrations/miro/sync.d.ts +8 -0
- package/dist/integrations/miro/sync.js +347 -0
- package/dist/plugin-api.d.ts +12 -0
- package/dist/plugin-api.js +7 -0
- package/dist/plugins/index.d.ts +3 -0
- package/dist/plugins/index.js +3 -0
- package/dist/plugins/loader.d.ts +21 -0
- package/dist/plugins/loader.js +104 -0
- package/dist/plugins/markdown/index.d.ts +48 -0
- package/dist/plugins/markdown/index.js +51 -0
- package/dist/plugins/markdown/parse-embedded.d.ts +90 -0
- package/dist/plugins/markdown/parse-embedded.js +663 -0
- package/dist/plugins/markdown/read-space.d.ts +7 -0
- package/dist/plugins/markdown/read-space.js +89 -0
- package/dist/plugins/markdown/render-bullets.d.ts +2 -0
- package/dist/plugins/markdown/render-bullets.js +42 -0
- package/dist/plugins/markdown/render-mermaid.d.ts +2 -0
- package/dist/plugins/markdown/render-mermaid.js +57 -0
- package/dist/plugins/markdown/template-sync.d.ts +16 -0
- package/dist/plugins/markdown/template-sync.js +294 -0
- package/dist/plugins/markdown/util.d.ts +19 -0
- package/dist/plugins/markdown/util.js +80 -0
- package/dist/plugins/util.d.ts +60 -0
- package/dist/plugins/util.js +7 -0
- package/dist/read/read-space.d.ts +2 -0
- package/dist/read/read-space.js +22 -0
- package/dist/read/resolve-graph-edges.d.ts +11 -0
- package/dist/read/resolve-graph-edges.js +201 -0
- package/dist/read/wikilink-utils.d.ts +16 -0
- package/dist/read/wikilink-utils.js +38 -0
- package/dist/render/registry.d.ts +13 -0
- package/dist/render/registry.js +22 -0
- package/dist/render/render.d.ts +4 -0
- package/dist/render/render.js +28 -0
- package/dist/schema/evaluate-rule.d.ts +30 -0
- package/dist/schema/evaluate-rule.js +82 -0
- package/dist/schema/metadata-contract.d.ts +538 -0
- package/dist/schema/metadata-contract.js +115 -0
- package/dist/schema/schema-refs.d.ts +22 -0
- package/dist/schema/schema-refs.js +168 -0
- package/dist/schema/schema.d.ts +27 -0
- package/dist/schema/schema.js +378 -0
- package/dist/schema/validate-graph.d.ts +24 -0
- package/dist/schema/validate-graph.js +141 -0
- package/dist/schema/validate-rules.d.ts +10 -0
- package/dist/schema/validate-rules.js +51 -0
- package/dist/schemas/_ost_strict.json +81 -0
- package/dist/schemas/_sctx_base.json +72 -0
- package/dist/schemas/general.json +261 -0
- package/dist/schemas/generated/_structured_context_schema_meta.json +191 -0
- package/dist/schemas/knowledge_wiki.json +206 -0
- package/dist/schemas/strict_ost.json +97 -0
- package/dist/space-graph.d.ts +28 -0
- package/dist/space-graph.js +82 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.js +0 -0
- package/docs/concepts.md +391 -0
- package/docs/config.md +140 -0
- package/docs/rules.md +120 -0
- package/docs/schemas.md +340 -0
- package/package.json +69 -0
- package/schemas/_ost_strict.json +81 -0
- package/schemas/_sctx_base.json +72 -0
- package/schemas/general.json +261 -0
- package/schemas/generated/_structured_context_schema_meta.json +191 -0
- package/schemas/knowledge_wiki.json +206 -0
- package/schemas/strict_ost.json +97 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { updateSpaceField } from '../../config';
|
|
2
|
+
import { readSpace } from '../../read/read-space';
|
|
3
|
+
import { buildSpaceGraph } from '../../space-graph';
|
|
4
|
+
import { computeMiroCardHash, computeNodeHash, loadCache, saveCache } from './cache';
|
|
5
|
+
import { MiroClient, MiroNotFoundError } from './client';
|
|
6
|
+
import { CARD_WIDTH, layoutNewCards } from './layout';
|
|
7
|
+
import { buildCardDescription, buildCardTitle, getCardColor } from './styles';
|
|
8
|
+
export async function miroSync(context, options) {
|
|
9
|
+
const token = process.env.MIRO_TOKEN;
|
|
10
|
+
if (!token) {
|
|
11
|
+
console.error('MIRO_TOKEN environment variable is required');
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
const { space, schema: { metadata }, } = context;
|
|
15
|
+
// 1. Resolve board
|
|
16
|
+
if (!space.miroBoardId) {
|
|
17
|
+
console.error(`No miroBoardId configured for space "${space.name}".`);
|
|
18
|
+
console.error('Add miroBoardId to the space entry in config.');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const boardId = space.miroBoardId;
|
|
22
|
+
// 2. Resolve frame
|
|
23
|
+
if (!space.miroFrameId && !options.newFrame) {
|
|
24
|
+
console.error('No miroFrameId in space config. Pass --new-frame "Title" to create one.');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
// 3. Load space nodes (load before creating frame so we can calculate size)
|
|
28
|
+
let nodes;
|
|
29
|
+
({ nodes } = await readSpace(context));
|
|
30
|
+
if (nodes.length === 0) {
|
|
31
|
+
console.log('No space nodes found.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
// Filter to hierarchy nodes only
|
|
35
|
+
const levels = metadata.hierarchy?.levels ?? [];
|
|
36
|
+
const { nonHierarchy, hierarchyTitles: hierarchyNodeTitles } = buildSpaceGraph(nodes, levels);
|
|
37
|
+
// Filter nodes to only hierarchy nodes
|
|
38
|
+
nodes = nodes.filter((n) => hierarchyNodeTitles.has(n.title));
|
|
39
|
+
if (options.verbose && nonHierarchy.length > 0) {
|
|
40
|
+
console.log(`Excluded ${nonHierarchy.length} non-hierarchy nodes from sync`);
|
|
41
|
+
}
|
|
42
|
+
const client = new MiroClient(boardId, token);
|
|
43
|
+
let frameId;
|
|
44
|
+
let layoutOffset = null;
|
|
45
|
+
if (options.newFrame) {
|
|
46
|
+
// Calculate layout bounds to size the frame appropriately
|
|
47
|
+
const { bounds } = layoutNewCards(nodes, new Map(), levels);
|
|
48
|
+
const frameWidth = Math.max(1600, bounds.maxX - bounds.minX);
|
|
49
|
+
const frameHeight = Math.max(1200, bounds.maxY - bounds.minY);
|
|
50
|
+
// Miro positions child items relative to the parent frame's top-left corner.
|
|
51
|
+
// We shift card positions so the layout bounds start at (0, 0) in frame-local space.
|
|
52
|
+
layoutOffset = {
|
|
53
|
+
x: -bounds.minX,
|
|
54
|
+
y: -bounds.minY,
|
|
55
|
+
};
|
|
56
|
+
// Add padding around the calculated bounds for visual breathing room
|
|
57
|
+
const padding = 200;
|
|
58
|
+
const finalFrameWidth = frameWidth + padding * 2;
|
|
59
|
+
const finalFrameHeight = frameHeight + padding * 2;
|
|
60
|
+
if (options.dryRun) {
|
|
61
|
+
console.log(`[dry-run] Would create frame: "${options.newFrame}" (size: ${finalFrameWidth}x${finalFrameHeight})`);
|
|
62
|
+
frameId = 'dry-run-frame-id';
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const frame = await client.createFrame({
|
|
66
|
+
data: { title: options.newFrame, type: 'freeform' },
|
|
67
|
+
position: { x: 0, y: 0, origin: 'center' },
|
|
68
|
+
geometry: { width: finalFrameWidth, height: finalFrameHeight },
|
|
69
|
+
});
|
|
70
|
+
frameId = frame.id;
|
|
71
|
+
console.log(`Created frame "${options.newFrame}" (${frameId}) - size: ${finalFrameWidth}x${finalFrameHeight}`);
|
|
72
|
+
updateSpaceField(space.name, 'miroFrameId', frameId);
|
|
73
|
+
console.log(`Saved miroFrameId to config`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
frameId = space.miroFrameId;
|
|
78
|
+
}
|
|
79
|
+
// 4. Load cache
|
|
80
|
+
const cache = loadCache(boardId, frameId);
|
|
81
|
+
cache.spaceName = space.name;
|
|
82
|
+
// 5. Verify cache against actual board state (skip for new frames)
|
|
83
|
+
// Fetch all cards from the frame to build a verified mapping
|
|
84
|
+
const existingPositions = new Map();
|
|
85
|
+
const verifiedCardIds = new Map(); // title → cardId (only cards that exist on board)
|
|
86
|
+
const miroCardData = new Map(); // cardId → actual Miro data
|
|
87
|
+
const staleCacheEntries = []; // titles with cached card IDs that no longer exist
|
|
88
|
+
// Skip fetching frame items for new frames (there are no existing cards)
|
|
89
|
+
if (!options.newFrame) {
|
|
90
|
+
// Build reverse lookup map (cardId → title) for O(1) cache lookup
|
|
91
|
+
const cacheByCardId = new Map();
|
|
92
|
+
for (const [title, cached] of Object.entries(cache.nodes)) {
|
|
93
|
+
cacheByCardId.set(cached.miroCardId, title);
|
|
94
|
+
}
|
|
95
|
+
const frameItems = await client.getItemsInFrame(frameId);
|
|
96
|
+
const boardCardIds = new Set();
|
|
97
|
+
for (const item of frameItems) {
|
|
98
|
+
if (item.type === 'card' && item.position && item.data) {
|
|
99
|
+
boardCardIds.add(item.id);
|
|
100
|
+
miroCardData.set(item.id, {
|
|
101
|
+
title: item.data.title,
|
|
102
|
+
description: item.data.description ?? '',
|
|
103
|
+
});
|
|
104
|
+
// Find which cached node this card belongs to using reverse lookup
|
|
105
|
+
const title = cacheByCardId.get(item.id);
|
|
106
|
+
if (title) {
|
|
107
|
+
existingPositions.set(title, {
|
|
108
|
+
x: item.position.x,
|
|
109
|
+
y: item.position.y,
|
|
110
|
+
});
|
|
111
|
+
verifiedCardIds.set(title, item.id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Detect stale cache entries (cached card IDs not on board)
|
|
116
|
+
for (const [title, cached] of Object.entries(cache.nodes)) {
|
|
117
|
+
if (!boardCardIds.has(cached.miroCardId)) {
|
|
118
|
+
staleCacheEntries.push(title);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (staleCacheEntries.length > 0) {
|
|
122
|
+
if (options.verbose || options.dryRun) {
|
|
123
|
+
console.log(`Found ${staleCacheEntries.length} stale cache entries (cards deleted from board)`);
|
|
124
|
+
for (const title of staleCacheEntries) {
|
|
125
|
+
console.log(` - "${title}" (will recreate)`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// 6. Determine which nodes are new vs updated vs unchanged
|
|
131
|
+
// Compare actual Miro card content against node (not cached hash)
|
|
132
|
+
const newNodes = [];
|
|
133
|
+
const updatedNodes = [];
|
|
134
|
+
let skippedCount = 0;
|
|
135
|
+
for (const node of nodes) {
|
|
136
|
+
const title = node.title;
|
|
137
|
+
// Compute what we expect to be in Miro (using the same build functions)
|
|
138
|
+
const expectedTitle = buildCardTitle(node);
|
|
139
|
+
const expectedDesc = buildCardDescription(node);
|
|
140
|
+
const expectedHash = computeMiroCardHash(expectedTitle, expectedDesc);
|
|
141
|
+
// Check if there's a verified card on the board for this node
|
|
142
|
+
const verifiedCardId = verifiedCardIds.get(title);
|
|
143
|
+
if (!verifiedCardId) {
|
|
144
|
+
// No card on board - needs to be created
|
|
145
|
+
newNodes.push(node);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
// Card exists - compare actual Miro content against expected node content
|
|
149
|
+
const miroData = miroCardData.get(verifiedCardId);
|
|
150
|
+
if (miroData) {
|
|
151
|
+
const miroHash = computeMiroCardHash(miroData.title, miroData.description);
|
|
152
|
+
if (miroHash !== expectedHash) {
|
|
153
|
+
// Miro content differs from node - needs update
|
|
154
|
+
if (options.verbose) {
|
|
155
|
+
console.log(`"${title}" differs from Miro:`);
|
|
156
|
+
console.log(` Expected: "${JSON.stringify(expectedTitle)}"`);
|
|
157
|
+
console.log(` Miro has: "${JSON.stringify(miroData.title)}"`);
|
|
158
|
+
console.log(` Expected desc: "${JSON.stringify(expectedDesc)}"`);
|
|
159
|
+
console.log(` Miro desc: "${JSON.stringify(miroData.description)}"`);
|
|
160
|
+
}
|
|
161
|
+
updatedNodes.push({ node, cardId: verifiedCardId });
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
skippedCount++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
// Card ID exists but we couldn't fetch data - recreate
|
|
169
|
+
newNodes.push(node);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Compute positions for new cards
|
|
174
|
+
const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, levels);
|
|
175
|
+
// 7. Create new cards
|
|
176
|
+
let createdCount = 0;
|
|
177
|
+
for (const node of newNodes) {
|
|
178
|
+
const title = node.title;
|
|
179
|
+
const type = node.schemaData.type;
|
|
180
|
+
let pos = newPositions.get(title) ?? { x: 0, y: 0 };
|
|
181
|
+
// Apply offset if we created a new frame (to center layout in frame)
|
|
182
|
+
if (layoutOffset) {
|
|
183
|
+
pos = { x: pos.x + layoutOffset.x, y: pos.y + layoutOffset.y };
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// For existing frames without layout offset, place at origin for simplicity
|
|
187
|
+
// User can manually rearrange or re-sync with --new-frame for better layout
|
|
188
|
+
pos = { x: 0, y: 0 };
|
|
189
|
+
}
|
|
190
|
+
if (options.dryRun) {
|
|
191
|
+
console.log(`[dry-run] Create card: "${title}" (${type}) at (${pos.x}, ${pos.y})`);
|
|
192
|
+
// For dry-run, use a fake ID so connectors can be calculated
|
|
193
|
+
verifiedCardIds.set(title, `dry-run-card-${title}`);
|
|
194
|
+
createdCount++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (options.verbose)
|
|
198
|
+
console.log(`Creating card: "${title}" (${type}) at (${pos.x}, ${pos.y})`);
|
|
199
|
+
const card = await client.createCard({
|
|
200
|
+
data: {
|
|
201
|
+
title: buildCardTitle(node),
|
|
202
|
+
description: buildCardDescription(node),
|
|
203
|
+
},
|
|
204
|
+
style: { cardTheme: getCardColor(type, levels) },
|
|
205
|
+
position: { x: pos.x, y: pos.y, origin: 'center' },
|
|
206
|
+
parent: { id: frameId },
|
|
207
|
+
geometry: { width: CARD_WIDTH },
|
|
208
|
+
});
|
|
209
|
+
cache.nodes[title] = {
|
|
210
|
+
miroCardId: card.id,
|
|
211
|
+
contentHash: computeNodeHash(node),
|
|
212
|
+
};
|
|
213
|
+
verifiedCardIds.set(title, card.id); // Add to verified set so connectors can use it
|
|
214
|
+
createdCount++;
|
|
215
|
+
}
|
|
216
|
+
// 8. Update changed cards
|
|
217
|
+
let updatedCount = 0;
|
|
218
|
+
for (const { node, cardId } of updatedNodes) {
|
|
219
|
+
const title = node.title;
|
|
220
|
+
if (options.dryRun) {
|
|
221
|
+
console.log(`[dry-run] Update card: "${title}"`);
|
|
222
|
+
updatedCount++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (options.verbose)
|
|
226
|
+
console.log(`Updating card: "${title}"`);
|
|
227
|
+
try {
|
|
228
|
+
await client.updateCard(cardId, {
|
|
229
|
+
data: {
|
|
230
|
+
title: buildCardTitle(node),
|
|
231
|
+
description: buildCardDescription(node),
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
cache.nodes[title] = {
|
|
235
|
+
miroCardId: cardId,
|
|
236
|
+
contentHash: computeNodeHash(node),
|
|
237
|
+
};
|
|
238
|
+
updatedCount++;
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
if (e instanceof MiroNotFoundError) {
|
|
242
|
+
// Card was deleted from Miro — recreate it
|
|
243
|
+
console.log(`Card "${title}" missing from Miro, recreating...`);
|
|
244
|
+
const type = node.schemaData.type;
|
|
245
|
+
const card = await client.createCard({
|
|
246
|
+
data: {
|
|
247
|
+
title: buildCardTitle(node),
|
|
248
|
+
description: buildCardDescription(node),
|
|
249
|
+
},
|
|
250
|
+
style: { cardTheme: getCardColor(type, levels) },
|
|
251
|
+
position: { x: 0, y: 0, origin: 'center' },
|
|
252
|
+
parent: { id: frameId },
|
|
253
|
+
geometry: { width: CARD_WIDTH },
|
|
254
|
+
});
|
|
255
|
+
cache.nodes[title] = {
|
|
256
|
+
miroCardId: card.id,
|
|
257
|
+
contentHash: computeNodeHash(node),
|
|
258
|
+
};
|
|
259
|
+
createdCount++;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
throw e;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// 9. Sync connectors
|
|
267
|
+
const prefix = options.dryRun ? '[dry-run] ' : '';
|
|
268
|
+
let connectorsCreated = 0;
|
|
269
|
+
let connectorsDeleted = 0;
|
|
270
|
+
// Build desired parent→child pairs from OST data
|
|
271
|
+
// Only include edges where both endpoints have verified cards on the board
|
|
272
|
+
const desiredEdges = new Map();
|
|
273
|
+
for (const node of nodes) {
|
|
274
|
+
const childTitle = node.title;
|
|
275
|
+
for (const { title: parentTitle, source } of node.resolvedParents) {
|
|
276
|
+
if (source !== 'hierarchy')
|
|
277
|
+
continue;
|
|
278
|
+
// Both endpoints must have verified cards on the board
|
|
279
|
+
if (verifiedCardIds.has(parentTitle) && verifiedCardIds.has(childTitle)) {
|
|
280
|
+
const key = `${parentTitle}\u2192${childTitle}`;
|
|
281
|
+
desiredEdges.set(key, { parentTitle, childTitle });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Build cardId → title mapping from VERIFIED cards only
|
|
286
|
+
const cardIdToTitle = new Map();
|
|
287
|
+
for (const [title, cardId] of verifiedCardIds.entries()) {
|
|
288
|
+
cardIdToTitle.set(cardId, title);
|
|
289
|
+
}
|
|
290
|
+
// Find existing connectors that we created (from cache)
|
|
291
|
+
const existingConnectorIds = new Set(Object.values(cache.connectors).map((c) => c.miroConnectorId));
|
|
292
|
+
// Verify our cached connectors still exist and connect our cards
|
|
293
|
+
const allConnectors = await client.getConnectors();
|
|
294
|
+
const validCachedEdges = new Map(); // edge key → connector ID
|
|
295
|
+
for (const conn of allConnectors) {
|
|
296
|
+
// Skip connectors not in our cache
|
|
297
|
+
if (!existingConnectorIds.has(conn.id))
|
|
298
|
+
continue;
|
|
299
|
+
// Skip connectors that don't connect two items
|
|
300
|
+
if (!conn.startItem || !conn.endItem)
|
|
301
|
+
continue;
|
|
302
|
+
const startTitle = cardIdToTitle.get(conn.startItem.id);
|
|
303
|
+
const endTitle = cardIdToTitle.get(conn.endItem.id);
|
|
304
|
+
if (startTitle && endTitle) {
|
|
305
|
+
validCachedEdges.set(`${startTitle}\u2192${endTitle}`, conn.id);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Create missing connectors
|
|
309
|
+
for (const [key, { parentTitle, childTitle }] of desiredEdges) {
|
|
310
|
+
if (!validCachedEdges.has(key)) {
|
|
311
|
+
if (options.verbose || options.dryRun)
|
|
312
|
+
console.log(`${prefix}Creating connector: ${parentTitle} -> ${childTitle}`);
|
|
313
|
+
if (!options.dryRun) {
|
|
314
|
+
const conn = await client.createConnector(verifiedCardIds.get(parentTitle), verifiedCardIds.get(childTitle));
|
|
315
|
+
cache.connectors[key] = { miroConnectorId: conn.id };
|
|
316
|
+
}
|
|
317
|
+
connectorsCreated++;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Delete ONLY connectors we created that are no longer valid
|
|
321
|
+
for (const [key, cached] of Object.entries(cache.connectors)) {
|
|
322
|
+
// Skip if this edge is still desired
|
|
323
|
+
if (desiredEdges.has(key))
|
|
324
|
+
continue;
|
|
325
|
+
// Check if the connector still exists in Miro and connects our cards
|
|
326
|
+
if (validCachedEdges.has(key)) {
|
|
327
|
+
if (options.verbose || options.dryRun)
|
|
328
|
+
console.log(`${prefix}Deleting stale connector: ${key}`);
|
|
329
|
+
if (!options.dryRun) {
|
|
330
|
+
await client.deleteConnector(cached.miroConnectorId);
|
|
331
|
+
connectorsDeleted++;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// Remove from cache regardless (it's either deleted or invalid)
|
|
335
|
+
if (!options.dryRun) {
|
|
336
|
+
delete cache.connectors[key];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// 10. Save cache
|
|
340
|
+
cache.lastSync = new Date().toISOString();
|
|
341
|
+
if (!options.dryRun)
|
|
342
|
+
saveCache(cache);
|
|
343
|
+
// Summary
|
|
344
|
+
console.log(`\n${prefix}Sync complete:`);
|
|
345
|
+
console.log(` Cards: ${createdCount} created, ${updatedCount} updated, ${skippedCount} unchanged`);
|
|
346
|
+
console.log(` Connectors: ${connectorsCreated} created, ${connectorsDeleted} deleted`);
|
|
347
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for external structured-context plugins.
|
|
3
|
+
*
|
|
4
|
+
* Import from this module to get the types needed to implement a StructuredContextPlugin:
|
|
5
|
+
*
|
|
6
|
+
* import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/plugin-api';
|
|
7
|
+
*/
|
|
8
|
+
export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
|
|
9
|
+
export type { ParseHook, ParseResult, PluginContext, RenderFormat, RenderHook, RenderOptions, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
|
|
10
|
+
export type { SharedEmbeddingFields } from './schema/metadata-contract';
|
|
11
|
+
export type { SpaceGraph } from './space-graph';
|
|
12
|
+
export type { BaseNode, EdgeDefinition, HierarchyLevel, Relationship, SchemaMetadata, SchemaWithMetadata, SpaceContext, SpaceNode, UnresolvedRef, } from './types';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API for external structured-context plugins.
|
|
3
|
+
*
|
|
4
|
+
* Import from this module to get the types needed to implement a StructuredContextPlugin:
|
|
5
|
+
*
|
|
6
|
+
* import type { StructuredContextPlugin, PluginContext, ParseResult } from 'structured-context/plugin-api';
|
|
7
|
+
*/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type StructuredContextPlugin } from './util';
|
|
2
|
+
export type LoadedPlugin = {
|
|
3
|
+
plugin: StructuredContextPlugin;
|
|
4
|
+
pluginConfig: Record<string, unknown>;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Discover all available plugins: built-ins first, then any config-adjacent plugins found
|
|
8
|
+
* in {configDir}/plugins/ across all loaded config files. Does not load npm plugins
|
|
9
|
+
* (those are only declared in space configs).
|
|
10
|
+
*/
|
|
11
|
+
export declare function discoverPlugins(): Promise<StructuredContextPlugin[]>;
|
|
12
|
+
/**
|
|
13
|
+
* Load plugins for a space.
|
|
14
|
+
*
|
|
15
|
+
* Built-in plugins are always included (with config from the map if declared, else {}).
|
|
16
|
+
* External plugins are loaded from the map and prepended in declaration order.
|
|
17
|
+
* Resolution order for external plugins: config-adjacent → npm.
|
|
18
|
+
* Fields annotated with format:'path' in a plugin's configSchema are resolved
|
|
19
|
+
* relative to configDir.
|
|
20
|
+
*/
|
|
21
|
+
export declare function loadPlugins(pluginMap: Record<string, Record<string, unknown>>, configDir: string): Promise<LoadedPlugin[]>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
import Ajv, {} from 'ajv';
|
|
4
|
+
import { getConfigSourceFiles } from '../config';
|
|
5
|
+
import { builtinPlugins } from '.';
|
|
6
|
+
import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX } from './util';
|
|
7
|
+
/**
|
|
8
|
+
* Walk a plugin's configSchema and resolve any string fields annotated with
|
|
9
|
+
* format:'path' relative to configDir.
|
|
10
|
+
*/
|
|
11
|
+
function resolveConfigPaths(schema, config, configDir) {
|
|
12
|
+
const props = schema.properties;
|
|
13
|
+
if (!props)
|
|
14
|
+
return config;
|
|
15
|
+
const result = { ...config };
|
|
16
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
17
|
+
if (propSchema.format === 'path' && typeof result[key] === 'string') {
|
|
18
|
+
const value = result[key];
|
|
19
|
+
if (!isAbsolute(value)) {
|
|
20
|
+
result[key] = resolve(configDir, value);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve an external plugin by canonical name.
|
|
28
|
+
* Resolution order: config-adjacent ({configDir}/plugins/{name}) → npm (import(name)).
|
|
29
|
+
*/
|
|
30
|
+
async function resolveExternalPlugin(name, configDir) {
|
|
31
|
+
const localPath = resolve(join(configDir, CONFIG_PLUGINS_DIR, name));
|
|
32
|
+
const module = existsSync(localPath) || existsSync(`${localPath}.ts`) ? await import(localPath) : await import(name);
|
|
33
|
+
const plugin = module.default ?? module;
|
|
34
|
+
if (!plugin || typeof plugin.name !== 'string') {
|
|
35
|
+
throw new Error(`Plugin "${name}" must export a StructuredContextPlugin as its default export`);
|
|
36
|
+
}
|
|
37
|
+
return plugin;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Discover all available plugins: built-ins first, then any config-adjacent plugins found
|
|
41
|
+
* in {configDir}/plugins/ across all loaded config files. Does not load npm plugins
|
|
42
|
+
* (those are only declared in space configs).
|
|
43
|
+
*/
|
|
44
|
+
export async function discoverPlugins() {
|
|
45
|
+
// Convert the Set of files into a Set of dirs
|
|
46
|
+
const configDirs = [...new Set(Array.from(getConfigSourceFiles()).map((f) => dirname(f)))];
|
|
47
|
+
const plugins = [...builtinPlugins];
|
|
48
|
+
const seenNames = new Set(builtinPlugins.map((p) => p.name));
|
|
49
|
+
for (const configDir of configDirs) {
|
|
50
|
+
const pluginsDir = join(configDir, CONFIG_PLUGINS_DIR);
|
|
51
|
+
if (existsSync(pluginsDir)) {
|
|
52
|
+
const entries = readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX));
|
|
53
|
+
for (const entry of entries) {
|
|
54
|
+
if (!seenNames.has(entry)) {
|
|
55
|
+
plugins.push(await resolveExternalPlugin(entry, configDir));
|
|
56
|
+
seenNames.add(entry);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return plugins;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Load plugins for a space.
|
|
65
|
+
*
|
|
66
|
+
* Built-in plugins are always included (with config from the map if declared, else {}).
|
|
67
|
+
* External plugins are loaded from the map and prepended in declaration order.
|
|
68
|
+
* Resolution order for external plugins: config-adjacent → npm.
|
|
69
|
+
* Fields annotated with format:'path' in a plugin's configSchema are resolved
|
|
70
|
+
* relative to configDir.
|
|
71
|
+
*/
|
|
72
|
+
export async function loadPlugins(pluginMap, configDir) {
|
|
73
|
+
const builtinsByName = new Map(builtinPlugins.map((p) => [p.name, p]));
|
|
74
|
+
const ajv = new Ajv();
|
|
75
|
+
ajv.addFormat('path', (value) => value.length > 0 && !value.includes('\0'));
|
|
76
|
+
const loaded = [];
|
|
77
|
+
// External plugins: entries in the map that are not built-in names
|
|
78
|
+
for (const [rawName, rawConfig] of Object.entries(pluginMap)) {
|
|
79
|
+
const name = normalizePluginName(rawName);
|
|
80
|
+
if (builtinsByName.has(name))
|
|
81
|
+
continue;
|
|
82
|
+
if (!name.startsWith(PLUGIN_PREFIX)) {
|
|
83
|
+
throw new Error(`Plugin name must start with "${PLUGIN_PREFIX}" (got "${rawName}")`);
|
|
84
|
+
}
|
|
85
|
+
const plugin = await resolveExternalPlugin(name, configDir);
|
|
86
|
+
const pluginConfig = resolveConfigPaths(plugin.configSchema, rawConfig, configDir);
|
|
87
|
+
const validate = ajv.compile(plugin.configSchema);
|
|
88
|
+
if (!validate(pluginConfig)) {
|
|
89
|
+
throw new Error(`Invalid config for plugin "${name}": ${JSON.stringify(validate.errors)}`);
|
|
90
|
+
}
|
|
91
|
+
loaded.push({ plugin, pluginConfig });
|
|
92
|
+
}
|
|
93
|
+
// Built-in plugins: always loaded, config taken from map if declared (with or without prefix)
|
|
94
|
+
for (const builtin of builtinPlugins) {
|
|
95
|
+
const rawConfig = pluginMap[builtin.name] ?? pluginMap[builtin.name.slice(PLUGIN_PREFIX.length)] ?? {};
|
|
96
|
+
const pluginConfig = resolveConfigPaths(builtin.configSchema, rawConfig, configDir);
|
|
97
|
+
const validate = ajv.compile(builtin.configSchema);
|
|
98
|
+
if (!validate(pluginConfig)) {
|
|
99
|
+
throw new Error(`Invalid config for plugin "${builtin.name}": ${JSON.stringify(validate.errors)}`);
|
|
100
|
+
}
|
|
101
|
+
loaded.push({ plugin: builtin, pluginConfig });
|
|
102
|
+
}
|
|
103
|
+
return loaded;
|
|
104
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { StructuredContextPlugin } from '../util';
|
|
2
|
+
export type TypeInferenceConfig = {
|
|
3
|
+
mode?: 'folder-name' | 'off';
|
|
4
|
+
folderMap?: Record<string, string>;
|
|
5
|
+
};
|
|
6
|
+
export type MarkdownPluginConfig = {
|
|
7
|
+
templateDir?: string;
|
|
8
|
+
fieldMap?: Record<string, string>;
|
|
9
|
+
templatePrefix?: string;
|
|
10
|
+
typeInference?: TypeInferenceConfig;
|
|
11
|
+
};
|
|
12
|
+
export declare const MARKDOWN_CONFIG_SCHEMA: {
|
|
13
|
+
type: string;
|
|
14
|
+
properties: {
|
|
15
|
+
templateDir: {
|
|
16
|
+
type: string;
|
|
17
|
+
format: string;
|
|
18
|
+
};
|
|
19
|
+
fieldMap: {
|
|
20
|
+
type: string;
|
|
21
|
+
additionalProperties: {
|
|
22
|
+
type: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
templatePrefix: {
|
|
26
|
+
type: string;
|
|
27
|
+
};
|
|
28
|
+
typeInference: {
|
|
29
|
+
type: string;
|
|
30
|
+
properties: {
|
|
31
|
+
mode: {
|
|
32
|
+
type: string;
|
|
33
|
+
enum: string[];
|
|
34
|
+
};
|
|
35
|
+
folderMap: {
|
|
36
|
+
type: string;
|
|
37
|
+
additionalProperties: {
|
|
38
|
+
type: string;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
additionalProperties: boolean;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
additionalProperties: boolean;
|
|
46
|
+
};
|
|
47
|
+
export declare function getMarkdownConfig(plugins?: Record<string, Record<string, unknown>>): MarkdownPluginConfig;
|
|
48
|
+
export declare const markdownPlugin: StructuredContextPlugin;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import { PLUGIN_PREFIX } from '../util';
|
|
3
|
+
import { readSpaceDirectory, readSpaceOnAPage } from './read-space';
|
|
4
|
+
import { renderBullets } from './render-bullets';
|
|
5
|
+
import { renderMermaid } from './render-mermaid';
|
|
6
|
+
import { templateSync } from './template-sync';
|
|
7
|
+
export const MARKDOWN_CONFIG_SCHEMA = {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
templateDir: { type: 'string', format: 'path' }, // format is hint to config loader to resolve relative directories
|
|
11
|
+
fieldMap: { type: 'object', additionalProperties: { type: 'string' } },
|
|
12
|
+
templatePrefix: { type: 'string' },
|
|
13
|
+
typeInference: {
|
|
14
|
+
type: 'object',
|
|
15
|
+
properties: {
|
|
16
|
+
mode: { type: 'string', enum: ['folder-name', 'off'] },
|
|
17
|
+
folderMap: { type: 'object', additionalProperties: { type: 'string' } },
|
|
18
|
+
},
|
|
19
|
+
additionalProperties: false,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
additionalProperties: false,
|
|
23
|
+
};
|
|
24
|
+
export function getMarkdownConfig(plugins) {
|
|
25
|
+
return (plugins?.[`${PLUGIN_PREFIX}markdown`] ?? {});
|
|
26
|
+
}
|
|
27
|
+
async function parse(context) {
|
|
28
|
+
if (statSync(context.space.path).isFile()) {
|
|
29
|
+
return readSpaceOnAPage(context);
|
|
30
|
+
}
|
|
31
|
+
return await readSpaceDirectory(context);
|
|
32
|
+
}
|
|
33
|
+
export const markdownPlugin = {
|
|
34
|
+
name: `${PLUGIN_PREFIX}markdown`,
|
|
35
|
+
configSchema: MARKDOWN_CONFIG_SCHEMA,
|
|
36
|
+
parse,
|
|
37
|
+
templateSync,
|
|
38
|
+
render: {
|
|
39
|
+
formats: [
|
|
40
|
+
{ name: 'bullets', description: 'Indented bullet list' },
|
|
41
|
+
{ name: 'mermaid', description: 'Mermaid graph TD diagram' },
|
|
42
|
+
],
|
|
43
|
+
render(_context, graph, { format }) {
|
|
44
|
+
if (format === 'bullets')
|
|
45
|
+
return renderBullets(graph);
|
|
46
|
+
if (format === 'mermaid')
|
|
47
|
+
return renderMermaid(graph);
|
|
48
|
+
throw new Error(`Unknown markdown render format: "${format}"`);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|