sf-plugin-cms 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +241 -20
- package/lib/commands/cms/export/workspace.d.ts +5 -1
- package/lib/commands/cms/export/workspace.js +60 -2
- package/lib/commands/cms/export/workspace.js.map +1 -1
- package/lib/commands/cms/import/workspace.d.ts +12 -4
- package/lib/commands/cms/import/workspace.js +167 -20
- package/lib/commands/cms/import/workspace.js.map +1 -1
- package/lib/commands/cms/info.js +21 -2
- package/lib/commands/cms/info.js.map +1 -1
- package/lib/contracts/info.d.ts +7 -4
- package/lib/contracts/info.js +17 -8
- package/lib/contracts/info.js.map +1 -1
- package/lib/contracts/shared.d.ts +2 -1
- package/lib/contracts/shared.js +11 -3
- package/lib/contracts/shared.js.map +1 -1
- package/lib/contracts/workspace-export.d.ts +23 -3
- package/lib/contracts/workspace-export.js +118 -5
- package/lib/contracts/workspace-export.js.map +1 -1
- package/lib/contracts/workspace-import.d.ts +64 -0
- package/lib/contracts/workspace-import.js +119 -0
- package/lib/contracts/workspace-import.js.map +1 -1
- package/lib/index.d.ts +3 -1
- package/lib/index.js +2 -0
- package/lib/index.js.map +1 -1
- package/lib/services/email-fragment.d.ts +15 -0
- package/lib/services/email-fragment.js +199 -0
- package/lib/services/email-fragment.js.map +1 -0
- package/lib/services/export-workspace.d.ts +13 -1
- package/lib/services/export-workspace.js +183 -7
- package/lib/services/export-workspace.js.map +1 -1
- package/lib/services/image-import.d.ts +91 -0
- package/lib/services/image-import.js +579 -0
- package/lib/services/image-import.js.map +1 -0
- package/lib/services/import-workspace.d.ts +16 -3
- package/lib/services/import-workspace.js +142 -14
- package/lib/services/import-workspace.js.map +1 -1
- package/lib/services/landing-page-template.d.ts +52 -0
- package/lib/services/landing-page-template.js +421 -0
- package/lib/services/landing-page-template.js.map +1 -0
- package/lib/services/landing-page.d.ts +43 -0
- package/lib/services/landing-page.js +117 -0
- package/lib/services/landing-page.js.map +1 -0
- package/lib/services/web-fragment.d.ts +41 -0
- package/lib/services/web-fragment.js +245 -0
- package/lib/services/web-fragment.js.map +1 -0
- package/lib/transport/experimental-media.d.ts +30 -0
- package/lib/transport/experimental-media.js +224 -0
- package/lib/transport/experimental-media.js.map +1 -0
- package/lib/transport/json-request.d.ts +9 -1
- package/lib/transport/json-request.js +30 -3
- package/lib/transport/json-request.js.map +1 -1
- package/lib/transport/multipart-image-create.d.ts +28 -0
- package/lib/transport/multipart-image-create.js +189 -0
- package/lib/transport/multipart-image-create.js.map +1 -0
- package/messages/cms.import.workspace.md +6 -0
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Connection } from '@salesforce/core';
|
|
2
|
+
import type { WorkspaceExportMedia } from '../contracts/workspace-export.js';
|
|
3
|
+
import { type ComponentStableIdentity, type ImageImportFieldPlan, type ImageImportIdentityField, type ImageImportStrategy, type WorkspaceImageImportResultV2 } from '../contracts/workspace-import.js';
|
|
4
|
+
import { type JsonRequestOptions } from '../transport/json-request.js';
|
|
5
|
+
import { type LoadedWorkspaceExport, type WorkspaceImportItem } from './import-workspace.js';
|
|
6
|
+
import { type MultipartImageCreateOptions } from '../transport/multipart-image-create.js';
|
|
7
|
+
export type ImageImportMapField = {
|
|
8
|
+
readonly strategy: ImageImportStrategy;
|
|
9
|
+
readonly value?: string;
|
|
10
|
+
};
|
|
11
|
+
export type ImageImportMapRow = {
|
|
12
|
+
readonly source: ComponentStableIdentity;
|
|
13
|
+
readonly contentKey: ImageImportMapField;
|
|
14
|
+
readonly apiName: ImageImportMapField;
|
|
15
|
+
readonly title: ImageImportMapField;
|
|
16
|
+
readonly urlName: ImageImportMapField;
|
|
17
|
+
};
|
|
18
|
+
export type PlannedImageImport = {
|
|
19
|
+
readonly source: WorkspaceImportItem;
|
|
20
|
+
readonly media: WorkspaceExportMedia;
|
|
21
|
+
readonly identities: Record<ImageImportIdentityField, ImageImportFieldPlan>;
|
|
22
|
+
};
|
|
23
|
+
type RequestConnection = Pick<Connection, 'request'>;
|
|
24
|
+
export type ImageImportPreflightOptions = {
|
|
25
|
+
readonly connection: RequestConnection;
|
|
26
|
+
readonly destinationOrgId: string;
|
|
27
|
+
readonly destinationWorkspaceId: string;
|
|
28
|
+
readonly plans: readonly PlannedImageImport[];
|
|
29
|
+
readonly requestOptions?: JsonRequestOptions;
|
|
30
|
+
readonly source: LoadedWorkspaceExport;
|
|
31
|
+
};
|
|
32
|
+
export type ImageImportPreflightResult = {
|
|
33
|
+
readonly contractResult: WorkspaceImageImportResultV2;
|
|
34
|
+
readonly dryRun: true;
|
|
35
|
+
readonly plans: readonly PlannedImageImport[];
|
|
36
|
+
};
|
|
37
|
+
export type ImageImportOperation = {
|
|
38
|
+
readonly operationId: string;
|
|
39
|
+
readonly requestIdentity: string;
|
|
40
|
+
readonly requestSha256: string;
|
|
41
|
+
readonly sourceApiName: string;
|
|
42
|
+
readonly destinationApiName: string;
|
|
43
|
+
readonly destinationOrgId: string;
|
|
44
|
+
readonly destinationWorkspaceId: string;
|
|
45
|
+
readonly runId: string;
|
|
46
|
+
state: 'pending' | 'succeeded' | 'failed';
|
|
47
|
+
error?: string;
|
|
48
|
+
result?: {
|
|
49
|
+
readonly contentKey: string;
|
|
50
|
+
readonly contentId: string;
|
|
51
|
+
readonly variantId: string;
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
export type ImageImportRunReport = {
|
|
55
|
+
readonly runId: string;
|
|
56
|
+
readonly sourceDirectory: string;
|
|
57
|
+
readonly sourceManifestSha256: string;
|
|
58
|
+
readonly destinationOrgId: string;
|
|
59
|
+
readonly destinationWorkspaceId: string;
|
|
60
|
+
readonly operations: ImageImportOperation[];
|
|
61
|
+
state: 'applying' | 'completed' | 'failed' | 'ownership-uncertain';
|
|
62
|
+
};
|
|
63
|
+
export type ImageImportReportPersistence = {
|
|
64
|
+
readonly rewrite: (file: string, report: ImageImportRunReport) => Promise<void>;
|
|
65
|
+
};
|
|
66
|
+
export type ImageImportApplyOptions = ImageImportPreflightOptions & {
|
|
67
|
+
readonly reportDirectory: string;
|
|
68
|
+
readonly reportPersistence?: ImageImportReportPersistence;
|
|
69
|
+
readonly createOptions?: MultipartImageCreateOptions;
|
|
70
|
+
};
|
|
71
|
+
export type ImageImportApplyResult = {
|
|
72
|
+
readonly contractResult: WorkspaceImageImportResultV2;
|
|
73
|
+
readonly dryRun: false;
|
|
74
|
+
readonly report: ImageImportRunReport;
|
|
75
|
+
readonly reportFile: string;
|
|
76
|
+
};
|
|
77
|
+
export declare function normalizeImageMultipartFilename(fileName: string, mimeType: string): string;
|
|
78
|
+
export declare function planImageImports(source: LoadedWorkspaceExport, input: unknown): PlannedImageImport[];
|
|
79
|
+
/**
|
|
80
|
+
* Validate every planned image against exact destination scope without mutation.
|
|
81
|
+
* @param {ImageImportPreflightOptions} options - Verified source, plans, destination, and transport.
|
|
82
|
+
* @returns {Promise<ImageImportPreflightResult>} Dry-run evidence after every row passes.
|
|
83
|
+
*/
|
|
84
|
+
export declare function preflightImageImports(options: ImageImportPreflightOptions): Promise<ImageImportPreflightResult>;
|
|
85
|
+
/**
|
|
86
|
+
* Apply verified image plans sequentially with durable mutation evidence.
|
|
87
|
+
* @param {ImageImportApplyOptions} options - Verified source, destination, plans, and report path.
|
|
88
|
+
* @returns {Promise<ImageImportApplyResult>} Completed v2 evidence and durable report.
|
|
89
|
+
*/
|
|
90
|
+
export declare function applyImageImports(options: ImageImportApplyOptions): Promise<ImageImportApplyResult>;
|
|
91
|
+
export {};
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, open } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { IMAGE_IMPORT_IDENTITY_FIELDS, InvalidImageImportMapError, } from '../contracts/workspace-import.js';
|
|
5
|
+
import { CmsRequestError, getSelectedOperation, requestJson, } from '../transport/json-request.js';
|
|
6
|
+
import { readStableRegularFile, rewriteAtomic, } from './import-workspace.js';
|
|
7
|
+
import { getContent, getVariant, getWorkspace } from './read.js';
|
|
8
|
+
import { buildImageCreateMultipart, createImageContent, } from '../transport/multipart-image-create.js';
|
|
9
|
+
const IMAGE_TYPE = 'sfdc_cms__image';
|
|
10
|
+
const SEARCH_PAGE_SIZE = 250;
|
|
11
|
+
const SEARCH_PAGE_CAP = 1000;
|
|
12
|
+
const IMAGE_MIME_EXTENSIONS = {
|
|
13
|
+
'image/gif': ['gif'],
|
|
14
|
+
'image/jpeg': ['jpg', 'jpeg'],
|
|
15
|
+
'image/png': ['png'],
|
|
16
|
+
'image/webp': ['webp'],
|
|
17
|
+
};
|
|
18
|
+
const IMAGE_MIME_DEFAULT_EXTENSION = {
|
|
19
|
+
'image/gif': 'gif',
|
|
20
|
+
'image/jpeg': 'jpg',
|
|
21
|
+
'image/png': 'png',
|
|
22
|
+
'image/webp': 'webp',
|
|
23
|
+
};
|
|
24
|
+
export function normalizeImageMultipartFilename(fileName, mimeType) {
|
|
25
|
+
const allowed = IMAGE_MIME_EXTENSIONS[mimeType];
|
|
26
|
+
const defaultExtension = IMAGE_MIME_DEFAULT_EXTENSION[mimeType];
|
|
27
|
+
if (allowed === undefined || defaultExtension === undefined) {
|
|
28
|
+
throw new TypeError(`Image import MIME type is unsupported: ${mimeType}`);
|
|
29
|
+
}
|
|
30
|
+
const extension = path.extname(fileName).slice(1).toLowerCase();
|
|
31
|
+
if (extension.length === 0)
|
|
32
|
+
return `${fileName}.${defaultExtension}`;
|
|
33
|
+
if (!allowed.includes(extension)) {
|
|
34
|
+
throw new TypeError('Image import filename extension does not match MIME type');
|
|
35
|
+
}
|
|
36
|
+
return fileName;
|
|
37
|
+
}
|
|
38
|
+
function record(value) {
|
|
39
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
function fail(message) {
|
|
42
|
+
throw new InvalidImageImportMapError(message);
|
|
43
|
+
}
|
|
44
|
+
function exactKeys(value, expected, label) {
|
|
45
|
+
if (Object.keys(value).toSorted().join('\0') !== [...expected].toSorted().join('\0')) {
|
|
46
|
+
fail(`${label} must contain exactly ${expected.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function nonempty(value, label) {
|
|
50
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
51
|
+
fail(`${label} must be nonempty`);
|
|
52
|
+
}
|
|
53
|
+
function sourceValue(item, field) {
|
|
54
|
+
const value = item[field];
|
|
55
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
56
|
+
}
|
|
57
|
+
function assertCanonicalImageContentKey(value, label) {
|
|
58
|
+
if (!/^MC[A-Z2-7]{26}$/u.test(value)) {
|
|
59
|
+
fail(`${label} must match ^MC[A-Z2-7]{26}$`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function validateField(raw, field, item) {
|
|
63
|
+
if (!record(raw))
|
|
64
|
+
fail(`${field} strategy must be an object`);
|
|
65
|
+
exactKeys(raw, ['strategy', ...(raw.value === undefined ? [] : ['value'])], field);
|
|
66
|
+
const strategy = raw.strategy;
|
|
67
|
+
if (!['preserve', 'fresh', 'generated'].includes(strategy)) {
|
|
68
|
+
fail(`${field} strategy is unsupported`);
|
|
69
|
+
}
|
|
70
|
+
if (field === 'title' && strategy === 'generated')
|
|
71
|
+
fail('title does not support generated');
|
|
72
|
+
const source = sourceValue(item, field);
|
|
73
|
+
if (strategy === 'preserve') {
|
|
74
|
+
if (raw.value !== undefined)
|
|
75
|
+
fail(`${field} preserve must not provide a value`);
|
|
76
|
+
if (source === undefined)
|
|
77
|
+
fail(`${field} cannot be preserved because the source value is missing`);
|
|
78
|
+
if (field === 'contentKey')
|
|
79
|
+
assertCanonicalImageContentKey(source, 'contentKey preserve source');
|
|
80
|
+
return { strategy, source, submitted: source };
|
|
81
|
+
}
|
|
82
|
+
if (strategy === 'fresh') {
|
|
83
|
+
nonempty(raw.value, `${field} fresh value`);
|
|
84
|
+
if (field === 'contentKey')
|
|
85
|
+
assertCanonicalImageContentKey(raw.value, 'contentKey fresh value');
|
|
86
|
+
if (raw.value === source)
|
|
87
|
+
fail(`${field} fresh value must differ from the source`);
|
|
88
|
+
return {
|
|
89
|
+
strategy,
|
|
90
|
+
...(source === undefined ? {} : { source }),
|
|
91
|
+
submitted: raw.value,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (raw.value !== undefined)
|
|
95
|
+
fail(`${field} generated must omit value`);
|
|
96
|
+
return { strategy, ...(source === undefined ? {} : { source }) };
|
|
97
|
+
}
|
|
98
|
+
function stableIdentity(plan) {
|
|
99
|
+
return ['contentKey', 'apiName', 'urlName']
|
|
100
|
+
.map((field) => {
|
|
101
|
+
const value = plan.identities[field];
|
|
102
|
+
return value.strategy === 'generated' ? '' : (value.submitted ?? '');
|
|
103
|
+
})
|
|
104
|
+
.join('\0');
|
|
105
|
+
}
|
|
106
|
+
export function planImageImports(source, input) {
|
|
107
|
+
if (source.manifest.schemaVersion !== 2)
|
|
108
|
+
fail('Image import requires a strict manifest v2 package');
|
|
109
|
+
if (!Array.isArray(input) || input.length === 0)
|
|
110
|
+
fail('Image map must be a nonempty array');
|
|
111
|
+
const media = new Map((source.manifest.media ?? []).map((descriptor) => [descriptor.variantId, descriptor]));
|
|
112
|
+
const selected = new Set();
|
|
113
|
+
const targets = new Set();
|
|
114
|
+
const plans = [];
|
|
115
|
+
for (const [index, raw] of input.entries()) {
|
|
116
|
+
if (!record(raw))
|
|
117
|
+
fail(`Image map row ${index} must be an object`);
|
|
118
|
+
exactKeys(raw, ['source', ...IMAGE_IMPORT_IDENTITY_FIELDS], `Image map row ${index}`);
|
|
119
|
+
if (!record(raw.source))
|
|
120
|
+
fail(`Image map row ${index}.source must be an object`);
|
|
121
|
+
const selector = raw.source;
|
|
122
|
+
exactKeys(selector, [
|
|
123
|
+
'family',
|
|
124
|
+
'type',
|
|
125
|
+
'apiName',
|
|
126
|
+
...(selector.title === undefined ? [] : ['title']),
|
|
127
|
+
...(selector.workspaceId === undefined ? [] : ['workspaceId']),
|
|
128
|
+
...(selector.language === undefined ? [] : ['language']),
|
|
129
|
+
...(selector.serverId === undefined ? [] : ['serverId']),
|
|
130
|
+
...(selector.version === undefined ? [] : ['version']),
|
|
131
|
+
], `Image map row ${index}.source`);
|
|
132
|
+
if (selector.family !== 'cms' || selector.type !== 'image') {
|
|
133
|
+
fail(`Image map row ${index}.source must select cms/image`);
|
|
134
|
+
}
|
|
135
|
+
nonempty(selector.apiName, `Image map row ${index}.source.apiName`);
|
|
136
|
+
for (const key of ['title', 'workspaceId', 'language']) {
|
|
137
|
+
if (selector[key] !== undefined)
|
|
138
|
+
nonempty(selector[key], `Image map row ${index}.source.${key}`);
|
|
139
|
+
}
|
|
140
|
+
if (selector.serverId !== undefined)
|
|
141
|
+
nonempty(selector.serverId, `Image map row ${index}.source.serverId`);
|
|
142
|
+
if (selector.version !== undefined)
|
|
143
|
+
nonempty(selector.version, `Image map row ${index}.source.version`);
|
|
144
|
+
const selectionKey = `${selector.family}\0${selector.type}\0${selector.apiName}`;
|
|
145
|
+
if (selected.has(selectionKey))
|
|
146
|
+
fail(`Duplicate component source identity: ${selector.apiName}`);
|
|
147
|
+
const matches = source.items.filter((item) => item.contentType === 'sfdc_cms__image' && item.apiName === selector.apiName);
|
|
148
|
+
if (matches.length !== 1) {
|
|
149
|
+
fail(`Typed source API name must match exactly one manifest entry: ${selector.apiName}`);
|
|
150
|
+
}
|
|
151
|
+
const item = matches[0];
|
|
152
|
+
if (selector.serverId !== undefined && selector.serverId !== item.id) {
|
|
153
|
+
fail(`Source API name and server ID binding do not match: ${selector.apiName}`);
|
|
154
|
+
}
|
|
155
|
+
const descriptor = media.get(item.id);
|
|
156
|
+
if (descriptor === undefined || item.contentType !== 'sfdc_cms__image') {
|
|
157
|
+
fail(`Image map source is non-image or has no bound media: ${selector.apiName}`);
|
|
158
|
+
}
|
|
159
|
+
if (descriptor.contentKey !== item.contentKey)
|
|
160
|
+
fail('Image media descriptor contentKey is not bound to its item');
|
|
161
|
+
const identities = Object.fromEntries(IMAGE_IMPORT_IDENTITY_FIELDS.map((field) => [field, validateField(raw[field], field, item)]));
|
|
162
|
+
const plan = { source: item, media: descriptor, identities };
|
|
163
|
+
const identity = stableIdentity(plan);
|
|
164
|
+
if (targets.has(identity))
|
|
165
|
+
fail('Duplicate planned target stable identity');
|
|
166
|
+
selected.add(selectionKey);
|
|
167
|
+
targets.add(identity);
|
|
168
|
+
plans.push(plan);
|
|
169
|
+
}
|
|
170
|
+
return plans.toSorted((left, right) => `${left.source.apiName}\0${left.source.contentKey}\0${left.source.id}`.localeCompare(`${right.source.apiName}\0${right.source.contentKey}\0${right.source.id}`));
|
|
171
|
+
}
|
|
172
|
+
function destinationApiName(plan) {
|
|
173
|
+
const field = plan.identities.apiName;
|
|
174
|
+
if (field.strategy === 'generated' || field.submitted === undefined) {
|
|
175
|
+
throw new TypeError('Destination image API name must be preserved or explicitly fresh');
|
|
176
|
+
}
|
|
177
|
+
return field.submitted;
|
|
178
|
+
}
|
|
179
|
+
function destinationContentKey(plan) {
|
|
180
|
+
const field = plan.identities.contentKey;
|
|
181
|
+
return field.strategy === 'generated' ? undefined : field.submitted;
|
|
182
|
+
}
|
|
183
|
+
function isSalesforceMissingContentKey(error) {
|
|
184
|
+
return (error instanceof CmsRequestError &&
|
|
185
|
+
error.operationKey === 'content.get' &&
|
|
186
|
+
(error.status === 404 ||
|
|
187
|
+
((error.status === 400 || error.status === undefined) &&
|
|
188
|
+
error.errorEntryCount === 1 &&
|
|
189
|
+
error.errorCode === 'INVALID_ID_FIELD' &&
|
|
190
|
+
error.responseMessage === 'Provide a valid content key, ID, or FQN.')));
|
|
191
|
+
}
|
|
192
|
+
async function assertContentKeyAvailable(connection, contentKey, requestOptions) {
|
|
193
|
+
try {
|
|
194
|
+
await getContent(connection, contentKey, {}, requestOptions);
|
|
195
|
+
throw new TypeError(`Destination image content key already exists: ${contentKey}`);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (!isSalesforceMissingContentKey(error))
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function searchItems(value) {
|
|
203
|
+
if (!record(value) || !Array.isArray(value.items)) {
|
|
204
|
+
throw new TypeError('Destination image search must return an items array');
|
|
205
|
+
}
|
|
206
|
+
return value.items;
|
|
207
|
+
}
|
|
208
|
+
function searchCount(value) {
|
|
209
|
+
if (record(value)) {
|
|
210
|
+
for (const key of ['total', 'totalCount', 'count']) {
|
|
211
|
+
const count = value[key];
|
|
212
|
+
if (typeof count === 'number' && Number.isInteger(count) && count >= 0)
|
|
213
|
+
return count;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
throw new TypeError('Destination image search must return a nonnegative count');
|
|
217
|
+
}
|
|
218
|
+
function searchCandidate(value, workspaceId) {
|
|
219
|
+
if (!record(value) ||
|
|
220
|
+
value.type !== 'ManagedContentVariantSearchResultRepresentation' ||
|
|
221
|
+
typeof value.id !== 'string' ||
|
|
222
|
+
value.id.length === 0 ||
|
|
223
|
+
value.managedContentSpaceId !== workspaceId) {
|
|
224
|
+
throw new TypeError('Destination image search returned ambiguous workspace/type evidence');
|
|
225
|
+
}
|
|
226
|
+
return { apiName: '', id: value.id, workspaceId };
|
|
227
|
+
}
|
|
228
|
+
function detailType(value) {
|
|
229
|
+
return record(value.contentType) ? value.contentType.fullyQualifiedName : value.contentType;
|
|
230
|
+
}
|
|
231
|
+
async function exactApiNameMatches(connection, workspaceId, apiName, requestOptions) {
|
|
232
|
+
const candidates = new Map();
|
|
233
|
+
const pageFingerprints = new Set();
|
|
234
|
+
let expectedCount;
|
|
235
|
+
for (let page = 0; page < SEARCH_PAGE_CAP; page += 1) {
|
|
236
|
+
const response = await requestJson(connection, getSelectedOperation('workspace.variant.search'), {
|
|
237
|
+
query: {
|
|
238
|
+
contentSpaceOrFolderIds: [workspaceId],
|
|
239
|
+
contentTypeFQN: IMAGE_TYPE,
|
|
240
|
+
languages: ['All'],
|
|
241
|
+
page,
|
|
242
|
+
pageSize: SEARCH_PAGE_SIZE,
|
|
243
|
+
queryTerm: apiName,
|
|
244
|
+
},
|
|
245
|
+
}, requestOptions);
|
|
246
|
+
const items = searchItems(response.data);
|
|
247
|
+
const count = searchCount(response.data);
|
|
248
|
+
if (expectedCount === undefined)
|
|
249
|
+
expectedCount = count;
|
|
250
|
+
else if (count !== expectedCount) {
|
|
251
|
+
throw new TypeError('Destination image search count changed during preflight');
|
|
252
|
+
}
|
|
253
|
+
const fingerprint = JSON.stringify(items);
|
|
254
|
+
if (pageFingerprints.has(fingerprint)) {
|
|
255
|
+
throw new TypeError('Destination image search repeated page content');
|
|
256
|
+
}
|
|
257
|
+
pageFingerprints.add(fingerprint);
|
|
258
|
+
for (const item of items) {
|
|
259
|
+
const candidate = searchCandidate(item, workspaceId);
|
|
260
|
+
if (candidates.has(candidate.id)) {
|
|
261
|
+
throw new TypeError('Destination image search returned a duplicate variant identity');
|
|
262
|
+
}
|
|
263
|
+
candidates.set(candidate.id, candidate);
|
|
264
|
+
}
|
|
265
|
+
if (items.length === 0 || candidates.size >= count)
|
|
266
|
+
break;
|
|
267
|
+
if (page === SEARCH_PAGE_CAP - 1) {
|
|
268
|
+
throw new TypeError('Destination image search exceeded the page safety limit');
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (expectedCount === undefined || candidates.size !== expectedCount) {
|
|
272
|
+
throw new TypeError('Destination image search did not satisfy its advertised count');
|
|
273
|
+
}
|
|
274
|
+
const matches = [];
|
|
275
|
+
for (const candidate of candidates.values()) {
|
|
276
|
+
const detail = await getVariant(connection, candidate.id, requestOptions);
|
|
277
|
+
const contentSpace = detail.contentSpace;
|
|
278
|
+
if (!record(contentSpace) ||
|
|
279
|
+
contentSpace.id !== workspaceId ||
|
|
280
|
+
detailType(detail) !== IMAGE_TYPE ||
|
|
281
|
+
typeof detail.apiName !== 'string') {
|
|
282
|
+
throw new TypeError('Destination image detail did not preserve workspace/type/API-name scope');
|
|
283
|
+
}
|
|
284
|
+
if (detail.apiName === apiName)
|
|
285
|
+
matches.push({ ...candidate, apiName });
|
|
286
|
+
}
|
|
287
|
+
return matches;
|
|
288
|
+
}
|
|
289
|
+
function sourceIdentity(plan) {
|
|
290
|
+
return {
|
|
291
|
+
family: 'cms',
|
|
292
|
+
type: 'image',
|
|
293
|
+
apiName: plan.source.apiName,
|
|
294
|
+
title: plan.source.title,
|
|
295
|
+
workspaceId: plan.source.contentSpace.id,
|
|
296
|
+
language: plan.source.language,
|
|
297
|
+
serverId: plan.source.id,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
function plannedAsset(plan, destinationWorkspaceId) {
|
|
301
|
+
const apiName = destinationApiName(plan);
|
|
302
|
+
return {
|
|
303
|
+
source: sourceIdentity(plan),
|
|
304
|
+
resolution: {
|
|
305
|
+
method: 'explicit-map',
|
|
306
|
+
target: {
|
|
307
|
+
family: 'cms',
|
|
308
|
+
type: 'image',
|
|
309
|
+
apiName,
|
|
310
|
+
workspaceId: destinationWorkspaceId,
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
identities: plan.identities,
|
|
314
|
+
binary: {
|
|
315
|
+
path: plan.media.path,
|
|
316
|
+
sha256: plan.media.sha256,
|
|
317
|
+
md5: plan.media.md5,
|
|
318
|
+
bytes: plan.media.bytes,
|
|
319
|
+
mimeType: plan.media.mimeType,
|
|
320
|
+
},
|
|
321
|
+
metadataReadback: 'not-attempted',
|
|
322
|
+
byteProof: 'unavailable',
|
|
323
|
+
operationStatus: 'planned',
|
|
324
|
+
reportStatus: 'not-created',
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Validate every planned image against exact destination scope without mutation.
|
|
329
|
+
* @param {ImageImportPreflightOptions} options - Verified source, plans, destination, and transport.
|
|
330
|
+
* @returns {Promise<ImageImportPreflightResult>} Dry-run evidence after every row passes.
|
|
331
|
+
*/
|
|
332
|
+
export async function preflightImageImports(options) {
|
|
333
|
+
nonempty(options.destinationOrgId, 'destinationOrgId');
|
|
334
|
+
nonempty(options.destinationWorkspaceId, 'destinationWorkspaceId');
|
|
335
|
+
if (options.plans.length === 0)
|
|
336
|
+
throw new TypeError('Image preflight requires planned images');
|
|
337
|
+
if (!options.source.integrity.verified)
|
|
338
|
+
throw new TypeError('Source integrity must be verified');
|
|
339
|
+
const workspace = await getWorkspace(options.connection, options.destinationWorkspaceId, options.requestOptions);
|
|
340
|
+
if (workspace.id !== options.destinationWorkspaceId) {
|
|
341
|
+
throw new TypeError('Destination workspace readback did not match the requested workspace');
|
|
342
|
+
}
|
|
343
|
+
const checks = await Promise.all(options.plans.map(async (plan) => {
|
|
344
|
+
const apiName = destinationApiName(plan);
|
|
345
|
+
const matches = await exactApiNameMatches(options.connection, options.destinationWorkspaceId, apiName, options.requestOptions ?? {});
|
|
346
|
+
if (matches.length > 1) {
|
|
347
|
+
throw new TypeError(`Destination image API name is ambiguous: ${apiName}`);
|
|
348
|
+
}
|
|
349
|
+
if (matches.length === 1) {
|
|
350
|
+
throw new TypeError(`Destination image API name already exists: ${apiName}`);
|
|
351
|
+
}
|
|
352
|
+
const contentKey = destinationContentKey(plan);
|
|
353
|
+
if (contentKey !== undefined) {
|
|
354
|
+
await assertContentKeyAvailable(options.connection, contentKey, options.requestOptions ?? {});
|
|
355
|
+
}
|
|
356
|
+
return plannedAsset(plan, options.destinationWorkspaceId);
|
|
357
|
+
}));
|
|
358
|
+
return {
|
|
359
|
+
contractResult: {
|
|
360
|
+
sourcePackage: {
|
|
361
|
+
manifestSha256: options.source.manifestSha256,
|
|
362
|
+
workspaceId: options.source.manifest.workspaceId,
|
|
363
|
+
manifestVersion: 2,
|
|
364
|
+
},
|
|
365
|
+
target: {
|
|
366
|
+
orgId: options.destinationOrgId,
|
|
367
|
+
workspaceId: options.destinationWorkspaceId,
|
|
368
|
+
},
|
|
369
|
+
status: 'planned',
|
|
370
|
+
assets: checks.toSorted((left, right) => `${left.source.family}\0${left.source.type}\0${left.source.apiName}`.localeCompare(`${right.source.family}\0${right.source.type}\0${right.source.apiName}`)),
|
|
371
|
+
},
|
|
372
|
+
dryRun: true,
|
|
373
|
+
plans: options.plans,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
async function assertImageIdentityAvailable(connection, workspaceId, plan, requestOptions) {
|
|
377
|
+
const apiName = destinationApiName(plan);
|
|
378
|
+
const matches = await exactApiNameMatches(connection, workspaceId, apiName, requestOptions);
|
|
379
|
+
if (matches.length > 0) {
|
|
380
|
+
throw new TypeError(`Destination image API name already exists: ${apiName}`);
|
|
381
|
+
}
|
|
382
|
+
const contentKey = destinationContentKey(plan);
|
|
383
|
+
if (contentKey !== undefined) {
|
|
384
|
+
await assertContentKeyAvailable(connection, contentKey, requestOptions);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function createInput(plan, workspaceId) {
|
|
388
|
+
const input = {
|
|
389
|
+
apiName: destinationApiName(plan),
|
|
390
|
+
contentSpaceOrFolderId: workspaceId,
|
|
391
|
+
title: plan.identities.title.submitted,
|
|
392
|
+
};
|
|
393
|
+
const contentKey = destinationContentKey(plan);
|
|
394
|
+
if (contentKey !== undefined)
|
|
395
|
+
input.contentKey = contentKey;
|
|
396
|
+
if (plan.identities.urlName.submitted !== undefined) {
|
|
397
|
+
input.urlName = plan.identities.urlName.submitted;
|
|
398
|
+
}
|
|
399
|
+
return input;
|
|
400
|
+
}
|
|
401
|
+
function mutationOperation(report, plan, requestSha256) {
|
|
402
|
+
const requestIdentity = `create-image\u0000${destinationApiName(plan)}\u0000${requestSha256}`;
|
|
403
|
+
return {
|
|
404
|
+
operationId: createHash('sha256')
|
|
405
|
+
.update(`${report.runId}\u0000${requestIdentity}`)
|
|
406
|
+
.digest('hex'),
|
|
407
|
+
requestIdentity,
|
|
408
|
+
requestSha256,
|
|
409
|
+
sourceApiName: plan.source.apiName,
|
|
410
|
+
destinationApiName: destinationApiName(plan),
|
|
411
|
+
destinationOrgId: report.destinationOrgId,
|
|
412
|
+
destinationWorkspaceId: report.destinationWorkspaceId,
|
|
413
|
+
runId: report.runId,
|
|
414
|
+
state: 'pending',
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
async function writeExclusive(file, value) {
|
|
418
|
+
const handle = await open(file, 'wx');
|
|
419
|
+
try {
|
|
420
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
await handle.close();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function returnedField(detail, field) {
|
|
427
|
+
const value = detail[field];
|
|
428
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
429
|
+
throw new TypeError(`Image readback is missing ${field}`);
|
|
430
|
+
}
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
function verifyImageReadback(detail, input, binding) {
|
|
434
|
+
const contentSpace = detail.contentSpace;
|
|
435
|
+
const status = detail.status;
|
|
436
|
+
const returned = {
|
|
437
|
+
apiName: returnedField(detail, 'apiName'),
|
|
438
|
+
contentKey: returnedField(detail, 'contentKey'),
|
|
439
|
+
title: returnedField(detail, 'title'),
|
|
440
|
+
urlName: returnedField(detail, 'urlName'),
|
|
441
|
+
};
|
|
442
|
+
if (!record(contentSpace) ||
|
|
443
|
+
contentSpace.id !== input.contentSpaceOrFolderId ||
|
|
444
|
+
detailType(detail) !== IMAGE_TYPE ||
|
|
445
|
+
detail.managedContentVariantId !== binding.managedContentVariantId ||
|
|
446
|
+
returned.apiName !== input.apiName ||
|
|
447
|
+
returned.contentKey !== binding.contentKey ||
|
|
448
|
+
returned.title !== input.title ||
|
|
449
|
+
(input.contentKey !== undefined && returned.contentKey !== input.contentKey) ||
|
|
450
|
+
(input.urlName !== undefined && returned.urlName !== input.urlName) ||
|
|
451
|
+
detail.isPublished !== false ||
|
|
452
|
+
!record(status) ||
|
|
453
|
+
status.status !== 'Draft') {
|
|
454
|
+
throw new TypeError('Image authoring metadata readback identity or Draft verification failed');
|
|
455
|
+
}
|
|
456
|
+
return returned;
|
|
457
|
+
}
|
|
458
|
+
function completedAsset(planned, returned, binding, requestSha256) {
|
|
459
|
+
return {
|
|
460
|
+
...planned,
|
|
461
|
+
resolution: {
|
|
462
|
+
...planned.resolution,
|
|
463
|
+
target: {
|
|
464
|
+
...planned.resolution.target,
|
|
465
|
+
title: returned.title,
|
|
466
|
+
serverId: binding.managedContentVariantId,
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
mutation: {
|
|
470
|
+
requestSha256,
|
|
471
|
+
contentId: binding.managedContentId,
|
|
472
|
+
variantId: binding.managedContentVariantId,
|
|
473
|
+
},
|
|
474
|
+
identities: Object.fromEntries(IMAGE_IMPORT_IDENTITY_FIELDS.map((field) => [
|
|
475
|
+
field,
|
|
476
|
+
{ ...planned.identities[field], returned: returned[field] },
|
|
477
|
+
])),
|
|
478
|
+
metadataReadback: 'passed',
|
|
479
|
+
byteProof: 'unavailable',
|
|
480
|
+
operationStatus: 'succeeded',
|
|
481
|
+
reportStatus: 'recorded',
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Apply verified image plans sequentially with durable mutation evidence.
|
|
486
|
+
* @param {ImageImportApplyOptions} options - Verified source, destination, plans, and report path.
|
|
487
|
+
* @returns {Promise<ImageImportApplyResult>} Completed v2 evidence and durable report.
|
|
488
|
+
*/
|
|
489
|
+
export async function applyImageImports(options) {
|
|
490
|
+
const preflight = await preflightImageImports(options);
|
|
491
|
+
nonempty(options.reportDirectory, 'reportDirectory');
|
|
492
|
+
await mkdir(path.resolve(options.reportDirectory));
|
|
493
|
+
const reportFile = path.join(path.resolve(options.reportDirectory), 'workspace-import-run.json');
|
|
494
|
+
const report = {
|
|
495
|
+
runId: randomUUID(),
|
|
496
|
+
sourceDirectory: options.source.sourceDirectory,
|
|
497
|
+
sourceManifestSha256: options.source.manifestSha256,
|
|
498
|
+
destinationOrgId: options.destinationOrgId,
|
|
499
|
+
destinationWorkspaceId: options.destinationWorkspaceId,
|
|
500
|
+
operations: [],
|
|
501
|
+
state: 'applying',
|
|
502
|
+
};
|
|
503
|
+
await writeExclusive(reportFile, report);
|
|
504
|
+
const rewrite = options.reportPersistence?.rewrite ?? rewriteAtomic;
|
|
505
|
+
const completed = [];
|
|
506
|
+
try {
|
|
507
|
+
for (const plan of options.plans) {
|
|
508
|
+
await assertImageIdentityAvailable(options.connection, options.destinationWorkspaceId, plan, options.requestOptions ?? {});
|
|
509
|
+
const mediaFile = path.join(options.source.sourceDirectory, ...plan.media.path.split('/'));
|
|
510
|
+
const bytes = await readStableRegularFile(mediaFile, `Image media ${plan.media.path}`);
|
|
511
|
+
if (bytes.byteLength !== plan.media.bytes ||
|
|
512
|
+
createHash('sha256').update(bytes).digest('hex') !== plan.media.sha256 ||
|
|
513
|
+
createHash('md5').update(bytes).digest('hex') !== plan.media.md5) {
|
|
514
|
+
throw new TypeError(`Image media evidence changed after package verification: ${plan.media.path}`);
|
|
515
|
+
}
|
|
516
|
+
const input = createInput(plan, options.destinationWorkspaceId);
|
|
517
|
+
const filename = normalizeImageMultipartFilename(plan.media.fileName, plan.media.mimeType);
|
|
518
|
+
const boundary = `sf-plugin-cms-${randomBytes(24).toString('hex')}`;
|
|
519
|
+
const multipart = buildImageCreateMultipart(input, filename, bytes, {
|
|
520
|
+
boundaryFactory: () => boundary,
|
|
521
|
+
});
|
|
522
|
+
const requestSha256 = createHash('sha256').update(multipart.body).digest('hex');
|
|
523
|
+
const operation = mutationOperation(report, plan, requestSha256);
|
|
524
|
+
report.operations.push(operation);
|
|
525
|
+
await rewrite(reportFile, report);
|
|
526
|
+
let binding;
|
|
527
|
+
try {
|
|
528
|
+
binding = await createImageContent(options.connection, input, filename, bytes, {
|
|
529
|
+
...options.createOptions,
|
|
530
|
+
boundaryFactory: () => boundary,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
operation.error = error instanceof Error ? error.message : String(error);
|
|
535
|
+
report.state = 'ownership-uncertain';
|
|
536
|
+
await rewrite(reportFile, report);
|
|
537
|
+
throw error;
|
|
538
|
+
}
|
|
539
|
+
operation.result = {
|
|
540
|
+
contentKey: binding.contentKey,
|
|
541
|
+
contentId: binding.managedContentId,
|
|
542
|
+
variantId: binding.managedContentVariantId,
|
|
543
|
+
};
|
|
544
|
+
report.state = 'ownership-uncertain';
|
|
545
|
+
await rewrite(reportFile, report);
|
|
546
|
+
const detail = await getVariant(options.connection, binding.managedContentVariantId, options.requestOptions);
|
|
547
|
+
const returned = verifyImageReadback(detail, input, binding);
|
|
548
|
+
operation.state = 'succeeded';
|
|
549
|
+
report.state = 'applying';
|
|
550
|
+
await rewrite(reportFile, report);
|
|
551
|
+
const planned = preflight.contractResult.assets.find(({ source }) => source.apiName === plan.source.apiName);
|
|
552
|
+
completed.push(completedAsset(planned, returned, binding, requestSha256));
|
|
553
|
+
}
|
|
554
|
+
report.state = 'completed';
|
|
555
|
+
await rewrite(reportFile, report);
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
if (report.state !== 'ownership-uncertain')
|
|
559
|
+
report.state = 'failed';
|
|
560
|
+
try {
|
|
561
|
+
await rewrite(reportFile, report);
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
// Preserve the primary error; the last durable report is the reconciliation source of truth.
|
|
565
|
+
}
|
|
566
|
+
throw error;
|
|
567
|
+
}
|
|
568
|
+
return {
|
|
569
|
+
contractResult: {
|
|
570
|
+
...preflight.contractResult,
|
|
571
|
+
status: 'completed',
|
|
572
|
+
assets: completed.toSorted((left, right) => left.source.apiName.localeCompare(right.source.apiName)),
|
|
573
|
+
},
|
|
574
|
+
dryRun: false,
|
|
575
|
+
report,
|
|
576
|
+
reportFile,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
//# sourceMappingURL=image-import.js.map
|