diff options
Diffstat (limited to 'src/data')
-rw-r--r-- | src/data/cacheable-object.js | 394 | ||||
-rw-r--r-- | src/data/patches.js | 609 | ||||
-rw-r--r-- | src/data/serialize.js | 32 | ||||
-rw-r--r-- | src/data/things.js | 2722 | ||||
-rw-r--r-- | src/data/validators.js | 400 | ||||
-rw-r--r-- | src/data/yaml.js | 2159 |
6 files changed, 3358 insertions, 2958 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index 4afb0368..fe1817f6 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -1,3 +1,7 @@ +/** + * @format + */ + // Generally extendable class for caching properties and handling dependencies, // with a few key properties: // @@ -74,21 +78,21 @@ // function, which provides a mapping of exposed property names to whether // or not their dependencies are yet met. -import { color, ENABLE_COLOR } from '../util/cli.js'; +import {color, ENABLE_COLOR} from '../util/cli.js'; -import { inspect as nodeInspect } from 'util'; +import {inspect as nodeInspect} from 'util'; function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); + return nodeInspect(value, {colors: ENABLE_COLOR}); } export default class CacheableObject { - static instance = Symbol('CacheableObject `this` instance'); + static instance = Symbol('CacheableObject `this` instance'); - #propertyUpdateValues = Object.create(null); - #propertyUpdateCacheInvalidators = Object.create(null); + #propertyUpdateValues = Object.create(null); + #propertyUpdateCacheInvalidators = Object.create(null); - /* + /* // Note the constructor doesn't take an initial data source. Due to a quirk // of JavaScript, private members can't be accessed before the superclass's // constructor is finished processing - so if we call the overridden @@ -99,211 +103,233 @@ export default class CacheableObject { // after constructing the new instance of the Thing (sub)class. */ - constructor() { - this.#defineProperties(); - this.#initializeUpdatingPropertyValues(); - - if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return new Proxy(this, { - get: (obj, key) => { - if (!Object.hasOwn(obj, key)) { - if (key !== 'constructor') { - CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`); - } - } - return obj[key]; - } - }); - } - } - - #initializeUpdatingPropertyValues() { - for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) { - const { flags, update } = descriptor; - - if (!flags.update) { - continue; - } - - if (update?.default) { - this[property] = update?.default; - } else { - this[property] = null; + constructor() { + this.#defineProperties(); + this.#initializeUpdatingPropertyValues(); + + if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { + return new Proxy(this, { + get: (obj, key) => { + if (!Object.hasOwn(obj, key)) { + if (key !== 'constructor') { + CacheableObject._invalidAccesses.add( + `(${obj.constructor.name}).${key}` + ); } - } + } + return obj[key]; + }, + }); } - - #defineProperties() { - if (!this.constructor.propertyDescriptors) { - throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`); - } - - for (const [ property, descriptor ] of Object.entries(this.constructor.propertyDescriptors)) { - const { flags } = descriptor; - - const definition = { - configurable: false, - enumerable: true - }; - - if (flags.update) { - definition.set = this.#getUpdateObjectDefinitionSetterFunction(property); - } - - if (flags.expose) { - definition.get = this.#getExposeObjectDefinitionGetterFunction(property); - } - - Object.defineProperty(this, property, definition); - } - - Object.seal(this); + } + + #initializeUpdatingPropertyValues() { + for (const [property, descriptor] of Object.entries( + this.constructor.propertyDescriptors + )) { + const {flags, update} = descriptor; + + if (!flags.update) { + continue; + } + + if (update?.default) { + this[property] = update?.default; + } else { + this[property] = null; + } } + } - #getUpdateObjectDefinitionSetterFunction(property) { - const { update } = this.#getPropertyDescriptor(property); - const validate = update?.validate; - const allowNull = update?.allowNull; - - return (newValue) => { - const oldValue = this.#propertyUpdateValues[property]; + #defineProperties() { + if (!this.constructor.propertyDescriptors) { + throw new Error( + `Expected constructor ${this.constructor.name} to define propertyDescriptors` + ); + } - if (newValue === undefined) { - throw new TypeError(`Properties cannot be set to undefined`); - } + for (const [property, descriptor] of Object.entries( + this.constructor.propertyDescriptors + )) { + const {flags} = descriptor; - if (newValue === oldValue) { - return; - } + const definition = { + configurable: false, + enumerable: true, + }; - if (newValue !== null && validate) { - try { - const result = validate(newValue); - if (result === undefined) { - throw new TypeError(`Validate function returned undefined`); - } else if (result !== true) { - throw new TypeError(`Validation failed for value ${newValue}`); - } - } catch (error) { - error.message = `Property ${color.green(property)} (${inspect(this[property])} -> ${inspect(newValue)}): ${error.message}`; - throw error; - } - } + if (flags.update) { + definition.set = + this.#getUpdateObjectDefinitionSetterFunction(property); + } - this.#propertyUpdateValues[property] = newValue; - this.#invalidateCachesDependentUpon(property); - }; - } + if (flags.expose) { + definition.get = + this.#getExposeObjectDefinitionGetterFunction(property); + } - #getUpdatePropertyValidateFunction(property) { - const descriptor = this.#getPropertyDescriptor(property); + Object.defineProperty(this, property, definition); } - #getPropertyDescriptor(property) { - return this.constructor.propertyDescriptors[property]; - } - - #invalidateCachesDependentUpon(property) { - for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || []) { - invalidate(); - } - } - - #getExposeObjectDefinitionGetterFunction(property) { - const { flags } = this.#getPropertyDescriptor(property); - const compute = this.#getExposeComputeFunction(property); - - if (compute) { - let cachedValue; - const checkCacheValid = this.#getExposeCheckCacheValidFunction(property); - return () => { - if (checkCacheValid()) { - return cachedValue; - } else { - return (cachedValue = compute()); - } - }; - } else if (!flags.update && !compute) { - throw new Error(`Exposed property ${property} does not update and is missing compute function`); - } else { - return () => this.#propertyUpdateValues[property]; + Object.seal(this); + } + + #getUpdateObjectDefinitionSetterFunction(property) { + const {update} = this.#getPropertyDescriptor(property); + const validate = update?.validate; + + return (newValue) => { + const oldValue = this.#propertyUpdateValues[property]; + + if (newValue === undefined) { + throw new TypeError(`Properties cannot be set to undefined`); + } + + if (newValue === oldValue) { + return; + } + + if (newValue !== null && validate) { + try { + const result = validate(newValue); + if (result === undefined) { + throw new TypeError(`Validate function returned undefined`); + } else if (result !== true) { + throw new TypeError(`Validation failed for value ${newValue}`); + } + } catch (error) { + error.message = `Property ${color.green(property)} (${inspect( + this[property] + )} -> ${inspect(newValue)}): ${error.message}`; + throw error; } - } - - #getExposeComputeFunction(property) { - const { flags, expose } = this.#getPropertyDescriptor(property); - - const compute = expose?.compute; - const transform = expose?.transform; + } - if (flags.update && !transform) { - return null; - } else if (flags.update && compute) { - throw new Error(`Updating property ${property} has compute function, should be formatted as transform`); - } else if (!flags.update && !compute) { - throw new Error(`Exposed property ${property} does not update and is missing compute function`); - } + this.#propertyUpdateValues[property] = newValue; + this.#invalidateCachesDependentUpon(property); + }; + } - const dependencyKeys = expose.dependencies || []; - const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]); - const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f()) - .concat([[this.constructor.instance, this]])); + #getPropertyDescriptor(property) { + return this.constructor.propertyDescriptors[property]; + } - if (flags.update) { - return () => transform(this.#propertyUpdateValues[property], getAllDependencies()); + #invalidateCachesDependentUpon(property) { + for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || + []) { + invalidate(); + } + } + + #getExposeObjectDefinitionGetterFunction(property) { + const {flags} = this.#getPropertyDescriptor(property); + const compute = this.#getExposeComputeFunction(property); + + if (compute) { + let cachedValue; + const checkCacheValid = this.#getExposeCheckCacheValidFunction(property); + return () => { + if (checkCacheValid()) { + return cachedValue; } else { - return () => compute(getAllDependencies()); + return (cachedValue = compute()); } + }; + } else if (!flags.update && !compute) { + throw new Error( + `Exposed property ${property} does not update and is missing compute function` + ); + } else { + return () => this.#propertyUpdateValues[property]; + } + } + + #getExposeComputeFunction(property) { + const {flags, expose} = this.#getPropertyDescriptor(property); + + const compute = expose?.compute; + const transform = expose?.transform; + + if (flags.update && !transform) { + return null; + } else if (flags.update && compute) { + throw new Error( + `Updating property ${property} has compute function, should be formatted as transform` + ); + } else if (!flags.update && !compute) { + throw new Error( + `Exposed property ${property} does not update and is missing compute function` + ); } - #getExposeCheckCacheValidFunction(property) { - const { flags, expose } = this.#getPropertyDescriptor(property); - - let valid = false; + const dependencyKeys = expose.dependencies || []; + const dependencyGetters = dependencyKeys.map((key) => () => [ + key, + this.#propertyUpdateValues[key], + ]); + const getAllDependencies = () => + Object.fromEntries( + dependencyGetters + .map((f) => f()) + .concat([[this.constructor.instance, this]]) + ); + + if (flags.update) { + return () => + transform(this.#propertyUpdateValues[property], getAllDependencies()); + } else { + return () => compute(getAllDependencies()); + } + } - const invalidate = () => { - valid = false; - }; + #getExposeCheckCacheValidFunction(property) { + const {flags, expose} = this.#getPropertyDescriptor(property); - const dependencyKeys = new Set(expose?.dependencies); + let valid = false; - if (flags.update) { - dependencyKeys.add(property); - } + const invalidate = () => { + valid = false; + }; - for (const key of dependencyKeys) { - if (this.#propertyUpdateCacheInvalidators[key]) { - this.#propertyUpdateCacheInvalidators[key].push(invalidate); - } else { - this.#propertyUpdateCacheInvalidators[key] = [invalidate]; - } - } + const dependencyKeys = new Set(expose?.dependencies); - return () => { - if (!valid) { - valid = true; - return false; - } else { - return true; - } - }; + if (flags.update) { + dependencyKeys.add(property); } - static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false; - static _invalidAccesses = new Set(); + for (const key of dependencyKeys) { + if (this.#propertyUpdateCacheInvalidators[key]) { + this.#propertyUpdateCacheInvalidators[key].push(invalidate); + } else { + this.#propertyUpdateCacheInvalidators[key] = [invalidate]; + } + } - static showInvalidAccesses() { - if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return; - } + return () => { + if (!valid) { + valid = true; + return false; + } else { + return true; + } + }; + } + + static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false; + static _invalidAccesses = new Set(); + + static showInvalidAccesses() { + if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { + return; + } - if (!this._invalidAccesses.size) { - return; - } + if (!this._invalidAccesses.size) { + return; + } - console.log(`${this._invalidAccesses.size} unique invalid accesses:`); - for (const line of this._invalidAccesses) { - console.log(` - ${line}`); - } + console.log(`${this._invalidAccesses.size} unique invalid accesses:`); + for (const line of this._invalidAccesses) { + console.log(` - ${line}`); } + } } diff --git a/src/data/patches.js b/src/data/patches.js index 3ed4fad0..dc757fa9 100644 --- a/src/data/patches.js +++ b/src/data/patches.js @@ -1,370 +1,389 @@ +/** @format */ + // --> Patch export class Patch { - static INPUT_NONE = 0; - static INPUT_CONSTANT = 1; - static INPUT_DIRECT_CONNECTION = 2; - static INPUT_MANAGED_CONNECTION = 3; - - static INPUT_UNAVAILABLE = 0; - static INPUT_AVAILABLE = 1; + static INPUT_NONE = 0; + static INPUT_CONSTANT = 1; + static INPUT_DIRECT_CONNECTION = 2; + static INPUT_MANAGED_CONNECTION = 3; - static OUTPUT_UNAVAILABLE = 0; - static OUTPUT_AVAILABLE = 1; + static INPUT_UNAVAILABLE = 0; + static INPUT_AVAILABLE = 1; - static inputNames = []; inputNames = null; - static outputNames = []; outputNames = null; + static OUTPUT_UNAVAILABLE = 0; + static OUTPUT_AVAILABLE = 1; - manager = null; - inputs = Object.create(null); + static inputNames = []; + inputNames = null; + static outputNames = []; + outputNames = null; - constructor({ - manager, + manager = null; + inputs = Object.create(null); - inputNames, - outputNames, + constructor({ + manager, - inputs, - } = {}) { - this.inputNames = inputNames ?? this.constructor.inputNames; - this.outputNames = outputNames ?? this.constructor.outputNames; + inputNames, + outputNames, - manager?.addManagedPatch(this); + inputs, + } = {}) { + this.inputNames = inputNames ?? this.constructor.inputNames; + this.outputNames = outputNames ?? this.constructor.outputNames; - if (inputs) { - Object.assign(this.inputs, inputs); - } + manager?.addManagedPatch(this); - this.initializeInputs(); + if (inputs) { + Object.assign(this.inputs, inputs); } - initializeInputs() { - for (const inputName of this.inputNames) { - if (!this.inputs[inputName]) { - this.inputs[inputName] = [Patch.INPUT_NONE]; - } - } - } + this.initializeInputs(); + } - computeInputs() { - const inputs = Object.create(null); - - for (const inputName of this.inputNames) { - const input = this.inputs[inputName]; - switch (input[0]) { - case Patch.INPUT_NONE: - inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; - break; - - case Patch.INPUT_CONSTANT: - inputs[inputName] = [Patch.INPUT_AVAILABLE, input[1]]; - break; - - case Patch.INPUT_DIRECT_CONNECTION: { - const patch = input[1]; - const outputName = input[2]; - const output = patch.computeOutputs()[outputName]; - switch (output[0]) { - case Patch.OUTPUT_UNAVAILABLE: - inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; - break; - case Patch.OUTPUT_AVAILABLE: - inputs[inputName] = [Patch.INPUT_AVAILABLE, output[1]]; - break; - } - throw new Error('Unreachable'); - } - - case Patch.INPUT_MANAGED_CONNECTION: { - if (!this.manager) { - inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; - break; - } - - inputs[inputName] = this.manager.getManagedInput(input[1]); - break; - } - } + initializeInputs() { + for (const inputName of this.inputNames) { + if (!this.inputs[inputName]) { + this.inputs[inputName] = [Patch.INPUT_NONE]; + } + } + } + + computeInputs() { + const inputs = Object.create(null); + + for (const inputName of this.inputNames) { + const input = this.inputs[inputName]; + switch (input[0]) { + case Patch.INPUT_NONE: + inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; + break; + + case Patch.INPUT_CONSTANT: + inputs[inputName] = [Patch.INPUT_AVAILABLE, input[1]]; + break; + + case Patch.INPUT_DIRECT_CONNECTION: { + const patch = input[1]; + const outputName = input[2]; + const output = patch.computeOutputs()[outputName]; + switch (output[0]) { + case Patch.OUTPUT_UNAVAILABLE: + inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; + break; + case Patch.OUTPUT_AVAILABLE: + inputs[inputName] = [Patch.INPUT_AVAILABLE, output[1]]; + break; + } + throw new Error('Unreachable'); } - return inputs; - } + case Patch.INPUT_MANAGED_CONNECTION: { + if (!this.manager) { + inputs[inputName] = [Patch.INPUT_UNAVAILABLE]; + break; + } - computeOutputs() { - const inputs = this.computeInputs(); - const outputs = Object.create(null); - console.log(`Compute: ${this.constructor.name}`); - this.compute(inputs, outputs); - return outputs; + inputs[inputName] = this.manager.getManagedInput(input[1]); + break; + } + } } - compute(inputs, outputs) { - // No-op. Return all outputs as unavailable. This should be overridden - // in subclasses. + return inputs; + } - for (const outputName of this.constructor.outputNames) { - outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE]; - } - } + computeOutputs() { + const inputs = this.computeInputs(); + const outputs = Object.create(null); + console.log(`Compute: ${this.constructor.name}`); + this.compute(inputs, outputs); + return outputs; + } + + compute(inputs, outputs) { + // No-op. Return all outputs as unavailable. This should be overridden + // in subclasses. - attachToManager(manager) { - manager.addManagedPatch(this); + for (const outputName of this.constructor.outputNames) { + outputs[outputName] = [Patch.OUTPUT_UNAVAILABLE]; } + } - detachFromManager() { - if (this.manager) { - this.manager.removeManagedPatch(this); - } + attachToManager(manager) { + manager.addManagedPatch(this); + } + + detachFromManager() { + if (this.manager) { + this.manager.removeManagedPatch(this); } + } } // --> PatchManager export class PatchManager extends Patch { - managedPatches = []; - managedInputs = {}; - - #externalInputPatch = null; - #externalOutputPatch = null; - - constructor(...args) { - super(...args); - - this.#externalInputPatch = new PatchManagerExternalInputPatch({manager: this}); - this.#externalOutputPatch = new PatchManagerExternalOutputPatch({manager: this}); + managedPatches = []; + managedInputs = {}; + + #externalInputPatch = null; + #externalOutputPatch = null; + + constructor(...args) { + super(...args); + + this.#externalInputPatch = new PatchManagerExternalInputPatch({ + manager: this, + }); + this.#externalOutputPatch = new PatchManagerExternalOutputPatch({ + manager: this, + }); + } + + addManagedPatch(patch) { + if (patch.manager === this) { + return false; } - addManagedPatch(patch) { - if (patch.manager === this) { - return false; - } - - patch.detachFromManager(); - patch.manager = this; + patch.detachFromManager(); + patch.manager = this; - if (patch.manager === this) { - this.managedPatches.push(patch); - return true; - } else { - return false; - } + if (patch.manager === this) { + this.managedPatches.push(patch); + return true; + } else { + return false; } + } - removeManagedPatch(patch) { - if (patch.manager !== this) { - return false; - } - - patch.manager = null; - - if (patch.manager === this) { - return false; - } - - for (const inputNames of patch.inputNames) { - const input = patch.inputs[inputName]; - if (input[0] === Patch.INPUT_MANAGED_CONNECTION) { - this.dropManagedInput(input[1]); - patch.inputs[inputName] = [Patch.INPUT_NONE]; - } - } - - this.managedPatches.splice(this.managedPatches.indexOf(patch), 1); - - return true; + removeManagedPatch(patch) { + if (patch.manager !== this) { + return false; } - addManagedInput(patchWithInput, inputName, patchWithOutput, outputName) { - if (patchWithInput.manager !== this || patchWithOutput.manager !== this) { - throw new Error(`Input and output patches must belong to same manager (this)`); - } - - const input = patchWithInput.inputs[inputName]; - if (input[0] === Patch.INPUT_MANAGED_CONNECTION) { - this.managedInputs[input[1]] = [patchWithOutput, outputName, {}]; - } else { - const key = this.getManagedConnectionIdentifier(); - this.managedInputs[key] = [patchWithOutput, outputName, {}]; - patchWithInput.inputs[inputName] = [Patch.INPUT_MANAGED_CONNECTION, key]; - } - - return true; - } + patch.manager = null; - dropManagedInput(identifier) { - return delete this.managedInputs[key]; + if (patch.manager === this) { + return false; } - getManagedInput(identifier) { - const connection = this.managedInputs[identifier]; - const patch = connection[0]; - const outputName = connection[1]; - const memory = connection[2]; - return this.computeManagedInput(patch, outputName, memory); + for (const inputName of patch.inputNames) { + const input = patch.inputs[inputName]; + if (input[0] === Patch.INPUT_MANAGED_CONNECTION) { + this.dropManagedInput(input[1]); + patch.inputs[inputName] = [Patch.INPUT_NONE]; + } } - computeManagedInput(patch, outputName, memory) { - // Override this function in subclasses to alter behavior of the "wire" - // used for connecting patches. - - const output = patch.computeOutputs()[outputName]; - switch (output[0]) { - case Patch.OUTPUT_UNAVAILABLE: - return [Patch.INPUT_UNAVAILABLE]; - case Patch.OUTPUT_AVAILABLE: - return [Patch.INPUT_AVAILABLE, output[1]]; - } - } + this.managedPatches.splice(this.managedPatches.indexOf(patch), 1); - #managedConnectionIdentifier = 0; - getManagedConnectionIdentifier() { - return this.#managedConnectionIdentifier++; - } + return true; + } - addExternalInput(patchWithInput, patchInputName, managerInputName) { - return this.addManagedInput(patchWithInput, patchInputName, this.#externalInputPatch, managerInputName); + addManagedInput(patchWithInput, inputName, patchWithOutput, outputName) { + if (patchWithInput.manager !== this || patchWithOutput.manager !== this) { + throw new Error( + `Input and output patches must belong to same manager (this)` + ); } - setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) { - return this.addManagedInput(this.#externalOutputPatch, managerOutputName, patchWithOutput, patchOutputName); + const input = patchWithInput.inputs[inputName]; + if (input[0] === Patch.INPUT_MANAGED_CONNECTION) { + this.managedInputs[input[1]] = [patchWithOutput, outputName, {}]; + } else { + const key = this.getManagedConnectionIdentifier(); + this.managedInputs[key] = [patchWithOutput, outputName, {}]; + patchWithInput.inputs[inputName] = [Patch.INPUT_MANAGED_CONNECTION, key]; } - compute(inputs, outputs) { - Object.assign(outputs, this.#externalOutputPatch.computeOutputs()); + return true; + } + + dropManagedInput(identifier) { + return delete this.managedInputs[identifier]; + } + + getManagedInput(identifier) { + const connection = this.managedInputs[identifier]; + const patch = connection[0]; + const outputName = connection[1]; + const memory = connection[2]; + return this.computeManagedInput(patch, outputName, memory); + } + + computeManagedInput(patch, outputName) { + // Override this function in subclasses to alter behavior of the "wire" + // used for connecting patches. + + const output = patch.computeOutputs()[outputName]; + switch (output[0]) { + case Patch.OUTPUT_UNAVAILABLE: + return [Patch.INPUT_UNAVAILABLE]; + case Patch.OUTPUT_AVAILABLE: + return [Patch.INPUT_AVAILABLE, output[1]]; } + } + + #managedConnectionIdentifier = 0; + getManagedConnectionIdentifier() { + return this.#managedConnectionIdentifier++; + } + + addExternalInput(patchWithInput, patchInputName, managerInputName) { + return this.addManagedInput( + patchWithInput, + patchInputName, + this.#externalInputPatch, + managerInputName + ); + } + + setExternalOutput(managerOutputName, patchWithOutput, patchOutputName) { + return this.addManagedInput( + this.#externalOutputPatch, + managerOutputName, + patchWithOutput, + patchOutputName + ); + } + + compute(inputs, outputs) { + Object.assign(outputs, this.#externalOutputPatch.computeOutputs()); + } } class PatchManagerExternalInputPatch extends Patch { - constructor({manager, ...rest}) { - super({ - manager, - inputNames: manager.inputNames, - outputNames: manager.inputNames, - ...rest - }); - } - - computeInputs() { - return this.manager.computeInputs(); - } - - compute(inputs, outputs) { - for (const name of this.inputNames) { - const input = inputs[name]; - switch (input[0]) { - case Patch.INPUT_UNAVAILABLE: - outputs[name] = [Patch.OUTPUT_UNAVAILABLE]; - break; - case Patch.INPUT_AVAILABLE: - outputs[name] = [Patch.INPUT_AVAILABLE, input[1]]; - break; - } - } + constructor({manager, ...rest}) { + super({ + manager, + inputNames: manager.inputNames, + outputNames: manager.inputNames, + ...rest, + }); + } + + computeInputs() { + return this.manager.computeInputs(); + } + + compute(inputs, outputs) { + for (const name of this.inputNames) { + const input = inputs[name]; + switch (input[0]) { + case Patch.INPUT_UNAVAILABLE: + outputs[name] = [Patch.OUTPUT_UNAVAILABLE]; + break; + case Patch.INPUT_AVAILABLE: + outputs[name] = [Patch.INPUT_AVAILABLE, input[1]]; + break; + } } + } } class PatchManagerExternalOutputPatch extends Patch { - constructor({manager, ...rest}) { - super({ - manager, - inputNames: manager.outputNames, - outputNames: manager.outputNames, - ...rest - }); - } - - compute(inputs, outputs) { - for (const name of this.inputNames) { - const input = inputs[name]; - switch (input[0]) { - case Patch.INPUT_UNAVAILABLE: - outputs[name] = [Patch.OUTPUT_UNAVAILABLE]; - break; - case Patch.INPUT_AVAILABLE: - outputs[name] = [Patch.INPUT_AVAILABLE, input[1]]; - break; - } - } + constructor({manager, ...rest}) { + super({ + manager, + inputNames: manager.outputNames, + outputNames: manager.outputNames, + ...rest, + }); + } + + compute(inputs, outputs) { + for (const name of this.inputNames) { + const input = inputs[name]; + switch (input[0]) { + case Patch.INPUT_UNAVAILABLE: + outputs[name] = [Patch.OUTPUT_UNAVAILABLE]; + break; + case Patch.INPUT_AVAILABLE: + outputs[name] = [Patch.INPUT_AVAILABLE, input[1]]; + break; + } } + } } // --> demo const caches = Symbol(); const common = Symbol(); -const hsmusic = Symbol(); Patch[caches] = { - WireCachedPatchManager: class extends PatchManager { - // "Wire" caching for PatchManager: Remembers the last outputs to come - // from each patch. As long as the inputs for a patch do not change, its - // cached outputs are reused. - - // TODO: This has a unique cache for each managed input. It should - // re-use a cache for the same patch and output name. How can we ensure - // the cache is dropped when the patch is removed, though? (Spoilers: - // probably just override removeManagedPatch) - computeManagedInput(patch, outputName, memory) { - let cache = true; - - const { previousInputs } = memory; - const { inputs } = patch; - if (memory.previousInputs) { - for (const inputName of patch.inputNames) { - // TODO: This doesn't account for connections whose values - // have changed (analogous to bubbling cache invalidation). - if (inputs[inputName] !== previousInputs[inputName]) { - cache = false; - break; - } - } - } else { - cache = false; - } - - if (cache) { - return memory.previousOutputs[outputName]; - } - - const outputs = patch.computeOutputs(); - memory.previousOutputs = outputs; - memory.previousInputs = {...inputs}; - return outputs[outputName]; + WireCachedPatchManager: class extends PatchManager { + // "Wire" caching for PatchManager: Remembers the last outputs to come + // from each patch. As long as the inputs for a patch do not change, its + // cached outputs are reused. + + // TODO: This has a unique cache for each managed input. It should + // re-use a cache for the same patch and output name. How can we ensure + // the cache is dropped when the patch is removed, though? (Spoilers: + // probably just override removeManagedPatch) + computeManagedInput(patch, outputName, memory) { + let cache = true; + + const {previousInputs} = memory; + const {inputs} = patch; + if (memory.previousInputs) { + for (const inputName of patch.inputNames) { + // TODO: This doesn't account for connections whose values + // have changed (analogous to bubbling cache invalidation). + if (inputs[inputName] !== previousInputs[inputName]) { + cache = false; + break; + } } - }, + } else { + cache = false; + } + + if (cache) { + return memory.previousOutputs[outputName]; + } + + const outputs = patch.computeOutputs(); + memory.previousOutputs = outputs; + memory.previousInputs = {...inputs}; + return outputs[outputName]; + } + }, }; Patch[common] = { - Stringify: class extends Patch { - static inputNames = ['value']; - static outputNames = ['value']; - - compute(inputs, outputs) { - if (inputs.value[0] === Patch.INPUT_AVAILABLE) { - outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1].toString()]; - } else { - outputs.value = [Patch.OUTPUT_UNAVAILABLE]; - } - } - }, - - Echo: class extends Patch { - static inputNames = ['value']; - static outputNames = ['value']; - - compute(inputs, outputs) { - if (inputs.value[0] === Patch.INPUT_AVAILABLE) { - outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1]]; - } else { - outputs.value = [Patch.OUTPUT_UNAVAILABLE]; - } - } - }, + Stringify: class extends Patch { + static inputNames = ['value']; + static outputNames = ['value']; + + compute(inputs, outputs) { + if (inputs.value[0] === Patch.INPUT_AVAILABLE) { + outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1].toString()]; + } else { + outputs.value = [Patch.OUTPUT_UNAVAILABLE]; + } + } + }, + + Echo: class extends Patch { + static inputNames = ['value']; + static outputNames = ['value']; + + compute(inputs, outputs) { + if (inputs.value[0] === Patch.INPUT_AVAILABLE) { + outputs.value = [Patch.OUTPUT_AVAILABLE, inputs.value[1]]; + } else { + outputs.value = [Patch.OUTPUT_UNAVAILABLE]; + } + } + }, }; const PM = new Patch[caches].WireCachedPatchManager({ - inputNames: ['externalInput'], - outputNames: ['externalOutput'], + inputNames: ['externalInput'], + outputNames: ['externalOutput'], }); const P1 = new Patch[common].Stringify({manager: PM}); diff --git a/src/data/serialize.js b/src/data/serialize.js index 9d4e8885..a4206fd0 100644 --- a/src/data/serialize.js +++ b/src/data/serialize.js @@ -1,22 +1,24 @@ +/** @format */ + // serialize-util.js: simple interface and utility functions for converting // Things into a directly serializeable format // Utility functions export function id(x) { - return x; + return x; } export function toRef(thing) { - return thing?.constructor.getReference(thing); + return thing?.constructor.getReference(thing); } export function toRefs(things) { - return things?.map(toRef); + return things?.map(toRef); } export function toContribRefs(contribs) { - return contribs?.map(({ who, what }) => ({who: toRef(who), what})); + return contribs?.map(({who, what}) => ({who: toRef(who), what})); } // Interface @@ -24,15 +26,21 @@ export function toContribRefs(contribs) { export const serializeDescriptors = Symbol(); export function serializeThing(thing) { - const descriptors = thing.constructor[serializeDescriptors]; - if (!descriptors) { - throw new Error(`Constructor ${thing.constructor.name} does not provide serialize descriptors`); - } - - return Object.fromEntries(Object.entries(descriptors) - .map(([ property, transform ]) => [property, transform(thing[property])])); + const descriptors = thing.constructor[serializeDescriptors]; + if (!descriptors) { + throw new Error( + `Constructor ${thing.constructor.name} does not provide serialize descriptors` + ); + } + + return Object.fromEntries( + Object.entries(descriptors).map(([property, transform]) => [ + property, + transform(thing[property]), + ]) + ); } export function serializeThings(things) { - return things.map(serializeThing); + return things.map(serializeThing); } diff --git a/src/data/things.js b/src/data/things.js index 6a5cdb5e..fa7a8d54 100644 --- a/src/data/things.js +++ b/src/data/things.js @@ -1,45 +1,45 @@ +/** @format */ + // things.js: class definitions for various object types used across the wiki, // most of which correspond to an output page, such as Track, Album, Artist import CacheableObject from './cacheable-object.js'; import { - isAdditionalFileList, - isBoolean, - isColor, - isCommentary, - isCountingNumber, - isContributionList, - isDate, - isDimensions, - isDirectory, - isDuration, - isInstance, - isFileExtension, - isLanguageCode, - isName, - isNumber, - isURL, - isString, - isWholeNumber, - oneOf, - validateArrayItems, - validateInstanceOf, - validateReference, - validateReferenceList, + isAdditionalFileList, + isBoolean, + isColor, + isCommentary, + isCountingNumber, + isContributionList, + isDate, + isDimensions, + isDirectory, + isDuration, + isFileExtension, + isLanguageCode, + isName, + isNumber, + isURL, + isString, + oneOf, + validateArrayItems, + validateInstanceOf, + validateReference, + validateReferenceList, } from './validators.js'; import * as S from './serialize.js'; import { - getKebabCase, - sortAlbumsTracksChronologically, + getKebabCase, + sortAlbumsTracksChronologically, } from '../util/wiki-data.js'; import find from '../util/find.js'; -import { inspect } from 'util'; -import { color } from '../util/cli.js'; +import {inspect} from 'util'; +import {color} from '../util/cli.js'; // Stub classes (and their exports) at the top of the file - these are // referenced later when we actually define static class fields. We deliberately @@ -112,551 +112,579 @@ Flash[Thing.referenceType] = 'flash'; // duplicating less code across wiki data types. These are specialized utility // functions, so check each for how its own arguments behave! Thing.common = { - name: (defaultName) => ({ - flags: {update: true, expose: true}, - update: {validate: isName, default: defaultName} - }), - - color: () => ({ - flags: {update: true, expose: true}, - update: {validate: isColor} - }), - - directory: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: { - dependencies: ['name'], - transform(directory, { name }) { - if (directory === null && name === null) - return null; - else if (directory === null) - return getKebabCase(name); - else - return directory; - } - } - }), - - urls: () => ({ - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)} - }), - - // A file extension! Or the default, if provided when calling this. - fileExtension: (defaultFileExtension = null) => ({ - flags: {update: true, expose: true}, - update: {validate: isFileExtension}, - expose: {transform: value => value ?? defaultFileExtension} - }), - - // Straightforward flag descriptor for a variety of property purposes. - // Provide a default value, true or false! - flag: (defaultValue = false) => { - if (typeof defaultValue !== 'boolean') { - throw new TypeError(`Always set explicit defaults for flags!`); - } + name: (defaultName) => ({ + flags: {update: true, expose: true}, + update: {validate: isName, default: defaultName}, + }), + + color: () => ({ + flags: {update: true, expose: true}, + update: {validate: isColor}, + }), + + directory: () => ({ + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + expose: { + dependencies: ['name'], + transform(directory, {name}) { + if (directory === null && name === null) return null; + else if (directory === null) return getKebabCase(name); + else return directory; + }, + }, + }), + + urls: () => ({ + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isURL)}, + }), + + // A file extension! Or the default, if provided when calling this. + fileExtension: (defaultFileExtension = null) => ({ + flags: {update: true, expose: true}, + update: {validate: isFileExtension}, + expose: {transform: (value) => value ?? defaultFileExtension}, + }), + + // Straightforward flag descriptor for a variety of property purposes. + // Provide a default value, true or false! + flag: (defaultValue = false) => { + if (typeof defaultValue !== 'boolean') { + throw new TypeError(`Always set explicit defaults for flags!`); + } - return { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: defaultValue} - }; - }, + return { + flags: {update: true, expose: true}, + update: {validate: isBoolean, default: defaultValue}, + }; + }, + + // General date type, used as the descriptor for a bunch of properties. + // This isn't dynamic though - it won't inherit from a date stored on + // another object, for example. + simpleDate: () => ({ + flags: {update: true, expose: true}, + update: {validate: isDate}, + }), + + // General string type. This should probably generally be avoided in favor + // of more specific validation, but using it makes it easy to find where we + // might want to improve later, and it's a useful shorthand meanwhile. + simpleString: () => ({ + flags: {update: true, expose: true}, + update: {validate: isString}, + }), + + // External function. These should only be used as dependencies for other + // properties, so they're left unexposed. + externalFunction: () => ({ + flags: {update: true}, + update: {validate: (t) => typeof t === 'function'}, + }), + + // Super simple "contributions by reference" list, used for a variety of + // properties (Artists, Cover Artists, etc). This is the property which is + // externally provided, in the form: + // + // [ + // {who: 'Artist Name', what: 'Viola'}, + // {who: 'artist:john-cena', what: null}, + // ... + // ] + // + // ...processed from YAML, spreadsheet, or any other kind of input. + contribsByRef: () => ({ + flags: {update: true, expose: true}, + update: {validate: isContributionList}, + }), + + // Artist commentary! Generally present on tracks and albums. + commentary: () => ({ + flags: {update: true, expose: true}, + update: {validate: isCommentary}, + }), + + // This is a somewhat more involved data structure - it's for additional + // or "bonus" files associated with albums or tracks (or anything else). + // It's got this form: + // + // [ + // {title: 'Booklet', files: ['Booklet.pdf']}, + // { + // title: 'Wallpaper', + // description: 'Cool Wallpaper!', + // files: ['1440x900.png', '1920x1080.png'] + // }, + // {title: 'Alternate Covers', description: null, files: [...]}, + // ... + // ] + // + additionalFiles: () => ({ + flags: {update: true, expose: true}, + update: {validate: isAdditionalFileList}, + }), + + // A reference list! Keep in mind this is for general references to wiki + // objects of (usually) other Thing subclasses, not specifically leitmotif + // references in tracks (although that property uses referenceList too!). + // + // The underlying function validateReferenceList expects a string like + // 'artist' or 'track', but this utility keeps from having to hard-code the + // string in multiple places by referencing the value saved on the class + // instead. + referenceList: (thingClass) => { + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error( + `The passed constructor ${thingClass.name} doesn't define Thing.referenceType!` + ); + } - // General date type, used as the descriptor for a bunch of properties. - // This isn't dynamic though - it won't inherit from a date stored on - // another object, for example. - simpleDate: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDate} - }), - - // General string type. This should probably generally be avoided in favor - // of more specific validation, but using it makes it easy to find where we - // might want to improve later, and it's a useful shorthand meanwhile. - simpleString: () => ({ - flags: {update: true, expose: true}, - update: {validate: isString} - }), - - // External function. These should only be used as dependencies for other - // properties, so they're left unexposed. - externalFunction: () => ({ - flags: {update: true}, - update: {validate: t => typeof t === 'function'} - }), - - // Super simple "contributions by reference" list, used for a variety of - // properties (Artists, Cover Artists, etc). This is the property which is - // externally provided, in the form: - // - // [ - // {who: 'Artist Name', what: 'Viola'}, - // {who: 'artist:john-cena', what: null}, - // ... - // ] - // - // ...processed from YAML, spreadsheet, or any other kind of input. - contribsByRef: () => ({ - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }), - - // Artist commentary! Generally present on tracks and albums. - commentary: () => ({ - flags: {update: true, expose: true}, - update: {validate: isCommentary} - }), - - // This is a somewhat more involved data structure - it's for additional - // or "bonus" files associated with albums or tracks (or anything else). - // It's got this form: - // - // [ - // {title: 'Booklet', files: ['Booklet.pdf']}, - // { - // title: 'Wallpaper', - // description: 'Cool Wallpaper!', - // files: ['1440x900.png', '1920x1080.png'] - // }, - // {title: 'Alternate Covers', description: null, files: [...]}, - // ... - // ] - // - additionalFiles: () => ({ - flags: {update: true, expose: true}, - update: {validate: isAdditionalFileList} - }), - - // A reference list! Keep in mind this is for general references to wiki - // objects of (usually) other Thing subclasses, not specifically leitmotif - // references in tracks (although that property uses referenceList too!). - // - // The underlying function validateReferenceList expects a string like - // 'artist' or 'track', but this utility keeps from having to hard-code the - // string in multiple places by referencing the value saved on the class - // instead. - referenceList: thingClass => { - const { [Thing.referenceType]: referenceType } = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } + return { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList(referenceType)}, + }; + }, + + // Corresponding function for a single reference. + singleReference: (thingClass) => { + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error( + `The passed constructor ${thingClass.name} doesn't define Thing.referenceType!` + ); + } - return { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList(referenceType)} - }; - }, + return { + flags: {update: true, expose: true}, + update: {validate: validateReference(referenceType)}, + }; + }, + + // Corresponding dynamic property to referenceList, which takes the values + // in the provided property and searches the specified wiki data for + // matching actual Thing-subclass objects. + dynamicThingsFromReferenceList: ( + referenceListProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, - // Corresponding function for a single reference. - singleReference: thingClass => { - const { [Thing.referenceType]: referenceType } = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } + expose: { + dependencies: [referenceListProperty, thingDataProperty], + compute: ({ + [referenceListProperty]: refs, + [thingDataProperty]: thingData, + }) => + refs && thingData + ? refs + .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) + .filter(Boolean) + : [], + }, + }), + + // Corresponding function for a single reference. + dynamicThingFromSingleReference: ( + singleReferenceProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, - return { - flags: {update: true, expose: true}, - update: {validate: validateReference(referenceType)} - }; - }, + expose: { + dependencies: [singleReferenceProperty, thingDataProperty], + compute: ({ + [singleReferenceProperty]: ref, + [thingDataProperty]: thingData, + }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null), + }, + }), + + // Corresponding dynamic property to contribsByRef, which takes the values + // in the provided property and searches the object's artistData for + // matching actual Artist objects. The computed structure has the same form + // as contribsByRef, but with Artist objects instead of string references: + // + // [ + // {who: (an Artist), what: 'Viola'}, + // {who: (an Artist), what: null}, + // ... + // ] + // + // Contributions whose "who" values don't match anything in artistData are + // filtered out. (So if the list is all empty, chances are that either the + // reference list is somehow messed up, or artistData isn't being provided + // properly.) + dynamicContribs: (contribsByRefProperty) => ({ + flags: {expose: true}, + expose: { + dependencies: ['artistData', contribsByRefProperty], + compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => + contribsByRef && artistData + ? contribsByRef + .map(({who: ref, what}) => ({ + who: find.artist(ref, artistData), + what, + })) + .filter(({who}) => who) + : [], + }, + }), + + // Dynamically inherit a contribution list from some other object, if it + // hasn't been overridden on this object. This is handy for solo albums + // where all tracks have the same artist, for example. + // + // Note: The arguments of this function aren't currently final! The final + // format will look more like (contribsByRef, parentContribsByRef), e.g. + // ('artistContribsByRef', '@album/artistContribsByRef'). + dynamicInheritContribs: ( + contribsByRefProperty, + parentContribsByRefProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, + expose: { + dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'], + compute({ + [Thing.instance]: thing, + [contribsByRefProperty]: contribsByRef, + [thingDataProperty]: thingData, + artistData, + }) { + if (!artistData) return []; + const refs = + contribsByRef ?? + findFn(thing, thingData, {mode: 'quiet'})?.[ + parentContribsByRefProperty + ]; + if (!refs) return []; + return refs + .map(({who: ref, what}) => ({ + who: find.artist(ref, artistData), + what, + })) + .filter(({who}) => who); + }, + }, + }), + + // Neat little shortcut for "reversing" the reference lists stored on other + // things - for example, tracks specify a "referenced tracks" property, and + // you would use this to compute a corresponding "referenced *by* tracks" + // property. Naturally, the passed ref list property is of the things in the + // wiki data provided, not the requesting Thing itself. + reverseReferenceList: (wikiDataProperty, referencerRefListProperty) => ({ + flags: {expose: true}, - // Corresponding dynamic property to referenceList, which takes the values - // in the provided property and searches the specified wiki data for - // matching actual Thing-subclass objects. - dynamicThingsFromReferenceList: ( - referenceListProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [referenceListProperty, thingDataProperty], - compute: ({ [referenceListProperty]: refs, [thingDataProperty]: thingData }) => ( - (refs && thingData - ? (refs - .map(ref => findFn(ref, thingData, {mode: 'quiet'})) - .filter(Boolean)) - : []) - ) - } - }), - - // Corresponding function for a single reference. - dynamicThingFromSingleReference: ( - singleReferenceProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [singleReferenceProperty, thingDataProperty], - compute: ({ [singleReferenceProperty]: ref, [thingDataProperty]: thingData }) => ( - (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null) - ) - } - }), - - // Corresponding dynamic property to contribsByRef, which takes the values - // in the provided property and searches the object's artistData for - // matching actual Artist objects. The computed structure has the same form - // as contribsByRef, but with Artist objects instead of string references: - // - // [ - // {who: (an Artist), what: 'Viola'}, - // {who: (an Artist), what: null}, - // ... - // ] - // - // Contributions whose "who" values don't match anything in artistData are - // filtered out. (So if the list is all empty, chances are that either the - // reference list is somehow messed up, or artistData isn't being provided - // properly.) - dynamicContribs: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: ['artistData', contribsByRefProperty], - compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) => ( - ((contribsByRef && artistData) - ? (contribsByRef - .map(({ who: ref, what }) => ({ - who: find.artist(ref, artistData), - what - })) - .filter(({ who }) => who)) - : []) - ) - } - }), - - // Dynamically inherit a contribution list from some other object, if it - // hasn't been overridden on this object. This is handy for solo albums - // where all tracks have the same artist, for example. - // - // Note: The arguments of this function aren't currently final! The final - // format will look more like (contribsByRef, parentContribsByRef), e.g. - // ('artistContribsByRef', '@album/artistContribsByRef'). - dynamicInheritContribs: ( - contribsByRefProperty, - parentContribsByRefProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - expose: { - dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'], - compute({ - [Thing.instance]: thing, - [contribsByRefProperty]: contribsByRef, - [thingDataProperty]: thingData, - artistData - }) { - if (!artistData) return []; - const refs = (contribsByRef ?? findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty]); - if (!refs) return []; - return (refs - .map(({ who: ref, what }) => ({ - who: find.artist(ref, artistData), - what - })) - .filter(({ who }) => who)); - } - } - }), - - // Neat little shortcut for "reversing" the reference lists stored on other - // things - for example, tracks specify a "referenced tracks" property, and - // you would use this to compute a corresponding "referenced *by* tracks" - // property. Naturally, the passed ref list property is of the things in the - // wiki data provided, not the requesting Thing itself. - reverseReferenceList: (wikiDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [wikiDataProperty], - - compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => ( - (wikiData - ? wikiData.filter(t => t[referencerRefListProperty]?.includes(thing)) - : []) + expose: { + dependencies: [wikiDataProperty], + + compute: ({[wikiDataProperty]: wikiData, [Thing.instance]: thing}) => + wikiData + ? wikiData.filter((t) => + t[referencerRefListProperty]?.includes(thing) ) - } - }), + : [], + }, + }), - // Corresponding function for single references. Note that the return value - // is still a list - this is for matching all the objects whose single - // reference (in the given property) matches this Thing. - reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, + // Corresponding function for single references. Note that the return value + // is still a list - this is for matching all the objects whose single + // reference (in the given property) matches this Thing. + reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({ + flags: {expose: true}, - expose: { - dependencies: [wikiDataProperty], + expose: { + dependencies: [wikiDataProperty], - compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => ( - wikiData?.filter(t => t[referencerRefListProperty] === thing)) - } - }), - - // General purpose wiki data constructor, for properties like artistData, - // trackData, etc. - wikiData: (thingClass) => ({ - flags: {update: true}, - update: { - validate: validateArrayItems(validateInstanceOf(thingClass)) - } - }), - - // This one's kinda tricky: it parses artist "references" from the - // commentary content, and finds the matching artist for each reference. - // This is mostly useful for credits and listings on artist pages. - commentatorArtists: () => ({ - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({ artistData, commentary }) => ( - (artistData && commentary - ? Array.from(new Set((Array - .from(commentary - .replace(/<\/?b>/g, '') - .matchAll(/<i>(?<who>.*?):<\/i>/g)) - .map(({ groups: {who} }) => find.artist(who, artistData, {mode: 'quiet'}))))) - : [])) - } - }), + compute: ({[wikiDataProperty]: wikiData, [Thing.instance]: thing}) => + wikiData?.filter((t) => t[referencerRefListProperty] === thing), + }, + }), + + // General purpose wiki data constructor, for properties like artistData, + // trackData, etc. + wikiData: (thingClass) => ({ + flags: {update: true}, + update: { + validate: validateArrayItems(validateInstanceOf(thingClass)), + }, + }), + + // This one's kinda tricky: it parses artist "references" from the + // commentary content, and finds the matching artist for each reference. + // This is mostly useful for credits and listings on artist pages. + commentatorArtists: () => ({ + flags: {expose: true}, + + expose: { + dependencies: ['artistData', 'commentary'], + + compute: ({artistData, commentary}) => + artistData && commentary + ? Array.from( + new Set( + Array.from( + commentary + .replace(/<\/?b>/g, '') + .matchAll(/<i>(?<who>.*?):<\/i>/g) + ).map(({groups: {who}}) => + find.artist(who, artistData, {mode: 'quiet'}) + ) + ) + ) + : [], + }, + }), }; // Get a reference to a thing (e.g. track:showtime-piano-refrain), using its // constructor's [Thing.referenceType] as the prefix. This will throw an error // if the thing's directory isn't yet provided/computable. -Thing.getReference = function(thing) { - if (!thing.constructor[Thing.referenceType]) - throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`); - - if (!thing.directory) - throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); - - return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; +Thing.getReference = function (thing) { + if (!thing.constructor[Thing.referenceType]) + throw TypeError( + `Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]` + ); + + if (!thing.directory) + throw TypeError( + `Passed ${thing.constructor.name} is missing its directory` + ); + + return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; }; // Default custom inspect function, which may be overridden by Thing subclasses. // This will be used when displaying aggregate errors and other in command-line // logging - it's the place to provide information useful in identifying the // Thing being presented. -Thing.prototype[inspect.custom] = function() { - const cname = this.constructor.name; - - return (this.name - ? `${cname} ${color.green(`"${this.name}"`)}` - : `${cname}`) + (this.directory - ? ` (${color.blue(Thing.getReference(this))})` - : ''); +Thing.prototype[inspect.custom] = function () { + const cname = this.constructor.name; + + return ( + (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) + + (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '') + ); }; // -> Album Album.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Album'), - color: Thing.common.color(), - directory: Thing.common.directory(), - urls: Thing.common.urls(), + name: Thing.common.name('Unnamed Album'), + color: Thing.common.color(), + directory: Thing.common.directory(), + urls: Thing.common.urls(), - date: Thing.common.simpleDate(), - trackArtDate: Thing.common.simpleDate(), - dateAddedToWiki: Thing.common.simpleDate(), + date: Thing.common.simpleDate(), + trackArtDate: Thing.common.simpleDate(), + dateAddedToWiki: Thing.common.simpleDate(), - coverArtDate: { - flags: {update: true, expose: true}, + coverArtDate: { + flags: {update: true, expose: true}, - update: {validate: isDate}, + update: {validate: isDate}, - expose: { - dependencies: ['date'], - transform: (coverArtDate, { date }) => coverArtDate ?? date ?? null - } + expose: { + dependencies: ['date'], + transform: (coverArtDate, {date}) => coverArtDate ?? date ?? null, }, + }, - artistContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), - trackCoverArtistContribsByRef: Thing.common.contribsByRef(), - wallpaperArtistContribsByRef: Thing.common.contribsByRef(), - bannerArtistContribsByRef: Thing.common.contribsByRef(), + artistContribsByRef: Thing.common.contribsByRef(), + coverArtistContribsByRef: Thing.common.contribsByRef(), + trackCoverArtistContribsByRef: Thing.common.contribsByRef(), + wallpaperArtistContribsByRef: Thing.common.contribsByRef(), + bannerArtistContribsByRef: Thing.common.contribsByRef(), - groupsByRef: Thing.common.referenceList(Group), - artTagsByRef: Thing.common.referenceList(ArtTag), + groupsByRef: Thing.common.referenceList(Group), + artTagsByRef: Thing.common.referenceList(ArtTag), - trackGroups: { - flags: {update: true, expose: true}, + trackGroups: { + flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(validateInstanceOf(TrackGroup)) - } + update: { + validate: validateArrayItems(validateInstanceOf(TrackGroup)), }, + }, - coverArtFileExtension: Thing.common.fileExtension('jpg'), - trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), + coverArtFileExtension: Thing.common.fileExtension('jpg'), + trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), - wallpaperStyle: Thing.common.simpleString(), - wallpaperFileExtension: Thing.common.fileExtension('jpg'), - - bannerStyle: Thing.common.simpleString(), - bannerFileExtension: Thing.common.fileExtension('jpg'), - bannerDimensions: { - flags: {update: true, expose: true}, - update: {validate: isDimensions} - }, + wallpaperStyle: Thing.common.simpleString(), + wallpaperFileExtension: Thing.common.fileExtension('jpg'), - hasCoverArt: Thing.common.flag(true), - hasTrackArt: Thing.common.flag(true), - hasTrackNumbers: Thing.common.flag(true), - isMajorRelease: Thing.common.flag(false), - isListedOnHomepage: Thing.common.flag(true), + bannerStyle: Thing.common.simpleString(), + bannerFileExtension: Thing.common.fileExtension('jpg'), + bannerDimensions: { + flags: {update: true, expose: true}, + update: {validate: isDimensions}, + }, - commentary: Thing.common.commentary(), - additionalFiles: Thing.common.additionalFiles(), + hasCoverArt: Thing.common.flag(true), + hasTrackArt: Thing.common.flag(true), + hasTrackNumbers: Thing.common.flag(true), + isMajorRelease: Thing.common.flag(false), + isListedOnHomepage: Thing.common.flag(true), - // Update only + commentary: Thing.common.commentary(), + additionalFiles: Thing.common.additionalFiles(), - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - groupData: Thing.common.wikiData(Group), - trackData: Thing.common.wikiData(Track), + // Update only - // Expose only + artistData: Thing.common.wikiData(Artist), + artTagData: Thing.common.wikiData(ArtTag), + groupData: Thing.common.wikiData(Group), + trackData: Thing.common.wikiData(Track), - artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), - coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), - trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'), - wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'), - bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'), + // Expose only - commentatorArtists: Thing.common.commentatorArtists(), + artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), + coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), + trackCoverArtistContribs: Thing.common.dynamicContribs( + 'trackCoverArtistContribsByRef' + ), + wallpaperArtistContribs: Thing.common.dynamicContribs( + 'wallpaperArtistContribsByRef' + ), + bannerArtistContribs: Thing.common.dynamicContribs( + 'bannerArtistContribsByRef' + ), - tracks: { - flags: {expose: true}, + commentatorArtists: Thing.common.commentatorArtists(), - expose: { - dependencies: ['trackGroups', 'trackData'], - compute: ({ trackGroups, trackData }) => ( - (trackGroups && trackData - ? (trackGroups - .flatMap(group => group.tracksByRef ?? []) - .map(ref => find.track(ref, trackData, {mode: 'quiet'})) - .filter(Boolean)) - : []) - ) - } - }, - - groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group), + tracks: { + flags: {expose: true}, - artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + expose: { + dependencies: ['trackGroups', 'trackData'], + compute: ({trackGroups, trackData}) => + trackGroups && trackData + ? trackGroups + .flatMap((group) => group.tracksByRef ?? []) + .map((ref) => find.track(ref, trackData, {mode: 'quiet'})) + .filter(Boolean) + : [], + }, + }, + + groups: Thing.common.dynamicThingsFromReferenceList( + 'groupsByRef', + 'groupData', + find.group + ), + + artTags: Thing.common.dynamicThingsFromReferenceList( + 'artTagsByRef', + 'artTagData', + find.artTag + ), }; Album[S.serializeDescriptors] = { - name: S.id, - color: S.id, - directory: S.id, - urls: S.id, - - date: S.id, - coverArtDate: S.id, - trackArtDate: S.id, - dateAddedToWiki: S.id, - - artistContribs: S.toContribRefs, - coverArtistContribs: S.toContribRefs, - trackCoverArtistContribs: S.toContribRefs, - wallpaperArtistContribs: S.toContribRefs, - bannerArtistContribs: S.toContribRefs, - - coverArtFileExtension: S.id, - trackCoverArtFileExtension: S.id, - wallpaperStyle: S.id, - wallpaperFileExtension: S.id, - bannerStyle: S.id, - bannerFileExtension: S.id, - bannerDimensions: S.id, - - hasTrackArt: S.id, - isMajorRelease: S.id, - isListedOnHomepage: S.id, - - commentary: S.id, - additionalFiles: S.id, - - tracks: S.toRefs, - groups: S.toRefs, - artTags: S.toRefs, - commentatorArtists: S.toRefs, + name: S.id, + color: S.id, + directory: S.id, + urls: S.id, + + date: S.id, + coverArtDate: S.id, + trackArtDate: S.id, + dateAddedToWiki: S.id, + + artistContribs: S.toContribRefs, + coverArtistContribs: S.toContribRefs, + trackCoverArtistContribs: S.toContribRefs, + wallpaperArtistContribs: S.toContribRefs, + bannerArtistContribs: S.toContribRefs, + + coverArtFileExtension: S.id, + trackCoverArtFileExtension: S.id, + wallpaperStyle: S.id, + wallpaperFileExtension: S.id, + bannerStyle: S.id, + bannerFileExtension: S.id, + bannerDimensions: S.id, + + hasTrackArt: S.id, + isMajorRelease: S.id, + isListedOnHomepage: S.id, + + commentary: S.id, + additionalFiles: S.id, + + tracks: S.toRefs, + groups: S.toRefs, + artTags: S.toRefs, + commentatorArtists: S.toRefs, }; TrackGroup.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Track Group'), + name: Thing.common.name('Unnamed Track Group'), - color: { - flags: {update: true, expose: true}, + color: { + flags: {update: true, expose: true}, - update: {validate: isColor}, + update: {validate: isColor}, - expose: { - dependencies: ['album'], + expose: { + dependencies: ['album'], - transform(color, { album }) { - return color ?? album?.color ?? null; - } - } + transform(color, {album}) { + return color ?? album?.color ?? null; + }, }, + }, - dateOriginallyReleased: Thing.common.simpleDate(), + dateOriginallyReleased: Thing.common.simpleDate(), - tracksByRef: Thing.common.referenceList(Track), + tracksByRef: Thing.common.referenceList(Track), - isDefaultTrackGroup: Thing.common.flag(false), + isDefaultTrackGroup: Thing.common.flag(false), - // Update only + // Update only - album: { - flags: {update: true}, - update: {validate: validateInstanceOf(Album)} - }, + album: { + flags: {update: true}, + update: {validate: validateInstanceOf(Album)}, + }, - trackData: Thing.common.wikiData(Track), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - tracks: { - flags: {expose: true}, + tracks: { + flags: {expose: true}, - expose: { - dependencies: ['tracksByRef', 'trackData'], - compute: ({ tracksByRef, trackData }) => ( - (tracksByRef && trackData - ? (tracksByRef - .map(ref => find.track(ref, trackData)) - .filter(Boolean)) - : []) - ) - } + expose: { + dependencies: ['tracksByRef', 'trackData'], + compute: ({tracksByRef, trackData}) => + tracksByRef && trackData + ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean) + : [], }, + }, - startIndex: { - flags: {expose: true}, + startIndex: { + flags: {expose: true}, - expose: { - dependencies: ['album'], - compute: ({ album, [TrackGroup.instance]: trackGroup }) => (album.trackGroups - .slice(0, album.trackGroups.indexOf(trackGroup)) - .reduce((acc, tg) => acc + tg.tracks.length, 0)) - } + expose: { + dependencies: ['album'], + compute: ({album, [TrackGroup.instance]: trackGroup}) => + album.trackGroups + .slice(0, album.trackGroups.indexOf(trackGroup)) + .reduce((acc, tg) => acc + tg.tracks.length, 0), }, + }, }; // -> Track @@ -665,1059 +693,1193 @@ TrackGroup.propertyDescriptors = { // several places. Ideally it wouldn't be - we'd just reuse the `album` property // - but support for that hasn't been coded yet :P Track.findAlbum = (track, albumData) => { - return albumData?.find(album => album.tracks.includes(track)); + return albumData?.find((album) => album.tracks.includes(track)); }; // Another reused utility function. This one's logic is a bit more complicated. -Track.hasCoverArt = (track, albumData, coverArtistContribsByRef, hasCoverArt) => { - return ( - hasCoverArt ?? - (coverArtistContribsByRef?.length > 0 || null) ?? - Track.findAlbum(track, albumData)?.hasTrackArt ?? - true); +Track.hasCoverArt = ( + track, + albumData, + coverArtistContribsByRef, + hasCoverArt +) => { + return ( + hasCoverArt ?? + (coverArtistContribsByRef?.length > 0 || null) ?? + Track.findAlbum(track, albumData)?.hasTrackArt ?? + true + ); }; Track.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Track'), - directory: Thing.common.directory(), + name: Thing.common.name('Unnamed Track'), + directory: Thing.common.directory(), - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration} - }, + duration: { + flags: {update: true, expose: true}, + update: {validate: isDuration}, + }, - urls: Thing.common.urls(), - dateFirstReleased: Thing.common.simpleDate(), + urls: Thing.common.urls(), + dateFirstReleased: Thing.common.simpleDate(), - hasURLs: Thing.common.flag(true), + hasURLs: Thing.common.flag(true), - artistContribsByRef: Thing.common.contribsByRef(), - contributorContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), + artistContribsByRef: Thing.common.contribsByRef(), + contributorContribsByRef: Thing.common.contribsByRef(), + coverArtistContribsByRef: Thing.common.contribsByRef(), - referencedTracksByRef: Thing.common.referenceList(Track), - artTagsByRef: Thing.common.referenceList(ArtTag), + referencedTracksByRef: Thing.common.referenceList(Track), + artTagsByRef: Thing.common.referenceList(ArtTag), - hasCoverArt: { - flags: {update: true, expose: true}, + hasCoverArt: { + flags: {update: true, expose: true}, - update: {validate: isBoolean}, + update: {validate: isBoolean}, - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (hasCoverArt, { albumData, coverArtistContribsByRef, [Track.instance]: track }) => ( - Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt)) - } + expose: { + dependencies: ['albumData', 'coverArtistContribsByRef'], + transform: ( + hasCoverArt, + {albumData, coverArtistContribsByRef, [Track.instance]: track} + ) => + Track.hasCoverArt( + track, + albumData, + coverArtistContribsByRef, + hasCoverArt + ), }, + }, - coverArtFileExtension: { - flags: {update: true, expose: true}, + coverArtFileExtension: { + flags: {update: true, expose: true}, - update: {validate: isFileExtension}, + update: {validate: isFileExtension}, - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (coverArtFileExtension, { albumData, coverArtistContribsByRef, hasCoverArt, [Track.instance]: track }) => ( - coverArtFileExtension ?? - (Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt) - ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension - : Track.findAlbum(track, albumData)?.coverArtFileExtension) ?? - 'jpg') + expose: { + dependencies: ['albumData', 'coverArtistContribsByRef'], + transform: ( + coverArtFileExtension, + { + albumData, + coverArtistContribsByRef, + hasCoverArt, + [Track.instance]: track, } + ) => + coverArtFileExtension ?? + (Track.hasCoverArt( + track, + albumData, + coverArtistContribsByRef, + hasCoverArt + ) + ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension + : Track.findAlbum(track, albumData)?.coverArtFileExtension) ?? + 'jpg', }, + }, - // Previously known as: (track).aka - originalReleaseTrackByRef: Thing.common.singleReference(Track), + // Previously known as: (track).aka + originalReleaseTrackByRef: Thing.common.singleReference(Track), - dataSourceAlbumByRef: Thing.common.singleReference(Album), + dataSourceAlbumByRef: Thing.common.singleReference(Album), - commentary: Thing.common.commentary(), - lyrics: Thing.common.simpleString(), - additionalFiles: Thing.common.additionalFiles(), + commentary: Thing.common.commentary(), + lyrics: Thing.common.simpleString(), + additionalFiles: Thing.common.additionalFiles(), - // Update only + // Update only - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), + albumData: Thing.common.wikiData(Album), + artistData: Thing.common.wikiData(Artist), + artTagData: Thing.common.wikiData(ArtTag), + flashData: Thing.common.wikiData(Flash), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - commentatorArtists: Thing.common.commentatorArtists(), + commentatorArtists: Thing.common.commentatorArtists(), - album: { - flags: {expose: true}, + album: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], - compute: ({ [Track.instance]: track, albumData }) => ( - albumData?.find(album => album.tracks.includes(track)) ?? null) - } - }, + expose: { + dependencies: ['albumData'], + compute: ({[Track.instance]: track, albumData}) => + albumData?.find((album) => album.tracks.includes(track)) ?? null, + }, + }, + + // Note - this is an internal property used only to help identify a track. + // It should not be assumed in general that the album and dataSourceAlbum match + // (i.e. a track may dynamically be moved from one album to another, at + // which point dataSourceAlbum refers to where it was originally from, and is + // not generally relevant information). It's also not guaranteed that + // dataSourceAlbum is available (depending on the Track creator to optionally + // provide dataSourceAlbumByRef). + dataSourceAlbum: Thing.common.dynamicThingFromSingleReference( + 'dataSourceAlbumByRef', + 'albumData', + find.album + ), + + date: { + flags: {expose: true}, - // Note - this is an internal property used only to help identify a track. - // It should not be assumed in general that the album and dataSourceAlbum match - // (i.e. a track may dynamically be moved from one album to another, at - // which point dataSourceAlbum refers to where it was originally from, and is - // not generally relevant information). It's also not guaranteed that - // dataSourceAlbum is available (depending on the Track creator to optionally - // provide dataSourceAlbumByRef). - dataSourceAlbum: Thing.common.dynamicThingFromSingleReference('dataSourceAlbumByRef', 'albumData', find.album), - - date: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - compute: ({ albumData, dateFirstReleased, [Track.instance]: track }) => ( - dateFirstReleased ?? - Track.findAlbum(track, albumData)?.date ?? - null - ) - } + expose: { + dependencies: ['albumData', 'dateFirstReleased'], + compute: ({albumData, dateFirstReleased, [Track.instance]: track}) => + dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null, }, + }, - color: { - flags: {expose: true}, + color: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], + expose: { + dependencies: ['albumData'], - compute: ({ albumData, [Track.instance]: track }) => ( - (Track.findAlbum(track, albumData)?.trackGroups - .find(tg => tg.tracks.includes(track))?.color) - ?? null - ) - } + compute: ({albumData, [Track.instance]: track}) => + Track.findAlbum(track, albumData)?.trackGroups.find((tg) => + tg.tracks.includes(track) + )?.color ?? null, }, + }, - coverArtDate: { - flags: {update: true, expose: true}, + coverArtDate: { + flags: {update: true, expose: true}, - update: {validate: isDate}, + update: {validate: isDate}, - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - transform: (coverArtDate, { albumData, dateFirstReleased, [Track.instance]: track }) => ( - coverArtDate ?? - dateFirstReleased ?? - Track.findAlbum(track, albumData)?.trackArtDate ?? - Track.findAlbum(track, albumData)?.date ?? - null - ) - } - }, - - originalReleaseTrack: Thing.common.dynamicThingFromSingleReference('originalReleaseTrackByRef', 'trackData', find.track), - - otherReleases: { - flags: {expose: true}, - - expose: { - dependencies: ['originalReleaseTrackByRef', 'trackData'], - - compute: ({ originalReleaseTrackByRef: t1origRef, trackData, [Track.instance]: t1 }) => { - if (!trackData) { - return []; - } - - const t1orig = find.track(t1origRef, trackData); - - return [ - t1orig, - ...trackData.filter(t2 => { - const { originalReleaseTrack: t2orig } = t2; - return ( - t2 !== t1 && - t2orig && - (t2orig === t1orig || t2orig === t1) - ); - }) - ].filter(Boolean); - } - } - }, + expose: { + dependencies: ['albumData', 'dateFirstReleased'], + transform: ( + coverArtDate, + {albumData, dateFirstReleased, [Track.instance]: track} + ) => + coverArtDate ?? + dateFirstReleased ?? + Track.findAlbum(track, albumData)?.trackArtDate ?? + Track.findAlbum(track, albumData)?.date ?? + null, + }, + }, + + originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( + 'originalReleaseTrackByRef', + 'trackData', + find.track + ), + + otherReleases: { + flags: {expose: true}, - // Previously known as: (track).artists - artistContribs: Thing.common.dynamicInheritContribs('artistContribsByRef', 'artistContribsByRef', 'albumData', Track.findAlbum), - - // Previously known as: (track).contributors - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), - - // Previously known as: (track).coverArtists - coverArtistContribs: Thing.common.dynamicInheritContribs('coverArtistContribsByRef', 'trackCoverArtistContribsByRef', 'albumData', Track.findAlbum), - - // Previously known as: (track).references - referencedTracks: Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), - - // Specifically exclude re-releases from this list - while it's useful to - // get from a re-release to the tracks it references, re-releases aren't - // generally relevant from the perspective of the tracks being referenced. - // Filtering them from data here hides them from the corresponding field - // on the site (obviously), and has the bonus of not counting them when - // counting the number of times a track has been referenced, for use in - // the "Tracks - by Times Referenced" listing page (or other data - // processing). - referencedByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({ trackData, [Track.instance]: track }) => (trackData - ? (trackData - .filter(t => !t.originalReleaseTrack) - .filter(t => t.referencedTracks?.includes(track))) - : []) + expose: { + dependencies: ['originalReleaseTrackByRef', 'trackData'], + + compute: ({ + originalReleaseTrackByRef: t1origRef, + trackData, + [Track.instance]: t1, + }) => { + if (!trackData) { + return []; } - }, - // Previously known as: (track).flashes - featuredInFlashes: Thing.common.reverseReferenceList('flashData', 'featuredTracks'), + const t1orig = find.track(t1origRef, trackData); + + return [ + t1orig, + ...trackData.filter((t2) => { + const {originalReleaseTrack: t2orig} = t2; + return t2 !== t1 && t2orig && (t2orig === t1orig || t2orig === t1); + }), + ].filter(Boolean); + }, + }, + }, + + // Previously known as: (track).artists + artistContribs: Thing.common.dynamicInheritContribs( + 'artistContribsByRef', + 'artistContribsByRef', + 'albumData', + Track.findAlbum + ), + + // Previously known as: (track).contributors + contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + + // Previously known as: (track).coverArtists + coverArtistContribs: Thing.common.dynamicInheritContribs( + 'coverArtistContribsByRef', + 'trackCoverArtistContribsByRef', + 'albumData', + Track.findAlbum + ), + + // Previously known as: (track).references + referencedTracks: Thing.common.dynamicThingsFromReferenceList( + 'referencedTracksByRef', + 'trackData', + find.track + ), + + // Specifically exclude re-releases from this list - while it's useful to + // get from a re-release to the tracks it references, re-releases aren't + // generally relevant from the perspective of the tracks being referenced. + // Filtering them from data here hides them from the corresponding field + // on the site (obviously), and has the bonus of not counting them when + // counting the number of times a track has been referenced, for use in + // the "Tracks - by Times Referenced" listing page (or other data + // processing). + referencedByTracks: { + flags: {expose: true}, - artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + expose: { + dependencies: ['trackData'], + + compute: ({trackData, [Track.instance]: track}) => + trackData + ? trackData + .filter((t) => !t.originalReleaseTrack) + .filter((t) => t.referencedTracks?.includes(track)) + : [], + }, + }, + + // Previously known as: (track).flashes + featuredInFlashes: Thing.common.reverseReferenceList( + 'flashData', + 'featuredTracks' + ), + + artTags: Thing.common.dynamicThingsFromReferenceList( + 'artTagsByRef', + 'artTagData', + find.artTag + ), }; -Track.prototype[inspect.custom] = function() { - const base = Thing.prototype[inspect.custom].apply(this); +Track.prototype[inspect.custom] = function () { + const base = Thing.prototype[inspect.custom].apply(this); - const { album, dataSourceAlbum } = this; - const albumName = (album ? album.name : dataSourceAlbum?.name); - const albumIndex = albumName && (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this)); - const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`); + const {album, dataSourceAlbum} = this; + const albumName = album ? album.name : dataSourceAlbum?.name; + const albumIndex = + albumName && + (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this)); + const trackNum = albumIndex === -1 ? '#?' : `#${albumIndex + 1}`; - return (albumName - ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})` - : base); + return albumName + ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})` + : base; }; // -> Artist Artist.filterByContrib = (thingDataProperty, contribsProperty) => ({ - flags: {expose: true}, + flags: {expose: true}, - expose: { - dependencies: [thingDataProperty], + expose: { + dependencies: [thingDataProperty], - compute: ({ [thingDataProperty]: thingData, [Artist.instance]: artist }) => ( - thingData?.filter(({ [contribsProperty]: contribs }) => ( - contribs?.some(contrib => contrib.who === artist)))) - } + compute: ({[thingDataProperty]: thingData, [Artist.instance]: artist}) => + thingData?.filter(({[contribsProperty]: contribs}) => + contribs?.some((contrib) => contrib.who === artist) + ), + }, }); Artist.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Artist'), - directory: Thing.common.directory(), - urls: Thing.common.urls(), - contextNotes: Thing.common.simpleString(), + name: Thing.common.name('Unnamed Artist'), + directory: Thing.common.directory(), + urls: Thing.common.urls(), + contextNotes: Thing.common.simpleString(), - hasAvatar: Thing.common.flag(false), - avatarFileExtension: Thing.common.fileExtension('jpg'), + hasAvatar: Thing.common.flag(false), + avatarFileExtension: Thing.common.fileExtension('jpg'), - aliasNames: { - flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(isName) - } + aliasNames: { + flags: {update: true, expose: true}, + update: { + validate: validateArrayItems(isName), }, + }, - isAlias: Thing.common.flag(), - aliasedArtistRef: Thing.common.singleReference(Artist), - - // Update only - - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), - - // Expose only - - aliasedArtist: { - flags: {expose: true}, + isAlias: Thing.common.flag(), + aliasedArtistRef: Thing.common.singleReference(Artist), - expose: { - dependencies: ['artistData', 'aliasedArtistRef'], - compute: ({ artistData, aliasedArtistRef }) => ( - (aliasedArtistRef && artistData - ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'}) - : null) - ) - } - }, - - tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'), - tracksAsContributor: Artist.filterByContrib('trackData', 'contributorContribs'), - tracksAsCoverArtist: Artist.filterByContrib('trackData', 'coverArtistContribs'), + // Update only - tracksAsAny: { - flags: {expose: true}, + albumData: Thing.common.wikiData(Album), + artistData: Thing.common.wikiData(Artist), + flashData: Thing.common.wikiData(Flash), + trackData: Thing.common.wikiData(Track), - expose: { - dependencies: ['trackData'], + // Expose only - compute: ({ trackData, [Artist.instance]: artist }) => ( - trackData?.filter(track => ( - [ - ...track.artistContribs, - ...track.contributorContribs, - ...track.coverArtistContribs - ].some(({ who }) => who === artist)))) - } - }, + aliasedArtist: { + flags: {expose: true}, - tracksAsCommentator: { - flags: {expose: true}, + expose: { + dependencies: ['artistData', 'aliasedArtistRef'], + compute: ({artistData, aliasedArtistRef}) => + aliasedArtistRef && artistData + ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'}) + : null, + }, + }, + + tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'), + tracksAsContributor: Artist.filterByContrib( + 'trackData', + 'contributorContribs' + ), + tracksAsCoverArtist: Artist.filterByContrib( + 'trackData', + 'coverArtistContribs' + ), + + tracksAsAny: { + flags: {expose: true}, - expose: { - dependencies: ['trackData'], + expose: { + dependencies: ['trackData'], - compute: ({ trackData, [Artist.instance]: artist }) => ( - trackData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist))) - } + compute: ({trackData, [Artist.instance]: artist}) => + trackData?.filter((track) => + [ + ...track.artistContribs, + ...track.contributorContribs, + ...track.coverArtistContribs, + ].some(({who}) => who === artist) + ), }, + }, - albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'), - albumsAsCoverArtist: Artist.filterByContrib('albumData', 'coverArtistContribs'), - albumsAsWallpaperArtist: Artist.filterByContrib('albumData', 'wallpaperArtistContribs'), - albumsAsBannerArtist: Artist.filterByContrib('albumData', 'bannerArtistContribs'), + tracksAsCommentator: { + flags: {expose: true}, - albumsAsCommentator: { - flags: {expose: true}, + expose: { + dependencies: ['trackData'], + + compute: ({trackData, [Artist.instance]: artist}) => + trackData.filter(({commentatorArtists}) => + commentatorArtists?.includes(artist) + ), + }, + }, + + albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'), + albumsAsCoverArtist: Artist.filterByContrib( + 'albumData', + 'coverArtistContribs' + ), + albumsAsWallpaperArtist: Artist.filterByContrib( + 'albumData', + 'wallpaperArtistContribs' + ), + albumsAsBannerArtist: Artist.filterByContrib( + 'albumData', + 'bannerArtistContribs' + ), + + albumsAsCommentator: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], + expose: { + dependencies: ['albumData'], - compute: ({ albumData, [Artist.instance]: artist }) => ( - albumData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist))) - } + compute: ({albumData, [Artist.instance]: artist}) => + albumData.filter(({commentatorArtists}) => + commentatorArtists?.includes(artist) + ), }, + }, - flashesAsContributor: Artist.filterByContrib('flashData', 'contributorContribs'), + flashesAsContributor: Artist.filterByContrib( + 'flashData', + 'contributorContribs' + ), }; Artist[S.serializeDescriptors] = { - name: S.id, - directory: S.id, - urls: S.id, - contextNotes: S.id, + name: S.id, + directory: S.id, + urls: S.id, + contextNotes: S.id, - hasAvatar: S.id, - avatarFileExtension: S.id, + hasAvatar: S.id, + avatarFileExtension: S.id, - aliasNames: S.id, + aliasNames: S.id, - tracksAsArtist: S.toRefs, - tracksAsContributor: S.toRefs, - tracksAsCoverArtist: S.toRefs, - tracksAsCommentator: S.toRefs, + tracksAsArtist: S.toRefs, + tracksAsContributor: S.toRefs, + tracksAsCoverArtist: S.toRefs, + tracksAsCommentator: S.toRefs, - albumsAsAlbumArtist: S.toRefs, - albumsAsCoverArtist: S.toRefs, - albumsAsWallpaperArtist: S.toRefs, - albumsAsBannerArtist: S.toRefs, - albumsAsCommentator: S.toRefs, + albumsAsAlbumArtist: S.toRefs, + albumsAsCoverArtist: S.toRefs, + albumsAsWallpaperArtist: S.toRefs, + albumsAsBannerArtist: S.toRefs, + albumsAsCommentator: S.toRefs, - flashesAsContributor: S.toRefs, + flashesAsContributor: S.toRefs, }; // -> Group Group.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Group'), - directory: Thing.common.directory(), + name: Thing.common.name('Unnamed Group'), + directory: Thing.common.directory(), - description: Thing.common.simpleString(), + description: Thing.common.simpleString(), - urls: Thing.common.urls(), + urls: Thing.common.urls(), - // Update only + // Update only - albumData: Thing.common.wikiData(Album), - groupCategoryData: Thing.common.wikiData(GroupCategory), + albumData: Thing.common.wikiData(Album), + groupCategoryData: Thing.common.wikiData(GroupCategory), - // Expose only + // Expose only - descriptionShort: { - flags: {expose: true}, + descriptionShort: { + flags: {expose: true}, - expose: { - dependencies: ['description'], - compute: ({ description }) => description.split('<hr class="split">')[0] - } + expose: { + dependencies: ['description'], + compute: ({description}) => description.split('<hr class="split">')[0], }, + }, - albums: { - flags: {expose: true}, + albums: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], - compute: ({ albumData, [Group.instance]: group }) => ( - albumData?.filter(album => album.groups.includes(group)) ?? []) - } + expose: { + dependencies: ['albumData'], + compute: ({albumData, [Group.instance]: group}) => + albumData?.filter((album) => album.groups.includes(group)) ?? [], }, + }, - color: { - flags: {expose: true}, + color: { + flags: {expose: true}, - expose: { - dependencies: ['groupCategoryData'], + expose: { + dependencies: ['groupCategoryData'], - compute: ({ groupCategoryData, [Group.instance]: group }) => ( - groupCategoryData.find(category => category.groups.includes(group))?.color ?? null) - } + compute: ({groupCategoryData, [Group.instance]: group}) => + groupCategoryData.find((category) => category.groups.includes(group)) + ?.color ?? null, }, + }, - category: { - flags: {expose: true}, + category: { + flags: {expose: true}, - expose: { - dependencies: ['groupCategoryData'], - compute: ({ groupCategoryData, [Group.instance]: group }) => ( - groupCategoryData.find(category => category.groups.includes(group)) ?? null) - } + expose: { + dependencies: ['groupCategoryData'], + compute: ({groupCategoryData, [Group.instance]: group}) => + groupCategoryData.find((category) => category.groups.includes(group)) ?? + null, }, + }, }; GroupCategory.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Group Category'), - color: Thing.common.color(), + name: Thing.common.name('Unnamed Group Category'), + color: Thing.common.color(), - groupsByRef: Thing.common.referenceList(Group), + groupsByRef: Thing.common.referenceList(Group), - // Update only + // Update only - groupData: Thing.common.wikiData(Group), + groupData: Thing.common.wikiData(Group), - // Expose only + // Expose only - groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group), + groups: Thing.common.dynamicThingsFromReferenceList( + 'groupsByRef', + 'groupData', + find.group + ), }; // -> ArtTag ArtTag.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Art Tag'), - directory: Thing.common.directory(), - color: Thing.common.color(), - isContentWarning: Thing.common.flag(false), + name: Thing.common.name('Unnamed Art Tag'), + directory: Thing.common.directory(), + color: Thing.common.color(), + isContentWarning: Thing.common.flag(false), - // Update only + // Update only - albumData: Thing.common.wikiData(Album), - trackData: Thing.common.wikiData(Track), + albumData: Thing.common.wikiData(Album), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - // Previously known as: (tag).things - taggedInThings: { - flags: {expose: true}, + // Previously known as: (tag).things + taggedInThings: { + flags: {expose: true}, - expose: { - dependencies: ['albumData', 'trackData'], - compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) => ( - sortAlbumsTracksChronologically( - ([...albumData, ...trackData] - .filter(thing => thing.artTags?.includes(artTag))), - {getDate: o => o.coverArtDate})) - } - } + expose: { + dependencies: ['albumData', 'trackData'], + compute: ({albumData, trackData, [ArtTag.instance]: artTag}) => + sortAlbumsTracksChronologically( + [...albumData, ...trackData].filter((thing) => + thing.artTags?.includes(artTag) + ), + {getDate: (o) => o.coverArtDate} + ), + }, + }, }; // -> NewsEntry NewsEntry.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed News Entry'), - directory: Thing.common.directory(), - date: Thing.common.simpleDate(), + name: Thing.common.name('Unnamed News Entry'), + directory: Thing.common.directory(), + date: Thing.common.simpleDate(), - content: Thing.common.simpleString(), + content: Thing.common.simpleString(), - // Expose only + // Expose only - contentShort: { - flags: {expose: true}, + contentShort: { + flags: {expose: true}, - expose: { - dependencies: ['content'], + expose: { + dependencies: ['content'], - compute: ({ content }) => content.split('<hr class="split">')[0] - } + compute: ({content}) => content.split('<hr class="split">')[0], }, + }, }; // -> StaticPage StaticPage.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Static Page'), + name: Thing.common.name('Unnamed Static Page'), - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, - expose: { - dependencies: ['name'], - transform: (value, { name }) => value ?? name - } + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, }, + }, - directory: Thing.common.directory(), - content: Thing.common.simpleString(), - stylesheet: Thing.common.simpleString(), - showInNavigationBar: Thing.common.flag(true), + directory: Thing.common.directory(), + content: Thing.common.simpleString(), + stylesheet: Thing.common.simpleString(), + showInNavigationBar: Thing.common.flag(true), }; // -> HomepageLayout HomepageLayout.propertyDescriptors = { - // Update & expose + // Update & expose - sidebarContent: Thing.common.simpleString(), + sidebarContent: Thing.common.simpleString(), - rows: { - flags: {update: true, expose: true}, + rows: { + flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)) - } + update: { + validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), }, + }, }; HomepageLayoutRow.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Homepage Row'), + name: Thing.common.name('Unnamed Homepage Row'), - type: { - flags: {update: true, expose: true}, + type: { + flags: {update: true, expose: true}, - update: { - validate(value) { - throw new Error(`'type' property validator must be overridden`); - } - } + update: { + validate() { + throw new Error(`'type' property validator must be overridden`); + }, }, + }, - color: Thing.common.color(), + color: Thing.common.color(), - // Update only + // Update only - // These aren't necessarily used by every HomepageLayoutRow subclass, but - // for convenience of providing this data, every row accepts all wiki data - // arrays depended upon by any subclass's behavior. - albumData: Thing.common.wikiData(Album), - groupData: Thing.common.wikiData(Group), + // These aren't necessarily used by every HomepageLayoutRow subclass, but + // for convenience of providing this data, every row accepts all wiki data + // arrays depended upon by any subclass's behavior. + albumData: Thing.common.wikiData(Album), + groupData: Thing.common.wikiData(Group), }; HomepageLayoutAlbumsRow.propertyDescriptors = { - ...HomepageLayoutRow.propertyDescriptors, + ...HomepageLayoutRow.propertyDescriptors, - // Update & expose + // Update & expose - type: { - flags: {update: true, expose: true}, - update: { - validate(value) { - if (value !== 'albums') { - throw new TypeError(`Expected 'albums'`); - } - - return true; - } + type: { + flags: {update: true, expose: true}, + update: { + validate(value) { + if (value !== 'albums') { + throw new TypeError(`Expected 'albums'`); } + + return true; + }, }, + }, - sourceGroupByRef: Thing.common.singleReference(Group), - sourceAlbumsByRef: Thing.common.referenceList(Album), + sourceGroupByRef: Thing.common.singleReference(Group), + sourceAlbumsByRef: Thing.common.referenceList(Album), - countAlbumsFromGroup: { - flags: {update: true, expose: true}, - update: {validate: isCountingNumber} - }, + countAlbumsFromGroup: { + flags: {update: true, expose: true}, + update: {validate: isCountingNumber}, + }, - actionLinks: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isString)} - }, + actionLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isString)}, + }, - // Expose only + // Expose only - sourceGroup: Thing.common.dynamicThingFromSingleReference('sourceGroupByRef', 'groupData', find.group), - sourceAlbums: Thing.common.dynamicThingsFromReferenceList('sourceAlbumsByRef', 'albumData', find.album), + sourceGroup: Thing.common.dynamicThingFromSingleReference( + 'sourceGroupByRef', + 'groupData', + find.group + ), + sourceAlbums: Thing.common.dynamicThingsFromReferenceList( + 'sourceAlbumsByRef', + 'albumData', + find.album + ), }; // -> Flash Flash.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Flash'), - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - - // Flashes expose directory differently from other Things! Their - // default directory is dependent on the page number (or ID), not - // the name. - expose: { - dependencies: ['page'], - transform(directory, { page }) { - if (directory === null && page === null) - return null; - else if (directory === null) - return page; - else - return directory; - } - } + // Update & expose + + name: Thing.common.name('Unnamed Flash'), + + directory: { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + + // Flashes expose directory differently from other Things! Their + // default directory is dependent on the page number (or ID), not + // the name. + expose: { + dependencies: ['page'], + transform(directory, {page}) { + if (directory === null && page === null) return null; + else if (directory === null) return page; + else return directory; + }, }, + }, - page: { - flags: {update: true, expose: true}, - update: {validate: oneOf(isString, isNumber)}, + page: { + flags: {update: true, expose: true}, + update: {validate: oneOf(isString, isNumber)}, - expose: { - transform: value => (value === null ? null : value.toString()) - } + expose: { + transform: (value) => (value === null ? null : value.toString()), }, + }, - date: Thing.common.simpleDate(), + date: Thing.common.simpleDate(), - coverArtFileExtension: Thing.common.fileExtension('jpg'), + coverArtFileExtension: Thing.common.fileExtension('jpg'), - contributorContribsByRef: Thing.common.contribsByRef(), + contributorContribsByRef: Thing.common.contribsByRef(), - featuredTracksByRef: Thing.common.referenceList(Track), + featuredTracksByRef: Thing.common.referenceList(Track), - urls: Thing.common.urls(), + urls: Thing.common.urls(), - // Update only + // Update only - artistData: Thing.common.wikiData(Artist), - trackData: Thing.common.wikiData(Track), - flashActData: Thing.common.wikiData(FlashAct), + artistData: Thing.common.wikiData(Artist), + trackData: Thing.common.wikiData(Track), + flashActData: Thing.common.wikiData(FlashAct), - // Expose only + // Expose only - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), - featuredTracks: Thing.common.dynamicThingsFromReferenceList('featuredTracksByRef', 'trackData', find.track), + featuredTracks: Thing.common.dynamicThingsFromReferenceList( + 'featuredTracksByRef', + 'trackData', + find.track + ), - act: { - flags: {expose: true}, + act: { + flags: {expose: true}, - expose: { - dependencies: ['flashActData'], + expose: { + dependencies: ['flashActData'], - compute: ({ flashActData, [Flash.instance]: flash }) => ( - flashActData.find(act => act.flashes.includes(flash)) ?? null) - } + compute: ({flashActData, [Flash.instance]: flash}) => + flashActData.find((act) => act.flashes.includes(flash)) ?? null, }, + }, - color: { - flags: {expose: true}, + color: { + flags: {expose: true}, - expose: { - dependencies: ['flashActData'], + expose: { + dependencies: ['flashActData'], - compute: ({ flashActData, [Flash.instance]: flash }) => ( - flashActData.find(act => act.flashes.includes(flash))?.color ?? null) - } + compute: ({flashActData, [Flash.instance]: flash}) => + flashActData.find((act) => act.flashes.includes(flash))?.color ?? null, }, + }, }; Flash[S.serializeDescriptors] = { - name: S.id, - page: S.id, - directory: S.id, - date: S.id, - contributors: S.toContribRefs, - tracks: S.toRefs, - urls: S.id, - color: S.id, + name: S.id, + page: S.id, + directory: S.id, + date: S.id, + contributors: S.toContribRefs, + tracks: S.toRefs, + urls: S.id, + color: S.id, }; FlashAct.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Flash Act'), - color: Thing.common.color(), - anchor: Thing.common.simpleString(), - jump: Thing.common.simpleString(), - jumpColor: Thing.common.color(), + name: Thing.common.name('Unnamed Flash Act'), + color: Thing.common.color(), + anchor: Thing.common.simpleString(), + jump: Thing.common.simpleString(), + jumpColor: Thing.common.color(), - flashesByRef: Thing.common.referenceList(Flash), + flashesByRef: Thing.common.referenceList(Flash), - // Update only + // Update only - flashData: Thing.common.wikiData(Flash), + flashData: Thing.common.wikiData(Flash), - // Expose only + // Expose only - flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash), + flashes: Thing.common.dynamicThingsFromReferenceList( + 'flashesByRef', + 'flashData', + find.flash + ), }; // -> WikiInfo WikiInfo.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Wiki'), + name: Thing.common.name('Unnamed Wiki'), - // Displayed in nav bar. - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, + // Displayed in nav bar. + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, - expose: { - dependencies: ['name'], - transform: (value, { name }) => value ?? name - } + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, }, + }, - color: Thing.common.color(), + color: Thing.common.color(), - // One-line description used for <meta rel="description"> tag. - description: Thing.common.simpleString(), + // One-line description used for <meta rel="description"> tag. + description: Thing.common.simpleString(), - footerContent: Thing.common.simpleString(), + footerContent: Thing.common.simpleString(), - defaultLanguage: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode} - }, + defaultLanguage: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, - canonicalBase: { - flags: {update: true, expose: true}, - update: {validate: isURL} - }, + canonicalBase: { + flags: {update: true, expose: true}, + update: {validate: isURL}, + }, - divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), + divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), - // Feature toggles - enableFlashesAndGames: Thing.common.flag(false), - enableListings: Thing.common.flag(false), - enableNews: Thing.common.flag(false), - enableArtTagUI: Thing.common.flag(false), - enableGroupUI: Thing.common.flag(false), + // Feature toggles + enableFlashesAndGames: Thing.common.flag(false), + enableListings: Thing.common.flag(false), + enableNews: Thing.common.flag(false), + enableArtTagUI: Thing.common.flag(false), + enableGroupUI: Thing.common.flag(false), - // Update only + // Update only - groupData: Thing.common.wikiData(Group), + groupData: Thing.common.wikiData(Group), - // Expose only + // Expose only - divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group), + divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList( + 'divideTrackListsByGroupsByRef', + 'groupData', + find.group + ), }; // -> Language const intlHelper = (constructor, opts) => ({ - flags: {expose: true}, - expose: { - dependencies: ['code', 'intlCode'], - compute: ({ code, intlCode }) => { - const constructCode = intlCode ?? code; - if (!constructCode) return null; - return Reflect.construct(constructor, [constructCode, opts]); - } - } + flags: {expose: true}, + expose: { + dependencies: ['code', 'intlCode'], + compute: ({code, intlCode}) => { + const constructCode = intlCode ?? code; + if (!constructCode) return null; + return Reflect.construct(constructor, [constructCode, opts]); + }, + }, }); Language.propertyDescriptors = { - // Update & expose - - // General language code. This is used to identify the language distinctly - // from other languages (similar to how "Directory" operates in many data - // objects). - code: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode} - }, - - // Human-readable name. This should be the language's own native name, not - // localized to any other language. - name: Thing.common.simpleString(), - - // Language code specific to JavaScript's Internationalization (Intl) API. - // Usually this will be the same as the language's general code, but it - // may be overridden to provide Intl constructors an alternative value. - intlCode: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode}, - expose: { - dependencies: ['code'], - transform: (intlCode, { code }) => intlCode ?? code - } - }, - - // Flag which represents whether or not to hide a language from general - // access. If a language is hidden, its portion of the website will still - // be built (with all strings localized to the language), but it won't be - // included in controls for switching languages or the <link rel=alternate> - // tags used for search engine optimization. This flag is intended for use - // with languages that are currently in development and not ready for - // formal release, or which are just kept hidden as "experimental zones" - // for wiki development or content testing. - hidden: Thing.common.flag(false), - - // Mapping of translation keys to values (strings). Generally, don't - // access this object directly - use methods instead. - strings: { - flags: {update: true, expose: true}, - update: {validate: t => typeof t === 'object'}, - expose: { - dependencies: ['inheritedStrings'], - transform(strings, { inheritedStrings }) { - if (strings || inheritedStrings) { - return {...inheritedStrings ?? {}, ...strings ?? {}}; - } else { - return null; - } - } + // Update & expose + + // General language code. This is used to identify the language distinctly + // from other languages (similar to how "Directory" operates in many data + // objects). + code: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, + + // Human-readable name. This should be the language's own native name, not + // localized to any other language. + name: Thing.common.simpleString(), + + // Language code specific to JavaScript's Internationalization (Intl) API. + // Usually this will be the same as the language's general code, but it + // may be overridden to provide Intl constructors an alternative value. + intlCode: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + expose: { + dependencies: ['code'], + transform: (intlCode, {code}) => intlCode ?? code, + }, + }, + + // Flag which represents whether or not to hide a language from general + // access. If a language is hidden, its portion of the website will still + // be built (with all strings localized to the language), but it won't be + // included in controls for switching languages or the <link rel=alternate> + // tags used for search engine optimization. This flag is intended for use + // with languages that are currently in development and not ready for + // formal release, or which are just kept hidden as "experimental zones" + // for wiki development or content testing. + hidden: Thing.common.flag(false), + + // Mapping of translation keys to values (strings). Generally, don't + // access this object directly - use methods instead. + strings: { + flags: {update: true, expose: true}, + update: {validate: (t) => typeof t === 'object'}, + expose: { + dependencies: ['inheritedStrings'], + transform(strings, {inheritedStrings}) { + if (strings || inheritedStrings) { + return {...(inheritedStrings ?? {}), ...(strings ?? {})}; + } else { + return null; } + }, }, + }, - // May be provided to specify "default" strings, generally (but not - // necessarily) inherited from another Language object. - inheritedStrings: { - flags: {update: true, expose: true}, - update: {validate: t => typeof t === 'object'} - }, + // May be provided to specify "default" strings, generally (but not + // necessarily) inherited from another Language object. + inheritedStrings: { + flags: {update: true, expose: true}, + update: {validate: (t) => typeof t === 'object'}, + }, - // Update only + // Update only - escapeHTML: Thing.common.externalFunction(), + escapeHTML: Thing.common.externalFunction(), - // Expose only + // Expose only - intl_date: intlHelper(Intl.DateTimeFormat, {full: true}), - intl_number: intlHelper(Intl.NumberFormat), - intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}), - intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}), - intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}), - intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}), - intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}), + intl_date: intlHelper(Intl.DateTimeFormat, {full: true}), + intl_number: intlHelper(Intl.NumberFormat), + intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}), + intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}), + intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}), + intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}), + intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}), - validKeys: { - flags: {expose: true}, - - expose: { - dependencies: ['strings', 'inheritedStrings'], - compute: ({ strings, inheritedStrings }) => Array.from(new Set([ - ...Object.keys(inheritedStrings ?? {}), - ...Object.keys(strings ?? {}) - ])) - } - }, + validKeys: { + flags: {expose: true}, - strings_htmlEscaped: { - flags: {expose: true}, - expose: { - dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], - compute({ strings, inheritedStrings, escapeHTML }) { - if (!(strings || inheritedStrings) || !escapeHTML) return null; - const allStrings = {...inheritedStrings ?? {}, ...strings ?? {}}; - return Object.fromEntries(Object.entries(allStrings) - .map(([ k, v ]) => [k, escapeHTML(v)])); - } - } - }, + expose: { + dependencies: ['strings', 'inheritedStrings'], + compute: ({strings, inheritedStrings}) => + Array.from( + new Set([ + ...Object.keys(inheritedStrings ?? {}), + ...Object.keys(strings ?? {}), + ]) + ), + }, + }, + + strings_htmlEscaped: { + flags: {expose: true}, + expose: { + dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], + compute({strings, inheritedStrings, escapeHTML}) { + if (!(strings || inheritedStrings) || !escapeHTML) return null; + const allStrings = {...(inheritedStrings ?? {}), ...(strings ?? {})}; + return Object.fromEntries( + Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)]) + ); + }, + }, + }, }; -const countHelper = (stringKey, argName = stringKey) => function(value, {unit = false} = {}) { +const countHelper = (stringKey, argName = stringKey) => + function (value, {unit = false} = {}) { return this.$( - (unit - ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) - : `count.${stringKey}`), - {[argName]: this.formatNumber(value)}); -}; + unit + ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) + : `count.${stringKey}`, + {[argName]: this.formatNumber(value)} + ); + }; Object.assign(Language.prototype, { - $(key, args = {}) { - return this.formatString(key, args); - }, - - assertIntlAvailable(property) { - if (!this[property]) { - throw new Error(`Intl API ${property} unavailable`); - } - }, - - getUnitForm(value) { - this.assertIntlAvailable('intl_pluralCardinal'); - return this.intl_pluralCardinal.select(value); - }, - - formatString(key, args = {}) { - if (this.strings && !this.strings_htmlEscaped) { - throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`); - } - - return this.formatStringHelper(this.strings_htmlEscaped, key, args); - }, - - formatStringNoHTMLEscape(key, args = {}) { - return this.formatStringHelper(this.strings, key, args); - }, - - formatStringHelper(strings, key, args = {}) { - if (!strings) { - throw new Error(`Strings unavailable`); - } - - if (!this.validKeys.includes(key)) { - throw new Error(`Invalid key ${key} accessed`); - } - - const template = strings[key]; - - // Convert the keys on the args dict from camelCase to CONSTANT_CASE. - // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut - // like, who cares, dude?) Also, this is an array, 8ecause it's handy - // for the iterating we're a8out to do. - const processedArgs = Object.entries(args) - .map(([ k, v ]) => [k.replace(/[A-Z]/g, '_$&').toUpperCase(), v]); - - // Replacement time! Woot. Reduce comes in handy here! - const output = processedArgs.reduce( - (x, [ k, v ]) => x.replaceAll(`{${k}}`, v), - template); - - // Post-processing: if any expected arguments *weren't* replaced, that - // is almost definitely an error. - if (output.match(/\{[A-Z_]+\}/)) { - throw new Error(`Args in ${key} were missing - output: ${output}`); - } - - return output; - }, + $(key, args = {}) { + return this.formatString(key, args); + }, - formatDate(date) { - this.assertIntlAvailable('intl_date'); - return this.intl_date.format(date); - }, - - formatDateRange(startDate, endDate) { - this.assertIntlAvailable('intl_date'); - return this.intl_date.formatRange(startDate, endDate); - }, - - formatDuration(secTotal, {approximate = false, unit = false} = {}) { - if (secTotal === 0) { - return this.formatString('count.duration.missing'); - } - - const hour = Math.floor(secTotal / 3600); - const min = Math.floor((secTotal - hour * 3600) / 60); - const sec = Math.floor(secTotal - hour * 3600 - min * 60); - - const pad = val => val.toString().padStart(2, '0'); - - const stringSubkey = unit ? '.withUnit' : ''; - - const duration = (hour > 0 - ? this.formatString('count.duration.hours' + stringSubkey, { - hours: hour, - minutes: pad(min), - seconds: pad(sec) - }) - : this.formatString('count.duration.minutes' + stringSubkey, { - minutes: min, - seconds: pad(sec) - })); - - return (approximate - ? this.formatString('count.duration.approximate', {duration}) - : duration); - }, - - formatIndex(value) { - this.assertIntlAvailable('intl_pluralOrdinal'); - return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); - }, - - formatNumber(value) { - this.assertIntlAvailable('intl_number'); - return this.intl_number.format(value); - }, - - formatWordCount(value) { - const num = this.formatNumber(value > 1000 - ? Math.floor(value / 100) / 10 - : value); + assertIntlAvailable(property) { + if (!this[property]) { + throw new Error(`Intl API ${property} unavailable`); + } + }, + + getUnitForm(value) { + this.assertIntlAvailable('intl_pluralCardinal'); + return this.intl_pluralCardinal.select(value); + }, + + formatString(key, args = {}) { + if (this.strings && !this.strings_htmlEscaped) { + throw new Error( + `HTML-escaped strings unavailable - please ensure escapeHTML function is provided` + ); + } - const words = (value > 1000 - ? this.formatString('count.words.thousand', {words: num}) - : this.formatString('count.words', {words: num})); + return this.formatStringHelper(this.strings_htmlEscaped, key, args); + }, - return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words}); - }, + formatStringNoHTMLEscape(key, args = {}) { + return this.formatStringHelper(this.strings, key, args); + }, - // Conjunction list: A, B, and C - formatConjunctionList(array) { - this.assertIntlAvailable('intl_listConjunction'); - return this.intl_listConjunction.format(array); - }, + formatStringHelper(strings, key, args = {}) { + if (!strings) { + throw new Error(`Strings unavailable`); + } - // Disjunction lists: A, B, or C - formatDisjunctionList(array) { - this.assertIntlAvailable('intl_listDisjunction'); - return this.intl_listDisjunction.format(array); - }, + if (!this.validKeys.includes(key)) { + throw new Error(`Invalid key ${key} accessed`); + } - // Unit lists: A, B, C - formatUnitList(array) { - this.assertIntlAvailable('intl_listUnit'); - return this.intl_listUnit.format(array); - }, + const template = strings[key]; + + // Convert the keys on the args dict from camelCase to CONSTANT_CASE. + // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut + // like, who cares, dude?) Also, this is an array, 8ecause it's handy + // for the iterating we're a8out to do. + const processedArgs = Object.entries(args).map(([k, v]) => [ + k.replace(/[A-Z]/g, '_$&').toUpperCase(), + v, + ]); + + // Replacement time! Woot. Reduce comes in handy here! + const output = processedArgs.reduce( + (x, [k, v]) => x.replaceAll(`{${k}}`, v), + template + ); + + // Post-processing: if any expected arguments *weren't* replaced, that + // is almost definitely an error. + if (output.match(/\{[A-Z_]+\}/)) { + throw new Error(`Args in ${key} were missing - output: ${output}`); + } - // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB - formatFileSize(bytes) { - if (!bytes) return ''; + return output; + }, - bytes = parseInt(bytes); - if (isNaN(bytes)) return ''; + formatDate(date) { + this.assertIntlAvailable('intl_date'); + return this.intl_date.format(date); + }, - const round = exp => Math.round(bytes / 10 ** (exp - 1)) / 10; + formatDateRange(startDate, endDate) { + this.assertIntlAvailable('intl_date'); + return this.intl_date.formatRange(startDate, endDate); + }, - if (bytes >= 10 ** 12) { - return this.formatString('count.fileSize.terabytes', {terabytes: round(12)}); - } else if (bytes >= 10 ** 9) { - return this.formatString('count.fileSize.gigabytes', {gigabytes: round(9)}); - } else if (bytes >= 10 ** 6) { - return this.formatString('count.fileSize.megabytes', {megabytes: round(6)}); - } else if (bytes >= 10 ** 3) { - return this.formatString('count.fileSize.kilobytes', {kilobytes: round(3)}); - } else { - return this.formatString('count.fileSize.bytes', {bytes}); - } - }, + formatDuration(secTotal, {approximate = false, unit = false} = {}) { + if (secTotal === 0) { + return this.formatString('count.duration.missing'); + } - // TODO: These are hard-coded. Is there a better way? - countAdditionalFiles: countHelper('additionalFiles', 'files'), - countAlbums: countHelper('albums'), - countCommentaryEntries: countHelper('commentaryEntries', 'entries'), - countContributions: countHelper('contributions'), - countCoverArts: countHelper('coverArts'), - countTimesReferenced: countHelper('timesReferenced'), - countTimesUsed: countHelper('timesUsed'), - countTracks: countHelper('tracks'), + const hour = Math.floor(secTotal / 3600); + const min = Math.floor((secTotal - hour * 3600) / 60); + const sec = Math.floor(secTotal - hour * 3600 - min * 60); + + const pad = (val) => val.toString().padStart(2, '0'); + + const stringSubkey = unit ? '.withUnit' : ''; + + const duration = + hour > 0 + ? this.formatString('count.duration.hours' + stringSubkey, { + hours: hour, + minutes: pad(min), + seconds: pad(sec), + }) + : this.formatString('count.duration.minutes' + stringSubkey, { + minutes: min, + seconds: pad(sec), + }); + + return approximate + ? this.formatString('count.duration.approximate', {duration}) + : duration; + }, + + formatIndex(value) { + this.assertIntlAvailable('intl_pluralOrdinal'); + return this.formatString( + 'count.index.' + this.intl_pluralOrdinal.select(value), + { + index: value, + } + ); + }, + + formatNumber(value) { + this.assertIntlAvailable('intl_number'); + return this.intl_number.format(value); + }, + + formatWordCount(value) { + const num = this.formatNumber( + value > 1000 ? Math.floor(value / 100) / 10 : value + ); + + const words = + value > 1000 + ? this.formatString('count.words.thousand', {words: num}) + : this.formatString('count.words', {words: num}); + + return this.formatString( + 'count.words.withUnit.' + this.getUnitForm(value), + {words} + ); + }, + + // Conjunction list: A, B, and C + formatConjunctionList(array) { + this.assertIntlAvailable('intl_listConjunction'); + return this.intl_listConjunction.format(array); + }, + + // Disjunction lists: A, B, or C + formatDisjunctionList(array) { + this.assertIntlAvailable('intl_listDisjunction'); + return this.intl_listDisjunction.format(array); + }, + + // Unit lists: A, B, C + formatUnitList(array) { + this.assertIntlAvailable('intl_listUnit'); + return this.intl_listUnit.format(array); + }, + + // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB + formatFileSize(bytes) { + if (!bytes) return ''; + + bytes = parseInt(bytes); + if (isNaN(bytes)) return ''; + + const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10; + + if (bytes >= 10 ** 12) { + return this.formatString('count.fileSize.terabytes', { + terabytes: round(12), + }); + } else if (bytes >= 10 ** 9) { + return this.formatString('count.fileSize.gigabytes', { + gigabytes: round(9), + }); + } else if (bytes >= 10 ** 6) { + return this.formatString('count.fileSize.megabytes', { + megabytes: round(6), + }); + } else if (bytes >= 10 ** 3) { + return this.formatString('count.fileSize.kilobytes', { + kilobytes: round(3), + }); + } else { + return this.formatString('count.fileSize.bytes', {bytes}); + } + }, + + // TODO: These are hard-coded. Is there a better way? + countAdditionalFiles: countHelper('additionalFiles', 'files'), + countAlbums: countHelper('albums'), + countCommentaryEntries: countHelper('commentaryEntries', 'entries'), + countContributions: countHelper('contributions'), + countCoverArts: countHelper('coverArts'), + countTimesReferenced: countHelper('timesReferenced'), + countTimesUsed: countHelper('timesUsed'), + countTracks: countHelper('tracks'), }); diff --git a/src/data/validators.js b/src/data/validators.js index 0d325aed..8d922399 100644 --- a/src/data/validators.js +++ b/src/data/validators.js @@ -1,367 +1,389 @@ -import { withAggregate } from '../util/sugar.js'; +/** @format */ -import { color, ENABLE_COLOR, decorateTime } from '../util/cli.js'; +import {withAggregate} from '../util/sugar.js'; -import { inspect as nodeInspect } from 'util'; +import {color, ENABLE_COLOR} from '../util/cli.js'; + +import {inspect as nodeInspect} from 'util'; function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); + return nodeInspect(value, {colors: ENABLE_COLOR}); } // Basic types (primitives) function a(noun) { - return (/[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`); + return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; } function isType(value, type) { - if (typeof value !== type) - throw new TypeError(`Expected ${a(type)}, got ${typeof value}`); + if (typeof value !== type) + throw new TypeError(`Expected ${a(type)}, got ${typeof value}`); - return true; + return true; } export function isBoolean(value) { - return isType(value, 'boolean'); + return isType(value, 'boolean'); } export function isNumber(value) { - return isType(value, 'number'); + return isType(value, 'number'); } export function isPositive(number) { - isNumber(number); + isNumber(number); - if (number <= 0) - throw new TypeError(`Expected positive number`); + if (number <= 0) throw new TypeError(`Expected positive number`); - return true; + return true; } export function isNegative(number) { - isNumber(number); + isNumber(number); - if (number >= 0) - throw new TypeError(`Expected negative number`); + if (number >= 0) throw new TypeError(`Expected negative number`); - return true; + return true; } export function isPositiveOrZero(number) { - isNumber(number); + isNumber(number); - if (number < 0) - throw new TypeError(`Expected positive number or zero`); + if (number < 0) throw new TypeError(`Expected positive number or zero`); - return true; + return true; } export function isNegativeOrZero(number) { - isNumber(number); + isNumber(number); - if (number > 0) - throw new TypeError(`Expected negative number or zero`); + if (number > 0) throw new TypeError(`Expected negative number or zero`); - return true; + return true; } export function isInteger(number) { - isNumber(number); + isNumber(number); - if (number % 1 !== 0) - throw new TypeError(`Expected integer`); + if (number % 1 !== 0) throw new TypeError(`Expected integer`); - return true; + return true; } export function isCountingNumber(number) { - isInteger(number); - isPositive(number); + isInteger(number); + isPositive(number); - return true; + return true; } export function isWholeNumber(number) { - isInteger(number); - isPositiveOrZero(number); + isInteger(number); + isPositiveOrZero(number); - return true; + return true; } export function isString(value) { - return isType(value, 'string'); + return isType(value, 'string'); } export function isStringNonEmpty(value) { - isString(value); + isString(value); - if (value.trim().length === 0) - throw new TypeError(`Expected non-empty string`); + if (value.trim().length === 0) + throw new TypeError(`Expected non-empty string`); - return true; + return true; } // Complex types (non-primitives) export function isInstance(value, constructor) { - isObject(value); + isObject(value); - if (!(value instanceof constructor)) - throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`); + if (!(value instanceof constructor)) + throw new TypeError( + `Expected ${constructor.name}, got ${value.constructor.name}` + ); - return true; + return true; } export function isDate(value) { - return isInstance(value, Date); + return isInstance(value, Date); } export function isObject(value) { - isType(value, 'object'); + isType(value, 'object'); - // Note: Please remember that null is always a valid value for properties - // held by a CacheableObject. This assertion is exclusively for use in other - // contexts. - if (value === null) - throw new TypeError(`Expected an object, got null`); + // Note: Please remember that null is always a valid value for properties + // held by a CacheableObject. This assertion is exclusively for use in other + // contexts. + if (value === null) throw new TypeError(`Expected an object, got null`); - return true; + return true; } export function isArray(value) { - if (typeof value !== 'object' || value === null || !Array.isArray(value)) - throw new TypeError(`Expected an array, got ${value}`); + if (typeof value !== 'object' || value === null || !Array.isArray(value)) + throw new TypeError(`Expected an array, got ${value}`); - return true; + return true; } function validateArrayItemsHelper(itemValidator) { - return (item, index) => { - try { - const value = itemValidator(item); - - if (value !== true) { - throw new Error(`Expected validator to return true`); - } - } catch (error) { - error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`; - throw error; - } - }; + return (item, index) => { + try { + const value = itemValidator(item); + + if (value !== true) { + throw new Error(`Expected validator to return true`); + } + } catch (error) { + error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${ + error.message + }`; + throw error; + } + }; } export function validateArrayItems(itemValidator) { - const fn = validateArrayItemsHelper(itemValidator); + const fn = validateArrayItemsHelper(itemValidator); - return array => { - isArray(array); + return (array) => { + isArray(array); - withAggregate({message: 'Errors validating array items'}, ({ wrap }) => { - array.forEach(wrap(fn)); - }); + withAggregate({message: 'Errors validating array items'}, ({wrap}) => { + array.forEach(wrap(fn)); + }); - return true; - }; + return true; + }; } export function validateInstanceOf(constructor) { - return object => isInstance(object, constructor); + return (object) => isInstance(object, constructor); } // Wiki data (primitives & non-primitives) export function isColor(color) { - isStringNonEmpty(color); + isStringNonEmpty(color); - if (color.startsWith('#')) { - if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length)) - throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`); + if (color.startsWith('#')) { + if (![1 + 3, 1 + 4, 1 + 6, 1 + 8].includes(color.length)) + throw new TypeError( + `Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}` + ); - if (/[^0-9a-fA-F]/.test(color.slice(1))) - throw new TypeError(`Expected hexadecimal digits`); + if (/[^0-9a-fA-F]/.test(color.slice(1))) + throw new TypeError(`Expected hexadecimal digits`); - return true; - } + return true; + } - throw new TypeError(`Unknown color format`); + throw new TypeError(`Unknown color format`); } export function isCommentary(commentary) { - return isString(commentary); + return isString(commentary); } const isArtistRef = validateReference('artist'); export function validateProperties(spec) { - const specEntries = Object.entries(spec); - const specKeys = Object.keys(spec); - - return object => { - isObject(object); - - if (Array.isArray(object)) - throw new TypeError(`Expected an object, got array`); - - withAggregate({message: `Errors validating object properties`}, ({ call }) => { - for (const [ specKey, specValidator ] of specEntries) { - call(() => { - const value = object[specKey]; - try { - specValidator(value); - } catch (error) { - error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`; - throw error; - } - }); - } + const specEntries = Object.entries(spec); + const specKeys = Object.keys(spec); + + return (object) => { + isObject(object); + + if (Array.isArray(object)) + throw new TypeError(`Expected an object, got array`); - const unknownKeys = Object.keys(object).filter(key => !specKeys.includes(key)); - if (unknownKeys.length > 0) { - call(() => { - throw new Error(`Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`); - }); + withAggregate( + {message: `Errors validating object properties`}, + ({call}) => { + for (const [specKey, specValidator] of specEntries) { + call(() => { + const value = object[specKey]; + try { + specValidator(value); + } catch (error) { + error.message = `(key: ${color.green(specKey)}, value: ${inspect( + value + )}) ${error.message}`; + throw error; } - }); + }); + } - return true; - }; -} + const unknownKeys = Object.keys(object).filter( + (key) => !specKeys.includes(key) + ); + if (unknownKeys.length > 0) { + call(() => { + throw new Error( + `Unknown keys present (${ + unknownKeys.length + }): [${unknownKeys.join(', ')}]` + ); + }); + } + } + ); + return true; + }; +} export const isContribution = validateProperties({ - who: isArtistRef, - what: value => value === undefined || value === null || isStringNonEmpty(value), + who: isArtistRef, + what: (value) => + value === undefined || value === null || isStringNonEmpty(value), }); export const isContributionList = validateArrayItems(isContribution); export const isAdditionalFile = validateProperties({ - title: isString, - description: value => (value === undefined || value === null || isString(value)), - files: validateArrayItems(isString) + title: isString, + description: (value) => + value === undefined || value === null || isString(value), + files: validateArrayItems(isString), }); export const isAdditionalFileList = validateArrayItems(isAdditionalFile); export function isDimensions(dimensions) { - isArray(dimensions); + isArray(dimensions); - if (dimensions.length !== 2) - throw new TypeError(`Expected 2 item array`); + if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`); - isPositive(dimensions[0]); - isInteger(dimensions[0]); - isPositive(dimensions[1]); - isInteger(dimensions[1]); + isPositive(dimensions[0]); + isInteger(dimensions[0]); + isPositive(dimensions[1]); + isInteger(dimensions[1]); - return true; + return true; } export function isDirectory(directory) { - isStringNonEmpty(directory); + isStringNonEmpty(directory); - if (directory.match(/[^a-zA-Z0-9_\-]/)) - throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`); + if (directory.match(/[^a-zA-Z0-9_-]/)) + throw new TypeError( + `Expected only letters, numbers, dash, and underscore, got "${directory}"` + ); - return true; + return true; } export function isDuration(duration) { - isNumber(duration); - isPositiveOrZero(duration); + isNumber(duration); + isPositiveOrZero(duration); - return true; + return true; } export function isFileExtension(string) { - isStringNonEmpty(string); + isStringNonEmpty(string); - if (string[0] === '.') - throw new TypeError(`Expected no dot (.) at the start of file extension`); + if (string[0] === '.') + throw new TypeError(`Expected no dot (.) at the start of file extension`); - if (string.match(/[^a-zA-Z0-9_]/)) - throw new TypeError(`Expected only alphanumeric and underscore`); + if (string.match(/[^a-zA-Z0-9_]/)) + throw new TypeError(`Expected only alphanumeric and underscore`); - return true; + return true; } export function isLanguageCode(string) { - // TODO: This is a stub function because really we don't need a detailed - // is-language-code parser right now. + // TODO: This is a stub function because really we don't need a detailed + // is-language-code parser right now. - isString(string); + isString(string); - return true; + return true; } export function isName(name) { - return isString(name); + return isString(name); } export function isURL(string) { - isStringNonEmpty(string); + isStringNonEmpty(string); - new URL(string); + new URL(string); - return true; + return true; } export function validateReference(type = 'track') { - return ref => { - isStringNonEmpty(ref); + return (ref) => { + isStringNonEmpty(ref); - const match = ref.trim().match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/); + const match = ref + .trim() + .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/); - if (!match) - throw new TypeError(`Malformed reference`); + if (!match) throw new TypeError(`Malformed reference`); - const { groups: { typePart, directoryPart } } = match; + const { + groups: {typePart, directoryPart}, + } = match; - if (typePart && typePart !== type) - throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`); + if (typePart && typePart !== type) + throw new TypeError( + `Expected ref to begin with "${type}:", got "${typePart}:"` + ); - if (typePart) - isDirectory(directoryPart); + if (typePart) isDirectory(directoryPart); - isName(ref); + isName(ref); - return true; - }; + return true; + }; } export function validateReferenceList(type = '') { - return validateArrayItems(validateReference(type)); + return validateArrayItems(validateReference(type)); } // Compositional utilities export function oneOf(...checks) { - return value => { - const errorMeta = []; + return (value) => { + const errorMeta = []; - for (let i = 0, check; check = checks[i]; i++) { - try { - const result = check(value); - - if (result !== true) { - throw new Error(`Check returned false`); - } + for (let i = 0, check; (check = checks[i]); i++) { + try { + const result = check(value); - return true; - } catch (error) { - errorMeta.push([check, i, error]); - } + if (result !== true) { + throw new Error(`Check returned false`); } - // Don't process error messages until every check has failed. - const errors = []; - for (const [ check, i, error ] of errorMeta) { - error.message = (check.name - ? `(#${i} "${check.name}") ${error.message}` - : `(#${i}) ${error.message}`); - error.check = check; - errors.push(error); - } - throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`); - }; + return true; + } catch (error) { + errorMeta.push([check, i, error]); + } + } + + // Don't process error messages until every check has failed. + const errors = []; + for (const [check, i, error] of errorMeta) { + error.message = check.name + ? `(#${i} "${check.name}") ${error.message}` + : `(#${i}) ${error.message}`; + error.check = check; + errors.push(error); + } + throw new AggregateError( + errors, + `Expected one of ${checks.length} possible checks, but none were true` + ); + }; } diff --git a/src/data/yaml.js b/src/data/yaml.js index 763dfd28..367876be 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -1,59 +1,55 @@ +/** @format */ + // yaml.js - specification for HSMusic YAML data file format and utilities for // loading and processing YAML files and documents import * as path from 'path'; import yaml from 'js-yaml'; -import { readFile } from 'fs/promises'; -import { inspect as nodeInspect } from 'util'; +import {readFile} from 'fs/promises'; +import {inspect as nodeInspect} from 'util'; import { - Album, - Artist, - ArtTag, - Flash, - FlashAct, - Group, - GroupCategory, - HomepageLayout, - HomepageLayoutAlbumsRow, - HomepageLayoutRow, - NewsEntry, - StaticPage, - Thing, - Track, - TrackGroup, - WikiInfo, + Album, + Artist, + ArtTag, + Flash, + FlashAct, + Group, + GroupCategory, + HomepageLayout, + HomepageLayoutAlbumsRow, + NewsEntry, + StaticPage, + Thing, + Track, + TrackGroup, + WikiInfo, } from './things.js'; -import { - color, - ENABLE_COLOR, - logInfo, - logWarn, -} from '../util/cli.js'; +import {color, ENABLE_COLOR, logInfo, logWarn} from '../util/cli.js'; import { - decorateErrorWithIndex, - mapAggregate, - openAggregate, - showAggregate, - withAggregate, + decorateErrorWithIndex, + mapAggregate, + openAggregate, + showAggregate, + withAggregate, } from '../util/sugar.js'; import { - sortAlbumsTracksChronologically, - sortAlphabetically, - sortChronologically, + sortAlbumsTracksChronologically, + sortAlphabetically, + sortChronologically, } from '../util/wiki-data.js'; -import find, { bindFind } from '../util/find.js'; -import { findFiles } from '../util/io.js'; +import find, {bindFind} from '../util/find.js'; +import {findFiles} from '../util/io.js'; // --> General supporting stuff function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); + return nodeInspect(value, {colors: ENABLE_COLOR}); } // --> YAML data repository structure constants @@ -78,7 +74,9 @@ export const DATA_ALBUM_DIRECTORY = 'album'; // makeProcessDocument is a factory function: the returned function will take a // document and apply the configuration passed to makeProcessDocument in order // to construct a Thing subclass. -function makeProcessDocument(thingClass, { +function makeProcessDocument( + thingClass, + { // Optional early step for transforming field values before providing them // to the Thing's update() method. This is useful when the input format // (i.e. values in the document) differ from the format the actual Thing @@ -101,454 +99,479 @@ function makeProcessDocument(thingClass, { // they're present in a document, but they won't be used for Thing property // generation, either. Useful for stuff that's present in data files but not // yet implemented as part of a Thing's data model! - ignoredFields = [] -}) { - if (!propertyFieldMapping) { - throw new Error(`Expected propertyFieldMapping to be provided`); - } - - const knownFields = Object.values(propertyFieldMapping); - - // Invert the property-field mapping, since it'll come in handy for - // assigning update() source values later. - const fieldPropertyMapping = Object.fromEntries( - (Object.entries(propertyFieldMapping) - .map(([ property, field ]) => [field, property]))); - - const decorateErrorWithName = fn => { - const nameField = propertyFieldMapping['name']; - if (!nameField) return fn; - - return document => { - try { - return fn(document); - } catch (error) { - const name = document[nameField]; - error.message = (name - ? `(name: ${inspect(name)}) ${error.message}` - : `(${color.dim(`no name found`)}) ${error.message}`); - throw error; - } - }; + ignoredFields = [], + } +) { + if (!propertyFieldMapping) { + throw new Error(`Expected propertyFieldMapping to be provided`); + } + + const knownFields = Object.values(propertyFieldMapping); + + // Invert the property-field mapping, since it'll come in handy for + // assigning update() source values later. + const fieldPropertyMapping = Object.fromEntries( + Object.entries(propertyFieldMapping).map(([property, field]) => [ + field, + property, + ]) + ); + + const decorateErrorWithName = (fn) => { + const nameField = propertyFieldMapping['name']; + if (!nameField) return fn; + + return (document) => { + try { + return fn(document); + } catch (error) { + const name = document[nameField]; + error.message = name + ? `(name: ${inspect(name)}) ${error.message}` + : `(${color.dim(`no name found`)}) ${error.message}`; + throw error; + } }; + }; - return decorateErrorWithName(document => { - const documentEntries = Object.entries(document) - .filter(([ field ]) => !ignoredFields.includes(field)); + return decorateErrorWithName((document) => { + const documentEntries = Object.entries(document).filter( + ([field]) => !ignoredFields.includes(field) + ); - const unknownFields = documentEntries - .map(([ field ]) => field) - .filter(field => !knownFields.includes(field)); + const unknownFields = documentEntries + .map(([field]) => field) + .filter((field) => !knownFields.includes(field)); - if (unknownFields.length) { - throw new makeProcessDocument.UnknownFieldsError(unknownFields); - } + if (unknownFields.length) { + throw new makeProcessDocument.UnknownFieldsError(unknownFields); + } - const fieldValues = {}; + const fieldValues = {}; - for (const [ field, value ] of documentEntries) { - if (Object.hasOwn(fieldTransformations, field)) { - fieldValues[field] = fieldTransformations[field](value); - } else { - fieldValues[field] = value; - } - } + for (const [field, value] of documentEntries) { + if (Object.hasOwn(fieldTransformations, field)) { + fieldValues[field] = fieldTransformations[field](value); + } else { + fieldValues[field] = value; + } + } - const sourceProperties = {}; + const sourceProperties = {}; - for (const [ field, value ] of Object.entries(fieldValues)) { - const property = fieldPropertyMapping[field]; - sourceProperties[property] = value; - } + for (const [field, value] of Object.entries(fieldValues)) { + const property = fieldPropertyMapping[field]; + sourceProperties[property] = value; + } - const thing = Reflect.construct(thingClass, []); + const thing = Reflect.construct(thingClass, []); - withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({ call }) => { - for (const [ property, value ] of Object.entries(sourceProperties)) { - call(() => (thing[property] = value)); - } - }); + withAggregate( + {message: `Errors applying ${color.green(thingClass.name)} properties`}, + ({call}) => { + for (const [property, value] of Object.entries(sourceProperties)) { + call(() => (thing[property] = value)); + } + } + ); - return thing; - }); + return thing; + }); } -makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error { - constructor(fields) { - super(`Unknown fields present: ${fields.join(', ')}`); - this.fields = fields; - } +makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends ( + Error +) { + constructor(fields) { + super(`Unknown fields present: ${fields.join(', ')}`); + this.fields = fields; + } }; export const processAlbumDocument = makeProcessDocument(Album, { - fieldTransformations: { - 'Artists': parseContributors, - 'Cover Artists': parseContributors, - 'Default Track Cover Artists': parseContributors, - 'Wallpaper Artists': parseContributors, - 'Banner Artists': parseContributors, - - 'Date': value => new Date(value), - 'Date Added': value => new Date(value), - 'Cover Art Date': value => new Date(value), - 'Default Track Cover Art Date': value => new Date(value), - - 'Banner Dimensions': parseDimensions, - - 'Additional Files': parseAdditionalFiles, - }, - - propertyFieldMapping: { - name: 'Album', - - color: 'Color', - directory: 'Directory', - urls: 'URLs', - - artistContribsByRef: 'Artists', - coverArtistContribsByRef: 'Cover Artists', - trackCoverArtistContribsByRef: 'Default Track Cover Artists', - - coverArtFileExtension: 'Cover Art File Extension', - trackCoverArtFileExtension: 'Track Art File Extension', - - wallpaperArtistContribsByRef: 'Wallpaper Artists', - wallpaperStyle: 'Wallpaper Style', - wallpaperFileExtension: 'Wallpaper File Extension', - - bannerArtistContribsByRef: 'Banner Artists', - bannerStyle: 'Banner Style', - bannerFileExtension: 'Banner File Extension', - bannerDimensions: 'Banner Dimensions', - - date: 'Date', - trackArtDate: 'Default Track Cover Art Date', - coverArtDate: 'Cover Art Date', - dateAddedToWiki: 'Date Added', - - hasCoverArt: 'Has Cover Art', - hasTrackArt: 'Has Track Art', - hasTrackNumbers: 'Has Track Numbers', - isMajorRelease: 'Major Release', - isListedOnHomepage: 'Listed on Homepage', - - groupsByRef: 'Groups', - artTagsByRef: 'Art Tags', - commentary: 'Commentary', - - additionalFiles: 'Additional Files', - } + fieldTransformations: { + Artists: parseContributors, + 'Cover Artists': parseContributors, + 'Default Track Cover Artists': parseContributors, + 'Wallpaper Artists': parseContributors, + 'Banner Artists': parseContributors, + + Date: (value) => new Date(value), + 'Date Added': (value) => new Date(value), + 'Cover Art Date': (value) => new Date(value), + 'Default Track Cover Art Date': (value) => new Date(value), + + 'Banner Dimensions': parseDimensions, + + 'Additional Files': parseAdditionalFiles, + }, + + propertyFieldMapping: { + name: 'Album', + + color: 'Color', + directory: 'Directory', + urls: 'URLs', + + artistContribsByRef: 'Artists', + coverArtistContribsByRef: 'Cover Artists', + trackCoverArtistContribsByRef: 'Default Track Cover Artists', + + coverArtFileExtension: 'Cover Art File Extension', + trackCoverArtFileExtension: 'Track Art File Extension', + + wallpaperArtistContribsByRef: 'Wallpaper Artists', + wallpaperStyle: 'Wallpaper Style', + wallpaperFileExtension: 'Wallpaper File Extension', + + bannerArtistContribsByRef: 'Banner Artists', + bannerStyle: 'Banner Style', + bannerFileExtension: 'Banner File Extension', + bannerDimensions: 'Banner Dimensions', + + date: 'Date', + trackArtDate: 'Default Track Cover Art Date', + coverArtDate: 'Cover Art Date', + dateAddedToWiki: 'Date Added', + + hasCoverArt: 'Has Cover Art', + hasTrackArt: 'Has Track Art', + hasTrackNumbers: 'Has Track Numbers', + isMajorRelease: 'Major Release', + isListedOnHomepage: 'Listed on Homepage', + + groupsByRef: 'Groups', + artTagsByRef: 'Art Tags', + commentary: 'Commentary', + + additionalFiles: 'Additional Files', + }, }); export const processTrackGroupDocument = makeProcessDocument(TrackGroup, { - fieldTransformations: { - 'Date Originally Released': value => new Date(value), - }, - - propertyFieldMapping: { - name: 'Group', - color: 'Color', - dateOriginallyReleased: 'Date Originally Released', - } + fieldTransformations: { + 'Date Originally Released': (value) => new Date(value), + }, + + propertyFieldMapping: { + name: 'Group', + color: 'Color', + dateOriginallyReleased: 'Date Originally Released', + }, }); export const processTrackDocument = makeProcessDocument(Track, { - fieldTransformations: { - 'Duration': getDurationInSeconds, + fieldTransformations: { + Duration: getDurationInSeconds, - 'Date First Released': value => new Date(value), - 'Cover Art Date': value => new Date(value), + 'Date First Released': (value) => new Date(value), + 'Cover Art Date': (value) => new Date(value), - 'Artists': parseContributors, - 'Contributors': parseContributors, - 'Cover Artists': parseContributors, + Artists: parseContributors, + Contributors: parseContributors, + 'Cover Artists': parseContributors, - 'Additional Files': parseAdditionalFiles, - }, + 'Additional Files': parseAdditionalFiles, + }, - propertyFieldMapping: { - name: 'Track', + propertyFieldMapping: { + name: 'Track', - directory: 'Directory', - duration: 'Duration', - urls: 'URLs', + directory: 'Directory', + duration: 'Duration', + urls: 'URLs', - coverArtDate: 'Cover Art Date', - coverArtFileExtension: 'Cover Art File Extension', - dateFirstReleased: 'Date First Released', - hasCoverArt: 'Has Cover Art', - hasURLs: 'Has URLs', + coverArtDate: 'Cover Art Date', + coverArtFileExtension: 'Cover Art File Extension', + dateFirstReleased: 'Date First Released', + hasCoverArt: 'Has Cover Art', + hasURLs: 'Has URLs', - referencedTracksByRef: 'Referenced Tracks', - artistContribsByRef: 'Artists', - contributorContribsByRef: 'Contributors', - coverArtistContribsByRef: 'Cover Artists', - artTagsByRef: 'Art Tags', - originalReleaseTrackByRef: 'Originally Released As', + referencedTracksByRef: 'Referenced Tracks', + artistContribsByRef: 'Artists', + contributorContribsByRef: 'Contributors', + coverArtistContribsByRef: 'Cover Artists', + artTagsByRef: 'Art Tags', + originalReleaseTrackByRef: 'Originally Released As', - commentary: 'Commentary', - lyrics: 'Lyrics', + commentary: 'Commentary', + lyrics: 'Lyrics', - additionalFiles: 'Additional Files', - }, + additionalFiles: 'Additional Files', + }, - ignoredFields: ['Sampled Tracks'] + ignoredFields: ['Sampled Tracks'], }); export const processArtistDocument = makeProcessDocument(Artist, { - propertyFieldMapping: { - name: 'Artist', + propertyFieldMapping: { + name: 'Artist', - directory: 'Directory', - urls: 'URLs', - hasAvatar: 'Has Avatar', - avatarFileExtension: 'Avatar File Extension', + directory: 'Directory', + urls: 'URLs', + hasAvatar: 'Has Avatar', + avatarFileExtension: 'Avatar File Extension', - aliasNames: 'Aliases', + aliasNames: 'Aliases', - contextNotes: 'Context Notes' - }, + contextNotes: 'Context Notes', + }, - ignoredFields: ['Dead URLs'] + ignoredFields: ['Dead URLs'], }); export const processFlashDocument = makeProcessDocument(Flash, { - fieldTransformations: { - 'Date': value => new Date(value), + fieldTransformations: { + Date: (value) => new Date(value), - 'Contributors': parseContributors, - }, + Contributors: parseContributors, + }, - propertyFieldMapping: { - name: 'Flash', + propertyFieldMapping: { + name: 'Flash', - directory: 'Directory', - page: 'Page', - date: 'Date', - coverArtFileExtension: 'Cover Art File Extension', + directory: 'Directory', + page: 'Page', + date: 'Date', + coverArtFileExtension: 'Cover Art File Extension', - featuredTracksByRef: 'Featured Tracks', - contributorContribsByRef: 'Contributors', - urls: 'URLs' - }, + featuredTracksByRef: 'Featured Tracks', + contributorContribsByRef: 'Contributors', + urls: 'URLs', + }, }); export const processFlashActDocument = makeProcessDocument(FlashAct, { - propertyFieldMapping: { - name: 'Act', - color: 'Color', - anchor: 'Anchor', - jump: 'Jump', - jumpColor: 'Jump Color' - } + propertyFieldMapping: { + name: 'Act', + color: 'Color', + anchor: 'Anchor', + jump: 'Jump', + jumpColor: 'Jump Color', + }, }); export const processNewsEntryDocument = makeProcessDocument(NewsEntry, { - fieldTransformations: { - 'Date': value => new Date(value) - }, - - propertyFieldMapping: { - name: 'Name', - directory: 'Directory', - date: 'Date', - content: 'Content', - } + fieldTransformations: { + Date: (value) => new Date(value), + }, + + propertyFieldMapping: { + name: 'Name', + directory: 'Directory', + date: 'Date', + content: 'Content', + }, }); export const processArtTagDocument = makeProcessDocument(ArtTag, { - propertyFieldMapping: { - name: 'Tag', - directory: 'Directory', - color: 'Color', - isContentWarning: 'Is CW' - } + propertyFieldMapping: { + name: 'Tag', + directory: 'Directory', + color: 'Color', + isContentWarning: 'Is CW', + }, }); export const processGroupDocument = makeProcessDocument(Group, { - propertyFieldMapping: { - name: 'Group', - directory: 'Directory', - description: 'Description', - urls: 'URLs', - } + propertyFieldMapping: { + name: 'Group', + directory: 'Directory', + description: 'Description', + urls: 'URLs', + }, }); export const processGroupCategoryDocument = makeProcessDocument(GroupCategory, { - propertyFieldMapping: { - name: 'Category', - color: 'Color', - } + propertyFieldMapping: { + name: 'Category', + color: 'Color', + }, }); export const processStaticPageDocument = makeProcessDocument(StaticPage, { - propertyFieldMapping: { - name: 'Name', - nameShort: 'Short Name', - directory: 'Directory', + propertyFieldMapping: { + name: 'Name', + nameShort: 'Short Name', + directory: 'Directory', - content: 'Content', - stylesheet: 'Style', + content: 'Content', + stylesheet: 'Style', - showInNavigationBar: 'Show in Navigation Bar' - } + showInNavigationBar: 'Show in Navigation Bar', + }, }); export const processWikiInfoDocument = makeProcessDocument(WikiInfo, { - propertyFieldMapping: { - name: 'Name', - nameShort: 'Short Name', - color: 'Color', - description: 'Description', - footerContent: 'Footer Content', - defaultLanguage: 'Default Language', - canonicalBase: 'Canonical Base', - divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups', - enableFlashesAndGames: 'Enable Flashes & Games', - enableListings: 'Enable Listings', - enableNews: 'Enable News', - enableArtTagUI: 'Enable Art Tag UI', - enableGroupUI: 'Enable Group UI', - } + propertyFieldMapping: { + name: 'Name', + nameShort: 'Short Name', + color: 'Color', + description: 'Description', + footerContent: 'Footer Content', + defaultLanguage: 'Default Language', + canonicalBase: 'Canonical Base', + divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups', + enableFlashesAndGames: 'Enable Flashes & Games', + enableListings: 'Enable Listings', + enableNews: 'Enable News', + enableArtTagUI: 'Enable Art Tag UI', + enableGroupUI: 'Enable Group UI', + }, }); -export const processHomepageLayoutDocument = makeProcessDocument(HomepageLayout, { +export const processHomepageLayoutDocument = makeProcessDocument( + HomepageLayout, + { propertyFieldMapping: { - sidebarContent: 'Sidebar Content' + sidebarContent: 'Sidebar Content', }, - ignoredFields: ['Homepage'] -}); + ignoredFields: ['Homepage'], + } +); export function makeProcessHomepageLayoutRowDocument(rowClass, spec) { - return makeProcessDocument(rowClass, { - ...spec, - - propertyFieldMapping: { - name: 'Row', - color: 'Color', - type: 'Type', - ...spec.propertyFieldMapping, - } - }); + return makeProcessDocument(rowClass, { + ...spec, + + propertyFieldMapping: { + name: 'Row', + color: 'Color', + type: 'Type', + ...spec.propertyFieldMapping, + }, + }); } export const homepageLayoutRowTypeProcessMapping = { - albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, { - propertyFieldMapping: { - sourceGroupByRef: 'Group', - countAlbumsFromGroup: 'Count', - sourceAlbumsByRef: 'Albums', - actionLinks: 'Actions' - } - }) + albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, { + propertyFieldMapping: { + sourceGroupByRef: 'Group', + countAlbumsFromGroup: 'Count', + sourceAlbumsByRef: 'Albums', + actionLinks: 'Actions', + }, + }), }; export function processHomepageLayoutRowDocument(document) { - const type = document['Type']; + const type = document['Type']; - const match = Object.entries(homepageLayoutRowTypeProcessMapping) - .find(([ key ]) => key === type); + const match = Object.entries(homepageLayoutRowTypeProcessMapping).find( + ([key]) => key === type + ); - if (!match) { - throw new TypeError(`No processDocument function for row type ${type}!`); - } + if (!match) { + throw new TypeError(`No processDocument function for row type ${type}!`); + } - return match[1](document); + return match[1](document); } // --> Utilities shared across document parsing functions export function getDurationInSeconds(string) { - if (typeof string === 'number') { - return string; - } - - if (typeof string !== 'string') { - throw new TypeError(`Expected a string or number, got ${string}`); - } - - const parts = string.split(':').map(n => parseInt(n)) - if (parts.length === 3) { - return parts[0] * 3600 + parts[1] * 60 + parts[2] - } else if (parts.length === 2) { - return parts[0] * 60 + parts[1] - } else { - return 0 - } + if (typeof string === 'number') { + return string; + } + + if (typeof string !== 'string') { + throw new TypeError(`Expected a string or number, got ${string}`); + } + + const parts = string.split(':').map((n) => parseInt(n)); + if (parts.length === 3) { + return parts[0] * 3600 + parts[1] * 60 + parts[2]; + } else if (parts.length === 2) { + return parts[0] * 60 + parts[1]; + } else { + return 0; + } } export function parseAdditionalFiles(array) { - if (!array) return null; - if (!Array.isArray(array)) { - // Error will be caught when validating against whatever this value is - return array; - } - - return array.map(item => ({ - title: item['Title'], - description: item['Description'] ?? null, - files: item['Files'] - })); + if (!array) return null; + if (!Array.isArray(array)) { + // Error will be caught when validating against whatever this value is + return array; + } + + return array.map((item) => ({ + title: item['Title'], + description: item['Description'] ?? null, + files: item['Files'], + })); } export function parseCommentary(text) { - if (text) { - const lines = String(text).split('\n'); - if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) { - return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`}; - } - return text; - } else { - return null; + if (text) { + const lines = String(text).split('\n'); + if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) { + return { + error: `An entry is missing commentary citation: "${lines[0].slice( + 0, + 40 + )}..."`, + }; } + return text; + } else { + return null; + } } export function parseContributors(contributors) { - if (!contributors) { - return null; - } - - if (contributors.length === 1 && contributors[0].startsWith('<i>')) { - const arr = []; - arr.textContent = contributors[0]; - return arr; + if (!contributors) { + return null; + } + + if (contributors.length === 1 && contributors[0].startsWith('<i>')) { + const arr = []; + arr.textContent = contributors[0]; + return arr; + } + + contributors = contributors.map((contrib) => { + // 8asically, the format is "Who (What)", or just "Who". 8e sure to + // keep in mind that "what" doesn't necessarily have a value! + const match = contrib.match(/^(.*?)( \((.*)\))?$/); + if (!match) { + return contrib; } + const who = match[1]; + const what = match[3] || null; + return {who, what}; + }); - contributors = contributors.map(contrib => { - // 8asically, the format is "Who (What)", or just "Who". 8e sure to - // keep in mind that "what" doesn't necessarily have a value! - const match = contrib.match(/^(.*?)( \((.*)\))?$/); - if (!match) { - return contrib; - } - const who = match[1]; - const what = match[3] || null; - return {who, what}; - }); - - const badContributor = contributors.find(val => typeof val === 'string'); - if (badContributor) { - return {error: `An entry has an incorrectly formatted contributor, "${badContributor}".`}; - } + const badContributor = contributors.find((val) => typeof val === 'string'); + if (badContributor) { + return { + error: `An entry has an incorrectly formatted contributor, "${badContributor}".`, + }; + } - if (contributors.length === 1 && contributors[0].who === 'none') { - return null; - } + if (contributors.length === 1 && contributors[0].who === 'none') { + return null; + } - return contributors; + return contributors; } function parseDimensions(string) { - if (!string) { - return null; - } - - const parts = string.split(/[x,* ]+/g); - if (parts.length !== 2) throw new Error(`Invalid dimensions: ${string} (expected width & height)`); - const nums = parts.map(part => Number(part.trim())); - if (nums.includes(NaN)) throw new Error(`Invalid dimensions: ${string} (couldn't parse as numbers)`); - return nums; + if (!string) { + return null; + } + + const parts = string.split(/[x,* ]+/g); + if (parts.length !== 2) + throw new Error(`Invalid dimensions: ${string} (expected width & height)`); + const nums = parts.map((part) => Number(part.trim())); + if (nums.includes(NaN)) + throw new Error( + `Invalid dimensions: ${string} (couldn't parse as numbers)` + ); + return nums; } // --> Data repository loading functions and descriptors @@ -556,41 +579,41 @@ function parseDimensions(string) { // documentModes: Symbols indicating sets of behavior for loading and processing // data files. export const documentModes = { - // onePerFile: One document per file. Expects files array (or function) and - // processDocument function. Obviously, each specified data file should only - // contain one YAML document (an error will be thrown otherwise). Calls save - // with an array of processed documents (wiki objects). - onePerFile: Symbol('Document mode: onePerFile'), - - // headerAndEntries: One or more documents per file; the first document is - // treated as a "header" and represents data which pertains to all following - // "entry" documents. Expects files array (or function) and - // processHeaderDocument and processEntryDocument functions. Calls save with - // an array of {header, entries} objects. - // - // Please note that the final results loaded from each file may be "missing" - // data objects corresponding to entry documents if the processEntryDocument - // function throws on any entries, resulting in partial data provided to - // save() - errors will be caught and thrown in the final buildSteps - // aggregate. However, if the processHeaderDocument function fails, all - // following documents in the same file will be ignored as well (i.e. an - // entire file will be excempt from the save() function's input). - headerAndEntries: Symbol('Document mode: headerAndEntries'), - - // allInOne: One or more documents, all contained in one file. Expects file - // string (or function) and processDocument function. Calls save with an - // array of processed documents (wiki objects). - allInOne: Symbol('Document mode: allInOne'), - - // oneDocumentTotal: Just a single document, represented in one file. - // Expects file string (or function) and processDocument function. Calls - // save with the single processed wiki document (data object). - // - // Please note that if the single document fails to process, the save() - // function won't be called at all, generally resulting in an altogether - // missing property from the global wikiData object. This should be caught - // and handled externally. - oneDocumentTotal: Symbol('Document mode: oneDocumentTotal'), + // onePerFile: One document per file. Expects files array (or function) and + // processDocument function. Obviously, each specified data file should only + // contain one YAML document (an error will be thrown otherwise). Calls save + // with an array of processed documents (wiki objects). + onePerFile: Symbol('Document mode: onePerFile'), + + // headerAndEntries: One or more documents per file; the first document is + // treated as a "header" and represents data which pertains to all following + // "entry" documents. Expects files array (or function) and + // processHeaderDocument and processEntryDocument functions. Calls save with + // an array of {header, entries} objects. + // + // Please note that the final results loaded from each file may be "missing" + // data objects corresponding to entry documents if the processEntryDocument + // function throws on any entries, resulting in partial data provided to + // save() - errors will be caught and thrown in the final buildSteps + // aggregate. However, if the processHeaderDocument function fails, all + // following documents in the same file will be ignored as well (i.e. an + // entire file will be excempt from the save() function's input). + headerAndEntries: Symbol('Document mode: headerAndEntries'), + + // allInOne: One or more documents, all contained in one file. Expects file + // string (or function) and processDocument function. Calls save with an + // array of processed documents (wiki objects). + allInOne: Symbol('Document mode: allInOne'), + + // oneDocumentTotal: Just a single document, represented in one file. + // Expects file string (or function) and processDocument function. Calls + // save with the single processed wiki document (data object). + // + // Please note that if the single document fails to process, the save() + // function won't be called at all, generally resulting in an altogether + // missing property from the global wikiData object. This should be caught + // and handled externally. + oneDocumentTotal: Symbol('Document mode: oneDocumentTotal'), }; // dataSteps: Top-level array of "steps" for loading YAML document files. @@ -626,499 +649,559 @@ export const documentModes = { // format depends on documentMode. // export const dataSteps = [ - { - title: `Process wiki info file`, - file: WIKI_INFO_FILE, + { + title: `Process wiki info file`, + file: WIKI_INFO_FILE, - documentMode: documentModes.oneDocumentTotal, - processDocument: processWikiInfoDocument, + documentMode: documentModes.oneDocumentTotal, + processDocument: processWikiInfoDocument, - save(wikiInfo) { - if (!wikiInfo) { - return; - } + save(wikiInfo) { + if (!wikiInfo) { + return; + } - return {wikiInfo}; - } + return {wikiInfo}; + }, + }, + + { + title: `Process album files`, + files: async (dataPath) => + ( + await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), { + filter: (f) => path.extname(f) === '.yaml', + joinParentDirectory: false, + }) + ).map((file) => path.join(DATA_ALBUM_DIRECTORY, file)), + + documentMode: documentModes.headerAndEntries, + processHeaderDocument: processAlbumDocument, + processEntryDocument(document) { + return 'Group' in document + ? processTrackGroupDocument(document) + : processTrackDocument(document); }, - { - title: `Process album files`, - files: async dataPath => ( - (await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), { - filter: f => path.extname(f) === '.yaml', - joinParentDirectory: false - })).map(file => path.join(DATA_ALBUM_DIRECTORY, file))), - - documentMode: documentModes.headerAndEntries, - processHeaderDocument: processAlbumDocument, - processEntryDocument(document) { - return ('Group' in document - ? processTrackGroupDocument(document) - : processTrackDocument(document)); - }, - - save(results) { - const albumData = []; - const trackData = []; - - for (const { header: album, entries } of results) { - // We can't mutate an array once it's set as a property - // value, so prepare the tracks and track groups that will - // show up in a track list all the way before actually - // applying them. - const trackGroups = []; - let currentTracksByRef = null; - let currentTrackGroup = null; - - const albumRef = Thing.getReference(album); - - function closeCurrentTrackGroup() { - if (currentTracksByRef) { - let trackGroup; - - if (currentTrackGroup) { - trackGroup = currentTrackGroup; - } else { - trackGroup = new TrackGroup(); - trackGroup.name = `Default Track Group`; - trackGroup.isDefaultTrackGroup = true; - } - - trackGroup.album = album; - trackGroup.tracksByRef = currentTracksByRef; - trackGroups.push(trackGroup); - } - } + save(results) { + const albumData = []; + const trackData = []; - for (const entry of entries) { - if (entry instanceof TrackGroup) { - closeCurrentTrackGroup(); - currentTracksByRef = []; - currentTrackGroup = entry; - continue; - } + for (const {header: album, entries} of results) { + // We can't mutate an array once it's set as a property + // value, so prepare the tracks and track groups that will + // show up in a track list all the way before actually + // applying them. + const trackGroups = []; + let currentTracksByRef = null; + let currentTrackGroup = null; - trackData.push(entry); + const albumRef = Thing.getReference(album); - entry.dataSourceAlbumByRef = albumRef; + const closeCurrentTrackGroup = () => { + if (currentTracksByRef) { + let trackGroup; - const trackRef = Thing.getReference(entry); - if (currentTracksByRef) { - currentTracksByRef.push(trackRef); - } else { - currentTracksByRef = [trackRef]; - } - } + if (currentTrackGroup) { + trackGroup = currentTrackGroup; + } else { + trackGroup = new TrackGroup(); + trackGroup.name = `Default Track Group`; + trackGroup.isDefaultTrackGroup = true; + } - closeCurrentTrackGroup(); + trackGroup.album = album; + trackGroup.tracksByRef = currentTracksByRef; + trackGroups.push(trackGroup); + } + }; - album.trackGroups = trackGroups; - albumData.push(album); - } + for (const entry of entries) { + if (entry instanceof TrackGroup) { + closeCurrentTrackGroup(); + currentTracksByRef = []; + currentTrackGroup = entry; + continue; + } - return {albumData, trackData}; - } - }, + trackData.push(entry); - { - title: `Process artists file`, - file: ARTIST_DATA_FILE, - - documentMode: documentModes.allInOne, - processDocument: processArtistDocument, - - save(results) { - const artistData = results; - - const artistAliasData = results.flatMap(artist => { - const origRef = Thing.getReference(artist); - return (artist.aliasNames?.map(name => { - const alias = new Artist(); - alias.name = name; - alias.isAlias = true; - alias.aliasedArtistRef = origRef; - alias.artistData = artistData; - return alias; - }) ?? []); - }); + entry.dataSourceAlbumByRef = albumRef; - return {artistData, artistAliasData}; + const trackRef = Thing.getReference(entry); + if (currentTracksByRef) { + currentTracksByRef.push(trackRef); + } else { + currentTracksByRef = [trackRef]; + } } - }, - // TODO: WD.wikiInfo.enableFlashesAndGames && - { - title: `Process flashes file`, - file: FLASH_DATA_FILE, + closeCurrentTrackGroup(); - documentMode: documentModes.allInOne, - processDocument(document) { - return ('Act' in document - ? processFlashActDocument(document) - : processFlashDocument(document)); - }, + album.trackGroups = trackGroups; + albumData.push(album); + } - save(results) { - let flashAct; - let flashesByRef = []; + return {albumData, trackData}; + }, + }, + + { + title: `Process artists file`, + file: ARTIST_DATA_FILE, + + documentMode: documentModes.allInOne, + processDocument: processArtistDocument, + + save(results) { + const artistData = results; + + const artistAliasData = results.flatMap((artist) => { + const origRef = Thing.getReference(artist); + return ( + artist.aliasNames?.map((name) => { + const alias = new Artist(); + alias.name = name; + alias.isAlias = true; + alias.aliasedArtistRef = origRef; + alias.artistData = artistData; + return alias; + }) ?? [] + ); + }); + + return {artistData, artistAliasData}; + }, + }, + + // TODO: WD.wikiInfo.enableFlashesAndGames && + { + title: `Process flashes file`, + file: FLASH_DATA_FILE, + + documentMode: documentModes.allInOne, + processDocument(document) { + return 'Act' in document + ? processFlashActDocument(document) + : processFlashDocument(document); + }, - if (results[0] && !(results[0] instanceof FlashAct)) { - throw new Error(`Expected an act at top of flash data file`); - } + save(results) { + let flashAct; + let flashesByRef = []; - for (const thing of results) { - if (thing instanceof FlashAct) { - if (flashAct) { - Object.assign(flashAct, {flashesByRef}); - } + if (results[0] && !(results[0] instanceof FlashAct)) { + throw new Error(`Expected an act at top of flash data file`); + } - flashAct = thing; - flashesByRef = []; - } else { - flashesByRef.push(Thing.getReference(thing)); - } - } + for (const thing of results) { + if (thing instanceof FlashAct) { + if (flashAct) { + Object.assign(flashAct, {flashesByRef}); + } - if (flashAct) { - Object.assign(flashAct, {flashesByRef}); - } + flashAct = thing; + flashesByRef = []; + } else { + flashesByRef.push(Thing.getReference(thing)); + } + } - const flashData = results.filter(x => x instanceof Flash); - const flashActData = results.filter(x => x instanceof FlashAct); + if (flashAct) { + Object.assign(flashAct, {flashesByRef}); + } - return {flashData, flashActData}; - } + const flashData = results.filter((x) => x instanceof Flash); + const flashActData = results.filter((x) => x instanceof FlashAct); + + return {flashData, flashActData}; }, + }, - { - title: `Process groups file`, - file: GROUP_DATA_FILE, + { + title: `Process groups file`, + file: GROUP_DATA_FILE, - documentMode: documentModes.allInOne, - processDocument(document) { - return ('Category' in document - ? processGroupCategoryDocument(document) - : processGroupDocument(document)); - }, + documentMode: documentModes.allInOne, + processDocument(document) { + return 'Category' in document + ? processGroupCategoryDocument(document) + : processGroupDocument(document); + }, - save(results) { - let groupCategory; - let groupsByRef = []; + save(results) { + let groupCategory; + let groupsByRef = []; - if (results[0] && !(results[0] instanceof GroupCategory)) { - throw new Error(`Expected a category at top of group data file`); - } + if (results[0] && !(results[0] instanceof GroupCategory)) { + throw new Error(`Expected a category at top of group data file`); + } - for (const thing of results) { - if (thing instanceof GroupCategory) { - if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); - } + for (const thing of results) { + if (thing instanceof GroupCategory) { + if (groupCategory) { + Object.assign(groupCategory, {groupsByRef}); + } - groupCategory = thing; - groupsByRef = []; - } else { - groupsByRef.push(Thing.getReference(thing)); - } - } + groupCategory = thing; + groupsByRef = []; + } else { + groupsByRef.push(Thing.getReference(thing)); + } + } - if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); - } + if (groupCategory) { + Object.assign(groupCategory, {groupsByRef}); + } - const groupData = results.filter(x => x instanceof Group); - const groupCategoryData = results.filter(x => x instanceof GroupCategory); + const groupData = results.filter((x) => x instanceof Group); + const groupCategoryData = results.filter( + (x) => x instanceof GroupCategory + ); - return {groupData, groupCategoryData}; - } + return {groupData, groupCategoryData}; }, + }, - { - title: `Process homepage layout file`, - files: [HOMEPAGE_LAYOUT_DATA_FILE], + { + title: `Process homepage layout file`, + files: [HOMEPAGE_LAYOUT_DATA_FILE], - documentMode: documentModes.headerAndEntries, - processHeaderDocument: processHomepageLayoutDocument, - processEntryDocument: processHomepageLayoutRowDocument, + documentMode: documentModes.headerAndEntries, + processHeaderDocument: processHomepageLayoutDocument, + processEntryDocument: processHomepageLayoutRowDocument, - save(results) { - if (!results[0]) { - return; - } + save(results) { + if (!results[0]) { + return; + } - const { header: homepageLayout, entries: rows } = results[0]; - Object.assign(homepageLayout, {rows}); - return {homepageLayout}; - } + const {header: homepageLayout, entries: rows} = results[0]; + Object.assign(homepageLayout, {rows}); + return {homepageLayout}; }, + }, - // TODO: WD.wikiInfo.enableNews && - { - title: `Process news data file`, - file: NEWS_DATA_FILE, + // TODO: WD.wikiInfo.enableNews && + { + title: `Process news data file`, + file: NEWS_DATA_FILE, - documentMode: documentModes.allInOne, - processDocument: processNewsEntryDocument, + documentMode: documentModes.allInOne, + processDocument: processNewsEntryDocument, - save(newsData) { - sortChronologically(newsData); - newsData.reverse(); + save(newsData) { + sortChronologically(newsData); + newsData.reverse(); - return {newsData}; - } + return {newsData}; }, + }, - { - title: `Process art tags file`, - file: ART_TAG_DATA_FILE, + { + title: `Process art tags file`, + file: ART_TAG_DATA_FILE, - documentMode: documentModes.allInOne, - processDocument: processArtTagDocument, + documentMode: documentModes.allInOne, + processDocument: processArtTagDocument, - save(artTagData) { - sortAlphabetically(artTagData); + save(artTagData) { + sortAlphabetically(artTagData); - return {artTagData}; - } + return {artTagData}; }, + }, - { - title: `Process static pages file`, - file: STATIC_PAGE_DATA_FILE, + { + title: `Process static pages file`, + file: STATIC_PAGE_DATA_FILE, - documentMode: documentModes.allInOne, - processDocument: processStaticPageDocument, + documentMode: documentModes.allInOne, + processDocument: processStaticPageDocument, - save(staticPageData) { - return {staticPageData}; - } + save(staticPageData) { + return {staticPageData}; }, + }, ]; -export async function loadAndProcessDataDocuments({ - dataPath, -}) { - const processDataAggregate = openAggregate({message: `Errors processing data files`}); - const wikiDataResult = {}; - - function decorateErrorWithFile(fn) { - return (x, index, array) => { - try { - return fn(x, index, array); - } catch (error) { - error.message += ( - (error.message.includes('\n') ? '\n' : ' ') + - `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})` - ); - throw error; - } - }; - } +export async function loadAndProcessDataDocuments({dataPath}) { + const processDataAggregate = openAggregate({ + message: `Errors processing data files`, + }); + const wikiDataResult = {}; + + function decorateErrorWithFile(fn) { + return (x, index, array) => { + try { + return fn(x, index, array); + } catch (error) { + error.message += + (error.message.includes('\n') ? '\n' : ' ') + + `(file: ${color.bright( + color.blue(path.relative(dataPath, x.file)) + )})`; + throw error; + } + }; + } - for (const dataStep of dataSteps) { - await processDataAggregate.nestAsync( - {message: `Errors during data step: ${dataStep.title}`}, - async ({call, callAsync, map, mapAsync, nest}) => { - const { documentMode } = dataStep; + for (const dataStep of dataSteps) { + await processDataAggregate.nestAsync( + {message: `Errors during data step: ${dataStep.title}`}, + async ({call, callAsync, map, mapAsync, nest}) => { + const {documentMode} = dataStep; - if (!(Object.values(documentModes).includes(documentMode))) { - throw new Error(`Invalid documentMode: ${documentMode.toString()}`); - } + if (!Object.values(documentModes).includes(documentMode)) { + throw new Error(`Invalid documentMode: ${documentMode.toString()}`); + } - if (documentMode === documentModes.allInOne || documentMode === documentModes.oneDocumentTotal) { - if (!dataStep.file) { - throw new Error(`Expected 'file' property for ${documentMode.toString()}`); - } + if ( + documentMode === documentModes.allInOne || + documentMode === documentModes.oneDocumentTotal + ) { + if (!dataStep.file) { + throw new Error( + `Expected 'file' property for ${documentMode.toString()}` + ); + } + + const file = path.join( + dataPath, + typeof dataStep.file === 'function' + ? await callAsync(dataStep.file, dataPath) + : dataStep.file + ); - const file = path.join(dataPath, - (typeof dataStep.file === 'function' - ? await callAsync(dataStep.file, dataPath) - : dataStep.file)); + const readResult = await callAsync(readFile, file, 'utf-8'); - const readResult = await callAsync(readFile, file, 'utf-8'); + if (!readResult) { + return; + } - if (!readResult) { - return; - } + const yamlResult = + documentMode === documentModes.oneDocumentTotal + ? call(yaml.load, readResult) + : call(yaml.loadAll, readResult); - const yamlResult = (documentMode === documentModes.oneDocumentTotal - ? call(yaml.load, readResult) - : call(yaml.loadAll, readResult)); + if (!yamlResult) { + return; + } - if (!yamlResult) { - return; - } + let processResults; - let processResults; - - if (documentMode === documentModes.oneDocumentTotal) { - nest({message: `Errors processing document`}, ({ call }) => { - processResults = call(dataStep.processDocument, yamlResult); - }); - } else { - const { result, aggregate } = mapAggregate( - yamlResult, - decorateErrorWithIndex(dataStep.processDocument), - {message: `Errors processing documents`} - ); - processResults = result; - call(aggregate.close); - } + if (documentMode === documentModes.oneDocumentTotal) { + nest({message: `Errors processing document`}, ({call}) => { + processResults = call(dataStep.processDocument, yamlResult); + }); + } else { + const {result, aggregate} = mapAggregate( + yamlResult, + decorateErrorWithIndex(dataStep.processDocument), + {message: `Errors processing documents`} + ); + processResults = result; + call(aggregate.close); + } - if (!processResults) return; + if (!processResults) return; - const saveResult = call(dataStep.save, processResults); + const saveResult = call(dataStep.save, processResults); - if (!saveResult) return; + if (!saveResult) return; - Object.assign(wikiDataResult, saveResult); + Object.assign(wikiDataResult, saveResult); - return; - } + return; + } + + if (!dataStep.files) { + throw new Error( + `Expected 'files' property for ${documentMode.toString()}` + ); + } - if (!dataStep.files) { - throw new Error(`Expected 'files' property for ${documentMode.toString()}`); + const files = ( + typeof dataStep.files === 'function' + ? await callAsync(dataStep.files, dataPath) + : dataStep.files + ).map((file) => path.join(dataPath, file)); + + const readResults = await mapAsync( + files, + (file) => + readFile(file, 'utf-8').then((contents) => ({file, contents})), + {message: `Errors reading data files`} + ); + + const yamlResults = map( + readResults, + decorateErrorWithFile(({file, contents}) => ({ + file, + documents: yaml.loadAll(contents), + })), + {message: `Errors parsing data files as valid YAML`} + ); + + let processResults; + + if (documentMode === documentModes.headerAndEntries) { + nest( + {message: `Errors processing data files as valid documents`}, + ({call, map}) => { + processResults = []; + + yamlResults.forEach(({file, documents}) => { + const [headerDocument, ...entryDocuments] = documents; + + const header = call( + decorateErrorWithFile(({document}) => + dataStep.processHeaderDocument(document) + ), + {file, document: headerDocument} + ); + + // Don't continue processing files whose header + // document is invalid - the entire file is excempt + // from data in this case. + if (!header) { + return; } - const files = ( - (typeof dataStep.files === 'function' - ? await callAsync(dataStep.files, dataPath) - : dataStep.files) - .map(file => path.join(dataPath, file))); - - const readResults = await mapAsync( - files, - file => (readFile(file, 'utf-8') - .then(contents => ({file, contents}))), - {message: `Errors reading data files`}); - - const yamlResults = map( - readResults, - decorateErrorWithFile( - ({ file, contents }) => ({file, documents: yaml.loadAll(contents)})), - {message: `Errors parsing data files as valid YAML`}); - - let processResults; - - if (documentMode === documentModes.headerAndEntries) { - nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => { - processResults = []; - - yamlResults.forEach(({ file, documents }) => { - const [ headerDocument, ...entryDocuments ] = documents; - - const header = call( - decorateErrorWithFile( - ({ document }) => dataStep.processHeaderDocument(document)), - {file, document: headerDocument}); - - // Don't continue processing files whose header - // document is invalid - the entire file is excempt - // from data in this case. - if (!header) { - return; - } - - const entries = map( - entryDocuments.map(document => ({file, document})), - decorateErrorWithFile( - decorateErrorWithIndex( - ({ document }) => dataStep.processEntryDocument(document))), - {message: `Errors processing entry documents`}); - - // Entries may be incomplete (i.e. any errored - // documents won't have a processed output - // represented here) - this is intentional! By - // principle, partial output is preferred over - // erroring an entire file. - processResults.push({header, entries}); - }); - }); + const entries = map( + entryDocuments.map((document) => ({file, document})), + decorateErrorWithFile( + decorateErrorWithIndex(({document}) => + dataStep.processEntryDocument(document) + ) + ), + {message: `Errors processing entry documents`} + ); + + // Entries may be incomplete (i.e. any errored + // documents won't have a processed output + // represented here) - this is intentional! By + // principle, partial output is preferred over + // erroring an entire file. + processResults.push({header, entries}); + }); + } + ); + } + + if (documentMode === documentModes.onePerFile) { + nest( + {message: `Errors processing data files as valid documents`}, + ({call}) => { + processResults = []; + + yamlResults.forEach(({file, documents}) => { + if (documents.length > 1) { + call( + decorateErrorWithFile(() => { + throw new Error( + `Only expected one document to be present per file` + ); + }) + ); + return; } - if (documentMode === documentModes.onePerFile) { - nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => { - processResults = []; - - yamlResults.forEach(({ file, documents }) => { - if (documents.length > 1) { - call(decorateErrorWithFile(() => { - throw new Error(`Only expected one document to be present per file`); - })); - return; - } - - const result = call( - decorateErrorWithFile( - ({ document }) => dataStep.processDocument(document)), - {file, document: documents[0]}); - - if (!result) { - return; - } - - processResults.push(result); - }); - }); + const result = call( + decorateErrorWithFile(({document}) => + dataStep.processDocument(document) + ), + {file, document: documents[0]} + ); + + if (!result) { + return; } - const saveResult = call(dataStep.save, processResults); + processResults.push(result); + }); + } + ); + } - if (!saveResult) return; + const saveResult = call(dataStep.save, processResults); - Object.assign(wikiDataResult, saveResult); - }); - } + if (!saveResult) return; - return { - aggregate: processDataAggregate, - result: wikiDataResult - }; + Object.assign(wikiDataResult, saveResult); + } + ); + } + + return { + aggregate: processDataAggregate, + result: wikiDataResult, + }; } // Data linking! Basically, provide (portions of) wikiData to the Things which // require it - they'll expose dynamically computed properties as a result (many // of which are required for page HTML generation). export function linkWikiDataArrays(wikiData) { - function assignWikiData(things, ...keys) { - for (let i = 0; i < things.length; i++) { - for (let j = 0; j < keys.length; j++) { - const key = keys[j]; - things[i][key] = wikiData[key]; - } - } + function assignWikiData(things, ...keys) { + for (let i = 0; i < things.length; i++) { + for (let j = 0; j < keys.length; j++) { + const key = keys[j]; + things[i][key] = wikiData[key]; + } } - - const WD = wikiData; - - assignWikiData([WD.wikiInfo], 'groupData'); - - assignWikiData(WD.albumData, 'artistData', 'artTagData', 'groupData', 'trackData'); - WD.albumData.forEach(album => assignWikiData(album.trackGroups, 'trackData')); - - assignWikiData(WD.trackData, 'albumData', 'artistData', 'artTagData', 'flashData', 'trackData'); - assignWikiData(WD.artistData, 'albumData', 'artistData', 'flashData', 'trackData'); - assignWikiData(WD.groupData, 'albumData', 'groupCategoryData'); - assignWikiData(WD.groupCategoryData, 'groupData'); - assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData'); - assignWikiData(WD.flashActData, 'flashData'); - assignWikiData(WD.artTagData, 'albumData', 'trackData'); - assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData'); + } + + const WD = wikiData; + + assignWikiData([WD.wikiInfo], 'groupData'); + + assignWikiData( + WD.albumData, + 'artistData', + 'artTagData', + 'groupData', + 'trackData' + ); + WD.albumData.forEach((album) => + assignWikiData(album.trackGroups, 'trackData') + ); + + assignWikiData( + WD.trackData, + 'albumData', + 'artistData', + 'artTagData', + 'flashData', + 'trackData' + ); + assignWikiData( + WD.artistData, + 'albumData', + 'artistData', + 'flashData', + 'trackData' + ); + assignWikiData(WD.groupData, 'albumData', 'groupCategoryData'); + assignWikiData(WD.groupCategoryData, 'groupData'); + assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData'); + assignWikiData(WD.flashActData, 'flashData'); + assignWikiData(WD.artTagData, 'albumData', 'trackData'); + assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData'); } export function sortWikiDataArrays(wikiData) { - Object.assign(wikiData, { - albumData: sortChronologically(wikiData.albumData.slice()), - trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()), - }); - - // Re-link data arrays, so that every object has the new, sorted versions. - // Note that the sorting step deliberately creates new arrays (mutating - // slices instead of the original arrays) - this is so that the object - // caching system understands that it's working with a new ordering. - // We still need to actually provide those updated arrays over again! - linkWikiDataArrays(wikiData); + Object.assign(wikiData, { + albumData: sortChronologically(wikiData.albumData.slice()), + trackData: sortAlbumsTracksChronologically(wikiData.trackData.slice()), + }); + + // Re-link data arrays, so that every object has the new, sorted versions. + // Note that the sorting step deliberately creates new arrays (mutating + // slices instead of the original arrays) - this is so that the object + // caching system understands that it's working with a new ordering. + // We still need to actually provide those updated arrays over again! + linkWikiDataArrays(wikiData); } // Warn about directories which are reused across more than one of the same type @@ -1128,63 +1211,76 @@ export function sortWikiDataArrays(wikiData) { // two tracks share the directory "megalovania", they'll both be skipped for the // build, for example). export function filterDuplicateDirectories(wikiData) { - const deduplicateSpec = [ - 'albumData', - 'artTagData', - 'flashData', - 'groupData', - 'newsData', - 'trackData', - ]; - - const aggregate = openAggregate({message: `Duplicate directories found`}); - for (const thingDataProp of deduplicateSpec) { - const thingData = wikiData[thingDataProp]; - aggregate.nest({message: `Duplicate directories found in ${color.green('wikiData.' + thingDataProp)}`}, ({ call }) => { - const directoryPlaces = Object.create(null); - const duplicateDirectories = []; - for (const thing of thingData) { - const { directory } = thing; - if (directory in directoryPlaces) { - directoryPlaces[directory].push(thing); - duplicateDirectories.push(directory); - } else { - directoryPlaces[directory] = [thing]; - } - } - if (!duplicateDirectories.length) return; - duplicateDirectories.sort((a, b) => { - const aL = a.toLowerCase(); - const bL = b.toLowerCase(); - return aL < bL ? -1 : aL > bL ? 1 : 0; - }); - for (const directory of duplicateDirectories) { - const places = directoryPlaces[directory]; - call(() => { - throw new Error(`Duplicate directory ${color.green(directory)}:\n` + - places.map(thing => ` - ` + inspect(thing)).join('\n')); - }); - } - const allDuplicatedThings = Object.values(directoryPlaces).filter(arr => arr.length > 1).flat(); - const filteredThings = thingData.filter(thing => !allDuplicatedThings.includes(thing)); - wikiData[thingDataProp] = filteredThings; + const deduplicateSpec = [ + 'albumData', + 'artTagData', + 'flashData', + 'groupData', + 'newsData', + 'trackData', + ]; + + const aggregate = openAggregate({message: `Duplicate directories found`}); + for (const thingDataProp of deduplicateSpec) { + const thingData = wikiData[thingDataProp]; + aggregate.nest( + { + message: `Duplicate directories found in ${color.green( + 'wikiData.' + thingDataProp + )}`, + }, + ({call}) => { + const directoryPlaces = Object.create(null); + const duplicateDirectories = []; + for (const thing of thingData) { + const {directory} = thing; + if (directory in directoryPlaces) { + directoryPlaces[directory].push(thing); + duplicateDirectories.push(directory); + } else { + directoryPlaces[directory] = [thing]; + } + } + if (!duplicateDirectories.length) return; + duplicateDirectories.sort((a, b) => { + const aL = a.toLowerCase(); + const bL = b.toLowerCase(); + return aL < bL ? -1 : aL > bL ? 1 : 0; }); - } - - // TODO: This code closes the aggregate but it generally gets closed again - // by the caller. This works but it might be weird to assume closing an - // aggregate twice is okay, maybe there's a better solution? Expose a new - // function on aggregates for checking if it *would* error? - // (i.e: errors.length > 0) - try { - aggregate.close(); - } catch (error) { - // Duplicate entries were found and filtered out, resulting in altered - // wikiData arrays. These must be re-linked so objects receive the new - // data. - linkWikiDataArrays(wikiData); - } - return aggregate; + for (const directory of duplicateDirectories) { + const places = directoryPlaces[directory]; + call(() => { + throw new Error( + `Duplicate directory ${color.green(directory)}:\n` + + places.map((thing) => ` - ` + inspect(thing)).join('\n') + ); + }); + } + const allDuplicatedThings = Object.values(directoryPlaces) + .filter((arr) => arr.length > 1) + .flat(); + const filteredThings = thingData.filter( + (thing) => !allDuplicatedThings.includes(thing) + ); + wikiData[thingDataProp] = filteredThings; + } + ); + } + + // TODO: This code closes the aggregate but it generally gets closed again + // by the caller. This works but it might be weird to assume closing an + // aggregate twice is okay, maybe there's a better solution? Expose a new + // function on aggregates for checking if it *would* error? + // (i.e: errors.length > 0) + try { + aggregate.close(); + } catch (error) { + // Duplicate entries were found and filtered out, resulting in altered + // wikiData arrays. These must be re-linked so objects receive the new + // data. + linkWikiDataArrays(wikiData); + } + return aggregate; } // Warn about references across data which don't match anything. This involves @@ -1193,102 +1289,168 @@ export function filterDuplicateDirectories(wikiData) { // any errors). At the same time, we remove errored references from the thing's // data array. export function filterReferenceErrors(wikiData) { - const referenceSpec = [ - ['wikiInfo', { - divideTrackListsByGroupsByRef: 'group', - }], - - ['albumData', { - artistContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - trackCoverArtistContribsByRef: '_contrib', - wallpaperArtistContribsByRef: '_contrib', - bannerArtistContribsByRef: '_contrib', - groupsByRef: 'group', - artTagsByRef: 'artTag', - }], - - ['trackData', { - artistContribsByRef: '_contrib', - contributorContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - referencedTracksByRef: 'track', - artTagsByRef: 'artTag', - originalReleaseTrackByRef: 'track', - }], - - ['groupCategoryData', { - groupsByRef: 'group', - }], - - ['homepageLayout.rows', { - sourceGroupsByRef: 'group', - sourceAlbumsByRef: 'album', - }], - - ['flashData', { - contributorContribsByRef: '_contrib', - featuredTracksByRef: 'track', - }], - - ['flashActData', { - flashesByRef: 'flash', - }], - ]; - - function getNestedProp(obj, key) { - const recursive = (o, k) => (k.length === 1 - ? o[k[0]] - : recursive(o[k[0]], k.slice(1))); - const keys = key.split(/(?<=(?<!\\)(?:\\\\)*)\./); - return recursive(obj, keys); - } - - const aggregate = openAggregate({message: `Errors validating between-thing references in data`}); - const boundFind = bindFind(wikiData, {mode: 'error'}); - for (const [ thingDataProp, propSpec ] of referenceSpec) { - const thingData = getNestedProp(wikiData, thingDataProp); - aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({ nest }) => { - const things = Array.isArray(thingData) ? thingData : [thingData]; - for (const thing of things) { - nest({message: `Reference errors in ${inspect(thing)}`}, ({ filter }) => { - for (const [ property, findFnKey ] of Object.entries(propSpec)) { - if (!thing[property]) continue; - if (findFnKey === '_contrib') { - thing[property] = filter(thing[property], - decorateErrorWithIndex(({ who }) => { - const alias = find.artist(who, wikiData.artistAliasData, {mode: 'quiet'}); - if (alias) { - const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'}); - throw new Error(`Reference ${color.red(who)} is to an alias, should be ${color.green(original.name)}`); - } - return boundFind.artist(who); - }), - {message: `Reference errors in contributions ${color.green(property)} (${color.green('find.artist')})`}); - continue; - } - const findFn = boundFind[findFnKey]; - const value = thing[property]; - if (Array.isArray(value)) { - thing[property] = filter(value, decorateErrorWithIndex(findFn), - {message: `Reference errors in property ${color.green(property)} (${color.green('find.' + findFnKey)})`}); - } else { - nest({message: `Reference error in property ${color.green(property)} (${color.green('find.' + findFnKey)})`}, ({ call }) => { - try { - call(findFn, value); - } catch (error) { - thing[property] = null; - throw error; - } - }); - } + const referenceSpec = [ + [ + 'wikiInfo', + { + divideTrackListsByGroupsByRef: 'group', + }, + ], + + [ + 'albumData', + { + artistContribsByRef: '_contrib', + coverArtistContribsByRef: '_contrib', + trackCoverArtistContribsByRef: '_contrib', + wallpaperArtistContribsByRef: '_contrib', + bannerArtistContribsByRef: '_contrib', + groupsByRef: 'group', + artTagsByRef: 'artTag', + }, + ], + + [ + 'trackData', + { + artistContribsByRef: '_contrib', + contributorContribsByRef: '_contrib', + coverArtistContribsByRef: '_contrib', + referencedTracksByRef: 'track', + artTagsByRef: 'artTag', + originalReleaseTrackByRef: 'track', + }, + ], + + [ + 'groupCategoryData', + { + groupsByRef: 'group', + }, + ], + + [ + 'homepageLayout.rows', + { + sourceGroupsByRef: 'group', + sourceAlbumsByRef: 'album', + }, + ], + + [ + 'flashData', + { + contributorContribsByRef: '_contrib', + featuredTracksByRef: 'track', + }, + ], + + [ + 'flashActData', + { + flashesByRef: 'flash', + }, + ], + ]; + + function getNestedProp(obj, key) { + const recursive = (o, k) => + k.length === 1 ? o[k[0]] : recursive(o[k[0]], k.slice(1)); + const keys = key.split(/(?<=(?<!\\)(?:\\\\)*)\./); + return recursive(obj, keys); + } + + const aggregate = openAggregate({ + message: `Errors validating between-thing references in data`, + }); + const boundFind = bindFind(wikiData, {mode: 'error'}); + for (const [thingDataProp, propSpec] of referenceSpec) { + const thingData = getNestedProp(wikiData, thingDataProp); + aggregate.nest( + { + message: `Reference errors in ${color.green( + 'wikiData.' + thingDataProp + )}`, + }, + ({nest}) => { + const things = Array.isArray(thingData) ? thingData : [thingData]; + for (const thing of things) { + nest( + {message: `Reference errors in ${inspect(thing)}`}, + ({filter}) => { + for (const [property, findFnKey] of Object.entries(propSpec)) { + if (!thing[property]) continue; + if (findFnKey === '_contrib') { + thing[property] = filter( + thing[property], + decorateErrorWithIndex(({who}) => { + const alias = find.artist(who, wikiData.artistAliasData, { + mode: 'quiet', + }); + if (alias) { + const original = find.artist( + alias.aliasedArtistRef, + wikiData.artistData, + { + mode: 'quiet', + } + ); + throw new Error( + `Reference ${color.red( + who + )} is to an alias, should be ${color.green( + original.name + )}` + ); + } + return boundFind.artist(who); + }), + { + message: `Reference errors in contributions ${color.green( + property + )} (${color.green('find.artist')})`, + } + ); + continue; + } + const findFn = boundFind[findFnKey]; + const value = thing[property]; + if (Array.isArray(value)) { + thing[property] = filter( + value, + decorateErrorWithIndex(findFn), + { + message: `Reference errors in property ${color.green( + property + )} (${color.green('find.' + findFnKey)})`, + } + ); + } else { + nest( + { + message: `Reference error in property ${color.green( + property + )} (${color.green('find.' + findFnKey)})`, + }, + ({call}) => { + try { + call(findFn, value); + } catch (error) { + thing[property] = null; + throw error; + } } - }); + ); + } + } } - }); - } + ); + } + } + ); + } - return aggregate; + return aggregate; } // Utility function for loading all wiki data from the provided YAML data @@ -1297,48 +1459,49 @@ export function filterReferenceErrors(wikiData) { // a boilerplate for more specialized output, or as a quick start in utilities // where reporting info about data loading isn't as relevant as during the // main wiki build process. -export async function quickLoadAllFromYAML(dataPath, { - showAggregate: customShowAggregate = showAggregate, -} = {}) { - const showAggregate = customShowAggregate; +export async function quickLoadAllFromYAML( + dataPath, + {showAggregate: customShowAggregate = showAggregate} = {} +) { + const showAggregate = customShowAggregate; - let wikiData; + let wikiData; - { - const { aggregate, result } = await loadAndProcessDataDocuments({ - dataPath, - }); + { + const {aggregate, result} = await loadAndProcessDataDocuments({ + dataPath, + }); - wikiData = result; - - try { - aggregate.close(); - logInfo`Loaded data without errors. (complete data)`; - } catch (error) { - showAggregate(error); - logWarn`Loaded data with errors. (partial data)`; - } - } - - linkWikiDataArrays(wikiData); + wikiData = result; try { - filterDuplicateDirectories(wikiData).close(); - logInfo`No duplicate directories found. (complete data)`; + aggregate.close(); + logInfo`Loaded data without errors. (complete data)`; } catch (error) { - showAggregate(error); - logWarn`Duplicate directories found. (partial data)`; + showAggregate(error); + logWarn`Loaded data with errors. (partial data)`; } + } - try { - filterReferenceErrors(wikiData).close(); - logInfo`No reference errors found. (complete data)`; - } catch (error) { - showAggregate(error); - logWarn`Duplicate directories found. (partial data)`; - } + linkWikiDataArrays(wikiData); + + try { + filterDuplicateDirectories(wikiData).close(); + logInfo`No duplicate directories found. (complete data)`; + } catch (error) { + showAggregate(error); + logWarn`Duplicate directories found. (partial data)`; + } + + try { + filterReferenceErrors(wikiData).close(); + logInfo`No reference errors found. (complete data)`; + } catch (error) { + showAggregate(error); + logWarn`Duplicate directories found. (partial data)`; + } - sortWikiDataArrays(wikiData); + sortWikiDataArrays(wikiData); - return wikiData; + return wikiData; } |