From 98c2012c0c6233fe3f70ba215c19f6d39d7e1e34 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 20 Jan 2024 17:23:37 -0400 Subject: data: tidy things folder & imports, nicer fields yaml spec --- src/data/cacheable-object.js | 369 ++++++++++ src/data/composite.js | 1307 +++++++++++++++++++++++++++++++++++ src/data/thing.js | 75 ++ src/data/things/album.js | 158 +++-- src/data/things/art-tag.js | 17 +- src/data/things/artist.js | 23 +- src/data/things/cacheable-object.js | 369 ---------- src/data/things/composite.js | 1307 ----------------------------------- src/data/things/flash.js | 62 +- src/data/things/group.js | 21 +- src/data/things/homepage-layout.js | 39 +- src/data/things/index.js | 4 +- src/data/things/language.js | 11 +- src/data/things/news-entry.js | 28 +- src/data/things/static-page.js | 27 +- src/data/things/thing.js | 83 --- src/data/things/track.js | 148 ++-- src/data/things/validators.js | 984 -------------------------- src/data/things/wiki-info.js | 40 +- src/data/validators.js | 984 ++++++++++++++++++++++++++ src/data/yaml.js | 156 ++--- 21 files changed, 3104 insertions(+), 3108 deletions(-) create mode 100644 src/data/cacheable-object.js create mode 100644 src/data/composite.js create mode 100644 src/data/thing.js delete mode 100644 src/data/things/cacheable-object.js delete mode 100644 src/data/things/composite.js delete mode 100644 src/data/things/thing.js delete mode 100644 src/data/things/validators.js create mode 100644 src/data/validators.js (limited to 'src/data') diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js new file mode 100644 index 00000000..1e7c7aa8 --- /dev/null +++ b/src/data/cacheable-object.js @@ -0,0 +1,369 @@ +// Generally extendable class for caching properties and handling dependencies, +// with a few key properties: +// +// 1) The behavior of every property is defined by its descriptor, which is a +// static value stored on the subclass (all instances share the same property +// descriptors). +// +// 1a) Additional properties may not be added past the time of object +// construction, and attempts to do so (including externally setting a +// property name which has no corresponding descriptor) will throw a +// TypeError. (This is done via an Object.seal(this) call after a newly +// created instance defines its own properties according to the descriptor +// on its constructor class.) +// +// 2) Properties may have two flags set: update and expose. Properties which +// update are provided values from the external. Properties which expose +// provide values to the external, generally dependent on other update +// properties (within the same object). +// +// 2a) Properties may be flagged as both updating and exposing. This is so +// that the same name may be used for both "output" and "input". +// +// 3) Exposed properties have values which are computations dependent on other +// properties, as described by a `compute` function on the descriptor. +// Depended-upon properties are explicitly listed on the descriptor next to +// this function, and are only provided as arguments to the function once +// listed. +// +// 3a) An exposed property may depend only upon updating properties, not other +// exposed properties (within the same object). This is to force the +// general complexity of a single object to be fairly simple: inputs +// directly determine outputs, with the only in-between step being the +// `compute` function, no multiple-layer dependencies. Note that this is +// only true within a given object - externally, values provided to one +// object's `update` may be (and regularly are) the exposed values of +// another object. +// +// 3b) If a property both updates and exposes, it is automatically regarded as +// a dependancy. (That is, its exposed value will depend on the value it is +// updated with.) Rather than a required `compute` function, these have an +// optional `transform` function, which takes the update value as its first +// argument and then the usual key-value dependencies as its second. If no +// `transform` function is provided, the expose value is the same as the +// update value. +// +// 4) Exposed properties are cached; that is, if no depended-upon properties are +// updated, the value of an exposed property is not recomputed. +// +// 4a) The cache for an exposed property is invalidated as soon as any of its +// dependencies are updated, but the cache itself is lazy: the exposed +// value will not be recomputed until it is again accessed. (Likewise, an +// exposed value won't be computed for the first time until it is first +// accessed.) +// +// 5) Updating a property may optionally apply validation checks before passing, +// declared by a `validate` function on the `update` block. This function +// should either throw an error (e.g. TypeError) or return false if the value +// is invalid. +// +// 6) Objects do not expect all updating properties to be provided at once. +// Incomplete objects are deliberately supported and enabled. +// +// 6a) The default value for every updating property is null; undefined is not +// accepted as a property value under any circumstances (it always errors). +// However, this default may be overridden by specifying a `default` value +// on a property's `update` block. (This value will be checked against +// the property's validate function.) Note that a property may always be +// updated to null, even if the default is non-null. (Null always bypasses +// the validate check.) +// +// 6b) It's required by the external consumer of an object to determine whether +// or not the object is ready for use (within the larger program). This is +// convenienced by the static CacheableObject.listAccessibleProperties() +// function, which provides a mapping of exposed property names to whether +// or not their dependencies are yet met. + +import {inspect as nodeInspect} from 'node:util'; + +import {colors, ENABLE_COLOR} from '#cli'; + +function inspect(value) { + return nodeInspect(value, {colors: ENABLE_COLOR}); +} + +export default class CacheableObject { + #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 + // update() function from inside this constructor, it will error when + // writing to private members. Pretty bad! + // + // That means initial data must be provided by following up with update() + // 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; + } + } + } + + #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: flags.expose, + }; + + if (flags.update) { + definition.set = this.#getUpdateObjectDefinitionSetterFunction(property); + } + + if (flags.expose) { + definition.get = this.#getExposeObjectDefinitionGetterFunction(property); + } + + Object.defineProperty(this, property, definition); + } + + 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 (caughtError) { + throw new CacheableObjectPropertyValueError( + property, oldValue, newValue, {cause: caughtError}); + } + } + + this.#propertyUpdateValues[property] = newValue; + this.#invalidateCachesDependentUpon(property); + }; + } + + #getPropertyDescriptor(property) { + return this.constructor.propertyDescriptors[property]; + } + + #invalidateCachesDependentUpon(property) { + const invalidators = this.#propertyUpdateCacheInvalidators[property]; + if (!invalidators) { + return; + } + + for (const invalidate of invalidators) { + 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]; + } + } + + #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`); + } + + let getAllDependencies; + + if (expose.dependencies?.length > 0) { + const dependencyKeys = expose.dependencies.slice(); + const shouldReflect = dependencyKeys.includes('this'); + + getAllDependencies = () => { + const dependencies = Object.create(null); + + for (const key of dependencyKeys) { + dependencies[key] = this.#propertyUpdateValues[key]; + } + + if (shouldReflect) { + dependencies.this = this; + } + + return dependencies; + }; + } else { + const dependencies = Object.create(null); + Object.freeze(dependencies); + getAllDependencies = () => dependencies; + } + + if (flags.update) { + return () => transform(this.#propertyUpdateValues[property], getAllDependencies()); + } else { + return () => compute(getAllDependencies()); + } + } + + #getExposeCheckCacheValidFunction(property) { + const {flags, expose} = this.#getPropertyDescriptor(property); + + let valid = false; + + const invalidate = () => { + valid = false; + }; + + const dependencyKeys = new Set(expose?.dependencies); + + if (flags.update) { + dependencyKeys.add(property); + } + + for (const key of dependencyKeys) { + if (this.#propertyUpdateCacheInvalidators[key]) { + this.#propertyUpdateCacheInvalidators[key].push(invalidate); + } else { + this.#propertyUpdateCacheInvalidators[key] = [invalidate]; + } + } + + return () => { + if (!valid) { + valid = true; + return false; + } else { + return true; + } + }; + } + + static cacheAllExposedProperties(obj) { + if (!(obj instanceof CacheableObject)) { + console.warn('Not a CacheableObject:', obj); + return; + } + + const {propertyDescriptors} = obj.constructor; + + if (!propertyDescriptors) { + console.warn('Missing property descriptors:', obj); + return; + } + + for (const [property, descriptor] of Object.entries(propertyDescriptors)) { + const {flags} = descriptor; + + if (!flags.expose) { + continue; + } + + obj[property]; + } + } + + 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; + } + + console.log(`${this._invalidAccesses.size} unique invalid accesses:`); + for (const line of this._invalidAccesses) { + console.log(` - ${line}`); + } + } + + static getUpdateValue(object, key) { + if (!Object.hasOwn(object, key)) { + return undefined; + } + + return object.#propertyUpdateValues[key] ?? null; + } +} + +export class CacheableObjectPropertyValueError extends Error { + [Symbol.for('hsmusic.aggregate.translucent')] = true; + + constructor(property, oldValue, newValue, options) { + super( + `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`, + options); + + this.property = property; + } +} diff --git a/src/data/composite.js b/src/data/composite.js new file mode 100644 index 00000000..113f0a4f --- /dev/null +++ b/src/data/composite.js @@ -0,0 +1,1307 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {TupleMap} from '#wiki-data'; +import {a} from '#validators'; + +import { + decorateErrorWithIndex, + empty, + filterProperties, + openAggregate, + stitchArrays, + typeAppearance, + unique, + withAggregate, +} from '#sugar'; + +const globalCompositeCache = {}; + +const _valueIntoToken = shape => + (value = null) => + (value === null + ? Symbol.for(`hsmusic.composite.${shape}`) + : typeof value === 'string' + ? Symbol.for(`hsmusic.composite.${shape}:${value}`) + : { + symbol: Symbol.for(`hsmusic.composite.input`), + shape, + value, + }); + +export const input = _valueIntoToken('input'); +input.symbol = Symbol.for('hsmusic.composite.input'); + +input.value = _valueIntoToken('input.value'); +input.dependency = _valueIntoToken('input.dependency'); + +input.myself = () => Symbol.for(`hsmusic.composite.input.myself`); + +input.updateValue = _valueIntoToken('input.updateValue'); + +input.staticDependency = _valueIntoToken('input.staticDependency'); +input.staticValue = _valueIntoToken('input.staticValue'); + +function isInputToken(token) { + if (token === null) { + return false; + } else if (typeof token === 'object') { + return token.symbol === Symbol.for('hsmusic.composite.input'); + } else if (typeof token === 'symbol') { + return token.description.startsWith('hsmusic.composite.input'); + } else { + return false; + } +} + +function getInputTokenShape(token) { + if (!isInputToken(token)) { + throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); + } + + if (typeof token === 'object') { + return token.shape; + } else { + return token.description.match(/hsmusic\.composite\.(input.*?)(:|$)/)[1]; + } +} + +function getInputTokenValue(token) { + if (!isInputToken(token)) { + throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); + } + + if (typeof token === 'object') { + return token.value; + } else { + return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null; + } +} + +function getStaticInputMetadata(inputOptions) { + const metadata = {}; + + for (const [name, token] of Object.entries(inputOptions)) { + if (typeof token === 'string') { + metadata[input.staticDependency(name)] = token; + metadata[input.staticValue(name)] = null; + } else if (isInputToken(token)) { + const tokenShape = getInputTokenShape(token); + const tokenValue = getInputTokenValue(token); + + metadata[input.staticDependency(name)] = + (tokenShape === 'input.dependency' + ? tokenValue + : null); + + metadata[input.staticValue(name)] = + (tokenShape === 'input.value' + ? tokenValue + : null); + } else { + metadata[input.staticDependency(name)] = null; + metadata[input.staticValue(name)] = null; + } + } + + return metadata; +} + +function getCompositionName(description) { + return ( + (description.annotation + ? description.annotation + : `unnamed composite`)); +} + +function validateInputValue(value, description) { + const tokenValue = getInputTokenValue(description); + + const {acceptsNull, defaultValue, type, validate} = tokenValue || {}; + + if (value === null || value === undefined) { + if (acceptsNull || defaultValue === null) { + return true; + } else { + throw new TypeError( + (type + ? `Expected ${a(type)}, got ${typeAppearance(value)}` + : `Expected a value, got ${typeAppearance(value)}`)); + } + } + + if (type) { + // Note: null is already handled earlier in this function, so it won't + // cause any trouble here. + const typeofValue = + (typeof value === 'object' + ? Array.isArray(value) ? 'array' : 'object' + : typeof value); + + if (typeofValue !== type) { + throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); + } + } + + if (validate) { + validate(value); + } + + return true; +} + +export function templateCompositeFrom(description) { + const compositionName = getCompositionName(description); + + withAggregate({message: `Errors in description for ${compositionName}`}, ({map, nest, push}) => { + if ('steps' in description) { + if (Array.isArray(description.steps)) { + push(new TypeError(`Wrap steps array in a function`)); + } else if (typeof description.steps !== 'function') { + push(new TypeError(`Expected steps to be a function (returning an array)`)); + } + } + + validateInputs: + if ('inputs' in description) { + if ( + Array.isArray(description.inputs) || + typeof description.inputs !== 'object' + ) { + push(new Error(`Expected inputs to be object, got ${typeAppearance(description.inputs)}`)); + break validateInputs; + } + + nest({message: `Errors in static input descriptions for ${compositionName}`}, ({push}) => { + const missingCallsToInput = []; + const wrongCallsToInput = []; + + for (const [name, value] of Object.entries(description.inputs)) { + if (!isInputToken(value)) { + missingCallsToInput.push(name); + continue; + } + + if (!['input', 'input.staticDependency', 'input.staticValue'].includes(getInputTokenShape(value))) { + wrongCallsToInput.push(name); + } + } + + for (const name of missingCallsToInput) { + push(new Error(`${name}: Missing call to input()`)); + } + + for (const name of wrongCallsToInput) { + const shape = getInputTokenShape(description.inputs[name]); + push(new Error(`${name}: Expected call to input, input.staticDependency, or input.staticValue, got ${shape}`)); + } + }); + } + + validateOutputs: + if ('outputs' in description) { + if ( + !Array.isArray(description.outputs) && + typeof description.outputs !== 'function' + ) { + push(new Error(`Expected outputs to be array or function, got ${typeAppearance(description.outputs)}`)); + break validateOutputs; + } + + if (Array.isArray(description.outputs)) { + map( + description.outputs, + decorateErrorWithIndex(value => { + if (typeof value !== 'string') { + throw new Error(`${value}: Expected string, got ${typeAppearance(value)}`) + } else if (!value.startsWith('#')) { + throw new Error(`${value}: Expected "#" at start`); + } + }), + {message: `Errors in output descriptions for ${compositionName}`}); + } + } + }); + + const expectedInputNames = + (description.inputs + ? Object.keys(description.inputs) + : []); + + const instantiate = (inputOptions = {}) => { + withAggregate({message: `Errors in input options passed to ${compositionName}`}, ({push}) => { + const providedInputNames = Object.keys(inputOptions); + + const misplacedInputNames = + providedInputNames + .filter(name => !expectedInputNames.includes(name)); + + const missingInputNames = + expectedInputNames + .filter(name => !providedInputNames.includes(name)) + .filter(name => { + const inputDescription = getInputTokenValue(description.inputs[name]); + if (!inputDescription) return true; + if ('defaultValue' in inputDescription) return false; + if ('defaultDependency' in inputDescription) return false; + return true; + }); + + const wrongTypeInputNames = []; + + const expectedStaticValueInputNames = []; + const expectedStaticDependencyInputNames = []; + const expectedValueProvidingTokenInputNames = []; + + const validateFailedErrors = []; + + for (const [name, value] of Object.entries(inputOptions)) { + if (misplacedInputNames.includes(name)) { + continue; + } + + if (typeof value !== 'string' && !isInputToken(value)) { + wrongTypeInputNames.push(name); + continue; + } + + const descriptionShape = getInputTokenShape(description.inputs[name]); + + const tokenShape = (isInputToken(value) ? getInputTokenShape(value) : null); + const tokenValue = (isInputToken(value) ? getInputTokenValue(value) : null); + + switch (descriptionShape) { + case'input.staticValue': + if (tokenShape !== 'input.value') { + expectedStaticValueInputNames.push(name); + continue; + } + break; + + case 'input.staticDependency': + if (typeof value !== 'string' && tokenShape !== 'input.dependency') { + expectedStaticDependencyInputNames.push(name); + continue; + } + break; + + case 'input': + if (typeof value !== 'string' && ![ + 'input', + 'input.value', + 'input.dependency', + 'input.myself', + 'input.updateValue', + ].includes(tokenShape)) { + expectedValueProvidingTokenInputNames.push(name); + continue; + } + break; + } + + if (tokenShape === 'input.value') { + try { + validateInputValue(tokenValue, description.inputs[name]); + } catch (error) { + error.message = `${name}: ${error.message}`; + validateFailedErrors.push(error); + } + } + } + + if (!empty(misplacedInputNames)) { + push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`)); + } + + if (!empty(missingInputNames)) { + push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`)); + } + + const inputAppearance = name => + (isInputToken(inputOptions[name]) + ? `${getInputTokenShape(inputOptions[name])}() call` + : `dependency name`); + + for (const name of expectedStaticDependencyInputNames) { + const appearance = inputAppearance(name); + push(new Error(`${name}: Expected dependency name, got ${appearance}`)); + } + + for (const name of expectedStaticValueInputNames) { + const appearance = inputAppearance(name) + push(new Error(`${name}: Expected input.value() call, got ${appearance}`)); + } + + for (const name of expectedValueProvidingTokenInputNames) { + const appearance = getInputTokenShape(inputOptions[name]); + push(new Error(`${name}: Expected dependency name or value-providing input() call, got ${appearance}`)); + } + + for (const name of wrongTypeInputNames) { + const type = typeAppearance(inputOptions[name]); + push(new Error(`${name}: Expected dependency name or input() call, got ${type}`)); + } + + for (const error of validateFailedErrors) { + push(error); + } + }); + + const inputMetadata = getStaticInputMetadata(inputOptions); + + const expectedOutputNames = + (Array.isArray(description.outputs) + ? description.outputs + : typeof description.outputs === 'function' + ? description.outputs(inputMetadata) + .map(name => + (name.startsWith('#') + ? name + : '#' + name)) + : []); + + const ownUpdateDescription = + (typeof description.update === 'object' + ? description.update + : typeof description.update === 'function' + ? description.update(inputMetadata) + : null); + + const outputOptions = {}; + + const instantiatedTemplate = { + symbol: templateCompositeFrom.symbol, + + outputs(providedOptions) { + withAggregate({message: `Errors in output options passed to ${compositionName}`}, ({push}) => { + const misplacedOutputNames = []; + const wrongTypeOutputNames = []; + + for (const [name, value] of Object.entries(providedOptions)) { + if (!expectedOutputNames.includes(name)) { + misplacedOutputNames.push(name); + continue; + } + + if (typeof value !== 'string') { + wrongTypeOutputNames.push(name); + continue; + } + } + + if (!empty(misplacedOutputNames)) { + push(new Error(`Unexpected output names: ${misplacedOutputNames.join(', ')}`)); + } + + for (const name of wrongTypeOutputNames) { + const appearance = typeAppearance(providedOptions[name]); + push(new Error(`${name}: Expected string, got ${appearance}`)); + } + }); + + Object.assign(outputOptions, providedOptions); + return instantiatedTemplate; + }, + + toDescription() { + const finalDescription = {}; + + if ('annotation' in description) { + finalDescription.annotation = description.annotation; + } + + if ('compose' in description) { + finalDescription.compose = description.compose; + } + + if (ownUpdateDescription) { + finalDescription.update = ownUpdateDescription; + } + + if ('inputs' in description) { + const inputMapping = {}; + + for (const [name, token] of Object.entries(description.inputs)) { + const tokenValue = getInputTokenValue(token); + if (name in inputOptions) { + if (typeof inputOptions[name] === 'string') { + inputMapping[name] = input.dependency(inputOptions[name]); + } else { + inputMapping[name] = inputOptions[name]; + } + } else if (tokenValue.defaultValue) { + inputMapping[name] = input.value(tokenValue.defaultValue); + } else if (tokenValue.defaultDependency) { + inputMapping[name] = input.dependency(tokenValue.defaultDependency); + } else { + inputMapping[name] = input.value(null); + } + } + + finalDescription.inputMapping = inputMapping; + finalDescription.inputDescriptions = description.inputs; + } + + if ('outputs' in description) { + const finalOutputs = {}; + + for (const name of expectedOutputNames) { + if (name in outputOptions) { + finalOutputs[name] = outputOptions[name]; + } else { + finalOutputs[name] = name; + } + } + + finalDescription.outputs = finalOutputs; + } + + if ('steps' in description) { + finalDescription.steps = description.steps; + } + + return finalDescription; + }, + + toResolvedComposition() { + const ownDescription = instantiatedTemplate.toDescription(); + + const finalDescription = {...ownDescription}; + + const aggregate = openAggregate({message: `Errors resolving ${compositionName}`}); + + const steps = ownDescription.steps(); + + const resolvedSteps = + aggregate.map( + steps, + decorateErrorWithIndex(step => + (step.symbol === templateCompositeFrom.symbol + ? compositeFrom(step.toResolvedComposition()) + : step)), + {message: `Errors resolving steps`}); + + aggregate.close(); + + finalDescription.steps = resolvedSteps; + + return finalDescription; + }, + }; + + return instantiatedTemplate; + }; + + instantiate.inputs = instantiate; + + return instantiate; +} + +templateCompositeFrom.symbol = Symbol(); + +export const continuationSymbol = Symbol.for('compositeFrom: continuation symbol'); +export const noTransformSymbol = Symbol.for('compositeFrom: no-transform symbol'); + +export function compositeFrom(description) { + const {annotation} = description; + const compositionName = getCompositionName(description); + + const debug = fn => { + if (compositeFrom.debug === true) { + const label = + (annotation + ? colors.dim(`[composite: ${annotation}]`) + : colors.dim(`[composite]`)); + const result = fn(); + if (Array.isArray(result)) { + console.log(label, ...result.map(value => + (typeof value === 'object' + ? inspect(value, {depth: 1, colors: true, compact: true, breakLength: Infinity}) + : value))); + } else { + console.log(label, result); + } + } + }; + + if (!Array.isArray(description.steps)) { + throw new TypeError( + `Expected steps to be array, got ${typeAppearance(description.steps)}` + + (annotation ? ` (${annotation})` : '')); + } + + const composition = + description.steps.map(step => + ('toResolvedComposition' in step + ? compositeFrom(step.toResolvedComposition()) + : step)); + + const inputMetadata = getStaticInputMetadata(description.inputMapping ?? {}); + + function _mapDependenciesToOutputs(providedDependencies) { + if (!description.outputs) { + return {}; + } + + if (!providedDependencies) { + return {}; + } + + return ( + Object.fromEntries( + Object.entries(description.outputs) + .map(([continuationName, outputName]) => [ + outputName, + (continuationName in providedDependencies + ? providedDependencies[continuationName] + : providedDependencies[continuationName.replace(/^#/, '')]), + ]))); + } + + // These dependencies were all provided by the composition which this one is + // nested inside, so input('name')-shaped tokens are going to be evaluated + // in the context of the containing composition. + const dependenciesFromInputs = + Object.values(description.inputMapping ?? {}) + .map(token => { + const tokenShape = getInputTokenShape(token); + const tokenValue = getInputTokenValue(token); + switch (tokenShape) { + case 'input.dependency': + return tokenValue; + case 'input': + case 'input.updateValue': + return token; + case 'input.myself': + return 'this'; + default: + return null; + } + }) + .filter(Boolean); + + const anyInputsUseUpdateValue = + dependenciesFromInputs + .filter(dependency => isInputToken(dependency)) + .some(token => getInputTokenShape(token) === 'input.updateValue'); + + const inputNames = + Object.keys(description.inputMapping ?? {}); + + const inputSymbols = + inputNames.map(name => input(name)); + + const inputsMayBeDynamicValue = + stitchArrays({ + mappingToken: Object.values(description.inputMapping ?? {}), + descriptionToken: Object.values(description.inputDescriptions ?? {}), + }).map(({mappingToken, descriptionToken}) => { + if (getInputTokenShape(descriptionToken) === 'input.staticValue') return false; + if (getInputTokenShape(mappingToken) === 'input.value') return false; + return true; + }); + + const inputDescriptions = + Object.values(description.inputDescriptions ?? {}); + + /* + const inputsAcceptNull = + Object.values(description.inputDescriptions ?? {}) + .map(token => { + const tokenValue = getInputTokenValue(token); + if (!tokenValue) return false; + if ('acceptsNull' in tokenValue) return tokenValue.acceptsNull; + if ('defaultValue' in tokenValue) return tokenValue.defaultValue === null; + return false; + }); + */ + + // Update descriptions passed as the value in an input.updateValue() token, + // as provided as inputs for this composition. + const inputUpdateDescriptions = + Object.values(description.inputMapping ?? {}) + .map(token => + (getInputTokenShape(token) === 'input.updateValue' + ? getInputTokenValue(token) + : null)) + .filter(Boolean); + + const base = composition.at(-1); + const steps = composition.slice(); + + const aggregate = openAggregate({ + message: + `Errors preparing composition` + + (annotation ? ` (${annotation})` : ''), + }); + + const compositionNests = description.compose ?? true; + + if (compositionNests && empty(steps)) { + aggregate.push(new TypeError(`Expected at least one step`)); + } + + // Steps default to exposing if using a shorthand syntax where flags aren't + // specified at all. + const stepsExpose = + steps + .map(step => + (step.flags + ? step.flags.expose ?? false + : true)); + + // Steps default to composing if using a shorthand syntax where flags aren't + // specified at all - *and* aren't the base (final step), unless the whole + // composition is nestable. + const stepsCompose = + steps + .map((step, index, {length}) => + (step.flags + ? step.flags.compose ?? false + : (index === length - 1 + ? compositionNests + : true))); + + // Steps update if the corresponding flag is explicitly set, if a transform + // function is provided, or if the dependencies include an input.updateValue + // token. + const stepsUpdate = + steps + .map(step => + (step.flags + ? step.flags.update ?? false + : !!step.transform || + !!step.dependencies?.some(dependency => + isInputToken(dependency) && + getInputTokenShape(dependency) === 'input.updateValue'))); + + // The expose description for a step is just the entire step object, when + // using the shorthand syntax where {flags: {expose: true}} is left implied. + const stepExposeDescriptions = + steps + .map((step, index) => + (stepsExpose[index] + ? (step.flags + ? step.expose ?? null + : step) + : null)); + + // The update description for a step, if present at all, is always set + // explicitly. There may be multiple per step - namely that step's own + // {update} description, and any descriptions passed as the value in an + // input.updateValue({...}) token. + const stepUpdateDescriptions = + steps + .map((step, index) => + (stepsUpdate[index] + ? [ + step.update ?? null, + ...(stepExposeDescriptions[index]?.dependencies ?? []) + .filter(dependency => isInputToken(dependency)) + .filter(token => getInputTokenShape(token) === 'input.updateValue') + .map(token => getInputTokenValue(token)), + ].filter(Boolean) + : [])); + + // Indicates presence of a {compute} function on the expose description. + const stepsCompute = + stepExposeDescriptions + .map(expose => !!expose?.compute); + + // Indicates presence of a {transform} function on the expose description. + const stepsTransform = + stepExposeDescriptions + .map(expose => !!expose?.transform); + + const dependenciesFromSteps = + unique( + stepExposeDescriptions + .flatMap(expose => expose?.dependencies ?? []) + .map(dependency => { + if (typeof dependency === 'string') + return (dependency.startsWith('#') ? null : dependency); + + const tokenShape = getInputTokenShape(dependency); + const tokenValue = getInputTokenValue(dependency); + switch (tokenShape) { + case 'input.dependency': + return (tokenValue.startsWith('#') ? null : tokenValue); + case 'input.myself': + return 'this'; + default: + return null; + } + }) + .filter(Boolean)); + + const anyStepsUseUpdateValue = + stepExposeDescriptions + .some(expose => + (expose?.dependencies + ? expose.dependencies.includes(input.updateValue()) + : false)); + + const anyStepsExpose = + stepsExpose.includes(true); + + const anyStepsUpdate = + stepsUpdate.includes(true); + + const anyStepsCompute = + stepsCompute.includes(true); + + const anyStepsTransform = + stepsTransform.includes(true); + + const compositionExposes = + anyStepsExpose; + + const compositionUpdates = + 'update' in description || + anyInputsUseUpdateValue || + anyStepsUseUpdateValue || + anyStepsUpdate; + + const stepEntries = stitchArrays({ + step: steps, + stepComposes: stepsCompose, + stepComputes: stepsCompute, + stepTransforms: stepsTransform, + }); + + for (let i = 0; i < stepEntries.length; i++) { + const { + step, + stepComposes, + stepComputes, + stepTransforms, + } = stepEntries[i]; + + const isBase = i === stepEntries.length - 1; + const message = + `Errors in step #${i + 1}` + + (isBase ? ` (base)` : ``) + + (step.annotation ? ` (${step.annotation})` : ``); + + aggregate.nest({message}, ({push}) => { + if (isBase && stepComposes !== compositionNests) { + return push(new TypeError( + (compositionNests + ? `Base must compose, this composition is nestable` + : `Base must not compose, this composition isn't nestable`))); + } else if (!isBase && !stepComposes) { + return push(new TypeError( + (compositionNests + ? `All steps must compose` + : `All steps (except base) must compose`))); + } + + if ( + !compositionNests && !compositionUpdates && + stepTransforms && !stepComputes + ) { + return push(new TypeError( + `Steps which only transform can't be used in a composition that doesn't update`)); + } + }); + } + + if (!compositionNests && !compositionUpdates && !anyStepsCompute) { + aggregate.push(new TypeError(`Expected at least one step to compute`)); + } + + aggregate.close(); + + function _prepareContinuation(callingTransformForThisStep) { + const continuationStorage = { + returnedWith: null, + providedDependencies: undefined, + providedValue: undefined, + }; + + const continuation = + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.exit = (providedValue) => { + continuationStorage.returnedWith = 'exit'; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + }; + + if (compositionNests) { + const makeRaiseLike = returnWith => + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.raiseOutput = makeRaiseLike('raiseOutput'); + continuation.raiseOutputAbove = makeRaiseLike('raiseOutputAbove'); + } + + return {continuation, continuationStorage}; + } + + function _computeOrTransform(initialValue, continuationIfApplicable, initialDependencies) { + const expectingTransform = initialValue !== noTransformSymbol; + + let valueSoFar = + (expectingTransform + ? initialValue + : undefined); + + const availableDependencies = {...initialDependencies}; + + const inputValues = + Object.values(description.inputMapping ?? {}) + .map(token => { + const tokenShape = getInputTokenShape(token); + const tokenValue = getInputTokenValue(token); + switch (tokenShape) { + case 'input.dependency': + return initialDependencies[tokenValue]; + case 'input.value': + return tokenValue; + case 'input.updateValue': + if (!expectingTransform) + throw new Error(`Unexpected input.updateValue() accessed on non-transform call`); + return valueSoFar; + case 'input.myself': + return initialDependencies['this']; + case 'input': + return initialDependencies[token]; + default: + throw new TypeError(`Unexpected input shape ${tokenShape}`); + } + }); + + withAggregate({message: `Errors in input values provided to ${compositionName}`}, ({push}) => { + for (const {dynamic, name, value, description} of stitchArrays({ + dynamic: inputsMayBeDynamicValue, + name: inputNames, + value: inputValues, + description: inputDescriptions, + })) { + if (!dynamic) continue; + try { + validateInputValue(value, description); + } catch (error) { + error.message = `${name}: ${error.message}`; + push(error); + } + } + }); + + if (expectingTransform) { + debug(() => [colors.bright(`begin composition - transforming from:`), initialValue]); + } else { + debug(() => colors.bright(`begin composition - not transforming`)); + } + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; + + debug(() => [ + `step #${i+1}` + + (isBase + ? ` (base):` + : ` of ${steps.length}:`), + step]); + + const expose = + (step.flags + ? step.expose + : step); + + if (!expose) { + if (!isBase) { + debug(() => `step #${i+1} - no expose description, nothing to do for this step`); + continue; + } + + if (expectingTransform) { + debug(() => `step #${i+1} (base) - no expose description, returning so-far update value:`, valueSoFar); + if (continuationIfApplicable) { + debug(() => colors.bright(`end composition - raise (inferred - composing)`)); + return continuationIfApplicable(valueSoFar); + } else { + debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); + return valueSoFar; + } + } else { + debug(() => `step #${i+1} (base) - no expose description, nothing to continue with`); + if (continuationIfApplicable) { + debug(() => colors.bright(`end composition - raise (inferred - composing)`)); + return continuationIfApplicable(); + } else { + debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); + return null; + } + } + } + + const callingTransformForThisStep = + expectingTransform && expose.transform; + + let continuationStorage; + + const inputDictionary = + Object.fromEntries( + stitchArrays({symbol: inputSymbols, value: inputValues}) + .map(({symbol, value}) => [symbol, value])); + + const filterableDependencies = { + ...availableDependencies, + ...inputMetadata, + ...inputDictionary, + ... + (expectingTransform + ? {[input.updateValue()]: valueSoFar} + : {}), + [input.myself()]: initialDependencies?.['this'] ?? null, + }; + + const selectDependencies = + (expose.dependencies ?? []).map(dependency => { + if (!isInputToken(dependency)) return dependency; + const tokenShape = getInputTokenShape(dependency); + const tokenValue = getInputTokenValue(dependency); + switch (tokenShape) { + case 'input': + case 'input.staticDependency': + case 'input.staticValue': + return dependency; + case 'input.myself': + return input.myself(); + case 'input.dependency': + return tokenValue; + case 'input.updateValue': + return input.updateValue(); + default: + throw new Error(`Unexpected token ${tokenShape} as dependency`); + } + }) + + const filteredDependencies = + filterProperties(filterableDependencies, selectDependencies); + + debug(() => [ + `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, + `with dependencies:`, filteredDependencies, + `selecting:`, selectDependencies, + `from available:`, filterableDependencies, + ...callingTransformForThisStep ? [`from value:`, valueSoFar] : []]); + + let result; + + const getExpectedEvaluation = () => + (callingTransformForThisStep + ? (filteredDependencies + ? ['transform', valueSoFar, continuationSymbol, filteredDependencies] + : ['transform', valueSoFar, continuationSymbol]) + : (filteredDependencies + ? ['compute', continuationSymbol, filteredDependencies] + : ['compute', continuationSymbol])); + + const naturalEvaluate = () => { + const [name, ...argsLayout] = getExpectedEvaluation(); + + let args; + + if (isBase && !compositionNests) { + args = + argsLayout.filter(arg => arg !== continuationSymbol); + } else { + let continuation; + + ({continuation, continuationStorage} = + _prepareContinuation(callingTransformForThisStep)); + + args = + argsLayout.map(arg => + (arg === continuationSymbol + ? continuation + : arg)); + } + + return expose[name](...args); + } + + switch (step.cache) { + // Warning! Highly WIP! + case 'aggressive': { + const hrnow = () => { + const hrTime = process.hrtime(); + return hrTime[0] * 1000000000 + hrTime[1]; + }; + + const [name, ...args] = getExpectedEvaluation(); + + let cache = globalCompositeCache[step.annotation]; + if (!cache) { + cache = globalCompositeCache[step.annotation] = { + transform: new TupleMap(), + compute: new TupleMap(), + times: { + read: [], + evaluate: [], + }, + }; + } + + const tuplefied = args + .flatMap(arg => [ + Symbol.for('compositeFrom: tuplefied arg divider'), + ...(typeof arg !== 'object' || Array.isArray(arg) + ? [arg] + : Object.entries(arg).flat()), + ]); + + const readTime = hrnow(); + const cacheContents = cache[name].get(tuplefied); + cache.times.read.push(hrnow() - readTime); + + if (cacheContents) { + ({result, continuationStorage} = cacheContents); + } else { + const evaluateTime = hrnow(); + result = naturalEvaluate(); + cache.times.evaluate.push(hrnow() - evaluateTime); + cache[name].set(tuplefied, {result, continuationStorage}); + } + + break; + } + + default: { + result = naturalEvaluate(); + break; + } + } + + if (result !== continuationSymbol) { + debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); + + if (compositionNests) { + throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); + } + + debug(() => colors.bright(`end composition - exit (inferred)`)); + + return result; + } + + const {returnedWith} = continuationStorage; + + if (returnedWith === 'exit') { + const {providedValue} = continuationStorage; + + debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); + debug(() => colors.bright(`end composition - exit (explicit)`)); + + if (compositionNests) { + return continuationIfApplicable.exit(providedValue); + } else { + return providedValue; + } + } + + const {providedValue, providedDependencies} = continuationStorage; + + const continuationArgs = []; + if (expectingTransform) { + continuationArgs.push( + (callingTransformForThisStep + ? providedValue ?? null + : valueSoFar ?? null)); + } + + debug(() => { + const base = `step #${i+1} - result: ` + returnedWith; + const parts = []; + + if (callingTransformForThisStep) { + parts.push('value:', providedValue); + } + + if (providedDependencies !== null) { + parts.push(`deps:`, providedDependencies); + } else { + parts.push(`(no deps)`); + } + + if (empty(parts)) { + return base; + } else { + return [base + ' ->', ...parts]; + } + }); + + switch (returnedWith) { + case 'raiseOutput': + debug(() => + (isBase + ? colors.bright(`end composition - raiseOutput (base: explicit)`) + : colors.bright(`end composition - raiseOutput`))); + continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); + return continuationIfApplicable(...continuationArgs); + + case 'raiseOutputAbove': + debug(() => colors.bright(`end composition - raiseOutputAbove`)); + continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); + return continuationIfApplicable.raiseOutput(...continuationArgs); + + case 'continuation': + if (isBase) { + debug(() => colors.bright(`end composition - raiseOutput (inferred)`)); + continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); + return continuationIfApplicable(...continuationArgs); + } else { + Object.assign(availableDependencies, providedDependencies); + if (callingTransformForThisStep && providedValue !== null) { + valueSoFar = providedValue; + } + break; + } + } + } + } + + const constructedDescriptor = {}; + + if (annotation) { + constructedDescriptor.annotation = annotation; + } + + constructedDescriptor.flags = { + update: compositionUpdates, + expose: compositionExposes, + compose: compositionNests, + }; + + if (compositionUpdates) { + // TODO: This is a dumb assign statement, and it could probably do more + // interesting things, like combining validation functions. + constructedDescriptor.update = + Object.assign( + {...description.update ?? {}}, + ...inputUpdateDescriptions, + ...stepUpdateDescriptions.flat()); + } + + if (compositionExposes) { + const expose = constructedDescriptor.expose = {}; + + expose.dependencies = + unique([ + ...dependenciesFromInputs, + ...dependenciesFromSteps, + ]); + + const _wrapper = (...args) => { + try { + return _computeOrTransform(...args); + } catch (thrownError) { + const error = new Error( + `Error computing composition` + + (annotation ? ` ${annotation}` : '')); + error.cause = thrownError; + throw error; + } + }; + + if (compositionNests) { + if (compositionUpdates) { + expose.transform = (value, continuation, dependencies) => + _wrapper(value, continuation, dependencies); + } + + if (anyStepsCompute && !anyStepsUseUpdateValue && !anyInputsUseUpdateValue) { + expose.compute = (continuation, dependencies) => + _wrapper(noTransformSymbol, continuation, dependencies); + } + + if (base.cacheComposition) { + expose.cache = base.cacheComposition; + } + } else if (compositionUpdates) { + if (!empty(steps)) { + expose.transform = (value, dependencies) => + _wrapper(value, null, dependencies); + } + } else { + expose.compute = (dependencies) => + _wrapper(noTransformSymbol, null, dependencies); + } + } + + return constructedDescriptor; +} + +export function displayCompositeCacheAnalysis() { + const showTimes = (cache, key) => { + const times = cache.times[key].slice().sort(); + + const all = times; + const worst10pc = times.slice(-times.length / 10); + const best10pc = times.slice(0, times.length / 10); + const middle50pc = times.slice(times.length / 4, -times.length / 4); + const middle80pc = times.slice(times.length / 10, -times.length / 10); + + const fmt = val => `${(val / 1000).toFixed(2)}ms`.padStart(9); + const avg = times => times.reduce((a, b) => a + b, 0) / times.length; + + const left = ` - ${key}: `; + const indn = ' '.repeat(left.length); + console.log(left + `${fmt(avg(all))} (all ${all.length})`); + console.log(indn + `${fmt(avg(worst10pc))} (worst 10%)`); + console.log(indn + `${fmt(avg(best10pc))} (best 10%)`); + console.log(indn + `${fmt(avg(middle80pc))} (middle 80%)`); + console.log(indn + `${fmt(avg(middle50pc))} (middle 50%)`); + }; + + for (const [annotation, cache] of Object.entries(globalCompositeCache)) { + console.log(`Cached ${annotation}:`); + showTimes(cache, 'evaluate'); + showTimes(cache, 'read'); + } +} + +// Evaluates a function with composite debugging enabled, turns debugging +// off again, and returns the result of the function. This is mostly syntax +// sugar, but also helps avoid unit tests avoid accidentally printing debug +// info for a bunch of unrelated composites (due to property enumeration +// when displaying an unexpected result). Use as so: +// +// Without debugging: +// t.same(thing.someProp, value) +// +// With debugging: +// t.same(debugComposite(() => thing.someProp), value) +// +export function debugComposite(fn) { + compositeFrom.debug = true; + const value = fn(); + compositeFrom.debug = false; + return value; +} diff --git a/src/data/thing.js b/src/data/thing.js new file mode 100644 index 00000000..ae8e71af --- /dev/null +++ b/src/data/thing.js @@ -0,0 +1,75 @@ +// Thing: base class for wiki data types, providing interfaces generally useful +// to all wiki data objects on top of foundational CacheableObject behavior. + +import {inspect} from 'node:util'; + +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; + +export default class Thing extends CacheableObject { + static referenceType = Symbol.for('Thing.referenceType'); + static friendlyName = Symbol.for('Thing.friendlyName'); + + static getPropertyDescriptors = Symbol.for('Thing.getPropertyDescriptors'); + static getSerializeDescriptors = Symbol.for('Thing.getSerializeDescriptors'); + + static yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec'); + + // Default custom inspect function, which may be overridden by Thing + // subclasses. This will be used when displaying aggregate errors and other + // command-line logging - it's the place to provide information useful in + // identifying the Thing being presented. + [inspect.custom]() { + const cname = this.constructor.name; + + return ( + (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) + + (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '') + ); + } + + static getReference(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}`; + } + + static extendDocumentSpec(thingClass, subspec) { + const superspec = thingClass[Thing.yamlDocumentSpec]; + + const { + fields, + ignoredFields, + invalidFieldCombinations, + ...restOfSubspec + } = subspec; + + const newFields = Object.keys(fields ?? {}); + + return { + ...superspec, + ...restOfSubspec, + + fields: { + ...superspec.fields ?? {}, + ...fields, + }, + + ignoredFields: + (superspec.ignoredFields ?? []) + .filter(field => newFields.includes(field)) + .concat(ignoredFields ?? []), + + invalidFieldCombinations: [ + ...superspec.invalidFieldCombinations ?? [], + ...invalidFieldCombinations ?? [], + ], + }; + } +} diff --git a/src/data/things/album.js b/src/data/things/album.js index 02d34544..3a05ac83 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,6 +1,9 @@ import {input} from '#composite'; import find from '#find'; +import Thing from '#thing'; import {isDate} from '#validators'; +import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions} + from '#yaml'; import {exposeDependency, exposeUpdateValueOrContinue} from '#composite/control-flow'; @@ -27,14 +30,6 @@ import { import {withTracks, withTrackSections} from '#composite/things/album'; -import { - parseAdditionalFiles, - parseContributors, - parseDimensions, -} from '#yaml'; - -import Thing from './thing.js'; - export class Album extends Thing { static [Thing.referenceType] = 'album'; @@ -205,58 +200,85 @@ export class Album extends Thing { }); static [Thing.yamlDocumentSpec] = { - 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', - directory: 'Directory', - date: 'Date', - color: 'Color', - urls: 'URLs', - - hasTrackNumbers: 'Has Track Numbers', - isListedOnHomepage: 'Listed on Homepage', - isListedInGalleries: 'Listed in Galleries', - - coverArtDate: 'Cover Art Date', - trackArtDate: 'Default Track Cover Art Date', - dateAddedToWiki: 'Date Added', - - coverArtFileExtension: 'Cover Art File Extension', - trackCoverArtFileExtension: 'Track Art File Extension', - - wallpaperArtistContribs: 'Wallpaper Artists', - wallpaperStyle: 'Wallpaper Style', - wallpaperFileExtension: 'Wallpaper File Extension', - - bannerArtistContribs: 'Banner Artists', - bannerStyle: 'Banner Style', - bannerFileExtension: 'Banner File Extension', - bannerDimensions: 'Banner Dimensions', - - commentary: 'Commentary', - additionalFiles: 'Additional Files', - - artistContribs: 'Artists', - coverArtistContribs: 'Cover Artists', - trackCoverArtistContribs: 'Default Track Cover Artists', - groups: 'Groups', - artTags: 'Art Tags', + fields: { + 'Album': {property: 'name'}, + 'Directory': {property: 'directory'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, + + 'Color': {property: 'color'}, + 'URLs': {property: 'urls'}, + + 'Has Track Numbers': {property: 'hasTrackNumbers'}, + 'Listed on Homepage': {property: 'isListedOnHomepage'}, + 'Listed in Galleries': {property: 'isListedInGalleries'}, + + 'Cover Art Date': { + property: 'coverArtDate', + transform: parseDate, + }, + + 'Default Track Cover Art Date': { + property: 'trackArtDate', + transform: parseDate, + }, + + 'Date Added': { + property: 'dateAddedToWiki', + transform: parseDate, + }, + + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, + + 'Wallpaper Artists': { + property: 'wallpaperArtistContribs', + transform: parseContributors, + }, + + 'Wallpaper Style': {property: 'wallpaperStyle'}, + 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + + 'Banner Artists': { + property: 'bannerArtistContribs', + transform: parseContributors, + }, + + 'Banner Style': {property: 'bannerStyle'}, + 'Banner File Extension': {property: 'bannerFileExtension'}, + + 'Banner Dimensions': { + property: 'bannerDimensions', + transform: parseDimensions, + }, + + 'Commentary': {property: 'commentary'}, + + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, + }, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, + + 'Default Track Cover Artists': { + property: 'trackCoverArtistContribs', + transform: parseContributors, + }, + + 'Groups': {property: 'groups'}, + 'Art Tags': {property: 'artTags'}, }, ignoredFields: ['Review Points'], @@ -274,14 +296,14 @@ export class TrackSectionHelper extends Thing { }) static [Thing.yamlDocumentSpec] = { - fieldTransformations: { - 'Date Originally Released': (value) => new Date(value), - }, - - propertyFieldMapping: { - name: 'Section', - color: 'Color', - dateOriginallyReleased: 'Date Originally Released', + fields: { + 'Section': {property: 'name'}, + 'Color': {property: 'color'}, + + 'Date Originally Released': { + property: 'dateOriginallyReleased', + transform: parseDate, + }, }, }; } diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index c0b4a6d6..af6677f0 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,6 +1,7 @@ import {input} from '#composite'; -import {sortAlbumsTracksChronologically} from '#wiki-data'; +import Thing from '#thing'; import {isName} from '#validators'; +import {sortAlbumsTracksChronologically} from '#wiki-data'; import {exposeUpdateValueOrContinue} from '#composite/control-flow'; @@ -12,8 +13,6 @@ import { wikiData, } from '#composite/wiki-properties'; -import Thing from './thing.js'; - export class ArtTag extends Thing { static [Thing.referenceType] = 'tag'; static [Thing.friendlyName] = `Art Tag`; @@ -65,13 +64,13 @@ export class ArtTag extends Thing { }); static [Thing.yamlDocumentSpec] = { - propertyFieldMapping: { - name: 'Tag', - nameShort: 'Short Name', - directory: 'Directory', + fields: { + 'Tag': {property: 'name'}, + 'Short Name': {property: 'nameShort'}, + 'Directory': {property: 'directory'}, - color: 'Color', - isContentWarning: 'Is CW', + 'Color': {property: 'color'}, + 'Is CW': {property: 'isContentWarning'}, }, }; } diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 42090557..502510a8 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -1,8 +1,11 @@ import {input} from '#composite'; import find from '#find'; import {unique} from '#sugar'; +import Thing from '#thing'; import {isName, validateArrayItems} from '#validators'; +import {withReverseContributionList} from '#composite/wiki-data'; + import { contentString, directory, @@ -16,10 +19,6 @@ import { wikiData, } from '#composite/wiki-properties'; -import {withReverseContributionList} from '#composite/wiki-data'; - -import Thing from './thing.js'; - export class Artist extends Thing { static [Thing.referenceType] = 'artist'; @@ -242,16 +241,16 @@ export class Artist extends Thing { }); static [Thing.yamlDocumentSpec] = { - propertyFieldMapping: { - name: 'Artist', - directory: 'Directory', - urls: 'URLs', - contextNotes: 'Context Notes', + fields: { + 'Artist': {property: 'name'}, + 'Directory': {property: 'directory'}, + 'URLs': {property: 'urls'}, + 'Context Notes': {property: 'contextNotes'}, - hasAvatar: 'Has Avatar', - avatarFileExtension: 'Avatar File Extension', + 'Has Avatar': {property: 'hasAvatar'}, + 'Avatar File Extension': {property: 'avatarFileExtension'}, - aliasNames: 'Aliases', + 'Aliases': {property: 'aliasNames'}, }, ignoredFields: ['Dead URLs', 'Review Points'], diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js deleted file mode 100644 index 1e7c7aa8..00000000 --- a/src/data/things/cacheable-object.js +++ /dev/null @@ -1,369 +0,0 @@ -// Generally extendable class for caching properties and handling dependencies, -// with a few key properties: -// -// 1) The behavior of every property is defined by its descriptor, which is a -// static value stored on the subclass (all instances share the same property -// descriptors). -// -// 1a) Additional properties may not be added past the time of object -// construction, and attempts to do so (including externally setting a -// property name which has no corresponding descriptor) will throw a -// TypeError. (This is done via an Object.seal(this) call after a newly -// created instance defines its own properties according to the descriptor -// on its constructor class.) -// -// 2) Properties may have two flags set: update and expose. Properties which -// update are provided values from the external. Properties which expose -// provide values to the external, generally dependent on other update -// properties (within the same object). -// -// 2a) Properties may be flagged as both updating and exposing. This is so -// that the same name may be used for both "output" and "input". -// -// 3) Exposed properties have values which are computations dependent on other -// properties, as described by a `compute` function on the descriptor. -// Depended-upon properties are explicitly listed on the descriptor next to -// this function, and are only provided as arguments to the function once -// listed. -// -// 3a) An exposed property may depend only upon updating properties, not other -// exposed properties (within the same object). This is to force the -// general complexity of a single object to be fairly simple: inputs -// directly determine outputs, with the only in-between step being the -// `compute` function, no multiple-layer dependencies. Note that this is -// only true within a given object - externally, values provided to one -// object's `update` may be (and regularly are) the exposed values of -// another object. -// -// 3b) If a property both updates and exposes, it is automatically regarded as -// a dependancy. (That is, its exposed value will depend on the value it is -// updated with.) Rather than a required `compute` function, these have an -// optional `transform` function, which takes the update value as its first -// argument and then the usual key-value dependencies as its second. If no -// `transform` function is provided, the expose value is the same as the -// update value. -// -// 4) Exposed properties are cached; that is, if no depended-upon properties are -// updated, the value of an exposed property is not recomputed. -// -// 4a) The cache for an exposed property is invalidated as soon as any of its -// dependencies are updated, but the cache itself is lazy: the exposed -// value will not be recomputed until it is again accessed. (Likewise, an -// exposed value won't be computed for the first time until it is first -// accessed.) -// -// 5) Updating a property may optionally apply validation checks before passing, -// declared by a `validate` function on the `update` block. This function -// should either throw an error (e.g. TypeError) or return false if the value -// is invalid. -// -// 6) Objects do not expect all updating properties to be provided at once. -// Incomplete objects are deliberately supported and enabled. -// -// 6a) The default value for every updating property is null; undefined is not -// accepted as a property value under any circumstances (it always errors). -// However, this default may be overridden by specifying a `default` value -// on a property's `update` block. (This value will be checked against -// the property's validate function.) Note that a property may always be -// updated to null, even if the default is non-null. (Null always bypasses -// the validate check.) -// -// 6b) It's required by the external consumer of an object to determine whether -// or not the object is ready for use (within the larger program). This is -// convenienced by the static CacheableObject.listAccessibleProperties() -// function, which provides a mapping of exposed property names to whether -// or not their dependencies are yet met. - -import {inspect as nodeInspect} from 'node:util'; - -import {colors, ENABLE_COLOR} from '#cli'; - -function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); -} - -export default class CacheableObject { - #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 - // update() function from inside this constructor, it will error when - // writing to private members. Pretty bad! - // - // That means initial data must be provided by following up with update() - // 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; - } - } - } - - #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: flags.expose, - }; - - if (flags.update) { - definition.set = this.#getUpdateObjectDefinitionSetterFunction(property); - } - - if (flags.expose) { - definition.get = this.#getExposeObjectDefinitionGetterFunction(property); - } - - Object.defineProperty(this, property, definition); - } - - 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 (caughtError) { - throw new CacheableObjectPropertyValueError( - property, oldValue, newValue, {cause: caughtError}); - } - } - - this.#propertyUpdateValues[property] = newValue; - this.#invalidateCachesDependentUpon(property); - }; - } - - #getPropertyDescriptor(property) { - return this.constructor.propertyDescriptors[property]; - } - - #invalidateCachesDependentUpon(property) { - const invalidators = this.#propertyUpdateCacheInvalidators[property]; - if (!invalidators) { - return; - } - - for (const invalidate of invalidators) { - 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]; - } - } - - #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`); - } - - let getAllDependencies; - - if (expose.dependencies?.length > 0) { - const dependencyKeys = expose.dependencies.slice(); - const shouldReflect = dependencyKeys.includes('this'); - - getAllDependencies = () => { - const dependencies = Object.create(null); - - for (const key of dependencyKeys) { - dependencies[key] = this.#propertyUpdateValues[key]; - } - - if (shouldReflect) { - dependencies.this = this; - } - - return dependencies; - }; - } else { - const dependencies = Object.create(null); - Object.freeze(dependencies); - getAllDependencies = () => dependencies; - } - - if (flags.update) { - return () => transform(this.#propertyUpdateValues[property], getAllDependencies()); - } else { - return () => compute(getAllDependencies()); - } - } - - #getExposeCheckCacheValidFunction(property) { - const {flags, expose} = this.#getPropertyDescriptor(property); - - let valid = false; - - const invalidate = () => { - valid = false; - }; - - const dependencyKeys = new Set(expose?.dependencies); - - if (flags.update) { - dependencyKeys.add(property); - } - - for (const key of dependencyKeys) { - if (this.#propertyUpdateCacheInvalidators[key]) { - this.#propertyUpdateCacheInvalidators[key].push(invalidate); - } else { - this.#propertyUpdateCacheInvalidators[key] = [invalidate]; - } - } - - return () => { - if (!valid) { - valid = true; - return false; - } else { - return true; - } - }; - } - - static cacheAllExposedProperties(obj) { - if (!(obj instanceof CacheableObject)) { - console.warn('Not a CacheableObject:', obj); - return; - } - - const {propertyDescriptors} = obj.constructor; - - if (!propertyDescriptors) { - console.warn('Missing property descriptors:', obj); - return; - } - - for (const [property, descriptor] of Object.entries(propertyDescriptors)) { - const {flags} = descriptor; - - if (!flags.expose) { - continue; - } - - obj[property]; - } - } - - 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; - } - - console.log(`${this._invalidAccesses.size} unique invalid accesses:`); - for (const line of this._invalidAccesses) { - console.log(` - ${line}`); - } - } - - static getUpdateValue(object, key) { - if (!Object.hasOwn(object, key)) { - return undefined; - } - - return object.#propertyUpdateValues[key] ?? null; - } -} - -export class CacheableObjectPropertyValueError extends Error { - [Symbol.for('hsmusic.aggregate.translucent')] = true; - - constructor(property, oldValue, newValue, options) { - super( - `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`, - options); - - this.property = property; - } -} diff --git a/src/data/things/composite.js b/src/data/things/composite.js deleted file mode 100644 index 113f0a4f..00000000 --- a/src/data/things/composite.js +++ /dev/null @@ -1,1307 +0,0 @@ -import {inspect} from 'node:util'; - -import {colors} from '#cli'; -import {TupleMap} from '#wiki-data'; -import {a} from '#validators'; - -import { - decorateErrorWithIndex, - empty, - filterProperties, - openAggregate, - stitchArrays, - typeAppearance, - unique, - withAggregate, -} from '#sugar'; - -const globalCompositeCache = {}; - -const _valueIntoToken = shape => - (value = null) => - (value === null - ? Symbol.for(`hsmusic.composite.${shape}`) - : typeof value === 'string' - ? Symbol.for(`hsmusic.composite.${shape}:${value}`) - : { - symbol: Symbol.for(`hsmusic.composite.input`), - shape, - value, - }); - -export const input = _valueIntoToken('input'); -input.symbol = Symbol.for('hsmusic.composite.input'); - -input.value = _valueIntoToken('input.value'); -input.dependency = _valueIntoToken('input.dependency'); - -input.myself = () => Symbol.for(`hsmusic.composite.input.myself`); - -input.updateValue = _valueIntoToken('input.updateValue'); - -input.staticDependency = _valueIntoToken('input.staticDependency'); -input.staticValue = _valueIntoToken('input.staticValue'); - -function isInputToken(token) { - if (token === null) { - return false; - } else if (typeof token === 'object') { - return token.symbol === Symbol.for('hsmusic.composite.input'); - } else if (typeof token === 'symbol') { - return token.description.startsWith('hsmusic.composite.input'); - } else { - return false; - } -} - -function getInputTokenShape(token) { - if (!isInputToken(token)) { - throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); - } - - if (typeof token === 'object') { - return token.shape; - } else { - return token.description.match(/hsmusic\.composite\.(input.*?)(:|$)/)[1]; - } -} - -function getInputTokenValue(token) { - if (!isInputToken(token)) { - throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); - } - - if (typeof token === 'object') { - return token.value; - } else { - return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null; - } -} - -function getStaticInputMetadata(inputOptions) { - const metadata = {}; - - for (const [name, token] of Object.entries(inputOptions)) { - if (typeof token === 'string') { - metadata[input.staticDependency(name)] = token; - metadata[input.staticValue(name)] = null; - } else if (isInputToken(token)) { - const tokenShape = getInputTokenShape(token); - const tokenValue = getInputTokenValue(token); - - metadata[input.staticDependency(name)] = - (tokenShape === 'input.dependency' - ? tokenValue - : null); - - metadata[input.staticValue(name)] = - (tokenShape === 'input.value' - ? tokenValue - : null); - } else { - metadata[input.staticDependency(name)] = null; - metadata[input.staticValue(name)] = null; - } - } - - return metadata; -} - -function getCompositionName(description) { - return ( - (description.annotation - ? description.annotation - : `unnamed composite`)); -} - -function validateInputValue(value, description) { - const tokenValue = getInputTokenValue(description); - - const {acceptsNull, defaultValue, type, validate} = tokenValue || {}; - - if (value === null || value === undefined) { - if (acceptsNull || defaultValue === null) { - return true; - } else { - throw new TypeError( - (type - ? `Expected ${a(type)}, got ${typeAppearance(value)}` - : `Expected a value, got ${typeAppearance(value)}`)); - } - } - - if (type) { - // Note: null is already handled earlier in this function, so it won't - // cause any trouble here. - const typeofValue = - (typeof value === 'object' - ? Array.isArray(value) ? 'array' : 'object' - : typeof value); - - if (typeofValue !== type) { - throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); - } - } - - if (validate) { - validate(value); - } - - return true; -} - -export function templateCompositeFrom(description) { - const compositionName = getCompositionName(description); - - withAggregate({message: `Errors in description for ${compositionName}`}, ({map, nest, push}) => { - if ('steps' in description) { - if (Array.isArray(description.steps)) { - push(new TypeError(`Wrap steps array in a function`)); - } else if (typeof description.steps !== 'function') { - push(new TypeError(`Expected steps to be a function (returning an array)`)); - } - } - - validateInputs: - if ('inputs' in description) { - if ( - Array.isArray(description.inputs) || - typeof description.inputs !== 'object' - ) { - push(new Error(`Expected inputs to be object, got ${typeAppearance(description.inputs)}`)); - break validateInputs; - } - - nest({message: `Errors in static input descriptions for ${compositionName}`}, ({push}) => { - const missingCallsToInput = []; - const wrongCallsToInput = []; - - for (const [name, value] of Object.entries(description.inputs)) { - if (!isInputToken(value)) { - missingCallsToInput.push(name); - continue; - } - - if (!['input', 'input.staticDependency', 'input.staticValue'].includes(getInputTokenShape(value))) { - wrongCallsToInput.push(name); - } - } - - for (const name of missingCallsToInput) { - push(new Error(`${name}: Missing call to input()`)); - } - - for (const name of wrongCallsToInput) { - const shape = getInputTokenShape(description.inputs[name]); - push(new Error(`${name}: Expected call to input, input.staticDependency, or input.staticValue, got ${shape}`)); - } - }); - } - - validateOutputs: - if ('outputs' in description) { - if ( - !Array.isArray(description.outputs) && - typeof description.outputs !== 'function' - ) { - push(new Error(`Expected outputs to be array or function, got ${typeAppearance(description.outputs)}`)); - break validateOutputs; - } - - if (Array.isArray(description.outputs)) { - map( - description.outputs, - decorateErrorWithIndex(value => { - if (typeof value !== 'string') { - throw new Error(`${value}: Expected string, got ${typeAppearance(value)}`) - } else if (!value.startsWith('#')) { - throw new Error(`${value}: Expected "#" at start`); - } - }), - {message: `Errors in output descriptions for ${compositionName}`}); - } - } - }); - - const expectedInputNames = - (description.inputs - ? Object.keys(description.inputs) - : []); - - const instantiate = (inputOptions = {}) => { - withAggregate({message: `Errors in input options passed to ${compositionName}`}, ({push}) => { - const providedInputNames = Object.keys(inputOptions); - - const misplacedInputNames = - providedInputNames - .filter(name => !expectedInputNames.includes(name)); - - const missingInputNames = - expectedInputNames - .filter(name => !providedInputNames.includes(name)) - .filter(name => { - const inputDescription = getInputTokenValue(description.inputs[name]); - if (!inputDescription) return true; - if ('defaultValue' in inputDescription) return false; - if ('defaultDependency' in inputDescription) return false; - return true; - }); - - const wrongTypeInputNames = []; - - const expectedStaticValueInputNames = []; - const expectedStaticDependencyInputNames = []; - const expectedValueProvidingTokenInputNames = []; - - const validateFailedErrors = []; - - for (const [name, value] of Object.entries(inputOptions)) { - if (misplacedInputNames.includes(name)) { - continue; - } - - if (typeof value !== 'string' && !isInputToken(value)) { - wrongTypeInputNames.push(name); - continue; - } - - const descriptionShape = getInputTokenShape(description.inputs[name]); - - const tokenShape = (isInputToken(value) ? getInputTokenShape(value) : null); - const tokenValue = (isInputToken(value) ? getInputTokenValue(value) : null); - - switch (descriptionShape) { - case'input.staticValue': - if (tokenShape !== 'input.value') { - expectedStaticValueInputNames.push(name); - continue; - } - break; - - case 'input.staticDependency': - if (typeof value !== 'string' && tokenShape !== 'input.dependency') { - expectedStaticDependencyInputNames.push(name); - continue; - } - break; - - case 'input': - if (typeof value !== 'string' && ![ - 'input', - 'input.value', - 'input.dependency', - 'input.myself', - 'input.updateValue', - ].includes(tokenShape)) { - expectedValueProvidingTokenInputNames.push(name); - continue; - } - break; - } - - if (tokenShape === 'input.value') { - try { - validateInputValue(tokenValue, description.inputs[name]); - } catch (error) { - error.message = `${name}: ${error.message}`; - validateFailedErrors.push(error); - } - } - } - - if (!empty(misplacedInputNames)) { - push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`)); - } - - if (!empty(missingInputNames)) { - push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`)); - } - - const inputAppearance = name => - (isInputToken(inputOptions[name]) - ? `${getInputTokenShape(inputOptions[name])}() call` - : `dependency name`); - - for (const name of expectedStaticDependencyInputNames) { - const appearance = inputAppearance(name); - push(new Error(`${name}: Expected dependency name, got ${appearance}`)); - } - - for (const name of expectedStaticValueInputNames) { - const appearance = inputAppearance(name) - push(new Error(`${name}: Expected input.value() call, got ${appearance}`)); - } - - for (const name of expectedValueProvidingTokenInputNames) { - const appearance = getInputTokenShape(inputOptions[name]); - push(new Error(`${name}: Expected dependency name or value-providing input() call, got ${appearance}`)); - } - - for (const name of wrongTypeInputNames) { - const type = typeAppearance(inputOptions[name]); - push(new Error(`${name}: Expected dependency name or input() call, got ${type}`)); - } - - for (const error of validateFailedErrors) { - push(error); - } - }); - - const inputMetadata = getStaticInputMetadata(inputOptions); - - const expectedOutputNames = - (Array.isArray(description.outputs) - ? description.outputs - : typeof description.outputs === 'function' - ? description.outputs(inputMetadata) - .map(name => - (name.startsWith('#') - ? name - : '#' + name)) - : []); - - const ownUpdateDescription = - (typeof description.update === 'object' - ? description.update - : typeof description.update === 'function' - ? description.update(inputMetadata) - : null); - - const outputOptions = {}; - - const instantiatedTemplate = { - symbol: templateCompositeFrom.symbol, - - outputs(providedOptions) { - withAggregate({message: `Errors in output options passed to ${compositionName}`}, ({push}) => { - const misplacedOutputNames = []; - const wrongTypeOutputNames = []; - - for (const [name, value] of Object.entries(providedOptions)) { - if (!expectedOutputNames.includes(name)) { - misplacedOutputNames.push(name); - continue; - } - - if (typeof value !== 'string') { - wrongTypeOutputNames.push(name); - continue; - } - } - - if (!empty(misplacedOutputNames)) { - push(new Error(`Unexpected output names: ${misplacedOutputNames.join(', ')}`)); - } - - for (const name of wrongTypeOutputNames) { - const appearance = typeAppearance(providedOptions[name]); - push(new Error(`${name}: Expected string, got ${appearance}`)); - } - }); - - Object.assign(outputOptions, providedOptions); - return instantiatedTemplate; - }, - - toDescription() { - const finalDescription = {}; - - if ('annotation' in description) { - finalDescription.annotation = description.annotation; - } - - if ('compose' in description) { - finalDescription.compose = description.compose; - } - - if (ownUpdateDescription) { - finalDescription.update = ownUpdateDescription; - } - - if ('inputs' in description) { - const inputMapping = {}; - - for (const [name, token] of Object.entries(description.inputs)) { - const tokenValue = getInputTokenValue(token); - if (name in inputOptions) { - if (typeof inputOptions[name] === 'string') { - inputMapping[name] = input.dependency(inputOptions[name]); - } else { - inputMapping[name] = inputOptions[name]; - } - } else if (tokenValue.defaultValue) { - inputMapping[name] = input.value(tokenValue.defaultValue); - } else if (tokenValue.defaultDependency) { - inputMapping[name] = input.dependency(tokenValue.defaultDependency); - } else { - inputMapping[name] = input.value(null); - } - } - - finalDescription.inputMapping = inputMapping; - finalDescription.inputDescriptions = description.inputs; - } - - if ('outputs' in description) { - const finalOutputs = {}; - - for (const name of expectedOutputNames) { - if (name in outputOptions) { - finalOutputs[name] = outputOptions[name]; - } else { - finalOutputs[name] = name; - } - } - - finalDescription.outputs = finalOutputs; - } - - if ('steps' in description) { - finalDescription.steps = description.steps; - } - - return finalDescription; - }, - - toResolvedComposition() { - const ownDescription = instantiatedTemplate.toDescription(); - - const finalDescription = {...ownDescription}; - - const aggregate = openAggregate({message: `Errors resolving ${compositionName}`}); - - const steps = ownDescription.steps(); - - const resolvedSteps = - aggregate.map( - steps, - decorateErrorWithIndex(step => - (step.symbol === templateCompositeFrom.symbol - ? compositeFrom(step.toResolvedComposition()) - : step)), - {message: `Errors resolving steps`}); - - aggregate.close(); - - finalDescription.steps = resolvedSteps; - - return finalDescription; - }, - }; - - return instantiatedTemplate; - }; - - instantiate.inputs = instantiate; - - return instantiate; -} - -templateCompositeFrom.symbol = Symbol(); - -export const continuationSymbol = Symbol.for('compositeFrom: continuation symbol'); -export const noTransformSymbol = Symbol.for('compositeFrom: no-transform symbol'); - -export function compositeFrom(description) { - const {annotation} = description; - const compositionName = getCompositionName(description); - - const debug = fn => { - if (compositeFrom.debug === true) { - const label = - (annotation - ? colors.dim(`[composite: ${annotation}]`) - : colors.dim(`[composite]`)); - const result = fn(); - if (Array.isArray(result)) { - console.log(label, ...result.map(value => - (typeof value === 'object' - ? inspect(value, {depth: 1, colors: true, compact: true, breakLength: Infinity}) - : value))); - } else { - console.log(label, result); - } - } - }; - - if (!Array.isArray(description.steps)) { - throw new TypeError( - `Expected steps to be array, got ${typeAppearance(description.steps)}` + - (annotation ? ` (${annotation})` : '')); - } - - const composition = - description.steps.map(step => - ('toResolvedComposition' in step - ? compositeFrom(step.toResolvedComposition()) - : step)); - - const inputMetadata = getStaticInputMetadata(description.inputMapping ?? {}); - - function _mapDependenciesToOutputs(providedDependencies) { - if (!description.outputs) { - return {}; - } - - if (!providedDependencies) { - return {}; - } - - return ( - Object.fromEntries( - Object.entries(description.outputs) - .map(([continuationName, outputName]) => [ - outputName, - (continuationName in providedDependencies - ? providedDependencies[continuationName] - : providedDependencies[continuationName.replace(/^#/, '')]), - ]))); - } - - // These dependencies were all provided by the composition which this one is - // nested inside, so input('name')-shaped tokens are going to be evaluated - // in the context of the containing composition. - const dependenciesFromInputs = - Object.values(description.inputMapping ?? {}) - .map(token => { - const tokenShape = getInputTokenShape(token); - const tokenValue = getInputTokenValue(token); - switch (tokenShape) { - case 'input.dependency': - return tokenValue; - case 'input': - case 'input.updateValue': - return token; - case 'input.myself': - return 'this'; - default: - return null; - } - }) - .filter(Boolean); - - const anyInputsUseUpdateValue = - dependenciesFromInputs - .filter(dependency => isInputToken(dependency)) - .some(token => getInputTokenShape(token) === 'input.updateValue'); - - const inputNames = - Object.keys(description.inputMapping ?? {}); - - const inputSymbols = - inputNames.map(name => input(name)); - - const inputsMayBeDynamicValue = - stitchArrays({ - mappingToken: Object.values(description.inputMapping ?? {}), - descriptionToken: Object.values(description.inputDescriptions ?? {}), - }).map(({mappingToken, descriptionToken}) => { - if (getInputTokenShape(descriptionToken) === 'input.staticValue') return false; - if (getInputTokenShape(mappingToken) === 'input.value') return false; - return true; - }); - - const inputDescriptions = - Object.values(description.inputDescriptions ?? {}); - - /* - const inputsAcceptNull = - Object.values(description.inputDescriptions ?? {}) - .map(token => { - const tokenValue = getInputTokenValue(token); - if (!tokenValue) return false; - if ('acceptsNull' in tokenValue) return tokenValue.acceptsNull; - if ('defaultValue' in tokenValue) return tokenValue.defaultValue === null; - return false; - }); - */ - - // Update descriptions passed as the value in an input.updateValue() token, - // as provided as inputs for this composition. - const inputUpdateDescriptions = - Object.values(description.inputMapping ?? {}) - .map(token => - (getInputTokenShape(token) === 'input.updateValue' - ? getInputTokenValue(token) - : null)) - .filter(Boolean); - - const base = composition.at(-1); - const steps = composition.slice(); - - const aggregate = openAggregate({ - message: - `Errors preparing composition` + - (annotation ? ` (${annotation})` : ''), - }); - - const compositionNests = description.compose ?? true; - - if (compositionNests && empty(steps)) { - aggregate.push(new TypeError(`Expected at least one step`)); - } - - // Steps default to exposing if using a shorthand syntax where flags aren't - // specified at all. - const stepsExpose = - steps - .map(step => - (step.flags - ? step.flags.expose ?? false - : true)); - - // Steps default to composing if using a shorthand syntax where flags aren't - // specified at all - *and* aren't the base (final step), unless the whole - // composition is nestable. - const stepsCompose = - steps - .map((step, index, {length}) => - (step.flags - ? step.flags.compose ?? false - : (index === length - 1 - ? compositionNests - : true))); - - // Steps update if the corresponding flag is explicitly set, if a transform - // function is provided, or if the dependencies include an input.updateValue - // token. - const stepsUpdate = - steps - .map(step => - (step.flags - ? step.flags.update ?? false - : !!step.transform || - !!step.dependencies?.some(dependency => - isInputToken(dependency) && - getInputTokenShape(dependency) === 'input.updateValue'))); - - // The expose description for a step is just the entire step object, when - // using the shorthand syntax where {flags: {expose: true}} is left implied. - const stepExposeDescriptions = - steps - .map((step, index) => - (stepsExpose[index] - ? (step.flags - ? step.expose ?? null - : step) - : null)); - - // The update description for a step, if present at all, is always set - // explicitly. There may be multiple per step - namely that step's own - // {update} description, and any descriptions passed as the value in an - // input.updateValue({...}) token. - const stepUpdateDescriptions = - steps - .map((step, index) => - (stepsUpdate[index] - ? [ - step.update ?? null, - ...(stepExposeDescriptions[index]?.dependencies ?? []) - .filter(dependency => isInputToken(dependency)) - .filter(token => getInputTokenShape(token) === 'input.updateValue') - .map(token => getInputTokenValue(token)), - ].filter(Boolean) - : [])); - - // Indicates presence of a {compute} function on the expose description. - const stepsCompute = - stepExposeDescriptions - .map(expose => !!expose?.compute); - - // Indicates presence of a {transform} function on the expose description. - const stepsTransform = - stepExposeDescriptions - .map(expose => !!expose?.transform); - - const dependenciesFromSteps = - unique( - stepExposeDescriptions - .flatMap(expose => expose?.dependencies ?? []) - .map(dependency => { - if (typeof dependency === 'string') - return (dependency.startsWith('#') ? null : dependency); - - const tokenShape = getInputTokenShape(dependency); - const tokenValue = getInputTokenValue(dependency); - switch (tokenShape) { - case 'input.dependency': - return (tokenValue.startsWith('#') ? null : tokenValue); - case 'input.myself': - return 'this'; - default: - return null; - } - }) - .filter(Boolean)); - - const anyStepsUseUpdateValue = - stepExposeDescriptions - .some(expose => - (expose?.dependencies - ? expose.dependencies.includes(input.updateValue()) - : false)); - - const anyStepsExpose = - stepsExpose.includes(true); - - const anyStepsUpdate = - stepsUpdate.includes(true); - - const anyStepsCompute = - stepsCompute.includes(true); - - const anyStepsTransform = - stepsTransform.includes(true); - - const compositionExposes = - anyStepsExpose; - - const compositionUpdates = - 'update' in description || - anyInputsUseUpdateValue || - anyStepsUseUpdateValue || - anyStepsUpdate; - - const stepEntries = stitchArrays({ - step: steps, - stepComposes: stepsCompose, - stepComputes: stepsCompute, - stepTransforms: stepsTransform, - }); - - for (let i = 0; i < stepEntries.length; i++) { - const { - step, - stepComposes, - stepComputes, - stepTransforms, - } = stepEntries[i]; - - const isBase = i === stepEntries.length - 1; - const message = - `Errors in step #${i + 1}` + - (isBase ? ` (base)` : ``) + - (step.annotation ? ` (${step.annotation})` : ``); - - aggregate.nest({message}, ({push}) => { - if (isBase && stepComposes !== compositionNests) { - return push(new TypeError( - (compositionNests - ? `Base must compose, this composition is nestable` - : `Base must not compose, this composition isn't nestable`))); - } else if (!isBase && !stepComposes) { - return push(new TypeError( - (compositionNests - ? `All steps must compose` - : `All steps (except base) must compose`))); - } - - if ( - !compositionNests && !compositionUpdates && - stepTransforms && !stepComputes - ) { - return push(new TypeError( - `Steps which only transform can't be used in a composition that doesn't update`)); - } - }); - } - - if (!compositionNests && !compositionUpdates && !anyStepsCompute) { - aggregate.push(new TypeError(`Expected at least one step to compute`)); - } - - aggregate.close(); - - function _prepareContinuation(callingTransformForThisStep) { - const continuationStorage = { - returnedWith: null, - providedDependencies: undefined, - providedValue: undefined, - }; - - const continuation = - (callingTransformForThisStep - ? (providedValue, providedDependencies = null) => { - continuationStorage.returnedWith = 'continuation'; - continuationStorage.providedDependencies = providedDependencies; - continuationStorage.providedValue = providedValue; - return continuationSymbol; - } - : (providedDependencies = null) => { - continuationStorage.returnedWith = 'continuation'; - continuationStorage.providedDependencies = providedDependencies; - return continuationSymbol; - }); - - continuation.exit = (providedValue) => { - continuationStorage.returnedWith = 'exit'; - continuationStorage.providedValue = providedValue; - return continuationSymbol; - }; - - if (compositionNests) { - const makeRaiseLike = returnWith => - (callingTransformForThisStep - ? (providedValue, providedDependencies = null) => { - continuationStorage.returnedWith = returnWith; - continuationStorage.providedDependencies = providedDependencies; - continuationStorage.providedValue = providedValue; - return continuationSymbol; - } - : (providedDependencies = null) => { - continuationStorage.returnedWith = returnWith; - continuationStorage.providedDependencies = providedDependencies; - return continuationSymbol; - }); - - continuation.raiseOutput = makeRaiseLike('raiseOutput'); - continuation.raiseOutputAbove = makeRaiseLike('raiseOutputAbove'); - } - - return {continuation, continuationStorage}; - } - - function _computeOrTransform(initialValue, continuationIfApplicable, initialDependencies) { - const expectingTransform = initialValue !== noTransformSymbol; - - let valueSoFar = - (expectingTransform - ? initialValue - : undefined); - - const availableDependencies = {...initialDependencies}; - - const inputValues = - Object.values(description.inputMapping ?? {}) - .map(token => { - const tokenShape = getInputTokenShape(token); - const tokenValue = getInputTokenValue(token); - switch (tokenShape) { - case 'input.dependency': - return initialDependencies[tokenValue]; - case 'input.value': - return tokenValue; - case 'input.updateValue': - if (!expectingTransform) - throw new Error(`Unexpected input.updateValue() accessed on non-transform call`); - return valueSoFar; - case 'input.myself': - return initialDependencies['this']; - case 'input': - return initialDependencies[token]; - default: - throw new TypeError(`Unexpected input shape ${tokenShape}`); - } - }); - - withAggregate({message: `Errors in input values provided to ${compositionName}`}, ({push}) => { - for (const {dynamic, name, value, description} of stitchArrays({ - dynamic: inputsMayBeDynamicValue, - name: inputNames, - value: inputValues, - description: inputDescriptions, - })) { - if (!dynamic) continue; - try { - validateInputValue(value, description); - } catch (error) { - error.message = `${name}: ${error.message}`; - push(error); - } - } - }); - - if (expectingTransform) { - debug(() => [colors.bright(`begin composition - transforming from:`), initialValue]); - } else { - debug(() => colors.bright(`begin composition - not transforming`)); - } - - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - const isBase = i === steps.length - 1; - - debug(() => [ - `step #${i+1}` + - (isBase - ? ` (base):` - : ` of ${steps.length}:`), - step]); - - const expose = - (step.flags - ? step.expose - : step); - - if (!expose) { - if (!isBase) { - debug(() => `step #${i+1} - no expose description, nothing to do for this step`); - continue; - } - - if (expectingTransform) { - debug(() => `step #${i+1} (base) - no expose description, returning so-far update value:`, valueSoFar); - if (continuationIfApplicable) { - debug(() => colors.bright(`end composition - raise (inferred - composing)`)); - return continuationIfApplicable(valueSoFar); - } else { - debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); - return valueSoFar; - } - } else { - debug(() => `step #${i+1} (base) - no expose description, nothing to continue with`); - if (continuationIfApplicable) { - debug(() => colors.bright(`end composition - raise (inferred - composing)`)); - return continuationIfApplicable(); - } else { - debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); - return null; - } - } - } - - const callingTransformForThisStep = - expectingTransform && expose.transform; - - let continuationStorage; - - const inputDictionary = - Object.fromEntries( - stitchArrays({symbol: inputSymbols, value: inputValues}) - .map(({symbol, value}) => [symbol, value])); - - const filterableDependencies = { - ...availableDependencies, - ...inputMetadata, - ...inputDictionary, - ... - (expectingTransform - ? {[input.updateValue()]: valueSoFar} - : {}), - [input.myself()]: initialDependencies?.['this'] ?? null, - }; - - const selectDependencies = - (expose.dependencies ?? []).map(dependency => { - if (!isInputToken(dependency)) return dependency; - const tokenShape = getInputTokenShape(dependency); - const tokenValue = getInputTokenValue(dependency); - switch (tokenShape) { - case 'input': - case 'input.staticDependency': - case 'input.staticValue': - return dependency; - case 'input.myself': - return input.myself(); - case 'input.dependency': - return tokenValue; - case 'input.updateValue': - return input.updateValue(); - default: - throw new Error(`Unexpected token ${tokenShape} as dependency`); - } - }) - - const filteredDependencies = - filterProperties(filterableDependencies, selectDependencies); - - debug(() => [ - `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, - `with dependencies:`, filteredDependencies, - `selecting:`, selectDependencies, - `from available:`, filterableDependencies, - ...callingTransformForThisStep ? [`from value:`, valueSoFar] : []]); - - let result; - - const getExpectedEvaluation = () => - (callingTransformForThisStep - ? (filteredDependencies - ? ['transform', valueSoFar, continuationSymbol, filteredDependencies] - : ['transform', valueSoFar, continuationSymbol]) - : (filteredDependencies - ? ['compute', continuationSymbol, filteredDependencies] - : ['compute', continuationSymbol])); - - const naturalEvaluate = () => { - const [name, ...argsLayout] = getExpectedEvaluation(); - - let args; - - if (isBase && !compositionNests) { - args = - argsLayout.filter(arg => arg !== continuationSymbol); - } else { - let continuation; - - ({continuation, continuationStorage} = - _prepareContinuation(callingTransformForThisStep)); - - args = - argsLayout.map(arg => - (arg === continuationSymbol - ? continuation - : arg)); - } - - return expose[name](...args); - } - - switch (step.cache) { - // Warning! Highly WIP! - case 'aggressive': { - const hrnow = () => { - const hrTime = process.hrtime(); - return hrTime[0] * 1000000000 + hrTime[1]; - }; - - const [name, ...args] = getExpectedEvaluation(); - - let cache = globalCompositeCache[step.annotation]; - if (!cache) { - cache = globalCompositeCache[step.annotation] = { - transform: new TupleMap(), - compute: new TupleMap(), - times: { - read: [], - evaluate: [], - }, - }; - } - - const tuplefied = args - .flatMap(arg => [ - Symbol.for('compositeFrom: tuplefied arg divider'), - ...(typeof arg !== 'object' || Array.isArray(arg) - ? [arg] - : Object.entries(arg).flat()), - ]); - - const readTime = hrnow(); - const cacheContents = cache[name].get(tuplefied); - cache.times.read.push(hrnow() - readTime); - - if (cacheContents) { - ({result, continuationStorage} = cacheContents); - } else { - const evaluateTime = hrnow(); - result = naturalEvaluate(); - cache.times.evaluate.push(hrnow() - evaluateTime); - cache[name].set(tuplefied, {result, continuationStorage}); - } - - break; - } - - default: { - result = naturalEvaluate(); - break; - } - } - - if (result !== continuationSymbol) { - debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); - - if (compositionNests) { - throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); - } - - debug(() => colors.bright(`end composition - exit (inferred)`)); - - return result; - } - - const {returnedWith} = continuationStorage; - - if (returnedWith === 'exit') { - const {providedValue} = continuationStorage; - - debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); - debug(() => colors.bright(`end composition - exit (explicit)`)); - - if (compositionNests) { - return continuationIfApplicable.exit(providedValue); - } else { - return providedValue; - } - } - - const {providedValue, providedDependencies} = continuationStorage; - - const continuationArgs = []; - if (expectingTransform) { - continuationArgs.push( - (callingTransformForThisStep - ? providedValue ?? null - : valueSoFar ?? null)); - } - - debug(() => { - const base = `step #${i+1} - result: ` + returnedWith; - const parts = []; - - if (callingTransformForThisStep) { - parts.push('value:', providedValue); - } - - if (providedDependencies !== null) { - parts.push(`deps:`, providedDependencies); - } else { - parts.push(`(no deps)`); - } - - if (empty(parts)) { - return base; - } else { - return [base + ' ->', ...parts]; - } - }); - - switch (returnedWith) { - case 'raiseOutput': - debug(() => - (isBase - ? colors.bright(`end composition - raiseOutput (base: explicit)`) - : colors.bright(`end composition - raiseOutput`))); - continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); - return continuationIfApplicable(...continuationArgs); - - case 'raiseOutputAbove': - debug(() => colors.bright(`end composition - raiseOutputAbove`)); - continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); - return continuationIfApplicable.raiseOutput(...continuationArgs); - - case 'continuation': - if (isBase) { - debug(() => colors.bright(`end composition - raiseOutput (inferred)`)); - continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); - return continuationIfApplicable(...continuationArgs); - } else { - Object.assign(availableDependencies, providedDependencies); - if (callingTransformForThisStep && providedValue !== null) { - valueSoFar = providedValue; - } - break; - } - } - } - } - - const constructedDescriptor = {}; - - if (annotation) { - constructedDescriptor.annotation = annotation; - } - - constructedDescriptor.flags = { - update: compositionUpdates, - expose: compositionExposes, - compose: compositionNests, - }; - - if (compositionUpdates) { - // TODO: This is a dumb assign statement, and it could probably do more - // interesting things, like combining validation functions. - constructedDescriptor.update = - Object.assign( - {...description.update ?? {}}, - ...inputUpdateDescriptions, - ...stepUpdateDescriptions.flat()); - } - - if (compositionExposes) { - const expose = constructedDescriptor.expose = {}; - - expose.dependencies = - unique([ - ...dependenciesFromInputs, - ...dependenciesFromSteps, - ]); - - const _wrapper = (...args) => { - try { - return _computeOrTransform(...args); - } catch (thrownError) { - const error = new Error( - `Error computing composition` + - (annotation ? ` ${annotation}` : '')); - error.cause = thrownError; - throw error; - } - }; - - if (compositionNests) { - if (compositionUpdates) { - expose.transform = (value, continuation, dependencies) => - _wrapper(value, continuation, dependencies); - } - - if (anyStepsCompute && !anyStepsUseUpdateValue && !anyInputsUseUpdateValue) { - expose.compute = (continuation, dependencies) => - _wrapper(noTransformSymbol, continuation, dependencies); - } - - if (base.cacheComposition) { - expose.cache = base.cacheComposition; - } - } else if (compositionUpdates) { - if (!empty(steps)) { - expose.transform = (value, dependencies) => - _wrapper(value, null, dependencies); - } - } else { - expose.compute = (dependencies) => - _wrapper(noTransformSymbol, null, dependencies); - } - } - - return constructedDescriptor; -} - -export function displayCompositeCacheAnalysis() { - const showTimes = (cache, key) => { - const times = cache.times[key].slice().sort(); - - const all = times; - const worst10pc = times.slice(-times.length / 10); - const best10pc = times.slice(0, times.length / 10); - const middle50pc = times.slice(times.length / 4, -times.length / 4); - const middle80pc = times.slice(times.length / 10, -times.length / 10); - - const fmt = val => `${(val / 1000).toFixed(2)}ms`.padStart(9); - const avg = times => times.reduce((a, b) => a + b, 0) / times.length; - - const left = ` - ${key}: `; - const indn = ' '.repeat(left.length); - console.log(left + `${fmt(avg(all))} (all ${all.length})`); - console.log(indn + `${fmt(avg(worst10pc))} (worst 10%)`); - console.log(indn + `${fmt(avg(best10pc))} (best 10%)`); - console.log(indn + `${fmt(avg(middle80pc))} (middle 80%)`); - console.log(indn + `${fmt(avg(middle50pc))} (middle 50%)`); - }; - - for (const [annotation, cache] of Object.entries(globalCompositeCache)) { - console.log(`Cached ${annotation}:`); - showTimes(cache, 'evaluate'); - showTimes(cache, 'read'); - } -} - -// Evaluates a function with composite debugging enabled, turns debugging -// off again, and returns the result of the function. This is mostly syntax -// sugar, but also helps avoid unit tests avoid accidentally printing debug -// info for a bunch of unrelated composites (due to property enumeration -// when displaying an unexpected result). Use as so: -// -// Without debugging: -// t.same(thing.someProp, value) -// -// With debugging: -// t.same(debugComposite(() => thing.someProp), value) -// -export function debugComposite(fn) { - compositeFrom.debug = true; - const value = fn(); - compositeFrom.debug = false; - return value; -} diff --git a/src/data/things/flash.js b/src/data/things/flash.js index d7e8bb46..7e859bfa 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -1,13 +1,8 @@ import {input} from '#composite'; import find from '#find'; - -import { - anyOf, - isColor, - isDirectory, - isNumber, - isString, -} from '#validators'; +import Thing from '#thing'; +import {anyOf, isColor, isDirectory, isNumber, isString} from '#validators'; +import {parseDate, parseContributors} from '#yaml'; import {exposeDependency, exposeUpdateValueOrContinue} from '#composite/control-flow'; @@ -28,10 +23,6 @@ import { import {withFlashAct} from '#composite/things/flash'; -import {parseContributors} from '#yaml'; - -import Thing from './thing.js'; - export class Flash extends Thing { static [Thing.referenceType] = 'flash'; @@ -137,24 +128,25 @@ export class Flash extends Thing { }); static [Thing.yamlDocumentSpec] = { - fieldTransformations: { - 'Date': (value) => new Date(value), - - 'Contributors': parseContributors, - }, - - propertyFieldMapping: { - name: 'Flash', - directory: 'Directory', - page: 'Page', - color: 'Color', - urls: 'URLs', + fields: { + 'Flash': {property: 'name'}, + 'Directory': {property: 'directory'}, + 'Page': {property: 'page'}, + 'Color': {property: 'color'}, + 'URLs': {property: 'urls'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, - date: 'Date', - coverArtFileExtension: 'Cover Art File Extension', + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, - featuredTracks: 'Featured Tracks', - contributorContribs: 'Contributors', + 'Featured Tracks': {property: 'featuredTracks'}, + 'Contributors': { + property: 'contributorContribs', + transform: parseContributors, + }, }, ignoredFields: ['Review Points'], @@ -199,15 +191,15 @@ export class FlashAct extends Thing { }); static [Thing.yamlDocumentSpec] = { - propertyFieldMapping: { - name: 'Act', - directory: 'Directory', + fields: { + 'Act': {property: 'name'}, + 'Directory': {property: 'directory'}, - color: 'Color', - listTerminology: 'List Terminology', + 'Color': {property: 'color'}, + 'List Terminology': {property: 'listTerminology'}, - jump: 'Jump', - jumpColor: 'Jump Color', + 'Jump': {property: 'jump'}, + 'Jump Color': {property: 'jumpColor'}, }, ignoredFields: ['Review Points'], diff --git a/src/data/things/group.js b/src/data/things/group.js index a9708fb4..fe04dfaa 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,5 +1,6 @@ import {input} from '#composite'; import find from '#find'; +import Thing from '#thing'; import { color, @@ -11,8 +12,6 @@ import { wikiData, } from '#composite/wiki-properties'; -import Thing from './thing.js'; - export class Group extends Thing { static [Thing.referenceType] = 'group'; @@ -87,13 +86,13 @@ export class Group extends Thing { }); static [Thing.yamlDocumentSpec] = { - propertyFieldMapping: { - name: 'Group', - directory: 'Directory', - description: 'Description', - urls: 'URLs', + fields: { + 'Group': {property: 'name'}, + 'Directory': {property: 'directory'}, + 'Description': {property: 'description'}, + 'URLs': {property: 'urls'}, - featuredAlbums: 'Featured Albums', + 'Featured Albums': {property: 'featuredAlbums'}, }, ignoredFields: ['Review Points'], @@ -126,9 +125,9 @@ export class GroupCategory extends Thing { }); static [Thing.yamlDocumentSpec] = { - propertyFieldMapping: { - name: 'Category', - color: 'Color', + fields: { + 'Category': {property: 'name'}, + 'Color': {property: 'color'}, }, }; } diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index b4fb97db..bd0970fe 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -1,5 +1,6 @@ import {input} from '#composite'; import find from '#find'; +import Thing from '#thing'; import { anyOf, @@ -14,16 +15,8 @@ import { import {exposeDependency} from '#composite/control-flow'; import {withResolvedReference} from '#composite/wiki-data'; - -import { - color, - contentString, - name, - referenceList, - wikiData, -} from '#composite/wiki-properties'; - -import Thing from './thing.js'; +import {color, contentString, name, referenceList, wikiData} + from '#composite/wiki-properties'; export class HomepageLayout extends Thing { static [Thing.friendlyName] = `Homepage Layout`; @@ -48,9 +41,9 @@ export class HomepageLayout extends Thing { }); static [Thing.yamlDocumentSpec] = { - propertyFieldMapping: { - sidebarContent: 'Sidebar Content', - navbarLinks: 'Navbar Links', + fields: { + 'Sidebar Content': {property: 'sidebarContent'}, + 'Navbar Links': {property: 'navbarLinks'}, }, ignoredFields: ['Homepage'], @@ -93,10 +86,10 @@ export class HomepageLayoutRow extends Thing { }); static [Thing.yamlDocumentSpec] = { - propertyFieldMapping: { - name: 'Row', - color: 'Color', - type: 'Type', + fields: { + 'Row': {property: 'name'}, + 'Color': {property: 'color'}, + 'Type': {property: 'type'}, }, }; } @@ -181,12 +174,12 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { }); static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { - propertyFieldMapping: { - displayStyle: 'Display Style', - sourceGroup: 'Group', - countAlbumsFromGroup: 'Count', - sourceAlbums: 'Albums', - actionLinks: 'Actions', + fields: { + 'Display Style': {property: 'displayStyle'}, + 'Group': {property: 'sourceGroup'}, + 'Count': {property: 'countAlbumsFromGroup'}, + 'Albums': {property: 'sourceAlbums'}, + 'Actions': {property: 'actionLinks'}, }, }); } diff --git a/src/data/things/index.js b/src/data/things/index.js index d1143b0a..9a36eaae 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -6,7 +6,7 @@ import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; import {openAggregate, showAggregate} from '#sugar'; -import Thing from './thing.js'; +import Thing from '#thing'; import * as albumClasses from './album.js'; import * as artTagClasses from './art-tag.js'; @@ -20,8 +20,6 @@ import * as staticPageClasses from './static-page.js'; import * as trackClasses from './track.js'; import * as wikiInfoClasses from './wiki-info.js'; -export {default as Thing} from './thing.js'; - const allClassLists = { 'album.js': albumClasses, 'art-tag.js': artTagClasses, diff --git a/src/data/things/language.js b/src/data/things/language.js index b7841ace..c576a316 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,8 +1,10 @@ import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; +import CacheableObject from '#cacheable-object'; import * as html from '#html'; import {empty, withAggregate} from '#sugar'; import {isLanguageCode} from '#validators'; +import Thing from '#thing'; import { getExternalLinkStringOfStyleFromDescriptors, @@ -12,14 +14,7 @@ import { isExternalLinkStyle, } from '#external-links'; -import { - externalFunction, - flag, - name, -} from '#composite/wiki-properties'; - -import CacheableObject from './cacheable-object.js'; -import Thing from './thing.js'; +import {externalFunction, flag, name} from '#composite/wiki-properties'; export class Language extends Thing { static [Thing.getPropertyDescriptors] = () => ({ diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js index 06dad629..5a022449 100644 --- a/src/data/things/news-entry.js +++ b/src/data/things/news-entry.js @@ -1,11 +1,8 @@ -import { - contentString, - directory, - name, - simpleDate, -} from '#composite/wiki-properties'; +import Thing from '#thing'; +import {parseDate} from '#yaml'; -import Thing from './thing.js'; +import {contentString, directory, name, simpleDate} + from '#composite/wiki-properties'; export class NewsEntry extends Thing { static [Thing.referenceType] = 'news-entry'; @@ -34,15 +31,16 @@ export class NewsEntry extends Thing { }); static [Thing.yamlDocumentSpec] = { - fieldTransformations: { - 'Date': (value) => new Date(value), - }, + fields: { + 'Name': {property: 'name'}, + 'Directory': {property: 'directory'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, - propertyFieldMapping: { - name: 'Name', - directory: 'Directory', - date: 'Date', - content: 'Content', + 'Content': {property: 'content'}, }, }; } diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index 00c0b09c..7f8b7c91 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -1,13 +1,8 @@ +import Thing from '#thing'; import {isName} from '#validators'; -import { - contentString, - directory, - name, - simpleString, -} from '#composite/wiki-properties'; - -import Thing from './thing.js'; +import {contentString, directory, name, simpleString} + from '#composite/wiki-properties'; export class StaticPage extends Thing { static [Thing.referenceType] = 'static'; @@ -35,14 +30,14 @@ export class StaticPage extends Thing { }); static [Thing.yamlDocumentSpec] = { - propertyFieldMapping: { - name: 'Name', - nameShort: 'Short Name', - directory: 'Directory', - - stylesheet: 'Style', - script: 'Script', - content: 'Content', + fields: { + 'Name': {property: 'name'}, + 'Short Name': {property: 'nameShort'}, + 'Directory': {property: 'directory'}, + + 'Style': {property: 'stylesheet'}, + 'Script': {property: 'script'}, + 'Content': {property: 'content'}, }, ignoredFields: ['Review Points'], diff --git a/src/data/things/thing.js b/src/data/things/thing.js deleted file mode 100644 index e1f488ee..00000000 --- a/src/data/things/thing.js +++ /dev/null @@ -1,83 +0,0 @@ -// Thing: base class for wiki data types, providing interfaces generally useful -// to all wiki data objects on top of foundational CacheableObject behavior. - -import {inspect} from 'node:util'; - -import {colors} from '#cli'; - -import CacheableObject from './cacheable-object.js'; - -export default class Thing extends CacheableObject { - static referenceType = Symbol.for('Thing.referenceType'); - static friendlyName = Symbol.for('Thing.friendlyName'); - - static getPropertyDescriptors = Symbol.for('Thing.getPropertyDescriptors'); - static getSerializeDescriptors = Symbol.for('Thing.getSerializeDescriptors'); - - static yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec'); - - // Default custom inspect function, which may be overridden by Thing - // subclasses. This will be used when displaying aggregate errors and other - // command-line logging - it's the place to provide information useful in - // identifying the Thing being presented. - [inspect.custom]() { - const cname = this.constructor.name; - - return ( - (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) + - (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '') - ); - } - - static getReference(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}`; - } - - static extendDocumentSpec(thingClass, subspec) { - const superspec = thingClass[Thing.yamlDocumentSpec]; - - const { - fieldTransformations, - propertyFieldMapping, - ignoredFields, - invalidFieldCombinations, - ...restOfSubspec - } = subspec; - - const newFields = - Object.values(subspec.propertyFieldMapping ?? {}); - - return { - ...superspec, - ...restOfSubspec, - - fieldTransformations: { - ...superspec.fieldTransformations, - ...fieldTransformations, - }, - - propertyFieldMapping: { - ...superspec.propertyFieldMapping, - ...propertyFieldMapping, - }, - - ignoredFields: - (superspec.ignoredFields ?? []) - .filter(field => newFields.includes(field)) - .concat(ignoredFields ?? []), - - invalidFieldCombinations: [ - ...superspec.invalidFieldCombinations ?? [], - ...invalidFieldCombinations ?? [], - ], - }; - } -} diff --git a/src/data/things/track.js b/src/data/things/track.js index 3621510b..9f44bd8d 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -1,15 +1,20 @@ import {inspect} from 'node:util'; +import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; import find from '#find'; +import Thing from '#thing'; +import {isColor, isContributionList, isDate, isFileExtension} + from '#validators'; import { - isColor, - isContributionList, - isDate, - isFileExtension, -} from '#validators'; + parseAdditionalFiles, + parseAdditionalNames, + parseContributors, + parseDate, + parseDuration, +} from '#yaml'; import {withPropertyFromObject} from '#composite/data'; import {withResolvedContribs} from '#composite/wiki-data'; @@ -55,16 +60,6 @@ import { withPropertyFromAlbum, } from '#composite/things/track'; -import { - parseAdditionalFiles, - parseAdditionalNames, - parseContributors, - parseDuration, -} from '#yaml'; - -import CacheableObject from './cacheable-object.js'; -import Thing from './thing.js'; - export class Track extends Thing { static [Thing.referenceType] = 'track'; @@ -340,54 +335,83 @@ export class Track extends Thing { }); static [Thing.yamlDocumentSpec] = { - fieldTransformations: { - 'Additional Names': parseAdditionalNames, - 'Duration': parseDuration, - - 'Date First Released': (value) => new Date(value), - 'Cover Art Date': (value) => new Date(value), - 'Has Cover Art': (value) => - (value === true ? false : - value === false ? true : - value), - - 'Artists': parseContributors, - 'Contributors': parseContributors, - 'Cover Artists': parseContributors, - - 'Additional Files': parseAdditionalFiles, - 'Sheet Music Files': parseAdditionalFiles, - 'MIDI Project Files': parseAdditionalFiles, - }, + fields: { + 'Track': {property: 'name'}, + 'Directory': {property: 'directory'}, + + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Duration': { + property: 'duration', + transform: parseDuration, + }, + + 'Color': {property: 'color'}, + 'URLs': {property: 'urls'}, + + 'Date First Released': { + property: 'dateFirstReleased', + transform: parseDate, + }, + + 'Cover Art Date': { + property: 'coverArtDate', + transform: parseDate, + }, + + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + + 'Has Cover Art': { + property: 'disableUniqueCoverArt', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + + 'Lyrics': {property: 'lyrics'}, + 'Commentary': {property: 'commentary'}, + + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, + }, + + 'Sheet Music Files': { + property: 'sheetMusicFiles', + transform: parseAdditionalFiles, + }, + + 'MIDI Project Files': { + property: 'midiProjectFiles', + transform: parseAdditionalFiles, + }, + + 'Originally Released As': {property: 'originalReleaseTrack'}, + 'Referenced Tracks': {property: 'referencedTracks'}, + 'Sampled Tracks': {property: 'sampledTracks'}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Contributors': { + property: 'contributorContribs', + transform: parseContributors, + }, + + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, - propertyFieldMapping: { - name: 'Track', - directory: 'Directory', - additionalNames: 'Additional Names', - duration: 'Duration', - color: 'Color', - urls: 'URLs', - - dateFirstReleased: 'Date First Released', - coverArtDate: 'Cover Art Date', - coverArtFileExtension: 'Cover Art File Extension', - disableUniqueCoverArt: 'Has Cover Art', // This gets transformed to flip true/false. - - alwaysReferenceByDirectory: 'Always Reference By Directory', - - lyrics: 'Lyrics', - commentary: 'Commentary', - additionalFiles: 'Additional Files', - sheetMusicFiles: 'Sheet Music Files', - midiProjectFiles: 'MIDI Project Files', - - originalReleaseTrack: 'Originally Released As', - referencedTracks: 'Referenced Tracks', - sampledTracks: 'Sampled Tracks', - artistContribs: 'Artists', - contributorContribs: 'Contributors', - coverArtistContribs: 'Cover Artists', - artTags: 'Art Tags', + 'Art Tags': {property: 'artTags'}, }, ignoredFields: ['Review Points'], diff --git a/src/data/things/validators.js b/src/data/things/validators.js deleted file mode 100644 index efe76fe0..00000000 --- a/src/data/things/validators.js +++ /dev/null @@ -1,984 +0,0 @@ -import {inspect as nodeInspect} from 'node:util'; - -// Heresy. -import printable_characters from 'printable-characters'; -const {strlen} = printable_characters; - -import {colors, ENABLE_COLOR} from '#cli'; -import {commentaryRegex} from '#wiki-data'; - -import { - cut, - empty, - matchMultiline, - openAggregate, - typeAppearance, - withAggregate, -} from '#sugar'; - -function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); -} - -export function getValidatorCreator(validator) { - return validator[Symbol.for(`hsmusic.validator.creator`)] ?? null; -} - -export function getValidatorCreatorMeta(validator) { - return validator[Symbol.for(`hsmusic.validator.creatorMeta`)] ?? null; -} - -export function setValidatorCreatorMeta(validator, creator, meta) { - validator[Symbol.for(`hsmusic.validator.creator`)] = creator; - validator[Symbol.for(`hsmusic.validator.creatorMeta`)] = meta; - return validator; -} - -// Basic types (primitives) - -export function a(noun) { - return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; -} - -export function validateType(type) { - const fn = value => { - if (typeof value !== type) - throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); - - return true; - }; - - setValidatorCreatorMeta(fn, validateType, {type}); - - return fn; -} - -export const isBoolean = - validateType('boolean'); - -export const isFunction = - validateType('function'); - -export const isNumber = - validateType('number'); - -export const isString = - validateType('string'); - -export const isSymbol = - validateType('symbol'); - -// Use isObject instead, which disallows null. -export const isTypeofObject = - validateType('object'); - -export function isPositive(number) { - isNumber(number); - - if (number <= 0) throw new TypeError(`Expected positive number`); - - return true; -} - -export function isNegative(number) { - isNumber(number); - - if (number >= 0) throw new TypeError(`Expected negative number`); - - return true; -} - -export function isPositiveOrZero(number) { - isNumber(number); - - if (number < 0) throw new TypeError(`Expected positive number or zero`); - - return true; -} - -export function isNegativeOrZero(number) { - isNumber(number); - - if (number > 0) throw new TypeError(`Expected negative number or zero`); - - return true; -} - -export function isInteger(number) { - isNumber(number); - - if (number % 1 !== 0) throw new TypeError(`Expected integer`); - - return true; -} - -export function isCountingNumber(number) { - isInteger(number); - isPositive(number); - - return true; -} - -export function isWholeNumber(number) { - isInteger(number); - isPositiveOrZero(number); - - return true; -} - -export function isStringNonEmpty(value) { - isString(value); - - if (value.trim().length === 0) - throw new TypeError(`Expected non-empty string`); - - return true; -} - -export function optional(validator) { - return value => - value === null || - value === undefined || - validator(value); -} - -// Complex types (non-primitives) - -export function isInstance(value, constructor) { - isObject(value); - - if (!(value instanceof constructor)) - throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`); - - return true; -} - -export function isDate(value) { - isInstance(value, Date); - - if (isNaN(value)) - throw new TypeError(`Expected valid date`); - - return true; -} - -export function isObject(value) { - isTypeofObject(value); - - // 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; -} - -export function isArray(value) { - if (typeof value !== 'object' || value === null || !Array.isArray(value)) - throw new TypeError(`Expected an array, got ${typeAppearance(value)}`); - - return true; -} - -// This one's shaped a bit different from other "is" functions. -// More like validate functions, it returns a function. -export function is(...values) { - if (Array.isArray(values)) { - values = new Set(values); - } - - if (values.size === 1) { - const expected = Array.from(values)[0]; - - return (value) => { - if (value !== expected) { - throw new TypeError(`Expected ${expected}, got ${value}`); - } - - return true; - }; - } - - const fn = (value) => { - if (!values.has(value)) { - throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`); - } - - return true; - }; - - setValidatorCreatorMeta(fn, is, {values}); - - return fn; -} - -function validateArrayItemsHelper(itemValidator) { - return (item, index, array) => { - try { - const value = itemValidator(item, index, array); - - if (value !== true) { - throw new Error(`Expected validator to return true`); - } - } catch (caughtError) { - const indexPart = colors.yellow(`zero-index ${index}`) - const itemPart = inspect(item); - const message = `Error at ${indexPart}: ${itemPart}`; - const error = new Error(message, {cause: caughtError}); - error[Symbol.for('hsmusic.annotateError.indexInSourceArray')] = index; - throw error; - } - }; -} - -export function validateArrayItems(itemValidator) { - const helper = validateArrayItemsHelper(itemValidator); - - return (array) => { - isArray(array); - - withAggregate({message: 'Errors validating array items'}, ({call}) => { - for (let index = 0; index < array.length; index++) { - call(helper, array[index], index, array); - } - }); - - return true; - }; -} - -export function strictArrayOf(itemValidator) { - return validateArrayItems(itemValidator); -} - -export function sparseArrayOf(itemValidator) { - return validateArrayItems((item, index, array) => { - if (item === false || item === null) { - return true; - } - - return itemValidator(item, index, array); - }); -} - -export function looseArrayOf(itemValidator) { - return validateArrayItems((item, index, array) => { - if (item === false || item === null || item === undefined) { - return true; - } - - return itemValidator(item, index, array); - }); -} - -export function validateInstanceOf(constructor) { - const fn = (object) => isInstance(object, constructor); - - setValidatorCreatorMeta(fn, validateInstanceOf, {constructor}); - - return fn; -} - -// Wiki data (primitives & non-primitives) - -export function isColor(color) { - isStringNonEmpty(color); - - if (color.startsWith('#')) { - if (![4, 5, 7, 9].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`); - - return true; - } - - throw new TypeError(`Unknown color format`); -} - -export function isCommentary(commentaryText) { - isContentString(commentaryText); - - const rawMatches = - Array.from(commentaryText.matchAll(commentaryRegex)); - - if (empty(rawMatches)) { - throw new TypeError(`Expected at least one commentary heading`); - } - - const niceMatches = - rawMatches.map(match => ({ - position: match.index, - length: match[0].length, - })); - - validateArrayItems(({position, length}, index) => { - if (index === 0 && position > 0) { - throw new TypeError(`Expected first commentary heading to be at top`); - } - - const ownInput = commentaryText.slice(position, position + length); - const restOfInput = commentaryText.slice(position + length); - const nextLineBreak = restOfInput.indexOf('\n'); - const upToNextLineBreak = restOfInput.slice(0, nextLineBreak); - - if (/\S/.test(upToNextLineBreak)) { - throw new TypeError( - `Expected commentary heading to occupy entire line, got extra text:\n` + - `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + - `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + - `(Check for missing "|-" in YAML, or a misshapen annotation)`); - } - - const nextHeading = - (index === niceMatches.length - 1 - ? commentaryText.length - : niceMatches[index + 1].position); - - const upToNextHeading = - commentaryText.slice(position + length, nextHeading); - - if (!/\S/.test(upToNextHeading)) { - throw new TypeError( - `Expected commentary entry to have body text, only got a heading`); - } - - return true; - })(niceMatches); - - return true; -} - -const isArtistRef = validateReference('artist'); - -export function validateProperties(spec) { - const { - [validateProperties.validateOtherKeys]: validateOtherKeys = null, - [validateProperties.allowOtherKeys]: allowOtherKeys = false, - } = 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`}, ({push}) => { - const testEntries = specEntries.slice(); - - const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key)); - if (validateOtherKeys) { - for (const key of unknownKeys) { - testEntries.push([key, validateOtherKeys]); - } - } - - for (const [specKey, specValidator] of testEntries) { - const value = object[specKey]; - try { - specValidator(value); - } catch (caughtError) { - const keyPart = colors.green(specKey); - const valuePart = inspect(value); - const message = `Error for key ${keyPart}: ${valuePart}`; - push(new Error(message, {cause: caughtError})); - } - } - - if (!validateOtherKeys && !allowOtherKeys && !empty(unknownKeys)) { - push(new Error( - `Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`)); - } - }); - - return true; - }; -} - -validateProperties.validateOtherKeys = Symbol(); -validateProperties.allowOtherKeys = Symbol(); - -export const validateAllPropertyValues = (validator) => - validateProperties({ - [validateProperties.validateOtherKeys]: validator, - }); - -const illeaglInvisibleSpace = { - action: 'delete', -}; - -const illegalVisibleSpace = { - action: 'replace', - with: ' ', - withAnnotation: `normal space`, -}; - -const illegalContentSpec = [ - {illegal: '\u200b', annotation: `zero-width space`, ...illeaglInvisibleSpace}, - {illegal: '\u2005', annotation: `four-per-em space`, ...illegalVisibleSpace}, - {illegal: '\u205f', annotation: `medium mathematical space`, ...illegalVisibleSpace}, - {illegal: '\xa0', annotation: `non-breaking space`, ...illegalVisibleSpace}, -]; - -for (const entry of illegalContentSpec) { - entry.test = string => - string.startsWith(entry.illegal); - - if (entry.action === 'replace') { - entry.enact = string => - string.replaceAll(entry.illegal, entry.with); - } -} - -const illegalContentRegexp = - new RegExp( - illegalContentSpec - .map(entry => entry.illegal) - .map(illegal => `${illegal}+`) - .join('|'), - 'g'); - -const illegalCharactersInContent = - illegalContentSpec - .map(entry => entry.illegal) - .join(''); - -const legalContentNearEndRegexp = - new RegExp(`[^\n${illegalCharactersInContent}]+$`); - -const legalContentNearStartRegexp = - new RegExp(`^[^\n${illegalCharactersInContent}]+`); - -const trimWhitespaceNearBothSidesRegexp = - /^ +| +$/gm; - -const trimWhitespaceNearEndRegexp = - / +$/gm; - -export function isContentString(content) { - isStringNonEmpty(content); - - const mainAggregate = openAggregate({ - message: `Errors validating content string`, - translucent: 'single', - }); - - const illegalAggregate = openAggregate({ - message: `Illegal characters found in content string`, - }); - - for (const {match, where} of matchMultiline(content, illegalContentRegexp)) { - const {annotation, action, ...options} = - illegalContentSpec - .find(entry => entry.test(match[0])); - - const matchStart = match.index; - const matchEnd = match.index + match[0].length; - - const before = - content - .slice(Math.max(0, matchStart - 3), matchStart) - .match(legalContentNearEndRegexp) - ?.[0]; - - const after = - content - .slice(matchEnd, Math.min(content.length, matchEnd + 3)) - .match(legalContentNearStartRegexp) - ?.[0]; - - const beforePart = - before && `"${before}"`; - - const afterPart = - after && `"${after}"`; - - const surroundings = - (before && after - ? `between ${beforePart} and ${afterPart}` - : before - ? `after ${beforePart}` - : after - ? `before ${afterPart}` - : ``); - - const illegalPart = - colors.red( - (annotation - ? `"${match[0]}" (${annotation})` - : `"${match[0]}"`)); - - const replacement = - (action === 'replace' - ? options.enact(match[0]) - : null); - - const replaceWithPart = - (action === 'replace' - ? colors.green( - (options.withAnnotation - ? `"${replacement}" (${options.withAnnotation})` - : `"${replacement}"`)) - : null); - - const actionPart = - (action === `delete` - ? `Delete ${illegalPart}` - : action === 'replace' - ? `Replace ${illegalPart} with ${replaceWithPart}` - : `Matched ${illegalPart}`); - - const parts = [ - actionPart, - surroundings, - `(${where})`, - ].filter(Boolean); - - illegalAggregate.push(new TypeError(parts.join(` `))); - } - - const isMultiline = content.includes('\n'); - - const trimWhitespaceAggregate = openAggregate({ - message: - (isMultiline - ? `Whitespace found at end of line` - : `Whitespace found at start or end`), - }); - - const trimWhitespaceRegexp = - (isMultiline - ? trimWhitespaceNearEndRegexp - : trimWhitespaceNearBothSidesRegexp); - - for ( - const {match, lineNumber, columnNumber, containingLine} of - matchMultiline(content, trimWhitespaceRegexp, { - formatWhere: false, - getContainingLine: true, - }) - ) { - const linePart = - colors.yellow(`line ${lineNumber + 1}`); - - const where = - (match[0].length === containingLine.length - ? `as all of ${linePart}` - : columnNumber === 0 - ? (isMultiline - ? `at start of ${linePart}` - : `at start`) - : (isMultiline - ? `at end of ${linePart}` - : `at end`)); - - const whitespacePart = - colors.red(`"${match[0]}"`); - - const parts = [ - `Matched ${whitespacePart}`, - where, - ]; - - trimWhitespaceAggregate.push(new TypeError(parts.join(` `))); - } - - mainAggregate.call(() => illegalAggregate.close()); - mainAggregate.call(() => trimWhitespaceAggregate.close()); - mainAggregate.close(); - - return true; -} - -export function isThingClass(thingClass) { - isFunction(thingClass); - - if (!Object.hasOwn(thingClass, Symbol.for('Thing.referenceType'))) { - throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`); - } - - return true; -} - -export const isContribution = validateProperties({ - who: isArtistRef, - what: optional(isStringNonEmpty), -}); - -export const isContributionList = validateArrayItems(isContribution); - -export const isAdditionalFile = validateProperties({ - title: isName, - description: optional(isContentString), - files: validateArrayItems(isString), -}); - -export const isAdditionalFileList = validateArrayItems(isAdditionalFile); - -export const isTrackSection = validateProperties({ - name: optional(isName), - color: optional(isColor), - dateOriginallyReleased: optional(isDate), - isDefaultTrackSection: optional(isBoolean), - tracks: optional(validateReferenceList('track')), -}); - -export const isTrackSectionList = validateArrayItems(isTrackSection); - -export function isDimensions(dimensions) { - isArray(dimensions); - - if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`); - - isPositive(dimensions[0]); - isInteger(dimensions[0]); - isPositive(dimensions[1]); - isInteger(dimensions[1]); - - return true; -} - -export function isDirectory(directory) { - isStringNonEmpty(directory); - - if (directory.match(/[^a-zA-Z0-9_-]/)) - throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`); - - return true; -} - -export function isDuration(duration) { - isNumber(duration); - isPositiveOrZero(duration); - - return true; -} - -export function isFileExtension(string) { - isStringNonEmpty(string); - - 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`); - - 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. - - isString(string); - - return true; -} - -export function isName(name) { - return isContentString(name); -} - -export function isURL(string) { - isStringNonEmpty(string); - - new URL(string); - - return true; -} - -export function validateReference(type = 'track') { - return (ref) => { - isStringNonEmpty(ref); - - const match = ref - .trim() - .match(/^(?:(?\S+):(?=\S))?(?.+)(? { - const subcache = validateWikiData_cache[referenceType][allowMixedTypes]; - if (subcache.has(array)) return subcache.get(array); - - let OK = false; - - try { - isArrayOfObjects(array); - - if (empty(array)) { - OK = true; return true; - } - - const allRefTypes = new Set(); - - let foundThing = false; - let foundOtherObject = false; - - for (const object of array) { - const {[Symbol.for('Thing.referenceType')]: referenceType} = object.constructor; - - if (referenceType === undefined) { - foundOtherObject = true; - - // Early-exit if a Thing has been found - nothing more can be learned. - if (foundThing) { - throw new TypeError(`Expected array of wiki data objects, got mixed items`); - } - } else { - foundThing = true; - - // Early-exit if a non-Thing object has been found - nothing more can - // be learned. - if (foundOtherObject) { - throw new TypeError(`Expected array of wiki data objects, got mixed items`); - } - - allRefTypes.add(referenceType); - } - } - - if (foundOtherObject && !foundThing) { - throw new TypeError(`Expected array of wiki data objects, got array of other objects`); - } - - if (allRefTypes.size > 1) { - if (allowMixedTypes) { - OK = true; return true; - } - - const types = () => Array.from(allRefTypes).join(', '); - - if (referenceType) { - if (allRefTypes.has(referenceType)) { - allRefTypes.remove(referenceType); - throw new TypeError(`Expected array of only ${referenceType}, also got other types: ${types()}`) - } else { - throw new TypeError(`Expected array of only ${referenceType}, got other types: ${types()}`); - } - } - - throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`); - } - - const onlyRefType = Array.from(allRefTypes)[0]; - - if (referenceType && onlyRefType !== referenceType) { - throw new TypeError(`Expected array of ${referenceType}, got array of ${onlyRefType}`) - } - - OK = true; return true; - } finally { - subcache.set(array, OK); - } - }; -} - -export const isAdditionalName = validateProperties({ - name: isName, - annotation: optional(isContentString), - - // TODO: This only allows indicating sourcing from a track. - // That's okay for the current limited use of "from", but - // could be expanded later. - from: - // Double TODO: Explicitly allowing both references and - // live objects to co-exist is definitely weird, and - // altogether questions the way we define validators... - optional(anyOf( - validateReferenceList('track'), - validateWikiData({referenceType: 'track'}))), -}); - -export const isAdditionalNameList = validateArrayItems(isAdditionalName); - -// Compositional utilities - -export function anyOf(...validators) { - const validConstants = new Set(); - const validConstructors = new Set(); - const validTypes = new Set(); - - const constantValidators = []; - const constructorValidators = []; - const typeValidators = []; - - const leftoverValidators = []; - - for (const validator of validators) { - const creator = getValidatorCreator(validator); - const creatorMeta = getValidatorCreatorMeta(validator); - - switch (creator) { - case is: - for (const value of creatorMeta.values) { - validConstants.add(value); - } - - constantValidators.push(validator); - break; - - case validateInstanceOf: - validConstructors.add(creatorMeta.constructor); - constructorValidators.push(validator); - break; - - case validateType: - validTypes.add(creatorMeta.type); - typeValidators.push(validator); - break; - - default: - leftoverValidators.push(validator); - break; - } - } - - return (value) => { - const errorInfo = []; - - if (validConstants.has(value)) { - return true; - } - - if (!empty(validTypes)) { - if (validTypes.has(typeof value)) { - return true; - } - } - - for (const constructor of validConstructors) { - if (value instanceof constructor) { - return true; - } - } - - for (const [i, validator] of leftoverValidators.entries()) { - try { - const result = validator(value); - - if (result !== true) { - throw new Error(`Check returned false`); - } - - return true; - } catch (error) { - errorInfo.push([validator, i, error]); - } - } - - // Don't process error messages until every validator has failed. - - const errors = []; - const prefaceErrorInfo = []; - - let offset = 0; - - if (!empty(validConstants)) { - const constants = - Array.from(validConstants); - - const gotPart = `, got ${value}`; - - prefaceErrorInfo.push([ - constantValidators, - offset++, - new TypeError( - `Expected any of ${constants.join(' ')}` + gotPart), - ]); - } - - if (!empty(validTypes)) { - const types = - Array.from(validTypes); - - const gotType = typeAppearance(value); - const gotPart = `, got ${gotType}`; - - prefaceErrorInfo.push([ - typeValidators, - offset++, - new TypeError( - `Expected any of ${types.join(', ')}` + gotPart), - ]); - } - - if (!empty(validConstructors)) { - const names = - Array.from(validConstructors) - .map(constructor => constructor.name); - - const gotName = value?.constructor?.name; - const gotPart = (gotName ? `, got ${gotName}` : ``); - - prefaceErrorInfo.push([ - constructorValidators, - offset++, - new TypeError( - `Expected any of ${names.join(', ')}` + gotPart), - ]); - } - - for (const info of errorInfo) { - info[1] += offset; - } - - for (const [validator, i, error] of prefaceErrorInfo.concat(errorInfo)) { - error.message = - (validator?.name - ? `${i + 1}. "${validator.name}": ${error.message}` - : `${i + 1}. ${error.message}`); - - error.check = - (Array.isArray(validator) && validator.length === 1 - ? validator[0] - : validator); - - errors.push(error); - } - - const total = offset + leftoverValidators.length; - throw new AggregateError(errors, - `Expected any of ${total} possible checks, ` + - `but none were true`); - }; -} diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 80793550..fd6c239c 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,16 +1,10 @@ import {input} from '#composite'; import find from '#find'; +import Thing from '#thing'; import {isColor, isLanguageCode, isName, isURL} from '#validators'; -import { - contentString, - flag, - name, - referenceList, - wikiData, -} from '#composite/wiki-properties'; - -import Thing from './thing.js'; +import {contentString, flag, name, referenceList, wikiData} + from '#composite/wiki-properties'; export class WikiInfo extends Thing { static [Thing.friendlyName] = `Wiki Info`; @@ -76,20 +70,20 @@ export class WikiInfo extends Thing { }); static [Thing.yamlDocumentSpec] = { - propertyFieldMapping: { - name: 'Name', - nameShort: 'Short Name', - color: 'Color', - description: 'Description', - footerContent: 'Footer Content', - defaultLanguage: 'Default Language', - canonicalBase: 'Canonical Base', - divideTrackListsByGroups: 'Divide Track Lists By Groups', - enableFlashesAndGames: 'Enable Flashes & Games', - enableListings: 'Enable Listings', - enableNews: 'Enable News', - enableArtTagUI: 'Enable Art Tag UI', - enableGroupUI: 'Enable Group UI', + fields: { + 'Name': {property: 'name'}, + 'Short Name': {property: 'nameShort'}, + 'Color': {property: 'color'}, + 'Description': {property: 'description'}, + 'Footer Content': {property: 'footerContent'}, + 'Default Language': {property: 'defaultLanguage'}, + 'Canonical Base': {property: 'canonicalBase'}, + 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + 'Enable Flashes & Games': {property: 'enableFlashesAndGames'}, + 'Enable Listings': {property: 'enableListings'}, + 'Enable News': {property: 'enableNews'}, + 'Enable Art Tag UI': {property: 'enableArtTagUI'}, + 'Enable Group UI': {property: 'enableGroupUI'}, }, }; } diff --git a/src/data/validators.js b/src/data/validators.js new file mode 100644 index 00000000..efe76fe0 --- /dev/null +++ b/src/data/validators.js @@ -0,0 +1,984 @@ +import {inspect as nodeInspect} from 'node:util'; + +// Heresy. +import printable_characters from 'printable-characters'; +const {strlen} = printable_characters; + +import {colors, ENABLE_COLOR} from '#cli'; +import {commentaryRegex} from '#wiki-data'; + +import { + cut, + empty, + matchMultiline, + openAggregate, + typeAppearance, + withAggregate, +} from '#sugar'; + +function inspect(value) { + return nodeInspect(value, {colors: ENABLE_COLOR}); +} + +export function getValidatorCreator(validator) { + return validator[Symbol.for(`hsmusic.validator.creator`)] ?? null; +} + +export function getValidatorCreatorMeta(validator) { + return validator[Symbol.for(`hsmusic.validator.creatorMeta`)] ?? null; +} + +export function setValidatorCreatorMeta(validator, creator, meta) { + validator[Symbol.for(`hsmusic.validator.creator`)] = creator; + validator[Symbol.for(`hsmusic.validator.creatorMeta`)] = meta; + return validator; +} + +// Basic types (primitives) + +export function a(noun) { + return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; +} + +export function validateType(type) { + const fn = value => { + if (typeof value !== type) + throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); + + return true; + }; + + setValidatorCreatorMeta(fn, validateType, {type}); + + return fn; +} + +export const isBoolean = + validateType('boolean'); + +export const isFunction = + validateType('function'); + +export const isNumber = + validateType('number'); + +export const isString = + validateType('string'); + +export const isSymbol = + validateType('symbol'); + +// Use isObject instead, which disallows null. +export const isTypeofObject = + validateType('object'); + +export function isPositive(number) { + isNumber(number); + + if (number <= 0) throw new TypeError(`Expected positive number`); + + return true; +} + +export function isNegative(number) { + isNumber(number); + + if (number >= 0) throw new TypeError(`Expected negative number`); + + return true; +} + +export function isPositiveOrZero(number) { + isNumber(number); + + if (number < 0) throw new TypeError(`Expected positive number or zero`); + + return true; +} + +export function isNegativeOrZero(number) { + isNumber(number); + + if (number > 0) throw new TypeError(`Expected negative number or zero`); + + return true; +} + +export function isInteger(number) { + isNumber(number); + + if (number % 1 !== 0) throw new TypeError(`Expected integer`); + + return true; +} + +export function isCountingNumber(number) { + isInteger(number); + isPositive(number); + + return true; +} + +export function isWholeNumber(number) { + isInteger(number); + isPositiveOrZero(number); + + return true; +} + +export function isStringNonEmpty(value) { + isString(value); + + if (value.trim().length === 0) + throw new TypeError(`Expected non-empty string`); + + return true; +} + +export function optional(validator) { + return value => + value === null || + value === undefined || + validator(value); +} + +// Complex types (non-primitives) + +export function isInstance(value, constructor) { + isObject(value); + + if (!(value instanceof constructor)) + throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`); + + return true; +} + +export function isDate(value) { + isInstance(value, Date); + + if (isNaN(value)) + throw new TypeError(`Expected valid date`); + + return true; +} + +export function isObject(value) { + isTypeofObject(value); + + // 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; +} + +export function isArray(value) { + if (typeof value !== 'object' || value === null || !Array.isArray(value)) + throw new TypeError(`Expected an array, got ${typeAppearance(value)}`); + + return true; +} + +// This one's shaped a bit different from other "is" functions. +// More like validate functions, it returns a function. +export function is(...values) { + if (Array.isArray(values)) { + values = new Set(values); + } + + if (values.size === 1) { + const expected = Array.from(values)[0]; + + return (value) => { + if (value !== expected) { + throw new TypeError(`Expected ${expected}, got ${value}`); + } + + return true; + }; + } + + const fn = (value) => { + if (!values.has(value)) { + throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`); + } + + return true; + }; + + setValidatorCreatorMeta(fn, is, {values}); + + return fn; +} + +function validateArrayItemsHelper(itemValidator) { + return (item, index, array) => { + try { + const value = itemValidator(item, index, array); + + if (value !== true) { + throw new Error(`Expected validator to return true`); + } + } catch (caughtError) { + const indexPart = colors.yellow(`zero-index ${index}`) + const itemPart = inspect(item); + const message = `Error at ${indexPart}: ${itemPart}`; + const error = new Error(message, {cause: caughtError}); + error[Symbol.for('hsmusic.annotateError.indexInSourceArray')] = index; + throw error; + } + }; +} + +export function validateArrayItems(itemValidator) { + const helper = validateArrayItemsHelper(itemValidator); + + return (array) => { + isArray(array); + + withAggregate({message: 'Errors validating array items'}, ({call}) => { + for (let index = 0; index < array.length; index++) { + call(helper, array[index], index, array); + } + }); + + return true; + }; +} + +export function strictArrayOf(itemValidator) { + return validateArrayItems(itemValidator); +} + +export function sparseArrayOf(itemValidator) { + return validateArrayItems((item, index, array) => { + if (item === false || item === null) { + return true; + } + + return itemValidator(item, index, array); + }); +} + +export function looseArrayOf(itemValidator) { + return validateArrayItems((item, index, array) => { + if (item === false || item === null || item === undefined) { + return true; + } + + return itemValidator(item, index, array); + }); +} + +export function validateInstanceOf(constructor) { + const fn = (object) => isInstance(object, constructor); + + setValidatorCreatorMeta(fn, validateInstanceOf, {constructor}); + + return fn; +} + +// Wiki data (primitives & non-primitives) + +export function isColor(color) { + isStringNonEmpty(color); + + if (color.startsWith('#')) { + if (![4, 5, 7, 9].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`); + + return true; + } + + throw new TypeError(`Unknown color format`); +} + +export function isCommentary(commentaryText) { + isContentString(commentaryText); + + const rawMatches = + Array.from(commentaryText.matchAll(commentaryRegex)); + + if (empty(rawMatches)) { + throw new TypeError(`Expected at least one commentary heading`); + } + + const niceMatches = + rawMatches.map(match => ({ + position: match.index, + length: match[0].length, + })); + + validateArrayItems(({position, length}, index) => { + if (index === 0 && position > 0) { + throw new TypeError(`Expected first commentary heading to be at top`); + } + + const ownInput = commentaryText.slice(position, position + length); + const restOfInput = commentaryText.slice(position + length); + const nextLineBreak = restOfInput.indexOf('\n'); + const upToNextLineBreak = restOfInput.slice(0, nextLineBreak); + + if (/\S/.test(upToNextLineBreak)) { + throw new TypeError( + `Expected commentary heading to occupy entire line, got extra text:\n` + + `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + + `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + + `(Check for missing "|-" in YAML, or a misshapen annotation)`); + } + + const nextHeading = + (index === niceMatches.length - 1 + ? commentaryText.length + : niceMatches[index + 1].position); + + const upToNextHeading = + commentaryText.slice(position + length, nextHeading); + + if (!/\S/.test(upToNextHeading)) { + throw new TypeError( + `Expected commentary entry to have body text, only got a heading`); + } + + return true; + })(niceMatches); + + return true; +} + +const isArtistRef = validateReference('artist'); + +export function validateProperties(spec) { + const { + [validateProperties.validateOtherKeys]: validateOtherKeys = null, + [validateProperties.allowOtherKeys]: allowOtherKeys = false, + } = 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`}, ({push}) => { + const testEntries = specEntries.slice(); + + const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key)); + if (validateOtherKeys) { + for (const key of unknownKeys) { + testEntries.push([key, validateOtherKeys]); + } + } + + for (const [specKey, specValidator] of testEntries) { + const value = object[specKey]; + try { + specValidator(value); + } catch (caughtError) { + const keyPart = colors.green(specKey); + const valuePart = inspect(value); + const message = `Error for key ${keyPart}: ${valuePart}`; + push(new Error(message, {cause: caughtError})); + } + } + + if (!validateOtherKeys && !allowOtherKeys && !empty(unknownKeys)) { + push(new Error( + `Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`)); + } + }); + + return true; + }; +} + +validateProperties.validateOtherKeys = Symbol(); +validateProperties.allowOtherKeys = Symbol(); + +export const validateAllPropertyValues = (validator) => + validateProperties({ + [validateProperties.validateOtherKeys]: validator, + }); + +const illeaglInvisibleSpace = { + action: 'delete', +}; + +const illegalVisibleSpace = { + action: 'replace', + with: ' ', + withAnnotation: `normal space`, +}; + +const illegalContentSpec = [ + {illegal: '\u200b', annotation: `zero-width space`, ...illeaglInvisibleSpace}, + {illegal: '\u2005', annotation: `four-per-em space`, ...illegalVisibleSpace}, + {illegal: '\u205f', annotation: `medium mathematical space`, ...illegalVisibleSpace}, + {illegal: '\xa0', annotation: `non-breaking space`, ...illegalVisibleSpace}, +]; + +for (const entry of illegalContentSpec) { + entry.test = string => + string.startsWith(entry.illegal); + + if (entry.action === 'replace') { + entry.enact = string => + string.replaceAll(entry.illegal, entry.with); + } +} + +const illegalContentRegexp = + new RegExp( + illegalContentSpec + .map(entry => entry.illegal) + .map(illegal => `${illegal}+`) + .join('|'), + 'g'); + +const illegalCharactersInContent = + illegalContentSpec + .map(entry => entry.illegal) + .join(''); + +const legalContentNearEndRegexp = + new RegExp(`[^\n${illegalCharactersInContent}]+$`); + +const legalContentNearStartRegexp = + new RegExp(`^[^\n${illegalCharactersInContent}]+`); + +const trimWhitespaceNearBothSidesRegexp = + /^ +| +$/gm; + +const trimWhitespaceNearEndRegexp = + / +$/gm; + +export function isContentString(content) { + isStringNonEmpty(content); + + const mainAggregate = openAggregate({ + message: `Errors validating content string`, + translucent: 'single', + }); + + const illegalAggregate = openAggregate({ + message: `Illegal characters found in content string`, + }); + + for (const {match, where} of matchMultiline(content, illegalContentRegexp)) { + const {annotation, action, ...options} = + illegalContentSpec + .find(entry => entry.test(match[0])); + + const matchStart = match.index; + const matchEnd = match.index + match[0].length; + + const before = + content + .slice(Math.max(0, matchStart - 3), matchStart) + .match(legalContentNearEndRegexp) + ?.[0]; + + const after = + content + .slice(matchEnd, Math.min(content.length, matchEnd + 3)) + .match(legalContentNearStartRegexp) + ?.[0]; + + const beforePart = + before && `"${before}"`; + + const afterPart = + after && `"${after}"`; + + const surroundings = + (before && after + ? `between ${beforePart} and ${afterPart}` + : before + ? `after ${beforePart}` + : after + ? `before ${afterPart}` + : ``); + + const illegalPart = + colors.red( + (annotation + ? `"${match[0]}" (${annotation})` + : `"${match[0]}"`)); + + const replacement = + (action === 'replace' + ? options.enact(match[0]) + : null); + + const replaceWithPart = + (action === 'replace' + ? colors.green( + (options.withAnnotation + ? `"${replacement}" (${options.withAnnotation})` + : `"${replacement}"`)) + : null); + + const actionPart = + (action === `delete` + ? `Delete ${illegalPart}` + : action === 'replace' + ? `Replace ${illegalPart} with ${replaceWithPart}` + : `Matched ${illegalPart}`); + + const parts = [ + actionPart, + surroundings, + `(${where})`, + ].filter(Boolean); + + illegalAggregate.push(new TypeError(parts.join(` `))); + } + + const isMultiline = content.includes('\n'); + + const trimWhitespaceAggregate = openAggregate({ + message: + (isMultiline + ? `Whitespace found at end of line` + : `Whitespace found at start or end`), + }); + + const trimWhitespaceRegexp = + (isMultiline + ? trimWhitespaceNearEndRegexp + : trimWhitespaceNearBothSidesRegexp); + + for ( + const {match, lineNumber, columnNumber, containingLine} of + matchMultiline(content, trimWhitespaceRegexp, { + formatWhere: false, + getContainingLine: true, + }) + ) { + const linePart = + colors.yellow(`line ${lineNumber + 1}`); + + const where = + (match[0].length === containingLine.length + ? `as all of ${linePart}` + : columnNumber === 0 + ? (isMultiline + ? `at start of ${linePart}` + : `at start`) + : (isMultiline + ? `at end of ${linePart}` + : `at end`)); + + const whitespacePart = + colors.red(`"${match[0]}"`); + + const parts = [ + `Matched ${whitespacePart}`, + where, + ]; + + trimWhitespaceAggregate.push(new TypeError(parts.join(` `))); + } + + mainAggregate.call(() => illegalAggregate.close()); + mainAggregate.call(() => trimWhitespaceAggregate.close()); + mainAggregate.close(); + + return true; +} + +export function isThingClass(thingClass) { + isFunction(thingClass); + + if (!Object.hasOwn(thingClass, Symbol.for('Thing.referenceType'))) { + throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`); + } + + return true; +} + +export const isContribution = validateProperties({ + who: isArtistRef, + what: optional(isStringNonEmpty), +}); + +export const isContributionList = validateArrayItems(isContribution); + +export const isAdditionalFile = validateProperties({ + title: isName, + description: optional(isContentString), + files: validateArrayItems(isString), +}); + +export const isAdditionalFileList = validateArrayItems(isAdditionalFile); + +export const isTrackSection = validateProperties({ + name: optional(isName), + color: optional(isColor), + dateOriginallyReleased: optional(isDate), + isDefaultTrackSection: optional(isBoolean), + tracks: optional(validateReferenceList('track')), +}); + +export const isTrackSectionList = validateArrayItems(isTrackSection); + +export function isDimensions(dimensions) { + isArray(dimensions); + + if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`); + + isPositive(dimensions[0]); + isInteger(dimensions[0]); + isPositive(dimensions[1]); + isInteger(dimensions[1]); + + return true; +} + +export function isDirectory(directory) { + isStringNonEmpty(directory); + + if (directory.match(/[^a-zA-Z0-9_-]/)) + throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`); + + return true; +} + +export function isDuration(duration) { + isNumber(duration); + isPositiveOrZero(duration); + + return true; +} + +export function isFileExtension(string) { + isStringNonEmpty(string); + + 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`); + + 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. + + isString(string); + + return true; +} + +export function isName(name) { + return isContentString(name); +} + +export function isURL(string) { + isStringNonEmpty(string); + + new URL(string); + + return true; +} + +export function validateReference(type = 'track') { + return (ref) => { + isStringNonEmpty(ref); + + const match = ref + .trim() + .match(/^(?:(?\S+):(?=\S))?(?.+)(? { + const subcache = validateWikiData_cache[referenceType][allowMixedTypes]; + if (subcache.has(array)) return subcache.get(array); + + let OK = false; + + try { + isArrayOfObjects(array); + + if (empty(array)) { + OK = true; return true; + } + + const allRefTypes = new Set(); + + let foundThing = false; + let foundOtherObject = false; + + for (const object of array) { + const {[Symbol.for('Thing.referenceType')]: referenceType} = object.constructor; + + if (referenceType === undefined) { + foundOtherObject = true; + + // Early-exit if a Thing has been found - nothing more can be learned. + if (foundThing) { + throw new TypeError(`Expected array of wiki data objects, got mixed items`); + } + } else { + foundThing = true; + + // Early-exit if a non-Thing object has been found - nothing more can + // be learned. + if (foundOtherObject) { + throw new TypeError(`Expected array of wiki data objects, got mixed items`); + } + + allRefTypes.add(referenceType); + } + } + + if (foundOtherObject && !foundThing) { + throw new TypeError(`Expected array of wiki data objects, got array of other objects`); + } + + if (allRefTypes.size > 1) { + if (allowMixedTypes) { + OK = true; return true; + } + + const types = () => Array.from(allRefTypes).join(', '); + + if (referenceType) { + if (allRefTypes.has(referenceType)) { + allRefTypes.remove(referenceType); + throw new TypeError(`Expected array of only ${referenceType}, also got other types: ${types()}`) + } else { + throw new TypeError(`Expected array of only ${referenceType}, got other types: ${types()}`); + } + } + + throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`); + } + + const onlyRefType = Array.from(allRefTypes)[0]; + + if (referenceType && onlyRefType !== referenceType) { + throw new TypeError(`Expected array of ${referenceType}, got array of ${onlyRefType}`) + } + + OK = true; return true; + } finally { + subcache.set(array, OK); + } + }; +} + +export const isAdditionalName = validateProperties({ + name: isName, + annotation: optional(isContentString), + + // TODO: This only allows indicating sourcing from a track. + // That's okay for the current limited use of "from", but + // could be expanded later. + from: + // Double TODO: Explicitly allowing both references and + // live objects to co-exist is definitely weird, and + // altogether questions the way we define validators... + optional(anyOf( + validateReferenceList('track'), + validateWikiData({referenceType: 'track'}))), +}); + +export const isAdditionalNameList = validateArrayItems(isAdditionalName); + +// Compositional utilities + +export function anyOf(...validators) { + const validConstants = new Set(); + const validConstructors = new Set(); + const validTypes = new Set(); + + const constantValidators = []; + const constructorValidators = []; + const typeValidators = []; + + const leftoverValidators = []; + + for (const validator of validators) { + const creator = getValidatorCreator(validator); + const creatorMeta = getValidatorCreatorMeta(validator); + + switch (creator) { + case is: + for (const value of creatorMeta.values) { + validConstants.add(value); + } + + constantValidators.push(validator); + break; + + case validateInstanceOf: + validConstructors.add(creatorMeta.constructor); + constructorValidators.push(validator); + break; + + case validateType: + validTypes.add(creatorMeta.type); + typeValidators.push(validator); + break; + + default: + leftoverValidators.push(validator); + break; + } + } + + return (value) => { + const errorInfo = []; + + if (validConstants.has(value)) { + return true; + } + + if (!empty(validTypes)) { + if (validTypes.has(typeof value)) { + return true; + } + } + + for (const constructor of validConstructors) { + if (value instanceof constructor) { + return true; + } + } + + for (const [i, validator] of leftoverValidators.entries()) { + try { + const result = validator(value); + + if (result !== true) { + throw new Error(`Check returned false`); + } + + return true; + } catch (error) { + errorInfo.push([validator, i, error]); + } + } + + // Don't process error messages until every validator has failed. + + const errors = []; + const prefaceErrorInfo = []; + + let offset = 0; + + if (!empty(validConstants)) { + const constants = + Array.from(validConstants); + + const gotPart = `, got ${value}`; + + prefaceErrorInfo.push([ + constantValidators, + offset++, + new TypeError( + `Expected any of ${constants.join(' ')}` + gotPart), + ]); + } + + if (!empty(validTypes)) { + const types = + Array.from(validTypes); + + const gotType = typeAppearance(value); + const gotPart = `, got ${gotType}`; + + prefaceErrorInfo.push([ + typeValidators, + offset++, + new TypeError( + `Expected any of ${types.join(', ')}` + gotPart), + ]); + } + + if (!empty(validConstructors)) { + const names = + Array.from(validConstructors) + .map(constructor => constructor.name); + + const gotName = value?.constructor?.name; + const gotPart = (gotName ? `, got ${gotName}` : ``); + + prefaceErrorInfo.push([ + constructorValidators, + offset++, + new TypeError( + `Expected any of ${names.join(', ')}` + gotPart), + ]); + } + + for (const info of errorInfo) { + info[1] += offset; + } + + for (const [validator, i, error] of prefaceErrorInfo.concat(errorInfo)) { + error.message = + (validator?.name + ? `${i + 1}. "${validator.name}": ${error.message}` + : `${i + 1}. ${error.message}`); + + error.check = + (Array.isArray(validator) && validator.length === 1 + ? validator[0] + : validator); + + errors.push(error); + } + + const total = offset + leftoverValidators.length; + throw new AggregateError(errors, + `Expected any of ${total} possible checks, ` + + `but none were true`); + }; +} diff --git a/src/data/yaml.js b/src/data/yaml.js index a232970b..19f56292 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -12,8 +12,8 @@ import CacheableObject, {CacheableObjectPropertyValueError} import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; import find, {bindFind} from '#find'; import {traverse} from '#node-utils'; - -import T, {Thing} from '#things'; +import Thing from '#thing'; +import T from '#things'; import { annotateErrorWithFile, @@ -28,6 +28,7 @@ import { showAggregate, typeAppearance, withAggregate, + withEntries, } from '#sugar'; import { @@ -65,76 +66,68 @@ export const DATA_STATIC_PAGE_DIRECTORY = 'static-page'; // 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( - thingConstructor, - { - // 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 - // expects. - // - // Each key and value are a field name (not an update() property) and a - // function which takes the value for that field and returns the value which - // will be passed on to update(). - // - fieldTransformations = {}, - - // Mapping of Thing.update() source properties to field names. - // - // Note this is property -> field, not field -> property. This is a - // shorthand convenience because properties are generally typical - // camel-cased JS properties, while fields may contain whitespace and be - // more easily represented as quoted strings. - // - propertyFieldMapping, - - // Completely ignored fields. These won't throw an unknown field error if - // 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 = [], - - // List of fields which are invalid when coexisting in a document. - // Data objects are generally allowing with regards to what properties go - // together, allowing for properties to be set separately from each other - // instead of complaining about invalid or unused-data cases. But it's - // useful to see these kinds of errors when actually validating YAML files! - // - // Each item of this array should itself be an object with a descriptive - // message and a list of fields. Of those fields, none should ever coexist - // with any other. For example: - // - // [ - // {message: '...', fields: ['A', 'B', 'C']}, - // {message: '...', fields: ['C', 'D']}, - // ] - // - // ...means A can't coexist with B or C, B can't coexist with A or C, and - // C can't coexist iwth A, B, or D - but it's okay for D to coexist with - // A or B. - // - invalidFieldCombinations = [], - } -) { +// +function makeProcessDocument(thingConstructor, { + // The bulk of configuration happens here in the spec's `fields` property. + // Each key is a field that's expected on the source document; fields that + // don't match one of these keys will cause an error. Values are object + // entries describing what to do with the field. + // + // A field entry's `property` tells what property the value for this field + // will be put into, on the respective Thing (subclass) instance. + // + // A field entry's `transform` optionally allows converting the raw value in + // YAML into some other format before providing setting it on the Thing + // instance. + // + fields: fieldSpecs = {}, + + // Completely ignored fields. These won't throw an unknown field error if + // 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 = [], + + // List of fields which are invalid when coexisting in a document. + // Data objects are generally allowing with regards to what properties go + // together, allowing for properties to be set separately from each other + // instead of complaining about invalid or unused-data cases. But it's + // useful to see these kinds of errors when actually validating YAML files! + // + // Each item of this array should itself be an object with a descriptive + // message and a list of fields. Of those fields, none should ever coexist + // with any other. For example: + // + // [ + // {message: '...', fields: ['A', 'B', 'C']}, + // {message: '...', fields: ['C', 'D']}, + // ] + // + // ...means A can't coexist with B or C, B can't coexist with A or C, and + // C can't coexist iwth A, B, or D - but it's okay for D to coexist with + // A or B. + // + invalidFieldCombinations = [], +}) { if (!thingConstructor) { throw new Error(`Missing Thing class`); } - if (!propertyFieldMapping) { - throw new Error(`Expected propertyFieldMapping to be provided`); + if (!fieldSpecs) { + throw new Error(`Expected fields to be provided`); } - const knownFields = Object.values(propertyFieldMapping); + const knownFields = Object.keys(fieldSpecs); - // 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 propertyToField = + withEntries(fieldSpecs, entries => entries + .map(([field, {property}]) => [property, field])); + // TODO: Is this function even necessary?? + // Aren't we doing basically the same work in the function it's decorating??? const decorateErrorWithName = (fn) => { - const nameField = propertyFieldMapping['name']; + const nameField = propertyToField.name; if (!nameField) return fn; return (document) => { @@ -151,7 +144,7 @@ function makeProcessDocument( }; return decorateErrorWithName((document) => { - const nameField = propertyFieldMapping['name']; + const nameField = propertyToField.name; const namePart = (nameField ? (document[nameField] @@ -192,7 +185,8 @@ function makeProcessDocument( const fieldCombinationErrors = []; for (const {message, fields} of invalidFieldCombinations) { - const fieldsPresent = presentFields.filter(field => fields.includes(field)); + const fieldsPresent = + presentFields.filter(field => fields.includes(field)); if (fieldsPresent.length >= 2) { const filteredDocument = @@ -201,7 +195,8 @@ function makeProcessDocument( fieldsPresent, {preserveOriginalOrder: true}); - fieldCombinationErrors.push(new FieldCombinationError(filteredDocument, message)); + fieldCombinationErrors.push( + new FieldCombinationError(filteredDocument, message)); for (const field of Object.keys(filteredDocument)) { skippedFields.add(field); @@ -220,8 +215,8 @@ function makeProcessDocument( // This variable would like to certify itself as "not into capitalism". let propertyValue = - (Object.hasOwn(fieldTransformations, field) - ? fieldTransformations[field](documentValue) + (fieldSpecs[field].transform + ? fieldSpecs[field].transform(documentValue) : documentValue); // Completely blank items in a YAML list are read as null. @@ -247,19 +242,13 @@ function makeProcessDocument( fieldValues[field] = propertyValue; } - const sourceProperties = {}; - - for (const [field, value] of Object.entries(fieldValues)) { - const property = fieldPropertyMapping[field]; - sourceProperties[property] = value; - } - const thing = Reflect.construct(thingConstructor, []); const fieldValueErrors = []; - for (const [property, value] of Object.entries(sourceProperties)) { - const field = propertyFieldMapping[property]; + for (const [field, value] of Object.entries(fieldValues)) { + const {property} = fieldSpecs[field]; + try { thing[property] = value; } catch (caughtError) { @@ -382,6 +371,10 @@ export class SkippedFieldsSummaryError extends Error { // --> Utilities shared across document parsing functions +export function parseDate(date) { + return new Date(date); +} + export function parseDuration(string) { if (typeof string !== 'string') { return string; @@ -779,7 +772,7 @@ export const getDataSteps = () => [ case 'albums': return T.HomepageLayoutAlbumsRow; default: - throw new TypeError(`No processDocument function for row type ${type}!`); + throw new TypeError(`No processDocument function for row type ${document['Type']}!`); } }, @@ -1574,9 +1567,12 @@ export function filterReferenceErrors(wikiData) { return false; }, fn); + const {fields} = thing.constructor[Thing.yamlDocumentSpec]; + const field = - thing.constructor[Thing.yamlDocumentSpec] - .propertyFieldMapping[property]; + Object.entries(fields ?? {}) + .find(([field, fieldSpec]) => fieldSpec.property === property) + ?.[0]; const fieldPropertyMessage = (field -- cgit 1.3.0-6-gf8a5