diff --git a/README.md b/README.md index c39015c..96bc208 100644 --- a/README.md +++ b/README.md @@ -335,13 +335,26 @@ sctx schemas show strategy_general --mermaid-erd sctx miro-sync [--new-frame ] [--dry-run] [--verbose] ``` -Syncs space nodes to a Miro board as cards with connectors. Requires `MIRO_TOKEN` env var and `miroBoardId` set in the space's config entry. +Syncs space nodes to a Miro board as cards with connectors. Requires `MIRO_TOKEN` env var and `boardId` set in the `miro` plugin config for the space. -- `--new-frame <title>` — create a new frame on the board and sync into it; auto-saves the resulting `miroFrameId` back to the config file +- `--new-frame <title>` — create a new frame on the board and sync into it; auto-saves the resulting `frameId` back to the miro plugin config - `--dry-run` — show what would change without touching Miro - `--verbose` / `-v` — detailed per-card and per-connector output -On subsequent runs, the cached `miroFrameId` is used automatically. Cards are colour-coded by node type and linked by parent→child connectors. A local `.miro-cache/` directory tracks Miro IDs to enable incremental updates. +Configure the miro plugin in the space's config entry: + +```json +{ + "plugins": { + "miro": { + "boardId": "your-board-id", + "frameId": "your-frame-id" + } + } +} +``` + +On subsequent runs, the cached `frameId` is used automatically. Cards are colour-coded by node type and linked by parent→child connectors. A local `.miro-cache/` directory tracks Miro IDs to enable incremental updates. Sync is one-way (OST → Miro) and scoped to a single frame. Only cards and connectors created by this tool within that frame are managed — everything else on the board is left untouched. Card content and connectors are overwritten or recreated to match the markdown source; any edits made directly in Miro to managed cards will be lost on the next sync. Existing card positions are not changed. diff --git a/config.example.json b/config.example.json index 136e62b..77796c3 100644 --- a/config.example.json +++ b/config.example.json @@ -5,12 +5,14 @@ "name": "ProductX", "path": "/path/to/ProductX/Opportunity Solution Tree", "schema": "/path/to/custom/schemas/my-projectx-schema.json", - "miroBoardId": "iAmAB04rdId", - "miroFrameId": "1234567789", "plugins": { "markdown": { "templateDir": "../templates", "templatePrefix": "prodx-" + }, + "miro": { + "boardId": "iAmAB04rdId", + "frameId": "1234567789" } } } diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 111d167..a0cd58c 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "structured-context", - "version": "0.10.0", + "version": "0.10.1", "description": "Structured content authoring for Obsidian — automatic validation hooks, slash commands, and agent skills to keep markdown files compliant with your schema.", "homepage": "https://github.com/mindsocket/structured-context", "repository": "https://github.com/mindsocket/structured-context", diff --git a/plugin/skills/structured-context/SKILL.md b/plugin/skills/structured-context/SKILL.md index 9d1ef84..32efb9d 100644 --- a/plugin/skills/structured-context/SKILL.md +++ b/plugin/skills/structured-context/SKILL.md @@ -68,7 +68,7 @@ validate <space> Validate space content against schema (--watch for live) show <space> Output the node hierarchy dump <space> Output parsed node data as JSON diagram <space> Generate Mermaid diagram (--output <file>) -miro-sync <space> Sync to Miro board (requires MIRO_TOKEN env var + miroBoardId in config) +miro-sync <space> Sync to Miro board (requires MIRO_TOKEN env var + miro plugin config with boardId) template-sync <space> Sync Obsidian templates from schema examples plugins List available plugins ``` diff --git a/plugin/skills/structured-context/references/commands.md b/plugin/skills/structured-context/references/commands.md index a8414ff..8216d7f 100644 --- a/plugin/skills/structured-context/references/commands.md +++ b/plugin/skills/structured-context/references/commands.md @@ -119,10 +119,10 @@ sctx miro-sync <space> [--new-frame <title>] [--dry-run] [--verbose] [--config < Syncs space nodes to a Miro board. Requires: - `MIRO_TOKEN` environment variable -- `miroBoardId` in the space's config entry +- `boardId` set in `plugins.miro` for the space -First sync: use `--new-frame "Frame Title"` to create a new frame; the resulting `miroFrameId` -is auto-saved to config. Subsequent syncs reuse the cached frame ID. +First sync: use `--new-frame "Frame Title"` to create a new frame; the resulting `frameId` +is auto-saved to the miro plugin config. Subsequent syncs reuse the cached frame ID. Sync is one-way (structured-context → Miro). Manual edits to managed cards in Miro are overwritten. @@ -172,10 +172,12 @@ sctx template-sync <space> --create-missing }, templateDir: '../templates', templatePrefix: '', - } + }, + miro: { + boardId: 'xxx', + frameId: 'xxx', // auto-populated by --new-frame + }, }, - miroBoardId: 'xxx', - miroFrameId: 'xxx', // auto-populated by --new-frame views: { 'active-solutions': { expression: "WHERE resolvedType='solution' and status='active'" }, }, diff --git a/src/api.ts b/src/api.ts index fbaf64e..2232e76 100644 --- a/src/api.ts +++ b/src/api.ts @@ -10,6 +10,7 @@ export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv'; export type { Config, SpaceConfig } from './config'; export { loadConfig, setConfigPath } from './config'; +export { PLUGIN_PREFIX } from './constants'; export type { ParseHook, ParseResult, diff --git a/src/commands/miro-sync.ts b/src/commands/miro-sync.ts new file mode 100644 index 0000000..98c91fd --- /dev/null +++ b/src/commands/miro-sync.ts @@ -0,0 +1,11 @@ +import type { SyncOptions } from '../plugins/miro/sync'; +import { executeRender } from '../render/render'; +import type { SpaceContext } from '../types'; + +export async function miroSyncCommand(context: SpaceContext, options: SyncOptions): Promise<void> { + const summary = await executeRender('sctx-miro:board', context, { + filter: undefined, + data: options as Record<string, unknown>, + }); + console.log(summary); +} diff --git a/src/commands/spaces.ts b/src/commands/spaces.ts index 8ff48af..49886c3 100644 --- a/src/commands/spaces.ts +++ b/src/commands/spaces.ts @@ -15,7 +15,6 @@ export function listSpaces(): void { console.log(` path: ${space.path}`); const schema = space.schema ?? config.schema; console.log(` schema: ${schema ? basename(schema) : '(none)'}`); - if (space.miroBoardId) console.log(` miro: configured`); const plugins = space.plugins ?? {}; if (Object.keys(plugins).length > 0) { console.log(' plugins:'); diff --git a/src/config.ts b/src/config.ts index 15aaa67..1276dad 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'; import Ajv from 'ajv'; import JSON5 from 'json5'; import { ENV_CONFIG_VAR, XDG_CONFIG_DIR } from './constants'; -import { normalizePluginName } from './plugins/util'; +import { normalizePluginName, shortenPluginName } from './plugins/util'; import { bundledSchemasDir } from './schema/schema'; const CONFIG_SCHEMA = { @@ -18,8 +18,6 @@ const CONFIG_SCHEMA = { name: { type: 'string', pattern: '^[a-z0-9_-]+$' }, path: { type: 'string' }, schema: { type: 'string' }, - miroBoardId: { type: 'string' }, - miroFrameId: { type: 'string' }, plugins: { type: 'object', additionalProperties: { type: 'object' } }, views: { type: 'object', @@ -46,8 +44,6 @@ export type SpaceConfig = { name: string; path: string; schema?: string; - miroBoardId?: string; - miroFrameId?: string; /** Plugin name → plugin config map. Overrides top-level plugins when set. */ plugins?: Record<string, Record<string, unknown>>; /** Named filter views for this space. Keys are view names; values contain the filter expression. */ @@ -209,19 +205,22 @@ export function resolveSchema(config: Config, space?: SpaceConfig): string { return schema; } -type StringFields<T> = { [K in keyof T]: T[K] extends string | undefined ? K : never }[keyof T]; - -/** Update a string field on a space entry and persist config. */ -export function updateSpaceField(spaceName: string, field: StringFields<SpaceConfig>, value: string): void { +/** Update a string field on a space entry and persist config. When `plugin` is given, updates that field inside `space.plugins[plugin]` instead. */ +export function updateSpaceField(spaceName: string, field: string, value: string, plugin?: string): void { const sourcePath = _spaceSourceFiles.get(spaceName); - if (!sourcePath) { - throw new Error(`Space "${spaceName}" not found in any config file`); - } + if (!sourcePath) throw new Error(`Space "${spaceName}" not found in any config file`); const config = _loadConfig(sourcePath); const space = config.spaces?.find((s: SpaceConfig) => s.name === spaceName); - if (!space) { - throw new Error(`Unknown space config: "${spaceName}". Check config.`); + if (!space) throw new Error(`Unknown space config: "${spaceName}". Check config.`); + if (plugin !== undefined) { + if (!space.plugins) space.plugins = {}; + const normalized = normalizePluginName(plugin); + const shortName = shortenPluginName(normalized); + const existingKey = Object.keys(space.plugins).find((k) => k === normalized || k === shortName) ?? normalized; + if (!space.plugins[existingKey]) space.plugins[existingKey] = {}; + space.plugins[existingKey][field] = value; + } else { + (space as unknown as Record<string, unknown>)[field] = value; } - (space as unknown as Record<string, unknown>)[field as string] = value; writeFileSync(sourcePath, `${JSON5.stringify(config, null, 2)}\n`); } diff --git a/src/index.ts b/src/index.ts index 17162e7..39fe38c 100755 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { Command } from 'commander'; import { diagram } from './commands/diagram'; import { docs } from './commands/docs'; import { dump } from './commands/dump'; +import { miroSyncCommand } from './commands/miro-sync'; import { listPlugins } from './commands/plugins'; import { render, renderList } from './commands/render'; import { listSchemas, showSchema } from './commands/schemas'; @@ -12,10 +13,8 @@ import { listSpaces } from './commands/spaces'; import { templateSync } from './commands/template-sync'; import { validate, watchValidate } from './commands/validate'; import { validateFileCommand } from './commands/validate-file'; - import { getSpaceConfigDir, loadConfig, resolveSchema, setConfigPath } from './config'; import { CLI_NAME } from './constants'; -import { miroSync } from './integrations/miro/sync'; import { loadSchema } from './schema/schema'; import type { SpaceContext } from './types'; @@ -109,7 +108,7 @@ program .option('--new-frame <title>', 'Create a new frame on the board and sync into it') .option('--dry-run', 'Show what would change without touching Miro') .option('-v, --verbose', 'Detailed output') - .action((spaceName, options) => miroSync(buildSpaceContext(spaceName), options)); + .action((spaceName, options) => miroSyncCommand(buildSpaceContext(spaceName), options)); program .command('template-sync') diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 3cc09ce..283d7aa 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,6 +1,7 @@ import { markdownPlugin } from './markdown'; import { mermaidPlugin } from './mermaid'; +import { miroPlugin } from './miro'; import type { StructuredContextPlugin } from './util'; /** All built-in plugins, in default load order. */ -export const builtinPlugins: StructuredContextPlugin[] = [markdownPlugin, mermaidPlugin]; +export const builtinPlugins: StructuredContextPlugin[] = [markdownPlugin, mermaidPlugin, miroPlugin]; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index f270783..64d5dfe 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -3,7 +3,13 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'; import Ajv, { type AnySchemaObject } from 'ajv'; import { getConfigSourceFiles } from '../config'; import { builtinPlugins } from '.'; -import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX, type StructuredContextPlugin } from './util'; +import { + CONFIG_PLUGINS_DIR, + normalizePluginName, + PLUGIN_PREFIX, + type StructuredContextPlugin, + shortenPluginName, +} from './util'; export type LoadedPlugin = { plugin: StructuredContextPlugin; @@ -110,7 +116,7 @@ export async function loadPlugins( // Built-in plugins: always loaded, config taken from map if declared (with or without prefix) for (const builtin of builtinPlugins) { - const rawConfig = pluginMap[builtin.name] ?? pluginMap[builtin.name.slice(PLUGIN_PREFIX.length)] ?? {}; + const rawConfig = pluginMap[builtin.name] ?? pluginMap[shortenPluginName(builtin.name)] ?? {}; const pluginConfig = resolveConfigPaths(builtin.configSchema, rawConfig, configDir); const validate = ajv.compile(builtin.configSchema); if (!validate(pluginConfig)) { diff --git a/src/integrations/miro/cache.ts b/src/plugins/miro/cache.ts similarity index 98% rename from src/integrations/miro/cache.ts rename to src/plugins/miro/cache.ts index 993d835..b0cf713 100644 --- a/src/integrations/miro/cache.ts +++ b/src/plugins/miro/cache.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import type { SpaceNode } from '../../types'; +import type { SpaceNode } from '../../api'; export interface CachedNode { miroCardId: string; diff --git a/src/integrations/miro/client.ts b/src/plugins/miro/client.ts similarity index 100% rename from src/integrations/miro/client.ts rename to src/plugins/miro/client.ts diff --git a/src/plugins/miro/index.ts b/src/plugins/miro/index.ts new file mode 100644 index 0000000..007d0d5 --- /dev/null +++ b/src/plugins/miro/index.ts @@ -0,0 +1,28 @@ +import { PLUGIN_PREFIX, type StructuredContextPlugin } from '../../api'; +import { miroSync } from './sync'; + +export const miroPlugin: StructuredContextPlugin = { + name: `${PLUGIN_PREFIX}miro`, + configSchema: { + type: 'object', + properties: { + boardId: { type: 'string' }, + frameId: { type: 'string' }, + }, + additionalProperties: false, + }, + render: { + formats: [{ name: 'board', description: 'Sync space to Miro board' }], + async render(context, graph, { format, data }) { + if (format === 'board') { + const options = { + newFrame: data?.newFrame as string | undefined, + dryRun: data?.dryRun as boolean | undefined, + verbose: data?.verbose as boolean | undefined, + }; + return await miroSync(context, graph, options); + } + throw new Error(`Unknown miro render format: "${format}"`); + }, + }, +}; diff --git a/src/integrations/miro/layout.ts b/src/plugins/miro/layout.ts similarity index 76% rename from src/integrations/miro/layout.ts rename to src/plugins/miro/layout.ts index e3a9641..cc557bb 100644 --- a/src/integrations/miro/layout.ts +++ b/src/plugins/miro/layout.ts @@ -1,4 +1,4 @@ -import type { HierarchyLevel, SpaceNode } from '../../types'; +import type { HierarchyLevel, SpaceNode } from '../../api'; export const CARD_WIDTH = 320; const CARD_HEIGHT = 160; @@ -12,19 +12,6 @@ export interface LayoutResult { bounds: { minX: number; minY: number; maxX: number; maxY: number }; } -/** - * DEPRECATED - likely not needed after migration to render plugin and SpaceGraph - * Build a depth map from hierarchy levels. - * The position in the hierarchy array determines the depth. - */ -function buildDepthMap(hierarchyLevels: HierarchyLevel[]): Map<string, number> { - const depthMap = new Map<string, number>(); - for (const [i, level] of hierarchyLevels.entries()) { - depthMap.set(level.type, i); - } - return depthMap; -} - /** * Compute positions for new cards only. Existing cards keep their Miro positions. * New cards are laid out in rows grouped by OST type depth, starting below @@ -37,11 +24,8 @@ function buildDepthMap(hierarchyLevels: HierarchyLevel[]): Map<string, number> { export function layoutNewCards( newNodes: SpaceNode[], existingPositions: Map<string, { x: number; y: number }>, - hierarchyLevels: HierarchyLevel[], + hierarchyLevels: readonly HierarchyLevel[], ): LayoutResult { - // Build depth map from hierarchy levels (position in hierarchy = depth) - const depthMap = buildDepthMap(hierarchyLevels); - // Find the lowest y among existing cards let lowestY = 0; for (const pos of existingPositions.values()) { @@ -52,10 +36,11 @@ export function layoutNewCards( const startY = existingPositions.size > 0 ? lowestY + V_GAP * 2 : 0; - // Group new nodes by depth + // Group new nodes by depth (position in hierarchy levels array) const byDepth = new Map<number, SpaceNode[]>(); for (const node of newNodes) { - const depth = depthMap.get(node.schemaData.type as string) ?? depthMap.size; + const idx = hierarchyLevels.findIndex((l) => l.type === (node.schemaData.type as string)); + const depth = idx === -1 ? hierarchyLevels.length : idx; if (!byDepth.has(depth)) byDepth.set(depth, []); byDepth.get(depth)?.push(node); } diff --git a/src/integrations/miro/styles.ts b/src/plugins/miro/styles.ts similarity index 93% rename from src/integrations/miro/styles.ts rename to src/plugins/miro/styles.ts index 872c217..8ca6df1 100644 --- a/src/integrations/miro/styles.ts +++ b/src/plugins/miro/styles.ts @@ -1,4 +1,4 @@ -import type { HierarchyLevel, SpaceNode } from '../../types'; +import type { HierarchyLevel, SpaceNode } from '../../api'; // Color palette for hierarchy levels (distinct, visually appealing colors) const COLOR_PALETTE = [ @@ -31,7 +31,7 @@ const STATUS_ICONS: Record<string, string> = { * @param type - The node's type string * @param hierarchyLevels - Hierarchy levels from metadata */ -export function getCardColor(type: string, hierarchyLevels: HierarchyLevel[]): string { +export function getCardColor(type: string, hierarchyLevels: readonly HierarchyLevel[]): string { // Find the level index for this type const levelIndex = hierarchyLevels.findIndex((level) => level.type === type); if (levelIndex >= 0) { diff --git a/src/integrations/miro/sync.ts b/src/plugins/miro/sync.ts similarity index 85% rename from src/integrations/miro/sync.ts rename to src/plugins/miro/sync.ts index 93dcaea..d25c3f0 100644 --- a/src/integrations/miro/sync.ts +++ b/src/plugins/miro/sync.ts @@ -1,65 +1,42 @@ -import { updateSpaceField } from '../../config'; -import { readSpace } from '../../read/read-space'; -import { buildSpaceGraph } from '../../space-graph'; -import type { SpaceContext, SpaceNode } from '../../types'; +import type { PluginContext, SpaceGraph, SpaceNode } from '../../api'; import { computeMiroCardHash, computeNodeHash, loadCache, saveCache } from './cache'; import { MiroClient, MiroNotFoundError } from './client'; import { CARD_WIDTH, layoutNewCards } from './layout'; import { buildCardDescription, buildCardTitle, getCardColor } from './styles'; -interface SyncOptions { +export interface SyncOptions { newFrame?: string; dryRun?: boolean; verbose?: boolean; } -export async function miroSync(context: SpaceContext, options: SyncOptions): Promise<void> { +export async function miroSync(context: PluginContext, graph: SpaceGraph, options: SyncOptions): Promise<string> { const token = process.env.MIRO_TOKEN; - if (!token) { - console.error('MIRO_TOKEN environment variable is required'); - process.exit(1); - } + if (!token) throw new Error('MIRO_TOKEN environment variable is required'); - const { - space, - schema: { metadata }, - } = context; + const { space } = context; + const boardId = context.pluginConfig.boardId as string | undefined; + const configuredFrameId = context.pluginConfig.frameId as string | undefined; // 1. Resolve board - if (!space.miroBoardId) { - console.error(`No miroBoardId configured for space "${space.name}".`); - console.error('Add miroBoardId to the space entry in config.'); - process.exit(1); - } - - const boardId = space.miroBoardId; + if (!boardId) + throw new Error( + `No boardId configured for space "${space.name}". Add boardId to the miro plugin config in the space entry.`, + ); // 2. Resolve frame - if (!space.miroFrameId && !options.newFrame) { - console.error('No miroFrameId in space config. Pass --new-frame "Title" to create one.'); - process.exit(1); - } + if (!configuredFrameId && !options.newFrame) + throw new Error('No frameId in miro plugin config. Pass --new-frame "Title" to create one.'); - // 3. Load space nodes (load before creating frame so we can calculate size) - let nodes: SpaceNode[]; - - ({ nodes } = await readSpace(context)); + // 3. Filter to hierarchy nodes only (graph already built by caller) + const nodes = [...graph.nodes.values()].filter((n) => graph.hierarchyTitles.has(n.title)); if (nodes.length === 0) { - console.log('No space nodes found.'); - return; + return 'No space nodes found.'; } - // Filter to hierarchy nodes only - const levels = metadata.hierarchy?.levels ?? []; - - const { nonHierarchy, hierarchyTitles: hierarchyNodeTitles } = buildSpaceGraph(nodes, levels); - - // Filter nodes to only hierarchy nodes - nodes = nodes.filter((n) => hierarchyNodeTitles.has(n.title)); - - if (options.verbose && nonHierarchy.length > 0) { - console.log(`Excluded ${nonHierarchy.length} non-hierarchy nodes from sync`); + if (options.verbose && graph.nonHierarchy.length > 0) { + console.log(`Excluded ${graph.nonHierarchy.length} non-hierarchy nodes from sync`); } const client = new MiroClient(boardId, token); @@ -68,7 +45,7 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro if (options.newFrame) { // Calculate layout bounds to size the frame appropriately - const { bounds } = layoutNewCards(nodes, new Map(), levels); + const { bounds } = layoutNewCards(nodes, new Map(), graph.levels); const frameWidth = Math.max(1600, bounds.maxX - bounds.minX); const frameHeight = Math.max(1200, bounds.maxY - bounds.minY); @@ -95,11 +72,11 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro }); frameId = frame.id; console.log(`Created frame "${options.newFrame}" (${frameId}) - size: ${finalFrameWidth}x${finalFrameHeight}`); - updateSpaceField(space.name, 'miroFrameId', frameId); - console.log(`Saved miroFrameId to config`); + context.callbacks?.persistConfig?.({ frameId }); + console.log(`Saved frameId to miro plugin config`); } } else { - frameId = space.miroFrameId!; + frameId = configuredFrameId!; } // 4. Load cache @@ -205,7 +182,7 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro } // Compute positions for new cards - const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, levels); + const { positions: newPositions } = layoutNewCards(newNodes, existingPositions, graph.levels); // 7. Create new cards let createdCount = 0; @@ -238,7 +215,7 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro title: buildCardTitle(node), description: buildCardDescription(node), }, - style: { cardTheme: getCardColor(type, levels) }, + style: { cardTheme: getCardColor(type, graph.levels) }, position: { x: pos.x, y: pos.y, origin: 'center' }, parent: { id: frameId }, geometry: { width: CARD_WIDTH }, @@ -287,7 +264,7 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro title: buildCardTitle(node), description: buildCardDescription(node), }, - style: { cardTheme: getCardColor(type, levels) }, + style: { cardTheme: getCardColor(type, graph.levels) }, position: { x: 0, y: 0, origin: 'center' }, parent: { id: frameId }, geometry: { width: CARD_WIDTH }, @@ -386,7 +363,5 @@ export async function miroSync(context: SpaceContext, options: SyncOptions): Pro if (!options.dryRun) saveCache(cache); // Summary - console.log(`\n${prefix}Sync complete:`); - console.log(` Cards: ${createdCount} created, ${updatedCount} updated, ${skippedCount} unchanged`); - console.log(` Connectors: ${connectorsCreated} created, ${connectorsDeleted} deleted`); + return `\n${prefix}Sync complete:\n Cards: ${createdCount} created, ${updatedCount} updated, ${skippedCount} unchanged\n Connectors: ${connectorsCreated} created, ${connectorsDeleted} deleted`; } diff --git a/src/plugins/util.ts b/src/plugins/util.ts index 7301fb4..a6da0ec 100644 --- a/src/plugins/util.ts +++ b/src/plugins/util.ts @@ -11,9 +11,19 @@ export function normalizePluginName(name: string): string { return name.startsWith(PLUGIN_PREFIX) ? name : `${PLUGIN_PREFIX}${name}`; } +/** Shorten a plugin name to its non-prefixed form. */ +export function shortenPluginName(name: string): string { + return name.startsWith(PLUGIN_PREFIX) ? name.slice(PLUGIN_PREFIX.length) : name; +} + export type PluginContext = SpaceContext & { /** Validated config for this plugin invocation. */ pluginConfig: Record<string, unknown>; + /** Optional callback functions for plugins to interact with the system. */ + callbacks?: { + /** Persist plugin config updates to the config file. */ + persistConfig?: (updates: Record<string, string>) => void; + }; }; export type ParseResult = { @@ -45,6 +55,8 @@ export type RenderFormat = { export type RenderOptions = { /** The format name being rendered (e.g. 'bullets', 'mermaid'). */ format: string; + /** Plugin-specific extra data (e.g. CLI flags forwarded by a convenience command). */ + data?: Record<string, unknown>; }; /** diff --git a/src/render/render.ts b/src/render/render.ts index f7924a7..dd401c0 100644 --- a/src/render/render.ts +++ b/src/render/render.ts @@ -1,3 +1,5 @@ +import { shortenPluginName } from '@/plugins/util'; +import { updateSpaceField } from '../config'; import { filterNodes } from '../filter/filter-nodes'; import { loadPlugins } from '../plugins/loader'; import { readSpace } from '../read/read-space'; @@ -8,7 +10,7 @@ import { buildFormatRegistry } from './registry'; export async function executeRender( formatName: string, context: SpaceContext, - options: { filter?: string }, + options: { filter?: string; data?: Record<string, unknown> }, ): Promise<string> { const pluginMap: Record<string, Record<string, unknown>> = context.space?.plugins ?? {}; const loaded = await loadPlugins(pluginMap, context.configDir); @@ -37,6 +39,20 @@ export async function executeRender( graph = await filterNodes(expression, graph); } - const pluginContext = { ...context, pluginConfig: entry.plugin.pluginConfig }; - return entry.plugin.plugin.render!.render(pluginContext, graph, { format: entry.format.name }); + const shortName = shortenPluginName(entry.plugin.plugin.name); + const pluginContext = { + ...context, + pluginConfig: entry.plugin.pluginConfig, + callbacks: { + persistConfig: (updates: Record<string, string>) => { + for (const [field, value] of Object.entries(updates)) { + updateSpaceField(context.space.name, field, value, shortName); + } + }, + }, + }; + return entry.plugin.plugin.render!.render(pluginContext, graph, { + format: entry.format.name, + data: options.data, + }); } diff --git a/tests/config.test.ts b/tests/config.test.ts index f53c87d..8b27577 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -274,7 +274,7 @@ describe('loadConfig with includeSpacesFrom', () => { otherConfigPath, JSON.stringify( { - spaces: [{ name: 'included-space', path: '/included', miroFrameId: 'old-frame-id' }], + spaces: [{ name: 'included-space', path: '/included', schema: 'old-schema' }], }, null, 2, @@ -297,14 +297,55 @@ describe('loadConfig with includeSpacesFrom', () => { loadConfig(); // Load and track space sources // Update the included space - updateSpaceField('included-space', 'miroFrameId', 'new-frame-id'); + updateSpaceField('included-space', 'schema', 'new-schema'); // Verify the included config file was updated const updatedConfig = JSON5.parse(readFileSync(otherConfigPath, 'utf-8')) as Config; - expect(updatedConfig.spaces[0]!.miroFrameId).toBe('new-frame-id'); + expect(updatedConfig.spaces[0]!.schema).toBe('new-schema'); // Verify the main config was not modified const mainConfig = JSON5.parse(readFileSync(mainConfigPath, 'utf-8')) as Config; expect(mainConfig.spaces[0]!.name).toBe('main-space'); }); + + it('updatePluginConfig writes plugin config to correct file for included spaces', () => { + const otherDir = join(testDir, 'other2'); + mkdirSync(otherDir, { recursive: true }); + const otherConfigPath = join(otherDir, 'config.json'); + + writeFileSync( + otherConfigPath, + JSON.stringify( + { + spaces: [{ name: 'plugin-space', path: '/plugin', plugins: { miro: { boardId: 'board-1' } } }], + }, + null, + 2, + ), + ); + + writeFileSync( + mainConfigPath, + JSON.stringify( + { + includeSpacesFrom: ['other2/config.json'], + spaces: [{ name: 'main-space', path: '/main' }], + }, + null, + 2, + ), + ); + + setConfigPath(mainConfigPath); + loadConfig(); + + updateSpaceField('plugin-space', 'frameId', 'frame-abc', 'miro'); + + const updatedConfig = JSON5.parse(readFileSync(otherConfigPath, 'utf-8')) as Config; + expect(updatedConfig.spaces[0]!.plugins!.miro!.frameId).toBe('frame-abc'); + expect(updatedConfig.spaces[0]!.plugins!.miro!.boardId).toBe('board-1'); + + const mainConfig = JSON5.parse(readFileSync(mainConfigPath, 'utf-8')) as Config; + expect(mainConfig.spaces[0]!.name).toBe('main-space'); + }); }); diff --git a/tests/helpers/context.ts b/tests/helpers/context.ts index 8b62baf..f839214 100644 --- a/tests/helpers/context.ts +++ b/tests/helpers/context.ts @@ -33,5 +33,13 @@ export function makePluginContext( schemaPath?: string, pluginConfig: Record<string, unknown> = {}, ): PluginContext { - return { ...makeSpaceContext(path, schemaPath), pluginConfig }; + return { + ...makeSpaceContext(path, schemaPath), + pluginConfig, + callbacks: { + persistConfig: () => { + // no-op in tests + }, + }, + }; } diff --git a/tests/plugins/loader.test.ts b/tests/plugins/loader.test.ts index 12d5cc1..0042dd2 100644 --- a/tests/plugins/loader.test.ts +++ b/tests/plugins/loader.test.ts @@ -9,11 +9,13 @@ describe('loadPlugins', () => { describe('built-in plugins', () => { it('always includes built-in plugins when no map is given', async () => { const loaded = await loadPlugins({}, CONFIG_DIR); - expect(loaded).toHaveLength(2); + expect(loaded).toHaveLength(3); expect(loaded.map((l) => l.plugin.name)).toContain('sctx-markdown'); expect(loaded.map((l) => l.plugin.name)).toContain('sctx-mermaid'); + expect(loaded.map((l) => l.plugin.name)).toContain('sctx-miro'); expect(loaded.find((l) => l.plugin.name === 'sctx-markdown')!.pluginConfig).toEqual({}); expect(loaded.find((l) => l.plugin.name === 'sctx-mermaid')!.pluginConfig).toEqual({}); + expect(loaded.find((l) => l.plugin.name === 'sctx-miro')!.pluginConfig).toEqual({}); }); it('built-in plugins come after external plugins', async () => {