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}`, + ); + } + } +}