openfig-cli 0.3.17 → 0.3.18

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.
@@ -40,22 +40,65 @@ export async function run(args, flags) {
40
40
  const tmplInst = deck.getSlideInstance(nid(tmplSlide));
41
41
  if (!tmplInst) { console.error(`No instance on template slide`); process.exit(1); }
42
42
 
43
- // Find SLIDE_ROW parent
44
- const slideRowId = tmplSlide.parentIndex?.guid
43
+ // Detect MODULE wrapper: SLIDE_ROW → MODULE → SLIDE
44
+ const tmplParentId = tmplSlide.parentIndex?.guid
45
45
  ? `${tmplSlide.parentIndex.guid.sessionID}:${tmplSlide.parentIndex.guid.localID}`
46
46
  : null;
47
+ const tmplParent = tmplParentId ? deck.getNode(tmplParentId) : null;
48
+ const hasModule = tmplParent?.type === 'MODULE';
49
+ const slideRowId = hasModule
50
+ ? (tmplParent.parentIndex?.guid
51
+ ? `${tmplParent.parentIndex.guid.sessionID}:${tmplParent.parentIndex.guid.localID}`
52
+ : null)
53
+ : tmplParentId;
47
54
 
48
55
  // Generate new IDs
49
56
  let nextId = deck.maxLocalID() + 1;
57
+ const moduleId = hasModule ? nextId++ : null;
50
58
  const slideId = nextId++;
51
59
  const instId = nextId++;
52
60
 
61
+ // Create new MODULE wrapper if template uses one
62
+ let newModule = null;
63
+ if (hasModule && slideRowId) {
64
+ const activeCount = deck.getActiveSlides().length;
65
+ newModule = {
66
+ guid: { sessionID: 1, localID: moduleId },
67
+ phase: 'CREATED',
68
+ type: 'MODULE',
69
+ name: newName,
70
+ isPublishable: true,
71
+ version: tmplParent.version,
72
+ userFacingVersion: tmplParent.userFacingVersion,
73
+ visible: true,
74
+ opacity: 1,
75
+ size: deepClone(tmplParent.size ?? { x: 1920, y: 1080 }),
76
+ transform: deepClone(tmplParent.transform ?? { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 }),
77
+ strokeWeight: 1,
78
+ strokeAlign: 'INSIDE',
79
+ strokeJoin: 'MITER',
80
+ fillPaints: deepClone(tmplParent.fillPaints ?? [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true, blendMode: 'NORMAL' }]),
81
+ fillGeometry: deepClone(tmplParent.fillGeometry ?? [{ windingRule: 'NONZERO', commandsBlob: 13, styleID: 0 }]),
82
+ frameMaskDisabled: false,
83
+ parentIndex: {
84
+ guid: parseId(slideRowId),
85
+ position: positionChar(activeCount),
86
+ },
87
+ };
88
+ }
89
+
53
90
  // Clone slide node
54
91
  const newSlide = deepClone(tmplSlide);
55
92
  newSlide.guid = { sessionID: 1, localID: slideId };
56
93
  newSlide.name = newName;
57
94
  newSlide.phase = 'CREATED';
58
- if (slideRowId) {
95
+ if (hasModule && newModule) {
96
+ // Parent to the new MODULE
97
+ newSlide.parentIndex = {
98
+ guid: { sessionID: 1, localID: moduleId },
99
+ position: '!',
100
+ };
101
+ } else if (slideRowId) {
59
102
  const activeCount = deck.getActiveSlides().length;
60
103
  newSlide.parentIndex = {
61
104
  guid: parseId(slideRowId),
@@ -119,16 +162,24 @@ export async function run(args, flags) {
119
162
 
120
163
  // Set slide position
121
164
  const activeSlides = deck.getActiveSlides();
165
+ const xOffset = activeSlides.length * 2160;
166
+ if (newModule?.transform) {
167
+ newModule.transform.m02 = xOffset;
168
+ }
122
169
  if (newSlide.transform) {
123
- newSlide.transform.m02 = activeSlides.length * 2160;
170
+ newSlide.transform.m02 = hasModule ? 0 : xOffset;
124
171
  }
125
172
 
126
173
  // Push to nodeChanges
174
+ if (newModule) {
175
+ deck.message.nodeChanges.push(newModule);
176
+ }
127
177
  deck.message.nodeChanges.push(newSlide);
128
178
  deck.message.nodeChanges.push(newInst);
129
179
  deck.rebuildMaps();
130
180
 
131
- console.log(`Cloned slide "${tmplSlide.name}" "${newName}" (1:${slideId} + 1:${instId})`);
181
+ const moduleNote = newModule ? ` + MODULE 1:${moduleId}` : '';
182
+ console.log(`Cloned slide "${tmplSlide.name}" → "${newName}" (1:${slideId} + 1:${instId}${moduleNote})`);
132
183
  console.log(` ${sets.length} text override(s), ${setImages.length} image override(s)`);
133
184
 
134
185
  const bytes = await deck.saveDeck(outPath);
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Image search and download — currently backed by DuckDuckGo.
3
+ * Generic interface so the backend can be swapped or extended later.
4
+ */
5
+ import { createWriteStream, mkdirSync } from 'fs';
6
+ import { pipeline } from 'stream/promises';
7
+ import { join } from 'path';
8
+
9
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
10
+
11
+ async function fetchWithRetry(url, opts, retries = 4) {
12
+ const delays = [2000, 5000, 10000, 20000];
13
+ for (let i = 0; i <= retries; i++) {
14
+ const res = await fetch(url, opts);
15
+ if (res.status !== 429 && res.status !== 403) return res;
16
+ if (i === retries) throw new Error(`Image search rate limit — try again in a minute (HTTP ${res.status})`);
17
+ await sleep(delays[i]);
18
+ }
19
+ }
20
+
21
+ async function getDDGToken(query) {
22
+ const res = await fetchWithRetry(
23
+ `https://duckduckgo.com/?q=${encodeURIComponent(query)}&iax=images&ia=images`,
24
+ { headers: { 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' } }
25
+ );
26
+ const html = await res.text();
27
+ const match = html.match(/vqd=(['"])([\d-]+)\1/);
28
+ if (!match) throw new Error('Could not get search token — DuckDuckGo may have changed its API');
29
+ return match[2];
30
+ }
31
+
32
+ export async function searchImages(query, count = 10) {
33
+ const vqd = await getDDGToken(query);
34
+ await sleep(1500); // avoid rate limiting
35
+ const url = `https://duckduckgo.com/i.js?l=us-en&o=json&q=${encodeURIComponent(query)}&vqd=${vqd}&f=,,,&p=1`;
36
+ const res = await fetchWithRetry(url, {
37
+ headers: {
38
+ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
39
+ 'Referer': 'https://duckduckgo.com/',
40
+ 'Accept': 'application/json',
41
+ }
42
+ });
43
+ const data = await res.json();
44
+ return (data.results ?? []).slice(0, Math.min(count, 20)).map(r => ({
45
+ title: r.title,
46
+ url: r.image,
47
+ width: r.width,
48
+ height: r.height,
49
+ source: r.source,
50
+ }));
51
+ }
52
+
53
+ export async function downloadImage(url, filename, outputDir) {
54
+ const dir = outputDir ?? join(process.cwd(), 'images');
55
+ mkdirSync(dir, { recursive: true });
56
+
57
+ const rawName = url.split('/').pop().split('?')[0].replace(/[^a-zA-Z0-9._-]/g, '_');
58
+ const name = filename ?? (rawName.length > 0 ? rawName : 'image.jpg');
59
+ const outPath = join(dir, name);
60
+
61
+ const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
62
+ if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
63
+
64
+ const ct = res.headers.get('content-type') ?? '';
65
+ if (ct && !ct.startsWith('image/') && !ct.startsWith('application/octet-stream'))
66
+ throw new Error(`URL returned ${ct}, not an image — try a different URL`);
67
+
68
+ const reader = res.body.getReader();
69
+ const { value: firstChunk } = await reader.read();
70
+ if (!firstChunk || firstChunk.length === 0) throw new Error('Empty response from URL');
71
+ const prefix = Buffer.from(firstChunk).toString('utf8', 0, 64);
72
+ if (/^\s*<(!DOCTYPE|html)/i.test(prefix))
73
+ throw new Error('URL returned an HTML page, not an image — try a different URL');
74
+
75
+ await pipeline(
76
+ (async function* () {
77
+ yield firstChunk;
78
+ while (true) {
79
+ const { done, value } = await reader.read();
80
+ if (done) break;
81
+ yield value;
82
+ }
83
+ })(),
84
+ createWriteStream(outPath)
85
+ );
86
+
87
+ return outPath;
88
+ }
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.2",
3
3
  "name": "openfig",
4
- "version": "0.3.17",
4
+ "version": "0.3.18",
5
5
  "description": "Open-source tools for Figma file parsing and rendering",
6
6
  "author": {
7
7
  "name": "OpenFig Contributors"
package/mcp-server.mjs CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  import { nid, ov, removeNode } from './lib/core/node-helpers.mjs';
22
22
  import { imageOv, hashToHex } from './lib/core/image-helpers.mjs';
23
23
  import { deepClone } from './lib/core/deep-clone.mjs';
24
+ import { searchImages, downloadImage } from './lib/images/search.mjs';
24
25
 
25
26
  const server = new McpServer({
26
27
  name: 'openfig',
@@ -222,45 +223,46 @@ server.tool(
222
223
  );
223
224
 
224
225
  // ── clone-slide ─────────────────────────────────────────────────────────
226
+ import { run as cloneSlideRun } from './bin/commands/clone-slide.mjs';
227
+
225
228
  server.tool(
226
229
  'openfig_clone_slide',
227
230
  'Duplicate a slide from the deck',
228
231
  {
229
232
  path: z.string().describe('Path to .deck file'),
230
233
  output: z.string().describe('Output .deck path'),
231
- slideId: z.string().describe('Source slide node ID to clone'),
234
+ slideId: z.string().describe('Source slide node ID or name to clone'),
235
+ name: z.string().optional().describe('Name for the new slide'),
236
+ set: z.array(z.string()).optional().describe('Text overrides as "nodeId=value" pairs'),
237
+ setImage: z.array(z.string()).optional().describe('Image overrides as "nodeId=path" pairs'),
232
238
  },
233
- async ({ path, output, slideId }) => {
234
- const deck = await FigDeck.fromDeckFile(path);
235
- const slide = deck.getNode(slideId);
236
- if (!slide) return { content: [{ type: 'text', text: `Slide ${slideId} not found` }] };
237
-
238
- let nextId = deck.maxLocalID() + 1;
239
- const newSlide = deepClone(slide);
240
- const newSlideId = nextId++;
241
- newSlide.guid = { sessionID: 1, localID: newSlideId };
242
- newSlide.phase = 'CREATED';
243
- delete newSlide.prototypeInteractions;
244
- delete newSlide.slideThumbnailHash;
245
- delete newSlide.editInfo;
246
-
247
- const inst = deck.getSlideInstance(slideId);
248
- if (inst) {
249
- const newInst = deepClone(inst);
250
- newInst.guid = { sessionID: 1, localID: nextId++ };
251
- newInst.phase = 'CREATED';
252
- newInst.parentIndex = { guid: { sessionID: 1, localID: newSlideId }, position: '!' };
253
- delete newInst.derivedSymbolData;
254
- delete newInst.derivedSymbolDataLayoutVersion;
255
- delete newInst.editInfo;
256
- deck.message.nodeChanges.push(newInst);
239
+ async ({ path, output, slideId, name, set, setImage }) => {
240
+ const logs = [];
241
+ const origLog = console.log;
242
+ const origError = console.error;
243
+ const origExit = process.exit;
244
+ console.log = (...a) => logs.push(a.join(' '));
245
+ console.error = (...a) => logs.push(a.join(' '));
246
+ process.exit = (code) => { throw new Error(`exit:${code}`); };
247
+ try {
248
+ await cloneSlideRun(
249
+ [path],
250
+ {
251
+ o: output,
252
+ template: slideId,
253
+ name: name || 'New Slide',
254
+ set: set || [],
255
+ 'set-image': setImage || [],
256
+ }
257
+ );
258
+ } catch (e) {
259
+ if (!e.message?.startsWith('exit:')) logs.push(`Error: ${e.message}`);
260
+ } finally {
261
+ console.log = origLog;
262
+ console.error = origError;
263
+ process.exit = origExit;
257
264
  }
258
-
259
- deck.message.nodeChanges.push(newSlide);
260
- deck.rebuildMaps();
261
-
262
- const bytes = await deck.saveDeck(output);
263
- return { content: [{ type: 'text', text: `Cloned slide ${slideId} → 1:${newSlideId}. Saved ${output} (${bytes} bytes)` }] };
265
+ return { content: [{ type: 'text', text: logs.join('\n') }] };
264
266
  }
265
267
  );
266
268
 
@@ -536,6 +538,38 @@ server.tool(
536
538
  }
537
539
  );
538
540
 
541
+ // ── search_images ────────────────────────────────────────────────────────
542
+ server.tool(
543
+ 'search_images',
544
+ 'Search for images by keyword. Returns a numbered list of results with URLs, dimensions, and source. Use this to find filler or placeholder images for slides.',
545
+ {
546
+ query: z.string().describe('Search query, e.g. "modern office sustainability"'),
547
+ count: z.number().optional().describe('Number of results to return (default: 10, max: 20)'),
548
+ },
549
+ async ({ query, count = 10 }) => {
550
+ const results = await searchImages(query, count);
551
+ const lines = results.map((r, i) =>
552
+ `${i + 1}. ${r.title}\n URL: ${r.url}\n Size: ${r.width}×${r.height} | Source: ${r.source}`
553
+ );
554
+ return { content: [{ type: 'text', text: lines.join('\n\n') || 'No results found' }] };
555
+ }
556
+ );
557
+
558
+ // ── download_image ───────────────────────────────────────────────────────
559
+ server.tool(
560
+ 'download_image',
561
+ 'Download an image from a URL to the project images/ folder. Returns the local file path. Always show the user the downloaded path before inserting into a slide.',
562
+ {
563
+ url: z.string().describe('Image URL to download'),
564
+ filename: z.string().optional().describe('Output filename (default: derived from URL)'),
565
+ output_dir: z.string().optional().describe('Directory to save into (default: ./images/ relative to cwd)'),
566
+ },
567
+ async ({ url, filename, output_dir }) => {
568
+ const outPath = await downloadImage(url, filename, output_dir);
569
+ return { content: [{ type: 'text', text: `Downloaded to: ${outPath}` }] };
570
+ }
571
+ );
572
+
539
573
  // ── Start server ────────────────────────────────────────────────────────
540
574
  const transport = new StdioServerTransport();
541
575
  await server.connect(transport);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openfig-cli",
3
- "version": "0.3.17",
3
+ "version": "0.3.18",
4
4
  "description": "OpenFig — Open-source tools for Figma file parsing and rendering",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,11 +36,11 @@
36
36
  },
37
37
  "repository": {
38
38
  "type": "git",
39
- "url": "git+https://github.com/rcoenen/OpenFig.git"
39
+ "url": "git+https://github.com/OpenFig-org/openfig-cli.git"
40
40
  },
41
- "homepage": "https://github.com/rcoenen/OpenFig",
41
+ "homepage": "https://github.com/OpenFig-org/openfig-cli",
42
42
  "bugs": {
43
- "url": "https://github.com/rcoenen/OpenFig/issues"
43
+ "url": "https://github.com/OpenFig-org/openfig-cli/issues"
44
44
  },
45
45
  "author": "rcoenen",
46
46
  "dependencies": {