sb-mig 6.2.0 → 6.3.0-beta.1

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.
Files changed (39) hide show
  1. package/README.md +15 -4
  2. package/dist/api/assets/asset-folders.d.ts +3 -0
  3. package/dist/api/assets/asset-folders.js +56 -0
  4. package/dist/api/assets/asset-folders.types.d.ts +29 -0
  5. package/dist/api/assets/asset-folders.types.js +1 -0
  6. package/dist/api/assets/assets.d.ts +4 -1
  7. package/dist/api/assets/assets.js +61 -16
  8. package/dist/api/assets/assets.types.d.ts +12 -1
  9. package/dist/api/assets/index.d.ts +4 -2
  10. package/dist/api/assets/index.js +2 -1
  11. package/dist/api/copy/assets.d.ts +12 -0
  12. package/dist/api/copy/assets.js +84 -0
  13. package/dist/api/copy/graph.d.ts +8 -0
  14. package/dist/api/copy/graph.js +27 -0
  15. package/dist/api/copy/index.d.ts +6 -0
  16. package/dist/api/copy/index.js +6 -0
  17. package/dist/api/copy/manifest.d.ts +23 -0
  18. package/dist/api/copy/manifest.js +115 -0
  19. package/dist/api/copy/reference-rewriter.d.ts +6 -0
  20. package/dist/api/copy/reference-rewriter.js +153 -0
  21. package/dist/api/copy/reference-scanner.d.ts +23 -0
  22. package/dist/api/copy/reference-scanner.js +322 -0
  23. package/dist/api/copy/types.d.ts +196 -0
  24. package/dist/api/copy/types.js +1 -0
  25. package/dist/api/managementApi.d.ts +6 -0
  26. package/dist/api/stories/stories.js +3 -3
  27. package/dist/api/stories/stories.types.d.ts +3 -1
  28. package/dist/api/testApi.d.ts +6 -0
  29. package/dist/api-v2/requestConfig.d.ts +17 -1
  30. package/dist/api-v2/requestConfig.js +28 -0
  31. package/dist/cli/cli-descriptions.d.ts +2 -2
  32. package/dist/cli/cli-descriptions.js +83 -18
  33. package/dist/cli/commands/copy.js +2091 -145
  34. package/dist/cli/index.js +0 -0
  35. package/dist/utils/files.d.ts +1 -0
  36. package/dist/utils/files.js +77 -3
  37. package/dist-cjs/api/stories/stories.js +3 -3
  38. package/dist-cjs/api-v2/requestConfig.js +29 -0
  39. package/package.json +2 -2
@@ -1,195 +1,2141 @@
1
+ import fs from "fs/promises";
2
+ import path from "path";
3
+ import { appendManifestEntry, buildCopyAssetsGraph, buildCopyMaps, createCopyGraph, dedupeManifestFile, getDefaultCopyManifestPaths, loadManifest, normalizeAssetFolderParentId, rewriteCopyReferences, scanStoriesReferences, summarizeCopyGraph, } from "../../api/copy/index.js";
4
+ import { buildPublishedLayerContext, resolveStoryLayerState, } from "../../api/data-migration/published-layer.js";
1
5
  import { managementApi } from "../../api/managementApi.js";
2
- import { createTree, traverseAndCreate } from "../../api/stories/tree.js";
6
+ import { parsePublishLanguagesOption, resolvePublishLanguageCodes, } from "../../api/stories/stories.js";
7
+ import { createTree } from "../../api/stories/tree.js";
8
+ import { mapWithConcurrency } from "../../utils/async-utils.js";
3
9
  import Logger from "../../utils/logger.js";
10
+ import { getFileName } from "../../utils/string-utils.js";
4
11
  import { apiConfig } from "../api-config.js";
5
- import { getSourceSpace, getTargetSpace, getWhat, getWhere, } from "../utils/cli-utils.js";
6
12
  const COPY_COMMANDS = {
7
13
  stories: "stories",
14
+ assets: "assets",
8
15
  };
9
- const attachToFolder = async (where, targetSpace) => {
10
- console.log("attachToFolder", where, targetSpace);
11
- const entryStory = await managementApi.stories.getStoryBySlug(where, {
16
+ const COPY_MODES = ["subtree", "children", "self"];
17
+ const TARGET_CONFLICT_CHECK_CONCURRENCY = 10;
18
+ const COPY_DRY_RUN_BASE_LIMITATIONS = [
19
+ "create_or_match",
20
+ "target_state_may_change",
21
+ ];
22
+ const COPY_DRY_RUN_STORY_ONLY_LIMITATIONS = [
23
+ "assets_not_copied_by_story_command",
24
+ "asset_rewrite_requires_existing_asset_manifest",
25
+ ];
26
+ const COPY_DRY_RUN_WITH_ASSETS_LIMITATIONS = [
27
+ "target_asset_identity_not_resolved_until_apply",
28
+ ];
29
+ const isCopyMode = (value) => COPY_MODES.includes(value);
30
+ const readStringFlag = (flags, names) => {
31
+ for (const name of names) {
32
+ const value = flags[name];
33
+ if (value !== undefined && value !== null && value !== "") {
34
+ return String(value);
35
+ }
36
+ }
37
+ return undefined;
38
+ };
39
+ const readStringListFlag = (flags, names) => {
40
+ const values = [];
41
+ for (const name of names) {
42
+ const value = flags[name];
43
+ if (Array.isArray(value)) {
44
+ values.push(...value.map(String));
45
+ continue;
46
+ }
47
+ if (value !== undefined && value !== null && value !== "") {
48
+ values.push(String(value));
49
+ }
50
+ }
51
+ return values
52
+ .map((value) => value.trim())
53
+ .filter((value) => value.length > 0);
54
+ };
55
+ const getCopySpace = (flags, names, fallback) => readStringFlag(flags, names) ?? fallback;
56
+ const parseCopyPublicationMode = (publicationModeFlag) => {
57
+ if (!publicationModeFlag) {
58
+ return "preserve-layers";
59
+ }
60
+ if (publicationModeFlag === "preserve-layers" ||
61
+ publicationModeFlag === "collapse-draft" ||
62
+ publicationModeFlag === "save-only") {
63
+ return publicationModeFlag;
64
+ }
65
+ throw new Error("--publicationMode must be one of: preserve-layers, collapse-draft, save-only.");
66
+ };
67
+ const resolveCopyPublicationOptions = async ({ flags, targetSpace, dryRun, }) => {
68
+ const mode = parseCopyPublicationMode(readStringFlag(flags, ["publicationMode", "publication-mode"]));
69
+ const publicationLanguagesFlag = readStringFlag(flags, [
70
+ "publicationLanguages",
71
+ "publication-languages",
72
+ ]);
73
+ if (mode === "save-only" && publicationLanguagesFlag) {
74
+ throw new Error("--publicationLanguages cannot be used with --publicationMode save-only.");
75
+ }
76
+ if (mode === "save-only") {
77
+ return { mode };
78
+ }
79
+ const publishLanguages = publicationLanguagesFlag
80
+ ? parsePublishLanguagesOption(publicationLanguagesFlag)
81
+ : "all";
82
+ return {
83
+ mode,
84
+ publishLanguages,
85
+ resolvedPublishLanguages: dryRun
86
+ ? undefined
87
+ : await resolvePublishLanguageCodes(publishLanguages, {
88
+ ...apiConfig,
89
+ spaceId: targetSpace,
90
+ }),
91
+ };
92
+ };
93
+ const normalizeDestination = (destination) => !destination || destination === "/" || destination === "root"
94
+ ? ""
95
+ : destination.replace(/^\/+|\/+$/g, "");
96
+ const joinSlugs = (...parts) => parts
97
+ .filter((part) => Boolean(part))
98
+ .map((part) => part.replace(/^\/+|\/+$/g, ""))
99
+ .filter((part) => part.length > 0)
100
+ .join("/");
101
+ const normalizeAssetFolderPath = (folderPath) => folderPath.replace(/^\/+|\/+$/g, "");
102
+ const toSelectionReport = (selection) => selection.type === "all"
103
+ ? "all"
104
+ : selection.type === "referenced_by_stories"
105
+ ? {
106
+ type: "referenced_by_stories",
107
+ source: selection.storySelection.source,
108
+ mode: selection.storySelection.mode,
109
+ }
110
+ : {
111
+ type: selection.type,
112
+ values: selection.values,
113
+ };
114
+ const resolveCopyAssetsSelection = (flags) => {
115
+ const assetValues = readStringListFlag(flags, ["asset", "assetId"]);
116
+ const assetFolderValues = readStringListFlag(flags, [
117
+ "assetFolder",
118
+ "asset-folder",
119
+ "assetFolderId",
120
+ "asset-folder-id",
121
+ ]);
122
+ const hasAll = Boolean(flags["all"]);
123
+ const hasReferencedByStories = Boolean(flags["referencedByStories"] ?? flags["referenced-by-stories"]);
124
+ const selectorCount = (hasAll ? 1 : 0) +
125
+ (assetValues.length > 0 ? 1 : 0) +
126
+ (assetFolderValues.length > 0 ? 1 : 0) +
127
+ (hasReferencedByStories ? 1 : 0);
128
+ if (selectorCount === 0) {
129
+ throw new Error("copy assets requires one selector: --all, --asset <id|url|unique-name>, --assetFolder <id|path>, or --referenced-by-stories --source <full_slug>.");
130
+ }
131
+ if (selectorCount > 1) {
132
+ throw new Error("copy assets accepts only one selector family at a time. Use --all, --asset, --assetFolder, or --referenced-by-stories.");
133
+ }
134
+ if (hasReferencedByStories) {
135
+ return {
136
+ type: "referenced_by_stories",
137
+ storySelection: resolveCopySelection(flags),
138
+ };
139
+ }
140
+ if (assetValues.length > 0) {
141
+ return {
142
+ type: "asset",
143
+ values: assetValues,
144
+ };
145
+ }
146
+ if (assetFolderValues.length > 0) {
147
+ return {
148
+ type: "asset_folder",
149
+ values: assetFolderValues.map(normalizeAssetFolderPath),
150
+ };
151
+ }
152
+ return {
153
+ type: "all",
154
+ };
155
+ };
156
+ const resolveCopySelection = (flags) => {
157
+ const rawSource = readStringFlag(flags, ["source", "what"]);
158
+ if (!rawSource) {
159
+ throw new Error("Missing source. Pass --source <full_slug> (or legacy --what <full_slug>).");
160
+ }
161
+ const rawMode = readStringFlag(flags, ["mode"]);
162
+ if (rawMode && !isCopyMode(rawMode)) {
163
+ throw new Error(`Unsupported copy mode '${rawMode}'. Use one of: ${COPY_MODES.join(", ")}.`);
164
+ }
165
+ const explicitMode = rawMode;
166
+ if (rawSource.endsWith("/*")) {
167
+ return {
168
+ source: rawSource.slice(0, -2),
169
+ mode: explicitMode ?? "children",
170
+ };
171
+ }
172
+ return {
173
+ source: rawSource,
174
+ mode: explicitMode ?? "subtree",
175
+ };
176
+ };
177
+ const resolveDestinationParentId = async (destination, targetSpace) => {
178
+ if (!destination || destination === "/" || destination === "root") {
179
+ return null;
180
+ }
181
+ const targetEntryStory = await managementApi.stories.getStoryBySlug(destination, {
12
182
  ...apiConfig,
13
183
  spaceId: targetSpace,
14
184
  });
15
- console.log("entryStory", entryStory);
185
+ if (!targetEntryStory) {
186
+ throw new Error(`Destination story or folder not found in target space: ${destination}`);
187
+ }
188
+ if (!targetEntryStory.story.is_folder) {
189
+ throw new Error(`Destination must be a folder or root. '${destination}' is not a folder.`);
190
+ }
191
+ return targetEntryStory.story.id;
192
+ };
193
+ const getStoryBySlugOrThrow = async (slug, sourceSpace) => {
194
+ const entryStory = await managementApi.stories.getStoryBySlug(slug, {
195
+ ...apiConfig,
196
+ spaceId: sourceSpace,
197
+ });
198
+ if (!entryStory) {
199
+ throw new Error(`Source story or folder not found: ${slug}`);
200
+ }
16
201
  return entryStory;
17
202
  };
18
- const decideStrategy = async (args) => {
19
- const { what, sourceSpace } = args;
20
- // Check if path ends with /* for recursive folder strategy
21
- if (what.endsWith("/*")) {
22
- const folderPath = what.slice(0, -2); // Remove /* from the endcurso
23
- const entryStory = await managementApi.stories.getStoryBySlug(folderPath, {
203
+ const getStoriesForSelection = async (selection, sourceSpace) => {
204
+ const rootStory = await getStoryBySlugOrThrow(selection.source, sourceSpace);
205
+ const root = rootStory.story;
206
+ if (selection.mode === "self") {
207
+ return [rootStory];
208
+ }
209
+ if (!root.is_folder) {
210
+ if (selection.mode === "children") {
211
+ throw new Error(`Copy mode 'children' requires a folder source. '${selection.source}' is not a folder.`);
212
+ }
213
+ return [rootStory];
214
+ }
215
+ const children = await managementApi.stories.getAllStories({
216
+ options: {
217
+ starts_with: `${selection.source}/`,
218
+ },
219
+ }, {
220
+ ...apiConfig,
221
+ spaceId: sourceSpace,
222
+ });
223
+ return [rootStory, ...children];
224
+ };
225
+ const stripGeneratedStoryFields = (story) => {
226
+ const { id, uuid, created_at, updated_at, published_at, first_published_at, last_author, alternates, parent, ...copyableStory } = story;
227
+ return copyableStory;
228
+ };
229
+ const normalizeStoriesForTree = (stories, selection) => stories.map((item) => {
230
+ const story = item.story;
231
+ const copyableStory = stripGeneratedStoryFields(story);
232
+ if (story.full_slug === selection.source) {
233
+ return {
234
+ ...copyableStory,
235
+ id: story.id,
236
+ parent_id: null,
237
+ };
238
+ }
239
+ return {
240
+ ...copyableStory,
241
+ id: story.id,
242
+ parent_id: story.parent_id === 0 ? null : story.parent_id,
243
+ };
244
+ });
245
+ const selectTreeRoots = (tree, selection) => {
246
+ if (selection.mode === "children") {
247
+ return tree[0]?.children ?? [];
248
+ }
249
+ return tree;
250
+ };
251
+ const prepareTreeForCreate = (tree) => tree.map((node) => ({
252
+ ...node,
253
+ story: stripGeneratedStoryFields(node.story),
254
+ children: node.children ? prepareTreeForCreate(node.children) : [],
255
+ }));
256
+ const resolveStorySlug = (story) => {
257
+ if (typeof story.slug === "string" && story.slug.length > 0) {
258
+ return story.slug;
259
+ }
260
+ const fullSlug = String(story.full_slug ?? "");
261
+ return fullSlug.split("/").filter(Boolean).at(-1) ?? "";
262
+ };
263
+ const buildStoryShellPayload = (story, parentId) => {
264
+ const component = story.content?.component;
265
+ return {
266
+ name: story.name,
267
+ slug: resolveStorySlug(story),
268
+ is_folder: story.is_folder === true,
269
+ ...(parentId !== null ? { parent_id: parentId } : {}),
270
+ ...(story.is_startpage === true && parentId !== null
271
+ ? { is_startpage: true }
272
+ : {}),
273
+ ...(component
274
+ ? {
275
+ content: {
276
+ _uid: "",
277
+ component,
278
+ },
279
+ }
280
+ : {}),
281
+ };
282
+ };
283
+ const buildFinalStoryPayload = ({ sourceStory, targetParentId, rewrittenContent, }) => {
284
+ const payload = stripGeneratedStoryFields(sourceStory);
285
+ delete payload.full_slug;
286
+ delete payload.parent_id;
287
+ payload.slug = resolveStorySlug(sourceStory);
288
+ payload.content = rewrittenContent;
289
+ if (targetParentId !== null) {
290
+ payload.parent_id = targetParentId;
291
+ }
292
+ if (sourceStory.is_startpage === true && targetParentId !== null) {
293
+ payload.is_startpage = true;
294
+ }
295
+ return payload;
296
+ };
297
+ const getValidMappedTargetStory = async ({ sourceStory, targetStoryId, targetFullSlug, targetSpace, }) => {
298
+ const targetStory = await managementApi.stories.getStoryById(String(targetStoryId), {
299
+ ...apiConfig,
300
+ spaceId: targetSpace,
301
+ });
302
+ if (targetStory?.story?.id) {
303
+ if (!targetFullSlug) {
304
+ return targetStory.story;
305
+ }
306
+ const targetStoryBySlug = await managementApi.stories.getStoryBySlug(targetFullSlug, {
307
+ ...apiConfig,
308
+ spaceId: targetSpace,
309
+ });
310
+ if (Number(targetStoryBySlug?.story?.id) === targetStoryId) {
311
+ return targetStory.story;
312
+ }
313
+ }
314
+ Logger.warning(`Ignoring stale story manifest mapping for '${sourceStory.full_slug ?? sourceStory.slug}' because target story '${targetStoryId}' was not found at the expected target path in space '${targetSpace}'.`);
315
+ return undefined;
316
+ };
317
+ const assertStoryUpdateSucceeded = ({ result, sourceStory, targetStoryId, targetSpace, }) => {
318
+ if (result?.ok !== false) {
319
+ return;
320
+ }
321
+ const statusLabel = result.status
322
+ ? `status ${result.status}`
323
+ : "unknown status";
324
+ const responseLabel = result.response
325
+ ? ` Response: ${result.response}`
326
+ : "";
327
+ throw new Error(`Failed to update copied story '${sourceStory.full_slug ?? sourceStory.slug}' in target space '${targetSpace}' (target story id ${targetStoryId}, ${statusLabel}).${responseLabel}`);
328
+ };
329
+ const isStoryUpdateNotFound = (result) => result?.ok === false && Number(result.status) === 404;
330
+ const buildCopyPlan = (tree, destination) => {
331
+ const destinationRoot = normalizeDestination(destination);
332
+ const plan = [];
333
+ const walk = (nodes, parentTargetSlug) => {
334
+ for (const node of nodes) {
335
+ const story = node.story;
336
+ const targetFullSlug = joinSlugs(parentTargetSlug, resolveStorySlug(story));
337
+ plan.push({
338
+ type: story.is_folder ? "folder" : "story",
339
+ sourceFullSlug: String(story.full_slug ?? story.slug ?? ""),
340
+ targetFullSlug,
341
+ name: String(story.name ?? story.slug ?? "unknown"),
342
+ action: "create",
343
+ });
344
+ if (node.children?.length) {
345
+ walk(node.children, targetFullSlug);
346
+ }
347
+ }
348
+ };
349
+ walk(tree, destinationRoot);
350
+ return plan;
351
+ };
352
+ const findTargetConflicts = async (plan, targetSpace) => {
353
+ let checked = 0;
354
+ if (plan.length === 0) {
355
+ return [];
356
+ }
357
+ Logger.warning(`Checking ${plan.length} planned target path(s) for existing stories/folders.`);
358
+ const results = await mapWithConcurrency(plan, TARGET_CONFLICT_CHECK_CONCURRENCY, async (item) => {
359
+ const existingStory = await managementApi.stories.getStoryBySlug(item.targetFullSlug, {
24
360
  ...apiConfig,
25
- spaceId: sourceSpace,
361
+ spaceId: targetSpace,
26
362
  });
27
- if (!entryStory) {
28
- throw new Error(`Story not found: ${folderPath}`);
363
+ checked += 1;
364
+ if (checked === plan.length ||
365
+ checked % 25 === 0 ||
366
+ plan.length <= 25) {
367
+ Logger.success(`Checked ${checked} of ${plan.length} target path(s) for conflicts.`);
29
368
  }
30
- if (!entryStory.story.is_folder) {
31
- throw new Error(`${folderPath} is not a folder`);
369
+ return existingStory ? item : null;
370
+ });
371
+ const conflicts = results.filter((item) => item !== null);
372
+ Logger.success(`Target conflict check complete. Found ${conflicts.length} existing target path(s).`);
373
+ return conflicts;
374
+ };
375
+ const withConflictFlags = (plan, conflicts) => {
376
+ const conflictSlugs = new Set(conflicts.map((conflict) => conflict.targetFullSlug));
377
+ return plan.map((item) => ({
378
+ ...item,
379
+ ...(conflictSlugs.has(item.targetFullSlug) ? { conflict: true } : {}),
380
+ }));
381
+ };
382
+ const buildDryRunWarnings = ({ conflicts, withAssets, }) => [
383
+ ...conflicts.map((conflict) => ({
384
+ code: "target_exists",
385
+ message: `Target ${conflict.type} already exists at '${conflict.targetFullSlug}'. Current copy is create-only, so the real copy may fail.`,
386
+ targetFullSlug: conflict.targetFullSlug,
387
+ })),
388
+ ...(withAssets
389
+ ? []
390
+ : [
391
+ {
392
+ code: "assets_not_copied_by_story_command",
393
+ message: "Assets are not copied by this command unless --with-assets is passed.",
394
+ },
395
+ {
396
+ code: "asset_rewrite_requires_existing_asset_manifest",
397
+ message: "Asset fields are rewritten only when matching asset manifest entries already exist.",
398
+ },
399
+ ]),
400
+ ];
401
+ const quoteCommandArg = (value) => /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : JSON.stringify(value);
402
+ const buildCopyCommand = ({ sourceSpace, targetSpace, selection, destination, withAssets, dryRun, outputPath, }) => {
403
+ const args = [
404
+ "sb-mig",
405
+ "copy",
406
+ "stories",
407
+ "--from",
408
+ sourceSpace,
409
+ "--to",
410
+ targetSpace,
411
+ "--source",
412
+ selection.source,
413
+ "--mode",
414
+ selection.mode,
415
+ ];
416
+ if (destination) {
417
+ args.push("--destination", destination);
418
+ }
419
+ if (withAssets) {
420
+ args.push("--with-assets");
421
+ }
422
+ if (dryRun) {
423
+ args.push("--dry-run");
424
+ }
425
+ if (outputPath) {
426
+ args.push("--outputPath", outputPath);
427
+ }
428
+ return args.map(quoteCommandArg).join(" ");
429
+ };
430
+ const buildCopyAssetsCommand = ({ sourceSpace, targetSpace, selection, dryRun, outputPath, }) => {
431
+ const args = [
432
+ "sb-mig",
433
+ "copy",
434
+ "assets",
435
+ "--from",
436
+ sourceSpace,
437
+ "--to",
438
+ targetSpace,
439
+ ];
440
+ if (selection.type === "all") {
441
+ args.push("--all");
442
+ }
443
+ else if (selection.type === "asset") {
444
+ for (const value of selection.values) {
445
+ args.push("--asset", value);
446
+ }
447
+ }
448
+ else if (selection.type === "asset_folder") {
449
+ for (const value of selection.values) {
450
+ args.push("--assetFolder", value);
32
451
  }
33
- return { strategy: "folder_recursive" };
34
452
  }
35
- // For regular paths, check if it's a folder or story
36
- const entryStory = await managementApi.stories.getStoryBySlug(what, {
453
+ else {
454
+ args.push("--referenced-by-stories", "--source", selection.storySelection.source, "--mode", selection.storySelection.mode);
455
+ }
456
+ if (dryRun) {
457
+ args.push("--dry-run");
458
+ }
459
+ if (outputPath) {
460
+ args.push("--outputPath", outputPath);
461
+ }
462
+ return args.map(quoteCommandArg).join(" ");
463
+ };
464
+ const parseStoryblokAssetUrl = (filename) => {
465
+ if (!filename) {
466
+ return {};
467
+ }
468
+ let url;
469
+ try {
470
+ url = new URL(filename);
471
+ }
472
+ catch {
473
+ return {
474
+ uniqueKey: filename,
475
+ };
476
+ }
477
+ if (url.hostname !== "a.storyblok.com") {
478
+ return {
479
+ uniqueKey: filename,
480
+ };
481
+ }
482
+ const parts = url.pathname.split("/").filter(Boolean);
483
+ const fIndex = parts.indexOf("f");
484
+ const spaceId = fIndex >= 0 ? parts[fIndex + 1] : undefined;
485
+ const assetHash = fIndex >= 0 ? parts[fIndex + 3] : undefined;
486
+ const assetName = fIndex >= 0 ? parts.slice(fIndex + 4).join("/") : "";
487
+ if (!spaceId || !assetHash || !assetName) {
488
+ return {
489
+ spaceId,
490
+ uniqueKey: filename,
491
+ };
492
+ }
493
+ return {
494
+ spaceId,
495
+ uniqueKey: `storyblok:${spaceId}:${assetHash}:${assetName}`,
496
+ };
497
+ };
498
+ const getAssetReferenceUniqueKey = (reference) => {
499
+ const parsed = parseStoryblokAssetUrl(reference.filename);
500
+ return (parsed.uniqueKey ??
501
+ (reference.assetId === undefined
502
+ ? `unknown:${reference.path}`
503
+ : `asset-id:${reference.assetId}`));
504
+ };
505
+ const buildAssetReferenceSummary = ({ graph, sourceSpace, }) => {
506
+ if (!graph) {
507
+ return undefined;
508
+ }
509
+ const statuses = [
510
+ "mapped",
511
+ "planned",
512
+ "unresolved",
513
+ "unsupported",
514
+ ];
515
+ const uniqueByStatus = new Map(statuses.map((status) => [status, new Set()]));
516
+ const occurrencesByStatus = new Map(statuses.map((status) => [status, 0]));
517
+ const foreignSpaces = new Map();
518
+ for (const reference of graph.assetReferences) {
519
+ const status = reference.status;
520
+ const uniqueKey = getAssetReferenceUniqueKey(reference);
521
+ occurrencesByStatus.set(status, (occurrencesByStatus.get(status) ?? 0) + 1);
522
+ uniqueByStatus.get(status)?.add(uniqueKey);
523
+ const parsed = parseStoryblokAssetUrl(reference.filename);
524
+ if (parsed.spaceId && parsed.spaceId !== sourceSpace) {
525
+ const existing = foreignSpaces.get(parsed.spaceId) ?? {
526
+ occurrences: 0,
527
+ uniqueAssets: new Set(),
528
+ };
529
+ existing.occurrences += 1;
530
+ existing.uniqueAssets.add(uniqueKey);
531
+ foreignSpaces.set(parsed.spaceId, existing);
532
+ }
533
+ }
534
+ const bucket = (status) => ({
535
+ occurrences: occurrencesByStatus.get(status) ?? 0,
536
+ uniqueAssets: uniqueByStatus.get(status)?.size ?? 0,
537
+ });
538
+ return {
539
+ mapped: bucket("mapped"),
540
+ planned: bucket("planned"),
541
+ unresolved: bucket("unresolved"),
542
+ unsupported: bucket("unsupported"),
543
+ foreignAssetSpaces: Array.from(foreignSpaces.entries())
544
+ .map(([spaceId, summary]) => ({
545
+ spaceId,
546
+ occurrences: summary.occurrences,
547
+ uniqueAssets: summary.uniqueAssets.size,
548
+ }))
549
+ .sort((left, right) => left.spaceId.localeCompare(right.spaceId)),
550
+ };
551
+ };
552
+ const buildCopyDryRunReport = ({ sourceSpace, targetSpace, selection, destination, withAssets, input, plan, conflicts, graph, outputPath, }) => {
553
+ const items = withConflictFlags(plan, conflicts);
554
+ const warnings = buildDryRunWarnings({ conflicts, withAssets });
555
+ const graphSummary = graph ? summarizeCopyGraph(graph) : undefined;
556
+ const assetReferencesMapped = graph?.assetReferences.filter((reference) => reference.status === "mapped").length ?? 0;
557
+ const assetReferencesPlanned = graph?.assetReferences.filter((reference) => reference.status === "planned").length ?? 0;
558
+ const assetReferencesUnresolved = graph?.assetReferences.filter((reference) => reference.status === "unresolved").length ?? 0;
559
+ const storyReferencesMapped = graph?.storyReferences.filter((reference) => reference.status === "mapped").length ?? 0;
560
+ const storyReferencesPreserved = graph?.storyReferences.filter((reference) => reference.status === "preserved_external").length ?? 0;
561
+ const storyReferencesUnresolved = graph?.storyReferences.filter((reference) => reference.status === "unresolved").length ?? 0;
562
+ const assetsMapped = graph?.assets.filter((asset) => asset.action === "match").length ?? 0;
563
+ const assetsToCopy = graph?.assets.filter((asset) => asset.action === "create").length ?? 0;
564
+ const assetReferenceSummary = buildAssetReferenceSummary({
565
+ graph,
566
+ sourceSpace,
567
+ });
568
+ const limitations = [
569
+ ...COPY_DRY_RUN_BASE_LIMITATIONS,
570
+ ...(withAssets
571
+ ? COPY_DRY_RUN_WITH_ASSETS_LIMITATIONS
572
+ : COPY_DRY_RUN_STORY_ONLY_LIMITATIONS),
573
+ ];
574
+ return {
575
+ schemaVersion: 1,
576
+ command: "copy stories",
577
+ dryRun: true,
578
+ generatedAt: new Date().toISOString(),
579
+ input,
580
+ normalized: {
581
+ sourceSpaceId: sourceSpace,
582
+ targetSpaceId: targetSpace,
583
+ source: selection.source,
584
+ destination: normalizeDestination(destination) || "root",
585
+ mode: selection.mode,
586
+ withAssets,
587
+ },
588
+ summary: {
589
+ plannedCreates: items.length,
590
+ folders: items.filter((item) => item.type === "folder").length,
591
+ stories: items.filter((item) => item.type === "story").length,
592
+ assetFolders: graphSummary?.assetFolders ?? 0,
593
+ assets: graphSummary?.assets ?? 0,
594
+ assetReferences: graphSummary?.assetReferences ?? 0,
595
+ assetReferencesMapped,
596
+ assetReferencesPlanned,
597
+ assetReferencesUnresolved,
598
+ assetsMapped,
599
+ assetsToCopy,
600
+ storyReferences: graphSummary?.storyReferences ?? 0,
601
+ storyReferencesMapped,
602
+ storyReferencesPreserved,
603
+ storyReferencesUnresolved,
604
+ conflicts: conflicts.length,
605
+ warnings: warnings.length + (graphSummary?.warnings ?? 0),
606
+ errors: graphSummary?.errors ?? 0,
607
+ },
608
+ items,
609
+ ...(graph ? { graph } : {}),
610
+ ...(assetReferenceSummary ? { assetReferenceSummary } : {}),
611
+ warnings,
612
+ errors: graph?.errors ?? [],
613
+ limitations,
614
+ commands: {
615
+ dryRun: buildCopyCommand({
616
+ sourceSpace,
617
+ targetSpace,
618
+ selection,
619
+ destination,
620
+ withAssets,
621
+ dryRun: true,
622
+ outputPath,
623
+ }),
624
+ apply: buildCopyCommand({
625
+ sourceSpace,
626
+ targetSpace,
627
+ selection,
628
+ destination,
629
+ withAssets,
630
+ dryRun: false,
631
+ }),
632
+ },
633
+ };
634
+ };
635
+ const buildCopyAssetsDryRunReport = ({ sourceSpace, targetSpace, selection, input, outputPath, graph, }) => {
636
+ const graphSummary = summarizeCopyGraph(graph);
637
+ return {
638
+ schemaVersion: 1,
639
+ command: "copy assets",
640
+ dryRun: true,
641
+ generatedAt: graph.generatedAt,
642
+ input,
643
+ normalized: {
644
+ sourceSpaceId: sourceSpace,
645
+ targetSpaceId: targetSpace,
646
+ selection: toSelectionReport(selection),
647
+ },
648
+ summary: {
649
+ plannedCreates: graphSummary.assetFolders + graphSummary.assets,
650
+ assetFolders: graphSummary.assetFolders,
651
+ assets: graphSummary.assets,
652
+ warnings: graphSummary.warnings,
653
+ errors: graphSummary.errors,
654
+ },
655
+ graph,
656
+ limitations: graph.limitations,
657
+ commands: {
658
+ dryRun: buildCopyAssetsCommand({
659
+ sourceSpace,
660
+ targetSpace,
661
+ selection,
662
+ dryRun: true,
663
+ outputPath,
664
+ }),
665
+ apply: buildCopyAssetsCommand({
666
+ sourceSpace,
667
+ targetSpace,
668
+ selection,
669
+ dryRun: false,
670
+ }),
671
+ },
672
+ };
673
+ };
674
+ const buildCopyStoriesApplyReport = ({ sourceSpace, targetSpace, selection, destination, withAssets, input, plan, storySummary, graph, assetCopyReport, manifestRoot, }) => {
675
+ const manifestPaths = getDefaultCopyManifestPaths({
676
+ sourceSpaceId: sourceSpace,
677
+ targetSpaceId: targetSpace,
678
+ rootDir: manifestRoot,
679
+ });
680
+ const graphSummary = graph ? summarizeCopyGraph(graph) : undefined;
681
+ return {
682
+ schemaVersion: 1,
683
+ command: "copy stories",
684
+ dryRun: false,
685
+ generatedAt: new Date().toISOString(),
686
+ input,
687
+ normalized: {
688
+ sourceSpaceId: sourceSpace,
689
+ targetSpaceId: targetSpace,
690
+ source: selection.source,
691
+ destination: normalizeDestination(destination) || "root",
692
+ mode: selection.mode,
693
+ withAssets,
694
+ },
695
+ summary: {
696
+ ...storySummary,
697
+ ...(assetCopyReport
698
+ ? {
699
+ assetFoldersCreated: assetCopyReport.summary.assetFoldersCreated,
700
+ assetFoldersMatched: assetCopyReport.summary.assetFoldersMatched,
701
+ assetsCreated: assetCopyReport.summary.assetsCreated,
702
+ assetsMatched: assetCopyReport.summary.assetsMatched,
703
+ }
704
+ : {}),
705
+ warnings: (graphSummary?.warnings ?? 0) +
706
+ (assetCopyReport?.summary.warnings ?? 0),
707
+ errors: (graphSummary?.errors ?? 0) +
708
+ (assetCopyReport?.summary.errors ?? 0),
709
+ },
710
+ items: plan,
711
+ ...(graph ? { graph } : {}),
712
+ ...(assetCopyReport ? { assetCopy: assetCopyReport } : {}),
713
+ manifestPaths: {
714
+ stories: manifestPaths.stories,
715
+ assets: manifestPaths.assets,
716
+ assetFolders: manifestPaths.assetFolders,
717
+ combined: manifestPaths.combined,
718
+ },
719
+ warnings: [
720
+ ...(graph?.warnings ?? []),
721
+ ...(assetCopyReport?.warnings ?? []),
722
+ ],
723
+ errors: [...(graph?.errors ?? []), ...(assetCopyReport?.errors ?? [])],
724
+ };
725
+ };
726
+ const writeDryRunReport = async (outputPath, report) => {
727
+ const outputDirectory = path.dirname(outputPath);
728
+ await fs.mkdir(outputDirectory, { recursive: true });
729
+ await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8");
730
+ Logger.success(`[dry-run] Copy plan written to ${outputPath}`);
731
+ };
732
+ const writeJsonReport = async (outputPath, report) => {
733
+ const outputDirectory = path.dirname(outputPath);
734
+ await fs.mkdir(outputDirectory, { recursive: true });
735
+ await fs.writeFile(outputPath, JSON.stringify(report, null, 2), "utf8");
736
+ Logger.success(`Copy report written to ${outputPath}`);
737
+ };
738
+ const appendCopyManifestEntry = async ({ combinedPath, resourcePath, entry, }) => {
739
+ await appendManifestEntry(resourcePath, entry);
740
+ await appendManifestEntry(combinedPath, entry);
741
+ };
742
+ const getAssetFolderPath = (folder, folderById) => {
743
+ const names = [String(folder.name ?? folder.id)];
744
+ const visited = new Set([Number(folder.id)]);
745
+ let parentId = folder.parent_id;
746
+ while (parentId !== null && parentId !== undefined) {
747
+ const parent = folderById.get(Number(parentId));
748
+ if (!parent || visited.has(Number(parent.id))) {
749
+ break;
750
+ }
751
+ names.unshift(String(parent.name ?? parent.id));
752
+ visited.add(Number(parent.id));
753
+ parentId = parent.parent_id;
754
+ }
755
+ return names.join("/");
756
+ };
757
+ const buildAssetFolderPathMap = (folders) => {
758
+ const folderById = new Map(folders.map((folder) => [Number(folder.id), folder]));
759
+ const folderByPath = new Map();
760
+ for (const folder of folders) {
761
+ folderByPath.set(getAssetFolderPath(folder, folderById), folder);
762
+ }
763
+ return folderByPath;
764
+ };
765
+ const isNumericSelector = (value) => /^\d+$/.test(value);
766
+ const findSourceAssetBySelector = (assets, selector) => {
767
+ if (isNumericSelector(selector)) {
768
+ const byId = assets.find((asset) => Number(asset.id) === Number(selector));
769
+ if (byId) {
770
+ return byId;
771
+ }
772
+ }
773
+ const exactFilenameMatches = assets.filter((asset) => asset.filename === selector);
774
+ if (exactFilenameMatches.length === 1) {
775
+ return exactFilenameMatches[0];
776
+ }
777
+ if (exactFilenameMatches.length > 1) {
778
+ throw new Error(`Asset selector '${selector}' matched multiple source assets by filename. Use --asset <asset-id>.`);
779
+ }
780
+ const fileNameMatches = assets.filter((asset) => getFileName(asset.filename) === selector);
781
+ if (fileNameMatches.length === 1) {
782
+ return fileNameMatches[0];
783
+ }
784
+ if (fileNameMatches.length > 1) {
785
+ throw new Error(`Asset selector '${selector}' matched multiple source assets by file name. Use --asset <asset-id>.`);
786
+ }
787
+ throw new Error(`Asset selector '${selector}' did not match a source asset by id, filename, or unique file name.`);
788
+ };
789
+ const findSourceAssetFolderBySelector = (folderById, folderByPath, selector) => {
790
+ if (isNumericSelector(selector)) {
791
+ const byId = folderById.get(Number(selector));
792
+ if (byId) {
793
+ return byId;
794
+ }
795
+ }
796
+ const normalizedPath = normalizeAssetFolderPath(selector);
797
+ const byPath = folderByPath.get(normalizedPath);
798
+ if (byPath) {
799
+ return byPath;
800
+ }
801
+ throw new Error(`Asset folder selector '${selector}' did not match a source asset folder by id or path.`);
802
+ };
803
+ const addAssetFolderAncestors = ({ folderId, folderById, selectedFolderIds, }) => {
804
+ const visited = new Set();
805
+ let currentFolderId = folderId;
806
+ while (currentFolderId !== null && currentFolderId !== undefined) {
807
+ const currentFolder = folderById.get(Number(currentFolderId));
808
+ if (!currentFolder || visited.has(Number(currentFolder.id))) {
809
+ break;
810
+ }
811
+ selectedFolderIds.add(Number(currentFolder.id));
812
+ visited.add(Number(currentFolder.id));
813
+ currentFolderId = currentFolder.parent_id;
814
+ }
815
+ };
816
+ const collectAssetFolderSubtreeIds = (rootFolderId, folders) => {
817
+ const selected = new Set([rootFolderId]);
818
+ let changed = true;
819
+ while (changed) {
820
+ changed = false;
821
+ for (const folder of folders) {
822
+ const folderId = Number(folder.id);
823
+ const parentId = folder.parent_id;
824
+ if (parentId !== null &&
825
+ parentId !== undefined &&
826
+ selected.has(Number(parentId)) &&
827
+ !selected.has(folderId)) {
828
+ selected.add(folderId);
829
+ changed = true;
830
+ }
831
+ }
832
+ }
833
+ return selected;
834
+ };
835
+ const selectSourceAssetsForCopy = ({ selection, sourceAssets, sourceAssetFolders, }) => {
836
+ if (selection.type === "all") {
837
+ return {
838
+ assets: sourceAssets,
839
+ assetFolders: sourceAssetFolders,
840
+ };
841
+ }
842
+ const folderById = new Map(sourceAssetFolders.map((folder) => [Number(folder.id), folder]));
843
+ const folderByPath = buildAssetFolderPathMap(sourceAssetFolders);
844
+ const selectedAssetIds = new Set();
845
+ const selectedFolderIds = new Set();
846
+ if (selection.type === "asset") {
847
+ for (const selector of selection.values) {
848
+ const asset = findSourceAssetBySelector(sourceAssets, selector);
849
+ selectedAssetIds.add(Number(asset.id));
850
+ addAssetFolderAncestors({
851
+ folderId: asset.asset_folder_id,
852
+ folderById,
853
+ selectedFolderIds,
854
+ });
855
+ }
856
+ }
857
+ if (selection.type === "asset_folder") {
858
+ for (const selector of selection.values) {
859
+ const folder = findSourceAssetFolderBySelector(folderById, folderByPath, selector);
860
+ const subtreeFolderIds = collectAssetFolderSubtreeIds(Number(folder.id), sourceAssetFolders);
861
+ for (const folderId of subtreeFolderIds) {
862
+ selectedFolderIds.add(folderId);
863
+ }
864
+ addAssetFolderAncestors({
865
+ folderId: folder.parent_id,
866
+ folderById,
867
+ selectedFolderIds,
868
+ });
869
+ }
870
+ for (const asset of sourceAssets) {
871
+ if (asset.asset_folder_id !== null &&
872
+ asset.asset_folder_id !== undefined &&
873
+ selectedFolderIds.has(Number(asset.asset_folder_id))) {
874
+ selectedAssetIds.add(Number(asset.id));
875
+ }
876
+ }
877
+ }
878
+ return {
879
+ assets: sourceAssets.filter((asset) => selectedAssetIds.has(Number(asset.id))),
880
+ assetFolders: sourceAssetFolders.filter((folder) => selectedFolderIds.has(Number(folder.id))),
881
+ };
882
+ };
883
+ const selectSourceAssetsFromGraph = ({ graph, sourceAssets, sourceAssetFolders, }) => {
884
+ const graphAssetIds = new Set(graph.assets.map((asset) => Number(asset.sourceId)));
885
+ const graphAssetFolderIds = new Set(graph.assetFolders.map((folder) => Number(folder.sourceId)));
886
+ return {
887
+ assets: sourceAssets.filter((asset) => graphAssetIds.has(Number(asset.id))),
888
+ assetFolders: sourceAssetFolders.filter((folder) => graphAssetFolderIds.has(Number(folder.id))),
889
+ };
890
+ };
891
+ const getAssetMetadataPayload = (asset) => ({
892
+ ...(asset.alt ? { alt: asset.alt } : {}),
893
+ ...(asset.title ? { title: asset.title } : {}),
894
+ ...(asset.copyright ? { copyright: asset.copyright } : {}),
895
+ ...(asset.source ? { source: asset.source } : {}),
896
+ ...(asset.focus ? { focus: asset.focus } : {}),
897
+ ...(asset.meta_data ? { meta_data: asset.meta_data } : {}),
898
+ ...(asset.is_private === undefined ? {} : { is_private: asset.is_private }),
899
+ ...(asset.locked === undefined ? {} : { locked: asset.locked }),
900
+ ...(asset.publish_at === undefined ? {} : { publish_at: asset.publish_at }),
901
+ ...(asset.internal_tag_ids?.length
902
+ ? { internal_tag_ids: asset.internal_tag_ids }
903
+ : {}),
904
+ });
905
+ const findUniqueTargetAssetByFileName = (targetAssets, fileName) => {
906
+ const matches = targetAssets.filter((asset) => getFileName(asset.filename) === fileName);
907
+ return matches.length === 1 ? matches[0] : undefined;
908
+ };
909
+ const buildCopyAssetsApplyReport = ({ sourceSpace, targetSpace, selection, input, graph, manifestPaths, assetFoldersCreated, assetFoldersMatched, assetsCreated, assetsMatched, }) => ({
910
+ schemaVersion: 1,
911
+ command: "copy assets",
912
+ dryRun: false,
913
+ generatedAt: graph.generatedAt,
914
+ input,
915
+ normalized: {
916
+ sourceSpaceId: sourceSpace,
917
+ targetSpaceId: targetSpace,
918
+ selection: toSelectionReport(selection),
919
+ },
920
+ summary: {
921
+ assetFoldersCreated,
922
+ assetFoldersMatched,
923
+ assetsCreated,
924
+ assetsMatched,
925
+ warnings: graph.warnings.length,
926
+ errors: graph.errors.length,
927
+ },
928
+ graph,
929
+ manifestPaths: {
930
+ assets: manifestPaths.assets,
931
+ assetFolders: manifestPaths.assetFolders,
932
+ combined: manifestPaths.combined,
933
+ },
934
+ warnings: graph.warnings,
935
+ errors: graph.errors,
936
+ });
937
+ const buildSourceStoryById = (sourceStories) => new Map(sourceStories
938
+ .map((item) => item?.story)
939
+ .filter(Boolean)
940
+ .map((story) => [Number(story.id), story]));
941
+ const buildTargetSlugBySourceSlug = (plan) => new Map(plan.map((item) => [item.sourceFullSlug, item.targetFullSlug]));
942
+ const countTreeStories = (nodes) => nodes.reduce((counts, node) => {
943
+ const childCounts = countTreeStories(node.children ?? []);
944
+ return {
945
+ folders: counts.folders +
946
+ childCounts.folders +
947
+ (node.story?.is_folder ? 1 : 0),
948
+ stories: counts.stories +
949
+ childCounts.stories +
950
+ (node.story?.is_folder ? 0 : 1),
951
+ };
952
+ }, { folders: 0, stories: 0 });
953
+ const buildComponentSchemaRegistry = async (sourceSpace) => {
954
+ const components = await managementApi.components.getAllComponents({
37
955
  ...apiConfig,
38
956
  spaceId: sourceSpace,
39
957
  });
40
- if (!entryStory) {
41
- throw new Error(`Story not found: ${what}`);
958
+ if (!Array.isArray(components)) {
959
+ return {};
42
960
  }
43
- const entryStoryIsFolder = entryStory.story.is_folder;
44
- if (entryStoryIsFolder) {
45
- return { strategy: "folder_with_root" };
961
+ return Object.fromEntries(components
962
+ .filter((component) => component?.name && component?.schema)
963
+ .map((component) => [component.name, component.schema]));
964
+ };
965
+ const collectAssetFolderAncestors = ({ assets, assetFolders, }) => {
966
+ const folderById = new Map(assetFolders.map((folder) => [Number(folder.id), folder]));
967
+ const selectedFolderIds = new Set();
968
+ for (const asset of assets) {
969
+ let folderId = asset.asset_folder_id;
970
+ const visited = new Set();
971
+ while (folderId !== null && folderId !== undefined) {
972
+ const numericFolderId = Number(folderId);
973
+ if (visited.has(numericFolderId)) {
974
+ break;
975
+ }
976
+ visited.add(numericFolderId);
977
+ selectedFolderIds.add(numericFolderId);
978
+ const folder = folderById.get(numericFolderId);
979
+ if (!folder) {
980
+ break;
981
+ }
982
+ folderId = folder.parent_id;
983
+ }
46
984
  }
47
- return { strategy: "story" };
985
+ return assetFolders.filter((folder) => selectedFolderIds.has(Number(folder.id)));
48
986
  };
49
- export const copyCommand = async (props) => {
50
- const { input, flags } = props;
51
- const command = input[1];
52
- switch (command) {
53
- case COPY_COMMANDS.stories:
54
- Logger.warning(`test sb-mig... with command: ${command}`);
55
- console.log({ flags });
56
- const sourceSpace = getSourceSpace(flags, apiConfig);
57
- const targetSpace = getTargetSpace(flags, apiConfig);
58
- const what = getWhat(flags);
59
- const where = getWhere(flags);
60
- console.log({ sourceSpace, targetSpace, what, where });
61
- const { strategy } = await decideStrategy({ what, sourceSpace });
62
- if (strategy === "folder_recursive") {
63
- console.log({ strategy });
64
- const rootStorySlug = what.slice(0, -2);
65
- // 0. get the root story
66
- const rootStory = await managementApi.stories.getStoryBySlug(rootStorySlug, {
67
- ...apiConfig,
68
- spaceId: sourceSpace,
987
+ const hasMappedAssetReference = ({ assetId, filename, copyMaps, }) => (assetId !== undefined && copyMaps.assetIds.has(assetId)) ||
988
+ (filename !== undefined && copyMaps.assetFilenames.has(filename));
989
+ const annotateReferencesWithManifestMaps = ({ graph, copyMaps, withAssets, }) => {
990
+ for (const reference of graph.assetReferences) {
991
+ if (hasMappedAssetReference({ ...reference, copyMaps })) {
992
+ reference.status = "mapped";
993
+ continue;
994
+ }
995
+ if (!withAssets && reference.status === "planned") {
996
+ reference.status = "unresolved";
997
+ }
998
+ }
999
+ for (const reference of graph.storyReferences) {
1000
+ if ((reference.referencedStoryId !== undefined &&
1001
+ copyMaps.storyIds.has(reference.referencedStoryId)) ||
1002
+ (reference.referencedStoryUuid !== undefined &&
1003
+ copyMaps.storyUuids.has(reference.referencedStoryUuid))) {
1004
+ reference.status = "mapped";
1005
+ }
1006
+ }
1007
+ };
1008
+ const annotateAssetsWithManifestMaps = ({ graph, copyMaps, }) => {
1009
+ for (const folder of graph.assetFolders) {
1010
+ const targetFolderId = copyMaps.assetFolderIds.get(folder.sourceId);
1011
+ if (targetFolderId === undefined) {
1012
+ continue;
1013
+ }
1014
+ const sourceParentId = normalizeAssetFolderParentId(folder.sourceParentId);
1015
+ folder.targetParentId =
1016
+ sourceParentId === null
1017
+ ? null
1018
+ : (copyMaps.assetFolderIds.get(sourceParentId) ?? null);
1019
+ folder.action = "match";
1020
+ }
1021
+ for (const asset of graph.assets) {
1022
+ const mappedAsset = copyMaps.assetIds.get(asset.sourceId);
1023
+ const targetFilename = mappedAsset?.filename ??
1024
+ copyMaps.assetFilenames.get(asset.sourceFilename);
1025
+ if (!mappedAsset && !targetFilename) {
1026
+ continue;
1027
+ }
1028
+ asset.action = "match";
1029
+ asset.targetFilename = targetFilename ?? asset.targetFilename;
1030
+ asset.targetAssetFolderId =
1031
+ asset.sourceAssetFolderId === null ||
1032
+ asset.sourceAssetFolderId === undefined
1033
+ ? null
1034
+ : (copyMaps.assetFolderIds.get(asset.sourceAssetFolderId) ??
1035
+ null);
1036
+ }
1037
+ };
1038
+ const buildStoryReferenceDryRunGraph = ({ sourceSpace, targetSpace, selection, destination, plan, sourceStories, schemas, copyMaps, onScanProgress, }) => {
1039
+ const sourceStoryByFullSlug = new Map(sourceStories
1040
+ .map((item) => item?.story)
1041
+ .filter(Boolean)
1042
+ .map((story) => [String(story.full_slug ?? ""), story]));
1043
+ const scanResult = scanStoriesReferences({
1044
+ stories: sourceStories.map((item) => item?.story).filter(Boolean),
1045
+ schemas,
1046
+ options: {
1047
+ referencePolicy: "preserve",
1048
+ onProgress: onScanProgress,
1049
+ },
1050
+ });
1051
+ const graph = createCopyGraph({
1052
+ sourceSpaceId: sourceSpace,
1053
+ targetSpaceId: targetSpace,
1054
+ scope: {
1055
+ command: "copy stories",
1056
+ source: selection.source,
1057
+ destination: normalizeDestination(destination) || "root",
1058
+ mode: selection.mode,
1059
+ withAssets: false,
1060
+ referencePolicy: "preserve",
1061
+ },
1062
+ });
1063
+ graph.stories = plan.map((item) => ({
1064
+ type: "story",
1065
+ sourceId: Number(sourceStoryByFullSlug.get(item.sourceFullSlug)?.id ?? 0),
1066
+ sourceUuid: sourceStoryByFullSlug.get(item.sourceFullSlug)?.uuid,
1067
+ sourceFullSlug: item.sourceFullSlug,
1068
+ targetFullSlug: item.targetFullSlug,
1069
+ isFolder: item.type === "folder",
1070
+ action: item.action,
1071
+ }));
1072
+ graph.assetReferences.push(...scanResult.assetReferences);
1073
+ graph.storyReferences.push(...scanResult.storyReferences);
1074
+ graph.opaqueFields.push(...scanResult.opaqueFields);
1075
+ graph.warnings.push(...scanResult.warnings);
1076
+ graph.errors.push(...scanResult.errors);
1077
+ annotateReferencesWithManifestMaps({
1078
+ graph,
1079
+ copyMaps,
1080
+ withAssets: false,
1081
+ });
1082
+ return graph;
1083
+ };
1084
+ const buildReferencedAssetsGraph = ({ sourceSpace, targetSpace, selection, destination, plan, sourceStories, sourceAssets, sourceAssetFolders, schemas, copyMaps, onScanProgress, }) => {
1085
+ const scanResult = scanStoriesReferences({
1086
+ stories: sourceStories.map((item) => item?.story).filter(Boolean),
1087
+ schemas,
1088
+ options: {
1089
+ referencePolicy: "preserve",
1090
+ onProgress: onScanProgress,
1091
+ },
1092
+ });
1093
+ const referencedAssetIds = new Set(scanResult.assetReferences
1094
+ .map((reference) => reference.assetId)
1095
+ .filter((assetId) => assetId !== undefined));
1096
+ const referencedFilenames = new Set(scanResult.assetReferences
1097
+ .map((reference) => reference.filename)
1098
+ .filter((filename) => filename !== undefined));
1099
+ const selectedAssets = sourceAssets.filter((asset) => referencedAssetIds.has(Number(asset.id)) ||
1100
+ referencedFilenames.has(String(asset.filename)));
1101
+ const selectedAssetIds = new Set(selectedAssets.map((asset) => Number(asset.id)));
1102
+ const selectedFilenames = new Set(selectedAssets.map((asset) => String(asset.filename)));
1103
+ const selectedAssetFolders = collectAssetFolderAncestors({
1104
+ assets: selectedAssets,
1105
+ assetFolders: sourceAssetFolders,
1106
+ });
1107
+ const sourceStoryByFullSlug = new Map(sourceStories
1108
+ .map((item) => item?.story)
1109
+ .filter(Boolean)
1110
+ .map((story) => [String(story.full_slug ?? ""), story]));
1111
+ const graph = buildCopyAssetsGraph({
1112
+ sourceSpaceId: sourceSpace,
1113
+ targetSpaceId: targetSpace,
1114
+ assets: selectedAssets,
1115
+ assetFolders: selectedAssetFolders,
1116
+ });
1117
+ graph.scope = {
1118
+ command: "copy stories",
1119
+ source: selection.source,
1120
+ destination: normalizeDestination(destination) || "root",
1121
+ mode: selection.mode,
1122
+ withAssets: true,
1123
+ referencePolicy: "preserve",
1124
+ };
1125
+ graph.stories = plan.map((item) => ({
1126
+ type: "story",
1127
+ sourceId: Number(sourceStoryByFullSlug.get(item.sourceFullSlug)?.id ?? 0),
1128
+ sourceUuid: sourceStoryByFullSlug.get(item.sourceFullSlug)?.uuid,
1129
+ sourceFullSlug: item.sourceFullSlug,
1130
+ targetFullSlug: item.targetFullSlug,
1131
+ isFolder: item.type === "folder",
1132
+ action: item.action,
1133
+ }));
1134
+ graph.assetReferences.push(...scanResult.assetReferences.map((reference) => {
1135
+ const isPlanned = (reference.assetId !== undefined &&
1136
+ selectedAssetIds.has(reference.assetId)) ||
1137
+ (reference.filename !== undefined &&
1138
+ selectedFilenames.has(reference.filename));
1139
+ return {
1140
+ ...reference,
1141
+ status: isPlanned ? "planned" : "unresolved",
1142
+ };
1143
+ }));
1144
+ graph.storyReferences.push(...scanResult.storyReferences);
1145
+ graph.opaqueFields.push(...scanResult.opaqueFields);
1146
+ graph.warnings.push(...scanResult.warnings);
1147
+ graph.errors.push(...scanResult.errors);
1148
+ annotateReferencesWithManifestMaps({
1149
+ graph,
1150
+ copyMaps,
1151
+ withAssets: true,
1152
+ });
1153
+ annotateAssetsWithManifestMaps({
1154
+ graph,
1155
+ copyMaps,
1156
+ });
1157
+ for (const reference of graph.assetReferences) {
1158
+ if (reference.status !== "unresolved") {
1159
+ continue;
1160
+ }
1161
+ graph.warnings.push({
1162
+ code: "referenced_asset_not_found",
1163
+ message: "A story references an asset that was not returned by the source space asset list.",
1164
+ path: reference.path,
1165
+ sourceValue: reference.assetId ?? reference.filename,
1166
+ });
1167
+ }
1168
+ return graph;
1169
+ };
1170
+ const countStoryItems = (sourceStories) => sourceStories.map((item) => item?.story).filter(Boolean).length;
1171
+ const logReferenceScanProgress = ({ scanned, total, }) => {
1172
+ Logger.success(`Scanned ${scanned} of ${total} stories for references.`);
1173
+ };
1174
+ const shouldUsePublishedLayerCopy = (publication, sourceStory, publishedLayerRecord) => publication.mode === "preserve-layers" &&
1175
+ resolveStoryLayerState(sourceStory) === "dirty-published" &&
1176
+ Boolean(publishedLayerRecord?.publishedLayerItem?.story);
1177
+ const shouldPublishCopiedCurrentStory = (publication, sourceStory) => {
1178
+ if (publication.mode === "save-only") {
1179
+ return false;
1180
+ }
1181
+ const layerState = resolveStoryLayerState(sourceStory);
1182
+ if (layerState === "clean-published") {
1183
+ return true;
1184
+ }
1185
+ if (layerState === "dirty-published") {
1186
+ return publication.mode === "collapse-draft";
1187
+ }
1188
+ return false;
1189
+ };
1190
+ const buildRewrittenStoryPayload = ({ sourceStory, content, targetParentId, maps, schemas, }) => {
1191
+ const rewritten = rewriteCopyReferences({
1192
+ value: content ?? {},
1193
+ maps,
1194
+ schemas,
1195
+ });
1196
+ return {
1197
+ payload: buildFinalStoryPayload({
1198
+ sourceStory,
1199
+ targetParentId,
1200
+ rewrittenContent: rewritten.value,
1201
+ }),
1202
+ rewrittenReferences: rewritten.records.length,
1203
+ };
1204
+ };
1205
+ const publishCopiedStory = async ({ storyId, story, publication, targetSpace, }) => {
1206
+ const languages = publication.resolvedPublishLanguages;
1207
+ if (!languages || languages.length === 0) {
1208
+ return { ok: true, stage: "publish_skipped" };
1209
+ }
1210
+ return managementApi.stories.publishStoryLanguages({
1211
+ storyId,
1212
+ story,
1213
+ languages,
1214
+ }, {
1215
+ ...apiConfig,
1216
+ spaceId: targetSpace,
1217
+ });
1218
+ };
1219
+ const rewriteCopiedStoryContents = async ({ tree, realParentId, sourceStoryById, targetSlugBySourceSlug, publication, publishedLayerRecordBySourceId, sourceSpace, targetSpace, manifestRoot, }) => {
1220
+ const manifestPaths = getDefaultCopyManifestPaths({
1221
+ sourceSpaceId: sourceSpace,
1222
+ targetSpaceId: targetSpace,
1223
+ rootDir: manifestRoot,
1224
+ });
1225
+ const manifestEntries = await loadManifest(manifestPaths.combined);
1226
+ const maps = buildCopyMaps(manifestEntries);
1227
+ const schemas = await buildComponentSchemaRegistry(sourceSpace);
1228
+ let updatedStories = 0;
1229
+ let rewrittenReferences = 0;
1230
+ const writeStoryMapping = async ({ sourceStory, targetStory, targetFullSlug, action, }) => {
1231
+ const entry = {
1232
+ type: "story",
1233
+ source_space_id: sourceSpace,
1234
+ target_space_id: targetSpace,
1235
+ source_id: Number(sourceStory.id),
1236
+ target_id: Number(targetStory.id),
1237
+ source_uuid: String(sourceStory.uuid),
1238
+ target_uuid: String(targetStory.uuid),
1239
+ source_full_slug: String(sourceStory.full_slug ?? ""),
1240
+ target_full_slug: targetStory.full_slug ?? targetFullSlug,
1241
+ action,
1242
+ created_at: new Date().toISOString(),
1243
+ };
1244
+ await appendCopyManifestEntry({
1245
+ combinedPath: manifestPaths.combined,
1246
+ resourcePath: manifestPaths.stories,
1247
+ entry,
1248
+ });
1249
+ maps.storyIds.set(entry.source_id, entry.target_id);
1250
+ maps.storyUuids.set(entry.source_uuid, entry.target_uuid);
1251
+ return entry.target_id;
1252
+ };
1253
+ const createOrMatchReplacementShell = async ({ node, sourceStory, parentId, staleTargetId, }) => {
1254
+ const sourceFullSlug = String(sourceStory.full_slug ?? "");
1255
+ const targetFullSlug = targetSlugBySourceSlug.get(sourceFullSlug);
1256
+ const existingTargetStory = targetFullSlug
1257
+ ? await managementApi.stories.getStoryBySlug(targetFullSlug, {
1258
+ ...apiConfig,
1259
+ spaceId: targetSpace,
1260
+ })
1261
+ : undefined;
1262
+ if (existingTargetStory?.story?.id &&
1263
+ Number(existingTargetStory.story.id) !== staleTargetId) {
1264
+ return writeStoryMapping({
1265
+ sourceStory,
1266
+ targetStory: existingTargetStory.story,
1267
+ targetFullSlug,
1268
+ action: "matched_by_target_key",
1269
+ });
1270
+ }
1271
+ const createdStoryResult = await managementApi.stories.createStory(buildStoryShellPayload(node.story ?? sourceStory, parentId), {
1272
+ ...apiConfig,
1273
+ spaceId: targetSpace,
1274
+ }, {
1275
+ publish: false,
1276
+ });
1277
+ const targetStory = createdStoryResult?.story;
1278
+ if (!targetStory?.id || !targetStory?.uuid) {
1279
+ throw new Error(`Failed to create replacement target story for '${sourceFullSlug}'.`);
1280
+ }
1281
+ return writeStoryMapping({
1282
+ sourceStory,
1283
+ targetStory,
1284
+ targetFullSlug,
1285
+ action: "created",
1286
+ });
1287
+ };
1288
+ const walk = async (nodes, parentId) => {
1289
+ for (const node of nodes) {
1290
+ const sourceId = Number(node.id ?? node.story?.id);
1291
+ const sourceStory = sourceStoryById.get(sourceId);
1292
+ if (!sourceStory?.id) {
1293
+ continue;
1294
+ }
1295
+ let targetStoryId = maps.storyIds.get(Number(sourceStory.id));
1296
+ if (!targetStoryId) {
1297
+ targetStoryId = await createOrMatchReplacementShell({
1298
+ node,
1299
+ sourceStory,
1300
+ parentId,
69
1301
  });
70
- // 1. get all stories inside this folder, and recursively
71
- const allSourceStories = [rootStory].concat(await managementApi.stories.getAllStories({
72
- options: {
73
- starts_with: `${rootStorySlug}/`,
74
- },
75
- }, {
76
- ...apiConfig,
77
- spaceId: sourceSpace,
78
- }));
79
- const normalizedStories = allSourceStories
80
- .map((item) => item.story)
81
- .map((item) => {
82
- if (item.full_slug === rootStorySlug) {
1302
+ }
1303
+ const writeStory = async (id) => {
1304
+ const current = buildRewrittenStoryPayload({
1305
+ sourceStory,
1306
+ content: sourceStory.content,
1307
+ targetParentId: parentId,
1308
+ maps,
1309
+ schemas,
1310
+ });
1311
+ const publishedLayerRecord = publishedLayerRecordBySourceId.get(String(sourceStory.id));
1312
+ if (shouldUsePublishedLayerCopy(publication, sourceStory, publishedLayerRecord)) {
1313
+ const publishedLayerStory = publishedLayerRecord?.publishedLayerItem?.story;
1314
+ const publishedLayer = buildRewrittenStoryPayload({
1315
+ sourceStory: publishedLayerStory,
1316
+ content: publishedLayerStory?.content,
1317
+ targetParentId: parentId,
1318
+ maps,
1319
+ schemas,
1320
+ });
1321
+ const publishedUpdateResult = await managementApi.stories.updateStory(publishedLayer.payload, String(id), {
1322
+ force_update: true,
1323
+ publish: false,
1324
+ }, {
1325
+ ...apiConfig,
1326
+ spaceId: targetSpace,
1327
+ });
1328
+ if (!publishedUpdateResult?.ok) {
83
1329
  return {
84
- ...item,
85
- parent_id: null,
1330
+ result: publishedUpdateResult,
1331
+ rewrittenReferences: current.rewrittenReferences +
1332
+ publishedLayer.rewrittenReferences,
86
1333
  };
87
1334
  }
1335
+ const publishResult = await publishCopiedStory({
1336
+ storyId: id,
1337
+ story: publishedLayer.payload,
1338
+ publication,
1339
+ targetSpace,
1340
+ });
1341
+ if (!publishResult?.ok) {
1342
+ return {
1343
+ result: publishResult,
1344
+ rewrittenReferences: current.rewrittenReferences +
1345
+ publishedLayer.rewrittenReferences,
1346
+ };
1347
+ }
1348
+ const restoreDraftResult = await managementApi.stories.updateStory(current.payload, String(id), {
1349
+ force_update: true,
1350
+ publish: false,
1351
+ }, {
1352
+ ...apiConfig,
1353
+ spaceId: targetSpace,
1354
+ });
88
1355
  return {
89
- ...item,
90
- };
91
- });
92
- const treeOfStories = createTree(normalizedStories);
93
- const entryStory = await attachToFolder(where, targetSpace);
94
- const reAttachedTreeOfStories = treeOfStories[0]?.children?.map((child) => {
95
- return {
96
- ...child,
97
- parent_id: entryStory.story.id,
98
- story: {
99
- ...child.story,
100
- parent_id: entryStory.story.id,
101
- },
1356
+ result: restoreDraftResult,
1357
+ rewrittenReferences: current.rewrittenReferences +
1358
+ publishedLayer.rewrittenReferences,
102
1359
  };
103
- });
104
- await traverseAndCreate({
105
- tree: reAttachedTreeOfStories,
106
- realParentId: entryStory.story.id,
107
- spaceId: targetSpace,
1360
+ }
1361
+ if (publication.mode === "preserve-layers" &&
1362
+ resolveStoryLayerState(sourceStory) === "dirty-published") {
1363
+ Logger.warning(`Skipping publish for copied story '${sourceStory.full_slug ?? sourceStory.slug}' because source story has unpublished changes and no published layer could be resolved.`);
1364
+ }
1365
+ const result = await managementApi.stories.updateStory(current.payload, String(id), {
1366
+ force_update: true,
1367
+ publish: false,
108
1368
  }, {
109
1369
  ...apiConfig,
110
1370
  spaceId: targetSpace,
111
1371
  });
112
- }
113
- if (strategy === "folder_with_root") {
114
- console.log({ strategy });
115
- // 0. get the root story
116
- const rootStory = await managementApi.stories.getStoryBySlug(what, {
117
- ...apiConfig,
118
- spaceId: sourceSpace,
119
- });
120
- // 1. get all stories
121
- const allSourceStories = [rootStory].concat(await managementApi.stories.getAllStories({
122
- options: {
123
- starts_with: `${what}/`,
124
- },
125
- }, {
126
- ...apiConfig,
127
- spaceId: sourceSpace,
128
- }));
129
- const normalizedStories = allSourceStories
130
- .map((item) => item.story)
131
- .map((item) => {
132
- if (item.full_slug === what) {
1372
+ if (result?.ok &&
1373
+ shouldPublishCopiedCurrentStory(publication, sourceStory)) {
1374
+ const publishResult = await publishCopiedStory({
1375
+ storyId: id,
1376
+ story: current.payload,
1377
+ publication,
1378
+ targetSpace,
1379
+ });
1380
+ if (!publishResult?.ok) {
133
1381
  return {
134
- ...item,
135
- parent_id: null,
1382
+ result: publishResult,
1383
+ rewrittenReferences: current.rewrittenReferences,
136
1384
  };
137
1385
  }
138
- return {
139
- ...item,
140
- };
141
- });
142
- const treeOfStories = createTree(normalizedStories);
143
- const entryStory = await attachToFolder(where, targetSpace);
144
- const enhancedTreeOfStories = {
145
- ...treeOfStories[0],
146
- parent_id: entryStory.story.id,
1386
+ }
1387
+ return {
1388
+ result,
1389
+ rewrittenReferences: current.rewrittenReferences,
147
1390
  };
148
- await traverseAndCreate({
149
- tree: [enhancedTreeOfStories],
150
- realParentId: entryStory.story.id,
151
- spaceId: targetSpace,
152
- }, {
1391
+ };
1392
+ let update = await writeStory(Number(targetStoryId));
1393
+ if (isStoryUpdateNotFound(update.result)) {
1394
+ Logger.warning(`Ignoring stale story manifest mapping for '${sourceStory.full_slug ?? sourceStory.slug}' because target story '${targetStoryId}' could not be updated in space '${targetSpace}'.`);
1395
+ maps.storyIds.delete(Number(sourceStory.id));
1396
+ maps.storyUuids.delete(String(sourceStory.uuid));
1397
+ targetStoryId = await createOrMatchReplacementShell({
1398
+ node,
1399
+ sourceStory,
1400
+ parentId,
1401
+ staleTargetId: Number(targetStoryId),
1402
+ });
1403
+ update = await writeStory(Number(targetStoryId));
1404
+ }
1405
+ assertStoryUpdateSucceeded({
1406
+ result: update.result,
1407
+ sourceStory,
1408
+ targetStoryId: Number(targetStoryId),
1409
+ targetSpace,
1410
+ });
1411
+ updatedStories += 1;
1412
+ rewrittenReferences += update.rewrittenReferences;
1413
+ await walk(node.children ?? [], Number(targetStoryId));
1414
+ }
1415
+ };
1416
+ await walk(tree, realParentId);
1417
+ await dedupeManifestFile(manifestPaths.stories);
1418
+ await dedupeManifestFile(manifestPaths.combined);
1419
+ if (updatedStories > 0) {
1420
+ Logger.success(`Updated ${updatedStories} copied story/story shell(s); rewrote ${rewrittenReferences} reference(s).`);
1421
+ }
1422
+ };
1423
+ const createStoriesAndWriteManifests = async ({ tree, realParentId, sourceStoryById, targetSlugBySourceSlug, sourceSpace, targetSpace, manifestRoot, }) => {
1424
+ const manifestPaths = getDefaultCopyManifestPaths({
1425
+ sourceSpaceId: sourceSpace,
1426
+ targetSpaceId: targetSpace,
1427
+ rootDir: manifestRoot,
1428
+ });
1429
+ const existingManifestEntries = await loadManifest(manifestPaths.combined);
1430
+ const copyMaps = buildCopyMaps(existingManifestEntries);
1431
+ let storiesCreated = 0;
1432
+ let storiesMatched = 0;
1433
+ const walk = async (nodes, parentId) => {
1434
+ for (const node of nodes) {
1435
+ const sourceStory = sourceStoryById.get(Number(node.id ?? node.story.id));
1436
+ const sourceFullSlug = String(sourceStory?.full_slug ?? node.story.full_slug ?? "");
1437
+ const targetFullSlug = targetSlugBySourceSlug.get(sourceFullSlug);
1438
+ if (!sourceStory?.uuid) {
1439
+ throw new Error(`Cannot write story manifest for '${sourceFullSlug}' because source uuid is missing.`);
1440
+ }
1441
+ const mappedTargetId = copyMaps.storyIds.get(Number(sourceStory.id));
1442
+ if (mappedTargetId) {
1443
+ const mappedTargetStory = await getValidMappedTargetStory({
1444
+ sourceStory,
1445
+ targetStoryId: mappedTargetId,
1446
+ targetFullSlug,
1447
+ targetSpace,
1448
+ });
1449
+ if (mappedTargetStory) {
1450
+ storiesMatched += 1;
1451
+ await walk(node.children ?? [], mappedTargetId);
1452
+ continue;
1453
+ }
1454
+ copyMaps.storyIds.delete(Number(sourceStory.id));
1455
+ copyMaps.storyUuids.delete(String(sourceStory.uuid));
1456
+ }
1457
+ const existingTargetStory = targetFullSlug
1458
+ ? await managementApi.stories.getStoryBySlug(targetFullSlug, {
153
1459
  ...apiConfig,
154
1460
  spaceId: targetSpace,
1461
+ })
1462
+ : undefined;
1463
+ const createdAt = new Date().toISOString();
1464
+ if (existingTargetStory?.story) {
1465
+ const targetStory = existingTargetStory.story;
1466
+ const entry = {
1467
+ type: "story",
1468
+ source_space_id: sourceSpace,
1469
+ target_space_id: targetSpace,
1470
+ source_id: Number(sourceStory.id),
1471
+ target_id: Number(targetStory.id),
1472
+ source_uuid: String(sourceStory.uuid),
1473
+ target_uuid: String(targetStory.uuid),
1474
+ source_full_slug: sourceFullSlug,
1475
+ target_full_slug: targetStory.full_slug ?? targetFullSlug,
1476
+ action: "matched_by_target_key",
1477
+ created_at: createdAt,
1478
+ };
1479
+ await appendCopyManifestEntry({
1480
+ combinedPath: manifestPaths.combined,
1481
+ resourcePath: manifestPaths.stories,
1482
+ entry,
155
1483
  });
1484
+ copyMaps.storyIds.set(entry.source_id, entry.target_id);
1485
+ copyMaps.storyUuids.set(entry.source_uuid, entry.target_uuid);
1486
+ storiesMatched += 1;
1487
+ await walk(node.children ?? [], entry.target_id);
1488
+ continue;
156
1489
  }
157
- if (strategy === "story") {
158
- console.log({ strategy });
159
- const entryStory = await managementApi.stories.getStoryBySlug(what, {
160
- ...apiConfig,
161
- spaceId: sourceSpace,
1490
+ const createdStoryResult = await managementApi.stories.createStory(buildStoryShellPayload(node.story, parentId), {
1491
+ ...apiConfig,
1492
+ spaceId: targetSpace,
1493
+ }, {
1494
+ publish: false,
1495
+ });
1496
+ const targetStory = createdStoryResult?.story;
1497
+ if (!targetStory?.id || !targetStory?.uuid) {
1498
+ throw new Error(`Failed to create target story for '${sourceFullSlug}'.`);
1499
+ }
1500
+ const entry = {
1501
+ type: "story",
1502
+ source_space_id: sourceSpace,
1503
+ target_space_id: targetSpace,
1504
+ source_id: Number(sourceStory.id),
1505
+ target_id: Number(targetStory.id),
1506
+ source_uuid: String(sourceStory.uuid),
1507
+ target_uuid: String(targetStory.uuid),
1508
+ source_full_slug: sourceFullSlug,
1509
+ target_full_slug: targetStory.full_slug ?? targetFullSlug,
1510
+ action: "created",
1511
+ created_at: createdAt,
1512
+ };
1513
+ await appendCopyManifestEntry({
1514
+ combinedPath: manifestPaths.combined,
1515
+ resourcePath: manifestPaths.stories,
1516
+ entry,
1517
+ });
1518
+ copyMaps.storyIds.set(entry.source_id, entry.target_id);
1519
+ copyMaps.storyUuids.set(entry.source_uuid, entry.target_uuid);
1520
+ storiesCreated += 1;
1521
+ await walk(node.children ?? [], entry.target_id);
1522
+ }
1523
+ };
1524
+ await walk(tree, realParentId);
1525
+ await dedupeManifestFile(manifestPaths.stories);
1526
+ await dedupeManifestFile(manifestPaths.combined);
1527
+ Logger.success(`Story manifest written to ${manifestPaths.stories}`);
1528
+ const plannedCounts = countTreeStories(tree);
1529
+ return {
1530
+ storyFoldersPlanned: plannedCounts.folders,
1531
+ storiesPlanned: plannedCounts.stories,
1532
+ storiesCreated,
1533
+ storiesMatched,
1534
+ };
1535
+ };
1536
+ const copyAssetsAndWriteManifests = async ({ sourceSpace, targetSpace, selection, input, graph, sourceAssets, sourceAssetFolders, outputPath, manifestRoot, }) => {
1537
+ const manifestPaths = getDefaultCopyManifestPaths({
1538
+ sourceSpaceId: sourceSpace,
1539
+ targetSpaceId: targetSpace,
1540
+ rootDir: manifestRoot,
1541
+ });
1542
+ const existingManifestEntries = await loadManifest(manifestPaths.combined);
1543
+ const copyMaps = buildCopyMaps(existingManifestEntries);
1544
+ const targetAssetFoldersResult = await managementApi.assets.getAllAssetFolders({ spaceId: targetSpace }, {
1545
+ ...apiConfig,
1546
+ spaceId: targetSpace,
1547
+ });
1548
+ const targetAssetsResult = await managementApi.assets.getAllAssets({ spaceId: targetSpace }, {
1549
+ ...apiConfig,
1550
+ spaceId: targetSpace,
1551
+ });
1552
+ const targetAssetFolders = Array.isArray(targetAssetFoldersResult?.asset_folders)
1553
+ ? targetAssetFoldersResult.asset_folders
1554
+ : [];
1555
+ const targetAssets = Array.isArray(targetAssetsResult?.assets)
1556
+ ? targetAssetsResult.assets
1557
+ : [];
1558
+ const targetFolderByPath = buildAssetFolderPathMap(targetAssetFolders);
1559
+ const sourceFolderById = new Map(sourceAssetFolders.map((folder) => [Number(folder.id), folder]));
1560
+ const graphFolderBySourceId = new Map(graph.assetFolders.map((folder) => [folder.sourceId, folder]));
1561
+ const graphAssetBySourceId = new Map(graph.assets.map((asset) => [asset.sourceId, asset]));
1562
+ let assetFoldersCreated = 0;
1563
+ let assetFoldersMatched = 0;
1564
+ let assetsCreated = 0;
1565
+ let assetsMatched = 0;
1566
+ Logger.warning(`Copying assets from space '${sourceSpace}' to space '${targetSpace}'.`);
1567
+ for (const folderNode of graph.assetFolders) {
1568
+ const sourceFolder = sourceFolderById.get(folderNode.sourceId);
1569
+ if (!sourceFolder) {
1570
+ continue;
1571
+ }
1572
+ const sourceParentId = normalizeAssetFolderParentId(sourceFolder.parent_id);
1573
+ const mappedTargetFolderId = copyMaps.assetFolderIds.get(folderNode.sourceId);
1574
+ if (mappedTargetFolderId) {
1575
+ folderNode.targetParentId =
1576
+ sourceParentId === null
1577
+ ? null
1578
+ : (copyMaps.assetFolderIds.get(sourceParentId) ?? null);
1579
+ folderNode.action = "match";
1580
+ assetFoldersMatched += 1;
1581
+ continue;
1582
+ }
1583
+ const existingTargetFolder = folderNode.sourcePath
1584
+ ? targetFolderByPath.get(folderNode.sourcePath)
1585
+ : undefined;
1586
+ const createdAt = new Date().toISOString();
1587
+ if (existingTargetFolder) {
1588
+ const entry = {
1589
+ type: "asset_folder",
1590
+ source_space_id: sourceSpace,
1591
+ target_space_id: targetSpace,
1592
+ source_id: folderNode.sourceId,
1593
+ target_id: Number(existingTargetFolder.id),
1594
+ source_name: String(sourceFolder.name ?? ""),
1595
+ target_name: String(existingTargetFolder.name ?? ""),
1596
+ source_parent_id: sourceParentId,
1597
+ target_parent_id: normalizeAssetFolderParentId(existingTargetFolder.parent_id),
1598
+ source_path: folderNode.sourcePath,
1599
+ target_path: folderNode.sourcePath,
1600
+ action: "matched_by_target_key",
1601
+ created_at: createdAt,
1602
+ };
1603
+ await appendCopyManifestEntry({
1604
+ combinedPath: manifestPaths.combined,
1605
+ resourcePath: manifestPaths.assetFolders,
1606
+ entry,
1607
+ });
1608
+ copyMaps.assetFolderIds.set(entry.source_id, entry.target_id);
1609
+ folderNode.targetParentId = entry.target_parent_id;
1610
+ folderNode.action = "match";
1611
+ assetFoldersMatched += 1;
1612
+ continue;
1613
+ }
1614
+ const targetParentId = sourceParentId === null
1615
+ ? null
1616
+ : (copyMaps.assetFolderIds.get(sourceParentId) ?? null);
1617
+ const createdFolder = await managementApi.assets.createAssetFolder({
1618
+ spaceId: targetSpace,
1619
+ payload: {
1620
+ name: String(sourceFolder.name),
1621
+ parent_id: targetParentId,
1622
+ },
1623
+ }, {
1624
+ ...apiConfig,
1625
+ spaceId: targetSpace,
1626
+ });
1627
+ const targetFolder = createdFolder.asset_folder;
1628
+ const entry = {
1629
+ type: "asset_folder",
1630
+ source_space_id: sourceSpace,
1631
+ target_space_id: targetSpace,
1632
+ source_id: folderNode.sourceId,
1633
+ target_id: Number(targetFolder.id),
1634
+ source_name: String(sourceFolder.name ?? ""),
1635
+ target_name: String(targetFolder.name ?? ""),
1636
+ source_parent_id: sourceParentId,
1637
+ target_parent_id: normalizeAssetFolderParentId(targetFolder.parent_id),
1638
+ source_path: folderNode.sourcePath,
1639
+ target_path: folderNode.sourcePath,
1640
+ action: "created",
1641
+ created_at: createdAt,
1642
+ };
1643
+ await appendCopyManifestEntry({
1644
+ combinedPath: manifestPaths.combined,
1645
+ resourcePath: manifestPaths.assetFolders,
1646
+ entry,
1647
+ });
1648
+ copyMaps.assetFolderIds.set(entry.source_id, entry.target_id);
1649
+ folderNode.targetParentId = entry.target_parent_id;
1650
+ folderNode.action = "create";
1651
+ assetFoldersCreated += 1;
1652
+ }
1653
+ for (const asset of sourceAssets) {
1654
+ const graphAsset = graphAssetBySourceId.get(Number(asset.id));
1655
+ if (!graphAsset) {
1656
+ continue;
1657
+ }
1658
+ const mappedTargetAsset = copyMaps.assetIds.get(Number(asset.id));
1659
+ if (mappedTargetAsset) {
1660
+ graphAsset.targetFilename = mappedTargetAsset.filename;
1661
+ graphAsset.targetAssetFolderId =
1662
+ asset.asset_folder_id === null ||
1663
+ asset.asset_folder_id === undefined
1664
+ ? null
1665
+ : (copyMaps.assetFolderIds.get(Number(asset.asset_folder_id)) ?? null);
1666
+ graphAsset.action = "match";
1667
+ assetsMatched += 1;
1668
+ continue;
1669
+ }
1670
+ const fileName = getFileName(asset.filename);
1671
+ const existingTargetAsset = findUniqueTargetAssetByFileName(targetAssets, fileName);
1672
+ const targetAssetFolderId = asset.asset_folder_id === null ||
1673
+ asset.asset_folder_id === undefined
1674
+ ? null
1675
+ : (copyMaps.assetFolderIds.get(Number(asset.asset_folder_id)) ??
1676
+ null);
1677
+ const createdAt = new Date().toISOString();
1678
+ if (existingTargetAsset) {
1679
+ const entry = {
1680
+ type: "asset",
1681
+ source_space_id: sourceSpace,
1682
+ target_space_id: targetSpace,
1683
+ source_id: Number(asset.id),
1684
+ target_id: Number(existingTargetAsset.id),
1685
+ source_filename: asset.filename,
1686
+ target_filename: existingTargetAsset.filename,
1687
+ source_asset_folder_id: asset.asset_folder_id ?? null,
1688
+ target_asset_folder_id: existingTargetAsset.asset_folder_id ?? null,
1689
+ action: "matched_by_target_key",
1690
+ created_at: createdAt,
1691
+ };
1692
+ await appendCopyManifestEntry({
1693
+ combinedPath: manifestPaths.combined,
1694
+ resourcePath: manifestPaths.assets,
1695
+ entry,
1696
+ });
1697
+ copyMaps.assetIds.set(entry.source_id, {
1698
+ id: entry.target_id,
1699
+ filename: entry.target_filename,
1700
+ });
1701
+ copyMaps.assetFilenames.set(entry.source_filename, entry.target_filename);
1702
+ graphAsset.targetFilename = entry.target_filename;
1703
+ graphAsset.targetAssetFolderId = entry.target_asset_folder_id;
1704
+ graphAsset.action = "match";
1705
+ assetsMatched += 1;
1706
+ continue;
1707
+ }
1708
+ const pathToFile = await managementApi.assets.downloadAsset({ payload: asset }, apiConfig);
1709
+ const targetAsset = await managementApi.assets.createAssetAndFinalize({
1710
+ spaceId: targetSpace,
1711
+ pathToFile,
1712
+ payload: {
1713
+ filename: asset.filename,
1714
+ asset_folder_id: targetAssetFolderId,
1715
+ },
1716
+ }, {
1717
+ ...apiConfig,
1718
+ spaceId: targetSpace,
1719
+ });
1720
+ if (Object.keys(getAssetMetadataPayload(asset)).length > 0) {
1721
+ await managementApi.assets.updateAsset({
1722
+ spaceId: targetSpace,
1723
+ assetId: Number(targetAsset.id),
1724
+ payload: getAssetMetadataPayload(asset),
1725
+ }, {
1726
+ ...apiConfig,
1727
+ spaceId: targetSpace,
1728
+ });
1729
+ }
1730
+ const entry = {
1731
+ type: "asset",
1732
+ source_space_id: sourceSpace,
1733
+ target_space_id: targetSpace,
1734
+ source_id: Number(asset.id),
1735
+ target_id: Number(targetAsset.id),
1736
+ source_filename: asset.filename,
1737
+ target_filename: targetAsset.filename,
1738
+ source_asset_folder_id: asset.asset_folder_id ?? null,
1739
+ target_asset_folder_id: targetAssetFolderId,
1740
+ action: "created",
1741
+ created_at: createdAt,
1742
+ };
1743
+ await appendCopyManifestEntry({
1744
+ combinedPath: manifestPaths.combined,
1745
+ resourcePath: manifestPaths.assets,
1746
+ entry,
1747
+ });
1748
+ copyMaps.assetIds.set(entry.source_id, {
1749
+ id: entry.target_id,
1750
+ filename: entry.target_filename,
1751
+ });
1752
+ copyMaps.assetFilenames.set(entry.source_filename, entry.target_filename);
1753
+ graphAsset.targetFilename = entry.target_filename;
1754
+ graphAsset.targetAssetFolderId = entry.target_asset_folder_id;
1755
+ graphAsset.action = "create";
1756
+ assetsCreated += 1;
1757
+ }
1758
+ await dedupeManifestFile(manifestPaths.assetFolders);
1759
+ await dedupeManifestFile(manifestPaths.assets);
1760
+ await dedupeManifestFile(manifestPaths.combined);
1761
+ graph.limitations = [];
1762
+ const report = buildCopyAssetsApplyReport({
1763
+ sourceSpace,
1764
+ targetSpace,
1765
+ selection,
1766
+ input,
1767
+ graph,
1768
+ manifestPaths,
1769
+ assetFoldersCreated,
1770
+ assetFoldersMatched,
1771
+ assetsCreated,
1772
+ assetsMatched,
1773
+ });
1774
+ if (outputPath) {
1775
+ await writeJsonReport(outputPath, report);
1776
+ }
1777
+ Logger.success(`Asset copy complete. Created ${assetsCreated} asset(s), matched ${assetsMatched} asset(s).`);
1778
+ Logger.success(`Asset manifest written to ${manifestPaths.assets}`);
1779
+ Logger.success(`Asset folder manifest written to ${manifestPaths.assetFolders}`);
1780
+ return report;
1781
+ };
1782
+ const logDryRunCopyPlan = async ({ report }) => {
1783
+ Logger.warning("[dry-run] Copy stories preview only. No Storyblok writes will be made.");
1784
+ Logger.warning(`[dry-run] Source space: ${report.normalized.sourceSpaceId}`);
1785
+ Logger.warning(`[dry-run] Target space: ${report.normalized.targetSpaceId}`);
1786
+ Logger.warning(`[dry-run] Source: ${report.normalized.source}`);
1787
+ Logger.warning(`[dry-run] Destination: ${report.normalized.destination}`);
1788
+ Logger.warning(`[dry-run] Mode: ${report.normalized.mode}`);
1789
+ Logger.warning(`[dry-run] With assets: ${report.normalized.withAssets ? "yes" : "no"}`);
1790
+ if (report.normalized.mode === "children") {
1791
+ Logger.warning("[dry-run] Source folder root will not be created; only descendants are planned.");
1792
+ }
1793
+ if (report.normalized.mode === "self") {
1794
+ Logger.warning("[dry-run] Descendants will not be copied; only the selected story or folder shell is planned.");
1795
+ }
1796
+ Logger.warning(`[dry-run] Would create ${report.items.length} story/folder item(s):`);
1797
+ for (const item of report.items) {
1798
+ Logger.warning(`[dry-run] ${item.type.padEnd(6)} ${item.targetFullSlug || "<root>"}`);
1799
+ }
1800
+ if (report.graph) {
1801
+ Logger.warning(`[dry-run] Would plan ${report.summary.assetFolders} referenced asset folder(s) and ${report.summary.assets} referenced asset(s).`);
1802
+ Logger.warning(`[dry-run] Asset refs: ${report.summary.assetReferencesMapped} mapped, ${report.summary.assetReferencesPlanned} planned, ${report.summary.assetReferencesUnresolved} unresolved.`);
1803
+ if (report.assetReferenceSummary) {
1804
+ Logger.warning(`[dry-run] Unique asset refs: ${report.assetReferenceSummary.mapped.uniqueAssets} mapped, ${report.assetReferenceSummary.planned.uniqueAssets} planned, ${report.assetReferenceSummary.unresolved.uniqueAssets} unresolved.`);
1805
+ for (const foreignSpace of report.assetReferenceSummary
1806
+ .foreignAssetSpaces) {
1807
+ Logger.warning(`[dry-run] Foreign asset space ${foreignSpace.spaceId}: ${foreignSpace.occurrences} occurrence(s), ${foreignSpace.uniqueAssets} unique asset(s).`);
1808
+ }
1809
+ }
1810
+ Logger.warning(`[dry-run] Story refs: ${report.summary.storyReferencesMapped} mapped, ${report.summary.storyReferencesPreserved} preserved, ${report.summary.storyReferencesUnresolved} unresolved.`);
1811
+ for (const folder of report.graph.assetFolders) {
1812
+ Logger.warning(`[dry-run] asset_folder ${folder.action.padEnd(6)} ${folder.targetPath ?? `#${folder.sourceId}`}`);
1813
+ }
1814
+ for (const asset of report.graph.assets) {
1815
+ Logger.warning(`[dry-run] asset ${asset.action.padEnd(6)} ${asset.targetFilename}`);
1816
+ }
1817
+ for (const reference of report.graph.assetReferences) {
1818
+ Logger.warning(`[dry-run] asset_ref ${reference.status.padEnd(10)} ${reference.filename ?? reference.assetId ?? "<unknown>"} at ${reference.sourceStoryFullSlug ?? reference.path}`);
1819
+ }
1820
+ }
1821
+ report.warnings.forEach((warning) => Logger.warning(`[dry-run] ${warning.message}`));
1822
+ };
1823
+ const logDryRunCopyAssetsPlan = async ({ report, }) => {
1824
+ const selectionLabel = report.normalized.selection === "all"
1825
+ ? "all assets and asset folders"
1826
+ : report.normalized.selection.type === "referenced_by_stories"
1827
+ ? `referenced_by_stories ${report.normalized.selection.source} (${report.normalized.selection.mode})`
1828
+ : `${report.normalized.selection.type} ${report.normalized.selection.values.join(", ")}`;
1829
+ Logger.warning("[dry-run] Copy assets preview only. No Storyblok writes will be made.");
1830
+ Logger.warning(`[dry-run] Source space: ${report.normalized.sourceSpaceId}`);
1831
+ Logger.warning(`[dry-run] Target space: ${report.normalized.targetSpaceId}`);
1832
+ Logger.warning(`[dry-run] Selection: ${selectionLabel}`);
1833
+ Logger.warning(`[dry-run] Would plan ${report.summary.assetFolders} asset folder(s) and ${report.summary.assets} asset(s).`);
1834
+ for (const folder of report.graph.assetFolders) {
1835
+ Logger.warning(`[dry-run] asset_folder ${folder.targetPath ?? `#${folder.sourceId}`}`);
1836
+ }
1837
+ for (const asset of report.graph.assets) {
1838
+ Logger.warning(`[dry-run] asset ${asset.targetFilename}`);
1839
+ }
1840
+ report.graph.warnings.forEach((warning) => Logger.warning(`[dry-run] ${warning.message}`));
1841
+ report.limitations.forEach((limitation) => Logger.warning(`[dry-run] limitation: ${limitation}`));
1842
+ };
1843
+ export const copyCommand = async (props) => {
1844
+ const { input, flags } = props;
1845
+ const command = input[1];
1846
+ switch (command) {
1847
+ case COPY_COMMANDS.stories: {
1848
+ const sourceSpace = getCopySpace(flags, ["from", "sourceSpace"], apiConfig.spaceId);
1849
+ const targetSpace = getCopySpace(flags, ["to", "targetSpace"], apiConfig.spaceId);
1850
+ const selection = resolveCopySelection(flags);
1851
+ const dryRun = Boolean(flags["dryRun"]);
1852
+ const outputPath = readStringFlag(flags, ["outputPath"]);
1853
+ const manifestRoot = readStringFlag(flags, ["manifestRoot"]);
1854
+ const withAssets = Boolean(flags["withAssets"] ?? flags["with-assets"]);
1855
+ const publication = await resolveCopyPublicationOptions({
1856
+ flags,
1857
+ targetSpace,
1858
+ dryRun,
1859
+ });
1860
+ const destination = readStringFlag(flags, ["destination", "where"]);
1861
+ const destinationParentId = await resolveDestinationParentId(destination, targetSpace);
1862
+ Logger.warning(`Copying stories from space '${sourceSpace}' to space '${targetSpace}'.`);
1863
+ Logger.log(`Source '${selection.source}', mode '${selection.mode}', destination '${destination ?? "root"}'.`);
1864
+ const sourceStories = await getStoriesForSelection(selection, sourceSpace);
1865
+ const normalizedStories = normalizeStoriesForTree(sourceStories, selection);
1866
+ const tree = createTree(normalizedStories);
1867
+ const rootsToCreate = prepareTreeForCreate(selectTreeRoots(tree, selection));
1868
+ if (rootsToCreate.length === 0) {
1869
+ Logger.warning("No stories matched the copy selection.");
1870
+ break;
1871
+ }
1872
+ const plan = buildCopyPlan(rootsToCreate, destination);
1873
+ const manifestPaths = getDefaultCopyManifestPaths({
1874
+ sourceSpaceId: sourceSpace,
1875
+ targetSpaceId: targetSpace,
1876
+ rootDir: manifestRoot,
1877
+ });
1878
+ const manifestEntries = await loadManifest(manifestPaths.combined);
1879
+ const copyMaps = buildCopyMaps(manifestEntries);
1880
+ let dryRunGraph;
1881
+ let withAssetsGraph;
1882
+ let sourceAssets = [];
1883
+ let sourceAssetFolders = [];
1884
+ if (dryRun || withAssets) {
1885
+ const schemasPromise = buildComponentSchemaRegistry(sourceSpace);
1886
+ if (withAssets) {
1887
+ const [schemas, assetsResult, assetFoldersResult] = await Promise.all([
1888
+ schemasPromise,
1889
+ managementApi.assets.getAllAssets({ spaceId: sourceSpace }, {
1890
+ ...apiConfig,
1891
+ spaceId: sourceSpace,
1892
+ }),
1893
+ managementApi.assets.getAllAssetFolders({ spaceId: sourceSpace }, {
1894
+ ...apiConfig,
1895
+ spaceId: sourceSpace,
1896
+ }),
1897
+ ]);
1898
+ sourceAssets = Array.isArray(assetsResult?.assets)
1899
+ ? assetsResult.assets
1900
+ : [];
1901
+ sourceAssetFolders = Array.isArray(assetFoldersResult?.asset_folders)
1902
+ ? assetFoldersResult.asset_folders
1903
+ : [];
1904
+ Logger.warning(`Planning referenced assets from ${countStoryItems(sourceStories)} stories against ${sourceAssets.length} source asset(s).`);
1905
+ withAssetsGraph = buildReferencedAssetsGraph({
1906
+ sourceSpace,
1907
+ targetSpace,
1908
+ selection,
1909
+ destination,
1910
+ plan,
1911
+ sourceStories,
1912
+ sourceAssets,
1913
+ sourceAssetFolders,
1914
+ schemas,
1915
+ copyMaps,
1916
+ onScanProgress: logReferenceScanProgress,
1917
+ });
1918
+ Logger.success(`Reference planning complete. Found ${withAssetsGraph.assets.length} referenced asset(s), ${withAssetsGraph.assetFolders.length} asset folder(s), ${withAssetsGraph.assetReferences.length} asset reference occurrence(s), and ${withAssetsGraph.storyReferences.length} story reference occurrence(s).`);
1919
+ dryRunGraph = withAssetsGraph;
1920
+ }
1921
+ else if (dryRun) {
1922
+ const schemas = await schemasPromise;
1923
+ Logger.warning(`Scanning ${countStoryItems(sourceStories)} stories for copy references.`);
1924
+ dryRunGraph = buildStoryReferenceDryRunGraph({
1925
+ sourceSpace,
1926
+ targetSpace,
1927
+ selection,
1928
+ destination,
1929
+ plan,
1930
+ sourceStories,
1931
+ schemas,
1932
+ copyMaps,
1933
+ onScanProgress: logReferenceScanProgress,
1934
+ });
1935
+ Logger.success(`Reference planning complete. Found ${dryRunGraph.assetReferences.length} asset reference occurrence(s) and ${dryRunGraph.storyReferences.length} story reference occurrence(s).`);
1936
+ }
1937
+ }
1938
+ if (dryRun) {
1939
+ const conflicts = await findTargetConflicts(plan, targetSpace);
1940
+ Logger.warning("Building dry-run copy report.");
1941
+ const report = buildCopyDryRunReport({
1942
+ sourceSpace,
1943
+ targetSpace,
1944
+ selection,
1945
+ destination,
1946
+ withAssets,
1947
+ input: { ...flags },
1948
+ plan,
1949
+ conflicts,
1950
+ graph: dryRunGraph,
1951
+ outputPath,
162
1952
  });
163
- const targetEntryStory = await attachToFolder(where, targetSpace);
164
- const finalStories = [
165
- {
166
- ...entryStory.story,
167
- parent_id: targetEntryStory.story.id,
1953
+ await logDryRunCopyPlan({ report });
1954
+ if (outputPath) {
1955
+ Logger.warning(`Writing dry-run copy report to ${outputPath}.`);
1956
+ await writeDryRunReport(outputPath, report);
1957
+ }
1958
+ break;
1959
+ }
1960
+ let assetCopyReport;
1961
+ if (withAssetsGraph) {
1962
+ Logger.warning("Copying referenced assets before stories because --with-assets was passed.");
1963
+ assetCopyReport = await copyAssetsAndWriteManifests({
1964
+ sourceSpace,
1965
+ targetSpace,
1966
+ selection: {
1967
+ type: "asset",
1968
+ values: withAssetsGraph.assets.map((asset) => String(asset.sourceId)),
168
1969
  },
169
- ];
170
- const normalizedStories = finalStories.map((item) => {
171
- if (item.full_slug === what) {
172
- return {
173
- ...item,
174
- parent_id: null,
175
- };
176
- }
177
- return {
178
- ...item,
179
- };
1970
+ input: { ...flags },
1971
+ graph: withAssetsGraph,
1972
+ sourceAssets,
1973
+ sourceAssetFolders,
1974
+ manifestRoot,
180
1975
  });
181
- const treeOfStories = createTree(normalizedStories);
182
- await traverseAndCreate({
183
- tree: treeOfStories,
184
- realParentId: targetEntryStory.story.id,
185
- spaceId: targetSpace,
186
- }, {
1976
+ }
1977
+ const publishedLayerRecordBySourceId = new Map();
1978
+ if (publication.mode === "preserve-layers") {
1979
+ const publishedLayerContext = await buildPublishedLayerContext({
1980
+ items: sourceStories,
1981
+ from: sourceSpace,
1982
+ }, apiConfig);
1983
+ for (const record of publishedLayerContext.records) {
1984
+ publishedLayerRecordBySourceId.set(String(record.storyId), record);
1985
+ }
1986
+ }
1987
+ const storySummary = await createStoriesAndWriteManifests({
1988
+ tree: rootsToCreate,
1989
+ realParentId: destinationParentId,
1990
+ sourceStoryById: buildSourceStoryById(sourceStories),
1991
+ targetSlugBySourceSlug: buildTargetSlugBySourceSlug(plan),
1992
+ sourceSpace,
1993
+ targetSpace,
1994
+ manifestRoot,
1995
+ });
1996
+ await rewriteCopiedStoryContents({
1997
+ tree: rootsToCreate,
1998
+ realParentId: destinationParentId,
1999
+ sourceStoryById: buildSourceStoryById(sourceStories),
2000
+ targetSlugBySourceSlug: buildTargetSlugBySourceSlug(plan),
2001
+ publication,
2002
+ publishedLayerRecordBySourceId,
2003
+ sourceSpace,
2004
+ targetSpace,
2005
+ manifestRoot,
2006
+ });
2007
+ if (outputPath) {
2008
+ const report = buildCopyStoriesApplyReport({
2009
+ sourceSpace,
2010
+ targetSpace,
2011
+ selection,
2012
+ destination,
2013
+ withAssets,
2014
+ input: { ...flags },
2015
+ plan,
2016
+ storySummary,
2017
+ graph: withAssetsGraph,
2018
+ assetCopyReport,
2019
+ manifestRoot,
2020
+ });
2021
+ await writeJsonReport(outputPath, report);
2022
+ }
2023
+ break;
2024
+ }
2025
+ case COPY_COMMANDS.assets: {
2026
+ const sourceSpace = getCopySpace(flags, ["from", "sourceSpace"], apiConfig.spaceId);
2027
+ const targetSpace = getCopySpace(flags, ["to", "targetSpace"], apiConfig.spaceId);
2028
+ const dryRun = Boolean(flags["dryRun"]);
2029
+ const outputPath = readStringFlag(flags, ["outputPath"]);
2030
+ const manifestRoot = readStringFlag(flags, ["manifestRoot"]);
2031
+ const selection = resolveCopyAssetsSelection(flags);
2032
+ Logger.warning(dryRun
2033
+ ? `Planning asset copy from space '${sourceSpace}' to space '${targetSpace}'.`
2034
+ : `Preparing asset copy from space '${sourceSpace}' to space '${targetSpace}'.`);
2035
+ const [assetsResult, assetFoldersResult] = await Promise.all([
2036
+ managementApi.assets.getAllAssets({ spaceId: sourceSpace }, {
187
2037
  ...apiConfig,
188
- spaceId: targetSpace,
2038
+ spaceId: sourceSpace,
2039
+ }),
2040
+ managementApi.assets.getAllAssetFolders({ spaceId: sourceSpace }, {
2041
+ ...apiConfig,
2042
+ spaceId: sourceSpace,
2043
+ }),
2044
+ ]);
2045
+ const sourceAssets = Array.isArray(assetsResult?.assets)
2046
+ ? assetsResult.assets
2047
+ : [];
2048
+ const sourceAssetFolders = Array.isArray(assetFoldersResult?.asset_folders)
2049
+ ? assetFoldersResult.asset_folders
2050
+ : [];
2051
+ let graph;
2052
+ let scopedSource;
2053
+ if (selection.type === "referenced_by_stories") {
2054
+ const storySelection = selection.storySelection;
2055
+ const [schemas, sourceStories] = await Promise.all([
2056
+ buildComponentSchemaRegistry(sourceSpace),
2057
+ getStoriesForSelection(storySelection, sourceSpace),
2058
+ ]);
2059
+ const normalizedStories = normalizeStoriesForTree(sourceStories, storySelection);
2060
+ const tree = createTree(normalizedStories);
2061
+ const rootsToCreate = prepareTreeForCreate(selectTreeRoots(tree, storySelection));
2062
+ const plan = buildCopyPlan(rootsToCreate, undefined);
2063
+ const manifestPaths = getDefaultCopyManifestPaths({
2064
+ sourceSpaceId: sourceSpace,
2065
+ targetSpaceId: targetSpace,
2066
+ rootDir: manifestRoot,
2067
+ });
2068
+ const manifestEntries = await loadManifest(manifestPaths.combined);
2069
+ const copyMaps = buildCopyMaps(manifestEntries);
2070
+ Logger.warning(`Planning referenced assets from ${countStoryItems(sourceStories)} stories against ${sourceAssets.length} source asset(s).`);
2071
+ graph = buildReferencedAssetsGraph({
2072
+ sourceSpace,
2073
+ targetSpace,
2074
+ selection: storySelection,
2075
+ destination: undefined,
2076
+ plan,
2077
+ sourceStories,
2078
+ sourceAssets,
2079
+ sourceAssetFolders,
2080
+ schemas,
2081
+ copyMaps,
2082
+ onScanProgress: logReferenceScanProgress,
2083
+ });
2084
+ Logger.success(`Reference planning complete. Found ${graph.assets.length} referenced asset(s), ${graph.assetFolders.length} asset folder(s), ${graph.assetReferences.length} asset reference occurrence(s), and ${graph.storyReferences.length} story reference occurrence(s).`);
2085
+ graph.scope = {
2086
+ command: "copy assets",
2087
+ source: storySelection.source,
2088
+ mode: storySelection.mode,
2089
+ referencePolicy: "preserve",
2090
+ };
2091
+ scopedSource = selectSourceAssetsFromGraph({
2092
+ graph,
2093
+ sourceAssets,
2094
+ sourceAssetFolders,
189
2095
  });
190
2096
  }
2097
+ else {
2098
+ scopedSource = selectSourceAssetsForCopy({
2099
+ selection,
2100
+ sourceAssets,
2101
+ sourceAssetFolders,
2102
+ });
2103
+ graph = buildCopyAssetsGraph({
2104
+ sourceSpaceId: sourceSpace,
2105
+ targetSpaceId: targetSpace,
2106
+ assets: scopedSource.assets,
2107
+ assetFolders: scopedSource.assetFolders,
2108
+ });
2109
+ }
2110
+ if (dryRun) {
2111
+ const report = buildCopyAssetsDryRunReport({
2112
+ sourceSpace,
2113
+ targetSpace,
2114
+ selection,
2115
+ input: { ...flags },
2116
+ outputPath,
2117
+ graph,
2118
+ });
2119
+ await logDryRunCopyAssetsPlan({ report });
2120
+ if (outputPath) {
2121
+ await writeDryRunReport(outputPath, report);
2122
+ }
2123
+ break;
2124
+ }
2125
+ await copyAssetsAndWriteManifests({
2126
+ sourceSpace,
2127
+ targetSpace,
2128
+ selection,
2129
+ input: { ...flags },
2130
+ graph,
2131
+ sourceAssets: scopedSource.assets,
2132
+ sourceAssetFolders: scopedSource.assetFolders,
2133
+ outputPath,
2134
+ manifestRoot,
2135
+ });
191
2136
  break;
2137
+ }
192
2138
  default:
193
- console.log(`no command like that: ${command}`);
2139
+ Logger.warning("Unsupported copy command. Use: sb-mig copy stories --from <sourceSpaceId> --to <targetSpaceId> --source <full_slug> --destination <target_folder>, or sb-mig copy assets --from <sourceSpaceId> --to <targetSpaceId> --all --dry-run");
194
2140
  }
195
2141
  };