Skip to content

Commit fa1589b

Browse files
committed
fix: metadata isn't refreshed in the correct path and is missing -meta.xml. Also fix refresh of metadata doesn't include -meta.xml
1 parent cf4c84f commit fa1589b

File tree

5 files changed

+196
-71
lines changed

5 files changed

+196
-71
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import path from 'path';
2+
import { SalesforcePackageComponentFile } from './package';
3+
import { outputFile } from 'fs-extra';
4+
5+
interface MetadataFile extends SalesforcePackageComponentFile {
6+
read(): Promise<Buffer>;
7+
}
8+
9+
interface OutputFile {
10+
( name: string, data: Buffer ): Promise<void>;
11+
}
12+
13+
export class MetadataExpander {
14+
public constructor() {
15+
}
16+
17+
public async expandMetadata(metadata: MetadataFile): Promise<Record<string, Buffer>> {
18+
const expandedMetadata: Record<string, Buffer> = {};
19+
20+
const metadataContent = await metadata.read();
21+
const basename = decodeURIComponent(path.basename(metadata.packagePath));
22+
23+
const appendMetaXml = this.shouldAppendMetaXml(metadata.packagePath, metadataContent);
24+
const expandedName = appendMetaXml ? `${basename}-meta.xml` : basename;
25+
26+
expandedMetadata[expandedName] = metadataContent;
27+
28+
return expandedMetadata;
29+
}
30+
31+
public async saveExpandedMetadata(metadata: MetadataFile, destinationPath: string, options?: { outputFile?: OutputFile }): Promise<void> {
32+
const expandedMetadata = await this.expandMetadata(metadata);
33+
for (const [fileName, content] of Object.entries(expandedMetadata)) {
34+
const filePath = path.join(destinationPath, fileName);
35+
(options?.outputFile ?? outputFile)(filePath, content)
36+
}
37+
}
38+
39+
private shouldAppendMetaXml(fileName: string, body: Buffer) {
40+
if (fileName.endsWith('.xml')) {
41+
return false;
42+
}
43+
// Check if the body starts with XML declaration
44+
const bodyString = body.toString('utf8', 0, Math.min(100, body.length));
45+
return bodyString.includes('<?xml');
46+
}
47+
}

packages/salesforce/src/deploy/retrieveResultPackage.ts

Lines changed: 92 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
import * as path from 'path';
33
import * as fs from 'fs-extra';
44
import ZipArchive from 'jszip';
5-
import { directoryName, fileName as baseName , groupBy } from '@vlocode/util';
5+
import { groupBy } from '@vlocode/util';
66
import { FileProperties, RetrieveResult } from '../connection';
77
import { PackageManifest } from './maifest';
88
import { SalesforcePackageComponentFile } from './package';
9+
import { MetadataExpander } from './metadataExpander';
910

1011
/**
1112
* Extends typings on the JSZipObject with internal _data object
@@ -93,12 +94,6 @@ export class RetrieveResultFile implements SalesforceRetrievedComponentFile {
9394
*/
9495
public readonly archivePath: string;
9596

96-
/**
97-
* If this file has an associated metadata XML file this is path to that file.
98-
* Undefined if no meta file exists that matches the source file.
99-
*/
100-
public readonly metaFilePath?: string;
101-
10297
/**
10398
* Uncompressed size of the file in bytes.
10499
*/
@@ -120,6 +115,32 @@ export class RetrieveResultFile implements SalesforceRetrievedComponentFile {
120115
return this.file?._data?.crc32
121116
}
122117

118+
/**
119+
* Gets the base name of the archive file path, excluding any directory components.
120+
*
121+
* @returns The file name portion of the archive path.
122+
*/
123+
public get fileName() {
124+
return path.basename(this.archivePath);
125+
}
126+
127+
/**
128+
* Gets the relative path of the archive file.
129+
*
130+
* @returns The directory name of the archive path specified by `this.archivePath`.
131+
*/
132+
public get folderName() {
133+
return path.dirname(this.archivePath);
134+
}
135+
136+
/**
137+
* Gets the full path of the file in the zip archive.
138+
* @see {@link archivePath}
139+
*/
140+
public get fullPath() {
141+
return this.archivePath
142+
}
143+
123144
constructor(
124145
properties: FileProperties,
125146
private readonly file?: ZipArchive.JSZipObject)
@@ -131,25 +152,25 @@ export class RetrieveResultFile implements SalesforceRetrievedComponentFile {
131152
}
132153

133154
/**
134-
* Extracts the file to the target folder and optionally the meta file.
135-
* @param {string} targetFolder Target folder to extract the file to.
136-
* @param {object} [options] Additional options.
137-
* @param {boolean} [options.fileOnly] If true only the file will be extracted, otherwise the full path will be used.
138-
* @returns
155+
* Extracts and writes expanded metadata files to the specified target folder.
156+
*
157+
* This method uses the `MetadataExpander` to expand the current metadata object,
158+
* then writes each expanded file to the given target directory. The method returns
159+
* a list of the relative file paths that were written.
160+
*
161+
* @param targetFolder - The absolute or relative path to the folder where the expanded files will be written.
162+
* @returns A promise that resolves to an array of file paths representing the files that were written.
139163
*/
140-
public writeFile(targetFolder: string, options?: { fileOnly?: boolean }): Promise<void> {
141-
const targetPath = path.join(targetFolder,
142-
options?.fileOnly
143-
? path.basename(this.packagePath)
144-
: this.packagePath
145-
);
146-
return new Promise((resolve, reject) => {
147-
fs.ensureDir(path.dirname(targetPath)).then(() => {
148-
this.getFile().nodeStream().pipe(fs.createWriteStream(targetPath, { flags: 'w' }))
149-
.on('finish', () => resolve())
150-
.on('error', reject);
151-
}).catch(reject);
152-
});
164+
public async extractTo(targetFolder: string) {
165+
const expander = new MetadataExpander();
166+
const result = await expander.expandMetadata(this);
167+
const filesWritten: string[] = []
168+
for (const [expandedFile, data] of Object.entries(result)) {
169+
const expandedFilePath = path.join(targetFolder, expandedFile);
170+
await fs.outputFile(expandedFilePath, data);
171+
filesWritten.push(expandedFilePath);
172+
}
173+
return filesWritten;
153174
}
154175

155176
/**
@@ -168,6 +189,15 @@ export class RetrieveResultFile implements SalesforceRetrievedComponentFile {
168189
return this.getFile().nodeStream();
169190
}
170191

192+
/**
193+
* Reads and returns the contents as a Buffer.
194+
*
195+
* @returns A promise that resolves to a Buffer containing the data.
196+
*/
197+
public read(): Promise<Buffer> {
198+
return this.getBuffer();
199+
}
200+
171201
private getFile() {
172202
const file = this.file;
173203
if (!file) {
@@ -270,7 +300,7 @@ export class RetrieveResultPackage {
270300
* Gets all files in the retrieve result package.
271301
* @returns A list of all files in the retrieve result package.
272302
*/
273-
public getFiles(): Array<RetrieveResultFile> {
303+
public getFiles(predicate?: (file: RetrieveResultFile, index: number) => boolean): Array<RetrieveResultFile> {
274304
if (this.files) {
275305
return this.files;
276306
}
@@ -299,7 +329,7 @@ export class RetrieveResultPackage {
299329
return files;
300330
}
301331
);
302-
return this.files;
332+
return predicate ? this.files.filter(predicate) : this.files;
303333
}
304334

305335
/**
@@ -317,31 +347,40 @@ export class RetrieveResultPackage {
317347
return manifests.reduce((manifest, current) => manifest.merge(current));
318348
}
319349

320-
public async unpackFolder(packageFolder: string, targetPath: string) : Promise<void> {
321-
const files = this.filterFiles(file => directoryName(file).endsWith(packageFolder.toLowerCase()));
322-
if (!files) {
323-
throw new Error(`The specified folder ${packageFolder} was not found in retrieved package or is empty`);
324-
}
325-
for (const file of files) {
326-
await this.streamFileToDisk(file, path.join(targetPath, baseName(file.name)));
327-
}
328-
}
329-
330-
public unpackFile(packageFile: string, targetPath: string) : Promise<void> {
331-
const file = this.file(file => file.toLowerCase().endsWith(packageFile.toLowerCase()));
332-
if (!file) {
333-
throw new Error(`The specified file ${packageFile} was not found in retrieved package`);
334-
}
335-
return this.streamFileToDisk(file, targetPath);
336-
}
337-
338-
public unpackFileToFolder(packageFile: string, targetPath: string) : Promise<void> {
339-
const file = this.file(file => file.toLowerCase().endsWith(packageFile.toLowerCase()));
340-
if (!file) {
341-
throw new Error(`The specified file ${packageFile} was not found in retrieved package`);
342-
}
343-
return this.streamFileToDisk(file, path.join(targetPath, baseName(file.name)));
344-
}
350+
// /**
351+
// * @deprecated Use {@link RetrieveResultFile.extractTo} instead.
352+
// */
353+
// public async unpackFolder(packageFolder: string, targetPath: string) : Promise<void> {
354+
// const files = await this.getFiles(file => file.folderName.endsWith(packageFolder.toLowerCase()));
355+
// if (!files) {
356+
// throw new Error(`The specified folder ${packageFolder} was not found in retrieved package or is empty`);
357+
// }
358+
// for (const file of files) {
359+
// await file.extractTo(targetPath);
360+
// }
361+
// }
362+
363+
// /**
364+
// * @deprecated Use {@link RetrieveResultFile.extractTo} instead.
365+
// */
366+
// public unpackFile(packageFile: string, targetPath: string) : Promise<void> {
367+
// const file = this.file(file => file.toLowerCase().endsWith(packageFile.toLowerCase()));
368+
// if (!file) {
369+
// throw new Error(`The specified file ${packageFile} was not found in retrieved package`);
370+
// }
371+
// return this.streamFileToDisk(file, targetPath);
372+
// }
373+
374+
// /**
375+
// * @deprecated Use {@link RetrieveResultFile.extractTo} instead.
376+
// */
377+
// public unpackFileToFolder(packageFile: string, targetPath: string) : Promise<void> {
378+
// const file = this.file(file => file.toLowerCase().endsWith(packageFile.toLowerCase()));
379+
// if (!file) {
380+
// throw new Error(`The specified file ${packageFile} was not found in retrieved package`);
381+
// }
382+
// return this.streamFileToDisk(file, path.join(targetPath, baseName(file.name)));
383+
// }
345384

346385
private file(filter: string | ((file: string) => any)) : ZipArchive.JSZipObject | undefined {
347386
for (const archive of this.archives ?? []) {
@@ -354,7 +393,7 @@ export class RetrieveResultPackage {
354393
}
355394
}
356395

357-
private filterFiles(filter: (file: string) => any) : Array<ZipArchive.JSZipObject> {
396+
private filterFiles(filter: (file: string) => any) {
358397
const files = new Array<ZipArchive.JSZipObject>();
359398
for (const archive of this.archives ?? []) {
360399
files.push(...archive.filter(filter));

packages/salesforce/src/metadataRegistry.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ export interface MetadataType extends RegistryMetadataType {
2727
* Array with the list of child XML fragments that match this metadata type
2828
*/
2929
childXmlNames: string[];
30+
/**
31+
* Human readable label for the metadata type, used in UI and commands
32+
* This is derived from the name of the metadata type and formatted to be more readable.
33+
*/
34+
label: string;
3035
}
3136

3237
@singletonMixin
@@ -44,6 +49,7 @@ export class MetadataRegistry {
4449
const metadataObject = registryEntry as MetadataType;
4550

4651
metadataObject.xmlName = metadataObject.name;
52+
metadataObject.label = this.formatLabel(metadataObject.name);
4753
metadataObject.childXmlNames = Object.values(metadataObject.children?.types ?? []).map(({ name }) => name);
4854
metadataObject.isBundle = metadataObject.strategies?.adapter == 'bundle' || metadataObject.name.endsWith('Bundle');
4955
metadataObject.hasContent = metadataObject.strategies?.adapter == 'matchingContentFile' ||
@@ -73,6 +79,28 @@ export class MetadataRegistry {
7379
}
7480
}
7581

82+
/**
83+
* Converts a camelCase or PascalCase string to a proper label format
84+
* @param name The name to format
85+
* @returns The formatted label
86+
*/
87+
private formatLabel(name: string): string {
88+
if (!name) {
89+
return '';
90+
}
91+
92+
// Insert spaces before uppercase letters (except the first one)
93+
// Handle sequences of uppercase letters properly (e.g., "XMLParser" -> "XML Parser")
94+
const result = name
95+
.replace(/([a-z])([A-Z])/g, '$1 $2') // Insert space between lowercase and uppercase
96+
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2') // Insert space between uppercase sequences and following lowercase
97+
.replace(/([0-9])([A-Z])/g, '$1 $2') // Insert space between numbers and uppercase letters
98+
.replace(/([a-zA-Z])([0-9])/g, '$1 $2'); // Insert space between letters and numbers
99+
100+
// Capitalize first letter and return
101+
return result.charAt(0).toUpperCase() + result.slice(1);
102+
}
103+
76104
public getUrlFormat(type: string) {
77105
return { ...urlFormats.$default, ...(urlFormats[type] ?? {}) };
78106
}

packages/vscode-extension/src/commands/metadata/refreshMetadataCommand.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export default class RefreshMetadataCommand extends MetadataCommand {
6969

7070
// Extract each file into the appropriate source folder
7171
for (const file of component.files) {
72-
await file.writeFile(sourceFolder);
72+
await file.extractTo(sourceFolder);
7373
}
7474
}
7575

packages/vscode-extension/src/commands/metadata/retrieveMetadataCommand.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ import { VlocodeCommand } from '../../constants';
1313
export default class RetrieveMetadataCommand extends MetadataCommand {
1414

1515
public async execute() : Promise<void> {
16-
return this.exportWizard();
16+
return this.retrieve();
1717
}
1818

19-
protected async exportWizard() : Promise<void> {
19+
protected async retrieve() : Promise<void> {
2020
const metadataType = await this.showMetadataTypeSelection();
2121
if (!metadataType) {
2222
return; // selection cancelled;
@@ -71,8 +71,8 @@ export default class RetrieveMetadataCommand extends MetadataCommand {
7171
protected async showMetadataTypeSelection() : Promise<MetadataType | undefined> {
7272
const metadataTypes = this.salesforce.getMetadataTypes()
7373
.map(type => ({
74-
label: type.xmlName,
75-
description: type.xmlName,
74+
label: type.label,
75+
description: type.name,
7676
type: type
7777
})).sort((a,b) => a.label.localeCompare(b.label));
7878

@@ -129,31 +129,42 @@ export default class RetrieveMetadataCommand extends MetadataCommand {
129129
void vscode.window.showWarningMessage('Decomposing metadata into SFDX format is currently not supported.');
130130
}
131131

132-
const unpackedFiles = new Array<string>();
132+
const retrievedMetadata = new Array<{
133+
type: string;
134+
path: string;
135+
}>();
136+
const outputPaths = new Array<string>();
137+
133138
for (const file of result.getFiles()) {
134139
try {
135-
const unpackTarget = path.join(vscode.workspace.workspaceFolders?.[0].uri.fsPath ?? '.', this.vlocode.config.salesforce.exportFolder);
136-
await file.writeFile(unpackTarget);
137-
this.logger.log(`Exported ${file.archivePath}`);
138-
unpackedFiles.push(path.join(unpackTarget, file.archivePath));
140+
const unpackTarget = path.join(
141+
vscode.workspace.workspaceFolders?.[0].uri.fsPath ?? '.',
142+
this.vlocode.config.salesforce.exportFolder
143+
);
144+
const writtenFiles = await file.extractTo(path.join(unpackTarget, file.folderName));
145+
outputPaths.push(...writtenFiles);
146+
retrievedMetadata.push(
147+
...writtenFiles.map(outputFile => ({
148+
type: file.componentType,
149+
path: path.relative(unpackTarget, outputFile)
150+
}))
151+
);
139152
} catch(err) {
140153
this.logger.error(`${file.componentName} -- ${err.message || err}`);
141154
}
142155
}
143156

144-
const successMessage = `Successfully retrieved ${unpackedFiles.length} components`;
145-
if (unpackedFiles.length == 1) {
146-
void vscode.window.showInformationMessage(successMessage, 'Open')
147-
.then(value => value ? void vscode.window.showTextDocument(vscode.Uri.file(unpackedFiles[0])) : undefined);
148-
} else {
149-
void vscode.window.showInformationMessage(successMessage);
150-
}
157+
const successMessage = `Successfully retrieved ${outputPaths.length} components`;
158+
void vscode.window.showInformationMessage(successMessage, 'Open')
159+
.then(value => value ? void vscode.window.showTextDocument(vscode.Uri.file(outputPaths[0])) : undefined);
160+
161+
this.output.table(retrievedMetadata, { appendEmptyLine: true, focus: true });
151162
});
152163
}
153164

154165
protected async showComponentSelection<T extends FileProperties>(records: T[]) : Promise<Array<T> | undefined> {
155166
const objectOptions = records.map(record => ({
156-
label: record.fullName,
167+
label: decodeURIComponent(record.fullName),
157168
description: `last modified: ${record.lastModifiedByName} (${record.lastModifiedDate})`,
158169
record
159170
})).sort((a, b) => a.label.localeCompare(b.label));

0 commit comments

Comments
 (0)