diff options
author | (quasar) nebula <qznebula@protonmail.com> | 2023-10-29 09:26:59 -0300 |
---|---|---|
committer | (quasar) nebula <qznebula@protonmail.com> | 2023-10-29 09:26:59 -0300 |
commit | bfa1953e79a562ee675940b7acc52b5b29d22d8f (patch) | |
tree | 5c1cd2f4050c801a60f4b65b367a714ed0979759 /src/data | |
parent | c4ef4ced62d659d217873c6c48dd8038dbf765af (diff) | |
parent | 940b2cbf8b68eb0b446cca0feeb507840c486394 (diff) |
Merge branch 'preview' into listing-tweaks
Diffstat (limited to 'src/data')
82 files changed, 5378 insertions, 1527 deletions
diff --git a/src/data/composite/control-flow/exitWithoutDependency.js b/src/data/composite/control-flow/exitWithoutDependency.js new file mode 100644 index 00000000..c660a7ef --- /dev/null +++ b/src/data/composite/control-flow/exitWithoutDependency.js @@ -0,0 +1,35 @@ +// Early exits if a dependency isn't available. +// See withResultOfAvailabilityCheck for {mode} options. + +import {input, templateCompositeFrom} from '#composite'; + +import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js'; +import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js'; + +export default templateCompositeFrom({ + annotation: `exitWithoutDependency`, + + inputs: { + dependency: input({acceptsNull: true}), + mode: inputAvailabilityCheckMode(), + value: input({defaultValue: null}), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('value')], + compute: (continuation, { + ['#availability']: availability, + [input('value')]: value, + }) => + (availability + ? continuation() + : continuation.exit(value)), + }, + ], +}); diff --git a/src/data/composite/control-flow/exitWithoutUpdateValue.js b/src/data/composite/control-flow/exitWithoutUpdateValue.js new file mode 100644 index 00000000..244b3233 --- /dev/null +++ b/src/data/composite/control-flow/exitWithoutUpdateValue.js @@ -0,0 +1,24 @@ +// Early exits if this property's update value isn't available. +// See withResultOfAvailabilityCheck for {mode} options. + +import {input, templateCompositeFrom} from '#composite'; + +import exitWithoutDependency from './exitWithoutDependency.js'; +import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js'; + +export default templateCompositeFrom({ + annotation: `exitWithoutUpdateValue`, + + inputs: { + mode: inputAvailabilityCheckMode(), + value: input({defaultValue: null}), + }, + + steps: () => [ + exitWithoutDependency({ + dependency: input.updateValue(), + mode: input('mode'), + value: input('value'), + }), + ], +}); diff --git a/src/data/composite/control-flow/exposeConstant.js b/src/data/composite/control-flow/exposeConstant.js new file mode 100644 index 00000000..e0435478 --- /dev/null +++ b/src/data/composite/control-flow/exposeConstant.js @@ -0,0 +1,26 @@ +// Exposes a constant value exactly as it is; like exposeDependency, this +// is typically the base of a composition serving as a particular property +// descriptor. It generally follows steps which will conditionally early +// exit with some other value, with the exposeConstant base serving as the +// fallback default value. + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `exposeConstant`, + + compose: false, + + inputs: { + value: input.staticValue(), + }, + + steps: () => [ + { + dependencies: [input('value')], + compute: ({ + [input('value')]: value, + }) => value, + }, + ], +}); diff --git a/src/data/composite/control-flow/exposeDependency.js b/src/data/composite/control-flow/exposeDependency.js new file mode 100644 index 00000000..3aa3d03a --- /dev/null +++ b/src/data/composite/control-flow/exposeDependency.js @@ -0,0 +1,28 @@ +// Exposes a dependency exactly as it is; this is typically the base of a +// composition which was created to serve as one property's descriptor. +// +// Please note that this *doesn't* verify that the dependency exists, so +// if you provide the wrong name or it hasn't been set by a previous +// compositional step, the property will be exposed as undefined instead +// of null. + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `exposeDependency`, + + compose: false, + + inputs: { + dependency: input.staticDependency({acceptsNull: true}), + }, + + steps: () => [ + { + dependencies: [input('dependency')], + compute: ({ + [input('dependency')]: dependency + }) => dependency, + }, + ], +}); diff --git a/src/data/composite/control-flow/exposeDependencyOrContinue.js b/src/data/composite/control-flow/exposeDependencyOrContinue.js new file mode 100644 index 00000000..0f7f223e --- /dev/null +++ b/src/data/composite/control-flow/exposeDependencyOrContinue.js @@ -0,0 +1,34 @@ +// Exposes a dependency as it is, or continues if it's unavailable. +// See withResultOfAvailabilityCheck for {mode} options. + +import {input, templateCompositeFrom} from '#composite'; + +import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js'; +import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js'; + +export default templateCompositeFrom({ + annotation: `exposeDependencyOrContinue`, + + inputs: { + dependency: input({acceptsNull: true}), + mode: inputAvailabilityCheckMode(), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('dependency')], + compute: (continuation, { + ['#availability']: availability, + [input('dependency')]: dependency, + }) => + (availability + ? continuation.exit(dependency) + : continuation()), + }, + ], +}); diff --git a/src/data/composite/control-flow/exposeUpdateValueOrContinue.js b/src/data/composite/control-flow/exposeUpdateValueOrContinue.js new file mode 100644 index 00000000..1f94b332 --- /dev/null +++ b/src/data/composite/control-flow/exposeUpdateValueOrContinue.js @@ -0,0 +1,40 @@ +// Exposes the update value of an {update: true} property as it is, +// or continues if it's unavailable. +// +// See withResultOfAvailabilityCheck for {mode} options. +// +// Provide {validate} here to conveniently set a custom validation check +// for this property's update value. +// + +import {input, templateCompositeFrom} from '#composite'; + +import exposeDependencyOrContinue from './exposeDependencyOrContinue.js'; +import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js'; + +export default templateCompositeFrom({ + annotation: `exposeUpdateValueOrContinue`, + + inputs: { + mode: inputAvailabilityCheckMode(), + + validate: input({ + type: 'function', + defaultValue: null, + }), + }, + + update: ({ + [input.staticValue('validate')]: validate, + }) => + (validate + ? {validate} + : {}), + + steps: () => [ + exposeDependencyOrContinue({ + dependency: input.updateValue(), + mode: input('mode'), + }), + ], +}); diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js new file mode 100644 index 00000000..dfc53db7 --- /dev/null +++ b/src/data/composite/control-flow/index.js @@ -0,0 +1,9 @@ +export {default as exitWithoutDependency} from './exitWithoutDependency.js'; +export {default as exitWithoutUpdateValue} from './exitWithoutUpdateValue.js'; +export {default as exposeConstant} from './exposeConstant.js'; +export {default as exposeDependency} from './exposeDependency.js'; +export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinue.js'; +export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js'; +export {default as raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js'; +export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js'; +export {default as withResultOfAvailabilityCheck} from './withResultOfAvailabilityCheck.js'; diff --git a/src/data/composite/control-flow/inputAvailabilityCheckMode.js b/src/data/composite/control-flow/inputAvailabilityCheckMode.js new file mode 100644 index 00000000..8008fdeb --- /dev/null +++ b/src/data/composite/control-flow/inputAvailabilityCheckMode.js @@ -0,0 +1,9 @@ +import {input} from '#composite'; +import {is} from '#validators'; + +export default function inputAvailabilityCheckMode() { + return input({ + validate: is('null', 'empty', 'falsy', 'index'), + defaultValue: 'null', + }); +} diff --git a/src/data/composite/control-flow/raiseOutputWithoutDependency.js b/src/data/composite/control-flow/raiseOutputWithoutDependency.js new file mode 100644 index 00000000..03d8036a --- /dev/null +++ b/src/data/composite/control-flow/raiseOutputWithoutDependency.js @@ -0,0 +1,39 @@ +// Raises if a dependency isn't available. +// See withResultOfAvailabilityCheck for {mode} options. + +import {input, templateCompositeFrom} from '#composite'; + +import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js'; +import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js'; + +export default templateCompositeFrom({ + annotation: `raiseOutputWithoutDependency`, + + inputs: { + dependency: input({acceptsNull: true}), + mode: inputAvailabilityCheckMode(), + output: input.staticValue({defaultValue: {}}), + }, + + outputs: ({ + [input.staticValue('output')]: output, + }) => Object.keys(output), + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('output')], + compute: (continuation, { + ['#availability']: availability, + [input('output')]: output, + }) => + (availability + ? continuation() + : continuation.raiseOutputAbove(output)), + }, + ], +}); diff --git a/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js new file mode 100644 index 00000000..3c39f5ba --- /dev/null +++ b/src/data/composite/control-flow/raiseOutputWithoutUpdateValue.js @@ -0,0 +1,47 @@ +// Raises if this property's update value isn't available. +// See withResultOfAvailabilityCheck for {mode} options! + +import {input, templateCompositeFrom} from '#composite'; + +import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js'; +import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js'; + +export default templateCompositeFrom({ + annotation: `raiseOutputWithoutUpdateValue`, + + inputs: { + mode: inputAvailabilityCheckMode(), + output: input.staticValue({defaultValue: {}}), + }, + + outputs: ({ + [input.staticValue('output')]: output, + }) => Object.keys(output), + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input.updateValue(), + mode: input('mode'), + }), + + // TODO: A bit of a kludge, below. Other "do something with the update + // value" type functions can get by pretty much just passing that value + // as an input (input.updateValue()) into the corresponding "do something + // with a dependency/arbitrary value" function. But we can't do that here, + // because the special behavior, raiseOutputAbove(), only works to raise + // output above the composition it's *directly* nested in. Other languages + // have a throw/catch system that might serve as inspiration for something + // better here. + + { + dependencies: ['#availability', input('output')], + compute: (continuation, { + ['#availability']: availability, + [input('output')]: output, + }) => + (availability + ? continuation() + : continuation.raiseOutputAbove(output)), + }, + ], +}); diff --git a/src/data/composite/control-flow/withResultOfAvailabilityCheck.js b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js new file mode 100644 index 00000000..a6942014 --- /dev/null +++ b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js @@ -0,0 +1,71 @@ +// Checks the availability of a dependency and provides the result to later +// steps under '#availability' (by default). This is mainly intended for use +// by the more specific utilities, which you should consider using instead. +// +// Customize {mode} to select one of these modes, or default to 'null': +// +// * 'null': Check that the value isn't null (and not undefined either). +// * 'empty': Check that the value is neither null, undefined, nor an empty +// array. +// * 'falsy': Check that the value isn't false when treated as a boolean +// (nor an empty array). Keep in mind this will also be false +// for values like zero and the empty string! +// * 'index': Check that the value is a number, and is at least zero. +// +// See also: +// - exitWithoutDependency +// - exitWithoutUpdateValue +// - exposeDependencyOrContinue +// - exposeUpdateValueOrContinue +// - raiseOutputWithoutDependency +// - raiseOutputWithoutUpdateValue +// + +import {input, templateCompositeFrom} from '#composite'; +import {empty} from '#sugar'; + +import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js'; + +export default templateCompositeFrom({ + annotation: `withResultOfAvailabilityCheck`, + + inputs: { + from: input({acceptsNull: true}), + mode: inputAvailabilityCheckMode(), + }, + + outputs: ['#availability'], + + steps: () => [ + { + dependencies: [input('from'), input('mode')], + + compute: (continuation, { + [input('from')]: value, + [input('mode')]: mode, + }) => { + let availability; + + switch (mode) { + case 'null': + availability = value !== undefined && value !== null; + break; + + case 'empty': + availability = value !== undefined && !empty(value); + break; + + case 'falsy': + availability = !!value && (!Array.isArray(value) || !empty(value)); + break; + + case 'index': + availability = typeof value === 'number' && value >= 0; + break; + } + + return continuation({'#availability': availability}); + }, + }, + ], +}); diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js new file mode 100644 index 00000000..718f2294 --- /dev/null +++ b/src/data/composite/data/excludeFromList.js @@ -0,0 +1,56 @@ +// Filters particular values out of a list. Note that this will always +// completely skip over null, but can be used to filter out any other +// primitive or object value. +// +// See also: +// - fillMissingListItems +// +// More list utilities: +// - withFlattenedList +// - withPropertyFromList +// - withPropertiesFromList +// - withUnflattenedList +// + +import {input, templateCompositeFrom} from '#composite'; +import {empty} from '#sugar'; + +export default templateCompositeFrom({ + annotation: `excludeFromList`, + + inputs: { + list: input(), + + item: input({defaultValue: null}), + items: input({type: 'array', defaultValue: null}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + }) => [list ?? '#list'], + + steps: () => [ + { + dependencies: [ + input.staticDependency('list'), + input('list'), + input('item'), + input('items'), + ], + + compute: (continuation, { + [input.staticDependency('list')]: listName, + [input('list')]: listContents, + [input('item')]: excludeItem, + [input('items')]: excludeItems, + }) => continuation({ + [listName ?? '#list']: + listContents.filter(item => { + if (excludeItem !== null && item === excludeItem) return false; + if (!empty(excludeItems) && excludeItems.includes(item)) return false; + return true; + }), + }), + }, + ], +}); diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js new file mode 100644 index 00000000..c06eceda --- /dev/null +++ b/src/data/composite/data/fillMissingListItems.js @@ -0,0 +1,51 @@ +// Replaces items of a list, which are null or undefined, with some fallback +// value. By default, this replaces the passed dependency. +// +// See also: +// - excludeFromList +// +// More list utilities: +// - withFlattenedList +// - withPropertyFromList +// - withPropertiesFromList +// - withUnflattenedList +// + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `fillMissingListItems`, + + inputs: { + list: input({type: 'array'}), + fill: input({acceptsNull: true}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + }) => [list ?? '#list'], + + steps: () => [ + { + dependencies: [input('list'), input('fill')], + compute: (continuation, { + [input('list')]: list, + [input('fill')]: fill, + }) => continuation({ + ['#filled']: + list.map(item => item ?? fill), + }), + }, + + { + dependencies: [input.staticDependency('list'), '#filled'], + compute: (continuation, { + [input.staticDependency('list')]: list, + ['#filled']: filled, + }) => continuation({ + [list ?? '#list']: + filled, + }), + }, + ], +}); diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js new file mode 100644 index 00000000..ecd05129 --- /dev/null +++ b/src/data/composite/data/index.js @@ -0,0 +1,8 @@ +export {default as excludeFromList} from './excludeFromList.js'; +export {default as fillMissingListItems} from './fillMissingListItems.js'; +export {default as withFlattenedList} from './withFlattenedList.js'; +export {default as withPropertiesFromList} from './withPropertiesFromList.js'; +export {default as withPropertiesFromObject} from './withPropertiesFromObject.js'; +export {default as withPropertyFromList} from './withPropertyFromList.js'; +export {default as withPropertyFromObject} from './withPropertyFromObject.js'; +export {default as withUnflattenedList} from './withUnflattenedList.js'; diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js new file mode 100644 index 00000000..b08edb4e --- /dev/null +++ b/src/data/composite/data/withFlattenedList.js @@ -0,0 +1,47 @@ +// Flattens an array with one level of nested arrays, providing as dependencies +// both the flattened array as well as the original starting indices of each +// successive source array. +// +// See also: +// - withFlattenedList +// +// More list utilities: +// - excludeFromList +// - fillMissingListItems +// - withPropertyFromList +// - withPropertiesFromList +// + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `withFlattenedList`, + + inputs: { + list: input({type: 'array'}), + }, + + outputs: ['#flattenedList', '#flattenedIndices'], + + steps: () => [ + { + dependencies: [input('list')], + compute(continuation, { + [input('list')]: sourceList, + }) { + const flattenedList = sourceList.flat(); + const indices = []; + let lastEndIndex = 0; + for (const {length} of sourceList) { + indices.push(lastEndIndex); + lastEndIndex += length; + } + + return continuation({ + ['#flattenedList']: flattenedList, + ['#flattenedIndices']: indices, + }); + }, + }, + ], +}); diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js new file mode 100644 index 00000000..76ba696c --- /dev/null +++ b/src/data/composite/data/withPropertiesFromList.js @@ -0,0 +1,92 @@ +// Gets the listed properties from each of a list of objects, providing lists +// of property values each into a dependency prefixed with the same name as the +// list (by default). +// +// Like withPropertyFromList, this doesn't alter indices. +// +// See also: +// - withPropertiesFromObject +// - withPropertyFromList +// +// More list utilities: +// - excludeFromList +// - fillMissingListItems +// - withFlattenedList +// - withUnflattenedList +// + +import {input, templateCompositeFrom} from '#composite'; +import {isString, validateArrayItems} from '#validators'; + +export default templateCompositeFrom({ + annotation: `withPropertiesFromList`, + + inputs: { + list: input({type: 'array'}), + + properties: input({ + validate: validateArrayItems(isString), + }), + + prefix: input.staticValue({type: 'string', defaultValue: null}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + [input.staticValue('properties')]: properties, + [input.staticValue('prefix')]: prefix, + }) => + (properties + ? properties.map(property => + (prefix + ? `${prefix}.${property}` + : list + ? `${list}.${property}` + : `#list.${property}`)) + : ['#lists']), + + steps: () => [ + { + dependencies: [input('list'), input('properties')], + compute: (continuation, { + [input('list')]: list, + [input('properties')]: properties, + }) => continuation({ + ['#lists']: + Object.fromEntries( + properties.map(property => [ + property, + list.map(item => item[property] ?? null), + ])), + }), + }, + + { + dependencies: [ + input.staticDependency('list'), + input.staticValue('properties'), + input.staticValue('prefix'), + '#lists', + ], + + compute: (continuation, { + [input.staticDependency('list')]: list, + [input.staticValue('properties')]: properties, + [input.staticValue('prefix')]: prefix, + ['#lists']: lists, + }) => + (properties + ? continuation( + Object.fromEntries( + properties.map(property => [ + (prefix + ? `${prefix}.${property}` + : list + ? `${list}.${property}` + : `#list.${property}`), + lists[property], + ]))) + : continuation({'#lists': lists})), + }, + ], +}); diff --git a/src/data/composite/data/withPropertiesFromObject.js b/src/data/composite/data/withPropertiesFromObject.js new file mode 100644 index 00000000..21726b58 --- /dev/null +++ b/src/data/composite/data/withPropertiesFromObject.js @@ -0,0 +1,87 @@ +// Gets the listed properties from some object, providing each property's value +// as a dependency prefixed with the same name as the object (by default). +// If the object itself is null, all provided dependencies will be null; +// if it's missing only select properties, those will be provided as null. +// +// See also: +// - withPropertiesFromList +// - withPropertyFromObject +// + +import {input, templateCompositeFrom} from '#composite'; +import {isString, validateArrayItems} from '#validators'; + +export default templateCompositeFrom({ + annotation: `withPropertiesFromObject`, + + inputs: { + object: input({type: 'object', acceptsNull: true}), + + properties: input({ + type: 'array', + validate: validateArrayItems(isString), + }), + + prefix: input.staticValue({type: 'string', defaultValue: null}), + }, + + outputs: ({ + [input.staticDependency('object')]: object, + [input.staticValue('properties')]: properties, + [input.staticValue('prefix')]: prefix, + }) => + (properties + ? properties.map(property => + (prefix + ? `${prefix}.${property}` + : object + ? `${object}.${property}` + : `#object.${property}`)) + : ['#object']), + + steps: () => [ + { + dependencies: [input('object'), input('properties')], + compute: (continuation, { + [input('object')]: object, + [input('properties')]: properties, + }) => continuation({ + ['#entries']: + (object === null + ? properties.map(property => [property, null]) + : properties.map(property => [property, object[property]])), + }), + }, + + { + dependencies: [ + input.staticDependency('object'), + input.staticValue('properties'), + input.staticValue('prefix'), + '#entries', + ], + + compute: (continuation, { + [input.staticDependency('object')]: object, + [input.staticValue('properties')]: properties, + [input.staticValue('prefix')]: prefix, + ['#entries']: entries, + }) => + (properties + ? continuation( + Object.fromEntries( + entries.map(([property, value]) => [ + (prefix + ? `${prefix}.${property}` + : object + ? `${object}.${property}` + : `#object.${property}`), + value ?? null, + ]))) + : continuation({ + ['#object']: + Object.fromEntries(entries), + })), + }, + ], +}); diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js new file mode 100644 index 00000000..1983ebbc --- /dev/null +++ b/src/data/composite/data/withPropertyFromList.js @@ -0,0 +1,82 @@ +// Gets a property from each of a list of objects (in a dependency) and +// provides the results. +// +// This doesn't alter any list indices, so positions which were null in the +// original list are kept null here. Objects which don't have the specified +// property are retained in-place as null. +// +// See also: +// - withPropertiesFromList +// - withPropertyFromObject +// +// More list utilities: +// - excludeFromList +// - fillMissingListItems +// - withFlattenedList +// - withUnflattenedList +// + +import {input, templateCompositeFrom} from '#composite'; + +function getOutputName({list, property, prefix}) { + if (!property) return `#values`; + if (prefix) return `${prefix}.${property}`; + if (list) return `${list}.${property}`; + return `#list.${property}`; +} + +export default templateCompositeFrom({ + annotation: `withPropertyFromList`, + + inputs: { + list: input({type: 'array'}), + property: input({type: 'string'}), + prefix: input.staticValue({type: 'string', defaultValue: null}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + [input.staticValue('property')]: property, + [input.staticValue('prefix')]: prefix, + }) => + [getOutputName({list, property, prefix})], + + steps: () => [ + { + dependencies: [input('list'), input('property')], + compute: (continuation, { + [input('list')]: list, + [input('property')]: property, + }) => continuation({ + ['#values']: + list.map(item => item[property] ?? null), + }), + }, + + { + dependencies: [ + input.staticDependency('list'), + input.staticValue('property'), + input.staticValue('prefix'), + ], + + compute: (continuation, { + [input.staticDependency('list')]: list, + [input.staticValue('property')]: property, + [input.staticValue('prefix')]: prefix, + }) => continuation({ + ['#outputName']: + getOutputName({list, property, prefix}), + }), + }, + + { + dependencies: ['#values', '#outputName'], + compute: (continuation, { + ['#values']: values, + ['#outputName']: outputName, + }) => + continuation.raiseOutput({[outputName]: values}), + }, + ], +}); diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js new file mode 100644 index 00000000..b31bab15 --- /dev/null +++ b/src/data/composite/data/withPropertyFromObject.js @@ -0,0 +1,69 @@ +// Gets a property of some object (in a dependency) and provides that value. +// If the object itself is null, or the object doesn't have the listed property, +// the provided dependency will also be null. +// +// See also: +// - withPropertiesFromObject +// - withPropertyFromList +// + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `withPropertyFromObject`, + + inputs: { + object: input({type: 'object', acceptsNull: true}), + property: input({type: 'string'}), + }, + + outputs: ({ + [input.staticDependency('object')]: object, + [input.staticValue('property')]: property, + }) => + (object && property + ? (object.startsWith('#') + ? [`${object}.${property}`] + : [`#${object}.${property}`]) + : ['#value']), + + steps: () => [ + { + dependencies: [ + input.staticDependency('object'), + input.staticValue('property'), + ], + + compute: (continuation, { + [input.staticDependency('object')]: object, + [input.staticValue('property')]: property, + }) => continuation({ + '#output': + (object && property + ? (object.startsWith('#') + ? `${object}.${property}` + : `#${object}.${property}`) + : '#value'), + }), + }, + + { + dependencies: [ + '#output', + input('object'), + input('property'), + ], + + compute: (continuation, { + ['#output']: output, + [input('object')]: object, + [input('property')]: property, + }) => continuation({ + [output]: + (object === null + ? null + : object[property] ?? null), + }), + }, + ], +}); diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js new file mode 100644 index 00000000..3cfc247b --- /dev/null +++ b/src/data/composite/data/withUnflattenedList.js @@ -0,0 +1,62 @@ +// After mapping the contents of a flattened array in-place (being careful to +// retain the original indices by replacing unmatched results with null instead +// of filtering them out), this function allows for recombining them. It will +// filter out null and undefined items by default (pass {filter: false} to +// disable this). + +import {input, templateCompositeFrom} from '#composite'; +import {isWholeNumber, validateArrayItems} from '#validators'; + +export default templateCompositeFrom({ + annotation: `withUnflattenedList`, + + inputs: { + list: input({ + type: 'array', + defaultDependency: '#flattenedList', + }), + + indices: input({ + validate: validateArrayItems(isWholeNumber), + defaultDependency: '#flattenedIndices', + }), + + filter: input({ + type: 'boolean', + defaultValue: true, + }), + }, + + outputs: ['#unflattenedList'], + + steps: () => [ + { + dependencies: [input('list'), input('indices'), input('filter')], + compute(continuation, { + [input('list')]: list, + [input('indices')]: indices, + [input('filter')]: filter, + }) { + const unflattenedList = []; + + for (let i = 0; i < indices.length; i++) { + const startIndex = indices[i]; + const endIndex = + (i === indices.length - 1 + ? list.length + : indices[i + 1]); + + const values = list.slice(startIndex, endIndex); + unflattenedList.push( + (filter + ? values.filter(value => value !== null && value !== undefined) + : values)); + } + + return continuation({ + ['#unflattenedList']: unflattenedList, + }); + }, + }, + ], +}); diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js new file mode 100644 index 00000000..8139f10e --- /dev/null +++ b/src/data/composite/things/album/index.js @@ -0,0 +1,2 @@ +export {default as withTracks} from './withTracks.js'; +export {default as withTrackSections} from './withTrackSections.js'; diff --git a/src/data/composite/things/album/withTrackSections.js b/src/data/composite/things/album/withTrackSections.js new file mode 100644 index 00000000..baa3cb4a --- /dev/null +++ b/src/data/composite/things/album/withTrackSections.js @@ -0,0 +1,128 @@ +import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; +import {empty, stitchArrays} from '#sugar'; +import {isTrackSectionList} from '#validators'; +import {filterMultipleArrays} from '#wiki-data'; + +import {exitWithoutDependency, exitWithoutUpdateValue} + from '#composite/control-flow'; +import {withResolvedReferenceList} from '#composite/wiki-data'; + +import { + fillMissingListItems, + withFlattenedList, + withPropertiesFromList, + withUnflattenedList, +} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withTrackSections`, + + outputs: ['#trackSections'], + + steps: () => [ + exitWithoutDependency({ + dependency: 'trackData', + value: input.value([]), + }), + + exitWithoutUpdateValue({ + mode: input.value('empty'), + value: input.value([]), + }), + + // TODO: input.updateValue description down here is a kludge. + withPropertiesFromList({ + list: input.updateValue({ + validate: isTrackSectionList, + }), + prefix: input.value('#sections'), + properties: input.value([ + 'tracks', + 'dateOriginallyReleased', + 'isDefaultTrackSection', + 'name', + 'color', + ]), + }), + + fillMissingListItems({ + list: '#sections.tracks', + fill: input.value([]), + }), + + fillMissingListItems({ + list: '#sections.isDefaultTrackSection', + fill: input.value(false), + }), + + fillMissingListItems({ + list: '#sections.name', + fill: input.value('Unnamed Track Section'), + }), + + fillMissingListItems({ + list: '#sections.color', + fill: input.dependency('color'), + }), + + withFlattenedList({ + list: '#sections.tracks', + }).outputs({ + ['#flattenedList']: '#trackRefs', + ['#flattenedIndices']: '#sections.startIndex', + }), + + withResolvedReferenceList({ + list: '#trackRefs', + data: 'trackData', + notFoundMode: input.value('null'), + find: input.value(find.track), + }).outputs({ + ['#resolvedReferenceList']: '#tracks', + }), + + withUnflattenedList({ + list: '#tracks', + indices: '#sections.startIndex', + }).outputs({ + ['#unflattenedList']: '#sections.tracks', + }), + + { + dependencies: [ + '#sections.tracks', + '#sections.name', + '#sections.color', + '#sections.dateOriginallyReleased', + '#sections.isDefaultTrackSection', + '#sections.startIndex', + ], + + compute: (continuation, { + '#sections.tracks': tracks, + '#sections.name': name, + '#sections.color': color, + '#sections.dateOriginallyReleased': dateOriginallyReleased, + '#sections.isDefaultTrackSection': isDefaultTrackSection, + '#sections.startIndex': startIndex, + }) => { + filterMultipleArrays( + tracks, name, color, dateOriginallyReleased, isDefaultTrackSection, startIndex, + tracks => !empty(tracks)); + + return continuation({ + ['#trackSections']: + stitchArrays({ + tracks, + name, + color, + dateOriginallyReleased, + isDefaultTrackSection, + startIndex, + }), + }); + }, + }, + ], +}); diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js new file mode 100644 index 00000000..dcea6593 --- /dev/null +++ b/src/data/composite/things/album/withTracks.js @@ -0,0 +1,51 @@ +import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; + +import {exitWithoutDependency, raiseOutputWithoutDependency} + from '#composite/control-flow'; +import {withResolvedReferenceList} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withTracks`, + + outputs: ['#tracks'], + + steps: () => [ + exitWithoutDependency({ + dependency: 'trackData', + value: input.value([]), + }), + + raiseOutputWithoutDependency({ + dependency: 'trackSections', + mode: input.value('empty'), + output: input.value({ + ['#tracks']: [], + }), + }), + + { + dependencies: ['trackSections'], + compute: (continuation, {trackSections}) => + continuation({ + '#trackRefs': trackSections + .flatMap(section => section.tracks ?? []), + }), + }, + + withResolvedReferenceList({ + list: '#trackRefs', + data: 'trackData', + find: input.value(find.track), + }), + + { + dependencies: ['#resolvedReferenceList'], + compute: (continuation, { + ['#resolvedReferenceList']: resolvedReferenceList, + }) => continuation({ + ['#tracks']: resolvedReferenceList, + }) + }, + ], +}); diff --git a/src/data/composite/things/flash/index.js b/src/data/composite/things/flash/index.js new file mode 100644 index 00000000..63ac13da --- /dev/null +++ b/src/data/composite/things/flash/index.js @@ -0,0 +1 @@ +export {default as withFlashAct} from './withFlashAct.js'; diff --git a/src/data/composite/things/flash/withFlashAct.js b/src/data/composite/things/flash/withFlashAct.js new file mode 100644 index 00000000..ada2dcfe --- /dev/null +++ b/src/data/composite/things/flash/withFlashAct.js @@ -0,0 +1,108 @@ +// Gets the flash's act. This will early exit if flashActData is missing. +// By default, if there's no flash whose list of flashes includes this flash, +// the output dependency will be null; set {notFoundMode: 'exit'} to early +// exit instead. +// +// This step models with Flash.withAlbum. + +import {input, templateCompositeFrom} from '#composite'; +import {is} from '#validators'; + +import {exitWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {withPropertyFromList} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withFlashAct`, + + inputs: { + notFoundMode: input({ + validate: is('exit', 'null'), + defaultValue: 'null', + }), + }, + + outputs: ['#flashAct'], + + steps: () => [ + // null flashActData is always an early exit. + + exitWithoutDependency({ + dependency: 'flashActData', + mode: input.value('null'), + }), + + // empty flashActData conditionally exits early or outputs null. + + withResultOfAvailabilityCheck({ + from: 'flashActData', + mode: input.value('empty'), + }).outputs({ + '#availability': '#flashActDataAvailability', + }), + + { + dependencies: [input('notFoundMode'), '#flashActDataAvailability'], + compute(continuation, { + [input('notFoundMode')]: notFoundMode, + ['#flashActDataAvailability']: flashActDataIsAvailable, + }) { + if (flashActDataIsAvailable) return continuation(); + switch (notFoundMode) { + case 'exit': return continuation.exit(null); + case 'null': return continuation.raiseOutput({'#flashAct': null}); + } + }, + }, + + withPropertyFromList({ + list: 'flashActData', + property: input.value('flashes'), + }), + + { + dependencies: [input.myself(), '#flashActData.flashes'], + compute: (continuation, { + [input.myself()]: track, + ['#flashActData.flashes']: flashLists, + }) => continuation({ + ['#flashActIndex']: + flashLists.findIndex(flashes => flashes.includes(track)), + }), + }, + + // album not found conditionally exits or outputs null. + + withResultOfAvailabilityCheck({ + from: '#flashActIndex', + mode: input.value('index'), + }).outputs({ + '#availability': '#flashActAvailability', + }), + + { + dependencies: [input('notFoundMode'), '#flashActAvailability'], + compute(continuation, { + [input('notFoundMode')]: notFoundMode, + ['#flashActAvailability']: flashActIsAvailable, + }) { + if (flashActIsAvailable) return continuation(); + switch (notFoundMode) { + case 'exit': return continuation.exit(null); + case 'null': return continuation.raiseOutput({'#flashAct': null}); + } + }, + }, + + { + dependencies: ['flashActData', '#flashActIndex'], + compute: (continuation, { + ['flashActData']: flashActData, + ['#flashActIndex']: flashActIndex, + }) => continuation.raiseOutput({ + ['#flashAct']: + flashActData[flashActIndex], + }), + }, + ], +}); diff --git a/src/data/composite/things/track/exitWithoutUniqueCoverArt.js b/src/data/composite/things/track/exitWithoutUniqueCoverArt.js new file mode 100644 index 00000000..f47086d9 --- /dev/null +++ b/src/data/composite/things/track/exitWithoutUniqueCoverArt.js @@ -0,0 +1,26 @@ +// Shorthand for checking if the track has unique cover art and exposing a +// fallback value if it isn't. + +import {input, templateCompositeFrom} from '#composite'; + +import {exitWithoutDependency} from '#composite/control-flow'; + +import withHasUniqueCoverArt from './withHasUniqueCoverArt.js'; + +export default templateCompositeFrom({ + annotation: `exitWithoutUniqueCoverArt`, + + inputs: { + value: input({defaultValue: null}), + }, + + steps: () => [ + withHasUniqueCoverArt(), + + exitWithoutDependency({ + dependency: '#hasUniqueCoverArt', + mode: input.value('falsy'), + value: input('value'), + }), + ], +}); diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js new file mode 100644 index 00000000..3354b1c4 --- /dev/null +++ b/src/data/composite/things/track/index.js @@ -0,0 +1,9 @@ +export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js'; +export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js'; +export {default as trackReverseReferenceList} from './trackReverseReferenceList.js'; +export {default as withAlbum} from './withAlbum.js'; +export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js'; +export {default as withContainingTrackSection} from './withContainingTrackSection.js'; +export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js'; +export {default as withOtherReleases} from './withOtherReleases.js'; +export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js'; diff --git a/src/data/composite/things/track/inheritFromOriginalRelease.js b/src/data/composite/things/track/inheritFromOriginalRelease.js new file mode 100644 index 00000000..a9d57f86 --- /dev/null +++ b/src/data/composite/things/track/inheritFromOriginalRelease.js @@ -0,0 +1,43 @@ +// Early exits with a value inherited from the original release, if +// this track is a rerelease, and otherwise continues with no further +// dependencies provided. If allowOverride is true, then the continuation +// will also be called if the original release exposed the requested +// property as null. + +import {input, templateCompositeFrom} from '#composite'; + +import withOriginalRelease from './withOriginalRelease.js'; + +export default templateCompositeFrom({ + annotation: `inheritFromOriginalRelease`, + + inputs: { + property: input({type: 'string'}), + allowOverride: input({type: 'boolean', defaultValue: false}), + }, + + steps: () => [ + withOriginalRelease(), + + { + dependencies: [ + '#originalRelease', + input('property'), + input('allowOverride'), + ], + + compute: (continuation, { + ['#originalRelease']: originalRelease, + [input('property')]: originalProperty, + [input('allowOverride')]: allowOverride, + }) => { + if (!originalRelease) return continuation(); + + const value = originalRelease[originalProperty]; + if (allowOverride && value === null) return continuation(); + + return continuation.exit(value); + }, + }, + ], +}); diff --git a/src/data/composite/things/track/trackReverseReferenceList.js b/src/data/composite/things/track/trackReverseReferenceList.js new file mode 100644 index 00000000..e7bfedf3 --- /dev/null +++ b/src/data/composite/things/track/trackReverseReferenceList.js @@ -0,0 +1,38 @@ +// Like a normal reverse reference list ("objects which reference this object +// under a specified property"), only excluding re-releases from the possible +// outputs. While it's useful to travel from a re-release to the tracks it +// references, re-releases aren't generally relevant from the perspective of +// the tracks *being* referenced. Apart from hiding re-releases from lists on +// the site, it also excludes keeps them from relational data processing, such +// as on the "Tracks - by Times Referenced" listing page. + +import {input, templateCompositeFrom} from '#composite'; +import {withReverseReferenceList} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `trackReverseReferenceList`, + + compose: false, + + inputs: { + list: input({type: 'string'}), + }, + + steps: () => [ + withReverseReferenceList({ + data: 'trackData', + list: input('list'), + }), + + { + flags: {expose: true}, + expose: { + dependencies: ['#reverseReferenceList'], + compute: ({ + ['#reverseReferenceList']: reverseReferenceList, + }) => + reverseReferenceList.filter(track => !track.originalReleaseTrack), + }, + }, + ], +}); diff --git a/src/data/composite/things/track/withAlbum.js b/src/data/composite/things/track/withAlbum.js new file mode 100644 index 00000000..cbd16dcd --- /dev/null +++ b/src/data/composite/things/track/withAlbum.js @@ -0,0 +1,108 @@ +// Gets the track's album. This will early exit if albumData is missing. +// By default, if there's no album whose list of tracks includes this track, +// the output dependency will be null; set {notFoundMode: 'exit'} to early +// exit instead. +// +// This step models with Flash.withFlashAct. + +import {input, templateCompositeFrom} from '#composite'; +import {is} from '#validators'; + +import {exitWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {withPropertyFromList} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withAlbum`, + + inputs: { + notFoundMode: input({ + validate: is('exit', 'null'), + defaultValue: 'null', + }), + }, + + outputs: ['#album'], + + steps: () => [ + // null albumData is always an early exit. + + exitWithoutDependency({ + dependency: 'albumData', + mode: input.value('null'), + }), + + // empty albumData conditionally exits early or outputs null. + + withResultOfAvailabilityCheck({ + from: 'albumData', + mode: input.value('empty'), + }).outputs({ + '#availability': '#albumDataAvailability', + }), + + { + dependencies: [input('notFoundMode'), '#albumDataAvailability'], + compute(continuation, { + [input('notFoundMode')]: notFoundMode, + ['#albumDataAvailability']: albumDataIsAvailable, + }) { + if (albumDataIsAvailable) return continuation(); + switch (notFoundMode) { + case 'exit': return continuation.exit(null); + case 'null': return continuation.raiseOutput({'#album': null}); + } + }, + }, + + withPropertyFromList({ + list: 'albumData', + property: input.value('tracks'), + }), + + { + dependencies: [input.myself(), '#albumData.tracks'], + compute: (continuation, { + [input.myself()]: track, + ['#albumData.tracks']: trackLists, + }) => continuation({ + ['#albumIndex']: + trackLists.findIndex(tracks => tracks.includes(track)), + }), + }, + + // album not found conditionally exits or outputs null. + + withResultOfAvailabilityCheck({ + from: '#albumIndex', + mode: input.value('index'), + }).outputs({ + '#availability': '#albumAvailability', + }), + + { + dependencies: [input('notFoundMode'), '#albumAvailability'], + compute(continuation, { + [input('notFoundMode')]: notFoundMode, + ['#albumAvailability']: albumIsAvailable, + }) { + if (albumIsAvailable) return continuation(); + switch (notFoundMode) { + case 'exit': return continuation.exit(null); + case 'null': return continuation.raiseOutput({'#album': null}); + } + }, + }, + + { + dependencies: ['albumData', '#albumIndex'], + compute: (continuation, { + ['albumData']: albumData, + ['#albumIndex']: albumIndex, + }) => continuation.raiseOutput({ + ['#album']: + albumData[albumIndex], + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js new file mode 100644 index 00000000..d27f7b23 --- /dev/null +++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js @@ -0,0 +1,91 @@ +// Controls how find.track works - it'll never be matched by a reference +// just to the track's name, which means you don't have to always reference +// some *other* (much more commonly referenced) track by directory instead +// of more naturally by name. +// +// See the implementation for an important caveat about matching the original +// track against other tracks, which uses a custom implementation pulling (and +// duplicating) details from #find instead of using withOriginalRelease and the +// usual withResolvedReference / find.track() utilities. +// + +import {input, templateCompositeFrom} from '#composite'; +import {isBoolean} from '#validators'; + +import {exitWithoutDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +// TODO: Kludge. (The usage of this, not so much the import.) +import CacheableObject from '../../../things/cacheable-object.js'; + +export default templateCompositeFrom({ + annotation: `withAlwaysReferenceByDirectory`, + + outputs: ['#alwaysReferenceByDirectory'], + + steps: () => [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + // Remaining code is for defaulting to true if this track is a rerelease of + // another with the same name, so everything further depends on access to + // trackData as well as originalReleaseTrack. + + exitWithoutDependency({ + dependency: 'trackData', + mode: input.value('empty'), + value: input.value(false), + }), + + exitWithoutDependency({ + dependency: 'originalReleaseTrack', + value: input.value(false), + }), + + // "Slow" / uncached, manual search from trackData (with this track + // excluded). Otherwise there end up being pretty bad recursion issues + // (track1.alwaysReferencedByDirectory depends on searching through data + // including track2, which depends on evaluating track2.alwaysReferenced- + // ByDirectory, which depends on searcing through data including track1...) + // That said, this is 100% a kludge, since it involves duplicating find + // logic on a completely unrelated context. + { + dependencies: [input.myself(), 'trackData', 'originalReleaseTrack'], + compute: (continuation, { + [input.myself()]: thisTrack, + ['trackData']: trackData, + ['originalReleaseTrack']: ref, + }) => continuation({ + ['#originalRelease']: + (ref.startsWith('track:') + ? trackData.find(track => track.directory === ref.slice('track:'.length)) + : trackData.find(track => + track !== thisTrack && + !CacheableObject.getUpdateValue(track, 'originalReleaseTrack') && + track.name.toLowerCase() === ref.toLowerCase())), + }) + }, + + exitWithoutDependency({ + dependency: '#originalRelease', + value: input.value(false), + }), + + withPropertyFromObject({ + object: '#originalRelease', + property: input.value('name'), + }), + + { + dependencies: ['name', '#originalRelease.name'], + compute: (continuation, { + name, + ['#originalRelease.name']: originalName, + }) => continuation({ + ['#alwaysReferenceByDirectory']: name === originalName, + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js new file mode 100644 index 00000000..b2e5f2b3 --- /dev/null +++ b/src/data/composite/things/track/withContainingTrackSection.js @@ -0,0 +1,63 @@ +// Gets the track section containing this track from its album's track list. +// If notFoundMode is set to 'exit', this will early exit if the album can't be +// found or if none of its trackSections includes the track for some reason. + +import {input, templateCompositeFrom} from '#composite'; +import {is} from '#validators'; + +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; + +export default templateCompositeFrom({ + annotation: `withContainingTrackSection`, + + inputs: { + notFoundMode: input({ + validate: is('exit', 'null'), + defaultValue: 'null', + }), + }, + + outputs: ['#trackSection'], + + steps: () => [ + withPropertyFromAlbum({ + property: input.value('trackSections'), + notFoundMode: input('notFoundMode'), + }), + + { + dependencies: [ + input.myself(), + input('notFoundMode'), + '#album.trackSections', + ], + + compute(continuation, { + [input.myself()]: track, + [input('notFoundMode')]: notFoundMode, + ['#album.trackSections']: trackSections, + }) { + if (!trackSections) { + return continuation.raiseOutput({ + ['#trackSection']: null, + }); + } + + const trackSection = + trackSections.find(({tracks}) => tracks.includes(track)); + + if (trackSection) { + return continuation.raiseOutput({ + ['#trackSection']: trackSection, + }); + } else if (notFoundMode === 'exit') { + return continuation.exit(null); + } else { + return continuation.raiseOutput({ + ['#trackSection']: null, + }); + } + }, + }, + ], +}); diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js new file mode 100644 index 00000000..96078d5f --- /dev/null +++ b/src/data/composite/things/track/withHasUniqueCoverArt.js @@ -0,0 +1,61 @@ +// Whether or not the track has "unique" cover artwork - a cover which is +// specifically associated with this track in particular, rather than with +// the track's album as a whole. This is typically used to select between +// displaying the track artwork and a fallback, such as the album artwork +// or a placeholder. (This property is named hasUniqueCoverArt instead of +// the usual hasCoverArt to emphasize that it does not inherit from the +// album.) + +import {input, templateCompositeFrom} from '#composite'; +import {empty} from '#sugar'; + +import {withResolvedContribs} from '#composite/wiki-data'; + +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; + +export default templateCompositeFrom({ + annotation: 'withHasUniqueCoverArt', + + outputs: ['#hasUniqueCoverArt'], + + steps: () => [ + { + dependencies: ['disableUniqueCoverArt'], + compute: (continuation, {disableUniqueCoverArt}) => + (disableUniqueCoverArt + ? continuation.raiseOutput({ + ['#hasUniqueCoverArt']: false, + }) + : continuation()), + }, + + withResolvedContribs({from: 'coverArtistContribs'}), + + { + dependencies: ['#resolvedContribs'], + compute: (continuation, { + ['#resolvedContribs']: contribsFromTrack, + }) => + (empty(contribsFromTrack) + ? continuation() + : continuation.raiseOutput({ + ['#hasUniqueCoverArt']: true, + })), + }, + + withPropertyFromAlbum({ + property: input.value('trackCoverArtistContribs'), + }), + + { + dependencies: ['#album.trackCoverArtistContribs'], + compute: (continuation, { + ['#album.trackCoverArtistContribs']: contribsFromAlbum, + }) => + continuation.raiseOutput({ + ['#hasUniqueCoverArt']: + !empty(contribsFromAlbum), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withOriginalRelease.js b/src/data/composite/things/track/withOriginalRelease.js new file mode 100644 index 00000000..d2ee39df --- /dev/null +++ b/src/data/composite/things/track/withOriginalRelease.js @@ -0,0 +1,59 @@ +// Just includes the original release of this track as a dependency. +// If this track isn't a rerelease, then it'll provide null, unless the +// {selfIfOriginal} option is set, in which case it'll provide this track +// itself. Note that this will early exit if the original release is +// specified by reference and that reference doesn't resolve to anything. +// Outputs to '#originalRelease' by default. + +import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; +import {validateWikiData} from '#validators'; + +import {withResolvedReference} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withOriginalRelease`, + + inputs: { + selfIfOriginal: input({type: 'boolean', defaultValue: false}), + + data: input({ + validate: validateWikiData({referenceType: 'track'}), + defaultDependency: 'trackData', + }), + }, + + outputs: ['#originalRelease'], + + steps: () => [ + withResolvedReference({ + ref: 'originalReleaseTrack', + data: input('data'), + find: input.value(find.track), + notFoundMode: input.value('exit'), + }).outputs({ + ['#resolvedReference']: '#originalRelease', + }), + + { + dependencies: [ + input.myself(), + input('selfIfOriginal'), + '#originalRelease', + ], + + compute: (continuation, { + [input.myself()]: track, + [input('selfIfOriginal')]: selfIfOriginal, + ['#originalRelease']: originalRelease, + }) => + continuation({ + ['#originalRelease']: + (originalRelease ?? + (selfIfOriginal + ? track + : null)), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js new file mode 100644 index 00000000..84420cf8 --- /dev/null +++ b/src/data/composite/things/track/withOtherReleases.js @@ -0,0 +1,40 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {exitWithoutDependency} from '#composite/control-flow'; + +import withOriginalRelease from './withOriginalRelease.js'; + +export default templateCompositeFrom({ + annotation: `withOtherReleases`, + + outputs: ['#otherReleases'], + + steps: () => [ + exitWithoutDependency({ + dependency: 'trackData', + mode: input.value('empty'), + }), + + withOriginalRelease({ + selfIfOriginal: input.value(true), + }), + + { + dependencies: [input.myself(), '#originalRelease', 'trackData'], + compute: (continuation, { + [input.myself()]: thisTrack, + ['#originalRelease']: originalRelease, + trackData, + }) => continuation({ + ['#otherReleases']: + (originalRelease === thisTrack + ? [] + : [originalRelease]) + .concat(trackData.filter(track => + track !== originalRelease && + track !== thisTrack && + track.originalReleaseTrack === originalRelease)), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js new file mode 100644 index 00000000..b236a6e8 --- /dev/null +++ b/src/data/composite/things/track/withPropertyFromAlbum.js @@ -0,0 +1,49 @@ +// Gets a single property from this track's album, providing it as the same +// property name prefixed with '#album.' (by default). If the track's album +// isn't available, then by default, the property will be provided as null; +// set {notFoundMode: 'exit'} to early exit instead. + +import {input, templateCompositeFrom} from '#composite'; +import {is} from '#validators'; + +import {withPropertyFromObject} from '#composite/data'; + +import withAlbum from './withAlbum.js'; + +export default templateCompositeFrom({ + annotation: `withPropertyFromAlbum`, + + inputs: { + property: input.staticValue({type: 'string'}), + + notFoundMode: input({ + validate: is('exit', 'null'), + defaultValue: 'null', + }), + }, + + outputs: ({ + [input.staticValue('property')]: property, + }) => ['#album.' + property], + + steps: () => [ + withAlbum({ + notFoundMode: input('notFoundMode'), + }), + + withPropertyFromObject({ + object: '#album', + property: input('property'), + }), + + { + dependencies: ['#value', input.staticValue('property')], + compute: (continuation, { + ['#value']: value, + [input.staticValue('property')]: property, + }) => continuation({ + ['#album.' + property]: value, + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/exitWithoutContribs.js b/src/data/composite/wiki-data/exitWithoutContribs.js new file mode 100644 index 00000000..2c8219fc --- /dev/null +++ b/src/data/composite/wiki-data/exitWithoutContribs.js @@ -0,0 +1,47 @@ +// Shorthand for exiting if the contribution list (usually a property's update +// value) resolves to empty - ensuring that the later computed results are only +// returned if these contributions are present. + +import {input, templateCompositeFrom} from '#composite'; +import {isContributionList} from '#validators'; + +import {withResultOfAvailabilityCheck} from '#composite/control-flow'; + +import withResolvedContribs from './withResolvedContribs.js'; + +export default templateCompositeFrom({ + annotation: `exitWithoutContribs`, + + inputs: { + contribs: input({ + validate: isContributionList, + acceptsNull: true, + }), + + value: input({defaultValue: null}), + }, + + steps: () => [ + withResolvedContribs({ + from: input('contribs'), + }), + + // TODO: Fairly certain exitWithoutDependency would be sufficient here. + + withResultOfAvailabilityCheck({ + from: '#resolvedContribs', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability', input('value')], + compute: (continuation, { + ['#availability']: availability, + [input('value')]: value, + }) => + (availability + ? continuation() + : continuation.exit(value)), + }, + ], +}); diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js new file mode 100644 index 00000000..1d0400fc --- /dev/null +++ b/src/data/composite/wiki-data/index.js @@ -0,0 +1,7 @@ +export {default as exitWithoutContribs} from './exitWithoutContribs.js'; +export {default as inputThingClass} from './inputThingClass.js'; +export {default as inputWikiData} from './inputWikiData.js'; +export {default as withResolvedContribs} from './withResolvedContribs.js'; +export {default as withResolvedReference} from './withResolvedReference.js'; +export {default as withResolvedReferenceList} from './withResolvedReferenceList.js'; +export {default as withReverseReferenceList} from './withReverseReferenceList.js'; diff --git a/src/data/composite/wiki-data/inputThingClass.js b/src/data/composite/wiki-data/inputThingClass.js new file mode 100644 index 00000000..d70480e6 --- /dev/null +++ b/src/data/composite/wiki-data/inputThingClass.js @@ -0,0 +1,23 @@ +// Please note that this input, used in a variety of #composite/wiki-data +// utilities, is basically always a kludge. Any usage of it depends on +// referencing Thing class values defined outside of the #composite folder. + +import {input} from '#composite'; +import {isType} from '#validators'; + +// TODO: Kludge. +import Thing from '../../things/thing.js'; + +export default function inputThingClass() { + return input.staticValue({ + validate(thingClass) { + isType(thingClass, 'function'); + + if (!Object.hasOwn(thingClass, Thing.referenceType)) { + throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`); + } + + return true; + }, + }); +} diff --git a/src/data/composite/wiki-data/inputWikiData.js b/src/data/composite/wiki-data/inputWikiData.js new file mode 100644 index 00000000..cf7a7c2c --- /dev/null +++ b/src/data/composite/wiki-data/inputWikiData.js @@ -0,0 +1,17 @@ +import {input} from '#composite'; +import {validateWikiData} from '#validators'; + +// TODO: This doesn't access a class's own ThingSubclass[Thing.referenceType] +// value because classes aren't initialized by when templateCompositeFrom gets +// called (see: circular imports). So the reference types have to be hard-coded, +// which somewhat defeats the point of storing them on the class in the first +// place... +export default function inputWikiData({ + referenceType = '', + allowMixedTypes = false, +} = {}) { + return input({ + validate: validateWikiData({referenceType, allowMixedTypes}), + acceptsNull: true, + }); +} diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js new file mode 100644 index 00000000..eda24160 --- /dev/null +++ b/src/data/composite/wiki-data/withResolvedContribs.js @@ -0,0 +1,77 @@ +// Resolves the contribsByRef contained in the provided dependency, +// providing (named by the second argument) the result. "Resolving" +// means mapping the "who" reference of each contribution to an artist +// object, and filtering out those whose "who" doesn't match any artist. + +import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; +import {stitchArrays} from '#sugar'; +import {is, isContributionList} from '#validators'; +import {filterMultipleArrays} from '#wiki-data'; + +import { + raiseOutputWithoutDependency, +} from '#composite/control-flow'; + +import { + withPropertiesFromList, +} from '#composite/data'; + +import withResolvedReferenceList from './withResolvedReferenceList.js'; + +export default templateCompositeFrom({ + annotation: `withResolvedContribs`, + + inputs: { + from: input({ + validate: isContributionList, + acceptsNull: true, + }), + + notFoundMode: input({ + validate: is('exit', 'filter', 'null'), + defaultValue: 'null', + }), + }, + + outputs: ['#resolvedContribs'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: input('from'), + mode: input.value('empty'), + output: input.value({ + ['#resolvedContribs']: [], + }), + }), + + withPropertiesFromList({ + list: input('from'), + properties: input.value(['who', 'what']), + prefix: input.value('#contribs'), + }), + + withResolvedReferenceList({ + list: '#contribs.who', + data: 'artistData', + find: input.value(find.artist), + notFoundMode: input('notFoundMode'), + }).outputs({ + ['#resolvedReferenceList']: '#contribs.who', + }), + + { + dependencies: ['#contribs.who', '#contribs.what'], + + compute(continuation, { + ['#contribs.who']: who, + ['#contribs.what']: what, + }) { + filterMultipleArrays(who, what, (who, _what) => who); + return continuation({ + ['#resolvedContribs']: stitchArrays({who, what}), + }); + }, + }, + ], +}); diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js new file mode 100644 index 00000000..0fa5c554 --- /dev/null +++ b/src/data/composite/wiki-data/withResolvedReference.js @@ -0,0 +1,73 @@ +// Resolves a reference by using the provided find function to match it +// within the provided thingData dependency. This will early exit if the +// data dependency is null, or, if notFoundMode is set to 'exit', if the find +// function doesn't match anything for the reference. Otherwise, the data +// object is provided on the output dependency; or null, if the reference +// doesn't match anything or itself was null to begin with. + +import {input, templateCompositeFrom} from '#composite'; +import {is} from '#validators'; + +import { + exitWithoutDependency, + raiseOutputWithoutDependency, +} from '#composite/control-flow'; + +import inputWikiData from './inputWikiData.js'; + +export default templateCompositeFrom({ + annotation: `withResolvedReference`, + + inputs: { + ref: input({type: 'string', acceptsNull: true}), + + data: inputWikiData({allowMixedTypes: false}), + find: input({type: 'function'}), + + notFoundMode: input({ + validate: is('null', 'exit'), + defaultValue: 'null', + }), + }, + + outputs: ['#resolvedReference'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: input('ref'), + output: input.value({ + ['#resolvedReference']: null, + }), + }), + + exitWithoutDependency({ + dependency: input('data'), + }), + + { + dependencies: [ + input('ref'), + input('data'), + input('find'), + input('notFoundMode'), + ], + + compute(continuation, { + [input('ref')]: ref, + [input('data')]: data, + [input('find')]: findFunction, + [input('notFoundMode')]: notFoundMode, + }) { + const match = findFunction(ref, data, {mode: 'quiet'}); + + if (match === null && notFoundMode === 'exit') { + return continuation.exit(null); + } + + return continuation.raiseOutput({ + ['#resolvedReference']: match ?? null, + }); + }, + }, + ], +}); diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js new file mode 100644 index 00000000..1d39e5b2 --- /dev/null +++ b/src/data/composite/wiki-data/withResolvedReferenceList.js @@ -0,0 +1,101 @@ +// Resolves a list of references, with each reference matched with provided +// data in the same way as withResolvedReference. This will early exit if the +// data dependency is null (even if the reference list is empty). By default +// it will filter out references which don't match, but this can be changed +// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null'). + +import {input, templateCompositeFrom} from '#composite'; +import {is, isString, validateArrayItems} from '#validators'; + +import { + exitWithoutDependency, + raiseOutputWithoutDependency, +} from '#composite/control-flow'; + +import inputWikiData from './inputWikiData.js'; + +export default templateCompositeFrom({ + annotation: `withResolvedReferenceList`, + + inputs: { + list: input({ + validate: validateArrayItems(isString), + acceptsNull: true, + }), + + data: inputWikiData({allowMixedTypes: false}), + find: input({type: 'function'}), + + notFoundMode: input({ + validate: is('exit', 'filter', 'null'), + defaultValue: 'filter', + }), + }, + + outputs: ['#resolvedReferenceList'], + + steps: () => [ + exitWithoutDependency({ + dependency: input('data'), + value: input.value([]), + }), + + raiseOutputWithoutDependency({ + dependency: input('list'), + mode: input.value('empty'), + output: input.value({ + ['#resolvedReferenceList']: [], + }), + }), + + { + dependencies: [input('list'), input('data'), input('find')], + compute: (continuation, { + [input('list')]: list, + [input('data')]: data, + [input('find')]: findFunction, + }) => + continuation({ + '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})), + }), + }, + + { + dependencies: ['#matches'], + compute: (continuation, {'#matches': matches}) => + (matches.every(match => match) + ? continuation.raiseOutput({ + ['#resolvedReferenceList']: matches, + }) + : continuation()), + }, + + { + dependencies: ['#matches', input('notFoundMode')], + compute(continuation, { + ['#matches']: matches, + [input('notFoundMode')]: notFoundMode, + }) { + switch (notFoundMode) { + case 'exit': + return continuation.exit([]); + + case 'filter': + return continuation.raiseOutput({ + ['#resolvedReferenceList']: + matches.filter(match => match), + }); + + case 'null': + return continuation.raiseOutput({ + ['#resolvedReferenceList']: + matches.map(match => match ?? null), + }); + + default: + throw new TypeError(`Expected notFoundMode to be exit, filter, or null`); + } + }, + }, + ], +}); diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js new file mode 100644 index 00000000..a025b5ed --- /dev/null +++ b/src/data/composite/wiki-data/withReverseReferenceList.js @@ -0,0 +1,41 @@ +// Check out the info on reverseReferenceList! +// This is its composable form. + +import {input, templateCompositeFrom} from '#composite'; + +import {exitWithoutDependency} from '#composite/control-flow'; + +import inputWikiData from './inputWikiData.js'; + +export default templateCompositeFrom({ + annotation: `withReverseReferenceList`, + + inputs: { + data: inputWikiData({allowMixedTypes: false}), + list: input({type: 'string'}), + }, + + outputs: ['#reverseReferenceList'], + + steps: () => [ + exitWithoutDependency({ + dependency: input('data'), + value: input.value([]), + mode: input.value('empty'), + }), + + { + dependencies: [input.myself(), input('data'), input('list')], + + compute: (continuation, { + [input.myself()]: thisThing, + [input('data')]: data, + [input('list')]: refListProperty, + }) => + continuation({ + ['#reverseReferenceList']: + data.filter(thing => thing[refListProperty].includes(thisThing)), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-properties/additionalFiles.js b/src/data/composite/wiki-properties/additionalFiles.js new file mode 100644 index 00000000..6760527a --- /dev/null +++ b/src/data/composite/wiki-properties/additionalFiles.js @@ -0,0 +1,30 @@ +// This is a somewhat more involved data structure - it's for additional +// or "bonus" files associated with albums or tracks (or anything else). +// It's got this form: +// +// [ +// {title: 'Booklet', files: ['Booklet.pdf']}, +// { +// title: 'Wallpaper', +// description: 'Cool Wallpaper!', +// files: ['1440x900.png', '1920x1080.png'] +// }, +// {title: 'Alternate Covers', description: null, files: [...]}, +// ... +// ] +// + +import {isAdditionalFileList} from '#validators'; + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isAdditionalFileList}, + expose: { + transform: (additionalFiles) => + additionalFiles ?? [], + }, + }; +} diff --git a/src/data/composite/wiki-properties/color.js b/src/data/composite/wiki-properties/color.js new file mode 100644 index 00000000..1bc9888b --- /dev/null +++ b/src/data/composite/wiki-properties/color.js @@ -0,0 +1,12 @@ +// A color! This'll be some CSS-ready value. + +import {isColor} from '#validators'; + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isColor}, + }; +} diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js new file mode 100644 index 00000000..fbea9d5c --- /dev/null +++ b/src/data/composite/wiki-properties/commentary.js @@ -0,0 +1,12 @@ +// Artist commentary! Generally present on tracks and albums. + +import {isCommentary} from '#validators'; + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isCommentary}, + }; +} diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js new file mode 100644 index 00000000..52aeb868 --- /dev/null +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -0,0 +1,55 @@ +// This one's kinda tricky: it parses artist "references" from the +// commentary content, and finds the matching artist for each reference. +// This is mostly useful for credits and listings on artist pages. + +import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; +import {unique} from '#sugar'; + +import {exitWithoutDependency} from '#composite/control-flow'; +import {withResolvedReferenceList} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `commentatorArtists`, + + compose: false, + + steps: () => [ + exitWithoutDependency({ + dependency: 'commentary', + mode: input.value('falsy'), + value: input.value([]), + }), + + { + dependencies: ['commentary'], + compute: (continuation, {commentary}) => + continuation({ + '#artistRefs': + Array.from( + commentary + .replace(/<\/?b>/g, '') + .matchAll(/<i>(?<who>.*?):<\/i>/g)) + .map(({groups: {who}}) => who), + }), + }, + + withResolvedReferenceList({ + list: '#artistRefs', + data: 'artistData', + find: input.value(find.artist), + }).outputs({ + '#resolvedReferenceList': '#artists', + }), + + { + flags: {expose: true}, + + expose: { + dependencies: ['#artists'], + compute: ({'#artists': artists}) => + unique(artists), + }, + }, + ], +}); diff --git a/src/data/composite/wiki-properties/contribsPresent.js b/src/data/composite/wiki-properties/contribsPresent.js new file mode 100644 index 00000000..24f302a5 --- /dev/null +++ b/src/data/composite/wiki-properties/contribsPresent.js @@ -0,0 +1,30 @@ +// Nice 'n simple shorthand for an exposed-only flag which is true when any +// contributions are present in the specified property. + +import {input, templateCompositeFrom} from '#composite'; +import {isContributionList} from '#validators'; + +import {exposeDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: `contribsPresent`, + + compose: false, + + inputs: { + contribs: input.staticDependency({ + validate: isContributionList, + acceptsNull: true, + }), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('contribs'), + mode: input.value('empty'), + }), + + exposeDependency({dependency: '#availability'}), + ], +}); diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js new file mode 100644 index 00000000..8fde2caa --- /dev/null +++ b/src/data/composite/wiki-properties/contributionList.js @@ -0,0 +1,35 @@ +// Strong 'n sturdy contribution list, rolling a list of references (provided +// as this property's update value) and the resolved results (as get exposed) +// into one property. Update value will look something like this: +// +// [ +// {who: 'Artist Name', what: 'Viola'}, +// {who: 'artist:john-cena', what: null}, +// ... +// ] +// +// ...typically as processed from YAML, spreadsheet, or elsewhere. +// Exposes as the same, but with the "who" replaced with matches found in +// artistData - which means this always depends on an `artistData` property +// also existing on this object! +// + +import {input, templateCompositeFrom} from '#composite'; +import {isContributionList} from '#validators'; + +import {exposeConstant, exposeDependencyOrContinue} from '#composite/control-flow'; +import {withResolvedContribs} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `contributionList`, + + compose: false, + + update: {validate: isContributionList}, + + steps: () => [ + withResolvedContribs({from: input.updateValue()}), + exposeDependencyOrContinue({dependency: '#resolvedContribs'}), + exposeConstant({value: input.value([])}), + ], +}); diff --git a/src/data/composite/wiki-properties/dimensions.js b/src/data/composite/wiki-properties/dimensions.js new file mode 100644 index 00000000..57a01279 --- /dev/null +++ b/src/data/composite/wiki-properties/dimensions.js @@ -0,0 +1,13 @@ +// Plain ol' image dimensions. This is a two-item array of positive integers, +// corresponding to width and height respectively. + +import {isDimensions} from '#validators'; + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isDimensions}, + }; +} diff --git a/src/data/composite/wiki-properties/directory.js b/src/data/composite/wiki-properties/directory.js new file mode 100644 index 00000000..0b2181c9 --- /dev/null +++ b/src/data/composite/wiki-properties/directory.js @@ -0,0 +1,23 @@ +// The all-encompassing "directory" property, used as the unique identifier for +// almost any data object. Also corresponds to a part of the URL which pages of +// such objects are visited at. + +import {isDirectory} from '#validators'; +import {getKebabCase} from '#wiki-data'; + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + expose: { + dependencies: ['name'], + transform(directory, {name}) { + if (directory === null && name === null) return null; + else if (directory === null) return getKebabCase(name); + else return directory; + }, + }, + }; +} diff --git a/src/data/composite/wiki-properties/duration.js b/src/data/composite/wiki-properties/duration.js new file mode 100644 index 00000000..827f282d --- /dev/null +++ b/src/data/composite/wiki-properties/duration.js @@ -0,0 +1,13 @@ +// Duration! This is a number of seconds, possibly floating point, always +// at minimum zero. + +import {isDuration} from '#validators'; + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isDuration}, + }; +} diff --git a/src/data/composite/wiki-properties/externalFunction.js b/src/data/composite/wiki-properties/externalFunction.js new file mode 100644 index 00000000..c388da6c --- /dev/null +++ b/src/data/composite/wiki-properties/externalFunction.js @@ -0,0 +1,11 @@ +// External function. These should only be used as dependencies for other +// properties, so they're left unexposed. + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true}, + update: {validate: (t) => typeof t === 'function'}, + }; +} diff --git a/src/data/composite/wiki-properties/fileExtension.js b/src/data/composite/wiki-properties/fileExtension.js new file mode 100644 index 00000000..c926fa8b --- /dev/null +++ b/src/data/composite/wiki-properties/fileExtension.js @@ -0,0 +1,13 @@ +// A file extension! Or the default, if provided when calling this. + +import {isFileExtension} from '#validators'; + +// TODO: Not templateCompositeFrom. + +export default function(defaultFileExtension = null) { + return { + flags: {update: true, expose: true}, + update: {validate: isFileExtension}, + expose: {transform: (value) => value ?? defaultFileExtension}, + }; +} diff --git a/src/data/composite/wiki-properties/flag.js b/src/data/composite/wiki-properties/flag.js new file mode 100644 index 00000000..076e663f --- /dev/null +++ b/src/data/composite/wiki-properties/flag.js @@ -0,0 +1,19 @@ +// Straightforward flag descriptor for a variety of property purposes. +// Provide a default value, true or false! + +import {isBoolean} from '#validators'; + +// TODO: Not templateCompositeFrom. + +// TODO: The description is a lie. This defaults to false. Bad. + +export default function(defaultValue = false) { + if (typeof defaultValue !== 'boolean') { + throw new TypeError(`Always set explicit defaults for flags!`); + } + + return { + flags: {update: true, expose: true}, + update: {validate: isBoolean, default: defaultValue}, + }; +} diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js new file mode 100644 index 00000000..2462b047 --- /dev/null +++ b/src/data/composite/wiki-properties/index.js @@ -0,0 +1,20 @@ +export {default as additionalFiles} from './additionalFiles.js'; +export {default as color} from './color.js'; +export {default as commentary} from './commentary.js'; +export {default as commentatorArtists} from './commentatorArtists.js'; +export {default as contribsPresent} from './contribsPresent.js'; +export {default as contributionList} from './contributionList.js'; +export {default as dimensions} from './dimensions.js'; +export {default as directory} from './directory.js'; +export {default as duration} from './duration.js'; +export {default as externalFunction} from './externalFunction.js'; +export {default as fileExtension} from './fileExtension.js'; +export {default as flag} from './flag.js'; +export {default as name} from './name.js'; +export {default as referenceList} from './referenceList.js'; +export {default as reverseReferenceList} from './reverseReferenceList.js'; +export {default as simpleDate} from './simpleDate.js'; +export {default as simpleString} from './simpleString.js'; +export {default as singleReference} from './singleReference.js'; +export {default as urls} from './urls.js'; +export {default as wikiData} from './wikiData.js'; diff --git a/src/data/composite/wiki-properties/name.js b/src/data/composite/wiki-properties/name.js new file mode 100644 index 00000000..5146488b --- /dev/null +++ b/src/data/composite/wiki-properties/name.js @@ -0,0 +1,11 @@ +// A wiki data object's name! Its directory (i.e. unique identifier) will be +// computed based on this value if not otherwise specified. + +import {isName} from '#validators'; + +export default function(defaultName) { + return { + flags: {update: true, expose: true}, + update: {validate: isName, default: defaultName}, + }; +} diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js new file mode 100644 index 00000000..f5b6c58e --- /dev/null +++ b/src/data/composite/wiki-properties/referenceList.js @@ -0,0 +1,47 @@ +// Stores and exposes a list of references to other data objects; all items +// must be references to the same type, which is specified on the class input. +// +// See also: +// - singleReference +// - withResolvedReferenceList +// + +import {input, templateCompositeFrom} from '#composite'; +import {validateReferenceList} from '#validators'; + +import {exposeDependency} from '#composite/control-flow'; +import {inputThingClass, inputWikiData, withResolvedReferenceList} + from '#composite/wiki-data'; + +// TODO: Kludge. +import Thing from '../../things/thing.js'; + +export default templateCompositeFrom({ + annotation: `referenceList`, + + compose: false, + + inputs: { + class: inputThingClass(), + + data: inputWikiData({allowMixedTypes: false}), + find: input({type: 'function'}), + }, + + update: ({ + [input.staticValue('class')]: thingClass, + }) => { + const {[Thing.referenceType]: referenceType} = thingClass; + return {validate: validateReferenceList(referenceType)}; + }, + + steps: () => [ + withResolvedReferenceList({ + list: input.updateValue(), + data: input('data'), + find: input('find'), + }), + + exposeDependency({dependency: '#resolvedReferenceList'}), + ], +}); diff --git a/src/data/composite/wiki-properties/reverseReferenceList.js b/src/data/composite/wiki-properties/reverseReferenceList.js new file mode 100644 index 00000000..84ba67df --- /dev/null +++ b/src/data/composite/wiki-properties/reverseReferenceList.js @@ -0,0 +1,30 @@ +// Neat little shortcut for "reversing" the reference lists stored on other +// things - for example, tracks specify a "referenced tracks" property, and +// you would use this to compute a corresponding "referenced *by* tracks" +// property. Naturally, the passed ref list property is of the things in the +// wiki data provided, not the requesting Thing itself. + +import {input, templateCompositeFrom} from '#composite'; + +import {exposeDependency} from '#composite/control-flow'; +import {inputWikiData, withReverseReferenceList} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `reverseReferenceList`, + + compose: false, + + inputs: { + data: inputWikiData({allowMixedTypes: false}), + list: input({type: 'string'}), + }, + + steps: () => [ + withReverseReferenceList({ + data: input('data'), + list: input('list'), + }), + + exposeDependency({dependency: '#reverseReferenceList'}), + ], +}); diff --git a/src/data/composite/wiki-properties/simpleDate.js b/src/data/composite/wiki-properties/simpleDate.js new file mode 100644 index 00000000..f08d8323 --- /dev/null +++ b/src/data/composite/wiki-properties/simpleDate.js @@ -0,0 +1,14 @@ +// General date type, used as the descriptor for a bunch of properties. +// This isn't dynamic though - it won't inherit from a date stored on +// another object, for example. + +import {isDate} from '#validators'; + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isDate}, + }; +} diff --git a/src/data/composite/wiki-properties/simpleString.js b/src/data/composite/wiki-properties/simpleString.js new file mode 100644 index 00000000..18d65146 --- /dev/null +++ b/src/data/composite/wiki-properties/simpleString.js @@ -0,0 +1,14 @@ +// General string type. This should probably generally be avoided in favor +// of more specific validation, but using it makes it easy to find where we +// might want to improve later, and it's a useful shorthand meanwhile. + +import {isString} from '#validators'; + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isString}, + }; +} diff --git a/src/data/composite/wiki-properties/singleReference.js b/src/data/composite/wiki-properties/singleReference.js new file mode 100644 index 00000000..34bd2e6d --- /dev/null +++ b/src/data/composite/wiki-properties/singleReference.js @@ -0,0 +1,47 @@ +// Stores and exposes one connection, or reference, to another data object. +// The reference must be to a specific type, which is specified on the class +// input. +// +// See also: +// - referenceList +// - withResolvedReference +// + +import {input, templateCompositeFrom} from '#composite'; +import {validateReference} from '#validators'; + +import {exposeDependency} from '#composite/control-flow'; +import {inputThingClass, inputWikiData, withResolvedReference} + from '#composite/wiki-data'; + +// TODO: Kludge. +import Thing from '../../things/thing.js'; + +export default templateCompositeFrom({ + annotation: `singleReference`, + + compose: false, + + inputs: { + class: inputThingClass(), + find: input({type: 'function'}), + data: inputWikiData({allowMixedTypes: false}), + }, + + update: ({ + [input.staticValue('class')]: thingClass, + }) => { + const {[Thing.referenceType]: referenceType} = thingClass; + return {validate: validateReference(referenceType)}; + }, + + steps: () => [ + withResolvedReference({ + ref: input.updateValue(), + data: input('data'), + find: input('find'), + }), + + exposeDependency({dependency: '#resolvedReference'}), + ], +}); diff --git a/src/data/composite/wiki-properties/urls.js b/src/data/composite/wiki-properties/urls.js new file mode 100644 index 00000000..3160a0bf --- /dev/null +++ b/src/data/composite/wiki-properties/urls.js @@ -0,0 +1,14 @@ +// A list of URLs! This will always be present on the data object, even if set +// to an empty array or null. + +import {isURL, validateArrayItems} from '#validators'; + +// TODO: Not templateCompositeFrom. + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isURL)}, + expose: {transform: value => value ?? []}, + }; +} diff --git a/src/data/composite/wiki-properties/wikiData.js b/src/data/composite/wiki-properties/wikiData.js new file mode 100644 index 00000000..4ea47785 --- /dev/null +++ b/src/data/composite/wiki-properties/wikiData.js @@ -0,0 +1,17 @@ +// General purpose wiki data constructor, for properties like artistData, +// trackData, etc. + +import {validateArrayItems, validateInstanceOf} from '#validators'; + +// TODO: Not templateCompositeFrom. + +// TODO: This should validate with validateWikiData. + +export default function(thingClass) { + return { + flags: {update: true}, + update: { + validate: validateArrayItems(validateInstanceOf(thingClass)), + }, + }; +} diff --git a/src/data/things/album.js b/src/data/things/album.js index c012c243..546fda3b 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,163 +1,143 @@ -import {empty} from '#sugar'; +import {input} from '#composite'; import find from '#find'; +import {isDate} from '#validators'; + +import {exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {exitWithoutContribs} from '#composite/wiki-data'; + +import { + additionalFiles, + commentary, + color, + commentatorArtists, + contribsPresent, + contributionList, + dimensions, + directory, + fileExtension, + flag, + name, + referenceList, + simpleDate, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import { + withTracks, + withTrackSections, +} from '#composite/things/album'; import Thing from './thing.js'; export class Album extends Thing { static [Thing.referenceType] = 'album'; - static [Thing.getPropertyDescriptors] = ({ - ArtTag, - Artist, - Group, - Track, - - validators: { - isDate, - isDimensions, - isTrackSectionList, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({ // Update & expose - name: Thing.common.name('Unnamed Album'), - color: Thing.common.color(), - directory: Thing.common.directory(), - urls: Thing.common.urls(), - - date: Thing.common.simpleDate(), - trackArtDate: Thing.common.simpleDate(), - dateAddedToWiki: Thing.common.simpleDate(), - - coverArtDate: { - flags: {update: true, expose: true}, - - update: {validate: isDate}, - - expose: { - dependencies: ['date', 'coverArtistContribsByRef'], - transform: (coverArtDate, { - coverArtistContribsByRef, - date, - }) => - (!empty(coverArtistContribsByRef) - ? coverArtDate ?? date ?? null - : null), - }, - }, - - artistContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), - trackCoverArtistContribsByRef: Thing.common.contribsByRef(), - wallpaperArtistContribsByRef: Thing.common.contribsByRef(), - bannerArtistContribsByRef: Thing.common.contribsByRef(), - - groupsByRef: Thing.common.referenceList(Group), - artTagsByRef: Thing.common.referenceList(ArtTag), - - trackSections: { - flags: {update: true, expose: true}, - - update: { - validate: isTrackSectionList, - }, - - expose: { - dependencies: ['color', 'trackData'], - transform(trackSections, { - color: albumColor, - trackData, - }) { - let startIndex = 0; - return trackSections?.map(section => ({ - name: section.name ?? null, - color: section.color ?? albumColor ?? null, - dateOriginallyReleased: section.dateOriginallyReleased ?? null, - isDefaultTrackSection: section.isDefaultTrackSection ?? false, - - startIndex: ( - startIndex += section.tracksByRef.length, - startIndex - section.tracksByRef.length - ), - - tracksByRef: section.tracksByRef ?? [], - tracks: - (trackData && section.tracksByRef - ?.map(ref => find.track(ref, trackData, {mode: 'quiet'})) - .filter(Boolean)) ?? - [], - })); - }, - }, - }, - - coverArtFileExtension: Thing.common.fileExtension('jpg'), - trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), - - wallpaperStyle: Thing.common.simpleString(), - wallpaperFileExtension: Thing.common.fileExtension('jpg'), - - bannerStyle: Thing.common.simpleString(), - bannerFileExtension: Thing.common.fileExtension('jpg'), - bannerDimensions: { - flags: {update: true, expose: true}, - update: {validate: isDimensions}, - }, - - hasTrackNumbers: Thing.common.flag(true), - isListedOnHomepage: Thing.common.flag(true), - isListedInGalleries: Thing.common.flag(true), - - commentary: Thing.common.commentary(), - additionalFiles: Thing.common.additionalFiles(), + name: name('Unnamed Album'), + color: color(), + directory: directory(), + urls: urls(), + + date: simpleDate(), + trackArtDate: simpleDate(), + dateAddedToWiki: simpleDate(), + + coverArtDate: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + exposeDependency({dependency: 'date'}), + ], + + coverArtFileExtension: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + fileExtension('jpg'), + ], + + trackCoverArtFileExtension: fileExtension('jpg'), + + wallpaperFileExtension: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + fileExtension('jpg'), + ], + + bannerFileExtension: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + fileExtension('jpg'), + ], + + wallpaperStyle: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + simpleString(), + ], + + bannerStyle: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + simpleString(), + ], + + bannerDimensions: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + dimensions(), + ], + + hasTrackNumbers: flag(true), + isListedOnHomepage: flag(true), + isListedInGalleries: flag(true), + + commentary: commentary(), + additionalFiles: additionalFiles(), + + trackSections: [ + withTrackSections(), + exposeDependency({dependency: '#trackSections'}), + ], + + artistContribs: contributionList(), + coverArtistContribs: contributionList(), + trackCoverArtistContribs: contributionList(), + wallpaperArtistContribs: contributionList(), + bannerArtistContribs: contributionList(), + + groups: referenceList({ + class: input.value(Group), + find: input.value(find.group), + data: 'groupData', + }), + + artTags: referenceList({ + class: input.value(ArtTag), + find: input.value(find.artTag), + data: 'artTagData', + }), // Update only - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - groupData: Thing.common.wikiData(Group), - trackData: Thing.common.wikiData(Track), + artistData: wikiData(Artist), + artTagData: wikiData(ArtTag), + groupData: wikiData(Group), + trackData: wikiData(Track), // Expose only - artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), - coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), - trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'), - wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'), - bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'), - - commentatorArtists: Thing.common.commentatorArtists(), - - hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'), - hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'), - hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'), - - tracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackSections', 'trackData'], - compute: ({trackSections, trackData}) => - trackSections && trackData - ? trackSections - .flatMap((section) => section.tracksByRef ?? []) - .map((ref) => find.track(ref, trackData, {mode: 'quiet'})) - .filter(Boolean) - : [], - }, - }, - - groups: Thing.common.dynamicThingsFromReferenceList( - 'groupsByRef', - 'groupData', - find.group - ), - - artTags: Thing.common.dynamicThingsFromReferenceList( - 'artTagsByRef', - 'artTagData', - find.artTag - ), + commentatorArtists: commentatorArtists(), + + hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), + hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), + + tracks: [ + withTracks(), + exposeDependency({dependency: '#tracks'}), + ], }); static [Thing.getSerializeDescriptors] = ({ @@ -201,10 +181,12 @@ export class Album extends Thing { } export class TrackSectionHelper extends Thing { + static [Thing.friendlyName] = `Track Section`; + static [Thing.getPropertyDescriptors] = () => ({ - name: Thing.common.name('Unnamed Track Group'), - color: Thing.common.color(), - dateOriginallyReleased: Thing.common.simpleDate(), - isDefaultTrackGroup: Thing.common.flag(false), + name: name('Unnamed Track Section'), + color: color(), + dateOriginallyReleased: simpleDate(), + isDefaultTrackGroup: flag(false), }) } diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index c103c4d5..6503beec 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,35 +1,47 @@ +import {input} from '#composite'; import {sortAlbumsTracksChronologically} from '#wiki-data'; +import {isName} from '#validators'; + +import {exposeUpdateValueOrContinue} from '#composite/control-flow'; + +import { + color, + directory, + flag, + name, + wikiData, +} from '#composite/wiki-properties'; import Thing from './thing.js'; export class ArtTag extends Thing { static [Thing.referenceType] = 'tag'; + static [Thing.friendlyName] = `Art Tag`; - static [Thing.getPropertyDescriptors] = ({ - Album, - Track, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ // Update & expose - name: Thing.common.name('Unnamed Art Tag'), - directory: Thing.common.directory(), - color: Thing.common.color(), - isContentWarning: Thing.common.flag(false), + name: name('Unnamed Art Tag'), + directory: directory(), + color: color(), + isContentWarning: flag(false), - nameShort: { - flags: {update: true, expose: true}, + nameShort: [ + exposeUpdateValueOrContinue({ + validate: input.value(isName), + }), - expose: { + { dependencies: ['name'], - transform: (value, {name}) => - value ?? name.replace(/ \(.*?\)$/, ''), + compute: ({name}) => + name.replace(/ \([^)]*?\)$/, ''), }, - }, + ], // Update only - albumData: Thing.common.wikiData(Album), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + trackData: wikiData(Track), // Expose only @@ -37,8 +49,8 @@ export class ArtTag extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData', 'trackData'], - compute: ({albumData, trackData, [ArtTag.instance]: artTag}) => + dependencies: ['this', 'albumData', 'trackData'], + compute: ({this: artTag, albumData, trackData}) => sortAlbumsTracksChronologically( [...albumData, ...trackData] .filter(({artTags}) => artTags.includes(artTag)), diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 6d4f4a0d..1b313db6 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -1,29 +1,33 @@ +import {input} from '#composite'; import find from '#find'; +import {isName, validateArrayItems} from '#validators'; + +import { + directory, + fileExtension, + flag, + name, + simpleString, + singleReference, + urls, + wikiData, +} from '#composite/wiki-properties'; import Thing from './thing.js'; export class Artist extends Thing { static [Thing.referenceType] = 'artist'; - static [Thing.getPropertyDescriptors] = ({ - Album, - Flash, - Track, - - validators: { - isName, - validateArrayItems, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({ // Update & expose - name: Thing.common.name('Unnamed Artist'), - directory: Thing.common.directory(), - urls: Thing.common.urls(), - contextNotes: Thing.common.simpleString(), + name: name('Unnamed Artist'), + directory: directory(), + urls: urls(), + contextNotes: simpleString(), - hasAvatar: Thing.common.flag(false), - avatarFileExtension: Thing.common.fileExtension('jpg'), + hasAvatar: flag(false), + avatarFileExtension: fileExtension('jpg'), aliasNames: { flags: {update: true, expose: true}, @@ -31,30 +35,23 @@ export class Artist extends Thing { expose: {transform: (names) => names ?? []}, }, - isAlias: Thing.common.flag(), - aliasedArtistRef: Thing.common.singleReference(Artist), + isAlias: flag(), + + aliasedArtist: singleReference({ + class: input.value(Artist), + find: input.value(find.artist), + data: 'artistData', + }), // Update only - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + artistData: wikiData(Artist), + flashData: wikiData(Flash), + trackData: wikiData(Track), // Expose only - aliasedArtist: { - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'aliasedArtistRef'], - compute: ({artistData, aliasedArtistRef}) => - aliasedArtistRef && artistData - ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'}) - : null, - }, - }, - tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'), tracksAsContributor: @@ -66,14 +63,14 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Artist.instance]: artist}) => + compute: ({this: artist, trackData}) => trackData?.filter((track) => [ - ...track.artistContribs, - ...track.contributorContribs, - ...track.coverArtistContribs, + ...track.artistContribs ?? [], + ...track.contributorContribs ?? [], + ...track.coverArtistContribs ?? [], ].some(({who}) => who === artist)) ?? [], }, }, @@ -82,9 +79,9 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Artist.instance]: artist}) => + compute: ({this: artist, trackData}) => trackData?.filter(({commentatorArtists}) => commentatorArtists.includes(artist)) ?? [], }, @@ -120,18 +117,16 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData'], + dependencies: ['this', 'albumData'], - compute: ({albumData, [Artist.instance]: artist}) => + compute: ({this: artist, albumData}) => albumData?.filter(({commentatorArtists}) => commentatorArtists.includes(artist)) ?? [], }, }, - flashesAsContributor: Artist.filterByContrib( - 'flashData', - 'contributorContribs' - ), + flashesAsContributor: + Artist.filterByContrib('flashData', 'contributorContribs'), }); static [Thing.getSerializeDescriptors] = ({ @@ -165,15 +160,15 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: [thingDataProperty], + dependencies: ['this', thingDataProperty], compute: ({ + this: artist, [thingDataProperty]: thingData, - [Artist.instance]: artist }) => thingData?.filter(thing => thing[contribsProperty] - .some(contrib => contrib.who === artist)) ?? [], + ?.some(contrib => contrib.who === artist)) ?? [], }, }); } diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js index ea705a61..9fda865e 100644 --- a/src/data/things/cacheable-object.js +++ b/src/data/things/cacheable-object.js @@ -76,28 +76,24 @@ import {inspect as nodeInspect} from 'node:util'; -import {color, ENABLE_COLOR} from '#cli'; +import {colors, ENABLE_COLOR} from '#cli'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); } export default class CacheableObject { - static instance = Symbol('CacheableObject `this` instance'); - #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. - */ + // 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(); @@ -143,7 +139,7 @@ export default class CacheableObject { const definition = { configurable: false, - enumerable: true, + enumerable: flags.expose, }; if (flags.update) { @@ -183,13 +179,8 @@ export default class CacheableObject { } else if (result !== true) { throw new TypeError(`Validation failed for value ${newValue}`); } - } catch (error) { - error.message = [ - `Property ${color.green(property)}`, - `(${inspect(this[property])} -> ${inspect(newValue)}):`, - error.message - ].join(' '); - throw error; + } catch (caughtError) { + throw new CacheableObjectPropertyValueError(property, this[property], newValue, caughtError); } } @@ -250,20 +241,27 @@ export default class CacheableObject { let getAllDependencies; - const dependencyKeys = expose.dependencies; - if (dependencyKeys?.length > 0) { - const reflectionEntry = [this.constructor.instance, this]; - const dependencyGetters = dependencyKeys - .map(key => () => [key, this.#propertyUpdateValues[key]]); + 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; + } - getAllDependencies = () => - Object.fromEntries(dependencyGetters - .map(f => f()) - .concat([reflectionEntry])); + return dependencies; + }; } else { - const allDependencies = {[this.constructor.instance]: this}; - Object.freeze(allDependencies); - getAllDependencies = () => allDependencies; + const dependencies = Object.create(null); + Object.freeze(dependencies); + getAllDependencies = () => dependencies; } if (flags.update) { @@ -347,4 +345,22 @@ export default class CacheableObject { console.log(` - ${line}`); } } + + static getUpdateValue(object, key) { + if (!Object.hasOwn(object, key)) { + return undefined; + } + + return object.#propertyUpdateValues[key] ?? null; + } +} + +export class CacheableObjectPropertyValueError extends Error { + constructor(property, oldValue, newValue, error) { + super( + `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`, + {cause: error}); + + this.property = property; + } } diff --git a/src/data/things/composite.js b/src/data/things/composite.js new file mode 100644 index 00000000..51525bc1 --- /dev/null +++ b/src/data/things/composite.js @@ -0,0 +1,1301 @@ +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; + + // 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 && !anyStepsCompute && !anyStepsTransform) { + aggregate.push(new TypeError(`Expected at least one step to compute or transform`)); + } + + 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) { + 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 6eb5234f..e2afcef4 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -1,25 +1,42 @@ +import {input} from '#composite'; import find from '#find'; +import { + isColor, + isDirectory, + isNumber, + isString, + oneOf, +} from '#validators'; + +import {exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +import { + color, + contributionList, + directory, + fileExtension, + name, + referenceList, + simpleDate, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import {withFlashAct} from '#composite/things/flash'; + import Thing from './thing.js'; export class Flash extends Thing { static [Thing.referenceType] = 'flash'; - static [Thing.getPropertyDescriptors] = ({ - Artist, - Track, - FlashAct, - - validators: { - isDirectory, - isNumber, - isString, - oneOf, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({ // Update & expose - name: Thing.common.name('Unnamed Flash'), + name: name('Unnamed Flash'), directory: { flags: {update: true, expose: true}, @@ -47,51 +64,51 @@ export class Flash extends Thing { }, }, - date: Thing.common.simpleDate(), - - coverArtFileExtension: Thing.common.fileExtension('jpg'), + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), - contributorContribsByRef: Thing.common.contribsByRef(), + withFlashAct(), - featuredTracksByRef: Thing.common.referenceList(Track), + withPropertyFromObject({ + object: '#flashAct', + property: input.value('color'), + }), - urls: Thing.common.urls(), + exposeDependency({dependency: '#flashAct.color'}), + ], - // Update only + date: simpleDate(), - artistData: Thing.common.wikiData(Artist), - trackData: Thing.common.wikiData(Track), - flashActData: Thing.common.wikiData(FlashAct), + coverArtFileExtension: fileExtension('jpg'), - // Expose only + contributorContribs: contributionList(), - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + featuredTracks: referenceList({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), - featuredTracks: Thing.common.dynamicThingsFromReferenceList( - 'featuredTracksByRef', - 'trackData', - find.track - ), + urls: urls(), - act: { - flags: {expose: true}, + // Update only - expose: { - dependencies: ['flashActData'], + artistData: wikiData(Artist), + trackData: wikiData(Track), + flashActData: wikiData(FlashAct), - compute: ({flashActData, [Flash.instance]: flash}) => - flashActData.find((act) => act.flashes.includes(flash)) ?? null, - }, - }, + // Expose only - color: { + act: { flags: {expose: true}, expose: { - dependencies: ['flashActData'], + dependencies: ['this', 'flashActData'], - compute: ({flashActData, [Flash.instance]: flash}) => - flashActData.find((act) => act.flashes.includes(flash))?.color ?? null, + compute: ({this: flash, flashActData}) => + flashActData.find((act) => act.flashes.includes(flash)) ?? null, }, }, }); @@ -111,17 +128,18 @@ export class Flash extends Thing { } export class FlashAct extends Thing { - static [Thing.getPropertyDescriptors] = ({ - validators: { - isColor, - }, - }) => ({ + static [Thing.referenceType] = 'flash-act'; + static [Thing.friendlyName] = `Flash Act`; + + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: Thing.common.name('Unnamed Flash Act'), - color: Thing.common.color(), - anchor: Thing.common.simpleString(), - jump: Thing.common.simpleString(), + name: name('Unnamed Flash Act'), + directory: directory(), + color: color(), + listTerminology: simpleString(), + + jump: simpleString(), jumpColor: { flags: {update: true, expose: true}, @@ -133,18 +151,14 @@ export class FlashAct extends Thing { } }, - flashesByRef: Thing.common.referenceList(Flash), + flashes: referenceList({ + class: input.value(Flash), + find: input.value(find.flash), + data: 'flashData', + }), // Update only - flashData: Thing.common.wikiData(Flash), - - // Expose only - - flashes: Thing.common.dynamicThingsFromReferenceList( - 'flashesByRef', - 'flashData', - find.flash - ), + flashData: wikiData(Flash), }) } diff --git a/src/data/things/group.js b/src/data/things/group.js index ba339b3e..8764a9db 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,33 +1,44 @@ +import {input} from '#composite'; import find from '#find'; +import { + color, + directory, + name, + referenceList, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + import Thing from './thing.js'; export class Group extends Thing { static [Thing.referenceType] = 'group'; - static [Thing.getPropertyDescriptors] = ({ - Album, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album}) => ({ // Update & expose - name: Thing.common.name('Unnamed Group'), - directory: Thing.common.directory(), + name: name('Unnamed Group'), + directory: directory(), - description: Thing.common.simpleString(), + description: simpleString(), - urls: Thing.common.urls(), + urls: urls(), - featuredAlbumsByRef: Thing.common.referenceList(Album), + featuredAlbums: referenceList({ + class: input.value(Album), + find: input.value(find.album), + data: 'albumData', + }), // Update only - albumData: Thing.common.wikiData(Album), - groupCategoryData: Thing.common.wikiData(GroupCategory), + albumData: wikiData(Album), + groupCategoryData: wikiData(GroupCategory), // Expose only - featuredAlbums: Thing.common.dynamicThingsFromReferenceList('featuredAlbumsByRef', 'albumData', find.album), - descriptionShort: { flags: {expose: true}, @@ -41,8 +52,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData'], - compute: ({albumData, [Group.instance]: group}) => + dependencies: ['this', 'albumData'], + compute: ({this: group, albumData}) => albumData?.filter((album) => album.groups.includes(group)) ?? [], }, }, @@ -51,9 +62,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['groupCategoryData'], - - compute: ({groupCategoryData, [Group.instance]: group}) => + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => groupCategoryData.find((category) => category.groups.includes(group)) ?.color, }, @@ -63,8 +73,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['groupCategoryData'], - compute: ({groupCategoryData, [Group.instance]: group}) => + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => groupCategoryData.find((category) => category.groups.includes(group)) ?? null, }, @@ -73,26 +83,22 @@ export class Group extends Thing { } export class GroupCategory extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Group, - }) => ({ + static [Thing.friendlyName] = `Group Category`; + + static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose - name: Thing.common.name('Unnamed Group Category'), - color: Thing.common.color(), + name: name('Unnamed Group Category'), + color: color(), - groupsByRef: Thing.common.referenceList(Group), + groups: referenceList({ + class: input.value(Group), + find: input.value(find.group), + data: 'groupData', + }), // Update only - groupData: Thing.common.wikiData(Group), - - // Expose only - - groups: Thing.common.dynamicThingsFromReferenceList( - 'groupsByRef', - 'groupData', - find.group - ), + groupData: wikiData(Group), }); } diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index ec9e9556..bfa971ca 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -1,20 +1,37 @@ +import {input} from '#composite'; import find from '#find'; +import { + is, + isCountingNumber, + isString, + isStringNonEmpty, + oneOf, + validateArrayItems, + validateInstanceOf, + validateReference, +} from '#validators'; + +import {exposeDependency} from '#composite/control-flow'; +import {withResolvedReference} from '#composite/wiki-data'; + +import { + color, + name, + referenceList, + simpleString, + wikiData, +} from '#composite/wiki-properties'; + import Thing from './thing.js'; export class HomepageLayout extends Thing { - static [Thing.getPropertyDescriptors] = ({ - HomepageLayoutRow, + static [Thing.friendlyName] = `Homepage Layout`; - validators: { - isStringNonEmpty, - validateArrayItems, - validateInstanceOf, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ // Update & expose - sidebarContent: Thing.common.simpleString(), + sidebarContent: simpleString(), navbarLinks: { flags: {update: true, expose: true}, @@ -32,13 +49,12 @@ export class HomepageLayout extends Thing { } export class HomepageLayoutRow extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Album, - Group, - }) => ({ + static [Thing.friendlyName] = `Homepage Row`; + + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ // Update & expose - name: Thing.common.name('Unnamed Homepage Row'), + name: name('Unnamed Homepage Row'), type: { flags: {update: true, expose: true}, @@ -50,30 +66,22 @@ export class HomepageLayoutRow extends Thing { }, }, - color: Thing.common.color(), + color: color(), // Update only // These aren't necessarily used by every HomepageLayoutRow subclass, but // for convenience of providing this data, every row accepts all wiki data // arrays depended upon by any subclass's behavior. - albumData: Thing.common.wikiData(Album), - groupData: Thing.common.wikiData(Group), + albumData: wikiData(Album), + groupData: wikiData(Group), }); } export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { - static [Thing.getPropertyDescriptors] = (opts, { - Album, - Group, - - validators: { - is, - isCountingNumber, - isString, - validateArrayItems, - }, - } = opts) => ({ + static [Thing.friendlyName] = `Homepage Albums Row`; + + static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), // Update & expose @@ -104,8 +112,39 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { }, }, - sourceGroupByRef: Thing.common.singleReference(Group), - sourceAlbumsByRef: Thing.common.referenceList(Album), + sourceGroup: [ + { + flags: {expose: true, update: true, compose: true}, + + update: { + validate: + oneOf( + is('new-releases', 'new-additions'), + validateReference(Group[Thing.referenceType])), + }, + + expose: { + transform: (value, continuation) => + (value === 'new-releases' || value === 'new-additions' + ? value + : continuation(value)), + }, + }, + + withResolvedReference({ + ref: input.updateValue(), + data: 'groupData', + find: input.value(find.group), + }), + + exposeDependency({dependency: '#resolvedReference'}), + ], + + sourceAlbums: referenceList({ + class: input.value(Album), + find: input.value(find.album), + data: 'albumData', + }), countAlbumsFromGroup: { flags: {update: true, expose: true}, @@ -116,19 +155,5 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { flags: {update: true, expose: true}, update: {validate: validateArrayItems(isString)}, }, - - // Expose only - - sourceGroup: Thing.common.dynamicThingFromSingleReference( - 'sourceGroupByRef', - 'groupData', - find.group - ), - - sourceAlbums: Thing.common.dynamicThingsFromReferenceList( - 'sourceAlbumsByRef', - 'albumData', - find.album - ), }); } diff --git a/src/data/things/index.js b/src/data/things/index.js index 591cdc3b..4ea1f007 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -2,9 +2,9 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {logError} from '#cli'; +import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; import {openAggregate, showAggregate} from '#sugar'; -import * as validators from '#validators'; import Thing from './thing.js'; @@ -21,7 +21,11 @@ import * as trackClasses from './track.js'; import * as wikiInfoClasses from './wiki-info.js'; export {default as Thing} from './thing.js'; -export {default as CacheableObject} from './cacheable-object.js'; + +export { + default as CacheableObject, + CacheableObjectPropertyValueError, +} from './cacheable-object.js'; const allClassLists = { 'album.js': albumClasses, @@ -82,6 +86,8 @@ function errorDuplicateClassNames() { function flattenClassLists() { for (const classes of Object.values(allClassLists)) { for (const [name, constructor] of Object.entries(classes)) { + if (typeof constructor !== 'function') continue; + if (!(constructor.prototype instanceof Thing)) continue; allClasses[name] = constructor; } } @@ -119,7 +125,7 @@ function descriptorAggregateHelper({ } function evaluatePropertyDescriptors() { - const opts = {...allClasses, validators}; + const opts = {...allClasses}; return descriptorAggregateHelper({ message: `Errors evaluating Thing class property descriptors`, @@ -129,8 +135,21 @@ function evaluatePropertyDescriptors() { throw new Error(`Missing [Thing.getPropertyDescriptors] function`); } - constructor.propertyDescriptors = - constructor[Thing.getPropertyDescriptors](opts); + const results = constructor[Thing.getPropertyDescriptors](opts); + + for (const [key, value] of Object.entries(results)) { + if (Array.isArray(value)) { + results[key] = compositeFrom({ + annotation: `${constructor.name}.${key}`, + compose: false, + steps: value, + }); + } else if (value.toResolvedComposition) { + results[key] = compositeFrom(value.toResolvedComposition()); + } + } + + constructor.propertyDescriptors = results; }, showFailedClasses(failedClasses) { diff --git a/src/data/things/language.js b/src/data/things/language.js index 7755c505..646eb6d1 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,11 +1,17 @@ +import {Tag} from '#html'; +import {isLanguageCode} from '#validators'; + +import { + externalFunction, + flag, + simpleString, +} from '#composite/wiki-properties'; + +import CacheableObject from './cacheable-object.js'; import Thing from './thing.js'; export class Language extends Thing { - static [Thing.getPropertyDescriptors] = ({ - validators: { - isLanguageCode, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose // General language code. This is used to identify the language distinctly @@ -18,7 +24,7 @@ export class Language extends Thing { // Human-readable name. This should be the language's own native name, not // localized to any other language. - name: Thing.common.simpleString(), + name: simpleString(), // Language code specific to JavaScript's Internationalization (Intl) API. // Usually this will be the same as the language's general code, but it @@ -40,7 +46,7 @@ export class Language extends Thing { // with languages that are currently in development and not ready for // formal release, or which are just kept hidden as "experimental zones" // for wiki development or content testing. - hidden: Thing.common.flag(false), + hidden: flag(false), // Mapping of translation keys to values (strings). Generally, don't // access this object directly - use methods instead. @@ -68,7 +74,7 @@ export class Language extends Thing { // Update only - escapeHTML: Thing.common.externalFunction(), + escapeHTML: externalFunction(), // Expose only @@ -95,6 +101,7 @@ export class Language extends Thing { }, }, + // TODO: This currently isn't used. Is it still needed? strings_htmlEscaped: { flags: {expose: true}, expose: { @@ -124,8 +131,8 @@ export class Language extends Thing { }; } - $(key, args = {}) { - return this.formatString(key, args); + $(...args) { + return this.formatString(...args); } assertIntlAvailable(property) { @@ -139,20 +146,22 @@ export class Language extends Thing { return this.intl_pluralCardinal.select(value); } - formatString(key, args = {}) { - if (this.strings && !this.strings_htmlEscaped) { - throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`); - } + formatString(...args) { + const hasOptions = + typeof args.at(-1) === 'object' && + args.at(-1) !== null; - return this.formatStringHelper(this.strings_htmlEscaped, key, args); - } + const key = + (hasOptions ? args.slice(0, -1) : args) + .filter(Boolean) + .join('.'); - formatStringNoHTMLEscape(key, args = {}) { - return this.formatStringHelper(this.strings, key, args); - } + const options = + (hasOptions + ? args.at(-1) + : null); - formatStringHelper(strings, key, args = {}) { - if (!strings) { + if (!this.strings) { throw new Error(`Strings unavailable`); } @@ -160,30 +169,94 @@ export class Language extends Thing { throw new Error(`Invalid key ${key} accessed`); } - const template = strings[key]; - - // Convert the keys on the args dict from camelCase to CONSTANT_CASE. - // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut - // like, who cares, dude?) Also, this is an array, 8ecause it's handy - // for the iterating we're a8out to do. - const processedArgs = Object.entries(args).map(([k, v]) => [ - k.replace(/[A-Z]/g, '_$&').toUpperCase(), - v, - ]); - - // Replacement time! Woot. Reduce comes in handy here! - const output = processedArgs.reduce( - (x, [k, v]) => x.replaceAll(`{${k}}`, v), - template - ); + const template = this.strings[key]; + + let output; + + if (hasOptions) { + // Convert the keys on the options dict from camelCase to CONSTANT_CASE. + // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut + // like, who cares, dude?) Also, this is an array, 8ecause it's handy + // for the iterating we're a8out to do. Also strip HTML from arguments + // that are literal strings - real HTML content should always be proper + // HTML objects (see html.js). + const processedOptions = + Object.entries(options).map(([k, v]) => [ + k.replace(/[A-Z]/g, '_$&').toUpperCase(), + this.#sanitizeStringArg(v), + ]); + + // Replacement time! Woot. Reduce comes in handy here! + output = + processedOptions.reduce( + (x, [k, v]) => x.replaceAll(`{${k}}`, v), + template); + } else { + // Without any options provided, just use the template as-is. This will + // still error if the template expected arguments, and otherwise will be + // the right value. + output = template; + } // Post-processing: if any expected arguments *weren't* replaced, that // is almost definitely an error. - if (output.match(/\{[A-Z_]+\}/)) { + if (output.match(/\{[A-Z][A-Z0-9_]*\}/)) { throw new Error(`Args in ${key} were missing - output: ${output}`); } - return output; + // Last caveat: Wrap the output in an HTML tag so that it doesn't get + // treated as unsanitized HTML if *it* gets passed as an argument to + // *another* formatString call. + return this.#wrapSanitized(output); + } + + // Escapes HTML special characters so they're displayed as-are instead of + // treated by the browser as a tag. This does *not* have an effect on actual + // html.Tag objects, which are treated as sanitized by default (so that they + // can be nested inside strings at all). + #sanitizeStringArg(arg) { + const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); + + if (!escapeHTML) { + throw new Error(`escapeHTML unavailable`); + } + + if (typeof arg !== 'string') { + return arg.toString(); + } + + return escapeHTML(arg); + } + + // Wraps the output of a formatting function in a no-name-nor-attributes + // HTML tag, which will indicate to other calls to formatString that this + // content is a string *that may contain HTML* and doesn't need to + // sanitized any further. It'll still .toString() to just the string + // contents, if needed. + #wrapSanitized(output) { + return new Tag(null, null, output); + } + + // Similar to the above internal methods, but this one is public. + // It should be used when embedding content that may not have previously + // been sanitized directly into an HTML tag or template's contents. + // The templating engine usually handles this on its own, as does passing + // a value (sanitized or not) directly as an argument to formatString, + // but if you used a custom validation function ({validate: v => v.isHTML} + // instead of {type: 'string'} / {type: 'html'}) and are embedding the + // contents of a slot directly, it should be manually sanitized with this + // function first. + sanitize(arg) { + const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); + + if (!escapeHTML) { + throw new Error(`escapeHTML unavailable`); + } + + return ( + (typeof arg === 'string' + ? new Tag(null, null, escapeHTML(arg)) + : arg)); } formatDate(date) { @@ -252,19 +325,32 @@ export class Language extends Thing { // Conjunction list: A, B, and C formatConjunctionList(array) { this.assertIntlAvailable('intl_listConjunction'); - return this.intl_listConjunction.format(array.map(arr => arr.toString())); + return this.#wrapSanitized( + this.intl_listConjunction.format( + array.map(item => this.#sanitizeStringArg(item)))); } // Disjunction lists: A, B, or C formatDisjunctionList(array) { this.assertIntlAvailable('intl_listDisjunction'); - return this.intl_listDisjunction.format(array.map(arr => arr.toString())); + return this.#wrapSanitized( + this.intl_listDisjunction.format( + array.map(item => this.#sanitizeStringArg(item)))); } // Unit lists: A, B, C formatUnitList(array) { this.assertIntlAvailable('intl_listUnit'); - return this.intl_listUnit.format(array.map(arr => arr.toString())); + return this.#wrapSanitized( + this.intl_listUnit.format( + array.map(item => this.#sanitizeStringArg(item)))); + } + + // Lists without separator: A B C + formatListWithoutSeparator(array) { + return this.#wrapSanitized( + array.map(item => this.#sanitizeStringArg(item)) + .join(' ')); } // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js index 43911410..36da0299 100644 --- a/src/data/things/news-entry.js +++ b/src/data/things/news-entry.js @@ -1,16 +1,24 @@ +import { + directory, + name, + simpleDate, + simpleString, +} from '#composite/wiki-properties'; + import Thing from './thing.js'; export class NewsEntry extends Thing { static [Thing.referenceType] = 'news-entry'; + static [Thing.friendlyName] = `News Entry`; static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: Thing.common.name('Unnamed News Entry'), - directory: Thing.common.directory(), - date: Thing.common.simpleDate(), + name: name('Unnamed News Entry'), + directory: directory(), + date: simpleDate(), - content: Thing.common.simpleString(), + content: simpleString(), // Expose only diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index 3d8d474c..ab9c5f98 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -1,16 +1,21 @@ +import {isName} from '#validators'; + +import { + directory, + name, + simpleString, +} from '#composite/wiki-properties'; + import Thing from './thing.js'; export class StaticPage extends Thing { static [Thing.referenceType] = 'static'; + static [Thing.friendlyName] = `Static Page`; - static [Thing.getPropertyDescriptors] = ({ - validators: { - isName, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: Thing.common.name('Unnamed Static Page'), + name: name('Unnamed Static Page'), nameShort: { flags: {update: true, expose: true}, @@ -22,8 +27,8 @@ export class StaticPage extends Thing { }, }, - directory: Thing.common.directory(), - content: Thing.common.simpleString(), - stylesheet: Thing.common.simpleString(), + directory: directory(), + content: simpleString(), + stylesheet: simpleString(), }); } diff --git a/src/data/things/thing.js b/src/data/things/thing.js index c2876f56..def7e914 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1,399 +1,19 @@ -// Thing: base class for wiki data types, providing wiki-specific utility -// functions on top of essential CacheableObject behavior. +// 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 {color} from '#cli'; -import find from '#find'; -import {empty} from '#sugar'; -import {getKebabCase} from '#wiki-data'; - -import { - isAdditionalFileList, - isBoolean, - isCommentary, - isColor, - isContributionList, - isDate, - isDirectory, - isFileExtension, - isName, - isString, - isURL, - validateArrayItems, - validateInstanceOf, - validateReference, - validateReferenceList, -} from '#validators'; +import {colors} from '#cli'; import CacheableObject from './cacheable-object.js'; export default class Thing extends CacheableObject { - static referenceType = Symbol('Thing.referenceType'); + static referenceType = Symbol.for('Thing.referenceType'); + static friendlyName = Symbol.for(`Thing.friendlyName`); static getPropertyDescriptors = Symbol('Thing.getPropertyDescriptors'); static getSerializeDescriptors = Symbol('Thing.getSerializeDescriptors'); - // Regularly reused property descriptors, for ease of access and generally - // duplicating less code across wiki data types. These are specialized utility - // functions, so check each for how its own arguments behave! - static common = { - name: (defaultName) => ({ - flags: {update: true, expose: true}, - update: {validate: isName, default: defaultName}, - }), - - color: () => ({ - flags: {update: true, expose: true}, - update: {validate: isColor}, - }), - - directory: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: { - dependencies: ['name'], - transform(directory, {name}) { - if (directory === null && name === null) return null; - else if (directory === null) return getKebabCase(name); - else return directory; - }, - }, - }), - - urls: () => ({ - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)}, - expose: {transform: (value) => value ?? []}, - }), - - // A file extension! Or the default, if provided when calling this. - fileExtension: (defaultFileExtension = null) => ({ - flags: {update: true, expose: true}, - update: {validate: isFileExtension}, - expose: {transform: (value) => value ?? defaultFileExtension}, - }), - - // Straightforward flag descriptor for a variety of property purposes. - // Provide a default value, true or false! - flag: (defaultValue = false) => { - if (typeof defaultValue !== 'boolean') { - throw new TypeError(`Always set explicit defaults for flags!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: defaultValue}, - }; - }, - - // General date type, used as the descriptor for a bunch of properties. - // This isn't dynamic though - it won't inherit from a date stored on - // another object, for example. - simpleDate: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDate}, - }), - - // General string type. This should probably generally be avoided in favor - // of more specific validation, but using it makes it easy to find where we - // might want to improve later, and it's a useful shorthand meanwhile. - simpleString: () => ({ - flags: {update: true, expose: true}, - update: {validate: isString}, - }), - - // External function. These should only be used as dependencies for other - // properties, so they're left unexposed. - externalFunction: () => ({ - flags: {update: true}, - update: {validate: (t) => typeof t === 'function'}, - }), - - // Super simple "contributions by reference" list, used for a variety of - // properties (Artists, Cover Artists, etc). This is the property which is - // externally provided, in the form: - // - // [ - // {who: 'Artist Name', what: 'Viola'}, - // {who: 'artist:john-cena', what: null}, - // ... - // ] - // - // ...processed from YAML, spreadsheet, or any other kind of input. - contribsByRef: () => ({ - flags: {update: true, expose: true}, - update: {validate: isContributionList}, - }), - - // Artist commentary! Generally present on tracks and albums. - commentary: () => ({ - flags: {update: true, expose: true}, - update: {validate: isCommentary}, - }), - - // This is a somewhat more involved data structure - it's for additional - // or "bonus" files associated with albums or tracks (or anything else). - // It's got this form: - // - // [ - // {title: 'Booklet', files: ['Booklet.pdf']}, - // { - // title: 'Wallpaper', - // description: 'Cool Wallpaper!', - // files: ['1440x900.png', '1920x1080.png'] - // }, - // {title: 'Alternate Covers', description: null, files: [...]}, - // ... - // ] - // - additionalFiles: () => ({ - flags: {update: true, expose: true}, - update: {validate: isAdditionalFileList}, - expose: { - transform: (additionalFiles) => - additionalFiles ?? [], - }, - }), - - // A reference list! Keep in mind this is for general references to wiki - // objects of (usually) other Thing subclasses, not specifically leitmotif - // references in tracks (although that property uses referenceList too!). - // - // The underlying function validateReferenceList expects a string like - // 'artist' or 'track', but this utility keeps from having to hard-code the - // string in multiple places by referencing the value saved on the class - // instead. - referenceList: (thingClass) => { - const {[Thing.referenceType]: referenceType} = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList(referenceType)}, - }; - }, - - // Corresponding function for a single reference. - singleReference: (thingClass) => { - const {[Thing.referenceType]: referenceType} = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: validateReference(referenceType)}, - }; - }, - - // Corresponding dynamic property to referenceList, which takes the values - // in the provided property and searches the specified wiki data for - // matching actual Thing-subclass objects. - dynamicThingsFromReferenceList: ( - referenceListProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [referenceListProperty, thingDataProperty], - compute: ({ - [referenceListProperty]: refs, - [thingDataProperty]: thingData, - }) => - refs && thingData - ? refs - .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) - .filter(Boolean) - : [], - }, - }), - - // Corresponding function for a single reference. - dynamicThingFromSingleReference: ( - singleReferenceProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [singleReferenceProperty, thingDataProperty], - compute: ({ - [singleReferenceProperty]: ref, - [thingDataProperty]: thingData, - }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null), - }, - }), - - // Corresponding dynamic property to contribsByRef, which takes the values - // in the provided property and searches the object's artistData for - // matching actual Artist objects. The computed structure has the same form - // as contribsByRef, but with Artist objects instead of string references: - // - // [ - // {who: (an Artist), what: 'Viola'}, - // {who: (an Artist), what: null}, - // ... - // ] - // - // Contributions whose "who" values don't match anything in artistData are - // filtered out. (So if the list is all empty, chances are that either the - // reference list is somehow messed up, or artistData isn't being provided - // properly.) - dynamicContribs: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: ['artistData', contribsByRefProperty], - compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => - contribsByRef && artistData - ? contribsByRef - .map(({who: ref, what}) => ({ - who: find.artist(ref, artistData), - what, - })) - .filter(({who}) => who) - : [], - }, - }), - - // Dynamically inherit a contribution list from some other object, if it - // hasn't been overridden on this object. This is handy for solo albums - // where all tracks have the same artist, for example. - dynamicInheritContribs: ( - // If this property is explicitly false, the contribution list returned - // will always be empty. - nullerProperty, - - // Property holding contributions on the current object. - contribsByRefProperty, - - // Property holding corresponding "default" contributions on the parent - // object, which will fallen back to if the object doesn't have its own - // contribs. - parentContribsByRefProperty, - - // Data array to search in and "find" function to locate parent object - // (which will be passed the child object and the wiki data array). - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - expose: { - dependencies: [ - contribsByRefProperty, - thingDataProperty, - nullerProperty, - 'artistData', - ].filter(Boolean), - - compute({ - [Thing.instance]: thing, - [nullerProperty]: nuller, - [contribsByRefProperty]: contribsByRef, - [thingDataProperty]: thingData, - artistData, - }) { - if (!artistData) return []; - if (nuller === false) return []; - const refs = - contribsByRef ?? - findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty]; - if (!refs) return []; - return refs - .map(({who: ref, what}) => ({ - who: find.artist(ref, artistData), - what, - })) - .filter(({who}) => who); - }, - }, - }), - - // Nice 'n simple shorthand for an exposed-only flag which is true when any - // contributions are present in the specified property. - contribsPresent: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: [contribsByRefProperty], - compute({ - [contribsByRefProperty]: contribsByRef, - }) { - return !empty(contribsByRef); - }, - } - }), - - // Neat little shortcut for "reversing" the reference lists stored on other - // things - for example, tracks specify a "referenced tracks" property, and - // you would use this to compute a corresponding "referenced *by* tracks" - // property. Naturally, the passed ref list property is of the things in the - // wiki data provided, not the requesting Thing itself. - reverseReferenceList: (thingDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [thingDataProperty], - - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => - thingData?.filter(t => t[referencerRefListProperty].includes(thing)) ?? [], - }, - }), - - // Corresponding function for single references. Note that the return value - // is still a list - this is for matching all the objects whose single - // reference (in the given property) matches this Thing. - reverseSingleReference: (thingDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [thingDataProperty], - - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => - thingData?.filter((t) => t[referencerRefListProperty] === thing) ?? [], - }, - }), - - // General purpose wiki data constructor, for properties like artistData, - // trackData, etc. - wikiData: (thingClass) => ({ - flags: {update: true}, - update: { - validate: validateArrayItems(validateInstanceOf(thingClass)), - }, - }), - - // This one's kinda tricky: it parses artist "references" from the - // commentary content, and finds the matching artist for each reference. - // This is mostly useful for credits and listings on artist pages. - commentatorArtists: () => ({ - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({artistData, commentary}) => - artistData && commentary - ? Array.from( - new Set( - Array.from( - commentary - .replace(/<\/?b>/g, '') - .matchAll(/<i>(?<who>.*?):<\/i>/g) - ).map(({groups: {who}}) => - find.artist(who, artistData, {mode: 'quiet'}) - ) - ) - ) - : [], - }, - }), - }; - // 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 @@ -402,8 +22,8 @@ export default class Thing extends CacheableObject { const cname = this.constructor.name; return ( - (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) + - (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '') + (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) + + (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '') ); } diff --git a/src/data/things/track.js b/src/data/things/track.js index e176acb4..db325a17 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -1,460 +1,330 @@ import {inspect} from 'node:util'; -import {color} from '#cli'; +import {colors} from '#cli'; +import {input} from '#composite'; import find from '#find'; -import {empty} from '#sugar'; +import { + isColor, + isContributionList, + isDate, + isFileExtension, +} from '#validators'; + +import {withPropertyFromObject} from '#composite/data'; +import {withResolvedContribs} from '#composite/wiki-data'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + additionalFiles, + commentary, + commentatorArtists, + contributionList, + directory, + duration, + flag, + name, + referenceList, + reverseReferenceList, + simpleDate, + singleReference, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import { + exitWithoutUniqueCoverArt, + inheritFromOriginalRelease, + trackReverseReferenceList, + withAlbum, + withAlwaysReferenceByDirectory, + withContainingTrackSection, + withHasUniqueCoverArt, + withOtherReleases, + withPropertyFromAlbum, +} from '#composite/things/track'; + +import CacheableObject from './cacheable-object.js'; import Thing from './thing.js'; export class Track extends Thing { static [Thing.referenceType] = 'track'; - static [Thing.getPropertyDescriptors] = ({ - Album, - ArtTag, - Artist, - Flash, - - validators: { - isBoolean, - isColor, - isDate, - isDuration, - isFileExtension, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({ // Update & expose - name: Thing.common.name('Unnamed Track'), - directory: Thing.common.directory(), - - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration}, - }, - - urls: Thing.common.urls(), - dateFirstReleased: Thing.common.simpleDate(), - - artistContribsByRef: Thing.common.contribsByRef(), - contributorContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), - - referencedTracksByRef: Thing.common.referenceList(Track), - sampledTracksByRef: Thing.common.referenceList(Track), - artTagsByRef: Thing.common.referenceList(ArtTag), - - hasCoverArt: { - flags: {update: true, expose: true}, - - update: { - validate(value) { - if (value !== false) { - throw new TypeError(`Expected false or null`); - } - - return true; - }, - }, - - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (hasCoverArt, { - albumData, - coverArtistContribsByRef, - [Track.instance]: track, - }) => - Track.hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ), - }, - }, - - coverArtFileExtension: { - flags: {update: true, expose: true}, - - update: {validate: isFileExtension}, - - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (coverArtFileExtension, { - albumData, - coverArtistContribsByRef, - hasCoverArt, - [Track.instance]: track, - }) => - coverArtFileExtension ?? - (Track.hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) - ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension - : Track.findAlbum(track, albumData)?.coverArtFileExtension) ?? - 'jpg', - }, - }, - - originalReleaseTrackByRef: Thing.common.singleReference(Track), - - dataSourceAlbumByRef: Thing.common.singleReference(Album), - - commentary: Thing.common.commentary(), - lyrics: Thing.common.simpleString(), - additionalFiles: Thing.common.additionalFiles(), - sheetMusicFiles: Thing.common.additionalFiles(), - midiProjectFiles: Thing.common.additionalFiles(), + name: name('Unnamed Track'), + directory: directory(), + + duration: duration(), + urls: urls(), + dateFirstReleased: simpleDate(), + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', + property: input.value('color'), + }), + + exposeDependencyOrContinue({dependency: '#trackSection.color'}), + + withPropertyFromAlbum({ + property: input.value('color'), + }), + + exposeDependency({dependency: '#album.color'}), + ], + + alwaysReferenceByDirectory: [ + withAlwaysReferenceByDirectory(), + exposeDependency({dependency: '#alwaysReferenceByDirectory'}), + ], + + // Disables presenting the track as though it has its own unique artwork. + // This flag should only be used in select circumstances, i.e. to override + // an album's trackCoverArtists. This flag supercedes that property, as well + // as the track's own coverArtists. + disableUniqueCoverArt: flag(), + + // File extension for track's corresponding media file. This represents the + // track's unique cover artwork, if any, and does not inherit the extension + // of the album's main artwork. It does inherit trackCoverArtFileExtension, + // if present on the album. + coverArtFileExtension: [ + exitWithoutUniqueCoverArt(), + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtFileExtension'), + }), + + exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), + + exposeConstant({ + value: input.value('jpg'), + }), + ], + + // Date of cover art release. Like coverArtFileExtension, this represents + // only the track's own unique cover artwork, if any. This exposes only as + // the track's own coverArtDate or its album's trackArtDate, so if neither + // is specified, this value is null. + coverArtDate: [ + withHasUniqueCoverArt(), + + exitWithoutDependency({ + dependency: '#hasUniqueCoverArt', + mode: input.value('falsy'), + }), + + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withPropertyFromAlbum({ + property: input.value('trackArtDate'), + }), + + exposeDependency({dependency: '#album.trackArtDate'}), + ], + + commentary: commentary(), + lyrics: simpleString(), + + additionalFiles: additionalFiles(), + sheetMusicFiles: additionalFiles(), + midiProjectFiles: additionalFiles(), + + originalReleaseTrack: singleReference({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + + // Internal use only - for directly identifying an album inside a track's + // util.inspect display, if it isn't indirectly available (by way of being + // included in an album's track list). + dataSourceAlbum: singleReference({ + class: input.value(Album), + find: input.value(find.album), + data: 'albumData', + }), + + artistContribs: [ + inheritFromOriginalRelease({ + property: input.value('artistContribs'), + }), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + }).outputs({ + '#resolvedContribs': '#artistContribs', + }), + + exposeDependencyOrContinue({ + dependency: '#artistContribs', + mode: input.value('empty'), + }), + + withPropertyFromAlbum({ + property: input.value('artistContribs'), + }), + + exposeDependency({dependency: '#album.artistContribs'}), + ], + + contributorContribs: [ + inheritFromOriginalRelease({ + property: input.value('contributorContribs'), + }), + + contributionList(), + ], + + // Cover artists aren't inherited from the original release, since it + // typically varies by release and isn't defined by the musical qualities + // of the track. + coverArtistContribs: [ + exitWithoutUniqueCoverArt({ + value: input.value([]), + }), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + }).outputs({ + '#resolvedContribs': '#coverArtistContribs', + }), + + exposeDependencyOrContinue({ + dependency: '#coverArtistContribs', + mode: input.value('empty'), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtistContribs'), + }), + + exposeDependency({dependency: '#album.trackCoverArtistContribs'}), + ], + + referencedTracks: [ + inheritFromOriginalRelease({ + property: input.value('referencedTracks'), + }), + + referenceList({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + ], + + sampledTracks: [ + inheritFromOriginalRelease({ + property: input.value('sampledTracks'), + }), + + referenceList({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + ], + + artTags: referenceList({ + class: input.value(ArtTag), + find: input.value(find.artTag), + data: 'artTagData', + }), // Update only - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + artistData: wikiData(Artist), + artTagData: wikiData(ArtTag), + flashData: wikiData(Flash), + trackData: wikiData(Track), // Expose only - commentatorArtists: Thing.common.commentatorArtists(), - - album: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData'], - compute: ({[Track.instance]: track, albumData}) => - albumData?.find((album) => album.tracks.includes(track)) ?? null, - }, - }, - - // Note - this is an internal property used only to help identify a track. - // It should not be assumed in general that the album and dataSourceAlbum match - // (i.e. a track may dynamically be moved from one album to another, at - // which point dataSourceAlbum refers to where it was originally from, and is - // not generally relevant information). It's also not guaranteed that - // dataSourceAlbum is available (depending on the Track creator to optionally - // provide dataSourceAlbumByRef). - dataSourceAlbum: Thing.common.dynamicThingFromSingleReference( - 'dataSourceAlbumByRef', - 'albumData', - find.album - ), - - date: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - compute: ({albumData, dateFirstReleased, [Track.instance]: track}) => - dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null, - }, - }, - - color: { - flags: {update: true, expose: true}, - - update: {validate: isColor}, - - expose: { - dependencies: ['albumData'], - - transform: (color, {albumData, [Track.instance]: track}) => - color ?? - Track.findAlbum(track, albumData) - ?.trackSections.find(({tracks}) => tracks.includes(track)) - ?.color ?? null, - }, - }, - - coverArtDate: { - flags: {update: true, expose: true}, - - update: {validate: isDate}, - - expose: { - dependencies: [ - 'albumData', - 'coverArtistContribsByRef', - 'dateFirstReleased', - 'hasCoverArt', - ], - transform: (coverArtDate, { - albumData, - coverArtistContribsByRef, - dateFirstReleased, - hasCoverArt, - [Track.instance]: track, - }) => - (Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt) - ? coverArtDate ?? - dateFirstReleased ?? - Track.findAlbum(track, albumData)?.trackArtDate ?? - Track.findAlbum(track, albumData)?.date ?? - null - : null), - }, - }, - - hasUniqueCoverArt: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef', 'hasCoverArt'], - compute: ({ - albumData, - coverArtistContribsByRef, - hasCoverArt, - [Track.instance]: track, - }) => - Track.hasUniqueCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ), - }, - }, - - originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( - 'originalReleaseTrackByRef', - 'trackData', - find.track - ), - - otherReleases: { - flags: {expose: true}, - - expose: { - dependencies: ['originalReleaseTrackByRef', 'trackData'], - - compute: ({ - originalReleaseTrackByRef: t1origRef, - trackData, - [Track.instance]: t1, - }) => { - if (!trackData) { - return []; - } - - const t1orig = find.track(t1origRef, trackData); - - return [ - t1orig, - ...trackData.filter((t2) => { - const {originalReleaseTrack: t2orig} = t2; - return t2 !== t1 && t2orig && (t2orig === t1orig || t2orig === t1); - }), - ].filter(Boolean); - }, - }, - }, - - artistContribs: - Track.inheritFromOriginalRelease('artistContribs', [], - Thing.common.dynamicInheritContribs( - null, - 'artistContribsByRef', - 'artistContribsByRef', - 'albumData', - Track.findAlbum)), - - contributorContribs: - Track.inheritFromOriginalRelease('contributorContribs', [], - Thing.common.dynamicContribs('contributorContribsByRef')), - - // Cover artists aren't inherited from the original release, since it - // typically varies by release and isn't defined by the musical qualities - // of the track. - coverArtistContribs: - Thing.common.dynamicInheritContribs( - 'hasCoverArt', - 'coverArtistContribsByRef', - 'trackCoverArtistContribsByRef', - 'albumData', - Track.findAlbum), - - referencedTracks: - Track.inheritFromOriginalRelease('referencedTracks', [], - Thing.common.dynamicThingsFromReferenceList( - 'referencedTracksByRef', - 'trackData', - find.track)), - - sampledTracks: - Track.inheritFromOriginalRelease('sampledTracks', [], - Thing.common.dynamicThingsFromReferenceList( - 'sampledTracksByRef', - 'trackData', - find.track)), - - // Specifically exclude re-releases from this list - while it's useful to - // get from a re-release to the tracks it references, re-releases aren't - // generally relevant from the perspective of the tracks being referenced. - // Filtering them from data here hides them from the corresponding field - // on the site (obviously), and has the bonus of not counting them when - // counting the number of times a track has been referenced, for use in - // the "Tracks - by Times Referenced" listing page (or other data - // processing). - referencedByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({trackData, [Track.instance]: track}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.referencedTracks?.includes(track)) - : [], - }, - }, - - // For the same reasoning, exclude re-releases from sampled tracks too. - sampledByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({trackData, [Track.instance]: track}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.sampledTracks?.includes(track)) - : [], - }, - }, - - featuredInFlashes: Thing.common.reverseReferenceList( - 'flashData', - 'featuredTracks' - ), - - artTags: Thing.common.dynamicThingsFromReferenceList( - 'artTagsByRef', - 'artTagData', - find.artTag - ), - }); - - // This is a quick utility function for now, since the same code is reused in - // several places. Ideally it wouldn't be - we'd just reuse the `album` - // property - but support for that hasn't been coded yet :P - static findAlbum = (track, albumData) => - albumData?.find((album) => album.tracks.includes(track)); - - // Another reused utility function. This one's logic is a bit more complicated. - static hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) { - if (!empty(coverArtistContribsByRef)) { - return true; - } + commentatorArtists: commentatorArtists(), - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; - } + album: [ + withAlbum(), + exposeDependency({dependency: '#album'}), + ], - return false; - } + date: [ + exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), - static hasUniqueCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) { - if (!empty(coverArtistContribsByRef)) { - return true; - } + withPropertyFromAlbum({ + property: input.value('date'), + }), - if (hasCoverArt === false) { - return false; - } + exposeDependency({dependency: '#album.date'}), + ], - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; - } + hasUniqueCoverArt: [ + withHasUniqueCoverArt(), + exposeDependency({dependency: '#hasUniqueCoverArt'}), + ], - return false; - } + otherReleases: [ + withOtherReleases(), + exposeDependency({dependency: '#otherReleases'}), + ], - static inheritFromOriginalRelease( - originalProperty, - originalMissingValue, - ownPropertyDescriptor - ) { - return { - flags: {expose: true}, - - expose: { - dependencies: [ - ...ownPropertyDescriptor.expose.dependencies, - 'originalReleaseTrackByRef', - 'trackData', - ], - - compute(dependencies) { - const { - originalReleaseTrackByRef, - trackData, - } = dependencies; - - if (originalReleaseTrackByRef) { - if (!trackData) return originalMissingValue; - const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); - if (!original) return originalMissingValue; - return original[originalProperty]; - } - - return ownPropertyDescriptor.expose.compute(dependencies); - }, - }, - }; - } + referencedByTracks: trackReverseReferenceList({ + list: input.value('referencedTracks'), + }), - [inspect.custom]() { - const base = Thing.prototype[inspect.custom].apply(this); + sampledByTracks: trackReverseReferenceList({ + list: input.value('sampledTracks'), + }), - const rereleasePart = - (this.originalReleaseTrackByRef - ? `${color.yellow('[rerelease]')} ` - : ``); + featuredInFlashes: reverseReferenceList({ + data: 'flashData', + list: input.value('featuredTracks'), + }), + }); - const {album, dataSourceAlbum} = this; + [inspect.custom](depth) { + const parts = []; - const albumName = - (album - ? album.name - : dataSourceAlbum?.name); + parts.push(Thing.prototype[inspect.custom].apply(this)); - const albumIndex = - albumName && - (album - ? album.tracks.indexOf(this) - : dataSourceAlbum.tracks.indexOf(this)); + if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) { + parts.unshift(`${colors.yellow('[rerelease]')} `); + } - const trackNum = - albumName && + let album; + if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) { + const albumName = album.name; + const albumIndex = album.tracks.indexOf(this); + const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`); + parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`); + } - const albumPart = - albumName - ? ` (${color.yellow(trackNum)} in ${color.green(albumName)})` - : ``; - - return rereleasePart + base + albumPart; + return parts.join(''); } } diff --git a/src/data/things/validators.js b/src/data/things/validators.js index fc953c2a..ee301f15 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -1,7 +1,7 @@ import {inspect as nodeInspect} from 'node:util'; -import {color, ENABLE_COLOR} from '#cli'; -import {withAggregate} from '#sugar'; +import {colors, ENABLE_COLOR} from '#cli'; +import {empty, typeAppearance, withAggregate} from '#sugar'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); @@ -9,13 +9,13 @@ function inspect(value) { // Basic types (primitives) -function a(noun) { +export function a(noun) { return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; } -function isType(value, type) { +export function isType(value, type) { if (typeof value !== type) - throw new TypeError(`Expected ${a(type)}, got ${typeof value}`); + throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); return true; } @@ -132,7 +132,7 @@ export function isObject(value) { export function isArray(value) { if (typeof value !== 'object' || value === null || !Array.isArray(value)) - throw new TypeError(`Expected an array, got ${value}`); + throw new TypeError(`Expected an array, got ${typeAppearance(value)}`); return true; } @@ -174,7 +174,8 @@ function validateArrayItemsHelper(itemValidator) { throw new Error(`Expected validator to return true`); } } catch (error) { - error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`; + error.message = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)}) ${error.message}`; + error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index; throw error; } }; @@ -264,7 +265,7 @@ export function validateProperties(spec) { try { specValidator(value); } catch (error) { - error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`; + error.message = `(key: ${colors.green(specKey)}, value: ${inspect(value)}) ${error.message}`; throw error; } }); @@ -308,7 +309,7 @@ export const isTrackSection = validateProperties({ color: optional(isColor), dateOriginallyReleased: optional(isDate), isDefaultTrackSection: optional(isBoolean), - tracksByRef: optional(validateReferenceList('track')), + tracks: optional(validateReferenceList('track')), }); export const isTrackSectionList = validateArrayItems(isTrackSection); @@ -404,6 +405,76 @@ export function validateReferenceList(type = '') { return validateArrayItems(validateReference(type)); } +const validateWikiData_cache = {}; + +export function validateWikiData({ + referenceType = '', + allowMixedTypes = false, +}) { + if (referenceType && allowMixedTypes) { + throw new TypeError(`Don't specify both referenceType and allowMixedTypes`); + } + + validateWikiData_cache[referenceType] ??= {}; + validateWikiData_cache[referenceType][allowMixedTypes] ??= new WeakMap(); + + const isArrayOfObjects = validateArrayItems(isObject); + + return (array) => { + 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(array.map(object => + object.constructor[Symbol.for('Thing.referenceType')])); + + if (allRefTypes.has(undefined)) { + if (allRefTypes.size === 1) { + throw new TypeError(`Expected array of wiki data objects, got array of other objects`); + } else { + throw new TypeError(`Expected array of wiki data objects, got mixed items`); + } + } + + 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()}`); + } + + if (referenceType && !allRefTypes.has(referenceType)) { + throw new TypeError(`Expected array of ${referenceType}, got array of ${allRefTypes[0]}`) + } + + OK = true; return true; + } finally { + subcache.set(array, OK); + } + }; +} + // Compositional utilities export function oneOf(...checks) { diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index e906cab1..6286a267 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,20 +1,25 @@ +import {input} from '#composite'; import find from '#find'; +import {isLanguageCode, isName, isURL} from '#validators'; + +import { + color, + flag, + name, + referenceList, + simpleString, + wikiData, +} from '#composite/wiki-properties'; import Thing from './thing.js'; export class WikiInfo extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Group, + static [Thing.friendlyName] = `Wiki Info`; - validators: { - isLanguageCode, - isName, - isURL, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose - name: Thing.common.name('Unnamed Wiki'), + name: name('Unnamed Wiki'), // Displayed in nav bar. nameShort: { @@ -27,12 +32,12 @@ export class WikiInfo extends Thing { }, }, - color: Thing.common.color(), + color: color(), // One-line description used for <meta rel="description"> tag. - description: Thing.common.simpleString(), + description: simpleString(), - footerContent: Thing.common.simpleString(), + footerContent: simpleString(), defaultLanguage: { flags: {update: true, expose: true}, @@ -44,25 +49,21 @@ export class WikiInfo extends Thing { update: {validate: isURL}, }, - divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), + divideTrackListsByGroups: referenceList({ + class: input.value(Group), + find: input.value(find.group), + data: 'groupData', + }), // Feature toggles - enableFlashesAndGames: Thing.common.flag(false), - enableListings: Thing.common.flag(false), - enableNews: Thing.common.flag(false), - enableArtTagUI: Thing.common.flag(false), - enableGroupUI: Thing.common.flag(false), + enableFlashesAndGames: flag(false), + enableListings: flag(false), + enableNews: flag(false), + enableArtTagUI: flag(false), + enableGroupUI: flag(false), // Update only - groupData: Thing.common.wikiData(Group), - - // Expose only - - divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList( - 'divideTrackListsByGroupsByRef', - 'groupData', - find.group - ), + groupData: wikiData(Group), }); } diff --git a/src/data/yaml.js b/src/data/yaml.js index 35943199..f7856cb7 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -7,10 +7,15 @@ import {inspect as nodeInspect} from 'node:util'; import yaml from 'js-yaml'; -import {color, ENABLE_COLOR, logInfo, logWarn} from '#cli'; +import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; import find, {bindFind} from '#find'; import {traverse} from '#node-utils'; -import T from '#things'; + +import T, { + CacheableObject, + CacheableObjectPropertyValueError, + Thing, +} from '#things'; import { conditionallySuppressError, @@ -59,7 +64,7 @@ export const DATA_STATIC_PAGE_DIRECTORY = 'static-page'; // document and apply the configuration passed to makeProcessDocument in order // to construct a Thing subclass. function makeProcessDocument( - thingClass, + thingConstructor, { // Optional early step for transforming field values before providing them // to the Thing's update() method. This is useful when the input format @@ -110,7 +115,7 @@ function makeProcessDocument( invalidFieldCombinations = [], } ) { - if (!thingClass) { + if (!thingConstructor) { throw new Error(`Missing Thing class`); } @@ -137,22 +142,47 @@ function makeProcessDocument( const name = document[nameField]; error.message = name ? `(name: ${inspect(name)}) ${error.message}` - : `(${color.dim(`no name found`)}) ${error.message}`; + : `(${colors.dim(`no name found`)}) ${error.message}`; throw error; } }; }; const fn = decorateErrorWithName((document) => { + const nameField = propertyFieldMapping['name']; + const namePart = + (nameField + ? (document[nameField] + ? ` named ${colors.green(`"${document[nameField]}"`)}` + : ` (name field, "${nameField}", not specified)`) + : ``); + + const constructorPart = + (thingConstructor[Thing.friendlyName] + ? colors.green(thingConstructor[Thing.friendlyName]) + : thingConstructor.name + ? colors.green(thingConstructor.name) + : `document`); + + const aggregate = openAggregate({ + message: `Errors processing ${constructorPart}` + namePart, + }); + const documentEntries = Object.entries(document) .filter(([field]) => !ignoredFields.includes(field)); + const skippedFields = new Set(); + const unknownFields = documentEntries .map(([field]) => field) .filter((field) => !knownFields.includes(field)); if (!empty(unknownFields)) { - throw new makeProcessDocument.UnknownFieldsError(unknownFields); + aggregate.push(new UnknownFieldsError(unknownFields)); + + for (const field of unknownFields) { + skippedFields.add(field); + } } const presentFields = Object.keys(document); @@ -162,28 +192,57 @@ function makeProcessDocument( for (const {message, fields} of invalidFieldCombinations) { const fieldsPresent = presentFields.filter(field => fields.includes(field)); - if (fieldsPresent.length <= 1) { - continue; - } + if (fieldsPresent.length >= 2) { + const filteredDocument = + filterProperties( + document, + fieldsPresent, + {preserveOriginalOrder: true}); - fieldCombinationErrors.push( - new makeProcessDocument.FieldCombinationError( - filterProperties(document, fieldsPresent), - message)); + fieldCombinationErrors.push(new FieldCombinationError(filteredDocument, message)); + + for (const field of Object.keys(filteredDocument)) { + skippedFields.add(field); + } + } } if (!empty(fieldCombinationErrors)) { - throw new makeProcessDocument.FieldCombinationsError(fieldCombinationErrors); + aggregate.push(new FieldCombinationAggregateError(fieldCombinationErrors)); } const fieldValues = {}; - for (const [field, value] of documentEntries) { - if (Object.hasOwn(fieldTransformations, field)) { - fieldValues[field] = fieldTransformations[field](value); - } else { - fieldValues[field] = value; + for (const [field, documentValue] of documentEntries) { + if (skippedFields.has(field)) continue; + + // This variable would like to certify itself as "not into capitalism". + let propertyValue = + (Object.hasOwn(fieldTransformations, field) + ? fieldTransformations[field](documentValue) + : documentValue); + + // Completely blank items in a YAML list are read as null. + // They're handy to have around when filling out a document and shouldn't + // be considered an error (or data at all). + if (Array.isArray(propertyValue)) { + const wasEmpty = empty(propertyValue); + + propertyValue = + propertyValue.filter(item => item !== null); + + const isEmpty = empty(propertyValue); + + // Don't set arrays which are empty as a result of the above filter. + // Arrays which were originally empty, i.e. `Field: []`, are still + // valid data, but if it's just an array not containing any filled out + // items, it should be treated as a placeholder and skipped over. + if (isEmpty && !wasEmpty) { + propertyValue = null; + } } + + fieldValues[field] = propertyValue; } const sourceProperties = {}; @@ -193,15 +252,34 @@ function makeProcessDocument( sourceProperties[property] = value; } - const thing = Reflect.construct(thingClass, []); + const thing = Reflect.construct(thingConstructor, []); - withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({call}) => { - for (const [property, value] of Object.entries(sourceProperties)) { - call(() => (thing[property] = value)); + const fieldValueErrors = []; + + for (const [property, value] of Object.entries(sourceProperties)) { + const field = propertyFieldMapping[property]; + try { + thing[property] = value; + } catch (caughtError) { + skippedFields.add(field); + fieldValueErrors.push(new FieldValueError(field, property, value, caughtError)); } - }); + } + + if (!empty(fieldValueErrors)) { + aggregate.push(new FieldValueAggregateError(thingConstructor, fieldValueErrors)); + } - return thing; + if (skippedFields.size >= 1) { + aggregate.push( + new SkippedFieldsSummaryError( + filterProperties( + document, + Array.from(skippedFields), + {preserveOriginalOrder: true}))); + } + + return {thing, aggregate}; }); Object.assign(fn, { @@ -212,36 +290,81 @@ function makeProcessDocument( return fn; } -makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error { +export class UnknownFieldsError extends Error { constructor(fields) { - super(`Unknown fields present: ${fields.join(', ')}`); + super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`); this.fields = fields; } -}; +} -makeProcessDocument.FieldCombinationsError = class FieldCombinationsError extends AggregateError { +export class FieldCombinationAggregateError extends AggregateError { constructor(errors) { - super(errors, `Errors in combinations of fields present`); + super(errors, `Invalid field combinations - all involved fields ignored`); } -}; +} -makeProcessDocument.FieldCombinationError = class FieldCombinationError extends Error { +export class FieldCombinationError extends Error { constructor(fields, message) { const fieldNames = Object.keys(fields); - const combinePart = `Don't combine ${fieldNames.map(field => color.red(field)).join(', ')}`; - const messagePart = + const mainMessage = `Don't combine ${fieldNames.map(field => colors.red(field)).join(', ')}`; + + const causeMessage = (typeof message === 'function' - ? `: ${message(fields)}` + ? message(fields) : typeof message === 'string' - ? `: ${message}` - : ``); + ? message + : null); + + super(mainMessage, { + cause: + (causeMessage + ? new Error(causeMessage) + : null), + }); - super(combinePart + messagePart); this.fields = fields; } } +export class FieldValueAggregateError extends AggregateError { + constructor(thingConstructor, errors) { + super(errors, `Errors processing field values for ${colors.green(thingConstructor.name)}`); + } +} + +export class FieldValueError extends Error { + constructor(field, property, value, caughtError) { + const cause = + (caughtError instanceof CacheableObjectPropertyValueError + ? caughtError.cause + : caughtError); + + super( + `Failed to set ${colors.green(`"${field}"`)} field (${colors.green(property)}) to ${inspect(value)}`, + {cause}); + } +} + +export class SkippedFieldsSummaryError extends Error { + constructor(filteredDocument) { + const entries = Object.entries(filteredDocument); + + const lines = + entries.map(([field, value]) => + ` - ${field}: ` + + inspect(value) + .split('\n') + .map((line, index) => index === 0 ? line : ` ${line}`) + .join('\n')); + + super( + colors.bright(colors.yellow(`Altogether, skipped ${entries.length === 1 ? `1 field` : `${entries.length} fields`}:\n`)) + + lines.join('\n') + '\n' + + colors.bright(colors.yellow(`See above errors for details.`))); + } +} + export const processAlbumDocument = makeProcessDocument(T.Album, { fieldTransformations: { 'Artists': parseContributors, @@ -278,11 +401,11 @@ export const processAlbumDocument = makeProcessDocument(T.Album, { coverArtFileExtension: 'Cover Art File Extension', trackCoverArtFileExtension: 'Track Art File Extension', - wallpaperArtistContribsByRef: 'Wallpaper Artists', + wallpaperArtistContribs: 'Wallpaper Artists', wallpaperStyle: 'Wallpaper Style', wallpaperFileExtension: 'Wallpaper File Extension', - bannerArtistContribsByRef: 'Banner Artists', + bannerArtistContribs: 'Banner Artists', bannerStyle: 'Banner Style', bannerFileExtension: 'Banner File Extension', bannerDimensions: 'Banner Dimensions', @@ -290,11 +413,11 @@ export const processAlbumDocument = makeProcessDocument(T.Album, { commentary: 'Commentary', additionalFiles: 'Additional Files', - artistContribsByRef: 'Artists', - coverArtistContribsByRef: 'Cover Artists', - trackCoverArtistContribsByRef: 'Default Track Cover Artists', - groupsByRef: 'Groups', - artTagsByRef: 'Art Tags', + artistContribs: 'Artists', + coverArtistContribs: 'Cover Artists', + trackCoverArtistContribs: 'Default Track Cover Artists', + groups: 'Groups', + artTags: 'Art Tags', }, }); @@ -316,6 +439,10 @@ export const processTrackDocument = makeProcessDocument(T.Track, { '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, @@ -336,7 +463,9 @@ export const processTrackDocument = makeProcessDocument(T.Track, { dateFirstReleased: 'Date First Released', coverArtDate: 'Cover Art Date', coverArtFileExtension: 'Cover Art File Extension', - hasCoverArt: 'Has Cover Art', + disableUniqueCoverArt: 'Has Cover Art', // This gets transformed to flip true/false. + + alwaysReferenceByDirectory: 'Always Reference By Directory', lyrics: 'Lyrics', commentary: 'Commentary', @@ -344,13 +473,13 @@ export const processTrackDocument = makeProcessDocument(T.Track, { sheetMusicFiles: 'Sheet Music Files', midiProjectFiles: 'MIDI Project Files', - originalReleaseTrackByRef: 'Originally Released As', - referencedTracksByRef: 'Referenced Tracks', - sampledTracksByRef: 'Sampled Tracks', - artistContribsByRef: 'Artists', - contributorContribsByRef: 'Contributors', - coverArtistContribsByRef: 'Cover Artists', - artTagsByRef: 'Art Tags', + originalReleaseTrack: 'Originally Released As', + referencedTracks: 'Referenced Tracks', + sampledTracks: 'Sampled Tracks', + artistContribs: 'Artists', + contributorContribs: 'Contributors', + coverArtistContribs: 'Cover Artists', + artTags: 'Art Tags', }, invalidFieldCombinations: [ @@ -415,21 +544,25 @@ export const processFlashDocument = makeProcessDocument(T.Flash, { name: 'Flash', directory: 'Directory', page: 'Page', + color: 'Color', urls: 'URLs', date: 'Date', coverArtFileExtension: 'Cover Art File Extension', - featuredTracksByRef: 'Featured Tracks', - contributorContribsByRef: 'Contributors', + featuredTracks: 'Featured Tracks', + contributorContribs: 'Contributors', }, }); export const processFlashActDocument = makeProcessDocument(T.FlashAct, { propertyFieldMapping: { name: 'Act', + directory: 'Directory', + color: 'Color', - anchor: 'Anchor', + listTerminology: 'List Terminology', + jump: 'Jump', jumpColor: 'Jump Color', }, @@ -466,7 +599,7 @@ export const processGroupDocument = makeProcessDocument(T.Group, { description: 'Description', urls: 'URLs', - featuredAlbumsByRef: 'Featured Albums', + featuredAlbums: 'Featured Albums', }, }); @@ -497,7 +630,7 @@ export const processWikiInfoDocument = makeProcessDocument(T.WikiInfo, { footerContent: 'Footer Content', defaultLanguage: 'Default Language', canonicalBase: 'Canonical Base', - divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups', + divideTrackListsByGroups: 'Divide Track Lists By Groups', enableFlashesAndGames: 'Enable Flashes & Games', enableListings: 'Enable Listings', enableNews: 'Enable News', @@ -532,9 +665,9 @@ export const homepageLayoutRowTypeProcessMapping = { albums: makeProcessHomepageLayoutRowDocument(T.HomepageLayoutAlbumsRow, { propertyFieldMapping: { displayStyle: 'Display Style', - sourceGroupByRef: 'Group', + sourceGroup: 'Group', countAlbumsFromGroup: 'Count', - sourceAlbumsByRef: 'Albums', + sourceAlbums: 'Albums', actionLinks: 'Actions', }, }), @@ -591,33 +724,17 @@ export function parseContributors(contributors) { return contributors; } - if (contributors.length === 1 && contributors[0].startsWith('<i>')) { - const arr = []; - arr.textContent = contributors[0]; - return arr; - } - contributors = contributors.map((contrib) => { - // 8asically, the format is "Who (What)", or just "Who". 8e sure to - // keep in mind that "what" doesn't necessarily have a value! + if (typeof contrib !== 'string') return contrib; + const match = contrib.match(/^(.*?)( \((.*)\))?$/); - if (!match) { - return contrib; - } + if (!match) return contrib; + const who = match[1]; const what = match[3] || null; return {who, what}; }); - const badContributor = contributors.find((val) => typeof val === 'string'); - if (badContributor) { - throw new Error(`Incorrectly formatted contribution: "${badContributor}".`); - } - - if (contributors.length === 1 && contributors[0].who === 'none') { - return null; - } - return contributors; } @@ -767,13 +884,13 @@ export const dataSteps = [ let currentTrackSection = { name: `Default Track Section`, isDefaultTrackSection: true, - tracksByRef: [], + tracks: [], }; - const albumRef = T.Thing.getReference(album); + const albumRef = Thing.getReference(album); const closeCurrentTrackSection = () => { - if (!empty(currentTrackSection.tracksByRef)) { + if (!empty(currentTrackSection.tracks)) { trackSections.push(currentTrackSection); } }; @@ -787,7 +904,7 @@ export const dataSteps = [ color: entry.color, dateOriginallyReleased: entry.dateOriginallyReleased, isDefaultTrackSection: false, - tracksByRef: [], + tracks: [], }; continue; @@ -795,9 +912,9 @@ export const dataSteps = [ trackData.push(entry); - entry.dataSourceAlbumByRef = albumRef; + entry.dataSourceAlbum = albumRef; - currentTrackSection.tracksByRef.push(T.Thing.getReference(entry)); + currentTrackSection.tracks.push(Thing.getReference(entry)); } closeCurrentTrackSection(); @@ -821,12 +938,12 @@ export const dataSteps = [ const artistData = results; const artistAliasData = results.flatMap((artist) => { - const origRef = T.Thing.getReference(artist); + const origRef = Thing.getReference(artist); return artist.aliasNames?.map((name) => { const alias = new T.Artist(); alias.name = name; alias.isAlias = true; - alias.aliasedArtistRef = origRef; + alias.aliasedArtist = origRef; alias.artistData = artistData; return alias; }) ?? []; @@ -850,7 +967,7 @@ export const dataSteps = [ save(results) { let flashAct; - let flashesByRef = []; + let flashRefs = []; if (results[0] && !(results[0] instanceof T.FlashAct)) { throw new Error(`Expected an act at top of flash data file`); @@ -859,18 +976,18 @@ export const dataSteps = [ for (const thing of results) { if (thing instanceof T.FlashAct) { if (flashAct) { - Object.assign(flashAct, {flashesByRef}); + Object.assign(flashAct, {flashes: flashRefs}); } flashAct = thing; - flashesByRef = []; + flashRefs = []; } else { - flashesByRef.push(T.Thing.getReference(thing)); + flashRefs.push(Thing.getReference(thing)); } } if (flashAct) { - Object.assign(flashAct, {flashesByRef}); + Object.assign(flashAct, {flashes: flashRefs}); } const flashData = results.filter((x) => x instanceof T.Flash); @@ -893,7 +1010,7 @@ export const dataSteps = [ save(results) { let groupCategory; - let groupsByRef = []; + let groupRefs = []; if (results[0] && !(results[0] instanceof T.GroupCategory)) { throw new Error(`Expected a category at top of group data file`); @@ -902,18 +1019,18 @@ export const dataSteps = [ for (const thing of results) { if (thing instanceof T.GroupCategory) { if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); + Object.assign(groupCategory, {groups: groupRefs}); } groupCategory = thing; - groupsByRef = []; + groupRefs = []; } else { - groupsByRef.push(T.Thing.getReference(thing)); + groupRefs.push(Thing.getReference(thing)); } } if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); + Object.assign(groupCategory, {groups: groupRefs}); } const groupData = results.filter((x) => x instanceof T.Group); @@ -925,6 +1042,10 @@ export const dataSteps = [ { title: `Process homepage layout file`, + + // Kludge: This benefits from the same headerAndEntries style messaging as + // albums and tracks (for example), but that document mode is designed to + // support multiple files, and only one is actually getting processed here. files: [HOMEPAGE_LAYOUT_DATA_FILE], documentMode: documentModes.headerAndEntries, @@ -1005,7 +1126,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { } catch (error) { error.message += (error.message.includes('\n') ? '\n' : ' ') + - `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})`; + `(file: ${colors.bright(colors.blue(path.relative(dataPath, x.file)))})`; throw error; } }; @@ -1013,8 +1134,8 @@ export async function loadAndProcessDataDocuments({dataPath}) { for (const dataStep of dataSteps) { await processDataAggregate.nestAsync( - {message: `Errors during data step: ${dataStep.title}`}, - async ({call, callAsync, map, mapAsync, nest}) => { + {message: `Errors during data step: ${colors.bright(dataStep.title)}`}, + async ({call, callAsync, map, mapAsync, push, nest}) => { const {documentMode} = dataStep; if (!Object.values(documentModes).includes(documentMode)) { @@ -1028,7 +1149,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { // just without the callbacks. Thank you. const filterBlankDocuments = documents => { const aggregate = openAggregate({ - message: `Found blank documents - check for extra '${color.cyan(`---`)}'`, + message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`, }); const filteredDocuments = @@ -1072,10 +1193,10 @@ export async function loadAndProcessDataDocuments({dataPath}) { if (count === 1) { const range = `#${start + 1}`; - parts.push(`${count} document (${color.yellow(range)}), `); + parts.push(`${count} document (${colors.yellow(range)}), `); } else { const range = `#${start + 1}-${end + 1}`; - parts.push(`${count} documents (${color.yellow(range)}), `); + parts.push(`${count} documents (${colors.yellow(range)}), `); } if (previous === null) { @@ -1085,7 +1206,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { } else { const previousDescription = Object.entries(previous).at(0).join(': '); const nextDescription = Object.entries(next).at(0).join(': '); - parts.push(`between "${color.cyan(previousDescription)}" and "${color.cyan(nextDescription)}"`); + parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`); } aggregate.push(new Error(parts.join(''))); @@ -1139,32 +1260,52 @@ export async function loadAndProcessDataDocuments({dataPath}) { return; } - const yamlResult = - documentMode === documentModes.oneDocumentTotal - ? call(yaml.load, readResult) - : call(yaml.loadAll, readResult); + let processResults; - if (!yamlResult) { - return; - } + switch (documentMode) { + case documentModes.oneDocumentTotal: { + const yamlResult = call(yaml.load, readResult); - let processResults; + if (!yamlResult) { + processResults = null; + break; + } + + const {thing, aggregate} = + dataStep.processDocument(yamlResult); + + processResults = thing; + + call(() => aggregate.close()); + + break; + } + + case documentModes.allInOne: { + const yamlResults = call(yaml.loadAll, readResult); + + if (!yamlResults) { + processResults = []; + return; + } + + const {documents, aggregate: filterAggregate} = + filterBlankDocuments(yamlResults); + + call(filterAggregate.close); + + processResults = []; - if (documentMode === documentModes.oneDocumentTotal) { - nest({message: `Errors processing document`}, ({call}) => { - processResults = call(dataStep.processDocument, yamlResult); - }); - } else { - const {documents, aggregate: aggregate1} = filterBlankDocuments(yamlResult); - call(aggregate1.close); - - const {result, aggregate: aggregate2} = mapAggregate( - documents, - decorateErrorWithIndex(dataStep.processDocument), - {message: `Errors processing documents`}); - call(aggregate2.close); - - processResults = result; + map(documents, decorateErrorWithIndex(document => { + const {thing, aggregate} = + dataStep.processDocument(document); + + processResults.push(thing); + aggregate.close(); + }), {message: `Errors processing documents`}); + + break; + } } if (!processResults) return; @@ -1222,81 +1363,74 @@ export async function loadAndProcessDataDocuments({dataPath}) { return {file, documents: filteredDocuments}; }); - let processResults; + const processResults = []; - if (documentMode === documentModes.headerAndEntries) { - nest({message: `Errors processing data files as valid documents`}, ({call, map}) => { - processResults = []; + switch (documentMode) { + case documentModes.headerAndEntries: + map(yamlResults, decorateErrorWithFile(({documents}) => { + const headerDocument = documents[0]; + const entryDocuments = documents.slice(1).filter(Boolean); - yamlResults.forEach(({file, documents}) => { - const [headerDocument, ...entryDocuments] = documents; + if (!headerDocument) + throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`); - if (!headerDocument) { - call(decorateErrorWithFile(() => { - throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`); - }), {file}); - return; - } + // This'll be decorated with the file, and groups together any + // errors from processing the header and entry documents. + const fileAggregate = + openAggregate({message: `Errors processing documents`}); - const header = call( - decorateErrorWithFile(({document}) => - dataStep.processHeaderDocument(document)), - {file, document: headerDocument}); + const {thing: headerObject, aggregate: headerAggregate} = + dataStep.processHeaderDocument(headerDocument); - // Don't continue processing files whose header - // document is invalid - the entire file is excempt - // from data in this case. - if (!header) { - return; + try { + headerAggregate.close() + } catch (caughtError) { + caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`; + fileAggregate.push(caughtError); } - const entries = map( - entryDocuments - .filter(Boolean) - .map((document) => ({file, document})), - decorateErrorWithFile( - decorateErrorWithIndex(({document}) => - dataStep.processEntryDocument(document))), - {message: `Errors processing entry documents`}); - - // Entries may be incomplete (i.e. any errored - // documents won't have a processed output - // represented here) - this is intentional! By - // principle, partial output is preferred over - // erroring an entire file. - processResults.push({header, entries}); - }); - }); - } + const entryObjects = []; - if (documentMode === documentModes.onePerFile) { - nest({message: `Errors processing data files as valid documents`}, ({call}) => { - processResults = []; + for (let index = 0; index < entryDocuments.length; index++) { + const entryDocument = entryDocuments[index]; - yamlResults.forEach(({file, documents}) => { - if (documents.length > 1) { - call(decorateErrorWithFile(() => { - throw new Error(`Only expected one document to be present per file`); - }), {file}); - return; - } else if (empty(documents) || !documents[0]) { - call(decorateErrorWithFile(() => { - throw new Error(`Expected a document, this file is empty`); - }), {file}); - } + const {thing: entryObject, aggregate: entryAggregate} = + dataStep.processEntryDocument(entryDocument); - const result = call( - decorateErrorWithFile(({document}) => - dataStep.processDocument(document)), - {file, document: documents[0]}); + entryObjects.push(entryObject); - if (!result) { - return; + try { + entryAggregate.close(); + } catch (caughtError) { + caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`; + fileAggregate.push(caughtError); + } } - processResults.push(result); - }); - }); + processResults.push({ + header: headerObject, + entries: entryObjects, + }); + + fileAggregate.close(); + }), {message: `Errors processing documents in data files`}); + break; + + case documentModes.onePerFile: + map(yamlResults, decorateErrorWithFile(({documents}) => { + if (documents.length > 1) + throw new Error(`Only expected one document to be present per file, got ${documents.length} here`); + + if (empty(documents) || !documents[0]) + throw new Error(`Expected a document, this file is empty`); + + const {thing, aggregate} = + dataStep.processDocument(documents[0]); + + processResults.push(thing); + aggregate.close(); + }), {message: `Errors processing data files as valid documents`}); + break; } const saveResult = call(dataStep.save, processResults); @@ -1316,13 +1450,27 @@ export async function loadAndProcessDataDocuments({dataPath}) { // Data linking! Basically, provide (portions of) wikiData to the Things which // require it - they'll expose dynamically computed properties as a result (many -// of which are required for page HTML generation). -export function linkWikiDataArrays(wikiData) { +// of which are required for page HTML generation and other expected behavior). +// +// The XXX_decacheWikiData option should be used specifically to mark +// points where you *aren't* replacing any of the arrays under wikiData with +// new values, and are using linkWikiDataArrays to instead "decache" data +// properties which depend on any of them. It's currently not possible for +// a CacheableObject to depend directly on the value of a property exposed +// on some other CacheableObject, so when those values change, you have to +// manually decache before the object will realize its cache isn't valid +// anymore. +export function linkWikiDataArrays(wikiData, { + XXX_decacheWikiData = false, +} = {}) { function assignWikiData(things, ...keys) { + if (things === undefined) return; for (let i = 0; i < things.length; i++) { const thing = things[i]; for (let j = 0; j < keys.length; j++) { const key = keys[j]; + if (!(key in wikiData)) continue; + if (XXX_decacheWikiData) thing[key] = []; thing[key] = wikiData[key]; } } @@ -1340,7 +1488,7 @@ export function linkWikiDataArrays(wikiData) { assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData'); assignWikiData(WD.flashActData, 'flashData'); assignWikiData(WD.artTagData, 'albumData', 'trackData'); - assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData'); + assignWikiData(WD.homepageLayout?.rows, 'albumData', 'groupData'); } export function sortWikiDataArrays(wikiData) { @@ -1368,7 +1516,9 @@ export function filterDuplicateDirectories(wikiData) { const deduplicateSpec = [ 'albumData', 'artTagData', + 'artistData', 'flashData', + 'flashActData', 'groupData', 'newsData', 'trackData', @@ -1377,7 +1527,7 @@ export function filterDuplicateDirectories(wikiData) { const aggregate = openAggregate({message: `Duplicate directories found`}); for (const thingDataProp of deduplicateSpec) { const thingData = wikiData[thingDataProp]; - aggregate.nest({message: `Duplicate directories found in ${color.green('wikiData.' + thingDataProp)}`}, ({call}) => { + aggregate.nest({message: `Duplicate directories found in ${colors.green('wikiData.' + thingDataProp)}`}, ({call}) => { const directoryPlaces = Object.create(null); const duplicateDirectories = []; @@ -1403,7 +1553,7 @@ export function filterDuplicateDirectories(wikiData) { const places = directoryPlaces[directory]; call(() => { throw new Error( - `Duplicate directory ${color.green(directory)}:\n` + + `Duplicate directory ${colors.green(directory)}:\n` + places.map((thing) => ` - ` + inspect(thing)).join('\n') ); }); @@ -1444,45 +1594,45 @@ export function filterDuplicateDirectories(wikiData) { export function filterReferenceErrors(wikiData) { const referenceSpec = [ ['wikiInfo', processWikiInfoDocument, { - divideTrackListsByGroupsByRef: 'group', + divideTrackListsByGroups: 'group', }], ['albumData', processAlbumDocument, { - artistContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - trackCoverArtistContribsByRef: '_contrib', - wallpaperArtistContribsByRef: '_contrib', - bannerArtistContribsByRef: '_contrib', - groupsByRef: 'group', - artTagsByRef: 'artTag', + artistContribs: '_contrib', + coverArtistContribs: '_contrib', + trackCoverArtistContribs: '_contrib', + wallpaperArtistContribs: '_contrib', + bannerArtistContribs: '_contrib', + groups: 'group', + artTags: 'artTag', }], ['trackData', processTrackDocument, { - artistContribsByRef: '_contrib', - contributorContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - referencedTracksByRef: '_trackNotRerelease', - sampledTracksByRef: '_trackNotRerelease', - artTagsByRef: 'artTag', - originalReleaseTrackByRef: '_trackNotRerelease', + artistContribs: '_contrib', + contributorContribs: '_contrib', + coverArtistContribs: '_contrib', + referencedTracks: '_trackNotRerelease', + sampledTracks: '_trackNotRerelease', + artTags: 'artTag', + originalReleaseTrack: '_trackNotRerelease', }], ['groupCategoryData', processGroupCategoryDocument, { - groupsByRef: 'group', + groups: 'group', }], ['homepageLayout.rows', undefined, { - sourceGroupByRef: 'group', - sourceAlbumsByRef: 'album', + sourceGroup: '_homepageSourceGroup', + sourceAlbums: 'album', }], ['flashData', processFlashDocument, { - contributorContribsByRef: '_contrib', - featuredTracksByRef: 'track', + contributorContribs: '_contrib', + featuredTracks: 'track', }], ['flashActData', processFlashActDocument, { - flashesByRef: 'flash', + flashes: 'flash', }], ]; @@ -1498,7 +1648,7 @@ export function filterReferenceErrors(wikiData) { for (const [thingDataProp, providedProcessDocumentFn, propSpec] of referenceSpec) { const thingData = getNestedProp(wikiData, thingDataProp); - aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({nest}) => { + aggregate.nest({message: `Reference errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => { const things = Array.isArray(thingData) ? thingData : [thingData]; for (const thing of things) { @@ -1514,10 +1664,10 @@ export function filterReferenceErrors(wikiData) { nest({message: `Reference errors in ${inspect(thing)}`}, ({push, filter}) => { for (const [property, findFnKey] of Object.entries(propSpec)) { - const value = thing[property]; + const value = CacheableObject.getUpdateValue(thing, property); if (value === undefined) { - push(new TypeError(`Property ${color.red(property)} isn't valid for ${color.green(thing.constructor.name)}`)); + push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`)); continue; } @@ -1534,23 +1684,34 @@ export function filterReferenceErrors(wikiData) { if (alias) { // No need to check if the original exists here. Aliases are automatically // created from a field on the original, so the original certainly exists. - const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'}); - throw new Error(`Reference ${color.red(contribRef.who)} is to an alias, should be ${color.green(original.name)}`); + const original = alias.aliasedArtist; + throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`); } return boundFind.artist(contribRef.who); }; break; + case '_homepageSourceGroup': + findFn = groupRef => { + if (groupRef === 'new-additions' || groupRef === 'new-releases') { + return true; + } + + return boundFind.group(groupRef); + }; + break; + case '_trackNotRerelease': findFn = trackRef => { const track = find.track(trackRef, wikiData.trackData, {mode: 'error'}); + const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack'); - if (track?.originalReleaseTrackByRef) { + if (originalRef) { // It's possible for the original to not actually exist, in this case. // It should still be reported since the 'Originally Released As' field // was present. - const original = find.track(track.originalReleaseTrackByRef, wikiData.trackData, {mode: 'quiet'}); + const original = find.track(originalRef, wikiData.trackData, {mode: 'quiet'}); // Prefer references by name, but only if it's unambiguous. const originalByName = @@ -1560,12 +1721,12 @@ export function filterReferenceErrors(wikiData) { const shouldBeMessage = (originalByName - ? color.green(original.name) + ? colors.green(original.name) : original - ? color.green('track:' + original.directory) - : color.green(track.originalReleaseTrackByRef)); + ? colors.green('track:' + original.directory) + : colors.green(originalRef)); - throw new Error(`Reference ${color.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`); + throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`); } return track; @@ -1578,7 +1739,7 @@ export function filterReferenceErrors(wikiData) { } const suppress = fn => conditionallySuppressError(error => { - if (property === 'sampledTracksByRef') { + if (property === 'sampledTracks') { // Suppress "didn't match anything" errors in particular, just for samples. // In hsmusic-data we have a lot of "stub" sample data which don't have // corresponding tracks yet, so it won't be useful to report such reference @@ -1596,13 +1757,13 @@ export function filterReferenceErrors(wikiData) { const fieldPropertyMessage = (processDocumentFn?.propertyFieldMapping?.[property] - ? ` in field ${color.green(processDocumentFn.propertyFieldMapping[property])}` - : ` in property ${color.green(property)}`); + ? ` in field ${colors.green(processDocumentFn.propertyFieldMapping[property])}` + : ` in property ${colors.green(property)}`); const findFnMessage = (findFnKey.startsWith('_') ? `` - : ` (${color.green('find.' + findFnKey)})`); + : ` (${colors.green('find.' + findFnKey)})`); const errorMessage = (Array.isArray(value) |