UNPKG

fomod

Version:

A library for creating, parsing, editing, and validating XML-based Fomod installers, widely popularized in the Bethesda modding scene

312 lines (244 loc) 15.4 kB
import { XmlNamespaces, attrToObject, getOrCreateElementByTagNameSafe } from "../../DomUtils"; import { DependenciesGroup } from "./dependencies/DependenciesGroup"; import { Install, InstallPattern } from "./Install"; import { InvalidityReason, InvalidityReport } from "../lib/InvalidityReporting"; import { Step } from "./Step"; import { ElementObjectMap, Verifiable, XmlRepresentation } from "../lib/XmlRepresentation"; import { AttributeName, BooleanString, ModuleNamePosition, SortingOrder, TagName } from "../Enums"; import { Option } from "./Option"; import { countDependencyConditions, gatherDependedUponOptions, gatherFlagDependencies } from "../lib/utils"; import { DefaultFomodDocumentConfig, FomodDocumentConfig } from "../lib/FomodDocumentConfig"; import { parseOptionFlags } from "../lib/ParseOptionFlags"; import type { MaybeStrictBoolString, MaybeStrictIntString, MaybeStrictString } from "../../TypeUtils"; export interface ModuleImageMetadata<TStrict extends boolean> { showFade?: MaybeStrictBoolString<TStrict>; showImage?: MaybeStrictBoolString<TStrict>; height?: MaybeStrictIntString<TStrict>; } export interface ModuleNameMetadata<TStrict extends boolean> { position?: MaybeStrictString<ModuleNamePosition, TStrict>; /** Must be an XML-valid hex string. XML-valid hex strings accept the standard `1234567890ABCDEF` range and the length must be even * * Should ideally appear as a 6-digit hex string, however this is not enforced by the schema. */ colour?: string; } /** A FOMOD installer in its entirety. * * @template TStrict Whether or not to use strict typing for this class. Any data parsed from user input should be considered untrusted and thus `false` should be used. Otherwise, `true` should be used. */ export class Fomod<TStrict extends boolean> extends XmlRepresentation<TStrict> { static override readonly tagName = TagName.Config; readonly tagName = TagName.Config; constructor( /** The name of the FOMOD. Must be specified in the installer however can be an empty string. * * Note that Info.xml also has its own name. */ public moduleName: string = '', public moduleImage: string | null = null, /** Dependencies required for this FOMOD to be shown to the user. * * Mod managers will show the user an error message if attempting to install a FOMOD that does not meet the requirements specified here. */ public moduleDependencies: DependenciesGroup<TagName.ModuleDependencies, TStrict> = new DependenciesGroup<TagName.ModuleDependencies, TStrict>(TagName.ModuleDependencies), /** Top-level file installs for the FOMOD * * Covers both the `requiredInstallFiles` and `conditionalFileInstalls` tags. */ public installs: Set<Install<TStrict> | InstallPattern<TStrict>> = new Set(), public sortingOrder: MaybeStrictString<SortingOrder, TStrict> = SortingOrder.Ascending, public steps: Set<Step<TStrict>> = new Set(), /** Unused metadata for `moduleName`. Included for completeness. * * @deprecated */ public moduleNameMetadata: (TStrict extends true ? Record<never, string> : Record<string, string>) & ModuleNameMetadata<TStrict> = {}, /** Unused metadata for `moduleImage`. Included for completeness. * * @deprecated */ public moduleImageMetadata: (TStrict extends true ? Record<never, string> : Record<Exclude<string, 'path'>, string>) & ModuleImageMetadata<TStrict> = {}, ) { super(); } isValid(): this is Fomod<true> { return ( (!this.moduleNameMetadata.position || Object.values(ModuleNamePosition).includes(this.moduleNameMetadata.position as any)) && (!this.moduleImageMetadata.showFade || Object.values(BooleanString).includes(this.moduleImageMetadata.showFade as any)) && (!this.moduleImageMetadata.showImage || Object.values(BooleanString).includes(this.moduleImageMetadata.showImage as any)) && !isNaN(parseInt(this.moduleNameMetadata.colour ?? '2', 16)) && !isNaN(parseInt(this.moduleImageMetadata.height ?? '1')) && (this.moduleDependencies?.isValid() ?? true) && Array.from(this.steps.values()).every(step => step.isValid()) && Array.from(this.installs.values()).every(installOrPatterns => installOrPatterns.isValid()) ); } reasonForInvalidity(...tree: Omit<Verifiable<false>, "isValid" | "reasonForInvalidity">[]): InvalidityReport | null { tree.push(this); if (this.moduleNameMetadata.position && !Object.values(ModuleNamePosition).includes(this.moduleNameMetadata.position as any)) return { tree, offendingValue: this.moduleNameMetadata.position, reason: InvalidityReason.FomodModuleNameMetadataInvalidPosition, }; if (isNaN(parseInt(this.moduleNameMetadata.colour ?? '2', 16))) return { tree, offendingValue: this.moduleNameMetadata.colour!, reason: InvalidityReason.FomodModuleNameMetadataColorHexNotHexNumber, }; if (this.moduleImageMetadata.showFade && !Object.values(BooleanString).includes(this.moduleImageMetadata.showFade as any)) return { tree, offendingValue: this.moduleImageMetadata.showFade, reason: InvalidityReason.FomodModuleImageMetadataShowFadeNotBool, }; if (this.moduleImageMetadata.showImage && !Object.values(BooleanString).includes(this.moduleImageMetadata.showImage as any)) return { tree, offendingValue: this.moduleImageMetadata.showImage, reason: InvalidityReason.FomodModuleImageMetadataShowImageNotBool, }; for (const step of this.steps) { const reason = step.reasonForInvalidity(...tree); if (reason) return reason; } for (const installOrPattern of this.installs) { const reason = installOrPattern.reasonForInvalidity(...tree); if (reason) return reason; } return null; } asElement(document: Document, config: FomodDocumentConfig = {}, knownOptions: Option<boolean>[] = this.gatherOptions()): Element { const element = this.getElementForDocument(document); this.associateWithDocument(document); if (config.generateNewOptionFlagNames ?? DefaultFomodDocumentConfig.generateNewOptionFlagNames) { for (const option of knownOptions) { option.existingOptionFlagSetterByDocument.get(document)?.decommission(); option.existingOptionFlagSetterByDocument.delete(document); } config = Object.assign({}, config, {generateNewOptionFlagNames: false}); } // Schemas are mandatory for ModuleConfig.xml element.setAttributeNS(XmlNamespaces.XMLNS, 'xmlns:xsi', XmlNamespaces.XSI); element.setAttributeNS(XmlNamespaces.XSI, 'xsi:noNamespaceSchemaLocation', 'http://qconsulting.ca/fo3/ModConfig5.0.xsd'); const moduleNameElement = getOrCreateElementByTagNameSafe(element, TagName.ModuleName); moduleNameElement.textContent = this.moduleName; for (const [key, value] of Object.entries(this.moduleNameMetadata) as [string, string | undefined][]) { if (!value) continue; if (key !== AttributeName.Color) moduleNameElement.setAttribute(key, value); else { // `xs:hexBinary` values are required to have an even number of digits. This enforces that so long as the color value is a valid hex number. let colorString = value; if (!isNaN(parseInt(colorString, 16)) && colorString.length % 2 !== 0) colorString = `0${colorString}`; moduleNameElement.setAttribute(key, colorString); } } const moduleImageElement = getOrCreateElementByTagNameSafe(element, TagName.ModuleImage); if (this.moduleImage === null) moduleImageElement.remove(); else { for (const [key, value] of Object.entries(this.moduleImageMetadata)) moduleImageElement.setAttribute(key, value); moduleImageElement.setAttribute(AttributeName.Path, this.moduleImage); } if (this.moduleDependencies.dependencies.size > 0) element.appendChild(this.moduleDependencies.asElement(document, config, knownOptions)); else this.moduleDependencies.getElementForDocument(document).remove(); const requiredInstallContainer = getOrCreateElementByTagNameSafe(element, TagName.RequiredInstallFiles); const conditionalInstallContainerRoot = getOrCreateElementByTagNameSafe(element, TagName.ConditionalFileInstalls); const conditionalInstallContainer = getOrCreateElementByTagNameSafe(conditionalInstallContainerRoot, TagName.Patterns); for (const installOrPattern of this.installs) { if (installOrPattern instanceof Install) { requiredInstallContainer.appendChild(installOrPattern.asElement(document, config)); continue; } const el = installOrPattern.asElement(document, config, knownOptions); const depCount = countDependencyConditions(installOrPattern.dependencies.dependencies); if ((config.flattenConditionalInstalls ?? DefaultFomodDocumentConfig.flattenConditionalInstalls) && depCount === 1) { const optionDependencies = gatherDependedUponOptions(installOrPattern.dependencies); if (optionDependencies.size === 1) { const option = optionDependencies.values().next().value as Option<boolean>; for (const install of installOrPattern.filesWrapper.installs) option.installsToSet.filesWrapper.installs.add(install); el.remove(); continue; } } if (depCount === 0) { if ((config.removeEmptyConditionalInstalls ?? DefaultFomodDocumentConfig.removeEmptyConditionalInstalls) && installOrPattern.filesWrapper.installs.size === 0) { el.remove(); continue; } else if (config.flattenConditionalInstallsNoDependencies ?? DefaultFomodDocumentConfig.flattenConditionalInstallsNoDependencies) { installOrPattern.filesWrapper.installs.forEach(install => requiredInstallContainer.appendChild(install.asElement(document, config))); el.remove(); continue; } } conditionalInstallContainer.appendChild(el); } if (requiredInstallContainer.children.length === 0) requiredInstallContainer.remove(); else element.appendChild(requiredInstallContainer); const stepContainer = getOrCreateElementByTagNameSafe(element, TagName.InstallSteps); for (const step of this.steps) stepContainer.appendChild(step.asElement(document, config)); if (stepContainer.children.length === 0) stepContainer.remove(); else { stepContainer.setAttribute(AttributeName.Order, this.sortingOrder); element.appendChild(stepContainer); } if (conditionalInstallContainer.children.length === 0) conditionalInstallContainerRoot.remove(); else element.appendChild(conditionalInstallContainerRoot); return element; } gatherOptions(): Option<TStrict>[] { const options: Option<TStrict>[] = []; for (const step of this.steps) options.push(...step.gatherOptions()); return options; } static override parse(element: Element, config: FomodDocumentConfig = {}): Fomod<boolean> { const existing = ElementObjectMap.get(element); if (existing && existing instanceof this) return existing; const moduleNameElement = element.querySelector(`:scope > ${TagName.ModuleName}`); const ModuleImageElement = element.querySelector(`:scope > ${TagName.ModuleImage}`); const moduleName = moduleNameElement?.textContent ?? ''; const moduleImage = ModuleImageElement?.getAttribute(AttributeName.Path) ?? null; const fomod = new Fomod<false>(moduleName, moduleImage); fomod.assignElement(element); fomod.moduleNameMetadata = attrToObject(moduleNameElement?.attributes ?? []); fomod.moduleImageMetadata = attrToObject(ModuleImageElement?.attributes ?? []); fomod.moduleImageMetadata.path = ''; const moduleDependencies = element.querySelector(`:scope > ${TagName.ModuleDependencies}`); if (moduleDependencies) fomod.moduleDependencies = DependenciesGroup.parse(moduleDependencies, config); for (const install of element.querySelectorAll(`:scope > ${TagName.RequiredInstallFiles} > :is(${TagName.File}, ${TagName.Folder})`)) { const parsed = Install.parse(install, config); if (parsed) fomod.installs.add(parsed); } fomod.sortingOrder = element.querySelector(`:scope > ${TagName.InstallStep}`)?.getAttribute(AttributeName.Order) ?? SortingOrder.Ascending; for (const install of element.querySelectorAll(`:scope > ${TagName.ConditionalFileInstalls} > ${TagName.Patterns} > ${TagName.Pattern}`)) { const parsed = InstallPattern.parse(install, config); if (parsed) fomod.installs.add(parsed); } let configForSteps = config; if (configForSteps.parseOptionFlags) configForSteps = Object.assign({}, configForSteps, {parseOptionFlags: false}); for (const step of element.querySelectorAll(`:scope > ${TagName.InstallSteps} > ${TagName.InstallStep}`)) { const parsed = Step.parse(step, configForSteps); if (parsed) fomod.steps.add(parsed); } if (config.parseOptionFlags ?? DefaultFomodDocumentConfig.parseOptionFlags) { const dependencies = Array.from(gatherFlagDependencies(fomod.moduleDependencies)); for (const install of fomod.installs) if (install instanceof InstallPattern) dependencies.push(...gatherFlagDependencies(install.dependencies)); for (const step of fomod.steps) dependencies.push(...gatherFlagDependencies(step.visibilityDeps)); const options = fomod.gatherOptions(); for (const option of options) { for (const pattern of option.typeDescriptor.patterns) { dependencies.push(...gatherFlagDependencies(pattern.dependencies)); } } parseOptionFlags(options, element.ownerDocument!, config, dependencies); } return fomod; } decommission(currentDocument?: Document) { this.moduleDependencies.decommission?.(currentDocument!); this.steps.forEach(step => step.decommission?.(currentDocument!)); this.installs.forEach(install => install.decommission?.(currentDocument!)); } associateWithDocument(document: Document) { this.moduleDependencies.associateWithDocument(document); this.steps.forEach(step => step.associateWithDocument(document)); this.installs.forEach(install => install.associateWithDocument(document)); } }