diff --git a/README.md b/README.md
index 1e126fd..6081790 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ A JavaScript package to disassemble then transform XML files into smaller JSON f
Large XML files can be a pain to mantain in version control. These files can contain thousands of lines and it becomes very difficult to track changes made to these files in a standard version control server like GitHub.
-This package offers a way to break down large XML files into smaller JSON files which can be used to review changes in a format easier to digest.
+This package offers a way to break down large XML files into smaller JSON files which can be used to review changes in a format easier to digest. When needed, the inverse class will reassemble the original XML file from the smaller JSON files.
This will parse and retain the following XML elements:
@@ -24,6 +24,8 @@ npm install xml2json-disassembler
## Usage
+### XML 2 JSON
+
```typescript
/*
FLAGS
@@ -47,6 +49,8 @@ await handler.transform({
});
```
+Disassemble then transform 1 or multiple XML files into JSON files. If the `xmlPath` is a directory, only the XMLs in the immediate directory will be processed. Each XML wiill be transformed into JSON files in new sub-directories using the XML's base name (everything before the first period in the file-name).
+
Example:
An XML file (`HR_Admin.permissionset-meta.xml`) with the following nested and leaf elements
@@ -111,6 +115,28 @@ will be disassembled into a sub-directory named `HR_Admin` as such:
+### JSON 2 XML
+
+```typescript
+/*
+FLAGS
+- jsonPath: Path to the directory containing the JSON files to reassemble into 1 XML file (must be a directory).
+- fileExtension: (Optional) Desired file extension for the final XML (default: `.xml`).
+- postPurge: (Optional) Boolean value. If set to true, purge the disassembled directory containing JSON files after the XML is reassembled.
+ Defaults to false.
+*/
+import { JsonToXmlReassembler } from "xml2json-disassembler";
+
+const handler = new JsonToXmlReassembler();
+await handler.reassemble({
+ jsonPath: "test/baselines/HR_Admin",
+ fileExtension: "permissionset-meta.xml",
+ postPurge: true,
+});
+```
+
+Reassemble all of the JSON files in a directory into 1 XML file. **Note:** You should only be reassembling JSON files created by the `XmlToJsonDisassembler` class for intended results. The reassembled XML file will be created in the parent directory of `jsonPath` and will overwrite the original file used to create the original disassembled directories, if it still exists and the `fileExtension` flag matches the original file extension.
+
## Logging
By default, the package will not print any debugging statements to the console. Any error or debugging statements will be added to a log file, `disassemble.log`, created in the same directory you are running this package in. This file will be created when running the package in all cases, even if there are no errors. I recommend adding `disassemble.log` to your `.gitignore` file.
@@ -143,14 +169,21 @@ import { XmlToJsonDisassembler, setLogLevel } from "xml2json-disassembler";
setLogLevel("debug");
-const handler = new XmlToJsonDisassembler();
-await handler.transform({
+const disassembleHandler = new XmlToJsonDisassembler();
+await disassembleHandler.transform({
xmlPath: "test/baselines/general",
uniqueIdElements:
"application,apexClass,name,externalDataSource,flow,object,apexPage,recordType,tab,field",
prePurge: true,
postPurge: true,
});
+
+const reassembleHandler = new JsonToXmlReassembler();
+await reassembleHandler.reassemble({
+ jsonPath: "test/baselines/HR_Admin",
+ fileExtension: "permissionset-meta.xml",
+ postPurge: true,
+});
```
## Template
diff --git a/src/helpers/types.ts b/src/helpers/types.ts
index 9f1ba67..f04e517 100644
--- a/src/helpers/types.ts
+++ b/src/helpers/types.ts
@@ -1,5 +1,7 @@
"use strict";
+export const INDENT = " ";
+
export const XML_PARSER_OPTION = {
commentPropName: "!---",
ignoreAttributes: false,
@@ -12,6 +14,14 @@ export const XML_PARSER_OPTION = {
cdataPropName: "![CDATA[",
};
+export const JSON_PARSER_OPTION = {
+ ...XML_PARSER_OPTION,
+ format: true,
+ indentBy: INDENT,
+ suppressBooleanAttributes: false,
+ suppressEmptyNode: false,
+};
+
export interface XmlElement {
[key: string]: string | XmlElement | string[] | XmlElement[];
}
diff --git a/src/index.ts b/src/index.ts
index fe1c748..e459837 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,6 @@
import { getLogger, configure } from "log4js";
export { XmlToJsonDisassembler } from "./service/xml2jsonDisassembler";
+export { JsonToXmlReassembler } from "./service/json2xmlReassembler";
export const logger = getLogger();
diff --git a/src/service/deleteReassembledXML.ts b/src/service/deleteReassembledXML.ts
new file mode 100644
index 0000000..9fcfcc1
--- /dev/null
+++ b/src/service/deleteReassembledXML.ts
@@ -0,0 +1,19 @@
+"use strict";
+
+import { stat, readdir, rm } from "node:fs/promises";
+import { join } from "node:path";
+
+export async function deleteReassembledXML(
+ disassembledPath: string,
+): Promise {
+ const files = await readdir(disassembledPath);
+ for (const file of files) {
+ const filePath = join(disassembledPath, file);
+ const fileStat = await stat(filePath);
+ if (fileStat.isFile() && filePath.endsWith(".xml")) {
+ await rm(filePath);
+ } else if (fileStat.isDirectory()) {
+ await deleteReassembledXML(filePath);
+ }
+ }
+}
diff --git a/src/service/disassembleHandler.ts b/src/service/disassembleHandler.ts
index 24c4f00..a2c6141 100644
--- a/src/service/disassembleHandler.ts
+++ b/src/service/disassembleHandler.ts
@@ -5,14 +5,14 @@ import { DisassembleXMLFileHandler } from "xml-disassembler";
export async function disassembleHandler(
xmlPath: string,
uniqueIdElements: string,
- prepurge: boolean,
- postpurge: boolean,
+ prePurge: boolean,
+ postPurge: boolean,
): Promise {
const handler = new DisassembleXMLFileHandler();
await handler.disassemble({
xmlPath,
uniqueIdElements,
- prePurge: prepurge,
- postPurge: postpurge,
+ prePurge,
+ postPurge,
});
}
diff --git a/src/service/json2xmlReassembler.ts b/src/service/json2xmlReassembler.ts
new file mode 100644
index 0000000..cdd4295
--- /dev/null
+++ b/src/service/json2xmlReassembler.ts
@@ -0,0 +1,48 @@
+"use strict";
+
+import { stat, readdir } from "node:fs/promises";
+import { join } from "node:path";
+
+import { logger } from "@src/index";
+import { reassembleHandler } from "@src/service/reassembleHandler";
+import { transform2XML } from "@src/service/transform2XML";
+import { deleteReassembledXML } from "@src/service/deleteReassembledXML";
+
+export class JsonToXmlReassembler {
+ async reassemble(xmlAttributes: {
+ jsonPath: string;
+ fileExtension?: string;
+ postPurge?: boolean;
+ }): Promise {
+ const {
+ jsonPath,
+ fileExtension = "xml",
+ postPurge = false,
+ } = xmlAttributes;
+ const fileStat = await stat(jsonPath);
+
+ if (fileStat.isFile()) {
+ logger.error(`The path ${jsonPath} is not a directory.`);
+ return;
+ } else if (fileStat.isDirectory()) {
+ await this.processFile(jsonPath);
+ }
+
+ await reassembleHandler(jsonPath, fileExtension, postPurge);
+ // delete XML files created during reassembly - this is needed if postPurge is false
+ if (!postPurge) await deleteReassembledXML(jsonPath);
+ }
+
+ async processFile(jsonPath: string): Promise {
+ const files = await readdir(jsonPath);
+ for (const file of files) {
+ const filePath = join(jsonPath, file);
+ const fileStat = await stat(filePath);
+ if (fileStat.isFile() && filePath.endsWith(".json")) {
+ await transform2XML(filePath);
+ } else if (fileStat.isDirectory()) {
+ await this.processFile(filePath);
+ }
+ }
+ }
+}
diff --git a/src/service/reassembleHandler.ts b/src/service/reassembleHandler.ts
new file mode 100644
index 0000000..75f2ae6
--- /dev/null
+++ b/src/service/reassembleHandler.ts
@@ -0,0 +1,16 @@
+"use strict";
+
+import { ReassembleXMLFileHandler } from "xml-disassembler";
+
+export async function reassembleHandler(
+ xmlPath: string,
+ fileExtension: string,
+ postpurge: boolean,
+): Promise {
+ const handler = new ReassembleXMLFileHandler();
+ await handler.reassemble({
+ xmlPath,
+ fileExtension,
+ postPurge: postpurge,
+ });
+}
diff --git a/src/service/transform2XML.ts b/src/service/transform2XML.ts
new file mode 100644
index 0000000..6f21709
--- /dev/null
+++ b/src/service/transform2XML.ts
@@ -0,0 +1,30 @@
+"use strict";
+
+import { readFile, writeFile } from "node:fs/promises";
+import { XMLBuilder } from "fast-xml-parser";
+
+import { logger } from "@src/index";
+import { JSON_PARSER_OPTION, INDENT } from "@src/helpers/types";
+
+export async function transform2XML(
+ jsonPath: string,
+ indentLevel: number = 0,
+): Promise {
+ const jsonString = await readFile(jsonPath, "utf-8");
+ const jsonObject = JSON.parse(jsonString);
+
+ // Remove XML declaration from JSON string
+ const xmlBuilder = new XMLBuilder(JSON_PARSER_OPTION);
+ const xmlString = xmlBuilder.build(jsonObject) as string;
+
+ // Manually format the XML string with the desired indentation
+ const formattedXml: string = xmlString
+ .split("\n")
+ .map((line: string) => `${" ".repeat(indentLevel * INDENT.length)}${line}`)
+ .join("\n")
+ .trimEnd();
+
+ const xmlPath = jsonPath.replace(/\.json$/, ".xml");
+ await writeFile(xmlPath, formattedXml);
+ logger.debug(`${jsonPath} has been transformed into ${xmlPath}`);
+}
diff --git a/test/main.spec.ts b/test/main.spec.ts
index b6ac363..7dfdd84 100644
--- a/test/main.spec.ts
+++ b/test/main.spec.ts
@@ -1,18 +1,26 @@
-import { writeFile, rm } from "node:fs/promises";
-import { resolve } from "node:path";
+import { readdir, readFile, writeFile, rm } from "node:fs/promises";
+import { strictEqual } from "node:assert";
+import { resolve, join } from "node:path";
import { copy } from "fs-extra";
-import { XmlToJsonDisassembler, setLogLevel, logger } from "../src/index";
+import {
+ XmlToJsonDisassembler,
+ JsonToXmlReassembler,
+ setLogLevel,
+ logger,
+} from "../src/index";
setLogLevel("debug");
const baselineDir: string = "test/baselines";
const mockDir: string = "mock";
let xml2jsonDisassemblerHandler: XmlToJsonDisassembler;
+let json2xmlReassemblerHandler: JsonToXmlReassembler;
describe("main function", () => {
beforeAll(async () => {
await copy(baselineDir, mockDir, { overwrite: true });
xml2jsonDisassemblerHandler = new XmlToJsonDisassembler();
+ json2xmlReassemblerHandler = new JsonToXmlReassembler();
});
beforeEach(async () => {
@@ -27,27 +35,66 @@ describe("main function", () => {
await rm(mockDir, { recursive: true });
});
- it("should disassemble & transform a general XML file (folder path) into JSON files.", async () => {
+ it("should disassemble & transform 1 XML file into JSON files.", async () => {
await xml2jsonDisassemblerHandler.transform({
- xmlPath: "mock",
+ xmlPath: "mock/HR_Admin.permissionset-meta.xml",
uniqueIdElements:
"application,apexClass,name,externalDataSource,flow,object,apexPage,recordType,tab,field",
});
expect(logger.error).not.toHaveBeenCalled();
});
- it("should disassemble & transform a general XML file (file path) into JSON files.", async () => {
+ it("should disassemble & transform a directory of XML files into JSON files.", async () => {
await xml2jsonDisassemblerHandler.transform({
- xmlPath: "mock/HR_Admin.permissionset-meta.xml",
- prePurge: true,
- postPurge: true,
+ xmlPath: "mock",
uniqueIdElements:
"application,apexClass,name,externalDataSource,flow,object,apexPage,recordType,tab,field",
+ prePurge: true,
+ postPurge: true,
+ });
+
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+ it("should reassemble the XML file.", async () => {
+ await json2xmlReassemblerHandler.reassemble({
+ jsonPath: "mock/HR_Admin",
+ fileExtension: "permissionset-meta.xml",
});
expect(logger.error).not.toHaveBeenCalled();
});
- it("should test error condition (XML file path not provided).", async () => {
+ it("should reassemble the XML file with comments.", async () => {
+ await json2xmlReassemblerHandler.reassemble({
+ jsonPath: "mock/Numbers-fr",
+ fileExtension: "globalValueSetTranslation-meta.xml",
+ });
+
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+ it("should reassemble the CDATA XML file.", async () => {
+ await json2xmlReassemblerHandler.reassemble({
+ jsonPath: "mock/VidLand_US",
+ fileExtension: "marketingappextension-meta.xml",
+ });
+
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+ it("should reassemble the XML file with an array of leafs.", async () => {
+ await json2xmlReassemblerHandler.reassemble({
+ jsonPath: "mock/Dreamhouse",
+ fileExtension: "app-meta.xml",
+ });
+
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+ it("should reassemble the XML file with attributes.", async () => {
+ await json2xmlReassemblerHandler.reassemble({
+ jsonPath: "mock/attributes",
+ });
+
+ expect(logger.error).not.toHaveBeenCalled();
+ });
+ it("should test disassemble error condition (XML file path not provided).", async () => {
let fakeFile = "mock/not-an-xml.txt";
fakeFile = resolve(fakeFile);
const fakeFileContents = "Testing error condition.";
@@ -55,7 +102,45 @@ describe("main function", () => {
await xml2jsonDisassemblerHandler.transform({
xmlPath: fakeFile,
});
+ expect(logger.error).toHaveBeenCalled();
+ });
+ it("should test reassemble error condition (file path provided).", async () => {
+ const fakeFile = "mock/not-an-xml.txt";
+ await json2xmlReassemblerHandler.reassemble({
+ jsonPath: fakeFile,
+ });
await rm(fakeFile);
expect(logger.error).toHaveBeenCalled();
});
+ // This should always be the final test
+ it("should compare the files created in the mock directory against the baselines to confirm no changes.", async () => {
+ await compareDirectories(baselineDir, mockDir);
+ });
});
+
+async function compareDirectories(
+ referenceDir: string,
+ mockDir: string,
+): Promise {
+ const entriesinRef = await readdir(referenceDir, { withFileTypes: true });
+
+ // Only compare files that are in the reference directory
+ for (const entry of entriesinRef) {
+ const refEntryPath = join(referenceDir, entry.name);
+ const mockPath = join(mockDir, entry.name);
+
+ if (entry.isDirectory()) {
+ // If it's a directory, recursively compare its contents
+ await compareDirectories(refEntryPath, mockPath);
+ } else {
+ // If it's a file, compare its content
+ const refContent = await readFile(refEntryPath, "utf-8");
+ const mockContent = await readFile(mockPath, "utf-8");
+ strictEqual(
+ refContent,
+ mockContent,
+ `File content is different for ${entry.name}`,
+ );
+ }
+ }
+}