From 363e3388065171423bb592000c63073f6ee4deba Mon Sep 17 00:00:00 2001 From: Roman Vyakhirev Date: Tue, 9 Sep 2025 15:09:06 +0200 Subject: [PATCH] chore: improve properties parsing schema --- .../utils/bin/rui-generate-package-xml.ts | 11 +- automation/utils/package.json | 2 +- automation/utils/src/package-xml-v2/index.ts | 94 -------- .../src/package-xml-v2/properties-xml.ts | 21 -- automation/utils/src/xml-reader/index.ts | 2 + .../utils/src/xml-reader/package-xml.ts | 56 +++++ automation/utils/src/xml-reader/parser.ts | 74 ++++++ .../utils/src/xml-reader/properties-xml.ts | 221 ++++++++++++++++++ .../{package-xml-v2 => xml-reader}/schema.ts | 8 +- pnpm-lock.yaml | 30 +-- 10 files changed, 377 insertions(+), 142 deletions(-) delete mode 100644 automation/utils/src/package-xml-v2/index.ts delete mode 100644 automation/utils/src/package-xml-v2/properties-xml.ts create mode 100644 automation/utils/src/xml-reader/index.ts create mode 100644 automation/utils/src/xml-reader/package-xml.ts create mode 100644 automation/utils/src/xml-reader/parser.ts create mode 100644 automation/utils/src/xml-reader/properties-xml.ts rename automation/utils/src/{package-xml-v2 => xml-reader}/schema.ts (91%) diff --git a/automation/utils/bin/rui-generate-package-xml.ts b/automation/utils/bin/rui-generate-package-xml.ts index 0f8e9a1430..fc05b59e13 100755 --- a/automation/utils/bin/rui-generate-package-xml.ts +++ b/automation/utils/bin/rui-generate-package-xml.ts @@ -1,8 +1,7 @@ #!/usr/bin/env ts-node -import { writeClientPackageXml, ClientPackageXML } from "../src/package-xml-v2"; +import { readPropertiesXml, writeClientPackage } from "../src/xml-reader"; import { getWidgetInfo } from "../src/package-info"; -import { readPropertiesFile } from "../src/package-xml-v2/properties-xml"; import path from "node:path"; import { existsSync } from "node:fs"; @@ -29,11 +28,11 @@ async function generatePackageXml(): Promise { } // Read properties file and extract widget ID - const propertiesXml = await readPropertiesFile(propertiesFilePath); + const propertiesXml = await readPropertiesXml(propertiesFilePath); const widgetId = propertiesXml.widget["@_id"]; // Generate ClientPackageXML structure - const clientPackageXml: ClientPackageXML = { + const clientPackageXml = { name: packageInfo.mxpackage.name, version: packageInfo.version, widgetFiles: [packageInfo.mxpackage.name + ".xml"], @@ -42,10 +41,10 @@ async function generatePackageXml(): Promise { // Write the generated package.xml const packageXmlPath = path.join(srcDir, "package.xml"); - await writeClientPackageXml(packageXmlPath, clientPackageXml); + await writeClientPackage(packageXmlPath, clientPackageXml); } -async function main() { +async function main(): Promise { try { await generatePackageXml(); } catch (error) { diff --git a/automation/utils/package.json b/automation/utils/package.json index 483509d4d9..967ce30ef8 100644 --- a/automation/utils/package.json +++ b/automation/utils/package.json @@ -44,7 +44,7 @@ "cross-zip": "^4.0.1", "enquirer": "^2.4.1", "execa": "^5.1.1", - "fast-xml-parser": "^4.1.3", + "fast-xml-parser": "^5.2.5", "glob": "^11.0.3", "node-fetch": "^2.7.0", "ora": "^5.4.1", diff --git a/automation/utils/src/package-xml-v2/index.ts b/automation/utils/src/package-xml-v2/index.ts deleted file mode 100644 index a781e91ded..0000000000 --- a/automation/utils/src/package-xml-v2/index.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { XMLBuilder, XMLParser } from "fast-xml-parser"; -import { readFile, writeFile } from "fs/promises"; -import { Version, VersionString } from "../version"; -import { ClientModulePackageFile } from "./schema"; - -export function xmlTextToXmlJson(xmlText: string | Buffer): unknown { - const parser = new XMLParser({ ignoreAttributes: false }); - return parser.parse(xmlText); -} - -export function xmlJsonToXmlText(xmlObject: any): string { - const builder = new XMLBuilder({ - ignoreAttributes: false, - format: true, - indentBy: " ", - suppressEmptyNode: true - }); - return builder - .build(xmlObject) - .replaceAll(/(<[^>]*?)\/>/g, "$1 />") // Add space before /> in self-closing tags - .replaceAll(/(<\?[^>]*?)\?>/g, "$1 ?>"); // Add space before ?> in XML declarations -} - -export interface ClientPackageXML { - name: string; - version: Version; - widgetFiles: string[]; - files: string[]; -} - -export async function readClientPackageXml(path: string): Promise { - return parseClientPackageXml(ClientModulePackageFile.passthrough().parse(xmlTextToXmlJson(await readFile(path)))); -} - -export async function writeClientPackageXml(path: string, data: ClientPackageXML): Promise { - await writeFile(path, xmlJsonToXmlText(buildClientPackageXml(data))); -} - -function parseClientPackageXml(xmlJson: ClientModulePackageFile): ClientPackageXML { - const clientModule = xmlJson?.package?.clientModule ?? {}; - const widgetFilesNode = clientModule.widgetFiles !== "" ? clientModule.widgetFiles?.widgetFile : undefined; - const filesNode = clientModule.files !== "" ? clientModule.files?.file : undefined; - - const extractPaths = (node: any): string[] => { - if (!node) return []; - if (Array.isArray(node)) { - return node.map((item: any) => item["@_path"]); - } - return [node["@_path"]]; - }; - - const name = clientModule["@_name"] ?? ""; - const versionString = clientModule["@_version"] ?? "1.0.0"; - - return { - name, - version: Version.fromString(versionString as VersionString), - widgetFiles: extractPaths(widgetFilesNode), - files: extractPaths(filesNode) - }; -} - -function buildClientPackageXml(clientPackage: ClientPackageXML): ClientModulePackageFile { - const toXmlNode = (arr: string[], tag: T) => { - if (arr.length === 0) return ""; - if (arr.length === 1) { - return { [tag]: { "@_path": arr[0] } }; - } - return { [tag]: arr.map(path => ({ "@_path": path })) }; - }; - - return { - "?xml": { - "@_version": "1.0", - "@_encoding": "utf-8" - }, - package: { - clientModule: { - widgetFiles: toXmlNode( - clientPackage.widgetFiles, - "widgetFile" - ) as ClientModulePackageFile["package"]["clientModule"]["widgetFiles"], - files: toXmlNode( - clientPackage.files, - "file" - ) as ClientModulePackageFile["package"]["clientModule"]["files"], - "@_name": clientPackage.name, - "@_version": clientPackage.version.format(), - "@_xmlns": "http://www.mendix.com/clientModule/1.0/" - }, - "@_xmlns": "http://www.mendix.com/package/1.0/" - } - }; -} diff --git a/automation/utils/src/package-xml-v2/properties-xml.ts b/automation/utils/src/package-xml-v2/properties-xml.ts deleted file mode 100644 index 46c95dfcce..0000000000 --- a/automation/utils/src/package-xml-v2/properties-xml.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { z } from "zod"; -import { xmlTextToXmlJson } from "./index"; -import { readFile } from "node:fs/promises"; - -export const PropertiesXMLFile = z.object({ - "?xml": z.object({ - "@_version": z.literal("1.0"), - "@_encoding": z.literal("utf-8") - }), - widget: z.object({ - "@_id": z.string(), - "@_xmlns": z.literal("http://www.mendix.com/widget/1.0/"), - "@_xmlns:xsi": z.literal("http://www.w3.org/2001/XMLSchema-instance") - }) -}); - -type PropertiesXMLFile = z.infer; - -export async function readPropertiesFile(filePath: string): Promise { - return PropertiesXMLFile.passthrough().parse(xmlTextToXmlJson(await readFile(filePath, "utf-8"))); -} diff --git a/automation/utils/src/xml-reader/index.ts b/automation/utils/src/xml-reader/index.ts new file mode 100644 index 0000000000..2aebb60b4d --- /dev/null +++ b/automation/utils/src/xml-reader/index.ts @@ -0,0 +1,2 @@ +export { ClientPackageInfo, readClientPackage, writeClientPackage } from "./package-xml"; +export { readPropertiesXml } from "./properties-xml"; diff --git a/automation/utils/src/xml-reader/package-xml.ts b/automation/utils/src/xml-reader/package-xml.ts new file mode 100644 index 0000000000..6facb9c886 --- /dev/null +++ b/automation/utils/src/xml-reader/package-xml.ts @@ -0,0 +1,56 @@ +import { ClientModulePackageFile } from "./schema"; +import { Version, VersionString } from "../version"; +import { readFile, writeFile } from "fs/promises"; +import { xmlJsonToXmlText, xmlTextToXmlJson } from "./parser"; + +export interface ClientPackageInfo { + name: string; + version: Version; + widgetFiles: string[]; + files: string[]; +} + +function parseClientPackageXml(xmlJson: ClientModulePackageFile): ClientPackageInfo { + const clientModule = xmlJson?.package?.clientModule ?? {}; + + const name = clientModule["@_name"] ?? ""; + const versionString = clientModule["@_version"] ?? "1.0.0"; + + return { + name, + version: Version.fromString(versionString as VersionString), + widgetFiles: clientModule.widgetFiles !== "" ? clientModule.widgetFiles?.widgetFile.map(i => i["@_path"]) : [], + files: clientModule.files !== "" ? clientModule.files?.file.map(i => i["@_path"]) : [] + }; +} + +function buildClientPackageXml(clientPackage: ClientPackageInfo): ClientModulePackageFile { + const toXmlArray = (paths: string[]): any => { + return paths.map(path => ({ "@_path": path })); + }; + return { + "?xml": { + "@_version": "1.0", + "@_encoding": "utf-8" + }, + package: { + clientModule: { + widgetFiles: + clientPackage.widgetFiles.length !== 0 ? { widgetFile: toXmlArray(clientPackage.widgetFiles) } : "", + files: clientPackage.files.length !== 0 ? { file: toXmlArray(clientPackage.files) } : "", + "@_name": clientPackage.name, + "@_version": clientPackage.version.format(), + "@_xmlns": "http://www.mendix.com/clientModule/1.0/" + }, + "@_xmlns": "http://www.mendix.com/package/1.0/" + } + }; +} + +export async function readClientPackage(path: string): Promise { + return parseClientPackageXml(ClientModulePackageFile.passthrough().parse(xmlTextToXmlJson(await readFile(path)))); +} + +export async function writeClientPackage(path: string, data: ClientPackageInfo): Promise { + await writeFile(path, xmlJsonToXmlText(buildClientPackageXml(data))); +} diff --git a/automation/utils/src/xml-reader/parser.ts b/automation/utils/src/xml-reader/parser.ts new file mode 100644 index 0000000000..d59ebb8c15 --- /dev/null +++ b/automation/utils/src/xml-reader/parser.ts @@ -0,0 +1,74 @@ +import { XMLBuilder, XMLParser } from "fast-xml-parser"; + +const arrayNodes = [ + "file", + "widgetFile", + "propertyGroup", + "enumerationValue", + "property", + "systemProperty", + "attributeType", + "translation", + "selectionType", + "actionVariable", + "associationType" +]; + +const singleNodes = [ + "name", + "caption", + "description", + "widget", + "helpUrl", + "enumerationValues", + "returnType", + "attributeTypes", + "studioCategory", + "studioProCategory", + "selectionTypes", + "category", + "translations", + "actionVariables", + "associationTypes", + "icon", + "module", + "projectFile", + "properties" +]; + +export function xmlTextToXmlJson(xmlText: string | Buffer): unknown { + const parser = new XMLParser({ + ignoreAttributes: false, + allowBooleanAttributes: true, + attributeValueProcessor: (attr, val) => { + if (attr === "defaultValue") { + return val; + } + if (val === "true") return true; + if (val === "false") return false; + return val; + }, + isArray: (name, _jpath, _isLeafNode, isAttribute) => { + if (isAttribute) return false; + if (arrayNodes.includes(name)) return true; + if (singleNodes.includes(name)) return false; + + return false; + } + }); + + return parser.parse(xmlText); +} + +export function xmlJsonToXmlText(xmlObject: any): string { + const builder = new XMLBuilder({ + ignoreAttributes: false, + format: true, + indentBy: " ", + suppressEmptyNode: true + }); + return builder + .build(xmlObject) + .replaceAll(/(<[^>]*?)\/>/g, "$1 />") // Add space before /> in self-closing tags + .replaceAll(/(<\?[^>]*?)\?>/g, "$1 ?>"); // Add space before ?> in XML declarations +} diff --git a/automation/utils/src/xml-reader/properties-xml.ts b/automation/utils/src/xml-reader/properties-xml.ts new file mode 100644 index 0000000000..72358774cc --- /dev/null +++ b/automation/utils/src/xml-reader/properties-xml.ts @@ -0,0 +1,221 @@ +import { z } from "zod"; +import { xmlTextToXmlJson } from "./parser"; +import { readFile } from "node:fs/promises"; + +// Enums from XSD +const PropertyTypeEnum = z.enum([ + "action", + "association", + "attribute", + "boolean", + "datasource", + "decimal", + "entity", + "entityConstraint", + "enumeration", + "expression", + "file", + "form", + "icon", + "image", + "integer", + "microflow", + "nanoflow", + "object", + "selection", + "string", + "translatableString", + "textTemplate", + "widgets" +]); + +const AttributeTypeEnum = z.enum([ + "AutoNumber", + "Binary", + "Boolean", + "Currency", + "DateTime", + "Enum", + "Float", + "HashString", + "Integer", + "Long", + "String", + "Decimal" +]); + +const AssociationTypeEnum = z.enum(["Reference", "ReferenceSet"]); + +const SelectionTypeEnum = z.enum(["None", "Single", "Multi"]); + +const ReturnTypeEnum = z.enum(["Void", "Boolean", "Integer", "Float", "DateTime", "String", "Object", "Decimal"]); + +const VariableTypeEnum = z.enum(["Boolean", "Integer", "DateTime", "String", "Decimal"]); + +const PlatformTypeEnum = z.enum(["All", "Native", "Web"]); + +const SystemPropertyKeyEnum = z.enum(["Label", "Name", "TabIndex", "Editability", "Visibility"]); + +const DefaultActionTypeEnum = z.enum([ + "None", + "CallMicroflow", + "CallNanoflow", + "OpenPage", + "Database", + "Microflow", + "Nanoflow", + "Association" +]); + +const IsPathTypeEnum = z.enum(["no", "optional", "yes"]); + +const PathTypeEnum = z.enum(["reference", "referenceSet"]); + +// Base schemas +const AttributeType = z.object({ + "@_name": AttributeTypeEnum +}); + +const AttributeTypes = z.object({ + attributeType: z.array(AttributeType) +}); + +const AssociationType = z.object({ + "@_name": AssociationTypeEnum +}); + +const AssociationTypes = z.object({ + associationType: z.array(AssociationType) +}); + +const SelectionType = z.object({ + "@_name": SelectionTypeEnum +}); + +const SelectionTypes = z.object({ + selectionType: z.array(SelectionType) +}); + +const EnumerationValue = z.object({ + "@_key": z.string(), + "#text": z.string() +}); + +const EnumerationValues = z.object({ + enumerationValue: z.array(EnumerationValue) +}); + +const Translation = z.object({ + "@_lang": z.string(), + "#text": z.string() +}); + +const Translations = z.object({ + translation: z.array(Translation) +}); + +const ActionVariable = z.object({ + "@_key": z.string(), + "@_type": VariableTypeEnum, + "@_caption": z.string() +}); + +const ActionVariables = z.object({ + actionVariable: z.array(ActionVariable) +}); + +const ReturnType = z.object({ + "@_type": ReturnTypeEnum.optional(), + "@_isList": z.boolean().optional(), + "@_entityProperty": z.string().optional(), + "@_assignableTo": z.string().optional() +}); + +// Forward declaration for recursive properties structure +const BasePropertyGroup: z.ZodType = z.lazy(() => PropertyGroup); + +const Property = z.object({ + "@_key": z.string(), + "@_type": PropertyTypeEnum, + "@_isList": z.boolean().optional(), + "@_entityProperty": z.string().optional(), + "@_allowNonPersistableEntities": z.boolean().optional(), + "@_isPath": IsPathTypeEnum.optional(), + "@_pathType": PathTypeEnum.optional(), + "@_parameterIsList": z.boolean().optional(), + "@_multiline": z.boolean().optional().default(false), + "@_defaultValue": z.string().optional(), + "@_required": z.boolean().optional().default(true), + "@_isDefault": z.boolean().optional(), + "@_onChange": z.string().optional(), + "@_dataSource": z.string().optional(), + "@_selectableObjects": z.string().optional(), + "@_setLabel": z.boolean().optional(), + "@_isLinked": z.boolean().optional(), + "@_isMetaData": z.boolean().optional(), + "@_defaultType": DefaultActionTypeEnum.optional(), + caption: z.string(), + category: z.string().optional(), + description: z.string(), + attributeTypes: AttributeTypes.optional(), + associationTypes: AssociationTypes.optional(), + selectionTypes: SelectionTypes.optional(), + enumerationValues: EnumerationValues.optional(), + properties: z.lazy(() => Properties).optional(), + returnType: ReturnType.optional(), + translations: Translations.optional(), + actionVariables: ActionVariables.optional() +}); + +const SystemProperty = z.object({ + "@_key": SystemPropertyKeyEnum, + category: z.string().optional() +}); + +const PropertyGroup = z.object({ + "@_caption": z.string(), + property: z.array(Property).optional(), + systemProperty: z.array(SystemProperty).optional(), + propertyGroup: z.array(BasePropertyGroup).optional() +}); + +const Properties = z.object({ + property: z.array(Property).optional(), + systemProperty: z.array(SystemProperty).optional() +}); + +const PhoneGap = z.object({ + "@_enabled": z.boolean() +}); + +const PropertiesXMLFile = z.object({ + "?xml": z.object({ + "@_version": z.literal("1.0"), + "@_encoding": z.literal("utf-8") + }), + widget: z.object({ + "@_id": z.string(), + "@_needsEntityContext": z.boolean().optional(), + "@_pluginWidget": z.boolean().optional(), + "@_mobile": z.boolean().optional(), + "@_supportedPlatform": PlatformTypeEnum.optional(), + "@_offlineCapable": z.boolean().optional(), + "@_xmlns": z.literal("http://www.mendix.com/widget/1.0/"), + "@_xmlns:xsi": z.literal("http://www.w3.org/2001/XMLSchema-instance"), + "@_xsi:schemaLocation": z.string().optional(), + name: z.string(), + description: z.string(), + studioProCategory: z.string().optional(), + studioCategory: z.string().optional(), + helpUrl: z.string().optional(), + icon: z.string().optional(), // base64Binary + phonegap: PhoneGap.optional(), + properties: z.union([z.object({ propertyGroup: z.array(PropertyGroup) }).optional(), z.literal("")]) + }) +}); + +type PropertiesXMLFile = z.infer; + +export async function readPropertiesXml(filePath: string): Promise { + return PropertiesXMLFile.passthrough().parse(xmlTextToXmlJson(await readFile(filePath, "utf-8"))); +} diff --git a/automation/utils/src/package-xml-v2/schema.ts b/automation/utils/src/xml-reader/schema.ts similarity index 91% rename from automation/utils/src/package-xml-v2/schema.ts rename to automation/utils/src/xml-reader/schema.ts index f37da9c932..734fdd4d9a 100644 --- a/automation/utils/src/package-xml-v2/schema.ts +++ b/automation/utils/src/xml-reader/schema.ts @@ -4,8 +4,6 @@ const FileTag = z.object({ "@_path": z.string() }); -const FileNode = z.union([FileTag, FileTag.array()]); - export const ModelerProjectPackageFile = z.object({ "?xml": z.object({ "@_version": z.literal("1.0"), @@ -24,7 +22,7 @@ export const ModelerProjectPackageFile = z.object({ files: z.union([ z.literal(""), z.object({ - file: FileNode + file: FileTag.array() }) ]) }) @@ -52,14 +50,14 @@ export const ClientModulePackageFile = z.object({ files: z.union([ z.literal(""), z.object({ - file: FileNode + file: FileTag.array() }) ]), widgetFiles: z.union([ z.literal(""), z.object({ - widgetFile: FileNode + widgetFile: FileTag.array() }) ]) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fd03b0f33..3d33ddcddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,8 +172,8 @@ importers: specifier: ^5.1.1 version: 5.1.1 fast-xml-parser: - specifier: ^4.1.3 - version: 4.4.1 + specifier: ^5.2.5 + version: 5.2.5 glob: specifier: ^11.0.3 version: 11.0.3 @@ -7110,14 +7110,14 @@ packages: fast-uri@3.0.2: resolution: {integrity: sha512-GR6f0hD7XXyNJa25Tb9BuIdN0tdr+0BMi6/CJPH3wJO1JjNG3n/VsSw38AwRdKZABm8lGbPfakLRkYzx2V9row==} - fast-xml-parser@4.4.1: - resolution: {integrity: sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==} - hasBin: true - fast-xml-parser@4.5.3: resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} hasBin: true + fast-xml-parser@5.2.5: + resolution: {integrity: sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==} + hasBin: true + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -10626,12 +10626,12 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - strnum@1.0.5: - resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} - strnum@1.1.2: resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + strnum@2.1.1: + resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + strongly-connected-components@1.0.1: resolution: {integrity: sha512-i0TFx4wPcO0FwX+4RkLJi1MxmcTv90jNZgxMu9XRnMXMeFUY1VJlIoXpZunPUvUUqbCT1pg5PEkFqqpcaElNaA==} @@ -17115,14 +17115,14 @@ snapshots: fast-uri@3.0.2: {} - fast-xml-parser@4.4.1: - dependencies: - strnum: 1.0.5 - fast-xml-parser@4.5.3: dependencies: strnum: 1.1.2 + fast-xml-parser@5.2.5: + dependencies: + strnum: 2.1.1 + fastest-levenshtein@1.0.16: {} fastq@1.15.0: @@ -21542,10 +21542,10 @@ snapshots: strip-json-comments@3.1.1: {} - strnum@1.0.5: {} - strnum@1.1.2: {} + strnum@2.1.1: {} + strongly-connected-components@1.0.1: {} style-inject@0.3.0: {}