UNPKG

fomod

Version:

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

464 lines (334 loc) 18.8 kB
import { ensureXmlDoctype, } from "../../DomUtils"; import { DependenciesGroup } from "./dependencies/DependenciesGroup"; import { InvalidityReason, InvalidityReport } from "../lib/InvalidityReporting"; import { ElementObjectMap, Verifiable, XmlRepresentation } from "../lib/XmlRepresentation"; import { AttributeName, BooleanString, TagName } from "../Enums"; import { FomodDocumentConfig } from "../lib/FomodDocumentConfig"; import type { Option } from "./Option"; import type { MaybeStrictBoolString, MaybeStrictIntString } from "../../TypeUtils"; /*** * $$$$$$\ $$\ $$\ $$$$$$$$\ $$\ $$\ * $$ __$$\ \__| $$ | $$ _____|\__|$$ | * $$ / \__|$$\ $$$$$$$\ $$$$$$\ $$ | $$$$$$\ $$ | $$\ $$ | $$$$$$\ * \$$$$$$\ $$ |$$ __$$\ $$ __$$\ $$ |$$ __$$\ $$$$$$\ $$$$$\ $$ |$$ |$$ __$$\ * \____$$\ $$ |$$ | $$ |$$ / $$ |$$ |$$$$$$$$ |\______|$$ __| $$ |$$ |$$$$$$$$ | * $$\ $$ |$$ |$$ | $$ |$$ | $$ |$$ |$$ ____| $$ | $$ |$$ |$$ ____| * \$$$$$$ |$$ |$$ | $$ |\$$$$$$$ |$$ |\$$$$$$$\ $$ | $$ |$$ |\$$$$$$$\ * \______/ \__|\__| \__| \____$$ |\__| \_______| \__| \__|\__| \_______| * $$\ $$ | * \$$$$$$ | * \______/ * $$$$$$\ $$\ $$\ $$\ * \_$$ _| $$ | $$ |$$ | * $$ | $$$$$$$\ $$$$$$$\ $$$$$$\ $$$$$$\ $$ |$$ | * $$ | $$ __$$\ $$ _____|\_$$ _| \____$$\ $$ |$$ | * $$ | $$ | $$ |\$$$$$$\ $$ | $$$$$$$ |$$ |$$ | * $$ | $$ | $$ | \____$$\ $$ |$$\ $$ __$$ |$$ |$$ | * $$$$$$\ $$ | $$ |$$$$$$$ | \$$$$ |\$$$$$$$ |$$ |$$ | * \______|\__| \__|\_______/ \____/ \_______|\__|\__| * * * */ export interface InstallInstances { all: Set<Install<boolean>>; bySource: Map<string, Set<Install<boolean>>>; byDestination: Map<string, Set<Install<boolean>>>; } /** A [weak map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/WeakMap) of documents to flag instances within that document. * * This map allows for quick access to all flag instances within a document as well as all flag instances with a given name. */ export const InstallInstancesByDocument = new WeakMap<Document, InstallInstances>(); type InstallTagName = TagName.File|TagName.Folder; export class Install<TStrict extends boolean> extends XmlRepresentation<TStrict> { static override readonly tagName = [TagName.File, TagName.Folder] as [TagName.File, TagName.Folder]; tagName: InstallTagName = TagName.File; // Very interchangeable; /** A list of documents this install is a part of */ documents: Set<Document> = new Set(); isValid(): this is Install<true> { try { BigInt(this.priority); } catch { return false; } return ( Object.values(BooleanString).includes(this.alwaysInstall as any) && Object.values(BooleanString).includes(this.installIfUsable as any) ); } reasonForInvalidity(...tree: Omit<Verifiable<false>, 'isValid' | 'reasonForInvalidity'>[]): InvalidityReport | null { tree.push(this); try { BigInt(this.priority); } catch { return { reason: InvalidityReason.InstallPriorityNotInteger, offendingValue: this.priority, tree }; } if (!Object.values(BooleanString).includes(this.alwaysInstall as any)) return { reason: InvalidityReason.InstallAlwaysInstallNotBoolean, offendingValue: this.priority, tree }; if (!Object.values(BooleanString).includes(this.installIfUsable as any)) return { reason: InvalidityReason.InstallInstallIfUsableNotBoolean, offendingValue: this.priority, tree }; return null; } constructor( /** File path relative to the archive root to install this file from. * * Whether a folder is being installed or not will be determined by if the path ends with a slash. It **WILL** cause errors later down the line if the source and destination paths are not the same type. */ public fileSource: string = '', /** File path relative to the archive root to install this file from. If no destination is provided, the file will be installed to the same path as the source. * * Whether a folder is being installed or not will be determined by if the path ends with a slash. It **WILL** cause errors later down the line if the source and destination paths are not the same type. */ public fileDestination: string | null = null, /** The priority of this file install. Higher priority files will be installed first. Must be an integer. * * Defaults to `0`. If the value is `0`, the value will not be written to the element. */ public priority: MaybeStrictIntString<TStrict> = '0', document?: Document, /** Whether to always install the file if it is considered "usable" * * @deprecated Has inconsistent behavior between mod managers. Instead, you might consider duplicating the `dependencies` object to specify when a file should be installed. Included for completeness. */ public installIfUsable: MaybeStrictBoolString<TStrict> = BooleanString.false, /** Whether to always install the file, even if the user has not selected it. * * @deprecated Has inconsistent behavior between mod managers. Instead, you might consider removing the `dependencies` object instead. Included for completeness. */ public alwaysInstall: MaybeStrictBoolString<TStrict> = BooleanString.false, ) { super(); if (document) this.attachDocument(document); } /** Generates an XML element from this object. */ asElement(document: Document, config: FomodDocumentConfig = {}): Element { if (this.fileSource.endsWith('/') || this.fileSource.endsWith('\\')) { if (this.fileDestination && (!this.fileDestination.endsWith('/') && !this.fileDestination.endsWith('\\'))) throw new Error('Source is a folder but destination is not', {cause: this}); this.tagName = TagName.Folder; } else if (this.fileDestination && (this.fileDestination.endsWith('/') || this.fileDestination.endsWith('\\'))) throw new Error('Destination is a folder but source is not', {cause: this}); else this.tagName = TagName.File; const element = this.getElementForDocument(document); this.associateWithDocument(document); element.setAttribute(AttributeName.Source, this.fileSource); if (this.fileDestination) element.setAttribute(AttributeName.Destination, this.fileDestination); if (this.priority !== '0') element.setAttribute(AttributeName.Priority, this.priority); else element.removeAttribute(AttributeName.Priority); if (this.alwaysInstall !== BooleanString.false) element.setAttribute(AttributeName.AlwaysInstall, this.alwaysInstall); else element.removeAttribute(AttributeName.AlwaysInstall); if (this.installIfUsable !== BooleanString.false) element.setAttribute(AttributeName.InstallIfUsable, this.installIfUsable); else element.removeAttribute(AttributeName.InstallIfUsable); return element; } static override parse(element: Element, config: FomodDocumentConfig = {}): Install<boolean> { let source = element.getAttribute(AttributeName.Source) ?? ''; let destination = element.getAttribute(AttributeName.Destination) ?? null; if (element.tagName === TagName.Folder) { if (!source.endsWith('/')) source += '/'; if (destination && !destination.endsWith('/')) destination += '/'; } const install = new Install<boolean>( source, destination, element.getAttribute(AttributeName.Priority) ?? '0' ); install.assignElement(element); install.alwaysInstall = element.getAttribute(AttributeName.AlwaysInstall) ?? BooleanString.false; install.installIfUsable = element.getAttribute(AttributeName.InstallIfUsable) ?? BooleanString.false; return install; } // Overwrite to handle the interchangeable `file` and `folder` tags override getElementForDocument(document: Document): Element { ensureXmlDoctype(document); const existingElement = super.getElementForDocument(document); if (existingElement.tagName === this.tagName) return existingElement; this.documentMap.delete(document); const newElement = this.getElementForDocument(document); for (let i = 0 - 1; i < existingElement.attributes.length; i++) newElement.setAttributeNode(existingElement.attributes[i]!.cloneNode(true) as Attr); newElement.replaceChildren(...existingElement.children); existingElement.replaceWith(newElement); return newElement; } // Overwrite to handle the interchangeable `file` and `folder` tags override assignElement(element: Element) { ensureXmlDoctype(element.ownerDocument); if (element.tagName === TagName.File || element.tagName === TagName.Folder) this.tagName = element.tagName; super.assignElement(element); } /** Attaches this flag instance to a document */ attachDocument(document: Document) { this.documents.add(document); let instancesForDoc = InstallInstancesByDocument.get(document); if (!instancesForDoc) { instancesForDoc = { all: new Set(), bySource: new Map(), byDestination: new Map(), }; InstallInstancesByDocument.set(document, instancesForDoc); } instancesForDoc.all.add(this); let instancesBySourceSet = instancesForDoc.bySource.get(this.fileSource); if (!instancesBySourceSet) { instancesBySourceSet = new Set(); instancesForDoc.bySource.set(this.fileSource, instancesBySourceSet); } instancesBySourceSet.add(this); let instancesByDestinationSet = instancesForDoc.byDestination.get(this.fileDestination ?? this.fileSource); if (!instancesByDestinationSet) { instancesByDestinationSet = new Set(); instancesForDoc.byDestination.set(this.fileDestination ?? this.fileSource, instancesByDestinationSet); } instancesByDestinationSet.add(this); } /** Removes this flag instance from a document */ removeFromDocument(document: Document) { const instancesForDoc = InstallInstancesByDocument.get(document); if (!instancesForDoc) { this.documents.delete(document); return; } instancesForDoc.all.delete(this); instancesForDoc.bySource.get(this.fileSource)?.delete(this); instancesForDoc.byDestination.get(this.fileDestination ?? this.fileSource)?.delete(this); this.documents.delete(document); } associateWithDocument(document: Document) { if (!this.documents.has(document)) this.attachDocument(document); } decommission(currentDocument?: Document) { if (currentDocument) this.removeFromDocument(currentDocument); else this.documents.forEach(document => this.removeFromDocument(document)); } } /*** * $$$$$$$$\ $$\ $$\ $$\ $$\ * $$ _____|\__|$$ | $$ | $\ $$ | * $$ | $$\ $$ | $$$$$$\ $$$$$$$\ $$ |$$$\ $$ | $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ * $$$$$\ $$ |$$ |$$ __$$\ $$ _____| $$ $$ $$\$$ |$$ __$$\ \____$$\ $$ __$$\ $$ __$$\ $$ __$$\ $$ __$$\ * $$ __| $$ |$$ |$$$$$$$$ |\$$$$$$\ $$$$ _$$$$ |$$ | \__| $$$$$$$ |$$ / $$ |$$ / $$ |$$$$$$$$ |$$ | \__| * $$ | $$ |$$ |$$ ____| \____$$\ $$$ / \$$$ |$$ | $$ __$$ |$$ | $$ |$$ | $$ |$$ ____|$$ | * $$ | $$ |$$ |\$$$$$$$\ $$$$$$$ | $$ / \$$ |$$ | \$$$$$$$ |$$$$$$$ |$$$$$$$ |\$$$$$$$\ $$ | * \__| \__|\__| \_______|\_______/ \__/ \__|\__| \_______|$$ ____/ $$ ____/ \_______|\__| * $$ | $$ | * $$ | $$ | * \__| \__| */ /** A helper class to represent the <files> element. Contains a list of files to be installed by a dependency or option. */ export class InstallPatternFilesWrapper<TStrict extends boolean> extends XmlRepresentation<TStrict> { static override tagName = TagName.Files; readonly tagName = TagName.Files; constructor( public installs: Set<Install<TStrict>> = new Set(), ) { super(); } override asElement(document: Document, config: FomodDocumentConfig = {}): Element { const el = this.getElementForDocument(document); this.associateWithDocument(document); for(const install of this.installs.values()) el.appendChild(install.asElement(document, config)); return el; } override isValid(): this is InstallPatternFilesWrapper<true> { for(const install of this.installs.values()) if (!install.isValid()) return false; return true; } static override parse(element: Element, config: FomodDocumentConfig = {}): InstallPatternFilesWrapper<boolean> { const existing = ElementObjectMap.get(element); if (existing && existing instanceof this) return existing; const installs = new Set<Install<boolean>>(); for (const child of element.children) { const install = Install.parse(child, config); installs.add(install); } const obj = new InstallPatternFilesWrapper(installs); obj.assignElement(element); return obj; } reasonForInvalidity(...tree: Omit<Verifiable<false>, 'isValid' | 'reasonForInvalidity'>[]): InvalidityReport | null { tree.push(this); for (const install of this.installs.values()) { const reason = install.reasonForInvalidity(...tree); if (reason) return reason; } return null; } associateWithDocument(document: Document) { this.installs.forEach(i => i.associateWithDocument(document)); } decommission(currentDocument?: Document ) { this.installs.forEach(install => install.decommission(currentDocument)); } } /*** * $$$$$$\ $$\ $$\ $$\ * \_$$ _| $$ | $$ |$$ | * $$ | $$$$$$$\ $$$$$$$\ $$$$$$\ $$$$$$\ $$ |$$ | * $$ | $$ __$$\ $$ _____|\_$$ _| \____$$\ $$ |$$ | * $$ | $$ | $$ |\$$$$$$\ $$ | $$$$$$$ |$$ |$$ | * $$ | $$ | $$ | \____$$\ $$ |$$\ $$ __$$ |$$ |$$ | * $$$$$$\ $$ | $$ |$$$$$$$ | \$$$$ |\$$$$$$$ |$$ |$$ | * \______|\__| \__|\_______/ \____/ \_______|\__|\__| * * * * $$$$$$$\ $$\ $$\ * $$ __$$\ $$ | $$ | * $$ | $$ | $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$\ $$$$$$$\ * $$$$$$$ | \____$$\ \_$$ _| \_$$ _| $$ __$$\ $$ __$$\ $$ __$$\ * $$ ____/ $$$$$$$ | $$ | $$ | $$$$$$$$ |$$ | \__|$$ | $$ | * $$ | $$ __$$ | $$ |$$\ $$ |$$\ $$ ____|$$ | $$ | $$ | * $$ | \$$$$$$$ | \$$$$ | \$$$$ |\$$$$$$$\ $$ | $$ | $$ | * \__| \_______| \____/ \____/ \_______|\__| \__| \__| * * * */ /** A helper class to represent the <pattern> element. Contains a list of files to install and a list of dependencies that must first be fulfilled. */ export class InstallPattern<TStrict extends boolean> extends XmlRepresentation<TStrict> { static override tagName = TagName.Pattern; readonly tagName = TagName.Pattern; constructor( public dependencies: DependenciesGroup<TagName.Dependencies, TStrict> = new DependenciesGroup<TagName.Dependencies, TStrict>(TagName.Dependencies), public filesWrapper: InstallPatternFilesWrapper<TStrict> = new InstallPatternFilesWrapper(), ) { super(); } override asElement(document: Document, config: FomodDocumentConfig = {}, knownOptions: Option<boolean>[] = []): Element { const el = this.getElementForDocument(document); this.associateWithDocument(document); el.appendChild(this.dependencies.asElement(document, config, knownOptions)); el.appendChild(this.filesWrapper.asElement(document, config)); return el; } override isValid(): this is InstallPattern<true> { return this.filesWrapper.isValid() && (this.dependencies ? this.dependencies.isValid() : true); } static override parse(element: Element, config: FomodDocumentConfig = {}): InstallPattern<boolean> { const existing = ElementObjectMap.get(element); if (existing && existing instanceof this) return existing; const dependenciesElement = element.querySelector(`:scope > ${TagName.Dependencies}`); const dependencies = dependenciesElement ? DependenciesGroup.parse<TagName.Dependencies>(dependenciesElement) : undefined; const filesElement = element.querySelector(`:scope > ${TagName.Files}`); const filesWrapper = filesElement ? InstallPatternFilesWrapper.parse(filesElement, config) : undefined; const obj = new InstallPattern(dependencies, filesWrapper); obj.assignElement(element); return obj; } reasonForInvalidity(...tree: Omit<Verifiable<false>, 'isValid' | 'reasonForInvalidity'>[]): InvalidityReport | null { tree.push(this); for (const install of this.filesWrapper.installs.values()) { const reason = install.reasonForInvalidity(...tree); if (reason) return reason; } if (this.dependencies && !this.dependencies.isValid()) { const reason = this.dependencies.reasonForInvalidity(...tree); if (reason) return reason; } return null; } decommission(currentDocument?: Document ) { this.filesWrapper.decommission(currentDocument); this.dependencies?.decommission(currentDocument); } associateWithDocument(document: Document) { this.filesWrapper.installs.forEach(i => i.associateWithDocument(document)); this.dependencies?.associateWithDocument(document); } }