@designcrowd/library.brand-page 6.0.11 → 6.0.13

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 (117) hide show
  1. package/lib/src/{CreatedOnBrandCrowd-de-de-DopPc6g2.js → CreatedOnBrandCrowd-de-de-CBjFFeBP.js} +1 -1
  2. package/lib/src/{CreatedOnBrandCrowd-en-us-CSHmaAWm.js → CreatedOnBrandCrowd-en-us-NuNXF81K.js} +1 -1
  3. package/lib/src/{CreatedOnBrandCrowd-es-es-C4vM0p2e.js → CreatedOnBrandCrowd-es-es-BJG8nfug.js} +1 -1
  4. package/lib/src/{CreatedOnBrandCrowd-fr-fr-ArpBbnxm.js → CreatedOnBrandCrowd-fr-ca-uX-nL53e.js} +1 -1
  5. package/lib/src/{CreatedOnBrandCrowd-fr-ca-ArpBbnxm.js → CreatedOnBrandCrowd-fr-fr-uX-nL53e.js} +1 -1
  6. package/lib/src/{CreatedOnBrandCrowd-pt-pt-CGAQLXiX.js → CreatedOnBrandCrowd-pt-br-DTrgFGQV.js} +1 -1
  7. package/lib/src/{CreatedOnBrandCrowd-pt-br-CGAQLXiX.js → CreatedOnBrandCrowd-pt-pt-DTrgFGQV.js} +1 -1
  8. package/lib/src/{CreatedOnDcom-de-de-3WVSgB6t.js → CreatedOnDcom-de-de-DSfpxioT.js} +1 -1
  9. package/lib/src/{CreatedOnDcom-en-us-Dx-RHXrH.js → CreatedOnDcom-en-us-C7G4gKem.js} +1 -1
  10. package/lib/src/{CreatedOnDcom-es-es-CfnEYFMA.js → CreatedOnDcom-es-es-DVckCaji.js} +1 -1
  11. package/lib/src/{CreatedOnDcom-fr-ca-BSHZTiUO.js → CreatedOnDcom-fr-ca-C5GDrm_C.js} +1 -1
  12. package/lib/src/{CreatedOnDcom-fr-fr-DtHssrQb.js → CreatedOnDcom-fr-fr-v5eMm-j2.js} +1 -1
  13. package/lib/src/{CreatedOnDcom-pt-br-CdX1aRI8.js → CreatedOnDcom-pt-br-Dq3-fPUc.js} +1 -1
  14. package/lib/src/{CreatedOnDcom-pt-pt-jy044FNL.js → CreatedOnDcom-pt-pt-DFsncYax.js} +1 -1
  15. package/lib/src/brand-page/components/image-gallery/ImageGallery.stories.js +3 -0
  16. package/lib/src/brand-page/components/image-gallery/masonry/MasonryGallery.mixin.d.ts +1 -1
  17. package/lib/src/brand-page/components/images-section/ImagesSection.stories.js +2 -1
  18. package/lib/src/brand-page/components/social-links/SocialLinks.stories.js +3 -0
  19. package/lib/src/brand-page/components/testimonial-section/__fixtures__/data.js +3 -0
  20. package/lib/src/brand-page/components/text/Text.stories.js +2 -0
  21. package/lib/src/brand-page/models/index.d.ts +13 -0
  22. package/lib/src/brand-page/models/index.js +4 -0
  23. package/lib/src/brand-page/utils/migration/migration-01.js +2 -0
  24. package/lib/src/brand-page/utils/migration/migration-18.d.ts +14 -0
  25. package/lib/src/brand-page/utils/migration/migration-18.js +17 -0
  26. package/lib/src/brand-page/utils/migration.util.js +6 -0
  27. package/lib/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.stories.js +1 -0
  28. package/lib/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.js +1 -0
  29. package/lib/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.js +5 -0
  30. package/lib/src/brand-page-maker/constants/config-settings-constant.js +4 -0
  31. package/lib/src/brand-page-maker/models/content-settings.js +4 -0
  32. package/lib/src/brand-page-maker/stores/ai-checkpoint/ai-checkpoint-store.d.ts +18 -0
  33. package/lib/src/index.mjs +84 -83
  34. package/lib/src/{lib-Bx7xK4dn.js → lib-BG-pyXi9.js} +19585 -19807
  35. package/lib/src/metadata-builder/ai-metadata-executor.js +24 -1
  36. package/lib/src/metadata-builder/builders/footer-builder.js +32 -41
  37. package/lib/src/metadata-builder/builders/item-id-utils.d.ts +54 -0
  38. package/lib/src/metadata-builder/builders/item-id-utils.js +96 -0
  39. package/lib/src/metadata-builder/builders/move-page-builder.d.ts +32 -0
  40. package/lib/src/metadata-builder/builders/move-page-builder.js +137 -0
  41. package/lib/src/metadata-builder/builders/nav-utils.d.ts +26 -0
  42. package/lib/src/metadata-builder/builders/nav-utils.js +95 -0
  43. package/lib/src/metadata-builder/builders/section-builders/image-gallery-builder.js +57 -68
  44. package/lib/src/metadata-builder/builders/section-builders/images-section-builder.js +58 -93
  45. package/lib/src/metadata-builder/builders/section-builders/move-section-builder.d.ts +13 -1
  46. package/lib/src/metadata-builder/builders/section-builders/move-section-builder.js +40 -30
  47. package/lib/src/metadata-builder/builders/section-builders/shapes-section-builder.js +66 -99
  48. package/lib/src/metadata-builder/builders/section-builders/social-links-builder.js +57 -17
  49. package/lib/src/metadata-builder/builders/section-builders/testimonial-section-builder.js +51 -85
  50. package/lib/src/metadata-builder/builders/section-builders/text-section-builder.js +50 -74
  51. package/lib/src/metadata-builder/builders/section-field-maps.js +17 -0
  52. package/lib/src/metadata-builder/builders/set-page-visibility-builder.d.ts +23 -0
  53. package/lib/src/metadata-builder/builders/set-page-visibility-builder.js +83 -0
  54. package/lib/src/metadata-builder/metadata-executor.js +4 -0
  55. package/lib/src/metadata-builder/models.d.ts +2 -0
  56. package/lib/src/metadata-builder/models.js +2 -0
  57. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.d.ts +31 -7
  58. package/lib/src/metadata-builder/parsers/ai-toolcall-parser.js +2 -0
  59. package/lib/src/metadata-builder/parsers/section-tool-arguments.d.ts +84 -48
  60. package/lib/src/shared/content-settings/components/links-field/LinksField.mixin.js +2 -0
  61. package/lib/src/shared/page-settings/components/page-settings/PageSettings.mixin.d.ts +2 -0
  62. package/lib/src/shared/page-settings/components/page-settings-section-styles/PageSettingsSectionStyles.mixin.d.ts +2 -0
  63. package/lib/src/tracker.d.ts +2 -0
  64. package/lib/src/tracker.js +12 -0
  65. package/lib/src/tracker.mjs +12 -0
  66. package/lib/src/useEventTracker-DmQ6AkNn.js +356 -0
  67. package/lib/src/utils/minify-brand-page-metadata.d.ts +2 -0
  68. package/lib/src/utils/minify-brand-page-metadata.js +8 -1
  69. package/package.json +16 -1
  70. package/src/brand-page/components/image-gallery/ImageGallery.stories.ts +3 -0
  71. package/src/brand-page/components/image-gallery/ImageGallery.test.ts +1 -0
  72. package/src/brand-page/components/image-gallery/masonry/MasonryGallery.mixin.ts +2 -1
  73. package/src/brand-page/components/images-section/ImagesSection.stories.ts +2 -1
  74. package/src/brand-page/components/social-links/SocialLinks.stories.ts +3 -0
  75. package/src/brand-page/components/social-links/SocialLinks.vue.test.ts +5 -0
  76. package/src/brand-page/components/testimonial-section/__fixtures__/data.ts +3 -0
  77. package/src/brand-page/components/text/Text.stories.ts +2 -0
  78. package/src/brand-page/models/index.ts +17 -0
  79. package/src/brand-page/utils/__snapshots__/migration.util.test.ts.snap +23 -3
  80. package/src/brand-page/utils/__snapshots__/migration2.util.test.ts.snap +6 -0
  81. package/src/brand-page/utils/__snapshots__/model.util.test.ts.snap +12 -0
  82. package/src/brand-page/utils/migration/migration-01.ts +2 -0
  83. package/src/brand-page/utils/migration/migration-18.test.ts +131 -0
  84. package/src/brand-page/utils/migration/migration-18.ts +22 -0
  85. package/src/brand-page/utils/migration.util.ts +7 -0
  86. package/src/brand-page/utils/personalisation.util.test.ts +4 -0
  87. package/src/brand-page-maker/components/brand-page-maker/BrandPageMaker.stories.ts +1 -0
  88. package/src/brand-page-maker/components/brand-page-maker/__fixtures__/brand-page-examples.ts +1 -0
  89. package/src/brand-page-maker/composables/ai-chat/useAiWebsiteBuilderChat.ts +6 -0
  90. package/src/brand-page-maker/constants/config-settings-constant.ts +4 -0
  91. package/src/brand-page-maker/models/content-settings.ts +4 -0
  92. package/src/metadata-builder/ai-metadata-executor.ts +31 -1
  93. package/src/metadata-builder/builders/footer-builder.test.ts +335 -28
  94. package/src/metadata-builder/builders/footer-builder.ts +40 -57
  95. package/src/metadata-builder/builders/item-id-utils.test.ts +184 -0
  96. package/src/metadata-builder/builders/item-id-utils.ts +117 -0
  97. package/src/metadata-builder/builders/move-page-builder.ts +227 -0
  98. package/src/metadata-builder/builders/nav-utils.ts +141 -0
  99. package/src/metadata-builder/builders/section-builders/image-gallery-builder.ts +62 -88
  100. package/src/metadata-builder/builders/section-builders/images-section-builder.ts +66 -129
  101. package/src/metadata-builder/builders/section-builders/move-section-builder.ts +58 -35
  102. package/src/metadata-builder/builders/section-builders/shapes-section-builder.ts +105 -160
  103. package/src/metadata-builder/builders/section-builders/social-links-builder.ts +65 -20
  104. package/src/metadata-builder/builders/section-builders/testimonial-section-builder.ts +83 -146
  105. package/src/metadata-builder/builders/section-builders/text-section-builder.ts +71 -122
  106. package/src/metadata-builder/builders/section-field-maps.ts +18 -0
  107. package/src/metadata-builder/builders/set-page-visibility-builder.ts +122 -0
  108. package/src/metadata-builder/metadata-executor.ts +4 -0
  109. package/src/metadata-builder/models.ts +2 -0
  110. package/src/metadata-builder/parsers/ai-toolcall-parser.ts +32 -7
  111. package/src/metadata-builder/parsers/section-tool-arguments.ts +84 -48
  112. package/src/shared/content-settings/components/links-field/LinksField.mixin.ts +2 -0
  113. package/src/tracker.ts +28 -0
  114. package/src/utils/minify-brand-page-metadata.ts +14 -2
  115. package/lib/src/metadata-builder/builders/reorder-pages-builder.d.ts +0 -13
  116. package/lib/src/metadata-builder/builders/reorder-pages-builder.js +0 -49
  117. package/src/metadata-builder/builders/reorder-pages-builder.ts +0 -80
@@ -10,6 +10,12 @@ import type { BuilderResult } from '../../models';
10
10
  import { applyScalarFields, isValidUrl } from '../section-field-utils';
11
11
  import { BaseSectionBuilder } from './base-section-builder';
12
12
  import { validateImageGallery } from '../section-rules/image-gallery.rules';
13
+ import {
14
+ ensureItemIds,
15
+ generateItemId,
16
+ removeItemsByIds,
17
+ repositionItemByAnchor,
18
+ } from '../item-id-utils';
13
19
 
14
20
  const applyGalleryType = (
15
21
  content: ImageGalleryContent,
@@ -21,11 +27,17 @@ const applyGalleryType = (
21
27
  };
22
28
  };
23
29
 
30
+ /**
31
+ * Full rebuild: discard existing images and map the payload directly.
32
+ * Used on create and when `_replace_items` is set. Each item gets a fresh id
33
+ * (or keeps the caller-supplied `item_id` when provided).
34
+ */
24
35
  const applyGalleryImages = (
25
36
  content: ImageGalleryContent,
26
37
  images: ImageGalleryItem[],
27
38
  ): void => {
28
39
  content.images = images.map((img) => ({
40
+ id: img.item_id || generateItemId(),
29
41
  imageUrl: isValidUrl(img.image_url) ? img.image_url! : '',
30
42
  imageTitle: img.caption || '',
31
43
  width: 0,
@@ -35,84 +47,53 @@ const applyGalleryImages = (
35
47
  }));
36
48
  };
37
49
 
50
+ /**
51
+ * Id-based merge (BP-5603), mirroring testimonial-section-builder one level
52
+ * deeper into `content.images`:
53
+ * - `item_id` matches existing item → update in place; reposition if
54
+ * `move_before_item_id` is set.
55
+ * - no `item_id` → add new item, positioned by
56
+ * `move_before_item_id`.
57
+ * - `removeIds` → delete by id (order-independent).
58
+ *
59
+ * Existing items are id-backfilled first so legacy galleries become
60
+ * referenceable as soon as the metadata builder touches them.
61
+ */
38
62
  const mergeGalleryImages = (
39
63
  content: ImageGalleryContent,
40
64
  images: ImageGalleryItem[],
41
- replaceAll: boolean,
65
+ removeIds: string[] | undefined,
42
66
  ): void => {
43
- const itemsToMerge = images.filter((img) => img.insert_at === undefined);
44
-
45
- const updatedImages = itemsToMerge.map((img, index) => {
46
- const existing = content.images?.[index];
47
- const imageUrl = isValidUrl(img.image_url)
48
- ? img.image_url!
49
- : (existing?.imageUrl ?? '');
50
-
51
- return {
52
- ...(existing || {}),
53
- imageUrl,
54
- imageTitle: img.caption ?? existing?.imageTitle ?? '',
55
- width: existing?.width ?? 0,
56
- height: existing?.height ?? 0,
57
- mediaType: existing?.mediaType ?? 'image',
58
- imageLink: existing?.imageLink ?? '',
59
- };
60
- });
61
-
62
- if (
63
- !replaceAll &&
64
- content.images &&
65
- content.images.length > itemsToMerge.length
66
- ) {
67
- const extras = content.images.slice(itemsToMerge.length).map((img) => ({
68
- imageUrl: img.imageUrl ?? '',
69
- imageTitle: img.imageTitle ?? '',
70
- width: img.width ?? 0,
71
- height: img.height ?? 0,
72
- mediaType: img.mediaType ?? ('image' as const),
73
- imageLink: img.imageLink ?? '',
74
- }));
75
-
76
- updatedImages.push(...extras);
77
- }
78
-
79
- content.images = updatedImages;
80
- };
81
-
82
- const insertGalleryImages = (
83
- content: ImageGalleryContent,
84
- images: ImageGalleryItem[],
85
- ): void => {
86
- const inserts = images.filter((img) => img.insert_at !== undefined);
87
-
88
- if (!inserts.length) return;
89
-
90
- const sorted = [...inserts].sort(
91
- (a, b) => (b.insert_at ?? 0) - (a.insert_at ?? 0),
92
- );
67
+ ensureItemIds(content.images);
68
+ content.images = removeItemsByIds(content.images, removeIds);
93
69
 
94
- for (const img of sorted) {
95
- if (!isValidUrl(img.image_url)) continue;
70
+ for (const img of images) {
71
+ if (img.item_id) {
72
+ const existing = content.images.find((i) => i.id === img.item_id);
96
73
 
97
- if (content.images?.some((existing) => existing.imageUrl === img.image_url))
98
- continue;
74
+ // Unknown id skip rather than guess at a position.
75
+ if (!existing) continue;
99
76
 
100
- content.images = content.images ?? [];
101
-
102
- const pos = Math.max(
103
- 0,
104
- Math.min(img.insert_at ?? 0, content.images.length),
105
- );
77
+ if (img.image_url !== undefined) {
78
+ existing.imageUrl = isValidUrl(img.image_url) ? img.image_url! : '';
79
+ }
106
80
 
107
- content.images.splice(pos, 0, {
108
- imageUrl: img.image_url!,
109
- imageTitle: img.caption ?? '',
110
- width: 0,
111
- height: 0,
112
- mediaType: 'image',
113
- imageLink: '',
114
- colorReplacements: [],
115
- });
81
+ if (img.caption !== undefined) existing.imageTitle = img.caption;
82
+
83
+ repositionItemByAnchor(content.images, existing, img.move_before_item_id);
84
+ } else {
85
+ const newItem = {
86
+ id: generateItemId(),
87
+ imageUrl: isValidUrl(img.image_url) ? img.image_url! : '',
88
+ imageTitle: img.caption ?? '',
89
+ width: 0,
90
+ height: 0,
91
+ mediaType: 'image' as const,
92
+ imageLink: '',
93
+ };
94
+
95
+ repositionItemByAnchor(content.images, newItem, img.move_before_item_id);
96
+ }
116
97
  }
117
98
  };
118
99
 
@@ -144,26 +125,19 @@ export class ImageGalleryBuilder extends BaseSectionBuilder<ImageGalleryContent>
144
125
  applyGalleryType(content, args.gallery_type);
145
126
  }
146
127
 
147
- if (args.images && Array.isArray(args.images)) {
148
- if (this.mode === 'create') {
128
+ if (args._replace_items && args.images && Array.isArray(args.images)) {
129
+ // Full replacement: discard existing images and rebuild from the payload.
130
+ applyGalleryImages(content, args.images);
131
+ } else if (this.mode === 'create') {
132
+ if (args.images && Array.isArray(args.images)) {
149
133
  applyGalleryImages(content, args.images);
150
- } else {
151
- mergeGalleryImages(content, args.images, !!args._replace_items);
152
134
  }
153
- }
154
-
155
- if (args._remove_item_indices?.length && content.images) {
156
- const sorted = [...args._remove_item_indices].sort((a, b) => b - a);
157
-
158
- for (const idx of sorted) {
159
- if (idx >= 0 && idx < content.images.length) {
160
- content.images.splice(idx, 1);
161
- }
162
- }
163
- }
164
-
165
- if (args.images && Array.isArray(args.images) && this.mode === 'update') {
166
- insertGalleryImages(content, args.images);
135
+ } else if (
136
+ (args.images && Array.isArray(args.images)) ||
137
+ args._remove_item_ids?.length
138
+ ) {
139
+ // Update: id-based add / edit / move / remove in a single pass.
140
+ mergeGalleryImages(content, args.images ?? [], args._remove_item_ids);
167
141
  }
168
142
 
169
143
  return { request: this.request, model };
@@ -18,12 +18,24 @@ import {
18
18
  } from '../section-field-utils';
19
19
  import { BaseSectionBuilder } from './base-section-builder';
20
20
  import { validateImagesSection } from '../section-rules/images-section.rules';
21
-
21
+ import {
22
+ ensureItemIds,
23
+ generateItemId,
24
+ removeItemsByIds,
25
+ repositionItemByAnchor,
26
+ } from '../item-id-utils';
27
+
28
+ /**
29
+ * Full replacement: discard existing items and rebuild from the payload.
30
+ * Assigns a stable `id` to each item; `itemsId` is set positionally here
31
+ * but will be re-indexed at the end of buildAsync.
32
+ */
22
33
  const applyImageItems = (
23
34
  content: ImagesSectionContent,
24
35
  items: ImagesSectionItem[],
25
36
  ): void => {
26
37
  content.items = items.map((item, index) => ({
38
+ id: item.item_id || generateItemId(),
27
39
  imageUrl: isValidUrl(item.image_url) ? item.image_url! : '',
28
40
  imageTitle: '',
29
41
  width: 0,
@@ -38,25 +50,33 @@ const applyImageItems = (
38
50
  }));
39
51
  };
40
52
 
53
+ /**
54
+ * Apply a batch of item operations by id (BP-5603), mirroring the
55
+ * section-level id-based contract one level deeper:
56
+ * - item with `item_id` matching an existing item → update in place (and
57
+ * reposition if `move_before_item_id` is set);
58
+ * - item without `item_id` → add a new item,
59
+ * positioned by `move_before_item_id`;
60
+ * - `_remove_item_ids` → delete by id.
61
+ *
62
+ * Existing items are id-backfilled first so legacy sections become
63
+ * referenceable the moment the metadata builder touches them.
64
+ * Order-independent: there are no positional indices to shift between ops.
65
+ */
41
66
  const mergeImageItems = (
42
67
  content: ImagesSectionContent,
43
68
  items: ImagesSectionItem[],
44
- replaceAll: boolean,
69
+ removeIds: string[] | undefined,
45
70
  ): void => {
46
- const hasIndexedItems = items.some((item) => item.item_index !== undefined);
71
+ ensureItemIds(content.items);
72
+ content.items = removeItemsByIds(content.items, removeIds);
47
73
 
48
- if (hasIndexedItems) {
49
- for (const item of items) {
50
- const targetIndex = item.item_index;
74
+ for (const item of items) {
75
+ if (item.item_id) {
76
+ const existing = content.items.find((i) => i.id === item.item_id);
51
77
 
52
- if (
53
- targetIndex === undefined ||
54
- targetIndex < 0 ||
55
- !content.items?.[targetIndex]
56
- )
57
- continue;
58
-
59
- const existing = content.items[targetIndex];
78
+ // Unknown id — skip rather than guess at a position.
79
+ if (!existing) continue;
60
80
 
61
81
  if (isValidUrl(item.image_url)) existing.imageUrl = item.image_url!;
62
82
 
@@ -66,47 +86,27 @@ const mergeImageItems = (
66
86
 
67
87
  if (item.button_text !== undefined)
68
88
  existing.buttonText = item.button_text || false;
69
- }
70
-
71
- return;
72
- }
73
-
74
- const itemsToMerge = items.filter((item) => item.insert_at === undefined);
75
-
76
- const updatedItems = itemsToMerge.map((item, index) => {
77
- const existing = content.items?.[index];
78
89
 
79
- return {
80
- ...(existing || {}),
81
- imageUrl: isValidUrl(item.image_url)
82
- ? item.image_url!
83
- : (existing?.imageUrl ?? ''),
84
- imageTitle: existing?.imageTitle ?? '',
85
- width: existing?.width ?? 0,
86
- height: existing?.height ?? 0,
87
- mediaType: existing?.mediaType ?? 'image',
88
- imageLink: existing?.imageLink ?? '',
89
- title: item.heading ?? existing?.title ?? '',
90
- paragraph: item.text ?? existing?.paragraph ?? '',
91
- buttonText: item.button_text ?? existing?.buttonText ?? '',
92
- itemsId: existing?.itemsId ?? index,
93
- colorReplacements: existing?.colorReplacements ?? [],
94
- };
95
- });
96
-
97
- if (
98
- !replaceAll &&
99
- content.items &&
100
- content.items.length > itemsToMerge.length
101
- ) {
102
- const remaining = content.items.slice(
103
- itemsToMerge.length,
104
- ) as typeof updatedItems;
105
-
106
- updatedItems.push(...remaining);
90
+ repositionItemByAnchor(content.items, existing, item.move_before_item_id);
91
+ } else {
92
+ const newItem = {
93
+ id: generateItemId(),
94
+ imageUrl: isValidUrl(item.image_url) ? item.image_url! : '',
95
+ imageTitle: '',
96
+ width: 0,
97
+ height: 0,
98
+ mediaType: 'image',
99
+ imageLink: '',
100
+ title: item.heading ?? '',
101
+ paragraph: item.text ?? '',
102
+ buttonText: item.button_text || false,
103
+ itemsId: 0, // re-indexed at the end of buildAsync
104
+ colorReplacements: [],
105
+ } as ImagesSectionContent['items'][number];
106
+
107
+ repositionItemByAnchor(content.items, newItem, item.move_before_item_id);
108
+ }
107
109
  }
108
-
109
- content.items = updatedItems;
110
110
  };
111
111
 
112
112
  export class ImagesSectionBuilder extends BaseSectionBuilder<ImagesSectionContent> {
@@ -192,86 +192,23 @@ export class ImagesSectionBuilder extends BaseSectionBuilder<ImagesSectionConten
192
192
  };
193
193
  }
194
194
 
195
- if (args.items && Array.isArray(args.items)) {
196
- if (this.mode === 'create') {
195
+ if (args._replace_items && args.items && Array.isArray(args.items)) {
196
+ // Full replacement: discard existing items and rebuild from the payload.
197
+ applyImageItems(content, args.items);
198
+ } else if (this.mode === 'create') {
199
+ if (args.items && Array.isArray(args.items)) {
197
200
  applyImageItems(content, args.items);
198
- } else {
199
- mergeImageItems(content, args.items, !!args._replace_items);
200
- }
201
- }
202
-
203
- // Phase 2: Item operations (remove, insert, reorder)
204
- if (args._remove_item_indices?.length && content.items) {
205
- const sorted = [...args._remove_item_indices].sort((a, b) => b - a);
206
-
207
- for (const idx of sorted) {
208
- if (idx >= 0 && idx < content.items.length) {
209
- content.items.splice(idx, 1);
210
- }
211
- }
212
- }
213
-
214
- if (args.items && Array.isArray(args.items)) {
215
- const insertItems = args.items.filter(
216
- (item) => item.insert_at !== undefined && item.item_index === undefined,
217
- );
218
- // Process inserts in reverse order of insert_at to preserve indices
219
- const sortedInserts = [...insertItems].sort(
220
- (a, b) => (b.insert_at ?? 0) - (a.insert_at ?? 0),
221
- );
222
-
223
- for (const item of sortedInserts) {
224
- if (!isValidUrl(item.image_url)) continue;
225
-
226
- // Skip if this image_url already exists — defensive against duplicate
227
- // tool call application
228
- if (
229
- content.items?.some(
230
- (existing: any) => existing.imageUrl === item.image_url,
231
- )
232
- ) {
233
- continue;
234
- }
235
-
236
- const pos = Math.max(
237
- 0,
238
- Math.min(item.insert_at ?? 0, content.items?.length ?? 0),
239
- );
240
- const newItem = {
241
- imageUrl: item.image_url,
242
- imageTitle: '',
243
- width: 0,
244
- height: 0,
245
- mediaType: 'image',
246
- imageLink: '',
247
- title: item.heading ?? '',
248
- paragraph: item.text ?? '',
249
- buttonText: item.button_text || '',
250
- itemsId: pos,
251
- colorReplacements: [],
252
- };
253
-
254
- content.items = content.items ?? [];
255
- content.items.splice(pos, 0, newItem as any);
256
- }
257
- }
258
-
259
- if (args._reorder_items?.length && content.items) {
260
- const newOrder = args._reorder_items;
261
- const unique = new Set(newOrder);
262
-
263
- if (unique.size === newOrder.length) {
264
- const reordered = newOrder
265
- .filter((idx) => idx >= 0 && idx < content.items!.length)
266
- .map((idx) => content.items![idx]);
267
-
268
- if (reordered.length === content.items.length) {
269
- content.items = reordered;
270
- }
271
201
  }
202
+ } else if (
203
+ (args.items && Array.isArray(args.items)) ||
204
+ args._remove_item_ids?.length
205
+ ) {
206
+ // Update: id-based add / edit / move / remove in a single pass.
207
+ mergeImageItems(content, args.items ?? [], args._remove_item_ids);
272
208
  }
273
209
 
274
- // Re-index itemsId after any item operations
210
+ // Re-index itemsId after all id-based item operations (render concern —
211
+ // separate from the stable `id` used for targeting).
275
212
  if (content.items) {
276
213
  content.items.forEach((item, i) => {
277
214
  item.itemsId = i;
@@ -8,7 +8,12 @@ import type { Logger } from '../../logger';
8
8
 
9
9
  export interface MoveSectionPayload {
10
10
  contentId: string;
11
- position: number;
11
+ /**
12
+ * Relative anchor on the destination page: the content_id to insert this section
13
+ * BEFORE (a page's first content_id moves it to the very top). Omit to append to the
14
+ * end — a move always relocates the section, so "no anchor" means "append".
15
+ */
16
+ moveBeforeContentId?: string;
12
17
  targetPageId?: string;
13
18
  }
14
19
 
@@ -22,16 +27,11 @@ export class MoveSectionBuilder extends BaseMetadataBuilder<MoveSectionPayload>
22
27
 
23
28
  async validateAsync(): Promise<{ errors: string[] }> {
24
29
  const errors: string[] = [];
25
- const payload = this.request.payload;
26
30
 
27
- if (!payload?.contentId) {
31
+ if (!this.request.payload?.contentId) {
28
32
  errors.push('contentId is required for move_section');
29
33
  }
30
34
 
31
- if (payload?.position === undefined || payload.position < 0) {
32
- errors.push('position must be a non-negative integer');
33
- }
34
-
35
35
  return { errors };
36
36
  }
37
37
 
@@ -44,51 +44,55 @@ export class MoveSectionBuilder extends BaseMetadataBuilder<MoveSectionPayload>
44
44
  return { request: this.request, model, errors: validation.errors };
45
45
  }
46
46
 
47
- const { contentId, position, targetPageId } = this.request.payload!;
47
+ const { contentId, moveBeforeContentId, targetPageId } =
48
+ this.request.payload!;
48
49
 
49
50
  for (const page of model.pages) {
50
51
  const currentIndex = page.contents.findIndex((c) => c.id === contentId);
51
52
 
52
- if (currentIndex !== -1) {
53
- const [section] = page.contents.splice(currentIndex, 1);
54
-
55
- // Cross-page move: insert into target page
56
- if (targetPageId && targetPageId !== page.id) {
57
- const targetPage = model.pages.find((p) => p.id === targetPageId);
58
-
59
- if (!targetPage) {
60
- // Undo the splice and return an error
61
- page.contents.splice(currentIndex, 0, section);
53
+ if (currentIndex === -1) continue;
62
54
 
63
- return {
64
- request: this.request,
65
- model,
66
- errors: [`Target page with ID "${targetPageId}" not found`],
67
- };
68
- }
55
+ const [section] = page.contents.splice(currentIndex, 1);
69
56
 
70
- const targetIndex = Math.min(position, targetPage.contents.length);
57
+ // Cross-page move: insert into target page
58
+ if (targetPageId && targetPageId !== page.id) {
59
+ const targetPage = model.pages.find((p) => p.id === targetPageId);
71
60
 
72
- targetPage.contents.splice(targetIndex, 0, section);
61
+ if (!targetPage) {
62
+ // Undo the splice and return an error
63
+ page.contents.splice(currentIndex, 0, section);
73
64
 
74
- this.log({
75
- message: `Moved section ${contentId} from page ${page.id} index ${currentIndex} to page ${targetPageId} index ${targetIndex}`,
76
- });
77
-
78
- return { request: this.request, model };
65
+ return {
66
+ request: this.request,
67
+ model,
68
+ errors: [`Target page with ID "${targetPageId}" not found`],
69
+ };
79
70
  }
80
71
 
81
- // Within-page reorder
82
- const targetIndex = Math.min(position, page.contents.length);
72
+ const targetIndex = this.resolveIndex(
73
+ targetPage.contents,
74
+ moveBeforeContentId,
75
+ );
83
76
 
84
- page.contents.splice(targetIndex, 0, section);
77
+ targetPage.contents.splice(targetIndex, 0, section);
85
78
 
86
79
  this.log({
87
- message: `Moved section ${contentId} from index ${currentIndex} to ${targetIndex} on page ${page.id}`,
80
+ message: `Moved section ${contentId} from page ${page.id} index ${currentIndex} to page ${targetPageId} index ${targetIndex}`,
88
81
  });
89
82
 
90
83
  return { request: this.request, model };
91
84
  }
85
+
86
+ // Within-page reorder
87
+ const targetIndex = this.resolveIndex(page.contents, moveBeforeContentId);
88
+
89
+ page.contents.splice(targetIndex, 0, section);
90
+
91
+ this.log({
92
+ message: `Moved section ${contentId} from index ${currentIndex} to ${targetIndex} on page ${page.id}`,
93
+ });
94
+
95
+ return { request: this.request, model };
92
96
  }
93
97
 
94
98
  return {
@@ -97,4 +101,23 @@ export class MoveSectionBuilder extends BaseMetadataBuilder<MoveSectionPayload>
97
101
  errors: [`Section with ID "${contentId}" not found`],
98
102
  };
99
103
  }
104
+
105
+ /**
106
+ * Resolve the insert index on a page from the relative anchor. `moveBeforeContentId`
107
+ * is the content_id to insert before; anything else — omitted, or an id not on the
108
+ * page — appends to the end. A move always relocates the section, so "no anchor"
109
+ * naturally means "append" (this also absorbs a legacy "__end__" token as append).
110
+ */
111
+ private resolveIndex<T extends { id: string }>(
112
+ contents: T[],
113
+ moveBeforeContentId: string | undefined,
114
+ ): number {
115
+ if (moveBeforeContentId) {
116
+ const idx = contents.findIndex((c) => c.id === moveBeforeContentId);
117
+
118
+ if (idx !== -1) return idx;
119
+ }
120
+
121
+ return contents.length;
122
+ }
100
123
  }