diff options
Diffstat (limited to 'src/data')
62 files changed, 2698 insertions, 361 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index 4a89b774..010d967a 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -132,7 +132,11 @@ export default class CacheableObject { return; } - if ('default' in update) { + if ( + typeof update === 'object' && + update !== null && + 'default' in update + ) { this[property] = update?.default; } else { this[property] = null; @@ -370,6 +374,18 @@ export default class CacheableObject { return object.#propertyUpdateValues[key] ?? null; } + + static clone(object) { + const newObject = Reflect.construct(object.constructor, []); + + this.copyUpdateValuesOnto(object, newObject); + + return newObject; + } + + static copyUpdateValuesOnto(source, target) { + Object.assign(target, source.#propertyUpdateValues); + } } export class CacheableObjectPropertyValueError extends Error { diff --git a/src/data/checks.js b/src/data/checks.js index f3741a19..ad621bab 100644 --- a/src/data/checks.js +++ b/src/data/checks.js @@ -95,6 +95,7 @@ export function reportDirectoryErrors(wikiData, { const seenDuplicateSets = new Map(); const deduplicateDuplicateSets = []; + iterateSets: for (const set of duplicateSets) { if (seenDuplicateSets.has(set.directory)) { const placeLists = seenDuplicateSets.get(set.directory); @@ -106,7 +107,7 @@ export function reportDirectoryErrors(wikiData, { // Two artists named Foodog aren't going to match two tracks named // Foodog. if (compareArrays(places, set.places, {checkOrder: false})) { - continue; + continue iterateSets; } } diff --git a/src/data/composite.js b/src/data/composite.js index 33d69a68..ea7a3480 100644 --- a/src/data/composite.js +++ b/src/data/composite.js @@ -758,6 +758,9 @@ export function compositeFrom(description) { anyStepsUseUpdateValue || anyStepsUpdate; + const stepsFirstTimeCalling = + Array.from({length: steps.length}).fill(true); + const stepEntries = stitchArrays({ step: steps, stepComposes: stepsCompose, @@ -977,8 +980,16 @@ export function compositeFrom(description) { (expectingTransform ? {[input.updateValue()]: valueSoFar} : {}), - [input.myself()]: initialDependencies?.['this'] ?? null, - [input.thisProperty()]: initialDependencies?.['thisProperty'] ?? null, + + [input.myself()]: + (initialDependencies && Object.hasOwn(initialDependencies, 'this') + ? initialDependencies.this + : null), + + [input.thisProperty()]: + (initialDependencies && Object.hasOwn(initialDependencies, 'thisProperty') + ? initialDependencies.thisProperty + : null), }; const selectDependencies = @@ -1028,7 +1039,123 @@ export function compositeFrom(description) { const naturalEvaluate = () => { const [name, ...argsLayout] = getExpectedEvaluation(); - let args; + let args = argsLayout; + + let effectiveDependencies; + let reviewAccessedDependencies; + + if (stepsFirstTimeCalling[i]) { + const expressedDependencies = + selectDependencies; + + const remainingDependencies = + new Set(expressedDependencies); + + const unavailableDependencies = []; + const accessedDependencies = []; + + effectiveDependencies = + new Proxy(filteredDependencies, { + get(target, key) { + accessedDependencies.push(key); + remainingDependencies.delete(key); + + const value = target[key]; + + if (value === undefined) { + unavailableDependencies.push(key); + } + + return value; + }, + }); + + reviewAccessedDependencies = () => { + const topAggregate = + openAggregate({ + message: `Errors in accessed dependencies`, + }); + + const showDependency = dependency => + (isInputToken(dependency) + ? getInputTokenShape(dependency) + + `(` + + inspect(getInputTokenValue(dependency), {compact: true}) + + ')' + : dependency.toString()); + + let anyErrors = false; + + for (const dependency of remainingDependencies) { + topAggregate.push(new Error( + `Expected to access ${showDependency(dependency)}`)); + + anyErrors = true; + } + + for (const dependency of unavailableDependencies) { + const subAggregate = + openAggregate({ + message: + `Accessed ${showDependency(dependency)}, which is unavailable`, + }); + + let reason = false; + + if (!expressedDependencies.includes(dependency)) { + subAggregate.push(new Error( + `Missing from step's expressed dependencies`)); + reason = true; + } + + if (filterableDependencies[dependency] === undefined) { + subAggregate.push( + new Error( + `Not available` + + (isInputToken(dependency) + ? ` in input()-type dependencies` + : dependency.startsWith('#') + ? ` in local dependencies` + : ` on object dependencies`))); + reason = true; + } + + if (!reason) { + subAggregate.push(new Error( + `Not sure why this is unavailable, sorry!`)); + } + + topAggregate.call(subAggregate.close); + + anyErrors = true; + } + + if (anyErrors) { + topAggregate.push(new Error( + `These dependencies, in total, were accessed:` + + (empty(accessedDependencies) + ? ` (none)` + : accessedDependencies.length === 1 + ? showDependency(accessedDependencies[0]) + : `\n` + + accessedDependencies + .map(showDependency) + .map(line => ` - ${line}`) + .join('\n')))); + } + + topAggregate.close(); + }; + } else { + effectiveDependencies = filteredDependencies; + reviewAccessedDependencies = null; + } + + args = + args.map(arg => + (arg === filteredDependencies + ? effectiveDependencies + : arg)); if (stepComposes) { let continuation; @@ -1037,17 +1164,50 @@ export function compositeFrom(description) { _prepareContinuation(callingTransformForThisStep)); args = - argsLayout.map(arg => + args.map(arg => (arg === continuationSymbol ? continuation : arg)); } else { args = - argsLayout.filter(arg => arg !== continuationSymbol); + args.filter(arg => arg !== continuationSymbol); } - return expose[name](...args); - } + let stepError; + try { + return expose[name](...args); + } catch (error) { + stepError = error; + } finally { + stepsFirstTimeCalling[i] = false; + + let reviewError; + if (reviewAccessedDependencies) { + try { + reviewAccessedDependencies(); + } catch (error) { + reviewError = error; + } + } + + const stepPart = + `step ${i+1}` + + (isBase + ? ` (base)` + : ` of ${steps.length}`) + + (step.annotation ? `, ${step.annotation}` : ``); + + if (stepError && reviewError) { + throw new AggregateError( + [stepError, reviewError], + `Errors in ${stepPart}`); + } else if (stepError || reviewError) { + throw new Error( + `Error in ${stepPart}`, + {cause: stepError || reviewError}); + } + } + }; switch (step.cache) { // Warning! Highly WIP! @@ -1223,6 +1383,7 @@ export function compositeFrom(description) { `Error computing composition` + (annotation ? ` ${annotation}` : '')); error.cause = thrownError; + error[Symbol.for('hsmusic.aggregate.translucent')] = true; throw error; } }; diff --git a/src/data/composite/control-flow/exposeWhetherDependencyAvailable.js b/src/data/composite/control-flow/exposeWhetherDependencyAvailable.js new file mode 100644 index 00000000..a2fdd6b0 --- /dev/null +++ b/src/data/composite/control-flow/exposeWhetherDependencyAvailable.js @@ -0,0 +1,42 @@ +// Exposes true if a dependency is available, and false otherwise, +// or the reverse if the `negate` input is set true. +// +// See withResultOfAvailabilityCheck for {mode} options. + +import {input, templateCompositeFrom} from '#composite'; + +import inputAvailabilityCheckMode from './inputAvailabilityCheckMode.js'; +import withResultOfAvailabilityCheck from './withResultOfAvailabilityCheck.js'; + +export default templateCompositeFrom({ + annotation: `exposeWhetherDependencyAvailable`, + + compose: false, + + inputs: { + dependency: input({acceptsNull: true}), + + mode: inputAvailabilityCheckMode(), + + negate: input({type: 'boolean', defaultValue: false}), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('negate')], + + compute: ({ + ['#availability']: availability, + [input('negate')]: negate, + }) => + (negate + ? !availability + : availability), + }, + ], +}); diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js index 7fad88b2..6148d465 100644 --- a/src/data/composite/control-flow/index.js +++ b/src/data/composite/control-flow/index.js @@ -9,6 +9,7 @@ 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 exposeWhetherDependencyAvailable} from './exposeWhetherDependencyAvailable.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/withResultOfAvailabilityCheck.js b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js index a6942014..1d90b324 100644 --- a/src/data/composite/control-flow/withResultOfAvailabilityCheck.js +++ b/src/data/composite/control-flow/withResultOfAvailabilityCheck.js @@ -17,6 +17,7 @@ // - exitWithoutUpdateValue // - exposeDependencyOrContinue // - exposeUpdateValueOrContinue +// - exposeWhetherDependencyAvailable // - raiseOutputWithoutDependency // - raiseOutputWithoutUpdateValue // diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js index d798dcdc..2a3e818e 100644 --- a/src/data/composite/data/excludeFromList.js +++ b/src/data/composite/data/excludeFromList.js @@ -5,11 +5,6 @@ // See also: // - fillMissingListItems // -// More list utilities: -// - withFilteredList, withMappedList, withSortedList -// - withFlattenedList, withUnflattenedList -// - withPropertyFromList, withPropertiesFromList -// import {input, templateCompositeFrom} from '#composite'; import {empty} from '#sugar'; diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js index 4f818a79..356b1119 100644 --- a/src/data/composite/data/fillMissingListItems.js +++ b/src/data/composite/data/fillMissingListItems.js @@ -4,11 +4,6 @@ // See also: // - excludeFromList // -// More list utilities: -// - withFilteredList, withMappedList, withSortedList -// - withFlattenedList, withUnflattenedList -// - withPropertyFromList, withPropertiesFromList -// import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js index 256c0490..c80bb350 100644 --- a/src/data/composite/data/index.js +++ b/src/data/composite/data/index.js @@ -3,15 +3,32 @@ // Entries here may depend on entries in #composite/control-flow. // +// Utilities which act on generic objects + +export {default as withPropertiesFromObject} from './withPropertiesFromObject.js'; +export {default as withPropertyFromObject} from './withPropertyFromObject.js'; + +// Utilities which act on generic lists + export {default as excludeFromList} from './excludeFromList.js'; + export {default as fillMissingListItems} from './fillMissingListItems.js'; +export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js'; + export {default as withFilteredList} from './withFilteredList.js'; -export {default as withFlattenedList} from './withFlattenedList.js'; export {default as withMappedList} from './withMappedList.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 withSortedList} from './withSortedList.js'; + +export {default as withPropertyFromList} from './withPropertyFromList.js'; +export {default as withPropertiesFromList} from './withPropertiesFromList.js'; + +export {default as withFlattenedList} from './withFlattenedList.js'; export {default as withUnflattenedList} from './withUnflattenedList.js'; -export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js'; + +export {default as withIndexInList} from './withIndexInList.js'; +export {default as withNearbyItemFromList} from './withNearbyItemFromList.js'; + +// Utilities which act on slightly more particular data forms +// (probably, containers of particular kinds of values) + +export {default as withSum} from './withSum.js'; diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js index 82e56903..60fe66f4 100644 --- a/src/data/composite/data/withFilteredList.js +++ b/src/data/composite/data/withFilteredList.js @@ -16,12 +16,6 @@ // - withMappedList // - withSortedList // -// More list utilities: -// - excludeFromList -// - fillMissingListItems -// - withFlattenedList, withUnflattenedList -// - withPropertyFromList, withPropertiesFromList -// import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js index edfa3403..31b1a742 100644 --- a/src/data/composite/data/withFlattenedList.js +++ b/src/data/composite/data/withFlattenedList.js @@ -5,12 +5,6 @@ // See also: // - withUnflattenedList // -// More list utilities: -// - excludeFromList -// - fillMissingListItems -// - withFilteredList, withMappedList, withSortedList -// - withPropertyFromList, withPropertiesFromList -// import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/withIndexInList.js b/src/data/composite/data/withIndexInList.js new file mode 100644 index 00000000..b1af2033 --- /dev/null +++ b/src/data/composite/data/withIndexInList.js @@ -0,0 +1,38 @@ +// Gets the index of the provided item in the provided list. Note that this +// will output -1 if the item is not found, and this may be detected using +// any availability check with type: 'index'. If the list includes the item +// twice, the output index will be of the first match. +// +// Both the list and item must be provided. +// +// See also: +// - withNearbyItemFromList +// - exitWithoutDependency +// - raiseOutputWithoutDependency +// + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `withIndexInList`, + + inputs: { + list: input({acceptsNull: false, type: 'array'}), + item: input({acceptsNull: false}), + }, + + outputs: ['#index'], + + steps: () => [ + { + dependencies: [input('list'), input('item')], + compute: (continuation, { + [input('list')]: list, + [input('item')]: item, + }) => continuation({ + ['#index']: + list.indexOf(item), + }), + }, + ], +}); diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js index e0a700b2..0bc63a92 100644 --- a/src/data/composite/data/withMappedList.js +++ b/src/data/composite/data/withMappedList.js @@ -5,12 +5,6 @@ // - withFilteredList // - withSortedList // -// More list utilities: -// - excludeFromList -// - fillMissingListItems -// - withFlattenedList, withUnflattenedList -// - withPropertyFromList, withPropertiesFromList -// import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/withNearbyItemFromList.js b/src/data/composite/data/withNearbyItemFromList.js new file mode 100644 index 00000000..83a8cc21 --- /dev/null +++ b/src/data/composite/data/withNearbyItemFromList.js @@ -0,0 +1,73 @@ +// Gets a nearby (typically adjacent) item in a list, meaning the item which is +// placed at a particular offset compared to the provided item. This is null if +// the provided list doesn't include the provided item at all, and also if the +// offset would read past either end of the list - except if configured: +// +// - If the 'wrap' input is provided (as true), the offset will loop around +// and continue from the opposing end. +// +// - If the 'valuePastEdge' input is provided, that value will be output +// instead of null. +// +// Both the list and item must be provided. +// +// See also: +// - withIndexInList +// + +import {input, templateCompositeFrom} from '#composite'; +import {atOffset} from '#sugar'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +import withIndexInList from './withIndexInList.js'; + +export default templateCompositeFrom({ + annotation: `withNearbyItemFromList`, + + inputs: { + list: input({acceptsNull: false, type: 'array'}), + item: input({acceptsNull: false}), + + offset: input({type: 'number'}), + wrap: input({type: 'boolean', defaultValue: false}), + }, + + outputs: ['#nearbyItem'], + + steps: () => [ + withIndexInList({ + list: input('list'), + item: input('item'), + }), + + raiseOutputWithoutDependency({ + dependency: '#index', + mode: input.value('index'), + + output: input.value({ + ['#nearbyItem']: + null, + }), + }), + + { + dependencies: [ + input('list'), + input('offset'), + input('wrap'), + '#index', + ], + + compute: (continuation, { + [input('list')]: list, + [input('offset')]: offset, + [input('wrap')]: wrap, + ['#index']: index, + }) => continuation({ + ['#nearbyItem']: + atOffset(list, index, offset, {wrap}), + }), + }, + ], +}); diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js index 08907bab..fb4134bc 100644 --- a/src/data/composite/data/withPropertiesFromList.js +++ b/src/data/composite/data/withPropertiesFromList.js @@ -8,12 +8,6 @@ // - withPropertiesFromObject // - withPropertyFromList // -// More list utilities: -// - excludeFromList -// - fillMissingListItems -// - withFilteredList, withMappedList, withSortedList -// - withFlattenedList, withUnflattenedList -// import {input, templateCompositeFrom} from '#composite'; import {isString, validateArrayItems} from '#validators'; diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js index a2c66d77..65ebf77b 100644 --- a/src/data/composite/data/withPropertyFromList.js +++ b/src/data/composite/data/withPropertyFromList.js @@ -9,12 +9,6 @@ // - withPropertiesFromList // - withPropertyFromObject // -// More list utilities: -// - excludeFromList -// - fillMissingListItems -// - withFilteredList, withMappedList, withSortedList -// - withFlattenedList, withUnflattenedList -// import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js index b31bab15..4f240506 100644 --- a/src/data/composite/data/withPropertyFromObject.js +++ b/src/data/composite/data/withPropertyFromObject.js @@ -2,11 +2,15 @@ // If the object itself is null, or the object doesn't have the listed property, // the provided dependency will also be null. // +// If the `internal` input is true, this reads the CacheableObject update value +// of the object rather than its exposed value. +// // See also: // - withPropertiesFromObject // - withPropertyFromList // +import CacheableObject from '#cacheable-object'; import {input, templateCompositeFrom} from '#composite'; export default templateCompositeFrom({ @@ -15,6 +19,7 @@ export default templateCompositeFrom({ inputs: { object: input({type: 'object', acceptsNull: true}), property: input({type: 'string'}), + internal: input({type: 'boolean', defaultValue: false}), }, outputs: ({ @@ -49,20 +54,35 @@ export default templateCompositeFrom({ { dependencies: [ - '#output', input('object'), input('property'), + input('internal'), ], compute: (continuation, { - ['#output']: output, [input('object')]: object, [input('property')]: property, + [input('internal')]: internal, }) => continuation({ - [output]: + '#value': (object === null ? null - : object[property] ?? null), + : internal + ? CacheableObject.getUpdateValue(object, property) + ?? null + : object[property] + ?? null), + }), + }, + + { + dependencies: ['#output', '#value'], + + compute: (continuation, { + ['#output']: output, + ['#value']: value, + }) => continuation({ + [output]: value, }), }, ], diff --git a/src/data/composite/data/withSortedList.js b/src/data/composite/data/withSortedList.js index dd810786..a7d21768 100644 --- a/src/data/composite/data/withSortedList.js +++ b/src/data/composite/data/withSortedList.js @@ -27,12 +27,6 @@ // - withFilteredList // - withMappedList // -// More list utilities: -// - excludeFromList -// - fillMissingListItems -// - withFlattenedList, withUnflattenedList -// - withPropertyFromList, withPropertiesFromList -// import {input, templateCompositeFrom} from '#composite'; diff --git a/src/data/composite/data/withSum.js b/src/data/composite/data/withSum.js new file mode 100644 index 00000000..484e9906 --- /dev/null +++ b/src/data/composite/data/withSum.js @@ -0,0 +1,33 @@ +// Gets the numeric total of adding all the values in a list together. +// Values that are false, null, or undefined are skipped over. + +import {input, templateCompositeFrom} from '#composite'; +import {isNumber, sparseArrayOf} from '#validators'; + +export default templateCompositeFrom({ + annotation: `withSum`, + + inputs: { + values: input({ + validate: sparseArrayOf(isNumber), + }), + }, + + outputs: ['#sum'], + + steps: () => [ + { + dependencies: [input('values')], + compute: (continuation, { + [input('values')]: values, + }) => continuation({ + ['#sum']: + values + .filter(item => typeof item === 'number') + .reduce( + (accumulator, value) => accumulator + value, + 0), + }), + }, + ], +}); diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js index 39a666dc..820d628a 100644 --- a/src/data/composite/data/withUnflattenedList.js +++ b/src/data/composite/data/withUnflattenedList.js @@ -7,12 +7,6 @@ // See also: // - withFlattenedList // -// More list utilities: -// - excludeFromList -// - fillMissingListItems -// - withFilteredList, withMappedList, withSortedList -// - withPropertyFromList, withPropertiesFromList -// import {input, templateCompositeFrom} from '#composite'; import {isWholeNumber, validateArrayItems} from '#validators'; diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js index 8b5098f0..0ef91b87 100644 --- a/src/data/composite/things/album/index.js +++ b/src/data/composite/things/album/index.js @@ -1 +1,2 @@ +export {default as withTrackSections} from './withTrackSections.js'; export {default as withTracks} from './withTracks.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..a56bda31 --- /dev/null +++ b/src/data/composite/things/album/withTrackSections.js @@ -0,0 +1,21 @@ +import {input, templateCompositeFrom} from '#composite'; + +import find from '#find'; + +import {withResolvedReferenceList} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withTrackSections`, + + outputs: ['#trackSections'], + + steps: () => [ + withResolvedReferenceList({ + list: 'trackSections', + data: 'ownTrackSectionData', + find: input.value(find.unqualifiedTrackSection), + }).outputs({ + ['#resolvedReferenceList']: '#trackSections', + }), + ], +}); diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js index 3fe6dd2e..c8d27c4c 100644 --- a/src/data/composite/things/album/withTracks.js +++ b/src/data/composite/things/album/withTracks.js @@ -1,24 +1,17 @@ import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; - -import {exitWithoutDependency} from '#composite/control-flow'; import {withFlattenedList, withPropertyFromList} from '#composite/data'; import {withResolvedReferenceList} from '#composite/wiki-data'; +import withTrackSections from './withTrackSections.js'; + export default templateCompositeFrom({ annotation: `withTracks`, outputs: ['#tracks'], steps: () => [ - withResolvedReferenceList({ - list: 'trackSections', - data: 'ownTrackSectionData', - find: input.value(find.unqualifiedTrackSection), - }).outputs({ - ['#resolvedReferenceList']: '#trackSections', - }), + withTrackSections(), withPropertyFromList({ list: '#trackSections', diff --git a/src/data/composite/things/artist/artistTotalDuration.js b/src/data/composite/things/artist/artistTotalDuration.js new file mode 100644 index 00000000..ff709f28 --- /dev/null +++ b/src/data/composite/things/artist/artistTotalDuration.js @@ -0,0 +1,70 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {exposeDependency} from '#composite/control-flow'; +import {withFilteredList, withPropertyFromList} from '#composite/data'; +import {withContributionListSums, withReverseContributionList} + from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `artistTotalDuration`, + + compose: false, + + steps: () => [ + withReverseContributionList({ + data: 'trackData', + list: input.value('artistContribs'), + }).outputs({ + '#reverseContributionList': '#contributionsAsArtist', + }), + + withReverseContributionList({ + data: 'trackData', + list: input.value('contributorContribs'), + }).outputs({ + '#reverseContributionList': '#contributionsAsContributor', + }), + + { + dependencies: [ + '#contributionsAsArtist', + '#contributionsAsContributor', + ], + + compute: (continuation, { + ['#contributionsAsArtist']: artistContribs, + ['#contributionsAsContributor']: contributorContribs, + }) => continuation({ + ['#allContributions']: [ + ...artistContribs, + ...contributorContribs, + ], + }), + }, + + withPropertyFromList({ + list: '#allContributions', + property: input.value('thing'), + }), + + withPropertyFromList({ + list: '#allContributions.thing', + property: input.value('isOriginalRelease'), + }), + + withFilteredList({ + list: '#allContributions', + filter: '#allContributions.thing.isOriginalRelease', + }).outputs({ + '#filteredList': '#originalContributions', + }), + + withContributionListSums({ + list: '#originalContributions', + }), + + exposeDependency({ + dependency: '#contributionListDuration', + }), + ], +}); diff --git a/src/data/composite/things/artist/index.js b/src/data/composite/things/artist/index.js new file mode 100644 index 00000000..55514c71 --- /dev/null +++ b/src/data/composite/things/artist/index.js @@ -0,0 +1 @@ +export {default as artistTotalDuration} from './artistTotalDuration.js'; diff --git a/src/data/composite/things/contribution/index.js b/src/data/composite/things/contribution/index.js new file mode 100644 index 00000000..9b22be2e --- /dev/null +++ b/src/data/composite/things/contribution/index.js @@ -0,0 +1,7 @@ +export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js'; +export {default as thingPropertyMatches} from './thingPropertyMatches.js'; +export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js'; +export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js'; +export {default as withContributionArtist} from './withContributionArtist.js'; +export {default as withContributionContext} from './withContributionContext.js'; +export {default as withMatchingContributionPresets} from './withMatchingContributionPresets.js'; diff --git a/src/data/composite/things/contribution/inheritFromContributionPresets.js b/src/data/composite/things/contribution/inheritFromContributionPresets.js new file mode 100644 index 00000000..82425b9c --- /dev/null +++ b/src/data/composite/things/contribution/inheritFromContributionPresets.js @@ -0,0 +1,61 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromList, withPropertyFromObject} from '#composite/data'; + +import withMatchingContributionPresets + from './withMatchingContributionPresets.js'; + +export default templateCompositeFrom({ + annotation: `inheritFromContributionPresets`, + + inputs: { + property: input({type: 'string'}), + }, + + steps: () => [ + withMatchingContributionPresets().outputs({ + '#matchingContributionPresets': '#presets', + }), + + raiseOutputWithoutDependency({ + dependency: '#presets', + mode: input.value('empty'), + }), + + withPropertyFromList({ + list: '#presets', + property: input('property'), + }), + + { + dependencies: ['#values'], + + compute: (continuation, { + ['#values']: values, + }) => continuation({ + ['#index']: + values.findIndex(value => + value !== undefined && + value !== null), + }), + }, + + raiseOutputWithoutDependency({ + dependency: '#index', + mode: input.value('index'), + }), + + { + dependencies: ['#values', '#index'], + + compute: (continuation, { + ['#values']: values, + ['#index']: index, + }) => continuation({ + ['#value']: + values[index], + }), + }, + ], +}); diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js new file mode 100644 index 00000000..4a37f2cf --- /dev/null +++ b/src/data/composite/things/contribution/thingPropertyMatches.js @@ -0,0 +1,33 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {exitWithoutDependency} from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: `thingPropertyMatches`, + + compose: false, + + inputs: { + value: input({type: 'string'}), + }, + + steps: () => [ + exitWithoutDependency({ + dependency: 'thingProperty', + value: input.value(false), + }), + + { + dependencies: [ + 'thingProperty', + input('value'), + ], + + compute: ({ + ['thingProperty']: thingProperty, + [input('value')]: value, + }) => + thingProperty === value, + }, + ], +}); diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js new file mode 100644 index 00000000..2ee811af --- /dev/null +++ b/src/data/composite/things/contribution/thingReferenceTypeMatches.js @@ -0,0 +1,39 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {exitWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `thingReferenceTypeMatches`, + + compose: false, + + inputs: { + value: input({type: 'string'}), + }, + + steps: () => [ + exitWithoutDependency({ + dependency: 'thing', + value: input.value(false), + }), + + withPropertyFromObject({ + object: 'thing', + property: input.value('constructor'), + }), + + { + dependencies: [ + '#thing.constructor', + input('value'), + ], + + compute: ({ + ['#thing.constructor']: constructor, + [input('value')]: value, + }) => + constructor[Symbol.for('Thing.referenceType')] === value, + }, + ], +}); diff --git a/src/data/composite/things/contribution/withContainingReverseContributionList.js b/src/data/composite/things/contribution/withContainingReverseContributionList.js new file mode 100644 index 00000000..56704c8b --- /dev/null +++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js @@ -0,0 +1,40 @@ +// Get the artist's contribution list containing this property. + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +import withContributionArtist from './withContributionArtist.js'; + +export default templateCompositeFrom({ + annotation: `withContainingReverseContributionList`, + + inputs: { + artistProperty: input({ + defaultDependency: 'artistProperty', + acceptsNull: true, + }), + }, + + outputs: ['#containingReverseContributionList'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: input('artistProperty'), + output: input.value({ + ['#containingReverseContributionList']: + null, + }), + }), + + withContributionArtist(), + + withPropertyFromObject({ + object: '#artist', + property: input('artistProperty'), + }).outputs({ + ['#value']: '#containingReverseContributionList', + }), + ], +}); diff --git a/src/data/composite/things/contribution/withContributionArtist.js b/src/data/composite/things/contribution/withContributionArtist.js new file mode 100644 index 00000000..5a611c1a --- /dev/null +++ b/src/data/composite/things/contribution/withContributionArtist.js @@ -0,0 +1,34 @@ +import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; + +import {withPropertyFromObject} from '#composite/data'; +import {withResolvedReference} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withContributionArtist`, + + inputs: { + ref: input({ + type: 'string', + defaultDependency: 'artist', + }), + }, + + outputs: ['#artist'], + + steps: () => [ + withPropertyFromObject({ + object: 'thing', + property: input.value('artistData'), + internal: input.value(true), + }), + + withResolvedReference({ + ref: input('ref'), + data: '#thing.artistData', + find: input.value(find.artist), + }).outputs({ + '#resolvedReference': '#artist', + }), + ], +}); diff --git a/src/data/composite/things/contribution/withContributionContext.js b/src/data/composite/things/contribution/withContributionContext.js new file mode 100644 index 00000000..3c1c31c0 --- /dev/null +++ b/src/data/composite/things/contribution/withContributionContext.js @@ -0,0 +1,45 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: `withContributionContext`, + + outputs: [ + '#contributionTarget', + '#contributionProperty', + ], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'thing', + output: input.value({ + '#contributionTarget': null, + '#contributionProperty': null, + }), + }), + + raiseOutputWithoutDependency({ + dependency: 'thingProperty', + output: input.value({ + '#contributionTarget': null, + '#contributionProperty': null, + }), + }), + + { + dependencies: ['thing', 'thingProperty'], + + compute: (continuation, { + ['thing']: thing, + ['thingProperty']: thingProperty, + }) => continuation({ + ['#contributionTarget']: + thing.constructor[Symbol.for('Thing.referenceType')], + + ['#contributionProperty']: + thingProperty, + }), + }, + ], +}); diff --git a/src/data/composite/things/contribution/withMatchingContributionPresets.js b/src/data/composite/things/contribution/withMatchingContributionPresets.js new file mode 100644 index 00000000..09454164 --- /dev/null +++ b/src/data/composite/things/contribution/withMatchingContributionPresets.js @@ -0,0 +1,70 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +import withContributionContext from './withContributionContext.js'; + +export default templateCompositeFrom({ + annotation: `withMatchingContributionPresets`, + + outputs: ['#matchingContributionPresets'], + + steps: () => [ + withPropertyFromObject({ + object: 'thing', + property: input.value('wikiInfo'), + internal: input.value(true), + }), + + raiseOutputWithoutDependency({ + dependency: '#thing.wikiInfo', + output: input.value({ + '#matchingContributionPresets': null, + }), + }), + + withPropertyFromObject({ + object: '#thing.wikiInfo', + property: input.value('contributionPresets'), + }).outputs({ + '#thing.wikiInfo.contributionPresets': '#contributionPresets', + }), + + raiseOutputWithoutDependency({ + dependency: '#contributionPresets', + mode: input.value('empty'), + output: input.value({ + '#matchingContributionPresets': [], + }), + }), + + withContributionContext(), + + { + dependencies: [ + '#contributionPresets', + '#contributionTarget', + '#contributionProperty', + 'annotation', + ], + + compute: (continuation, { + ['#contributionPresets']: presets, + ['#contributionTarget']: target, + ['#contributionProperty']: property, + ['annotation']: annotation, + }) => continuation({ + ['#matchingContributionPresets']: + presets + .filter(preset => + preset.context[0] === target && + preset.context.slice(1).includes(property) && + // For now, only match if the annotation is a complete match. + // Partial matches (e.g. because the contribution includes "two" + // annotations, separated by commas) don't count. + preset.annotation === annotation), + }) + }, + ], +}); diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js index 8959de9f..714858a0 100644 --- a/src/data/composite/things/track/index.js +++ b/src/data/composite/things/track/index.js @@ -1,12 +1,16 @@ export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js'; export {default as inferredAdditionalNameList} from './inferredAdditionalNameList.js'; +export {default as inheritContributionListFromOriginalRelease} from './inheritContributionListFromOriginalRelease.js'; export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js'; export {default as sharedAdditionalNameList} from './sharedAdditionalNameList.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 withDate} from './withDate.js'; export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js'; +export {default as withOriginalRelease} from './withOriginalRelease.js'; export {default as withOtherReleases} from './withOtherReleases.js'; export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js'; export {default as withPropertyFromOriginalRelease} from './withPropertyFromOriginalRelease.js'; +export {default as withTrackArtDate} from './withTrackArtDate.js'; diff --git a/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js b/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js new file mode 100644 index 00000000..f4ae3ddb --- /dev/null +++ b/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js @@ -0,0 +1,44 @@ +// Like inheritFromOriginalRelease, but tuned for contributions. +// Recontextualized contributions for this track. + +import {input, templateCompositeFrom} from '#composite'; + +import {exposeDependency, raiseOutputWithoutDependency} + from '#composite/control-flow'; +import {withRecontextualizedContributionList, withRedatedContributionList} + from '#composite/wiki-data'; + +import withDate from './withDate.js'; +import withPropertyFromOriginalRelease + from './withPropertyFromOriginalRelease.js'; + +export default templateCompositeFrom({ + annotation: `inheritContributionListFromOriginalRelease`, + + steps: () => [ + withPropertyFromOriginalRelease({ + property: input.thisProperty(), + notFoundValue: input.value([]), + }), + + raiseOutputWithoutDependency({ + dependency: '#isRerelease', + mode: input.value('falsy'), + }), + + withRecontextualizedContributionList({ + list: '#originalValue', + }), + + withDate(), + + withRedatedContributionList({ + list: '#originalValue', + date: '#date', + }), + + exposeDependency({ + dependency: '#originalValue', + }), + ], +}); diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js index eaac14de..2c42709b 100644 --- a/src/data/composite/things/track/withContainingTrackSection.js +++ b/src/data/composite/things/track/withContainingTrackSection.js @@ -30,7 +30,6 @@ export default templateCompositeFrom({ compute: (continuation, { [input.myself()]: track, - [input('notFoundMode')]: notFoundMode, ['#album.trackSections']: trackSections, }) => continuation({ ['#trackSection']: diff --git a/src/data/composite/things/track/withDate.js b/src/data/composite/things/track/withDate.js new file mode 100644 index 00000000..b5a770e9 --- /dev/null +++ b/src/data/composite/things/track/withDate.js @@ -0,0 +1,34 @@ +// Gets the track's own date. This is either its dateFirstReleased property +// or, if unset, the album's date. + +import {input, templateCompositeFrom} from '#composite'; + +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; + +export default templateCompositeFrom({ + annotation: `withDate`, + + outputs: ['#date'], + + steps: () => [ + { + dependencies: ['dateFirstReleased'], + compute: (continuation, {dateFirstReleased}) => + (dateFirstReleased + ? continuation.raiseOutput({'#date': dateFirstReleased}) + : continuation()), + }, + + withPropertyFromAlbum({ + property: input.value('date'), + }), + + { + dependencies: ['#album.date'], + compute: (continuation, {['#album.date']: albumDate}) => + (albumDate + ? continuation.raiseOutput({'#date': albumDate}) + : continuation.raiseOutput({'#date': null})), + }, + ], +}) diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js index 96078d5f..f7e65f25 100644 --- a/src/data/composite/things/track/withHasUniqueCoverArt.js +++ b/src/data/composite/things/track/withHasUniqueCoverArt.js @@ -29,7 +29,10 @@ export default templateCompositeFrom({ : continuation()), }, - withResolvedContribs({from: 'coverArtistContribs'}), + withResolvedContribs({ + from: 'coverArtistContribs', + date: input.value(null), + }), { dependencies: ['#resolvedContribs'], diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js new file mode 100644 index 00000000..e2c4d8bc --- /dev/null +++ b/src/data/composite/things/track/withTrackArtDate.js @@ -0,0 +1,80 @@ +// Gets the date of cover art release. This represents only the track's own +// unique cover artwork, if any. +// +// If the 'fallback' option is false (the default), this will only output +// the track's own coverArtDate or its album's trackArtDate. If 'fallback' +// is set, and neither of these is available, it'll output the track's own +// date instead. + +import {input, templateCompositeFrom} from '#composite'; +import {isDate} from '#validators'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +import withDate from './withDate.js'; +import withHasUniqueCoverArt from './withHasUniqueCoverArt.js'; +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; + +export default templateCompositeFrom({ + annotation: `withTrackArtDate`, + + inputs: { + from: input({ + validate: isDate, + defaultDependency: 'coverArtDate', + acceptsNull: true, + }), + + fallback: input({ + type: 'boolean', + defaultValue: false, + }), + }, + + outputs: ['#trackArtDate'], + + steps: () => [ + withHasUniqueCoverArt(), + + raiseOutputWithoutDependency({ + dependency: '#hasUniqueCoverArt', + mode: input.value('falsy'), + output: input.value({'#trackArtDate': null}), + }), + + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: from, + }) => + (from + ? continuation.raiseOutput({'#trackArtDate': from}) + : continuation()), + }, + + withPropertyFromAlbum({ + property: input.value('trackArtDate'), + }), + + { + dependencies: [ + '#album.trackArtDate', + input('fallback'), + ], + + compute: (continuation, { + ['#album.trackArtDate']: albumTrackArtDate, + [input('fallback')]: fallback, + }) => + (albumTrackArtDate + ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate}) + : fallback + ? continuation() + : continuation.raiseOutput({'#trackArtDate': null})), + }, + + withDate().outputs({ + '#date': '#trackArtDate', + }), + ], +}); diff --git a/src/data/composite/wiki-data/exitWithoutContribs.js b/src/data/composite/wiki-data/exitWithoutContribs.js index 2c8219fc..cf52950d 100644 --- a/src/data/composite/wiki-data/exitWithoutContribs.js +++ b/src/data/composite/wiki-data/exitWithoutContribs.js @@ -24,6 +24,7 @@ export default templateCompositeFrom({ steps: () => [ withResolvedContribs({ from: input('contribs'), + date: input.value(null), }), // TODO: Fairly certain exitWithoutDependency would be sufficient here. diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index 15ebaffa..5f17ca3a 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -6,9 +6,14 @@ export {default as exitWithoutContribs} from './exitWithoutContribs.js'; export {default as inputWikiData} from './inputWikiData.js'; +export {default as withClonedThings} from './withClonedThings.js'; +export {default as withContributionListSums} from './withContributionListSums.js'; +export {default as withCoverArtDate} from './withCoverArtDate.js'; export {default as withDirectory} from './withDirectory.js'; export {default as withDirectoryFromName} from './withDirectoryFromName.js'; export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js'; +export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js'; +export {default as withRedatedContributionList} from './withRedatedContributionList.js'; export {default as withResolvedContribs} from './withResolvedContribs.js'; export {default as withResolvedReference} from './withResolvedReference.js'; export {default as withResolvedReferenceList} from './withResolvedReferenceList.js'; diff --git a/src/data/composite/wiki-data/withClonedThings.js b/src/data/composite/wiki-data/withClonedThings.js new file mode 100644 index 00000000..9af6aa84 --- /dev/null +++ b/src/data/composite/wiki-data/withClonedThings.js @@ -0,0 +1,68 @@ +// Clones all the things in a list. If the 'assign' input is provided, +// all new things are assigned the same specified properties. If the +// 'assignEach' input is provided, each new thing is assigned the +// corresponding properties. + +import CacheableObject from '#cacheable-object'; +import {input, templateCompositeFrom} from '#composite'; +import {isObject, sparseArrayOf} from '#validators'; + +import {withMappedList} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withClonedThings`, + + inputs: { + things: input({type: 'array'}), + + assign: input({ + type: 'object', + defaultValue: null, + }), + + assignEach: input({ + validate: sparseArrayOf(isObject), + defaultValue: null, + }), + }, + + outputs: ['#clonedThings'], + + steps: () => [ + { + dependencies: [input('assign'), input('assignEach')], + compute: (continuation, { + [input('assign')]: assign, + [input('assignEach')]: assignEach, + }) => continuation({ + ['#assignmentMap']: + (index) => + (assign && assignEach + ? {...assignEach[index] ?? {}, ...assign} + : assignEach + ? assignEach[index] ?? {} + : assign ?? {}), + }), + }, + + { + dependencies: ['#assignmentMap'], + compute: (continuation, { + ['#assignmentMap']: assignmentMap, + }) => continuation({ + ['#cloningMap']: + (thing, index) => + Object.assign( + CacheableObject.clone(thing), + assignmentMap(index)), + }), + }, + + withMappedList({ + list: input('things'), + map: '#cloningMap', + }).outputs({ + '#mappedList': '#clonedThings', + }), + ], +}); diff --git a/src/data/composite/wiki-data/withContributionListSums.js b/src/data/composite/wiki-data/withContributionListSums.js new file mode 100644 index 00000000..b4f36361 --- /dev/null +++ b/src/data/composite/wiki-data/withContributionListSums.js @@ -0,0 +1,95 @@ +// Gets the total duration and contribution count from a list of contributions, +// respecting their `countInContributionTotals` and `countInDurationTotals` +// flags. + +import {input, templateCompositeFrom} from '#composite'; + +import { + withFilteredList, + withPropertiesFromList, + withPropertyFromList, + withSum, + withUniqueItemsOnly, +} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withContributionListSums`, + + inputs: { + list: input({type: 'array'}), + }, + + outputs: [ + '#contributionListCount', + '#contributionListDuration', + ], + + steps: () => [ + withPropertiesFromList({ + list: input('list'), + properties: input.value([ + 'countInContributionTotals', + 'countInDurationTotals', + ]), + }), + + withFilteredList({ + list: input('list'), + filter: '#list.countInContributionTotals', + }).outputs({ + '#filteredList': '#contributionsForCounting', + }), + + withFilteredList({ + list: input('list'), + filter: '#list.countInDurationTotals', + }).outputs({ + '#filteredList': '#contributionsForDuration', + }), + + { + dependencies: ['#contributionsForCounting'], + compute: (continuation, { + ['#contributionsForCounting']: contributionsForCounting, + }) => continuation({ + ['#count']: + contributionsForCounting.length, + }), + }, + + withPropertyFromList({ + list: '#contributionsForDuration', + property: input.value('thing'), + }), + + // Don't double-up the durations for a track where the artist has multiple + // contributions. + withUniqueItemsOnly({ + list: '#contributionsForDuration.thing', + }), + + withPropertyFromList({ + list: '#contributionsForDuration.thing', + property: input.value('duration'), + }).outputs({ + '#contributionsForDuration.thing.duration': '#durationValues', + }), + + withSum({ + values: '#durationValues', + }).outputs({ + '#sum': '#duration', + }), + + { + dependencies: ['#count', '#duration'], + compute: (continuation, { + ['#count']: count, + ['#duration']: duration, + }) => continuation({ + ['#contributionListCount']: count, + ['#contributionListDuration']: duration, + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/wiki-data/withCoverArtDate.js new file mode 100644 index 00000000..0c644c77 --- /dev/null +++ b/src/data/composite/wiki-data/withCoverArtDate.js @@ -0,0 +1,70 @@ +// Gets the current thing's coverArtDate, or, if the 'fallback' option is set, +// the thing's date. This is always null if the thing doesn't actually have +// any coverArtistContribs. + +import {input, templateCompositeFrom} from '#composite'; +import {isDate} from '#validators'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +import withResolvedContribs from './withResolvedContribs.js'; + +export default templateCompositeFrom({ + annotation: `withCoverArtDate`, + + inputs: { + from: input({ + validate: isDate, + defaultDependency: 'coverArtDate', + acceptsNull: true, + }), + + fallback: input({ + type: 'boolean', + defaultValue: false, + }), + }, + + outputs: ['#coverArtDate'], + + steps: () => [ + withResolvedContribs({ + from: 'coverArtistContribs', + date: input.value(null), + }), + + raiseOutputWithoutDependency({ + dependency: '#resolvedContribs', + mode: input.value('empty'), + output: input.value({'#coverArtDate': null}), + }), + + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: from, + }) => + (from + ? continuation.raiseOutput({'#coverArtDate': from}) + : continuation()), + }, + + { + dependencies: [input('fallback')], + compute: (continuation, { + [input('fallback')]: fallback, + }) => + (fallback + ? continuation() + : continuation.raiseOutput({'#coverArtDate': null})), + }, + + { + dependencies: ['date'], + compute: (continuation, {date}) => + (date + ? continuation.raiseOutput({'#coverArtDate': date}) + : continuation.raiseOutput({'#coverArtDate': null})), + }, + ], +}); diff --git a/src/data/composite/wiki-data/withRecontextualizedContributionList.js b/src/data/composite/wiki-data/withRecontextualizedContributionList.js new file mode 100644 index 00000000..d2401eac --- /dev/null +++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js @@ -0,0 +1,101 @@ +// Clones all the contributions in a list, with thing and thingProperty both +// updated to match the current thing. Overwrites the provided dependency. +// Optionally updates artistProperty as well. Doesn't do anything if +// the provided dependency is null. +// +// See also: +// - withRedatedContributionList +// + +import {input, templateCompositeFrom} from '#composite'; +import {isStringNonEmpty} from '#validators'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withClonedThings} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withRecontextualizedContributionList`, + + inputs: { + list: input.staticDependency({ + type: 'array', + acceptsNull: true, + }), + + artistProperty: input({ + validate: isStringNonEmpty, + defaultValue: null, + }), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + }) => [list], + + steps: () => [ + // TODO: Is raiseOutputWithoutDependency workable here? + // Is it true that not specifying any output wouldn't overwrite + // the provided dependency? + { + dependencies: [ + input.staticDependency('list'), + input('list'), + ], + + compute: (continuation, { + [input.staticDependency('list')]: dependency, + [input('list')]: list, + }) => + (list + ? continuation() + : continuation.raiseOutput({ + [dependency]: list, + })), + }, + + { + dependencies: [ + input.myself(), + input.thisProperty(), + input('artistProperty'), + ], + + compute: (continuation, { + [input.myself()]: myself, + [input.thisProperty()]: thisProperty, + [input('artistProperty')]: artistProperty, + }) => continuation({ + ['#assignment']: + Object.assign( + {thing: myself}, + {thingProperty: thisProperty}, + + (artistProperty + ? {artistProperty} + : {})), + }), + }, + + withClonedThings({ + things: input('list'), + assign: '#assignment', + }).outputs({ + '#clonedThings': '#newContributions', + }), + + { + dependencies: [ + input.staticDependency('list'), + '#newContributions', + ], + + compute: (continuation, { + [input.staticDependency('list')]: listDependency, + ['#newContributions']: newContributions, + }) => continuation({ + [listDependency]: + newContributions, + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/withRedatedContributionList.js b/src/data/composite/wiki-data/withRedatedContributionList.js new file mode 100644 index 00000000..12f3e16b --- /dev/null +++ b/src/data/composite/wiki-data/withRedatedContributionList.js @@ -0,0 +1,127 @@ +// Clones all the contributions in a list, with date updated to the provided +// value. Overwrites the provided dependency. Doesn't do anything if the +// provided dependency is null, or the provided date is null. +// +// If 'override' is true (the default), then so long as the provided date has +// a value at all, it's always written onto the (cloned) contributions. +// +// If 'override' is false, and any of the contributions were already dated, +// those will keep their existing dates. +// +// See also: +// - withRecontextualizedContributionList +// + +import {input, templateCompositeFrom} from '#composite'; +import {isDate} from '#validators'; + +import {withMappedList, withPropertyFromList} from '#composite/data'; +import {withClonedThings} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withRedatedContributionList`, + + inputs: { + list: input.staticDependency({ + type: 'array', + acceptsNull: true, + }), + + date: input({ + validate: isDate, + acceptsNull: true, + }), + + override: input({ + type: 'boolean', + defaultValue: true, + }), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + }) => [list], + + steps: () => [ + // TODO: Is raiseOutputWithoutDependency workable here? + // Is it true that not specifying any output wouldn't overwrite + // the provided dependency? + { + dependencies: [ + input.staticDependency('list'), + input('list'), + input('date'), + ], + + compute: (continuation, { + [input.staticDependency('list')]: dependency, + [input('list')]: list, + [input('date')]: date, + }) => + (list && date + ? continuation() + : continuation.raiseOutput({ + [dependency]: list, + })), + }, + + withPropertyFromList({ + list: input('list'), + property: input.value('date'), + }).outputs({ + '#list.date': '#existingDates', + }), + + { + dependencies: [ + input('date'), + input('override'), + '#existingDates', + ], + + compute: (continuation, { + [input('date')]: date, + [input('override')]: override, + '#existingDates': existingDates, + }) => continuation({ + ['#assignmentMap']: + // TODO: Should be mapping over withIndicesFromList + (_, index) => + (!override && existingDates[index] + ? {date: existingDates[index]} + : date + ? {date} + : {}), + }), + }, + + withMappedList({ + list: input('list'), + map: '#assignmentMap', + }).outputs({ + '#mappedList': '#assignment', + }), + + withClonedThings({ + things: input('list'), + assignEach: '#assignment', + }).outputs({ + '#clonedThings': '#newContributions', + }), + + { + dependencies: [ + input.staticDependency('list'), + '#newContributions', + ], + + compute: (continuation, { + [input.staticDependency('list')]: listDependency, + ['#newContributions']: newContributions, + }) => continuation({ + [listDependency]: + newContributions, + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js index 95266382..b5d7255b 100644 --- a/src/data/composite/wiki-data/withResolvedContribs.js +++ b/src/data/composite/wiki-data/withResolvedContribs.js @@ -7,17 +7,11 @@ import {input, templateCompositeFrom} from '#composite'; import find from '#find'; import {filterMultipleArrays, stitchArrays} from '#sugar'; -import {is, isContributionList} from '#validators'; +import thingConstructors from '#things'; +import {is, isContributionList, isDate, isStringNonEmpty} from '#validators'; -import { - raiseOutputWithoutDependency, -} from '#composite/control-flow'; - -import { - withPropertiesFromList, -} from '#composite/data'; - -import withResolvedReferenceList from './withResolvedReferenceList.js'; +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertiesFromList} from '#composite/data'; export default templateCompositeFrom({ annotation: `withResolvedContribs`, @@ -28,10 +22,25 @@ export default templateCompositeFrom({ acceptsNull: true, }), + date: input({ + validate: isDate, + acceptsNull: true, + }), + notFoundMode: input({ validate: is('exit', 'filter', 'null'), defaultValue: 'null', }), + + thingProperty: input({ + validate: isStringNonEmpty, + defaultValue: null, + }), + + artistProperty: input({ + validate: isStringNonEmpty, + defaultValue: null, + }), }, outputs: ['#resolvedContribs'], @@ -45,34 +54,96 @@ export default templateCompositeFrom({ }), }), + { + dependencies: [ + input('thingProperty'), + input.staticDependency('from'), + ], + + compute: (continuation, { + [input('thingProperty')]: thingProperty, + [input.staticDependency('from')]: fromDependency, + }) => continuation({ + ['#thingProperty']: + (thingProperty + ? thingProperty + : !fromDependency?.startsWith('#') + ? fromDependency + : null), + }), + }, + withPropertiesFromList({ list: input('from'), properties: input.value(['artist', 'annotation']), prefix: input.value('#contribs'), }), - withResolvedReferenceList({ - list: '#contribs.artist', - data: 'artistData', - find: input.value(find.artist), - notFoundMode: input('notFoundMode'), - }).outputs({ - ['#resolvedReferenceList']: '#contribs.artist', - }), - { - dependencies: ['#contribs.artist', '#contribs.annotation'], + dependencies: [ + '#contribs.artist', + '#contribs.annotation', + input('date'), + ], compute(continuation, { ['#contribs.artist']: artist, ['#contribs.annotation']: annotation, + [input('date')]: date, }) { filterMultipleArrays(artist, annotation, (artist, _annotation) => artist); + return continuation({ - ['#resolvedContribs']: - stitchArrays({artist, annotation}), + ['#details']: + stitchArrays({artist, annotation}) + .map(details => ({ + ...details, + date: date ?? null, + })), }); }, }, + + { + dependencies: [ + '#details', + '#thingProperty', + input('artistProperty'), + input.myself(), + ], + + compute: (continuation, { + ['#details']: details, + ['#thingProperty']: thingProperty, + [input('artistProperty')]: artistProperty, + [input.myself()]: myself, + }) => continuation({ + ['#contributions']: + details.map(details => { + const contrib = new thingConstructors.Contribution(); + + Object.assign(contrib, { + ...details, + thing: myself, + thingProperty: thingProperty, + artistProperty: artistProperty, + }); + + return contrib; + }), + }), + }, + + { + dependencies: ['#contributions'], + + compute: (continuation, { + ['#contributions']: contributions, + }) => continuation({ + ['#resolvedContribs']: + contributions + .filter(contrib => contrib.artist), + }), + }, ], }); diff --git a/src/data/composite/wiki-data/withReverseContributionList.js b/src/data/composite/wiki-data/withReverseContributionList.js index 91e125e4..63e712bb 100644 --- a/src/data/composite/wiki-data/withReverseContributionList.js +++ b/src/data/composite/wiki-data/withReverseContributionList.js @@ -1,6 +1,6 @@ // Analogous implementation for withReverseReferenceList, for contributions. -// This is all duplicate code and both should be ported to the same underlying -// data form later on. +// This is mostly duplicate code and both should be ported to the same +// underlying data form later on. // // This implementation uses a global cache (via WeakMap) to attempt to speed // up subsequent similar accesses. @@ -10,9 +10,11 @@ // is used, a fresh cache will always be created. import {input, templateCompositeFrom} from '#composite'; +import {stitchArrays} from '#sugar'; import {exitWithoutDependency, raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withFlattenedList, withMappedList} from '#composite/data'; import inputWikiData from './inputWikiData.js'; @@ -33,6 +35,8 @@ export default templateCompositeFrom({ outputs: ['#reverseContributionList'], steps: () => [ + // Common behavior -- + // Early exit with an empty array if the data list isn't available. exitWithoutDependency({ dependency: input('data'), @@ -46,46 +50,122 @@ export default templateCompositeFrom({ output: input.value({'#reverseContributionList': []}), }), + // Check for an existing cache record which corresponds to this + // input('list') and input('data'). If it exists, query it for the + // current thing, and raise that; if it doesn't, create it, put it + // where it needs to be, and provide it so the next steps can fill + // it in. { - dependencies: [input.myself(), input('data'), input('list')], + dependencies: [input('list'), input('data'), input.myself()], compute: (continuation, { - [input.myself()]: myself, - [input('data')]: data, [input('list')]: list, + [input('data')]: data, + [input.myself()]: myself, }) => { if (!caches.has(list)) { - caches.set(list, new WeakMap()); + const cache = new WeakMap(); + caches.set(list, cache); + + const cacheRecord = new WeakMap(); + cache.set(data, cacheRecord); + + return continuation({ + ['#cacheRecord']: cacheRecord, + }); } const cache = caches.get(list); if (!cache.has(data)) { const cacheRecord = new WeakMap(); + cache.set(data, cacheRecord); + + return continuation({ + ['#cacheRecord']: cacheRecord, + }); + } + + return continuation.raiseOutput({ + ['#reverseContributionList']: + cache.get(data).get(myself) ?? [], + }); + }, + }, + + // Unique behavior for contribution lists -- + + { + dependencies: [input('list')], + compute: (continuation, { + [input('list')]: list, + }) => continuation({ + ['#contributionListMap']: + thing => thing[list], + }), + }, + + withMappedList({ + list: input('data'), + map: '#contributionListMap', + }).outputs({ + '#mappedList': '#contributionLists', + }), - for (const referencingThing of data) { - const referenceList = referencingThing[list]; + withFlattenedList({ + list: '#contributionLists', + }).outputs({ + '#flattenedList': '#referencingThings', + }), - // Destructuring {artist} is the only unique part of the - // withReverseContributionList implementation, compared to - // withReverseReferneceList. - for (const {artist: referencedThing} of referenceList) { + withMappedList({ + list: '#referencingThings', + map: input.value(contrib => [contrib.artist]), + }).outputs({ + '#mappedList': '#referencedThings', + }), + + // Common behavior -- + + // Actually fill in the cache record. Since we're building up a *reverse* + // reference list, track connections in terms of the referenced thing. + // No newly-provided dependencies here since we're mutating the cache + // record, which is properly in store and will probably be reused in the + // future (and certainly in the next step). + { + dependencies: ['#cacheRecord', '#referencingThings', '#referencedThings'], + compute: (continuation, { + ['#cacheRecord']: cacheRecord, + ['#referencingThings']: referencingThings, + ['#referencedThings']: referencedThings, + }) => { + stitchArrays({ + referencingThing: referencingThings, + referencedThings: referencedThings, + }).forEach(({referencingThing, referencedThings}) => { + for (const referencedThing of referencedThings) { if (cacheRecord.has(referencedThing)) { cacheRecord.get(referencedThing).push(referencingThing); } else { cacheRecord.set(referencedThing, [referencingThing]); } } - } + }); - cache.set(data, cacheRecord); - } - - return continuation({ - ['#reverseContributionList']: - cache.get(data).get(myself) ?? [], - }); + return continuation(); }, }, + + // Then just pluck out the current object from the now-filled cache record! + { + dependencies: ['#cacheRecord', input.myself()], + compute: (continuation, { + ['#cacheRecord']: cacheRecord, + [input.myself()]: myself, + }) => continuation({ + ['#reverseContributionList']: + cacheRecord.get(myself) ?? [], + }), + }, ], }); diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js index 8cd540a5..1f8c082f 100644 --- a/src/data/composite/wiki-data/withReverseReferenceList.js +++ b/src/data/composite/wiki-data/withReverseReferenceList.js @@ -12,9 +12,11 @@ // so any changes should be reflected there (until these are combined). import {input, templateCompositeFrom} from '#composite'; +import {stitchArrays} from '#sugar'; import {exitWithoutDependency, raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withMappedList} from '#composite/data'; import inputWikiData from './inputWikiData.js'; @@ -35,6 +37,8 @@ export default templateCompositeFrom({ outputs: ['#reverseReferenceList'], steps: () => [ + // Common behavior -- + // Early exit with an empty array if the data list isn't available. exitWithoutDependency({ dependency: input('data'), @@ -48,42 +52,119 @@ export default templateCompositeFrom({ output: input.value({'#reverseReferenceList': []}), }), + // Check for an existing cache record which corresponds to this + // input('list') and input('data'). If it exists, query it for the + // current thing, and raise that; if it doesn't, create it, put it + // where it needs to be, and provide it so the next steps can fill + // it in. { - dependencies: [input.myself(), input('data'), input('list')], + dependencies: [input('list'), input('data'), input.myself()], compute: (continuation, { - [input.myself()]: myself, - [input('data')]: data, [input('list')]: list, + [input('data')]: data, + [input.myself()]: myself, }) => { if (!caches.has(list)) { - caches.set(list, new WeakMap()); + const cache = new WeakMap(); + caches.set(list, cache); + + const cacheRecord = new WeakMap(); + cache.set(data, cacheRecord); + + return continuation({ + ['#cacheRecord']: cacheRecord, + }); } const cache = caches.get(list); if (!cache.has(data)) { const cacheRecord = new WeakMap(); + cache.set(data, cacheRecord); + + return continuation({ + ['#cacheRecord']: cacheRecord, + }); + } + + return continuation.raiseOutput({ + ['#reverseReferenceList']: + cache.get(data).get(myself) ?? [], + }); + }, + }, + + // Unique behavior for reference lists -- + + { + dependencies: [input('list')], + compute: (continuation, { + [input('list')]: list, + }) => continuation({ + ['#referenceMap']: + thing => thing[list], + }), + }, + + withMappedList({ + list: input('data'), + map: '#referenceMap', + }).outputs({ + '#mappedList': '#referencedThings', + }), + + { + dependencies: [input('data')], + compute: (continuation, { + [input('data')]: data, + }) => continuation({ + ['#referencingThings']: + data, + }), + }, + + // Common behavior -- - for (const referencingThing of data) { - const referenceList = referencingThing[list]; - for (const referencedThing of referenceList) { + // Actually fill in the cache record. Since we're building up a *reverse* + // reference list, track connections in terms of the referenced thing. + // No newly-provided dependencies here since we're mutating the cache + // record, which is properly in store and will probably be reused in the + // future (and certainly in the next step). + { + dependencies: ['#cacheRecord', '#referencingThings', '#referencedThings'], + compute: (continuation, { + ['#cacheRecord']: cacheRecord, + ['#referencingThings']: referencingThings, + ['#referencedThings']: referencedThings, + }) => { + stitchArrays({ + referencingThing: referencingThings, + referencedThings: referencedThings, + }).forEach(({referencingThing, referencedThings}) => { + for (const referencedThing of referencedThings) { if (cacheRecord.has(referencedThing)) { cacheRecord.get(referencedThing).push(referencingThing); } else { cacheRecord.set(referencedThing, [referencingThing]); } } - } + }); - cache.set(data, cacheRecord); - } - - return continuation({ - ['#reverseReferenceList']: - cache.get(data).get(myself) ?? [], - }); + return continuation(); }, }, + + // Then just pluck out the current object from the now-filled cache record! + { + dependencies: ['#cacheRecord', input.myself()], + compute: (continuation, { + ['#cacheRecord']: cacheRecord, + [input.myself()]: myself, + }) => continuation({ + ['#reverseReferenceList']: + cacheRecord.get(myself) ?? [], + }), + }, ], }); diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js index aad12a2d..d9a6b417 100644 --- a/src/data/composite/wiki-properties/contributionList.js +++ b/src/data/composite/wiki-properties/contributionList.js @@ -15,7 +15,7 @@ // import {input, templateCompositeFrom} from '#composite'; -import {isContributionList} from '#validators'; +import {isContributionList, isDate, isStringNonEmpty} from '#validators'; import {exposeConstant, exposeDependencyOrContinue} from '#composite/control-flow'; import {withResolvedContribs} from '#composite/wiki-data'; @@ -25,11 +25,34 @@ export default templateCompositeFrom({ compose: false, + inputs: { + date: input({ + validate: isDate, + acceptsNull: true, + }), + + artistProperty: input({ + validate: isStringNonEmpty, + defaultValue: null, + }), + }, + update: {validate: isContributionList}, steps: () => [ - withResolvedContribs({from: input.updateValue()}), - exposeDependencyOrContinue({dependency: '#resolvedContribs'}), - exposeConstant({value: input.value([])}), + withResolvedContribs({ + from: input.updateValue(), + thingProperty: input.thisProperty(), + artistProperty: input('artistProperty'), + date: input('date'), + }), + + exposeDependencyOrContinue({ + dependency: '#resolvedContribs', + }), + + exposeConstant({ + value: input.value([]), + }), ], }); diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index 89cb6838..5328d17e 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -24,5 +24,6 @@ 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 thing} from './thing.js'; export {default as urls} from './urls.js'; export {default as wikiData} from './wikiData.js'; diff --git a/src/data/composite/wiki-properties/thing.js b/src/data/composite/wiki-properties/thing.js new file mode 100644 index 00000000..5b5d77dd --- /dev/null +++ b/src/data/composite/wiki-properties/thing.js @@ -0,0 +1,31 @@ +// An individual Thing, provided directly rather than by reference. + +import {input, templateCompositeFrom} from '#composite'; +import {isThingClass, validateThing} from '#validators'; + +export default templateCompositeFrom({ + annotation: `wikiData`, + + compose: false, + + inputs: { + class: input.staticValue({ + validate: isThingClass, + defaultValue: null, + }), + }, + + update: ({ + [input.staticValue('class')]: thingClass, + }) => ({ + validate: + validateThing({ + referenceType: + (thingClass + ? thingClass[Symbol.for('Thing.referenceType')] + : ''), + }), + }), + + steps: () => [], +}); diff --git a/src/data/things/album.js b/src/data/things/album.js index e9f55b2c..a0021946 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -18,8 +18,13 @@ import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions} import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; -import {exitWithoutContribs, withDirectory, withResolvedReference} - from '#composite/wiki-data'; + +import { + exitWithoutContribs, + withDirectory, + withResolvedReference, + withCoverArtDate, +} from '#composite/wiki-data'; import { additionalFiles, @@ -37,6 +42,7 @@ import { simpleDate, simpleString, singleReference, + thing, urls, wikiData, } from '#composite/wiki-properties'; @@ -53,6 +59,7 @@ export class Album extends Thing { Group, Track, TrackSection, + WikiInfo, }) => ({ // Update & expose @@ -71,13 +78,16 @@ export class Album extends Thing { dateAddedToWiki: simpleDate(), coverArtDate: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), + // TODO: Why does this fall back, but Track.coverArtDate doesn't? + withCoverArtDate({ + from: input.updateValue({ + validate: isDate, + }), - exposeUpdateValueOrContinue({ - validate: input.value(isDate), + fallback: input.value(true), }), - exposeDependency({dependency: 'date'}), + exposeDependency({dependency: '#coverArtDate'}), ], coverArtFileExtension: [ @@ -130,11 +140,53 @@ export class Album extends Thing { find: input.value(find.unqualifiedTrackSection), }), - artistContribs: contributionList(), - coverArtistContribs: contributionList(), - trackCoverArtistContribs: contributionList(), - wallpaperArtistContribs: contributionList(), - bannerArtistContribs: contributionList(), + artistContribs: contributionList({ + date: 'date', + artistProperty: input.value('albumArtistContributions'), + }), + + coverArtistContribs: [ + withCoverArtDate({ + fallback: input.value(true), + }), + + contributionList({ + date: '#coverArtDate', + artistProperty: input.value('albumCoverArtistContributions'), + }), + ], + + trackCoverArtistContribs: contributionList({ + // May be null, indicating cover art was added for tracks on the date + // each track specifies, or else the track's own release date. + date: 'trackArtDate', + + // This is the "correct" value, but it gets overwritten - with the same + // value - regardless. + artistProperty: input.value('trackCoverArtistContributions'), + }), + + wallpaperArtistContribs: [ + withCoverArtDate({ + fallback: input.value(true), + }), + + contributionList({ + date: '#coverArtDate', + artistProperty: input.value('albumWallpaperArtistContributions'), + }), + ], + + bannerArtistContribs: [ + withCoverArtDate({ + fallback: input.value(true), + }), + + contributionList({ + date: '#coverArtDate', + artistProperty: input.value('albumBannerArtistContributions'), + }), + ], groups: referenceList({ class: input.value(Group), @@ -173,6 +225,10 @@ export class Album extends Thing { class: input.value(TrackSection), }), + wikiInfo: thing({ + class: input.value(WikiInfo), + }), + // Expose only commentatorArtists: commentatorArtists(), diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 841d652f..6d5e33c0 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -12,6 +12,7 @@ import Thing from '#thing'; import {isName, validateArrayItems} from '#validators'; import {getKebabCase} from '#wiki-data'; +import {exposeDependency} from '#composite/control-flow'; import {withReverseContributionList} from '#composite/wiki-data'; import { @@ -27,6 +28,8 @@ import { wikiData, } from '#composite/wiki-properties'; +import {artistTotalDuration} from '#composite/things/artist'; + export class Artist extends Thing { static [Thing.referenceType] = 'artist'; static [Thing.wikiDataArray] = 'artistData'; @@ -77,146 +80,52 @@ export class Artist extends Thing { // Expose only - tracksAsArtist: reverseContributionList({ + trackArtistContributions: reverseContributionList({ data: 'trackData', list: input.value('artistContribs'), }), - tracksAsContributor: reverseContributionList({ + trackContributorContributions: reverseContributionList({ data: 'trackData', list: input.value('contributorContribs'), }), - tracksAsCoverArtist: reverseContributionList({ + trackCoverArtistContributions: reverseContributionList({ data: 'trackData', list: input.value('coverArtistContribs'), }), - tracksAsAny: [ - withReverseContributionList({ - data: 'trackData', - list: input.value('artistContribs'), - }).outputs({ - '#reverseContributionList': '#tracksAsArtist', - }), - - withReverseContributionList({ - data: 'trackData', - list: input.value('contributorContribs'), - }).outputs({ - '#reverseContributionList': '#tracksAsContributor', - }), - - withReverseContributionList({ - data: 'trackData', - list: input.value('coverArtistContribs'), - }).outputs({ - '#reverseContributionList': '#tracksAsCoverArtist', - }), - - { - dependencies: [ - '#tracksAsArtist', - '#tracksAsContributor', - '#tracksAsCoverArtist', - ], - - compute: ({ - ['#tracksAsArtist']: tracksAsArtist, - ['#tracksAsContributor']: tracksAsContributor, - ['#tracksAsCoverArtist']: tracksAsCoverArtist, - }) => - unique([ - ...tracksAsArtist, - ...tracksAsContributor, - ...tracksAsCoverArtist, - ]), - }, - ], - tracksAsCommentator: reverseReferenceList({ data: 'trackData', list: input.value('commentatorArtists'), }), - albumsAsAlbumArtist: reverseContributionList({ + albumArtistContributions: reverseContributionList({ data: 'albumData', list: input.value('artistContribs'), }), - albumsAsCoverArtist: reverseContributionList({ + albumCoverArtistContributions: reverseContributionList({ data: 'albumData', list: input.value('coverArtistContribs'), }), - albumsAsWallpaperArtist: reverseContributionList({ + albumWallpaperArtistContributions: reverseContributionList({ data: 'albumData', list: input.value('wallpaperArtistContribs'), }), - albumsAsBannerArtist: reverseContributionList({ + albumBannerArtistContributions: reverseContributionList({ data: 'albumData', list: input.value('bannerArtistContribs'), }), - albumsAsAny: [ - withReverseContributionList({ - data: 'albumData', - list: input.value('artistContribs'), - }).outputs({ - '#reverseContributionList': '#albumsAsArtist', - }), - - withReverseContributionList({ - data: 'albumData', - list: input.value('coverArtistContribs'), - }).outputs({ - '#reverseContributionList': '#albumsAsCoverArtist', - }), - - withReverseContributionList({ - data: 'albumData', - list: input.value('wallpaperArtistContribs'), - }).outputs({ - '#reverseContributionList': '#albumsAsWallpaperArtist', - }), - - withReverseContributionList({ - data: 'albumData', - list: input.value('bannerArtistContribs'), - }).outputs({ - '#reverseContributionList': '#albumsAsBannerArtist', - }), - - { - dependencies: [ - '#albumsAsArtist', - '#albumsAsCoverArtist', - '#albumsAsWallpaperArtist', - '#albumsAsBannerArtist', - ], - - compute: ({ - ['#albumsAsArtist']: albumsAsArtist, - ['#albumsAsCoverArtist']: albumsAsCoverArtist, - ['#albumsAsWallpaperArtist']: albumsAsWallpaperArtist, - ['#albumsAsBannerArtist']: albumsAsBannerArtist, - }) => - unique([ - ...albumsAsArtist, - ...albumsAsCoverArtist, - ...albumsAsWallpaperArtist, - ...albumsAsBannerArtist, - ]), - }, - ], - albumsAsCommentator: reverseReferenceList({ data: 'albumData', list: input.value('commentatorArtists'), }), - flashesAsContributor: reverseContributionList({ + flashContributorContributions: reverseContributionList({ data: 'flashData', list: input.value('contributorContribs'), }), @@ -225,6 +134,8 @@ export class Artist extends Thing { data: 'flashData', list: input.value('commentatorArtists'), }), + + totalDuration: artistTotalDuration(), }); static [Thing.getSerializeDescriptors] = ({ @@ -240,18 +151,8 @@ export class Artist extends Thing { aliasNames: S.id, - tracksAsArtist: S.toRefs, - tracksAsContributor: S.toRefs, - tracksAsCoverArtist: S.toRefs, tracksAsCommentator: S.toRefs, - - albumsAsAlbumArtist: S.toRefs, - albumsAsCoverArtist: S.toRefs, - albumsAsWallpaperArtist: S.toRefs, - albumsAsBannerArtist: S.toRefs, albumsAsCommentator: S.toRefs, - - flashesAsContributor: S.toRefs, }); static [Thing.findSpecs] = { diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js new file mode 100644 index 00000000..79acf1e1 --- /dev/null +++ b/src/data/things/contribution.js @@ -0,0 +1,265 @@ +import {inspect} from 'node:util'; + +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; +import {input} from '#composite'; +import {empty} from '#sugar'; +import Thing from '#thing'; +import {isStringNonEmpty, isThing, validateReference} from '#validators'; + +import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; +import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data'; +import {withResolvedReference} from '#composite/wiki-data'; +import {flag, simpleDate} from '#composite/wiki-properties'; + +import { + inheritFromContributionPresets, + thingPropertyMatches, + thingReferenceTypeMatches, + withContainingReverseContributionList, + withContributionArtist, + withContributionContext, + withMatchingContributionPresets, +} from '#composite/things/contribution'; + +export class Contribution extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: { + flags: {update: true, expose: true}, + update: {validate: isThing}, + }, + + thingProperty: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + artistProperty: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + date: simpleDate(), + + artist: [ + withContributionArtist({ + ref: input.updateValue({ + validate: validateReference('artist'), + }), + }), + + exposeDependency({ + dependency: '#artist', + }), + ], + + annotation: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + countInContributionTotals: [ + inheritFromContributionPresets({ + property: input.thisProperty(), + }), + + flag(true), + ], + + countInDurationTotals: [ + inheritFromContributionPresets({ + property: input.thisProperty(), + }), + + flag(true), + ], + + // Expose only + + context: [ + withContributionContext(), + + { + dependencies: [ + '#contributionTarget', + '#contributionProperty', + ], + + compute: ({ + ['#contributionTarget']: target, + ['#contributionProperty']: property, + }) => ({ + target, + property, + }), + }, + ], + + matchingPresets: [ + withMatchingContributionPresets(), + + exposeDependency({ + dependency: '#matchingContributionPresets', + }), + ], + + // All the contributions from the list which includes this contribution. + // Note that this list contains not only other contributions by the same + // artist, but also this very contribution. It doesn't mix contributions + // exposed on different properties. + associatedContributions: [ + exitWithoutDependency({ + dependency: 'thing', + value: input.value([]), + }), + + exitWithoutDependency({ + dependency: 'thingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'thingProperty', + }), + + exposeDependency({ + dependency: '#value', + }), + ], + + isArtistContribution: thingPropertyMatches({ + value: input.value('artistContribs'), + }), + + isContributorContribution: thingPropertyMatches({ + value: input.value('contributorContribs'), + }), + + isCoverArtistContribution: thingPropertyMatches({ + value: input.value('coverArtistContribs'), + }), + + isBannerArtistContribution: thingPropertyMatches({ + value: input.value('bannerArtistContribs'), + }), + + isWallpaperArtistContribution: thingPropertyMatches({ + value: input.value('wallpaperArtistContribs'), + }), + + isForTrack: thingReferenceTypeMatches({ + value: input.value('track'), + }), + + isForAlbum: thingReferenceTypeMatches({ + value: input.value('album'), + }), + + isForFlash: thingReferenceTypeMatches({ + value: input.value('flash'), + }), + + previousBySameArtist: [ + withContainingReverseContributionList().outputs({ + '#containingReverseContributionList': '#list', + }), + + exitWithoutDependency({ + dependency: '#list', + }), + + withNearbyItemFromList({ + list: '#list', + item: input.myself(), + offset: input.value(-1), + }), + + exposeDependency({ + dependency: '#nearbyItem', + }), + ], + + nextBySameArtist: [ + withContainingReverseContributionList().outputs({ + '#containingReverseContributionList': '#list', + }), + + exitWithoutDependency({ + dependency: '#list', + }), + + withNearbyItemFromList({ + list: '#list', + item: input.myself(), + offset: input.value(+1), + }), + + exposeDependency({ + dependency: '#nearbyItem', + }), + ], + }); + + [inspect.custom](depth, options, inspect) { + const parts = []; + const accentParts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (this.annotation) { + accentParts.push(colors.green(`"${this.annotation}"`)); + } + + if (this.date) { + accentParts.push(colors.yellow(this.date.toLocaleDateString())); + } + + let artistRef; + if (depth >= 0) { + let artist; + try { + artist = this.artist; + } catch (_error) { + // Computing artist might crash for any reason - don't distract from + // other errors as a result of inspecting this contribution. + } + + if (artist) { + artistRef = + colors.blue(Thing.getReference(artist)); + } + } else { + artistRef = + colors.green(CacheableObject.getUpdateValue(this, 'artist')); + } + + if (artistRef) { + accentParts.push(`by ${artistRef}`); + } + + if (this.thing) { + if (depth >= 0) { + const newOptions = { + ...options, + depth: + (options.depth === null + ? null + : options.depth - 1), + }; + + accentParts.push(`to ${inspect(this.thing, newOptions)}`); + } else { + accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`); + } + } + + if (!empty(accentParts)) { + parts.push(` (${accentParts.join(', ')})`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/flash.js b/src/data/things/flash.js index ceed79f7..89e59fe7 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -7,7 +7,7 @@ import {sortFlashesChronologically} from '#sort'; import Thing from '#thing'; import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} from '#validators'; -import {parseDate, parseContributors} from '#yaml'; +import {parseContributors, parseDate, parseDimensions} from '#yaml'; import {withPropertyFromObject} from '#composite/data'; @@ -24,11 +24,13 @@ import { commentatorArtists, contentString, contributionList, + dimensions, directory, fileExtension, name, referenceList, simpleDate, + thing, urls, wikiData, } from '#composite/wiki-properties'; @@ -39,7 +41,12 @@ import {withFlashSide} from '#composite/things/flash-act'; export class Flash extends Thing { static [Thing.referenceType] = 'flash'; - static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({ + static [Thing.getPropertyDescriptors] = ({ + Artist, + Track, + FlashAct, + WikiInfo, + }) => ({ // Update & expose name: name('Unnamed Flash'), @@ -89,7 +96,12 @@ export class Flash extends Thing { coverArtFileExtension: fileExtension('jpg'), - contributorContribs: contributionList(), + coverArtDimensions: dimensions(), + + contributorContribs: contributionList({ + date: 'date', + artistProperty: input.value('flashContributorContributions'), + }), featuredTracks: referenceList({ class: input.value(Track), @@ -115,6 +127,10 @@ export class Flash extends Thing { class: input.value(FlashAct), }), + wikiInfo: thing({ + class: input.value(WikiInfo), + }), + // Expose only commentatorArtists: commentatorArtists(), @@ -171,6 +187,11 @@ export class Flash extends Thing { 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, + }, + 'Featured Tracks': {property: 'featuredTracks'}, 'Contributors': { diff --git a/src/data/things/index.js b/src/data/things/index.js index 4f87f492..f18e283a 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -11,6 +11,7 @@ import Thing from '#thing'; import * as albumClasses from './album.js'; import * as artTagClasses from './art-tag.js'; import * as artistClasses from './artist.js'; +import * as contributionClasses from './contribution.js'; import * as flashClasses from './flash.js'; import * as groupClasses from './group.js'; import * as homepageLayoutClasses from './homepage-layout.js'; @@ -24,6 +25,7 @@ const allClassLists = { 'album.js': albumClasses, 'art-tag.js': artTagClasses, 'artist.js': artistClasses, + 'contribution.js': contributionClasses, 'flash.js': flashClasses, 'group.js': groupClasses, 'homepage-layout.js': homepageLayoutClasses, diff --git a/src/data/things/language.js b/src/data/things/language.js index dbe1ff3d..88f16ecb 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -127,6 +127,13 @@ export class Language extends Thing { // Expose only + onlyIfOptions: { + flags: {expose: true}, + expose: { + compute: () => Symbol.for(`language.onlyIfOptions`), + }, + }, + intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), intl_number: this.#intlHelper(Intl.NumberFormat), intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), @@ -201,9 +208,7 @@ export class Language extends Thing { args.at(-1) !== null; const key = - (hasOptions ? args.slice(0, -1) : args) - .filter(Boolean) - .join('.'); + this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args); const options = (hasOptions @@ -218,18 +223,42 @@ export class Language extends Thing { throw new Error(`Invalid key ${key} accessed`); } + const constantCasify = name => + name + .replace(/[A-Z]/g, '_$&') + .toUpperCase(); + // These will be filled up as we iterate over the template, slotting in // each option (if it's present). const missingOptionNames = new Set(); + // These will also be filled. It's a bit different of an error, indicating + // a provided option was *expected,* but its value was null, undefined, or + // blank HTML content. + const valuelessOptionNames = new Set(); + + // These *might* be missing, and if they are, that's OK!! Instead of adding + // to the valueless set above, we'll just mark to return a blank for the + // whole string. + const expectedValuelessOptionNames = + new Set( + (options[this.onlyIfOptions] ?? []) + .map(constantCasify)); + + let seenExpectedValuelessOption = false; + + const isValueless = + value => + value === null || + value === undefined || + html.isBlank(value); + // And this will have entries deleted as they're encountered in the // template. Leftover entries are misplaced. const optionsMap = new Map( Object.entries(options).map(([name, value]) => [ - name - .replace(/[A-Z]/g, '_$&') - .toUpperCase(), + constantCasify(name), value, ])); @@ -239,32 +268,48 @@ export class Language extends Thing { match: languageOptionRegex, insert: ({name: optionName}, canceledForming) => { - if (optionsMap.has(optionName)) { - let optionValue; - - // We'll only need the option's value if we're going to use it as - // part of the formed output (see below). - if (!canceledForming) { - optionValue = optionsMap.get(optionName); - } - - // But we always have to delete expected options off the provided - // option map, since the leftovers are what will be used to tell - // which are misplaced. - optionsMap.delete(optionName); + if (!optionsMap.has(optionName)) { + missingOptionNames.add(optionName); - if (canceledForming) { - return undefined; - } else { - return optionValue; - } - } else { // We don't need to continue forming the output if we've hit a // missing option name, since the end result of this formatString // call will be a thrown error, and formed output won't be needed. - missingOptionNames.add(optionName); + // Return undefined to mark canceledForming for the following + // iterations (and exit early out of this iteration). + return undefined; + } + + // Even if we're not actually forming the output anymore, we'll still + // have to access this option's value to check if it is invalid. + const optionValue = optionsMap.get(optionName); + + // We always have to delete expected options off the provided option + // map, since the leftovers are what will be used to tell which are + // misplaced - information you want even (or doubly so) if we've + // already stopped forming the output thanks to missing options. + optionsMap.delete(optionName); + + // Just like if an option is missing, a valueless option cancels + // forming the rest of the output. + if (isValueless(optionValue)) { + // It's also an error, *except* if this option is one of the ones + // that we're indicated to *expect* might be valueless! In that case, + // we still need to stop forming the string (and mark a separate flag + // so that we return a blank), but it's not an error. + if (expectedValuelessOptionNames.has(optionName)) { + seenExpectedValuelessOption = true; + } else { + valuelessOptionNames.add(optionName); + } + return undefined; } + + if (canceledForming) { + return undefined; + } + + return optionValue; }, }); @@ -272,17 +317,30 @@ export class Language extends Thing { Array.from(optionsMap.keys()); withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => { + const names = set => Array.from(set).join(', '); + if (!empty(missingOptionNames)) { - const names = Array.from(missingOptionNames).join(`, `); - push(new Error(`Missing options: ${names}`)); + push(new Error( + `Missing options: ${names(missingOptionNames)}`)); + } + + if (!empty(valuelessOptionNames)) { + push(new Error( + `Valueless options: ${names(valuelessOptionNames)}`)); } if (!empty(misplacedOptionNames)) { - const names = Array.from(misplacedOptionNames).join(`, `); - push(new Error(`Unexpected options: ${names}`)); + push(new Error( + `Unexpected options: ${names(misplacedOptionNames)}`)); } }); + // If an option was valueless as marked to expect, then that indicates + // the whole string should be treated as blank content. + if (seenExpectedValuelessOption) { + return html.blank(); + } + return output; } @@ -416,11 +474,32 @@ export class Language extends Thing { } formatDate(date) { + // Null or undefined date is blank content. + if (date === null || date === undefined) { + return html.blank(); + } + this.assertIntlAvailable('intl_date'); return this.intl_date.format(date); } formatDateRange(startDate, endDate) { + // formatDateRange expects both values to be present, but if both are null + // or both are undefined, that's just blank content. + const hasStart = startDate !== null && startDate !== undefined; + const hasEnd = endDate !== null && endDate !== undefined; + if (!hasStart || !hasEnd) { + if (startDate === endDate) { + return html.blank(); + } else if (hasStart) { + throw new Error(`Expected both start and end of date range, got only start`); + } else if (hasEnd) { + throw new Error(`Expected both start and end of date range, got only end`); + } else { + throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`); + } + } + this.assertIntlAvailable('intl_date'); return this.intl_date.formatRange(startDate, endDate); } @@ -431,6 +510,17 @@ export class Language extends Thing { days: numDays = 0, approximate = false, }) { + // Give up if any of years, months, or days is null or undefined. + // These default to zero, so something's gone pretty badly wrong to + // pass in all or partial missing values. + if ( + numYears === undefined || numYears === null || + numMonths === undefined || numMonths === null || + numDays === undefined || numDays === null + ) { + throw new Error(`Expected values or default zero for years, months, and days`); + } + let basis; const years = this.countYears(numYears, {unit: true}); @@ -468,6 +558,14 @@ export class Language extends Thing { approximate = true, absolute = true, } = {}) { + // Give up if current and/or reference date is null or undefined. + if ( + currentDate === undefined || currentDate === null || + referenceDate === undefined || referenceDate === null + ) { + throw new Error(`Expected values for currentDate and referenceDate`); + } + const currentInstant = toTemporalInstant.apply(currentDate); const referenceInstant = toTemporalInstant.apply(referenceDate); @@ -528,6 +626,12 @@ export class Language extends Thing { } formatDuration(secTotal, {approximate = false, unit = false} = {}) { + // Null or undefined duration is blank content. + if (secTotal === null || secTotal === undefined) { + return html.blank(); + } + + // Zero duration is a "missing" string. if (secTotal === 0) { return this.formatString('count.duration.missing'); } @@ -565,6 +669,11 @@ export class Language extends Thing { throw new TypeError(`externalLinkSpec unavailable`); } + // Null or undefined url is blank content. + if (url === null || url === undefined) { + return html.blank(); + } + isExternalLinkContext(context); if (style === 'all') { @@ -589,16 +698,31 @@ export class Language extends Thing { } formatIndex(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + this.assertIntlAvailable('intl_pluralOrdinal'); return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); } formatNumber(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + this.assertIntlAvailable('intl_number'); return this.intl_number.format(value); } formatWordCount(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + const num = this.formatNumber( value > 1000 ? Math.floor(value / 100) / 10 : value ); @@ -612,6 +736,11 @@ export class Language extends Thing { } #formatListHelper(array, processFn) { + // Empty lists, null, and undefined are blank content. + if (empty(array) || array === null || array === undefined) { + return html.blank(); + } + // Operate on "insertion markers" instead of the actual contents of the // array, because the process function (likely an Intl operation) is taken // to only operate on strings. We'll insert the contents of the array back @@ -673,10 +802,22 @@ export class Language extends Thing { // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB formatFileSize(bytes) { - if (!bytes) return ''; + // Null or undefined bytes is blank content. + if (bytes === null || bytes === undefined) { + return html.blank(); + } + + // Zero bytes is blank content. + if (bytes === 0) { + return html.blank(); + } bytes = parseInt(bytes); - if (isNaN(bytes)) return ''; + + // Non-number bytes is blank content! Wow. + if (isNaN(bytes)) { + return html.blank(); + } const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10; @@ -700,10 +841,42 @@ export class Language extends Thing { return this.formatString('count.fileSize.bytes', {bytes}); } } + + // Utility function to quickly provide a useful string key + // (generally a prefix) to stuff nested beneath it. + encapsulate(...args) { + const fn = + (typeof args.at(-1) === 'function' + ? args.at(-1) + : null); + + const parts = + (fn + ? args.slice(0, -1) + : args); + + const capsule = + this.#joinKeyParts(parts); + + if (fn) { + return fn(capsule); + } else { + return capsule; + } + } + + #joinKeyParts(parts) { + return parts.filter(Boolean).join('.'); + } } const countHelper = (stringKey, optionName = stringKey) => function(value, {unit = false} = {}) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + return this.formatString( unit ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) diff --git a/src/data/things/track.js b/src/data/things/track.js index 725b1bb7..4aaf364c 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -18,7 +18,6 @@ import { } from '#yaml'; import {withPropertyFromObject} from '#composite/data'; -import {withResolvedContribs} from '#composite/wiki-data'; import { exitWithoutDependency, @@ -26,9 +25,16 @@ import { exposeDependency, exposeDependencyOrContinue, exposeUpdateValueOrContinue, + exposeWhetherDependencyAvailable, } from '#composite/control-flow'; import { + withRecontextualizedContributionList, + withRedatedContributionList, + withResolvedContribs, +} from '#composite/wiki-data'; + +import { additionalFiles, additionalNameList, commentary, @@ -43,8 +49,9 @@ import { referenceList, reverseReferenceList, simpleDate, - singleReference, simpleString, + singleReference, + thing, urls, wikiData, } from '#composite/wiki-properties'; @@ -52,21 +59,31 @@ import { import { exitWithoutUniqueCoverArt, inferredAdditionalNameList, + inheritContributionListFromOriginalRelease, inheritFromOriginalRelease, sharedAdditionalNameList, trackReverseReferenceList, withAlbum, withAlwaysReferenceByDirectory, withContainingTrackSection, + withDate, withHasUniqueCoverArt, + withOriginalRelease, withOtherReleases, withPropertyFromAlbum, + withTrackArtDate, } from '#composite/things/track'; export class Track extends Thing { static [Thing.referenceType] = 'track'; - static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({ + static [Thing.getPropertyDescriptors] = ({ + Album, + ArtTag, + Artist, + Flash, + WikiInfo, + }) => ({ // Update & expose name: name('Unnamed Track'), @@ -137,27 +154,14 @@ export class Track extends Thing { }), ], - // 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'), + withTrackArtDate({ + from: input.updateValue({ + validate: isDate, + }), }), - exposeDependency({dependency: '#album.trackArtDate'}), + exposeDependency({dependency: '#trackArtDate'}), ], coverArtDimensions: [ @@ -192,12 +196,15 @@ export class Track extends Thing { }), artistContribs: [ - inheritFromOriginalRelease({ - notFoundValue: input.value([]), - }), + inheritContributionListFromOriginalRelease(), + + withDate(), withResolvedContribs({ from: input.updateValue({validate: isContributionList}), + thingProperty: input.thisProperty(), + artistProperty: input.value('trackArtistContributions'), + date: '#date', }).outputs({ '#resolvedContribs': '#artistContribs', }), @@ -211,15 +218,28 @@ export class Track extends Thing { property: input.value('artistContribs'), }), + withRecontextualizedContributionList({ + list: '#album.artistContribs', + artistProperty: input.value('trackArtistContributions'), + }), + + withRedatedContributionList({ + list: '#album.artistContribs', + date: '#date', + }), + exposeDependency({dependency: '#album.artistContribs'}), ], contributorContribs: [ - inheritFromOriginalRelease({ - notFoundValue: input.value([]), - }), + inheritContributionListFromOriginalRelease(), - contributionList(), + withDate(), + + contributionList({ + date: '#date', + artistProperty: input.value('trackContributorContributions'), + }), ], // Cover artists aren't inherited from the original release, since it @@ -230,8 +250,15 @@ export class Track extends Thing { value: input.value([]), }), + withTrackArtDate({ + fallback: input.value(true), + }), + withResolvedContribs({ from: input.updateValue({validate: isContributionList}), + thingProperty: input.thisProperty(), + artistProperty: input.value('trackCoverArtistContributions'), + date: '#trackArtDate', }).outputs({ '#resolvedContribs': '#coverArtistContribs', }), @@ -245,6 +272,16 @@ export class Track extends Thing { property: input.value('trackCoverArtistContribs'), }), + withRecontextualizedContributionList({ + list: '#album.trackCoverArtistContribs', + artistProperty: input.value('trackCoverArtistContributions'), + }), + + withRedatedContributionList({ + list: '#album.trackCoverArtistContribs', + date: '#trackArtDate', + }), + exposeDependency({dependency: '#album.trackCoverArtistContribs'}), ], @@ -306,6 +343,10 @@ export class Track extends Thing { class: input.value(Track), }), + wikiInfo: thing({ + class: input.value(WikiInfo), + }), + // Expose only commentatorArtists: commentatorArtists(), @@ -316,13 +357,8 @@ export class Track extends Thing { ], date: [ - exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), - - withPropertyFromAlbum({ - property: input.value('date'), - }), - - exposeDependency({dependency: '#album.date'}), + withDate(), + exposeDependency({dependency: '#date'}), ], hasUniqueCoverArt: [ @@ -330,6 +366,23 @@ export class Track extends Thing { exposeDependency({dependency: '#hasUniqueCoverArt'}), ], + isOriginalRelease: [ + withOriginalRelease(), + + exposeWhetherDependencyAvailable({ + dependency: '#originalRelease', + negate: input.value(true), + }), + ], + + isRerelease: [ + withOriginalRelease(), + + exposeWhetherDependencyAvailable({ + dependency: '#originalRelease', + }), + ], + otherReleases: [ withOtherReleases(), exposeDependency({dependency: '#otherReleases'}), diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 316bd3bb..b7b7b01c 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -3,8 +3,18 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml'; import {input} from '#composite'; import find from '#find'; import Thing from '#thing'; -import {isColor, isLanguageCode, isName, isURL} from '#validators'; - +import {parseContributionPresets} from '#yaml'; + +import { + isBoolean, + isColor, + isContributionPresetList, + isLanguageCode, + isName, + isURL, +} from '#validators'; + +import {exitWithoutDependency} from '#composite/control-flow'; import {contentString, flag, name, referenceList, wikiData} from '#composite/wiki-properties'; @@ -57,6 +67,11 @@ export class WikiInfo extends Thing { data: 'groupData', }), + contributionPresets: { + flags: {update: true, expose: true}, + update: {validate: isContributionPresetList}, + }, + // Feature toggles enableFlashesAndGames: flag(false), enableListings: flag(false), @@ -64,8 +79,26 @@ export class WikiInfo extends Thing { enableArtTagUI: flag(false), enableGroupUI: flag(false), + enableSearch: [ + exitWithoutDependency({ + dependency: 'searchDataAvailable', + mode: input.value('falsy'), + value: input.value(false), + }), + + flag(true), + ], + // Update only + searchDataAvailable: { + flags: {update: true}, + update: { + validate: isBoolean, + default: false, + }, + }, + groupData: wikiData({ class: input.value(Group), }), @@ -86,6 +119,11 @@ export class WikiInfo extends Thing { 'Enable News': {property: 'enableNews'}, 'Enable Art Tag UI': {property: 'enableArtTagUI'}, 'Enable Group UI': {property: 'enableGroupUI'}, + + 'Contribution Presets': { + property: 'contributionPresets', + transform: parseContributionPresets, + }, }, }; diff --git a/src/data/validators.js b/src/data/validators.js index 5d681311..354de6fa 100644 --- a/src/data/validators.js +++ b/src/data/validators.js @@ -636,10 +636,65 @@ export function isThing(thing) { export const isContribution = validateProperties({ artist: isArtistRef, annotation: optional(isStringNonEmpty), + + countInDurationTotals: optional(isBoolean), + countInContributionTotals: optional(isBoolean), }); export const isContributionList = validateArrayItems(isContribution); +export const contributionPresetPropertySpec = { + album: [ + 'artistContribs', + ], + + flash: [ + 'contributorContribs', + ], + + track: [ + 'artistContribs', + 'contributorContribs', + ], +}; + +// TODO: This validator basically constructs itself as it goes. +// This is definitely some shenanigans! +export function isContributionPresetContext(list) { + isArray(list); + + if (empty(list)) { + throw new TypeError(`Expected at least one item`); + } + + const isTarget = + is(...Object.keys(contributionPresetPropertySpec)); + + const [target, ...properties] = list; + + isTarget(target); + + const isProperty = + is(...contributionPresetPropertySpec[target]); + + const isPropertyList = + validateArrayItems(isProperty); + + isPropertyList(properties); + + return true; +} + +export const isContributionPreset = validateProperties({ + annotation: isStringNonEmpty, + context: isContributionPresetContext, + + countInDurationTotals: optional(isBoolean), + countInContributionTotals: optional(isBoolean), +}); + +export const isContributionPresetList = validateArrayItems(isContributionPreset); + export const isAdditionalFile = validateProperties({ title: isName, description: optional(isContentString), diff --git a/src/data/yaml.js b/src/data/yaml.js index c9ce5329..7a16341b 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -370,34 +370,42 @@ export function parseDuration(string) { } } -export function parseAdditionalFiles(array) { - if (!Array.isArray(array)) { - // Error will be caught when validating against whatever this value is - return array; - } - - return array.map((item) => ({ - title: item['Title'], - description: item['Description'] ?? null, - files: item['Files'], - })); -} - export const extractAccentRegex = /^(?<main>.*?)(?: \((?<accent>.*)\))?$/; export const extractPrefixAccentRegex = /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/; -export function parseContributors(contributionStrings) { +// TODO: Should this fit better within actual YAML loading infrastructure?? +export function parseArrayEntries(entries, mapFn) { // If this isn't something we can parse, just return it as-is. // The Thing object's validators will handle the data error better // than we're able to here. - if (!Array.isArray(contributionStrings)) { - return contributionStrings; + if (!Array.isArray(entries)) { + return entries; + } + + // If the array is REALLY ACTUALLY empty (it's represented in YAML + // as literally an empty []), that's something we want to reflect. + if (empty(entries)) { + return entries; } - return contributionStrings.map(item => { + const nonNullEntries = + entries.filter(value => value !== null); + + // On the other hand, if the array only contains null, it's just + // a placeholder, so skip over the field like it's not actually + // been put there yet. + if (empty(nonNullEntries)) { + return null; + } + + return entries.map(mapFn); +} + +export function parseContributors(entries) { + return parseArrayEntries(entries, item => { if (typeof item === 'object' && item['Who']) return { artist: item['Who'], @@ -408,6 +416,9 @@ export function parseContributors(contributionStrings) { return { artist: item['Artist'], annotation: item['Annotation'] ?? null, + + countInContributionTotals: item['Count In Contribution Totals'] ?? null, + countInDurationTotals: item['Count In Duration Totals'] ?? null, }; if (typeof item !== 'string') return item; @@ -422,12 +433,20 @@ export function parseContributors(contributionStrings) { }); } -export function parseAdditionalNames(additionalNameStrings) { - if (!Array.isArray(additionalNameStrings)) { - return additionalNameStrings; - } +export function parseAdditionalFiles(entries) { + return parseArrayEntries(entries, item => { + if (typeof item !== 'object') return item; - return additionalNameStrings.map(item => { + return { + title: item['Title'], + description: item['Description'] ?? null, + files: item['Files'], + }; + }); +} + +export function parseAdditionalNames(entries) { + return parseArrayEntries(entries, item => { if (typeof item === 'object' && item['Name']) return {name: item['Name'], annotation: item['Annotation'] ?? null}; @@ -466,6 +485,73 @@ export function parseDimensions(string) { return nums; } +export const contributionPresetYAMLSpec = [ + {from: 'Album', to: 'album', fields: [ + {from: 'Artists', to: 'artistContribs'}, + ]}, + + {from: 'Flash', to: 'flash', fields: [ + {from: 'Contributors', to: 'contributorContribs'}, + ]}, + + {from: 'Track', to: 'track', fields: [ + {from: 'Artists', to: 'artistContribs'}, + {from: 'Contributors', to: 'contributorContribs'}, + ]}, +]; + +export function parseContributionPresetContext(context) { + if (!Array.isArray(context)) { + return context; + } + + const [target, ...fields] = context; + + const targetEntry = + contributionPresetYAMLSpec + .find(({from}) => from === target); + + if (!targetEntry) { + return context; + } + + const properties = + fields.map(field => { + const fieldEntry = + targetEntry.fields + .find(({from}) => from === field); + + if (!fieldEntry) return field; + + return fieldEntry.to; + }); + + return [targetEntry.to, ...properties]; +} + +export function parseContributionPresets(list) { + if (!Array.isArray(list)) return list; + + return list.map(item => { + if (typeof item !== 'object') return item; + + return { + annotation: + item['Annotation'] ?? null, + + context: + parseContributionPresetContext( + item['Context'] ?? null), + + countInContributionTotals: + item['Count In Contribution Totals'] ?? null, + + countInDurationTotals: + item['Count In Duration Totals'] ?? null, + }; + }); +} + // documentModes: Symbols indicating sets of behavior for loading and processing // data files. export const documentModes = { @@ -1066,6 +1152,7 @@ export function linkWikiDataArrays(wikiData) { 'artTagData', 'artistData', 'groupData', + 'wikiInfo', ]], [wikiData.artTagData, [ @@ -1084,6 +1171,7 @@ export function linkWikiDataArrays(wikiData) { 'artistData', 'flashActData', 'trackData', + 'wikiInfo', ]], [wikiData.flashActData, [ @@ -1115,6 +1203,7 @@ export function linkWikiDataArrays(wikiData) { 'artistData', 'flashData', 'trackData', + 'wikiInfo', ]], [[wikiData.wikiInfo], [ |