openfig-cli 0.3.22 → 0.3.23

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.
@@ -14,11 +14,12 @@ import { decompress } from 'fzstd';
14
14
  import { inflateRaw, deflateRaw } from 'pako';
15
15
  import { ZstdCodec } from 'zstd-codec';
16
16
  import yazl from 'yazl';
17
- import { readFileSync, createWriteStream, existsSync, mkdtempSync, readdirSync } from 'fs';
17
+ import { readFileSync, createWriteStream, existsSync, mkdtempSync, readdirSync, copyFileSync, mkdirSync } from 'fs';
18
18
  import { execSync } from 'child_process';
19
19
  import { join, resolve } from 'path';
20
20
  import { tmpdir } from 'os';
21
21
  import { nid } from './node-helpers.mjs';
22
+ import { deepClone } from './deep-clone.mjs';
22
23
 
23
24
  export class FigDeck {
24
25
  constructor() {
@@ -231,6 +232,206 @@ export class FigDeck {
231
232
  }
232
233
  }
233
234
 
235
+ /**
236
+ * Import SYMBOL nodes (and their full subtrees) from another FigDeck into this one.
237
+ *
238
+ * Handles:
239
+ * - Deep cloning with full GUID remapping
240
+ * - parentIndex.guid rebinding
241
+ * - symbolData.symbolID remapping for nested INSTANCE nodes
242
+ * - overrideKey remapping
243
+ * - Image/blob file copying between decks
244
+ * - Deduplication by componentKey
245
+ *
246
+ * @param {FigDeck} sourceDeck - The FigDeck to copy symbols from
247
+ * @param {string[]} symbolIds - Array of symbol node IDs in "s:l" format (e.g., ['1:500', '1:600'])
248
+ * @returns {Map<string, string>} Map of old ID → new ID for every remapped node
249
+ */
250
+ importSymbols(sourceDeck, symbolIds) {
251
+ if (!sourceDeck || !symbolIds?.length) return new Map();
252
+
253
+ // Build componentKey index for dedup in the target deck
254
+ const existingByKey = new Map();
255
+ for (const sym of this.getSymbols()) {
256
+ if (sym.phase === 'REMOVED' || !sym.componentKey) continue;
257
+ existingByKey.set(sym.componentKey, nid(sym));
258
+ }
259
+
260
+ // Find the Internal Only Canvas to parent imported symbols under
261
+ const internalCanvas = this.message.nodeChanges.find(
262
+ n => n.type === 'CANVAS' && n.name === 'Internal Only Canvas' && n.phase !== 'REMOVED'
263
+ );
264
+ if (!internalCanvas) {
265
+ throw new Error('No "Internal Only Canvas" found in target deck');
266
+ }
267
+ const canvasId = nid(internalCanvas);
268
+
269
+ let nextId = this.maxLocalID() + 1;
270
+ const sessionId = 1;
271
+ const globalIdMap = new Map(); // old "s:l" → new "s:l" across all imported symbols
272
+
273
+ for (const symId of symbolIds) {
274
+ const sourceSymbol = sourceDeck.getNode(symId);
275
+ if (!sourceSymbol || sourceSymbol.type !== 'SYMBOL') {
276
+ throw new Error(`SYMBOL not found in source deck: ${symId}`);
277
+ }
278
+
279
+ // Dedup: if target already has a symbol with the same componentKey, skip
280
+ if (sourceSymbol.componentKey && existingByKey.has(sourceSymbol.componentKey)) {
281
+ globalIdMap.set(symId, existingByKey.get(sourceSymbol.componentKey));
282
+ continue;
283
+ }
284
+
285
+ // Collect the full subtree (DFS)
286
+ const subtreeNodes = [];
287
+ sourceDeck.walkTree(symId, node => {
288
+ if (node.phase !== 'REMOVED') subtreeNodes.push(node);
289
+ });
290
+
291
+ // Build ID remap table for this subtree
292
+ const idMap = new Map(); // old "s:l" → new guid { sessionID, localID }
293
+ for (const node of subtreeNodes) {
294
+ idMap.set(nid(node), { sessionID: sessionId, localID: nextId++ });
295
+ }
296
+
297
+ // Clone and remap each node
298
+ const clonedNodes = subtreeNodes.map(node => {
299
+ const clone = deepClone(node);
300
+ const oldId = nid(node);
301
+ const newGuid = idMap.get(oldId);
302
+ if (newGuid) clone.guid = newGuid;
303
+
304
+ // Root symbol → parent under Internal Only Canvas
305
+ if (oldId === symId) {
306
+ clone.parentIndex = {
307
+ guid: deepClone(internalCanvas.guid),
308
+ position: String.fromCharCode(0x21 + this.getChildren(canvasId).length),
309
+ };
310
+ } else if (clone.parentIndex?.guid) {
311
+ // Remap parent reference
312
+ const parentOldId = `${clone.parentIndex.guid.sessionID}:${clone.parentIndex.guid.localID}`;
313
+ const remappedParent = idMap.get(parentOldId);
314
+ if (remappedParent) {
315
+ clone.parentIndex = { ...clone.parentIndex, guid: deepClone(remappedParent) };
316
+ }
317
+ }
318
+
319
+ // Remap symbolData.symbolID on INSTANCE nodes
320
+ if (clone.type === 'INSTANCE' && clone.symbolData?.symbolID) {
321
+ const sid = clone.symbolData.symbolID;
322
+ const symRef = `${sid.sessionID}:${sid.localID}`;
323
+ const remappedSym = idMap.get(symRef);
324
+ if (remappedSym) {
325
+ clone.symbolData.symbolID = deepClone(remappedSym);
326
+ } else if (globalIdMap.has(symRef)) {
327
+ // Reference to a previously imported symbol
328
+ const newRef = globalIdMap.get(symRef);
329
+ const [s, l] = newRef.split(':').map(Number);
330
+ clone.symbolData.symbolID = { sessionID: s, localID: l };
331
+ }
332
+ }
333
+
334
+ // Remap overrideKey
335
+ if (clone.overrideKey) {
336
+ const okOld = `${clone.overrideKey.sessionID}:${clone.overrideKey.localID}`;
337
+ const remappedOk = idMap.get(okOld);
338
+ if (remappedOk) {
339
+ clone.overrideKey = deepClone(remappedOk);
340
+ }
341
+ }
342
+
343
+ // Remap symbolOverrides guid paths
344
+ if (clone.symbolOverrides) {
345
+ for (const ov of clone.symbolOverrides) {
346
+ if (ov.guidPath?.guids) {
347
+ ov.guidPath.guids = ov.guidPath.guids.map(g => {
348
+ const gOld = `${g.sessionID}:${g.localID}`;
349
+ const remapped = idMap.get(gOld);
350
+ return remapped ? deepClone(remapped) : g;
351
+ });
352
+ }
353
+ }
354
+ }
355
+
356
+ // Remap derivedSymbolData references
357
+ if (clone.derivedSymbolData) {
358
+ for (const dsd of clone.derivedSymbolData) {
359
+ if (dsd.symbolID) {
360
+ const dsdOld = `${dsd.symbolID.sessionID}:${dsd.symbolID.localID}`;
361
+ const remappedDsd = idMap.get(dsdOld);
362
+ if (remappedDsd) {
363
+ dsd.symbolID = deepClone(remappedDsd);
364
+ }
365
+ }
366
+ }
367
+ }
368
+
369
+ clone.phase = 'CREATED';
370
+ delete clone.slideThumbnailHash;
371
+ delete clone.editInfo;
372
+ delete clone.prototypeInteractions;
373
+
374
+ return clone;
375
+ });
376
+
377
+ // Copy referenced images from source deck
378
+ if (sourceDeck.imagesDir) {
379
+ if (!this.imagesDir) {
380
+ // Create a temp images dir for the target deck
381
+ const tmp = this._tempDir || mkdtempSync(join(tmpdir(), 'openfig_'));
382
+ if (!this._tempDir) this._tempDir = tmp;
383
+ this.imagesDir = join(tmp, 'images');
384
+ mkdirSync(this.imagesDir, { recursive: true });
385
+ }
386
+
387
+ for (const clone of clonedNodes) {
388
+ // Copy IMAGE fills
389
+ if (clone.fillPaints) {
390
+ for (const paint of clone.fillPaints) {
391
+ if (paint.type === 'IMAGE' && paint.image?.name) {
392
+ this._copyImageAsset(sourceDeck.imagesDir, paint.image.name);
393
+ }
394
+ if (paint.imageThumbnail?.name) {
395
+ this._copyImageAsset(sourceDeck.imagesDir, paint.imageThumbnail.name);
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+
402
+ // Add cloned nodes to target deck
403
+ this.message.nodeChanges.push(...clonedNodes);
404
+
405
+ // Record mappings
406
+ for (const [oldId, newGuid] of idMap.entries()) {
407
+ globalIdMap.set(oldId, `${newGuid.sessionID}:${newGuid.localID}`);
408
+ }
409
+
410
+ // Register componentKey for dedup of subsequent symbols in the same call
411
+ if (sourceSymbol.componentKey) {
412
+ const newSymId = `${idMap.get(symId).sessionID}:${idMap.get(symId).localID}`;
413
+ existingByKey.set(sourceSymbol.componentKey, newSymId);
414
+ }
415
+ }
416
+
417
+ this.rebuildMaps();
418
+ return globalIdMap;
419
+ }
420
+
421
+ /**
422
+ * Copy an image asset file from a source images directory to this deck's images directory.
423
+ * @param {string} srcImagesDir - Source images directory
424
+ * @param {string} fileName - Image file name (hash)
425
+ */
426
+ _copyImageAsset(srcImagesDir, fileName) {
427
+ if (!fileName || !this.imagesDir) return;
428
+ const srcPath = join(srcImagesDir, fileName);
429
+ const destPath = join(this.imagesDir, fileName);
430
+ if (existsSync(srcPath) && !existsSync(destPath)) {
431
+ copyFileSync(srcPath, destPath);
432
+ }
433
+ }
434
+
234
435
  /**
235
436
  * Encode message to canvas.fig binary.
236
437
  * Returns a Promise<Uint8Array> because zstd-codec uses callbacks.
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.2",
3
3
  "name": "openfig",
4
- "version": "0.3.22",
4
+ "version": "0.3.23",
5
5
  "description": "Open-source tools for Figma file parsing and rendering",
6
6
  "author": {
7
7
  "name": "OpenFig Contributors"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openfig-cli",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "description": "OpenFig — Open-source tools for Figma file parsing and rendering",
5
5
  "type": "module",
6
6
  "bin": {