Skip to content

Commit 2f5822a

Browse files
committed
bug fixing import zip conventions and adding multi-select
1 parent 46f3b42 commit 2f5822a

5 files changed

Lines changed: 122 additions & 96 deletions

File tree

packages/b2c-tooling-sdk/src/operations/jobs/site-archive.ts

Lines changed: 40 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,14 @@ export interface SiteArchiveImportResult {
5050
* - A Buffer containing zip data
5151
* - A filename already on the instance (in Impex/src/instance/)
5252
*
53+
* **Buffer handling:** When passing a Buffer, the `archiveName` option controls
54+
* the contract:
55+
* - **Without `archiveName`:** The buffer should contain archive entries without
56+
* a root directory (e.g. `libraries/mylib/library.xml`). The SDK generates
57+
* an archive name and wraps the contents under it.
58+
* - **With `archiveName`:** The buffer must already be correctly structured with
59+
* `archiveName/` as the top-level directory. It is uploaded as-is.
60+
*
5361
* @param instance - B2C instance to import to
5462
* @param target - Source to import (directory path, zip file path, Buffer, or remote filename)
5563
* @param options - Import options
@@ -64,9 +72,17 @@ export interface SiteArchiveImportResult {
6472
* // Import from a zip file
6573
* const result = await siteArchiveImport(instance, './export.zip');
6674
*
67-
* // Import from a buffer
68-
* const zipBuffer = await fs.promises.readFile('./export.zip');
69-
* const result = await siteArchiveImport(instance, zipBuffer, {
75+
* // Import from a buffer (SDK wraps contents automatically)
76+
* const zip = new JSZip();
77+
* zip.file('libraries/mylib/library.xml', xmlContent);
78+
* const buffer = await zip.generateAsync({type: 'nodebuffer'});
79+
* const result = await siteArchiveImport(instance, buffer);
80+
*
81+
* // Import from a buffer with explicit archive name (caller owns structure)
82+
* const zip = new JSZip();
83+
* zip.file('my-import/libraries/mylib/library.xml', xmlContent);
84+
* const buffer = await zip.generateAsync({type: 'nodebuffer'});
85+
* const result = await siteArchiveImport(instance, buffer, {
7086
* archiveName: 'my-import'
7187
* });
7288
*
@@ -94,13 +110,20 @@ export async function siteArchiveImport(
94110
zipFilename = target.remoteFilename;
95111
needsUpload = false;
96112
} else if (Buffer.isBuffer(target)) {
97-
// Buffer - use provided archive name
98-
if (!archiveName) {
99-
throw new Error('archiveName is required when importing from a Buffer');
113+
if (archiveName) {
114+
// Caller provides name — buffer must already contain the correct
115+
// top-level directory structure (archiveName/...).
116+
const baseName = archiveName.endsWith('.zip') ? archiveName.slice(0, -4) : archiveName;
117+
zipFilename = `${baseName}.zip`;
118+
archiveContent = target;
119+
} else {
120+
// No name — SDK generates one and wraps the buffer contents under it.
121+
// The buffer should contain archive entries without a root directory
122+
// (e.g. libraries/mylib/library.xml, sites/RefArch/site.xml).
123+
const archiveDirName = `import-${Date.now()}`;
124+
zipFilename = `${archiveDirName}.zip`;
125+
archiveContent = await wrapArchiveContents(target, archiveDirName, logger);
100126
}
101-
const baseName = archiveName.endsWith('.zip') ? archiveName.slice(0, -4) : archiveName;
102-
zipFilename = `${baseName}.zip`;
103-
archiveContent = await ensureArchiveStructure(target, baseName, logger);
104127
} else {
105128
// File path - check if directory or zip file
106129
const targetPath = target as string;
@@ -238,57 +261,28 @@ async function addDirectoryToZip(zipFolder: JSZip, dirPath: string): Promise<voi
238261
}
239262

240263
/**
241-
* Ensures a zip buffer has the correct top-level directory structure required
242-
* by B2C Commerce site archive import. The archive must contain a single
243-
* top-level directory matching the archive name.
264+
* Wraps the contents of a zip buffer under a new top-level directory.
244265
*
245-
* If the zip is already correctly structured, the original buffer is returned.
246-
* Otherwise, the contents are re-wrapped under the expected directory name.
266+
* The input buffer should contain archive entries without a root directory
267+
* (e.g. `libraries/mylib/library.xml`). The output will have all entries
268+
* nested under `archiveDirName/` (e.g. `archiveDirName/libraries/mylib/library.xml`).
247269
*/
248-
async function ensureArchiveStructure(
270+
async function wrapArchiveContents(
249271
buffer: Buffer,
250272
archiveDirName: string,
251273
logger: ReturnType<typeof getLogger>,
252274
): Promise<Buffer> {
253-
let zip: JSZip;
254-
try {
255-
zip = await JSZip.loadAsync(buffer);
256-
} catch {
257-
// If we can't parse the zip, pass it through as-is
258-
logger.debug('Could not parse zip buffer for structure check; passing through as-is');
259-
return buffer;
260-
}
261-
262-
// Determine the unique top-level directory names
263-
const topLevelEntries = new Set<string>();
264-
for (const filePath of Object.keys(zip.files)) {
265-
const topLevel = filePath.split('/')[0];
266-
topLevelEntries.add(topLevel);
267-
}
268-
269-
if (topLevelEntries.size === 1 && topLevelEntries.has(archiveDirName)) {
270-
return buffer; // Already correctly structured
271-
}
272-
273-
// Re-wrap all entries under archiveDirName/
274-
logger.debug(
275-
{archiveDirName, topLevelEntries: [...topLevelEntries]},
276-
`Re-wrapping archive contents under ${archiveDirName}/`,
277-
);
275+
const zip = await JSZip.loadAsync(buffer);
278276

279-
// When a single top-level directory exists with a different name, strip it
280-
// to avoid nesting (e.g. newRoot/oldRoot/...).
281-
const stripPrefix = topLevelEntries.size === 1 ? [...topLevelEntries][0] + '/' : undefined;
277+
logger.debug({archiveDirName}, `Wrapping archive contents under ${archiveDirName}/`);
282278

283279
const newZip = new JSZip();
284280
const rootFolder = newZip.folder(archiveDirName)!;
285281

286282
for (const [filePath, entry] of Object.entries(zip.files)) {
287283
if (!entry.dir) {
288284
const content = await entry.async('nodebuffer');
289-
const adjustedPath =
290-
stripPrefix && filePath.startsWith(stripPrefix) ? filePath.slice(stripPrefix.length) : filePath;
291-
rootFolder.file(adjustedPath, content);
285+
rootFolder.file(filePath, content);
292286
}
293287
}
294288

packages/b2c-tooling-sdk/test/operations/jobs/site-archive.test.ts

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,25 @@ describe('operations/jobs/site-archive', () => {
166166
expect(uploadedZip).to.not.be.null;
167167
});
168168

169-
it('should import from a Buffer', async () => {
170-
const zipBuffer = Buffer.from('PK\x03\x04test-data');
169+
it('should import from a Buffer with archiveName (caller owns structure)', async () => {
170+
// When archiveName is provided, the buffer is used as-is
171+
const srcZip = new JSZip();
172+
srcZip.file('buffer-import/libraries/mylib/library.xml', '<library/>');
173+
const zipBuffer = await srcZip.generateAsync({type: 'nodebuffer'});
174+
175+
let uploadedZip: Buffer | null = null;
171176

172177
server.use(
173-
http.all(`${WEBDAV_BASE}/*`, async () => {
174-
return new HttpResponse(null, {status: 201});
178+
http.all(`${WEBDAV_BASE}/*`, async ({request}) => {
179+
const url = new URL(request.url);
180+
if (request.method === 'PUT' && url.pathname.includes('Impex/src/instance/')) {
181+
uploadedZip = Buffer.from(await request.arrayBuffer());
182+
return new HttpResponse(null, {status: 201});
183+
}
184+
if (request.method === 'DELETE') {
185+
return new HttpResponse(null, {status: 204});
186+
}
187+
return new HttpResponse(null, {status: 404});
175188
}),
176189
http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => {
177190
return HttpResponse.json({
@@ -196,7 +209,12 @@ describe('operations/jobs/site-archive', () => {
196209
});
197210

198211
expect(result.execution.id).to.equal('exec-3');
199-
expect(result.archiveFilename).to.include('buffer-import');
212+
expect(result.archiveFilename).to.equal('buffer-import.zip');
213+
214+
// Buffer should be passed through as-is (no re-wrapping)
215+
const resultZip = await JSZip.loadAsync(uploadedZip!);
216+
const paths = Object.keys(resultZip.files).filter((p) => !resultZip.files[p].dir);
217+
expect(paths).to.include('buffer-import/libraries/mylib/library.xml');
200218
});
201219

202220
it('should import from remote filename', async () => {
@@ -269,11 +287,10 @@ describe('operations/jobs/site-archive', () => {
269287
expect(deleteRequested).to.be.false;
270288
});
271289

272-
it('should strip single existing top-level root when re-wrapping archive', async () => {
273-
// Create a zip with a different top-level directory name
290+
it('should auto-wrap buffer contents when archiveName is omitted', async () => {
291+
// Create a zip without a root directory (like the content FS provider does)
274292
const srcZip = new JSZip();
275-
srcZip.file('oldRoot/meta/system-objecttype-extensions.xml', '<metadata/>');
276-
srcZip.file('oldRoot/sites/RefArch/site.xml', '<site/>');
293+
srcZip.file('libraries/mylib/library.xml', '<library/>');
277294
const zipBuffer = await srcZip.generateAsync({type: 'nodebuffer'});
278295

279296
let uploadedZip: Buffer | null = null;
@@ -292,48 +309,34 @@ describe('operations/jobs/site-archive', () => {
292309
}),
293310
http.post(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions`, () => {
294311
return HttpResponse.json({
295-
id: 'exec-rewrap',
312+
id: 'exec-wrap',
296313
execution_status: 'finished',
297314
exit_status: {code: 'OK'},
298315
});
299316
}),
300-
http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-rewrap`, () => {
317+
http.get(`${OCAPI_BASE}/jobs/sfcc-site-archive-import/executions/exec-wrap`, () => {
301318
return HttpResponse.json({
302-
id: 'exec-rewrap',
319+
id: 'exec-wrap',
303320
execution_status: 'finished',
304321
exit_status: {code: 'OK'},
305322
is_log_file_existing: false,
306323
});
307324
}),
308325
);
309326

310-
await siteArchiveImport(mockInstance, zipBuffer, {
311-
archiveName: 'my-import',
327+
const result = await siteArchiveImport(mockInstance, zipBuffer, {
312328
waitOptions: FAST_WAIT_OPTIONS,
313329
});
314330

331+
// SDK should auto-generate an import-{timestamp} archive name
332+
expect(result.archiveFilename).to.match(/^import-\d+\.zip$/);
315333
expect(uploadedZip).to.not.be.null;
316334

317-
// Verify the uploaded archive has the correct structure:
318-
// my-import/meta/... and my-import/sites/... (not my-import/oldRoot/...)
335+
// Contents must be wrapped under the generated root directory
319336
const resultZip = await JSZip.loadAsync(uploadedZip!);
320337
const paths = Object.keys(resultZip.files).filter((p) => !resultZip.files[p].dir);
321-
expect(paths).to.include('my-import/meta/system-objecttype-extensions.xml');
322-
expect(paths).to.include('my-import/sites/RefArch/site.xml');
323-
// Ensure the old root was stripped
324-
const hasOldRoot = paths.some((p) => p.includes('oldRoot'));
325-
expect(hasOldRoot).to.be.false;
326-
});
327-
328-
it('should throw error when archiveName is missing for Buffer', async () => {
329-
const zipBuffer = Buffer.from('PK\x03\x04test-data');
330-
331-
try {
332-
await siteArchiveImport(mockInstance, zipBuffer);
333-
expect.fail('Should have thrown error');
334-
} catch (error: any) {
335-
expect(error.message).to.include('archiveName is required');
336-
}
338+
const archiveRoot = result.archiveFilename.replace(/\.zip$/, '');
339+
expect(paths).to.include(`${archiveRoot}/libraries/mylib/library.xml`);
337340
});
338341

339342
it('should throw JobExecutionError when import fails', async () => {

packages/b2c-vs-extension/src/content-tree/content-commands.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export function registerContentCommands(
6969
});
7070

7171
async function runExport(
72-
node: ContentTreeItem,
72+
nodes: ContentTreeItem[],
7373
{offline, assetsOnly}: {offline: boolean; assetsOnly: boolean},
7474
): Promise<void> {
7575
const instance = configProvider.getInstance();
@@ -78,6 +78,16 @@ export function registerContentCommands(
7878
return;
7979
}
8080

81+
// All selected nodes must be from the same library
82+
const libraryId = nodes[0].libraryId;
83+
const isSiteLibrary = nodes[0].isSiteLibrary;
84+
if (nodes.some((n) => n.libraryId !== libraryId)) {
85+
vscode.window.showErrorMessage('Cannot export content from different libraries at the same time.');
86+
return;
87+
}
88+
89+
const contentIds = nodes.map((n) => n.contentId);
90+
8191
const dialogTitle = assetsOnly ? 'Select directory for static assets' : 'Select export directory';
8292
const folders = await vscode.window.showOpenDialog({
8393
title: dialogTitle,
@@ -90,15 +100,16 @@ export function registerContentCommands(
90100

91101
const outputPath = folders[0].fsPath;
92102
const label = assetsOnly ? 'static assets for' : offline ? '(without assets)' : '';
93-
const progressTitle = `Exporting ${label ? `${label} ` : ''}${node.contentId}...`;
103+
const itemLabel = contentIds.length === 1 ? contentIds[0] : `${contentIds.length} items`;
104+
const progressTitle = `Exporting ${label ? `${label} ` : ''}${itemLabel}...`;
94105

95106
let result;
96107
try {
97108
result = await vscode.window.withProgress(
98109
{location: vscode.ProgressLocation.Notification, title: progressTitle, cancellable: false},
99110
async (progress) => {
100-
return exportContent(instance, [node.contentId], node.libraryId, outputPath, {
101-
isSiteLibrary: node.isSiteLibrary,
111+
return exportContent(instance, contentIds, libraryId, outputPath, {
112+
isSiteLibrary,
102113
offline,
103114
onAssetProgress: (_asset, index, total) => {
104115
progress.report({
@@ -118,7 +129,7 @@ export function registerContentCommands(
118129
let msg: string;
119130
if (assetsOnly) {
120131
if (result.downloadedAssets.length === 0) {
121-
vscode.window.showInformationMessage('No static assets found for this content item.');
132+
vscode.window.showInformationMessage('No static assets found for the selected content.');
122133
return;
123134
}
124135
msg = `Downloaded ${result.downloadedAssets.length} static asset(s)`;
@@ -143,23 +154,32 @@ export function registerContentCommands(
143154
}
144155
}
145156

146-
const exportCmd = vscode.commands.registerCommand('b2c-dx.content.export', async (node: ContentTreeItem) => {
147-
if (!node) return;
148-
await runExport(node, {offline: false, assetsOnly: false});
149-
});
157+
const exportCmd = vscode.commands.registerCommand(
158+
'b2c-dx.content.export',
159+
async (node: ContentTreeItem, selectedNodes?: ContentTreeItem[]) => {
160+
const nodes = selectedNodes?.length ? selectedNodes : node ? [node] : [];
161+
if (!nodes.length) return;
162+
await runExport(nodes, {offline: false, assetsOnly: false});
163+
},
164+
);
150165

151166
const exportNoAssets = vscode.commands.registerCommand(
152167
'b2c-dx.content.exportNoAssets',
153-
async (node: ContentTreeItem) => {
154-
if (!node) return;
155-
await runExport(node, {offline: true, assetsOnly: false});
168+
async (node: ContentTreeItem, selectedNodes?: ContentTreeItem[]) => {
169+
const nodes = selectedNodes?.length ? selectedNodes : node ? [node] : [];
170+
if (!nodes.length) return;
171+
await runExport(nodes, {offline: true, assetsOnly: false});
156172
},
157173
);
158174

159-
const exportAssets = vscode.commands.registerCommand('b2c-dx.content.exportAssets', async (node: ContentTreeItem) => {
160-
if (!node) return;
161-
await runExport(node, {offline: false, assetsOnly: true});
162-
});
175+
const exportAssets = vscode.commands.registerCommand(
176+
'b2c-dx.content.exportAssets',
177+
async (node: ContentTreeItem, selectedNodes?: ContentTreeItem[]) => {
178+
const nodes = selectedNodes?.length ? selectedNodes : node ? [node] : [];
179+
if (!nodes.length) return;
180+
await runExport(nodes, {offline: false, assetsOnly: true});
181+
},
182+
);
163183

164184
const filter = vscode.commands.registerCommand('b2c-dx.content.filter', async () => {
165185
const current = treeProvider.getFilter();

packages/b2c-vs-extension/src/content-tree/content-fs-provider.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
* For full license text, see the license.txt file in the repo root or http://www.apache.org/licenses/LICENSE-2.0
55
*/
66

7+
import * as fs from 'node:fs';
8+
import * as os from 'node:os';
9+
import * as path from 'node:path';
710
import type {Library, LibraryNode} from '@salesforce/b2c-tooling-sdk/operations/content';
811
import {siteArchiveImport, getJobLog, JobExecutionError} from '@salesforce/b2c-tooling-sdk';
912
import JSZip from 'jszip';
@@ -149,11 +152,16 @@ export class ContentFileSystemProvider implements vscode.FileSystemProvider {
149152
await vscode.window.withProgress(
150153
{location: vscode.ProgressLocation.Notification, title: `Importing content to ${libraryId}...`},
151154
async () => {
152-
const archiveName = `content-update-${Date.now()}`;
153155
const zip = new JSZip();
154156
zip.file(archivePath, xmlContent);
155157
const buffer = await zip.generateAsync({type: 'nodebuffer'});
156-
await siteArchiveImport(instance, buffer, {archiveName});
158+
159+
// DEBUG: write archive to temp dir for inspection
160+
const debugPath = path.join(os.tmpdir(), `content-update-${Date.now()}.zip`);
161+
await fs.promises.writeFile(debugPath, buffer);
162+
console.log(`[content-fs] Debug archive written to: ${debugPath}`);
163+
164+
await siteArchiveImport(instance, buffer);
157165
},
158166
);
159167
} catch (err) {

packages/b2c-vs-extension/src/content-tree/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function registerContentTree(context: vscode.ExtensionContext, configProv
2525
const treeView = vscode.window.createTreeView('b2cContentExplorer', {
2626
treeDataProvider: treeProvider,
2727
showCollapseAll: true,
28+
canSelectMany: true,
2829
});
2930

3031
// Show active filter in tree view description

0 commit comments

Comments
 (0)