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.
- package/bin/commands/clone-slide.mjs +56 -5
- package/lib/images/search.mjs +88 -0
- package/manifest.json +1 -1
- package/mcp-server.mjs +65 -31
- package/package.json +4 -4
|
@@ -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
|
-
//
|
|
44
|
-
const
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
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
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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.
|
|
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/
|
|
39
|
+
"url": "git+https://github.com/OpenFig-org/openfig-cli.git"
|
|
40
40
|
},
|
|
41
|
-
"homepage": "https://github.com/
|
|
41
|
+
"homepage": "https://github.com/OpenFig-org/openfig-cli",
|
|
42
42
|
"bugs": {
|
|
43
|
-
"url": "https://github.com/
|
|
43
|
+
"url": "https://github.com/OpenFig-org/openfig-cli/issues"
|
|
44
44
|
},
|
|
45
45
|
"author": "rcoenen",
|
|
46
46
|
"dependencies": {
|