diff options
-rw-r--r-- | src/data/things/album.js | 181 | ||||
-rw-r--r-- | src/data/things/composite.js | 223 | ||||
-rw-r--r-- | src/data/things/index.js | 13 | ||||
-rw-r--r-- | src/data/things/thing.js | 156 | ||||
-rw-r--r-- | src/data/things/track.js | 293 |
5 files changed, 531 insertions, 335 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js index fb0c3427..9ca662a0 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,15 +1,17 @@ import find from '#find'; import {stitchArrays} from '#sugar'; -import {isDate, isDimensions, isTrackSectionList} from '#validators'; +import {isDate, isTrackSectionList} from '#validators'; import { - compositeFrom, exitWithoutDependency, exitWithoutUpdateValue, exposeDependency, exposeUpdateValueOrContinue, + fillMissingListItems, withFlattenedArray, + withPropertiesFromList, withUnflattenedArray, + withUpdateValueAsDependency, } from '#composite'; import Thing, { @@ -19,7 +21,9 @@ import Thing, { commentatorArtists, contribsPresent, contributionList, + dimensions, directory, + exitWithoutContribs, fileExtension, flag, name, @@ -28,7 +32,6 @@ import Thing, { simpleString, urls, wikiData, - withResolvedContribs, withResolvedReferenceList, } from './thing.js'; @@ -47,83 +50,92 @@ export class Album extends Thing { trackArtDate: simpleDate(), dateAddedToWiki: simpleDate(), - coverArtDate: compositeFrom(`Album.coverArtDate`, [ - withResolvedContribs({from: 'coverArtistContribs'}), - exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}), - + coverArtDate: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), exposeUpdateValueOrContinue(), exposeDependency({ dependency: 'date', update: {validate: isDate}, }), - ]), + ], - artistContribs: contributionList(), - coverArtistContribs: contributionList(), - trackCoverArtistContribs: contributionList(), - wallpaperArtistContribs: contributionList(), - bannerArtistContribs: contributionList(), + coverArtFileExtension: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + fileExtension('jpg'), + ], - groups: referenceList({ - class: Group, - find: find.group, - data: 'groupData', - }), + trackCoverArtFileExtension: fileExtension('jpg'), - artTags: referenceList({ - class: ArtTag, - find: find.artTag, - data: 'artTagData', - }), + wallpaperFileExtension: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + fileExtension('jpg'), + ], + + bannerFileExtension: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + fileExtension('jpg'), + ], + + wallpaperStyle: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + simpleString(), + ], + + bannerStyle: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + simpleString(), + ], + + bannerDimensions: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + dimensions(), + ], - trackSections: compositeFrom(`Album.trackSections`, [ + hasTrackNumbers: flag(true), + isListedOnHomepage: flag(true), + isListedInGalleries: flag(true), + + commentary: commentary(), + additionalFiles: additionalFiles(), + + trackSections: [ exitWithoutDependency({dependency: 'trackData', value: []}), exitWithoutUpdateValue({value: [], mode: 'empty'}), - { - transform: (trackSections, continuation) => - continuation(trackSections, { - '#sectionTrackRefs': - trackSections.map(section => section.tracks), - - '#sectionDateOriginallyReleased': - trackSections - .map(({dateOriginallyReleased}) => dateOriginallyReleased ?? null), - - '#sectionIsDefaultTrackSection': - trackSections - .map(({isDefaultTrackSection}) => isDefaultTrackSection ?? false), - }), - }, + withUpdateValueAsDependency({into: '#sections'}), - { - dependencies: ['color'], - transform: (trackSections, {color: albumColor}, continuation) => - continuation(trackSections, { - '#sectionColor': - trackSections - .map(({color: sectionColor}) => sectionColor ?? albumColor), - }), - }, + withPropertiesFromList({ + list: '#sections', + properties: [ + 'tracks', + 'dateOriginallyReleased', + 'isDefaultTrackSection', + 'color', + ], + }), + + fillMissingListItems({list: '#sections.tracks', value: []}), + fillMissingListItems({list: '#sections.isDefaultTrackSection', value: false}), + fillMissingListItems({list: '#sections.color', dependency: 'color'}), withFlattenedArray({ - from: '#sectionTrackRefs', + from: '#sections.tracks', into: '#trackRefs', - intoIndices: '#sectionStartIndex', + intoIndices: '#sections.startIndex', }), withResolvedReferenceList({ list: '#trackRefs', data: 'trackData', - mode: 'null', + notFoundMode: 'null', find: find.track, into: '#tracks', }), withUnflattenedArray({ from: '#tracks', - fromIndices: '#sectionStartIndex', - into: '#sectionTracks', + fromIndices: '#sections.startIndex', + into: '#sections.tracks', }), { @@ -133,19 +145,19 @@ export class Album extends Thing { expose: { dependencies: [ - '#sectionTracks', - '#sectionColor', - '#sectionDateOriginallyReleased', - '#sectionIsDefaultTrackSection', - '#sectionStartIndex', + '#sections.tracks', + '#sections.color', + '#sections.dateOriginallyReleased', + '#sections.isDefaultTrackSection', + '#sections.startIndex', ], transform: (trackSections, { - '#sectionTracks': tracks, - '#sectionColor': color, - '#sectionDateOriginallyReleased': dateOriginallyReleased, - '#sectionIsDefaultTrackSection': isDefaultTrackSection, - '#sectionStartIndex': startIndex, + '#sections.tracks': tracks, + '#sections.color': color, + '#sections.dateOriginallyReleased': dateOriginallyReleased, + '#sections.isDefaultTrackSection': isDefaultTrackSection, + '#sections.startIndex': startIndex, }) => stitchArrays({ tracks, @@ -156,32 +168,25 @@ export class Album extends Thing { }), }, }, - ]), - - coverArtFileExtension: compositeFrom(`Album.coverArtFileExtension`, [ - withResolvedContribs({from: 'coverArtistContribs'}), - exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}), - fileExtension('jpg'), - ]), - - trackCoverArtFileExtension: fileExtension('jpg'), - - wallpaperStyle: simpleString(), - wallpaperFileExtension: fileExtension('jpg'), + ], - bannerStyle: simpleString(), - bannerFileExtension: fileExtension('jpg'), - bannerDimensions: { - flags: {update: true, expose: true}, - update: {validate: isDimensions}, - }, + artistContribs: contributionList(), + coverArtistContribs: contributionList(), + trackCoverArtistContribs: contributionList(), + wallpaperArtistContribs: contributionList(), + bannerArtistContribs: contributionList(), - hasTrackNumbers: flag(true), - isListedOnHomepage: flag(true), - isListedInGalleries: flag(true), + groups: referenceList({ + class: Group, + find: find.group, + data: 'groupData', + }), - commentary: commentary(), - additionalFiles: additionalFiles(), + artTags: referenceList({ + class: ArtTag, + find: find.artTag, + data: 'artTagData', + }), // Update only @@ -198,7 +203,7 @@ export class Album extends Thing { hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), - tracks: compositeFrom(`Album.tracks`, [ + tracks: [ exitWithoutDependency({dependency: 'trackData', value: []}), exitWithoutDependency({dependency: 'trackSections', mode: 'empty', value: []}), @@ -218,7 +223,7 @@ export class Album extends Thing { }), exposeDependency({dependency: '#resolvedReferenceList'}), - ]), + ], }); static [Thing.getSerializeDescriptors] = ({ diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 1f6482f6..2dd92f17 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -432,13 +432,8 @@ export function compositeFrom(firstArg, secondArg) { ? step.expose : step); - const stepComputes = !!expose.compute; - const stepTransforms = !!expose.transform; - - if (!stepComputes && !stepTransforms) { - push(new TypeError(`Steps must provide compute or transform (or both)`)); - return; - } + const stepComputes = !!expose?.compute; + const stepTransforms = !!expose?.transform; if ( stepTransforms && !stepComputes && @@ -459,7 +454,7 @@ export function compositeFrom(firstArg, secondArg) { // Unmapped dependencies are exposed on the final composition only if // they're "public", i.e. pointing to update values of other properties // on the CacheableObject. - for (const dependency of expose.dependencies ?? []) { + for (const dependency of expose?.dependencies ?? []) { if (typeof dependency === 'string' && dependency.startsWith('#')) { continue; } @@ -470,22 +465,14 @@ export function compositeFrom(firstArg, secondArg) { // Mapped dependencies are always exposed on the final composition. // These are explicitly for reading values which are named outside of // the current compositional step. - for (const dependency of Object.values(expose.mapDependencies ?? {})) { + for (const dependency of Object.values(expose?.mapDependencies ?? {})) { exposeDependencies.add(dependency); } }); } - if (!baseComposes) { - if (baseUpdates) { - if (!anyStepsTransform) { - aggregate.push(new TypeError(`Expected at least one step to transform`)); - } - } else { - if (!anyStepsCompute) { - aggregate.push(new TypeError(`Expected at least one step to compute`)); - } - } + if (!baseComposes && !baseUpdates && !anyStepsCompute) { + aggregate.push(new TypeError(`Expected at least one step to compute`)); } aggregate.close(); @@ -615,6 +602,11 @@ export function compositeFrom(firstArg, secondArg) { ? step.expose : step); + if (!expose) { + debug(() => `step #${i+1} - no expose description, nothing to do for this step`); + continue; + } + const callingTransformForThisStep = expectingTransform && expose.transform; @@ -1089,6 +1081,199 @@ export function withUpdateValueAsDependency({ }; } +// Gets a property of some object (in a dependency) and provides that value. +// If the object itself is null, or the object doesn't have the listed property, +// the provided dependency will also be null. +export function withPropertyFromObject({ + object, + property, + into = null, +}) { + into ??= + (object.startsWith('#') + ? `${object}.${property}` + : `#${object}.${property}`); + + return { + annotation: `withPropertyFromObject`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {object}, + mapContinuation: {into}, + options: {property}, + + compute: ({object, '#options': {property}}, continuation) => + (object === null || object === undefined + ? continuation({into: null}) + : continuation({into: object[property] ?? null})), + }, + }; +} + +// Gets the listed properties from some object, providing each property's value +// as a dependency prefixed with the same name as the object (by default). +// If the object itself is null, all provided dependencies will be null; +// if it's missing only select properties, those will be provided as null. +export function withPropertiesFromObject({ + object, + properties, + prefix = + (object.startsWith('#') + ? object + : `#${object}`), +}) { + return { + annotation: `withPropertiesFromObject`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {object}, + options: {prefix, properties}, + + compute: ({object, '#options': {prefix, properties}}, continuation) => + continuation( + Object.fromEntries( + properties.map(property => [ + `${prefix}.${property}`, + (object === null || object === undefined + ? null + : object[property] ?? null), + ]))), + }, + }; +} + +// Gets a property from each of a list of objects (in a dependency) and +// provides the results. This doesn't alter any list indices, so positions +// which were null in the original list are kept null here. Objects which don't +// have the specified property are retained in-place as null. +export function withPropertyFromList({ + list, + property, + into = null, +}) { + into ??= + (list.startsWith('#') + ? `${list}.${property}` + : `#${list}.${property}`); + + return { + annotation: `withPropertyFromList`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + mapContinuation: {into}, + options: {property}, + + compute({list, '#options': {property}}, continuation) { + if (list === undefined || empty(list)) { + return continuation({into: []}); + } + + return continuation({ + into: + list.map(item => + (item === null || item === undefined + ? null + : item[property] ?? null)), + }); + }, + }, + }; +} + +// Gets the listed properties from each of a list of objects, providing lists +// of property values each into a dependency prefixed with the same name as the +// list (by default). Like withPropertyFromList, this doesn't alter indices. +export function withPropertiesFromList({ + list, + properties, + prefix = + (list.startsWith('#') + ? list + : `#${list}`), +}) { + return { + annotation: `withPropertiesFromList`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + options: {prefix, properties}, + + compute({list, '#options': {prefix, properties}}, continuation) { + const lists = + Object.fromEntries( + properties.map(property => [`${prefix}.${property}`, []])); + + for (const item of list) { + for (const property of properties) { + lists[`${prefix}.${property}`].push( + (item === null || item === undefined + ? null + : item[property] ?? null)); + } + } + + return continuation(lists); + } + } + } +} + +// Replaces items of a list, which are null or undefined, with some fallback +// value, either a constant (set {value}) or from a dependency ({dependency}). +// By default, this replaces the passed dependency. +export function fillMissingListItems({ + list, + value, + dependency, + into = list, +}) { + if (value !== undefined && dependency !== undefined) { + throw new TypeError(`Don't provide both value and dependency`); + } + + if (value === undefined && dependency === undefined) { + throw new TypeError(`Missing value or dependency`); + } + + if (dependency) { + return { + annotation: `fillMissingListItems.fromDependency`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list, dependency}, + mapContinuation: {into}, + + compute: ({list, dependency}, continuation) => + continuation({ + into: list.map(item => item ?? dependency), + }), + }, + }; + } else { + return { + annotation: `fillMissingListItems.fromValue`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + mapContinuation: {into}, + options: {value}, + + compute: ({list, '#options': {value}}, continuation) => + continuation({ + into: list.map(item => item ?? value), + }), + }, + }; + } +} + // Flattens an array with one level of nested arrays, providing as dependencies // both the flattened array as well as the original starting indices of each // successive source array. diff --git a/src/data/things/index.js b/src/data/things/index.js index 3b73a772..4d8d9d1f 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -2,6 +2,7 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {logError} from '#cli'; +import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; import {openAggregate, showAggregate} from '#sugar'; @@ -130,8 +131,16 @@ function evaluatePropertyDescriptors() { throw new Error(`Missing [Thing.getPropertyDescriptors] function`); } - constructor.propertyDescriptors = - constructor[Thing.getPropertyDescriptors](opts); + const results = constructor[Thing.getPropertyDescriptors](opts); + + for (const [key, value] of Object.entries(results)) { + if (Array.isArray(value)) { + results[key] = compositeFrom(`${constructor.name}.${key}`, value); + continue; + } + } + + constructor.propertyDescriptors = results; }, showFailedClasses(failedClasses) { diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 0f47dc90..b1a9a802 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -5,7 +5,7 @@ import {inspect} from 'node:util'; import {colors} from '#cli'; import find from '#find'; -import {empty, stitchArrays} from '#sugar'; +import {empty, stitchArrays, unique} from '#sugar'; import {filterMultipleArrays, getKebabCase} from '#wiki-data'; import { @@ -16,6 +16,7 @@ import { exposeDependencyOrContinue, raiseWithoutDependency, withResultOfAvailabilityCheck, + withPropertiesFromList, withUpdateValueAsDependency, } from '#composite'; @@ -26,7 +27,9 @@ import { isColor, isContributionList, isDate, + isDimensions, isDirectory, + isDuration, isFileExtension, isName, isString, @@ -123,6 +126,24 @@ export function fileExtension(defaultFileExtension = null) { }; } +// Plain ol' image dimensions. This is a two-item array of positive integers, +// corresponding to width and height respectively. +export function dimensions() { + return { + flags: {update: true, expose: true}, + update: {validate: isDimensions}, + }; +} + +// Duration! This is a number of seconds, possibly floating point, always +// at minimum zero. +export function duration() { + return { + flags: {update: true, expose: true}, + update: {validate: isDuration}, + }; +} + // Straightforward flag descriptor for a variety of property purposes. // Provide a default value, true or false! export function flag(defaultValue = false) { @@ -331,29 +352,40 @@ export function wikiData(thingClass) { // This one's kinda tricky: it parses artist "references" from the // commentary content, and finds the matching artist for each reference. // This is mostly useful for credits and listings on artist pages. -export function commentatorArtists(){ - return { - flags: {expose: true}, +export function commentatorArtists() { + return compositeFrom(`commentatorArtists`, [ + exitWithoutDependency({dependency: 'commentary', mode: 'falsy', value: []}), - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({artistData, commentary}) => - artistData && commentary - ? Array.from( - new Set( - Array.from( - commentary - .replace(/<\/?b>/g, '') - .matchAll(/<i>(?<who>.*?):<\/i>/g) - ).map(({groups: {who}}) => - find.artist(who, artistData, {mode: 'quiet'}) - ) - ) - ) - : [], + { + dependencies: ['commentary'], + compute: ({commentary}, continuation) => + continuation({ + '#artistRefs': + Array.from( + commentary + .replace(/<\/?b>/g, '') + .matchAll(/<i>(?<who>.*?):<\/i>/g)) + .map(({groups: {who}}) => who), + }), }, - }; + + withResolvedReferenceList({ + list: '#artistRefs', + data: 'artistData', + into: '#artists', + find: find.artist, + }), + + { + flags: {expose: true}, + + expose: { + dependencies: ['#artists'], + compute: ({'#artists': artists}) => + unique(artists), + }, + }, + ]); } // Compositional utilities @@ -374,27 +406,24 @@ export function withResolvedContribs({ raise: {into: []}, }), - { - mapDependencies: {from}, - compute: ({from}, continuation) => - continuation({ - '#artistRefs': from.map(({who}) => who), - '#what': from.map(({what}) => what), - }), - }, + withPropertiesFromList({ + list: from, + properties: ['who', 'what'], + prefix: '#contribs', + }), withResolvedReferenceList({ - list: '#artistRefs', + list: '#contribs.who', data: 'artistData', - into: '#who', + into: '#contribs.who', find: find.artist, notFoundMode: 'null', }), { - dependencies: ['#who', '#what'], + dependencies: ['#contribs.who', '#contribs.what'], mapContinuation: {into}, - compute({'#who': who, '#what': what}, continuation) { + compute({'#contribs.who': who, '#contribs.what': what}, continuation) { filterMultipleArrays(who, what, (who, _what) => who); return continuation({ into: stitchArrays({who, what}), @@ -404,6 +433,23 @@ export function withResolvedContribs({ ]); } +// Shorthand for exiting if the contribution list (usually a property's update +// value) resolves to empty - ensuring that the later computed results are only +// returned if these contributions are present. +export function exitWithoutContribs({ + contribs, + value = null, +}) { + return compositeFrom(`exitWithoutContribs`, [ + withResolvedContribs({from: contribs}), + exitWithoutDependency({ + dependency: '#resolvedContribs', + mode: 'empty', + value, + }), + ]); +} + // Resolves a reference by using the provided find function to match it // within the provided thingData dependency. This will early exit if the // data dependency is null, or, if notFoundMode is set to 'exit', if the find @@ -480,29 +526,45 @@ export function withResolvedReferenceList({ }), { - options: {findFunction, notFoundMode}, mapDependencies: {list, data}, - mapContinuation: {matches: into}, + options: {findFunction}, - compute({list, data, '#options': {findFunction, notFoundMode}}, continuation) { - let matches = - list.map(ref => findFunction(ref, data, {mode: 'quiet'})); + compute: ({list, data, '#options': {findFunction}}, continuation) => + continuation({ + '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})), + }), + }, - if (matches.every(match => match)) { - return continuation.raise({matches}); - } + { + dependencies: ['#matches'], + mapContinuation: {into}, - switch (notFoundMode) { - case 'filter': - matches = matches.filter(match => match); - return continuation.raise({matches}); + compute: ({'#matches': matches}, continuation) => + (matches.every(match => match) + ? continuation.raise({into: matches}) + : continuation()), + }, + { + dependencies: ['#matches'], + options: {notFoundMode}, + mapContinuation: {into}, + + compute({ + '#matches': matches, + '#options': {notFoundMode}, + }, continuation) { + switch (notFoundMode) { case 'exit': return continuation.exit([]); + case 'filter': + matches = matches.filter(match => match); + return continuation.raise({into: matches}); + case 'null': matches = matches.map(match => match ?? null); - return continuation.raise({matches}); + return continuation.raise({into: matches}); } }, }, diff --git a/src/data/things/track.js b/src/data/things/track.js index 8263d399..a8d59023 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -11,6 +11,7 @@ import { exposeDependency, exposeDependencyOrContinue, exposeUpdateValueOrContinue, + withPropertyFromObject, withResultOfAvailabilityCheck, withUpdateValueAsDependency, } from '#composite'; @@ -19,7 +20,6 @@ import { isColor, isContributionList, isDate, - isDuration, isFileExtension, } from '#validators'; @@ -31,6 +31,7 @@ import Thing, { commentatorArtists, contributionList, directory, + duration, flag, name, referenceList, @@ -54,43 +55,23 @@ export class Track extends Thing { name: name('Unnamed Track'), directory: directory(), - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration}, - }, - + duration: duration(), urls: urls(), dateFirstReleased: simpleDate(), - artTags: referenceList({ - class: ArtTag, - find: find.artTag, - data: 'artTagData', - }), - - color: compositeFrom(`Track.color`, [ + color: [ exposeUpdateValueOrContinue(), - withContainingTrackSection(), - { - dependencies: ['#trackSection'], - compute: ({'#trackSection': trackSection}, continuation) => - // Album.trackSections guarantees the track section will have a - // color property (inheriting from the album's own color), but only - // if it's actually present! Color will be inherited directly from - // album otherwise. - (trackSection - ? trackSection.color - : continuation()), - }, - - withAlbumProperty({property: 'color'}), + withContainingTrackSection(), + withPropertyFromObject({object: '#trackSection', property: 'color'}), + exposeDependencyOrContinue({dependency: '#trackSection.color'}), + withPropertyFromAlbum({property: 'color'}), exposeDependency({ dependency: '#album.color', update: {validate: isColor}, }), - ]), + ], // Disables presenting the track as though it has its own unique artwork. // This flag should only be used in select circumstances, i.e. to override @@ -102,42 +83,43 @@ export class Track extends Thing { // track's unique cover artwork, if any, and does not inherit the extension // of the album's main artwork. It does inherit trackCoverArtFileExtension, // if present on the album. - coverArtFileExtension: compositeFrom(`Track.coverArtFileExtension`, [ - // No cover art file extension if the track doesn't have unique artwork - // in the first place. - withHasUniqueCoverArt(), - exitWithoutDependency({dependency: '#hasUniqueCoverArt', mode: 'falsy'}), + coverArtFileExtension: [ + exitWithoutUniqueCoverArt(), - // Expose custom coverArtFileExtension update value first. exposeUpdateValueOrContinue(), - // Expose album's trackCoverArtFileExtension if no update value set. - withAlbumProperty({property: 'trackCoverArtFileExtension'}), + withPropertyFromAlbum({property: 'trackCoverArtFileExtension'}), exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), - // Fallback to 'jpg'. exposeConstant({ value: 'jpg', update: {validate: isFileExtension}, }), - ]), + ], // 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: compositeFrom(`Track.coverArtDate`, [ + coverArtDate: [ withHasUniqueCoverArt(), exitWithoutDependency({dependency: '#hasUniqueCoverArt', mode: 'falsy'}), exposeUpdateValueOrContinue(), - withAlbumProperty({property: 'trackArtDate'}), + withPropertyFromAlbum({property: 'trackArtDate'}), exposeDependency({ dependency: '#album.trackArtDate', update: {validate: isDate}, }), - ]), + ], + + commentary: commentary(), + lyrics: simpleString(), + + additionalFiles: additionalFiles(), + sheetMusicFiles: additionalFiles(), + midiProjectFiles: additionalFiles(), originalReleaseTrack: singleReference({ class: Track, @@ -145,24 +127,74 @@ export class Track extends Thing { data: 'trackData', }), - // Note - this is an internal property used only to help identify a track. - // It should not be assumed in general that the album and dataSourceAlbum match - // (i.e. a track may dynamically be moved from one album to another, at - // which point dataSourceAlbum refers to where it was originally from, and is - // not generally relevant information). It's also not guaranteed that - // dataSourceAlbum is available (depending on the Track creator to optionally - // provide this property's update value). + // Internal use only - for directly identifying an album inside a track's + // util.inspect display, if it isn't indirectly available (by way of being + // included in an album's track list). dataSourceAlbum: singleReference({ class: Album, find: find.album, data: 'albumData', }), - commentary: commentary(), - lyrics: simpleString(), - additionalFiles: additionalFiles(), - sheetMusicFiles: additionalFiles(), - midiProjectFiles: additionalFiles(), + artistContribs: [ + inheritFromOriginalRelease({property: 'artistContribs'}), + + withUpdateValueAsDependency(), + withResolvedContribs({from: '#updateValue', into: '#artistContribs'}), + exposeDependencyOrContinue({dependency: '#artistContribs'}), + + withPropertyFromAlbum({property: 'artistContribs'}), + exposeDependency({ + dependency: '#album.artistContribs', + update: {validate: isContributionList}, + }), + ], + + contributorContribs: [ + inheritFromOriginalRelease({property: 'contributorContribs'}), + contributionList(), + ], + + // Cover artists aren't inherited from the original release, since it + // typically varies by release and isn't defined by the musical qualities + // of the track. + coverArtistContribs: [ + exitWithoutUniqueCoverArt(), + + withUpdateValueAsDependency(), + withResolvedContribs({from: '#updateValue', into: '#coverArtistContribs'}), + exposeDependencyOrContinue({dependency: '#coverArtistContribs'}), + + withPropertyFromAlbum({property: 'trackCoverArtistContribs'}), + exposeDependency({ + dependency: '#album.trackCoverArtistContribs', + update: {validate: isContributionList}, + }), + ], + + referencedTracks: [ + inheritFromOriginalRelease({property: 'referencedTracks'}), + referenceList({ + class: Track, + find: find.track, + data: 'trackData', + }), + ], + + sampledTracks: [ + inheritFromOriginalRelease({property: 'sampledTracks'}), + referenceList({ + class: Track, + find: find.track, + data: 'trackData', + }), + ], + + artTags: referenceList({ + class: ArtTag, + find: find.artTag, + data: 'artTagData', + }), // Update only @@ -176,16 +208,16 @@ export class Track extends Thing { commentatorArtists: commentatorArtists(), - album: compositeFrom(`Track.album`, [ + album: [ withAlbum(), exposeDependency({dependency: '#album'}), - ]), + ], - date: compositeFrom(`Track.date`, [ + date: [ exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), - withAlbumProperty({property: 'date'}), + withPropertyFromAlbum({property: 'date'}), exposeDependency({dependency: '#album.date'}), - ]), + ], // Whether or not the track has "unique" cover artwork - a cover which is // specifically associated with this track in particular, rather than with @@ -194,12 +226,12 @@ export class Track extends Thing { // or a placeholder. (This property is named hasUniqueCoverArt instead of // the usual hasCoverArt to emphasize that it does not inherit from the // album.) - hasUniqueCoverArt: compositeFrom(`Track.hasUniqueCoverArt`, [ + hasUniqueCoverArt: [ withHasUniqueCoverArt(), exposeDependency({dependency: '#hasUniqueCoverArt'}), - ]), + ], - otherReleases: compositeFrom(`Track.otherReleases`, [ + otherReleases: [ exitWithoutDependency({dependency: 'trackData', mode: 'empty'}), withOriginalRelease({selfIfOriginal: true}), @@ -221,67 +253,7 @@ export class Track extends Thing { track.originalReleaseTrack === originalRelease)), }, }, - ]), - - artistContribs: compositeFrom(`Track.artistContribs`, [ - inheritFromOriginalRelease({property: 'artistContribs'}), - - withUpdateValueAsDependency(), - withResolvedContribs({from: '#updateValue', into: '#artistContribs'}), - exposeDependencyOrContinue({dependency: '#artistContribs'}), - - withAlbumProperty({property: 'artistContribs'}), - exposeDependency({ - dependency: '#album.artistContribs', - update: {validate: isContributionList}, - }), - ]), - - contributorContribs: compositeFrom(`Track.contributorContribs`, [ - inheritFromOriginalRelease({property: 'contributorContribs'}), - contributionList(), - ]), - - // Cover artists aren't inherited from the original release, since it - // typically varies by release and isn't defined by the musical qualities - // of the track. - coverArtistContribs: compositeFrom(`Track.coverArtistContribs`, [ - { - dependencies: ['disableUniqueCoverArt'], - compute: ({disableUniqueCoverArt}, continuation) => - (disableUniqueCoverArt - ? null - : continuation()), - }, - - withUpdateValueAsDependency(), - withResolvedContribs({from: '#updateValue', into: '#coverArtistContribs'}), - exposeDependencyOrContinue({dependency: '#coverArtistContribs'}), - - withAlbumProperty({property: 'trackCoverArtistContribs'}), - exposeDependency({ - dependency: '#album.trackCoverArtistContribs', - update: {validate: isContributionList}, - }), - ]), - - referencedTracks: compositeFrom(`Track.referencedTracks`, [ - inheritFromOriginalRelease({property: 'referencedTracks'}), - referenceList({ - class: Track, - find: find.track, - data: 'trackData', - }), - ]), - - sampledTracks: compositeFrom(`Track.sampledTracks`, [ - inheritFromOriginalRelease({property: 'sampledTracks'}), - referenceList({ - class: Track, - find: find.track, - data: 'trackData', - }), - ]), + ], // Specifically exclude re-releases from this list - while it's useful to // get from a re-release to the tracks it references, re-releases aren't @@ -430,66 +402,14 @@ function withAlbum({ // property name prefixed with '#album.' (by default). If the track's album // isn't available, then by default, the property will be provided as null; // set {notFoundMode: 'exit'} to early exit instead. -function withAlbumProperty({ +function withPropertyFromAlbum({ property, into = '#album.' + property, notFoundMode = 'null', }) { - return compositeFrom(`withAlbumProperty`, [ - withAlbum({notFoundMode}), - - { - dependencies: ['#album'], - options: {property}, - mapContinuation: {into}, - - compute: ({ - '#album': album, - '#options': {property}, - }, continuation) => - (album - ? continuation.raise({into: album[property]}) - : continuation.raise({into: null})), - }, - ]); -} - -// Gets the listed properties from this track's album, providing them as -// dependencies (by default) with '#album.' prefixed before each property -// name. If the track's album isn't available, then by default, the same -// dependency names will be provided as null; set {notFoundMode: 'exit'} -// to early exit instead. -function withAlbumProperties({ - properties, - prefix = '#album', - notFoundMode = 'null', -}) { - return compositeFrom(`withAlbumProperties`, [ + return compositeFrom(`withPropertyFromAlbum`, [ withAlbum({notFoundMode}), - - { - dependencies: ['#album'], - options: {properties, prefix}, - - compute({ - '#album': album, - '#options': {properties, prefix}, - }, continuation) { - const raise = {}; - - if (album) { - for (const property of properties) { - raise[prefix + '.' + property] = album[property]; - } - } else { - for (const property of properties) { - raise[prefix + '.' + property] = null; - } - } - - return continuation.raise(raise); - }, - }, + withPropertyFromObject({object: '#album', property, into}), ]); } @@ -505,7 +425,7 @@ function withContainingTrackSection({ } return compositeFrom(`withContainingTrackSection`, [ - withAlbumProperty({property: 'trackSections', notFoundMode}), + withPropertyFromAlbum({property: 'trackSections', notFoundMode}), { dependencies: ['this', '#album.trackSections'], @@ -604,7 +524,7 @@ function withHasUniqueCoverArt({ : continuation.raise({into: true})), }, - withAlbumProperty({property: 'trackCoverArtistContribs'}), + withPropertyFromAlbum({property: 'trackCoverArtistContribs'}), { dependencies: ['#album.trackCoverArtistContribs'], @@ -617,6 +537,21 @@ function withHasUniqueCoverArt({ ]); } +// Shorthand for checking if the track has unique cover art and exposing a +// fallback value if it isn't. +function exitWithoutUniqueCoverArt({ + value = null, +} = {}) { + return compositeFrom(`exitWithoutUniqueCoverArt`, [ + withHasUniqueCoverArt(), + exitWithoutDependency({ + dependency: '#hasUniqueCoverArt', + mode: 'falsy', + value, + }), + ]); +} + function trackReverseReferenceList({ property: refListProperty, }) { |