diff options
author | (quasar) nebula <qznebula@protonmail.com> | 2023-10-29 09:26:59 -0300 |
---|---|---|
committer | (quasar) nebula <qznebula@protonmail.com> | 2023-10-29 09:26:59 -0300 |
commit | bfa1953e79a562ee675940b7acc52b5b29d22d8f (patch) | |
tree | 5c1cd2f4050c801a60f4b65b367a714ed0979759 /src/data/things | |
parent | c4ef4ced62d659d217873c6c48dd8038dbf765af (diff) | |
parent | 940b2cbf8b68eb0b446cca0feeb507840c486394 (diff) |
Merge branch 'preview' into listing-tweaks
Diffstat (limited to 'src/data/things')
-rw-r--r-- | src/data/things/album.js | 280 | ||||
-rw-r--r-- | src/data/things/art-tag.js | 48 | ||||
-rw-r--r-- | src/data/things/artist.js | 95 | ||||
-rw-r--r-- | src/data/things/cacheable-object.js | 82 | ||||
-rw-r--r-- | src/data/things/composite.js | 1301 | ||||
-rw-r--r-- | src/data/things/flash.js | 136 | ||||
-rw-r--r-- | src/data/things/group.js | 74 | ||||
-rw-r--r-- | src/data/things/homepage-layout.js | 113 | ||||
-rw-r--r-- | src/data/things/index.js | 29 | ||||
-rw-r--r-- | src/data/things/language.js | 170 | ||||
-rw-r--r-- | src/data/things/news-entry.js | 16 | ||||
-rw-r--r-- | src/data/things/static-page.js | 23 | ||||
-rw-r--r-- | src/data/things/thing.js | 394 | ||||
-rw-r--r-- | src/data/things/track.js | 718 | ||||
-rw-r--r-- | src/data/things/validators.js | 89 | ||||
-rw-r--r-- | src/data/things/wiki-info.js | 55 |
16 files changed, 2327 insertions, 1296 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js index c012c243..546fda3b 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,163 +1,143 @@ -import {empty} from '#sugar'; +import {input} from '#composite'; import find from '#find'; +import {isDate} from '#validators'; + +import {exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {exitWithoutContribs} from '#composite/wiki-data'; + +import { + additionalFiles, + commentary, + color, + commentatorArtists, + contribsPresent, + contributionList, + dimensions, + directory, + fileExtension, + flag, + name, + referenceList, + simpleDate, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import { + withTracks, + withTrackSections, +} from '#composite/things/album'; import Thing from './thing.js'; export class Album extends Thing { static [Thing.referenceType] = 'album'; - static [Thing.getPropertyDescriptors] = ({ - ArtTag, - Artist, - Group, - Track, - - validators: { - isDate, - isDimensions, - isTrackSectionList, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({ // Update & expose - name: Thing.common.name('Unnamed Album'), - color: Thing.common.color(), - directory: Thing.common.directory(), - urls: Thing.common.urls(), - - date: Thing.common.simpleDate(), - trackArtDate: Thing.common.simpleDate(), - dateAddedToWiki: Thing.common.simpleDate(), - - coverArtDate: { - flags: {update: true, expose: true}, - - update: {validate: isDate}, - - expose: { - dependencies: ['date', 'coverArtistContribsByRef'], - transform: (coverArtDate, { - coverArtistContribsByRef, - date, - }) => - (!empty(coverArtistContribsByRef) - ? coverArtDate ?? date ?? null - : null), - }, - }, - - artistContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), - trackCoverArtistContribsByRef: Thing.common.contribsByRef(), - wallpaperArtistContribsByRef: Thing.common.contribsByRef(), - bannerArtistContribsByRef: Thing.common.contribsByRef(), - - groupsByRef: Thing.common.referenceList(Group), - artTagsByRef: Thing.common.referenceList(ArtTag), - - trackSections: { - flags: {update: true, expose: true}, - - update: { - validate: isTrackSectionList, - }, - - expose: { - dependencies: ['color', 'trackData'], - transform(trackSections, { - color: albumColor, - trackData, - }) { - let startIndex = 0; - return trackSections?.map(section => ({ - name: section.name ?? null, - color: section.color ?? albumColor ?? null, - dateOriginallyReleased: section.dateOriginallyReleased ?? null, - isDefaultTrackSection: section.isDefaultTrackSection ?? false, - - startIndex: ( - startIndex += section.tracksByRef.length, - startIndex - section.tracksByRef.length - ), - - tracksByRef: section.tracksByRef ?? [], - tracks: - (trackData && section.tracksByRef - ?.map(ref => find.track(ref, trackData, {mode: 'quiet'})) - .filter(Boolean)) ?? - [], - })); - }, - }, - }, - - coverArtFileExtension: Thing.common.fileExtension('jpg'), - trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), - - wallpaperStyle: Thing.common.simpleString(), - wallpaperFileExtension: Thing.common.fileExtension('jpg'), - - bannerStyle: Thing.common.simpleString(), - bannerFileExtension: Thing.common.fileExtension('jpg'), - bannerDimensions: { - flags: {update: true, expose: true}, - update: {validate: isDimensions}, - }, - - hasTrackNumbers: Thing.common.flag(true), - isListedOnHomepage: Thing.common.flag(true), - isListedInGalleries: Thing.common.flag(true), - - commentary: Thing.common.commentary(), - additionalFiles: Thing.common.additionalFiles(), + name: name('Unnamed Album'), + color: color(), + directory: directory(), + urls: urls(), + + date: simpleDate(), + trackArtDate: simpleDate(), + dateAddedToWiki: simpleDate(), + + coverArtDate: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + exposeDependency({dependency: 'date'}), + ], + + coverArtFileExtension: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + fileExtension('jpg'), + ], + + trackCoverArtFileExtension: fileExtension('jpg'), + + wallpaperFileExtension: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + fileExtension('jpg'), + ], + + bannerFileExtension: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + fileExtension('jpg'), + ], + + wallpaperStyle: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + simpleString(), + ], + + bannerStyle: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + simpleString(), + ], + + bannerDimensions: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + dimensions(), + ], + + hasTrackNumbers: flag(true), + isListedOnHomepage: flag(true), + isListedInGalleries: flag(true), + + commentary: commentary(), + additionalFiles: additionalFiles(), + + trackSections: [ + withTrackSections(), + exposeDependency({dependency: '#trackSections'}), + ], + + artistContribs: contributionList(), + coverArtistContribs: contributionList(), + trackCoverArtistContribs: contributionList(), + wallpaperArtistContribs: contributionList(), + bannerArtistContribs: contributionList(), + + groups: referenceList({ + class: input.value(Group), + find: input.value(find.group), + data: 'groupData', + }), + + artTags: referenceList({ + class: input.value(ArtTag), + find: input.value(find.artTag), + data: 'artTagData', + }), // Update only - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - groupData: Thing.common.wikiData(Group), - trackData: Thing.common.wikiData(Track), + artistData: wikiData(Artist), + artTagData: wikiData(ArtTag), + groupData: wikiData(Group), + trackData: wikiData(Track), // Expose only - artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), - coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), - trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'), - wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'), - bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'), - - commentatorArtists: Thing.common.commentatorArtists(), - - hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'), - hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'), - hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'), - - tracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackSections', 'trackData'], - compute: ({trackSections, trackData}) => - trackSections && trackData - ? trackSections - .flatMap((section) => section.tracksByRef ?? []) - .map((ref) => find.track(ref, trackData, {mode: 'quiet'})) - .filter(Boolean) - : [], - }, - }, - - groups: Thing.common.dynamicThingsFromReferenceList( - 'groupsByRef', - 'groupData', - find.group - ), - - artTags: Thing.common.dynamicThingsFromReferenceList( - 'artTagsByRef', - 'artTagData', - find.artTag - ), + commentatorArtists: commentatorArtists(), + + hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), + hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), + + tracks: [ + withTracks(), + exposeDependency({dependency: '#tracks'}), + ], }); static [Thing.getSerializeDescriptors] = ({ @@ -201,10 +181,12 @@ export class Album extends Thing { } export class TrackSectionHelper extends Thing { + static [Thing.friendlyName] = `Track Section`; + static [Thing.getPropertyDescriptors] = () => ({ - name: Thing.common.name('Unnamed Track Group'), - color: Thing.common.color(), - dateOriginallyReleased: Thing.common.simpleDate(), - isDefaultTrackGroup: Thing.common.flag(false), + name: name('Unnamed Track Section'), + color: color(), + dateOriginallyReleased: simpleDate(), + isDefaultTrackGroup: flag(false), }) } diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index c103c4d5..6503beec 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,35 +1,47 @@ +import {input} from '#composite'; import {sortAlbumsTracksChronologically} from '#wiki-data'; +import {isName} from '#validators'; + +import {exposeUpdateValueOrContinue} from '#composite/control-flow'; + +import { + color, + directory, + flag, + name, + wikiData, +} from '#composite/wiki-properties'; import Thing from './thing.js'; export class ArtTag extends Thing { static [Thing.referenceType] = 'tag'; + static [Thing.friendlyName] = `Art Tag`; - static [Thing.getPropertyDescriptors] = ({ - Album, - Track, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ // Update & expose - name: Thing.common.name('Unnamed Art Tag'), - directory: Thing.common.directory(), - color: Thing.common.color(), - isContentWarning: Thing.common.flag(false), + name: name('Unnamed Art Tag'), + directory: directory(), + color: color(), + isContentWarning: flag(false), - nameShort: { - flags: {update: true, expose: true}, + nameShort: [ + exposeUpdateValueOrContinue({ + validate: input.value(isName), + }), - expose: { + { dependencies: ['name'], - transform: (value, {name}) => - value ?? name.replace(/ \(.*?\)$/, ''), + compute: ({name}) => + name.replace(/ \([^)]*?\)$/, ''), }, - }, + ], // Update only - albumData: Thing.common.wikiData(Album), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + trackData: wikiData(Track), // Expose only @@ -37,8 +49,8 @@ export class ArtTag extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData', 'trackData'], - compute: ({albumData, trackData, [ArtTag.instance]: artTag}) => + dependencies: ['this', 'albumData', 'trackData'], + compute: ({this: artTag, albumData, trackData}) => sortAlbumsTracksChronologically( [...albumData, ...trackData] .filter(({artTags}) => artTags.includes(artTag)), diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 6d4f4a0d..1b313db6 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -1,29 +1,33 @@ +import {input} from '#composite'; import find from '#find'; +import {isName, validateArrayItems} from '#validators'; + +import { + directory, + fileExtension, + flag, + name, + simpleString, + singleReference, + urls, + wikiData, +} from '#composite/wiki-properties'; import Thing from './thing.js'; export class Artist extends Thing { static [Thing.referenceType] = 'artist'; - static [Thing.getPropertyDescriptors] = ({ - Album, - Flash, - Track, - - validators: { - isName, - validateArrayItems, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({ // Update & expose - name: Thing.common.name('Unnamed Artist'), - directory: Thing.common.directory(), - urls: Thing.common.urls(), - contextNotes: Thing.common.simpleString(), + name: name('Unnamed Artist'), + directory: directory(), + urls: urls(), + contextNotes: simpleString(), - hasAvatar: Thing.common.flag(false), - avatarFileExtension: Thing.common.fileExtension('jpg'), + hasAvatar: flag(false), + avatarFileExtension: fileExtension('jpg'), aliasNames: { flags: {update: true, expose: true}, @@ -31,30 +35,23 @@ export class Artist extends Thing { expose: {transform: (names) => names ?? []}, }, - isAlias: Thing.common.flag(), - aliasedArtistRef: Thing.common.singleReference(Artist), + isAlias: flag(), + + aliasedArtist: singleReference({ + class: input.value(Artist), + find: input.value(find.artist), + data: 'artistData', + }), // Update only - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + artistData: wikiData(Artist), + flashData: wikiData(Flash), + trackData: wikiData(Track), // Expose only - aliasedArtist: { - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'aliasedArtistRef'], - compute: ({artistData, aliasedArtistRef}) => - aliasedArtistRef && artistData - ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'}) - : null, - }, - }, - tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'), tracksAsContributor: @@ -66,14 +63,14 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Artist.instance]: artist}) => + compute: ({this: artist, trackData}) => trackData?.filter((track) => [ - ...track.artistContribs, - ...track.contributorContribs, - ...track.coverArtistContribs, + ...track.artistContribs ?? [], + ...track.contributorContribs ?? [], + ...track.coverArtistContribs ?? [], ].some(({who}) => who === artist)) ?? [], }, }, @@ -82,9 +79,9 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Artist.instance]: artist}) => + compute: ({this: artist, trackData}) => trackData?.filter(({commentatorArtists}) => commentatorArtists.includes(artist)) ?? [], }, @@ -120,18 +117,16 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData'], + dependencies: ['this', 'albumData'], - compute: ({albumData, [Artist.instance]: artist}) => + compute: ({this: artist, albumData}) => albumData?.filter(({commentatorArtists}) => commentatorArtists.includes(artist)) ?? [], }, }, - flashesAsContributor: Artist.filterByContrib( - 'flashData', - 'contributorContribs' - ), + flashesAsContributor: + Artist.filterByContrib('flashData', 'contributorContribs'), }); static [Thing.getSerializeDescriptors] = ({ @@ -165,15 +160,15 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: [thingDataProperty], + dependencies: ['this', thingDataProperty], compute: ({ + this: artist, [thingDataProperty]: thingData, - [Artist.instance]: artist }) => thingData?.filter(thing => thing[contribsProperty] - .some(contrib => contrib.who === artist)) ?? [], + ?.some(contrib => contrib.who === artist)) ?? [], }, }); } diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js index ea705a61..9fda865e 100644 --- a/src/data/things/cacheable-object.js +++ b/src/data/things/cacheable-object.js @@ -76,28 +76,24 @@ import {inspect as nodeInspect} from 'node:util'; -import {color, ENABLE_COLOR} from '#cli'; +import {colors, ENABLE_COLOR} from '#cli'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); } export default class CacheableObject { - static instance = Symbol('CacheableObject `this` instance'); - #propertyUpdateValues = Object.create(null); #propertyUpdateCacheInvalidators = Object.create(null); - /* - // Note the constructor doesn't take an initial data source. Due to a quirk - // of JavaScript, private members can't be accessed before the superclass's - // constructor is finished processing - so if we call the overridden - // update() function from inside this constructor, it will error when - // writing to private members. Pretty bad! - // - // That means initial data must be provided by following up with update() - // after constructing the new instance of the Thing (sub)class. - */ + // Note the constructor doesn't take an initial data source. Due to a quirk + // of JavaScript, private members can't be accessed before the superclass's + // constructor is finished processing - so if we call the overridden + // update() function from inside this constructor, it will error when + // writing to private members. Pretty bad! + // + // That means initial data must be provided by following up with update() + // after constructing the new instance of the Thing (sub)class. constructor() { this.#defineProperties(); @@ -143,7 +139,7 @@ export default class CacheableObject { const definition = { configurable: false, - enumerable: true, + enumerable: flags.expose, }; if (flags.update) { @@ -183,13 +179,8 @@ export default class CacheableObject { } else if (result !== true) { throw new TypeError(`Validation failed for value ${newValue}`); } - } catch (error) { - error.message = [ - `Property ${color.green(property)}`, - `(${inspect(this[property])} -> ${inspect(newValue)}):`, - error.message - ].join(' '); - throw error; + } catch (caughtError) { + throw new CacheableObjectPropertyValueError(property, this[property], newValue, caughtError); } } @@ -250,20 +241,27 @@ export default class CacheableObject { let getAllDependencies; - const dependencyKeys = expose.dependencies; - if (dependencyKeys?.length > 0) { - const reflectionEntry = [this.constructor.instance, this]; - const dependencyGetters = dependencyKeys - .map(key => () => [key, this.#propertyUpdateValues[key]]); + if (expose.dependencies?.length > 0) { + const dependencyKeys = expose.dependencies.slice(); + const shouldReflect = dependencyKeys.includes('this'); + + getAllDependencies = () => { + const dependencies = Object.create(null); + + for (const key of dependencyKeys) { + dependencies[key] = this.#propertyUpdateValues[key]; + } + + if (shouldReflect) { + dependencies.this = this; + } - getAllDependencies = () => - Object.fromEntries(dependencyGetters - .map(f => f()) - .concat([reflectionEntry])); + return dependencies; + }; } else { - const allDependencies = {[this.constructor.instance]: this}; - Object.freeze(allDependencies); - getAllDependencies = () => allDependencies; + const dependencies = Object.create(null); + Object.freeze(dependencies); + getAllDependencies = () => dependencies; } if (flags.update) { @@ -347,4 +345,22 @@ export default class CacheableObject { console.log(` - ${line}`); } } + + static getUpdateValue(object, key) { + if (!Object.hasOwn(object, key)) { + return undefined; + } + + return object.#propertyUpdateValues[key] ?? null; + } +} + +export class CacheableObjectPropertyValueError extends Error { + constructor(property, oldValue, newValue, error) { + super( + `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`, + {cause: error}); + + this.property = property; + } } diff --git a/src/data/things/composite.js b/src/data/things/composite.js new file mode 100644 index 00000000..51525bc1 --- /dev/null +++ b/src/data/things/composite.js @@ -0,0 +1,1301 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {TupleMap} from '#wiki-data'; +import {a} from '#validators'; + +import { + decorateErrorWithIndex, + empty, + filterProperties, + openAggregate, + stitchArrays, + typeAppearance, + unique, + withAggregate, +} from '#sugar'; + +const globalCompositeCache = {}; + +const _valueIntoToken = shape => + (value = null) => + (value === null + ? Symbol.for(`hsmusic.composite.${shape}`) + : typeof value === 'string' + ? Symbol.for(`hsmusic.composite.${shape}:${value}`) + : { + symbol: Symbol.for(`hsmusic.composite.input`), + shape, + value, + }); + +export const input = _valueIntoToken('input'); +input.symbol = Symbol.for('hsmusic.composite.input'); + +input.value = _valueIntoToken('input.value'); +input.dependency = _valueIntoToken('input.dependency'); + +input.myself = () => Symbol.for(`hsmusic.composite.input.myself`); + +input.updateValue = _valueIntoToken('input.updateValue'); + +input.staticDependency = _valueIntoToken('input.staticDependency'); +input.staticValue = _valueIntoToken('input.staticValue'); + +function isInputToken(token) { + if (token === null) { + return false; + } else if (typeof token === 'object') { + return token.symbol === Symbol.for('hsmusic.composite.input'); + } else if (typeof token === 'symbol') { + return token.description.startsWith('hsmusic.composite.input'); + } else { + return false; + } +} + +function getInputTokenShape(token) { + if (!isInputToken(token)) { + throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); + } + + if (typeof token === 'object') { + return token.shape; + } else { + return token.description.match(/hsmusic\.composite\.(input.*?)(:|$)/)[1]; + } +} + +function getInputTokenValue(token) { + if (!isInputToken(token)) { + throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); + } + + if (typeof token === 'object') { + return token.value; + } else { + return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null; + } +} + +function getStaticInputMetadata(inputOptions) { + const metadata = {}; + + for (const [name, token] of Object.entries(inputOptions)) { + if (typeof token === 'string') { + metadata[input.staticDependency(name)] = token; + metadata[input.staticValue(name)] = null; + } else if (isInputToken(token)) { + const tokenShape = getInputTokenShape(token); + const tokenValue = getInputTokenValue(token); + + metadata[input.staticDependency(name)] = + (tokenShape === 'input.dependency' + ? tokenValue + : null); + + metadata[input.staticValue(name)] = + (tokenShape === 'input.value' + ? tokenValue + : null); + } else { + metadata[input.staticDependency(name)] = null; + metadata[input.staticValue(name)] = null; + } + } + + return metadata; +} + +function getCompositionName(description) { + return ( + (description.annotation + ? description.annotation + : `unnamed composite`)); +} + +function validateInputValue(value, description) { + const tokenValue = getInputTokenValue(description); + + const {acceptsNull, defaultValue, type, validate} = tokenValue || {}; + + if (value === null || value === undefined) { + if (acceptsNull || defaultValue === null) { + return true; + } else { + throw new TypeError( + (type + ? `Expected ${a(type)}, got ${typeAppearance(value)}` + : `Expected a value, got ${typeAppearance(value)}`)); + } + } + + if (type) { + // Note: null is already handled earlier in this function, so it won't + // cause any trouble here. + const typeofValue = + (typeof value === 'object' + ? Array.isArray(value) ? 'array' : 'object' + : typeof value); + + if (typeofValue !== type) { + throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); + } + } + + if (validate) { + validate(value); + } + + return true; +} + +export function templateCompositeFrom(description) { + const compositionName = getCompositionName(description); + + withAggregate({message: `Errors in description for ${compositionName}`}, ({map, nest, push}) => { + if ('steps' in description) { + if (Array.isArray(description.steps)) { + push(new TypeError(`Wrap steps array in a function`)); + } else if (typeof description.steps !== 'function') { + push(new TypeError(`Expected steps to be a function (returning an array)`)); + } + } + + validateInputs: + if ('inputs' in description) { + if ( + Array.isArray(description.inputs) || + typeof description.inputs !== 'object' + ) { + push(new Error(`Expected inputs to be object, got ${typeAppearance(description.inputs)}`)); + break validateInputs; + } + + nest({message: `Errors in static input descriptions for ${compositionName}`}, ({push}) => { + const missingCallsToInput = []; + const wrongCallsToInput = []; + + for (const [name, value] of Object.entries(description.inputs)) { + if (!isInputToken(value)) { + missingCallsToInput.push(name); + continue; + } + + if (!['input', 'input.staticDependency', 'input.staticValue'].includes(getInputTokenShape(value))) { + wrongCallsToInput.push(name); + } + } + + for (const name of missingCallsToInput) { + push(new Error(`${name}: Missing call to input()`)); + } + + for (const name of wrongCallsToInput) { + const shape = getInputTokenShape(description.inputs[name]); + push(new Error(`${name}: Expected call to input, input.staticDependency, or input.staticValue, got ${shape}`)); + } + }); + } + + validateOutputs: + if ('outputs' in description) { + if ( + !Array.isArray(description.outputs) && + typeof description.outputs !== 'function' + ) { + push(new Error(`Expected outputs to be array or function, got ${typeAppearance(description.outputs)}`)); + break validateOutputs; + } + + if (Array.isArray(description.outputs)) { + map( + description.outputs, + decorateErrorWithIndex(value => { + if (typeof value !== 'string') { + throw new Error(`${value}: Expected string, got ${typeAppearance(value)}`) + } else if (!value.startsWith('#')) { + throw new Error(`${value}: Expected "#" at start`); + } + }), + {message: `Errors in output descriptions for ${compositionName}`}); + } + } + }); + + const expectedInputNames = + (description.inputs + ? Object.keys(description.inputs) + : []); + + const instantiate = (inputOptions = {}) => { + withAggregate({message: `Errors in input options passed to ${compositionName}`}, ({push}) => { + const providedInputNames = Object.keys(inputOptions); + + const misplacedInputNames = + providedInputNames + .filter(name => !expectedInputNames.includes(name)); + + const missingInputNames = + expectedInputNames + .filter(name => !providedInputNames.includes(name)) + .filter(name => { + const inputDescription = getInputTokenValue(description.inputs[name]); + if (!inputDescription) return true; + if ('defaultValue' in inputDescription) return false; + if ('defaultDependency' in inputDescription) return false; + return true; + }); + + const wrongTypeInputNames = []; + + const expectedStaticValueInputNames = []; + const expectedStaticDependencyInputNames = []; + const expectedValueProvidingTokenInputNames = []; + + const validateFailedErrors = []; + + for (const [name, value] of Object.entries(inputOptions)) { + if (misplacedInputNames.includes(name)) { + continue; + } + + if (typeof value !== 'string' && !isInputToken(value)) { + wrongTypeInputNames.push(name); + continue; + } + + const descriptionShape = getInputTokenShape(description.inputs[name]); + + const tokenShape = (isInputToken(value) ? getInputTokenShape(value) : null); + const tokenValue = (isInputToken(value) ? getInputTokenValue(value) : null); + + switch (descriptionShape) { + case'input.staticValue': + if (tokenShape !== 'input.value') { + expectedStaticValueInputNames.push(name); + continue; + } + break; + + case 'input.staticDependency': + if (typeof value !== 'string' && tokenShape !== 'input.dependency') { + expectedStaticDependencyInputNames.push(name); + continue; + } + break; + + case 'input': + if (typeof value !== 'string' && ![ + 'input', + 'input.value', + 'input.dependency', + 'input.myself', + 'input.updateValue', + ].includes(tokenShape)) { + expectedValueProvidingTokenInputNames.push(name); + continue; + } + break; + } + + if (tokenShape === 'input.value') { + try { + validateInputValue(tokenValue, description.inputs[name]); + } catch (error) { + error.message = `${name}: ${error.message}`; + validateFailedErrors.push(error); + } + } + } + + if (!empty(misplacedInputNames)) { + push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`)); + } + + if (!empty(missingInputNames)) { + push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`)); + } + + const inputAppearance = name => + (isInputToken(inputOptions[name]) + ? `${getInputTokenShape(inputOptions[name])}() call` + : `dependency name`); + + for (const name of expectedStaticDependencyInputNames) { + const appearance = inputAppearance(name); + push(new Error(`${name}: Expected dependency name, got ${appearance}`)); + } + + for (const name of expectedStaticValueInputNames) { + const appearance = inputAppearance(name) + push(new Error(`${name}: Expected input.value() call, got ${appearance}`)); + } + + for (const name of expectedValueProvidingTokenInputNames) { + const appearance = getInputTokenShape(inputOptions[name]); + push(new Error(`${name}: Expected dependency name or value-providing input() call, got ${appearance}`)); + } + + for (const name of wrongTypeInputNames) { + const type = typeAppearance(inputOptions[name]); + push(new Error(`${name}: Expected dependency name or input() call, got ${type}`)); + } + + for (const error of validateFailedErrors) { + push(error); + } + }); + + const inputMetadata = getStaticInputMetadata(inputOptions); + + const expectedOutputNames = + (Array.isArray(description.outputs) + ? description.outputs + : typeof description.outputs === 'function' + ? description.outputs(inputMetadata) + .map(name => + (name.startsWith('#') + ? name + : '#' + name)) + : []); + + const ownUpdateDescription = + (typeof description.update === 'object' + ? description.update + : typeof description.update === 'function' + ? description.update(inputMetadata) + : null); + + const outputOptions = {}; + + const instantiatedTemplate = { + symbol: templateCompositeFrom.symbol, + + outputs(providedOptions) { + withAggregate({message: `Errors in output options passed to ${compositionName}`}, ({push}) => { + const misplacedOutputNames = []; + const wrongTypeOutputNames = []; + + for (const [name, value] of Object.entries(providedOptions)) { + if (!expectedOutputNames.includes(name)) { + misplacedOutputNames.push(name); + continue; + } + + if (typeof value !== 'string') { + wrongTypeOutputNames.push(name); + continue; + } + } + + if (!empty(misplacedOutputNames)) { + push(new Error(`Unexpected output names: ${misplacedOutputNames.join(', ')}`)); + } + + for (const name of wrongTypeOutputNames) { + const appearance = typeAppearance(providedOptions[name]); + push(new Error(`${name}: Expected string, got ${appearance}`)); + } + }); + + Object.assign(outputOptions, providedOptions); + return instantiatedTemplate; + }, + + toDescription() { + const finalDescription = {}; + + if ('annotation' in description) { + finalDescription.annotation = description.annotation; + } + + if ('compose' in description) { + finalDescription.compose = description.compose; + } + + if (ownUpdateDescription) { + finalDescription.update = ownUpdateDescription; + } + + if ('inputs' in description) { + const inputMapping = {}; + + for (const [name, token] of Object.entries(description.inputs)) { + const tokenValue = getInputTokenValue(token); + if (name in inputOptions) { + if (typeof inputOptions[name] === 'string') { + inputMapping[name] = input.dependency(inputOptions[name]); + } else { + inputMapping[name] = inputOptions[name]; + } + } else if (tokenValue.defaultValue) { + inputMapping[name] = input.value(tokenValue.defaultValue); + } else if (tokenValue.defaultDependency) { + inputMapping[name] = input.dependency(tokenValue.defaultDependency); + } else { + inputMapping[name] = input.value(null); + } + } + + finalDescription.inputMapping = inputMapping; + finalDescription.inputDescriptions = description.inputs; + } + + if ('outputs' in description) { + const finalOutputs = {}; + + for (const name of expectedOutputNames) { + if (name in outputOptions) { + finalOutputs[name] = outputOptions[name]; + } else { + finalOutputs[name] = name; + } + } + + finalDescription.outputs = finalOutputs; + } + + if ('steps' in description) { + finalDescription.steps = description.steps; + } + + return finalDescription; + }, + + toResolvedComposition() { + const ownDescription = instantiatedTemplate.toDescription(); + + const finalDescription = {...ownDescription}; + + const aggregate = openAggregate({message: `Errors resolving ${compositionName}`}); + + const steps = ownDescription.steps(); + + const resolvedSteps = + aggregate.map( + steps, + decorateErrorWithIndex(step => + (step.symbol === templateCompositeFrom.symbol + ? compositeFrom(step.toResolvedComposition()) + : step)), + {message: `Errors resolving steps`}); + + aggregate.close(); + + finalDescription.steps = resolvedSteps; + + return finalDescription; + }, + }; + + return instantiatedTemplate; + }; + + instantiate.inputs = instantiate; + + return instantiate; +} + +templateCompositeFrom.symbol = Symbol(); + +export const continuationSymbol = Symbol.for('compositeFrom: continuation symbol'); +export const noTransformSymbol = Symbol.for('compositeFrom: no-transform symbol'); + +export function compositeFrom(description) { + const {annotation} = description; + const compositionName = getCompositionName(description); + + const debug = fn => { + if (compositeFrom.debug === true) { + const label = + (annotation + ? colors.dim(`[composite: ${annotation}]`) + : colors.dim(`[composite]`)); + const result = fn(); + if (Array.isArray(result)) { + console.log(label, ...result.map(value => + (typeof value === 'object' + ? inspect(value, {depth: 1, colors: true, compact: true, breakLength: Infinity}) + : value))); + } else { + console.log(label, result); + } + } + }; + + if (!Array.isArray(description.steps)) { + throw new TypeError( + `Expected steps to be array, got ${typeAppearance(description.steps)}` + + (annotation ? ` (${annotation})` : '')); + } + + const composition = + description.steps.map(step => + ('toResolvedComposition' in step + ? compositeFrom(step.toResolvedComposition()) + : step)); + + const inputMetadata = getStaticInputMetadata(description.inputMapping ?? {}); + + function _mapDependenciesToOutputs(providedDependencies) { + if (!description.outputs) { + return {}; + } + + if (!providedDependencies) { + return {}; + } + + return ( + Object.fromEntries( + Object.entries(description.outputs) + .map(([continuationName, outputName]) => [ + outputName, + (continuationName in providedDependencies + ? providedDependencies[continuationName] + : providedDependencies[continuationName.replace(/^#/, '')]), + ]))); + } + + // These dependencies were all provided by the composition which this one is + // nested inside, so input('name')-shaped tokens are going to be evaluated + // in the context of the containing composition. + const dependenciesFromInputs = + Object.values(description.inputMapping ?? {}) + .map(token => { + const tokenShape = getInputTokenShape(token); + const tokenValue = getInputTokenValue(token); + switch (tokenShape) { + case 'input.dependency': + return tokenValue; + case 'input': + case 'input.updateValue': + return token; + case 'input.myself': + return 'this'; + default: + return null; + } + }) + .filter(Boolean); + + const anyInputsUseUpdateValue = + dependenciesFromInputs + .filter(dependency => isInputToken(dependency)) + .some(token => getInputTokenShape(token) === 'input.updateValue'); + + const inputNames = + Object.keys(description.inputMapping ?? {}); + + const inputSymbols = + inputNames.map(name => input(name)); + + const inputsMayBeDynamicValue = + stitchArrays({ + mappingToken: Object.values(description.inputMapping ?? {}), + descriptionToken: Object.values(description.inputDescriptions ?? {}), + }).map(({mappingToken, descriptionToken}) => { + if (getInputTokenShape(descriptionToken) === 'input.staticValue') return false; + if (getInputTokenShape(mappingToken) === 'input.value') return false; + return true; + }); + + const inputDescriptions = + Object.values(description.inputDescriptions ?? {}); + + /* + const inputsAcceptNull = + Object.values(description.inputDescriptions ?? {}) + .map(token => { + const tokenValue = getInputTokenValue(token); + if (!tokenValue) return false; + if ('acceptsNull' in tokenValue) return tokenValue.acceptsNull; + if ('defaultValue' in tokenValue) return tokenValue.defaultValue === null; + return false; + }); + */ + + // Update descriptions passed as the value in an input.updateValue() token, + // as provided as inputs for this composition. + const inputUpdateDescriptions = + Object.values(description.inputMapping ?? {}) + .map(token => + (getInputTokenShape(token) === 'input.updateValue' + ? getInputTokenValue(token) + : null)) + .filter(Boolean); + + const base = composition.at(-1); + const steps = composition.slice(); + + const aggregate = openAggregate({ + message: + `Errors preparing composition` + + (annotation ? ` (${annotation})` : ''), + }); + + const compositionNests = description.compose ?? true; + + // Steps default to exposing if using a shorthand syntax where flags aren't + // specified at all. + const stepsExpose = + steps + .map(step => + (step.flags + ? step.flags.expose ?? false + : true)); + + // Steps default to composing if using a shorthand syntax where flags aren't + // specified at all - *and* aren't the base (final step), unless the whole + // composition is nestable. + const stepsCompose = + steps + .map((step, index, {length}) => + (step.flags + ? step.flags.compose ?? false + : (index === length - 1 + ? compositionNests + : true))); + + // Steps update if the corresponding flag is explicitly set, if a transform + // function is provided, or if the dependencies include an input.updateValue + // token. + const stepsUpdate = + steps + .map(step => + (step.flags + ? step.flags.update ?? false + : !!step.transform || + !!step.dependencies?.some(dependency => + isInputToken(dependency) && + getInputTokenShape(dependency) === 'input.updateValue'))); + + // The expose description for a step is just the entire step object, when + // using the shorthand syntax where {flags: {expose: true}} is left implied. + const stepExposeDescriptions = + steps + .map((step, index) => + (stepsExpose[index] + ? (step.flags + ? step.expose ?? null + : step) + : null)); + + // The update description for a step, if present at all, is always set + // explicitly. There may be multiple per step - namely that step's own + // {update} description, and any descriptions passed as the value in an + // input.updateValue({...}) token. + const stepUpdateDescriptions = + steps + .map((step, index) => + (stepsUpdate[index] + ? [ + step.update ?? null, + ...(stepExposeDescriptions[index]?.dependencies ?? []) + .filter(dependency => isInputToken(dependency)) + .filter(token => getInputTokenShape(token) === 'input.updateValue') + .map(token => getInputTokenValue(token)), + ].filter(Boolean) + : [])); + + // Indicates presence of a {compute} function on the expose description. + const stepsCompute = + stepExposeDescriptions + .map(expose => !!expose?.compute); + + // Indicates presence of a {transform} function on the expose description. + const stepsTransform = + stepExposeDescriptions + .map(expose => !!expose?.transform); + + const dependenciesFromSteps = + unique( + stepExposeDescriptions + .flatMap(expose => expose?.dependencies ?? []) + .map(dependency => { + if (typeof dependency === 'string') + return (dependency.startsWith('#') ? null : dependency); + + const tokenShape = getInputTokenShape(dependency); + const tokenValue = getInputTokenValue(dependency); + switch (tokenShape) { + case 'input.dependency': + return (tokenValue.startsWith('#') ? null : tokenValue); + case 'input.myself': + return 'this'; + default: + return null; + } + }) + .filter(Boolean)); + + const anyStepsUseUpdateValue = + stepExposeDescriptions + .some(expose => + (expose?.dependencies + ? expose.dependencies.includes(input.updateValue()) + : false)); + + const anyStepsExpose = + stepsExpose.includes(true); + + const anyStepsUpdate = + stepsUpdate.includes(true); + + const anyStepsCompute = + stepsCompute.includes(true); + + const anyStepsTransform = + stepsTransform.includes(true); + + const compositionExposes = + anyStepsExpose; + + const compositionUpdates = + 'update' in description || + anyInputsUseUpdateValue || + anyStepsUseUpdateValue || + anyStepsUpdate; + + const stepEntries = stitchArrays({ + step: steps, + stepComposes: stepsCompose, + stepComputes: stepsCompute, + stepTransforms: stepsTransform, + }); + + for (let i = 0; i < stepEntries.length; i++) { + const { + step, + stepComposes, + stepComputes, + stepTransforms, + } = stepEntries[i]; + + const isBase = i === stepEntries.length - 1; + const message = + `Errors in step #${i + 1}` + + (isBase ? ` (base)` : ``) + + (step.annotation ? ` (${step.annotation})` : ``); + + aggregate.nest({message}, ({push}) => { + if (isBase && stepComposes !== compositionNests) { + return push(new TypeError( + (compositionNests + ? `Base must compose, this composition is nestable` + : `Base must not compose, this composition isn't nestable`))); + } else if (!isBase && !stepComposes) { + return push(new TypeError( + (compositionNests + ? `All steps must compose` + : `All steps (except base) must compose`))); + } + + if ( + !compositionNests && !compositionUpdates && + stepTransforms && !stepComputes + ) { + return push(new TypeError( + `Steps which only transform can't be used in a composition that doesn't update`)); + } + }); + } + + if (!compositionNests && !anyStepsCompute && !anyStepsTransform) { + aggregate.push(new TypeError(`Expected at least one step to compute or transform`)); + } + + aggregate.close(); + + function _prepareContinuation(callingTransformForThisStep) { + const continuationStorage = { + returnedWith: null, + providedDependencies: undefined, + providedValue: undefined, + }; + + const continuation = + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.exit = (providedValue) => { + continuationStorage.returnedWith = 'exit'; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + }; + + if (compositionNests) { + const makeRaiseLike = returnWith => + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.raiseOutput = makeRaiseLike('raiseOutput'); + continuation.raiseOutputAbove = makeRaiseLike('raiseOutputAbove'); + } + + return {continuation, continuationStorage}; + } + + function _computeOrTransform(initialValue, continuationIfApplicable, initialDependencies) { + const expectingTransform = initialValue !== noTransformSymbol; + + let valueSoFar = + (expectingTransform + ? initialValue + : undefined); + + const availableDependencies = {...initialDependencies}; + + const inputValues = + Object.values(description.inputMapping ?? {}) + .map(token => { + const tokenShape = getInputTokenShape(token); + const tokenValue = getInputTokenValue(token); + switch (tokenShape) { + case 'input.dependency': + return initialDependencies[tokenValue]; + case 'input.value': + return tokenValue; + case 'input.updateValue': + if (!expectingTransform) + throw new Error(`Unexpected input.updateValue() accessed on non-transform call`); + return valueSoFar; + case 'input.myself': + return initialDependencies['this']; + case 'input': + return initialDependencies[token]; + default: + throw new TypeError(`Unexpected input shape ${tokenShape}`); + } + }); + + withAggregate({message: `Errors in input values provided to ${compositionName}`}, ({push}) => { + for (const {dynamic, name, value, description} of stitchArrays({ + dynamic: inputsMayBeDynamicValue, + name: inputNames, + value: inputValues, + description: inputDescriptions, + })) { + if (!dynamic) continue; + try { + validateInputValue(value, description); + } catch (error) { + error.message = `${name}: ${error.message}`; + push(error); + } + } + }); + + if (expectingTransform) { + debug(() => [colors.bright(`begin composition - transforming from:`), initialValue]); + } else { + debug(() => colors.bright(`begin composition - not transforming`)); + } + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; + + debug(() => [ + `step #${i+1}` + + (isBase + ? ` (base):` + : ` of ${steps.length}:`), + step]); + + const expose = + (step.flags + ? step.expose + : step); + + if (!expose) { + if (!isBase) { + debug(() => `step #${i+1} - no expose description, nothing to do for this step`); + continue; + } + + if (expectingTransform) { + debug(() => `step #${i+1} (base) - no expose description, returning so-far update value:`, valueSoFar); + if (continuationIfApplicable) { + debug(() => colors.bright(`end composition - raise (inferred - composing)`)); + return continuationIfApplicable(valueSoFar); + } else { + debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); + return valueSoFar; + } + } else { + debug(() => `step #${i+1} (base) - no expose description, nothing to continue with`); + if (continuationIfApplicable) { + debug(() => colors.bright(`end composition - raise (inferred - composing)`)); + return continuationIfApplicable(); + } else { + debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); + return null; + } + } + } + + const callingTransformForThisStep = + expectingTransform && expose.transform; + + let continuationStorage; + + const inputDictionary = + Object.fromEntries( + stitchArrays({symbol: inputSymbols, value: inputValues}) + .map(({symbol, value}) => [symbol, value])); + + const filterableDependencies = { + ...availableDependencies, + ...inputMetadata, + ...inputDictionary, + ... + (expectingTransform + ? {[input.updateValue()]: valueSoFar} + : {}), + [input.myself()]: initialDependencies?.['this'] ?? null, + }; + + const selectDependencies = + (expose.dependencies ?? []).map(dependency => { + if (!isInputToken(dependency)) return dependency; + const tokenShape = getInputTokenShape(dependency); + const tokenValue = getInputTokenValue(dependency); + switch (tokenShape) { + case 'input': + case 'input.staticDependency': + case 'input.staticValue': + return dependency; + case 'input.myself': + return input.myself(); + case 'input.dependency': + return tokenValue; + case 'input.updateValue': + return input.updateValue(); + default: + throw new Error(`Unexpected token ${tokenShape} as dependency`); + } + }) + + const filteredDependencies = + filterProperties(filterableDependencies, selectDependencies); + + debug(() => [ + `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, + `with dependencies:`, filteredDependencies, + `selecting:`, selectDependencies, + `from available:`, filterableDependencies, + ...callingTransformForThisStep ? [`from value:`, valueSoFar] : []]); + + let result; + + const getExpectedEvaluation = () => + (callingTransformForThisStep + ? (filteredDependencies + ? ['transform', valueSoFar, continuationSymbol, filteredDependencies] + : ['transform', valueSoFar, continuationSymbol]) + : (filteredDependencies + ? ['compute', continuationSymbol, filteredDependencies] + : ['compute', continuationSymbol])); + + const naturalEvaluate = () => { + const [name, ...argsLayout] = getExpectedEvaluation(); + + let args; + + if (isBase && !compositionNests) { + args = + argsLayout.filter(arg => arg !== continuationSymbol); + } else { + let continuation; + + ({continuation, continuationStorage} = + _prepareContinuation(callingTransformForThisStep)); + + args = + argsLayout.map(arg => + (arg === continuationSymbol + ? continuation + : arg)); + } + + return expose[name](...args); + } + + switch (step.cache) { + // Warning! Highly WIP! + case 'aggressive': { + const hrnow = () => { + const hrTime = process.hrtime(); + return hrTime[0] * 1000000000 + hrTime[1]; + }; + + const [name, ...args] = getExpectedEvaluation(); + + let cache = globalCompositeCache[step.annotation]; + if (!cache) { + cache = globalCompositeCache[step.annotation] = { + transform: new TupleMap(), + compute: new TupleMap(), + times: { + read: [], + evaluate: [], + }, + }; + } + + const tuplefied = args + .flatMap(arg => [ + Symbol.for('compositeFrom: tuplefied arg divider'), + ...(typeof arg !== 'object' || Array.isArray(arg) + ? [arg] + : Object.entries(arg).flat()), + ]); + + const readTime = hrnow(); + const cacheContents = cache[name].get(tuplefied); + cache.times.read.push(hrnow() - readTime); + + if (cacheContents) { + ({result, continuationStorage} = cacheContents); + } else { + const evaluateTime = hrnow(); + result = naturalEvaluate(); + cache.times.evaluate.push(hrnow() - evaluateTime); + cache[name].set(tuplefied, {result, continuationStorage}); + } + + break; + } + + default: { + result = naturalEvaluate(); + break; + } + } + + if (result !== continuationSymbol) { + debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); + + if (compositionNests) { + throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); + } + + debug(() => colors.bright(`end composition - exit (inferred)`)); + + return result; + } + + const {returnedWith} = continuationStorage; + + if (returnedWith === 'exit') { + const {providedValue} = continuationStorage; + + debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); + debug(() => colors.bright(`end composition - exit (explicit)`)); + + if (compositionNests) { + return continuationIfApplicable.exit(providedValue); + } else { + return providedValue; + } + } + + const {providedValue, providedDependencies} = continuationStorage; + + const continuationArgs = []; + if (expectingTransform) { + continuationArgs.push( + (callingTransformForThisStep + ? providedValue ?? null + : valueSoFar ?? null)); + } + + debug(() => { + const base = `step #${i+1} - result: ` + returnedWith; + const parts = []; + + if (callingTransformForThisStep) { + parts.push('value:', providedValue); + } + + if (providedDependencies !== null) { + parts.push(`deps:`, providedDependencies); + } else { + parts.push(`(no deps)`); + } + + if (empty(parts)) { + return base; + } else { + return [base + ' ->', ...parts]; + } + }); + + switch (returnedWith) { + case 'raiseOutput': + debug(() => + (isBase + ? colors.bright(`end composition - raiseOutput (base: explicit)`) + : colors.bright(`end composition - raiseOutput`))); + continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); + return continuationIfApplicable(...continuationArgs); + + case 'raiseOutputAbove': + debug(() => colors.bright(`end composition - raiseOutputAbove`)); + continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); + return continuationIfApplicable.raiseOutput(...continuationArgs); + + case 'continuation': + if (isBase) { + debug(() => colors.bright(`end composition - raiseOutput (inferred)`)); + continuationArgs.push(_mapDependenciesToOutputs(providedDependencies)); + return continuationIfApplicable(...continuationArgs); + } else { + Object.assign(availableDependencies, providedDependencies); + if (callingTransformForThisStep && providedValue !== null) { + valueSoFar = providedValue; + } + break; + } + } + } + } + + const constructedDescriptor = {}; + + if (annotation) { + constructedDescriptor.annotation = annotation; + } + + constructedDescriptor.flags = { + update: compositionUpdates, + expose: compositionExposes, + compose: compositionNests, + }; + + if (compositionUpdates) { + // TODO: This is a dumb assign statement, and it could probably do more + // interesting things, like combining validation functions. + constructedDescriptor.update = + Object.assign( + {...description.update ?? {}}, + ...inputUpdateDescriptions, + ...stepUpdateDescriptions.flat()); + } + + if (compositionExposes) { + const expose = constructedDescriptor.expose = {}; + + expose.dependencies = + unique([ + ...dependenciesFromInputs, + ...dependenciesFromSteps, + ]); + + const _wrapper = (...args) => { + try { + return _computeOrTransform(...args); + } catch (thrownError) { + const error = new Error( + `Error computing composition` + + (annotation ? ` ${annotation}` : '')); + error.cause = thrownError; + throw error; + } + }; + + if (compositionNests) { + if (compositionUpdates) { + expose.transform = (value, continuation, dependencies) => + _wrapper(value, continuation, dependencies); + } + + if (anyStepsCompute && !anyStepsUseUpdateValue && !anyInputsUseUpdateValue) { + expose.compute = (continuation, dependencies) => + _wrapper(noTransformSymbol, continuation, dependencies); + } + + if (base.cacheComposition) { + expose.cache = base.cacheComposition; + } + } else if (compositionUpdates) { + expose.transform = (value, dependencies) => + _wrapper(value, null, dependencies); + } else { + expose.compute = (dependencies) => + _wrapper(noTransformSymbol, null, dependencies); + } + } + + return constructedDescriptor; +} + +export function displayCompositeCacheAnalysis() { + const showTimes = (cache, key) => { + const times = cache.times[key].slice().sort(); + + const all = times; + const worst10pc = times.slice(-times.length / 10); + const best10pc = times.slice(0, times.length / 10); + const middle50pc = times.slice(times.length / 4, -times.length / 4); + const middle80pc = times.slice(times.length / 10, -times.length / 10); + + const fmt = val => `${(val / 1000).toFixed(2)}ms`.padStart(9); + const avg = times => times.reduce((a, b) => a + b, 0) / times.length; + + const left = ` - ${key}: `; + const indn = ' '.repeat(left.length); + console.log(left + `${fmt(avg(all))} (all ${all.length})`); + console.log(indn + `${fmt(avg(worst10pc))} (worst 10%)`); + console.log(indn + `${fmt(avg(best10pc))} (best 10%)`); + console.log(indn + `${fmt(avg(middle80pc))} (middle 80%)`); + console.log(indn + `${fmt(avg(middle50pc))} (middle 50%)`); + }; + + for (const [annotation, cache] of Object.entries(globalCompositeCache)) { + console.log(`Cached ${annotation}:`); + showTimes(cache, 'evaluate'); + showTimes(cache, 'read'); + } +} + +// Evaluates a function with composite debugging enabled, turns debugging +// off again, and returns the result of the function. This is mostly syntax +// sugar, but also helps avoid unit tests avoid accidentally printing debug +// info for a bunch of unrelated composites (due to property enumeration +// when displaying an unexpected result). Use as so: +// +// Without debugging: +// t.same(thing.someProp, value) +// +// With debugging: +// t.same(debugComposite(() => thing.someProp), value) +// +export function debugComposite(fn) { + compositeFrom.debug = true; + const value = fn(); + compositeFrom.debug = false; + return value; +} diff --git a/src/data/things/flash.js b/src/data/things/flash.js index 6eb5234f..e2afcef4 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -1,25 +1,42 @@ +import {input} from '#composite'; import find from '#find'; +import { + isColor, + isDirectory, + isNumber, + isString, + oneOf, +} from '#validators'; + +import {exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +import { + color, + contributionList, + directory, + fileExtension, + name, + referenceList, + simpleDate, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import {withFlashAct} from '#composite/things/flash'; + import Thing from './thing.js'; export class Flash extends Thing { static [Thing.referenceType] = 'flash'; - static [Thing.getPropertyDescriptors] = ({ - Artist, - Track, - FlashAct, - - validators: { - isDirectory, - isNumber, - isString, - oneOf, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({ // Update & expose - name: Thing.common.name('Unnamed Flash'), + name: name('Unnamed Flash'), directory: { flags: {update: true, expose: true}, @@ -47,51 +64,51 @@ export class Flash extends Thing { }, }, - date: Thing.common.simpleDate(), - - coverArtFileExtension: Thing.common.fileExtension('jpg'), + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), - contributorContribsByRef: Thing.common.contribsByRef(), + withFlashAct(), - featuredTracksByRef: Thing.common.referenceList(Track), + withPropertyFromObject({ + object: '#flashAct', + property: input.value('color'), + }), - urls: Thing.common.urls(), + exposeDependency({dependency: '#flashAct.color'}), + ], - // Update only + date: simpleDate(), - artistData: Thing.common.wikiData(Artist), - trackData: Thing.common.wikiData(Track), - flashActData: Thing.common.wikiData(FlashAct), + coverArtFileExtension: fileExtension('jpg'), - // Expose only + contributorContribs: contributionList(), - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + featuredTracks: referenceList({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), - featuredTracks: Thing.common.dynamicThingsFromReferenceList( - 'featuredTracksByRef', - 'trackData', - find.track - ), + urls: urls(), - act: { - flags: {expose: true}, + // Update only - expose: { - dependencies: ['flashActData'], + artistData: wikiData(Artist), + trackData: wikiData(Track), + flashActData: wikiData(FlashAct), - compute: ({flashActData, [Flash.instance]: flash}) => - flashActData.find((act) => act.flashes.includes(flash)) ?? null, - }, - }, + // Expose only - color: { + act: { flags: {expose: true}, expose: { - dependencies: ['flashActData'], + dependencies: ['this', 'flashActData'], - compute: ({flashActData, [Flash.instance]: flash}) => - flashActData.find((act) => act.flashes.includes(flash))?.color ?? null, + compute: ({this: flash, flashActData}) => + flashActData.find((act) => act.flashes.includes(flash)) ?? null, }, }, }); @@ -111,17 +128,18 @@ export class Flash extends Thing { } export class FlashAct extends Thing { - static [Thing.getPropertyDescriptors] = ({ - validators: { - isColor, - }, - }) => ({ + static [Thing.referenceType] = 'flash-act'; + static [Thing.friendlyName] = `Flash Act`; + + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: Thing.common.name('Unnamed Flash Act'), - color: Thing.common.color(), - anchor: Thing.common.simpleString(), - jump: Thing.common.simpleString(), + name: name('Unnamed Flash Act'), + directory: directory(), + color: color(), + listTerminology: simpleString(), + + jump: simpleString(), jumpColor: { flags: {update: true, expose: true}, @@ -133,18 +151,14 @@ export class FlashAct extends Thing { } }, - flashesByRef: Thing.common.referenceList(Flash), + flashes: referenceList({ + class: input.value(Flash), + find: input.value(find.flash), + data: 'flashData', + }), // Update only - flashData: Thing.common.wikiData(Flash), - - // Expose only - - flashes: Thing.common.dynamicThingsFromReferenceList( - 'flashesByRef', - 'flashData', - find.flash - ), + flashData: wikiData(Flash), }) } diff --git a/src/data/things/group.js b/src/data/things/group.js index ba339b3e..8764a9db 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,33 +1,44 @@ +import {input} from '#composite'; import find from '#find'; +import { + color, + directory, + name, + referenceList, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + import Thing from './thing.js'; export class Group extends Thing { static [Thing.referenceType] = 'group'; - static [Thing.getPropertyDescriptors] = ({ - Album, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album}) => ({ // Update & expose - name: Thing.common.name('Unnamed Group'), - directory: Thing.common.directory(), + name: name('Unnamed Group'), + directory: directory(), - description: Thing.common.simpleString(), + description: simpleString(), - urls: Thing.common.urls(), + urls: urls(), - featuredAlbumsByRef: Thing.common.referenceList(Album), + featuredAlbums: referenceList({ + class: input.value(Album), + find: input.value(find.album), + data: 'albumData', + }), // Update only - albumData: Thing.common.wikiData(Album), - groupCategoryData: Thing.common.wikiData(GroupCategory), + albumData: wikiData(Album), + groupCategoryData: wikiData(GroupCategory), // Expose only - featuredAlbums: Thing.common.dynamicThingsFromReferenceList('featuredAlbumsByRef', 'albumData', find.album), - descriptionShort: { flags: {expose: true}, @@ -41,8 +52,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData'], - compute: ({albumData, [Group.instance]: group}) => + dependencies: ['this', 'albumData'], + compute: ({this: group, albumData}) => albumData?.filter((album) => album.groups.includes(group)) ?? [], }, }, @@ -51,9 +62,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['groupCategoryData'], - - compute: ({groupCategoryData, [Group.instance]: group}) => + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => groupCategoryData.find((category) => category.groups.includes(group)) ?.color, }, @@ -63,8 +73,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['groupCategoryData'], - compute: ({groupCategoryData, [Group.instance]: group}) => + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => groupCategoryData.find((category) => category.groups.includes(group)) ?? null, }, @@ -73,26 +83,22 @@ export class Group extends Thing { } export class GroupCategory extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Group, - }) => ({ + static [Thing.friendlyName] = `Group Category`; + + static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose - name: Thing.common.name('Unnamed Group Category'), - color: Thing.common.color(), + name: name('Unnamed Group Category'), + color: color(), - groupsByRef: Thing.common.referenceList(Group), + groups: referenceList({ + class: input.value(Group), + find: input.value(find.group), + data: 'groupData', + }), // Update only - groupData: Thing.common.wikiData(Group), - - // Expose only - - groups: Thing.common.dynamicThingsFromReferenceList( - 'groupsByRef', - 'groupData', - find.group - ), + groupData: wikiData(Group), }); } diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index ec9e9556..bfa971ca 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -1,20 +1,37 @@ +import {input} from '#composite'; import find from '#find'; +import { + is, + isCountingNumber, + isString, + isStringNonEmpty, + oneOf, + validateArrayItems, + validateInstanceOf, + validateReference, +} from '#validators'; + +import {exposeDependency} from '#composite/control-flow'; +import {withResolvedReference} from '#composite/wiki-data'; + +import { + color, + name, + referenceList, + simpleString, + wikiData, +} from '#composite/wiki-properties'; + import Thing from './thing.js'; export class HomepageLayout extends Thing { - static [Thing.getPropertyDescriptors] = ({ - HomepageLayoutRow, + static [Thing.friendlyName] = `Homepage Layout`; - validators: { - isStringNonEmpty, - validateArrayItems, - validateInstanceOf, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ // Update & expose - sidebarContent: Thing.common.simpleString(), + sidebarContent: simpleString(), navbarLinks: { flags: {update: true, expose: true}, @@ -32,13 +49,12 @@ export class HomepageLayout extends Thing { } export class HomepageLayoutRow extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Album, - Group, - }) => ({ + static [Thing.friendlyName] = `Homepage Row`; + + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ // Update & expose - name: Thing.common.name('Unnamed Homepage Row'), + name: name('Unnamed Homepage Row'), type: { flags: {update: true, expose: true}, @@ -50,30 +66,22 @@ export class HomepageLayoutRow extends Thing { }, }, - color: Thing.common.color(), + color: color(), // Update only // These aren't necessarily used by every HomepageLayoutRow subclass, but // for convenience of providing this data, every row accepts all wiki data // arrays depended upon by any subclass's behavior. - albumData: Thing.common.wikiData(Album), - groupData: Thing.common.wikiData(Group), + albumData: wikiData(Album), + groupData: wikiData(Group), }); } export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { - static [Thing.getPropertyDescriptors] = (opts, { - Album, - Group, - - validators: { - is, - isCountingNumber, - isString, - validateArrayItems, - }, - } = opts) => ({ + static [Thing.friendlyName] = `Homepage Albums Row`; + + static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), // Update & expose @@ -104,8 +112,39 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { }, }, - sourceGroupByRef: Thing.common.singleReference(Group), - sourceAlbumsByRef: Thing.common.referenceList(Album), + sourceGroup: [ + { + flags: {expose: true, update: true, compose: true}, + + update: { + validate: + oneOf( + is('new-releases', 'new-additions'), + validateReference(Group[Thing.referenceType])), + }, + + expose: { + transform: (value, continuation) => + (value === 'new-releases' || value === 'new-additions' + ? value + : continuation(value)), + }, + }, + + withResolvedReference({ + ref: input.updateValue(), + data: 'groupData', + find: input.value(find.group), + }), + + exposeDependency({dependency: '#resolvedReference'}), + ], + + sourceAlbums: referenceList({ + class: input.value(Album), + find: input.value(find.album), + data: 'albumData', + }), countAlbumsFromGroup: { flags: {update: true, expose: true}, @@ -116,19 +155,5 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { flags: {update: true, expose: true}, update: {validate: validateArrayItems(isString)}, }, - - // Expose only - - sourceGroup: Thing.common.dynamicThingFromSingleReference( - 'sourceGroupByRef', - 'groupData', - find.group - ), - - sourceAlbums: Thing.common.dynamicThingsFromReferenceList( - 'sourceAlbumsByRef', - 'albumData', - find.album - ), }); } diff --git a/src/data/things/index.js b/src/data/things/index.js index 591cdc3b..4ea1f007 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -2,9 +2,9 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {logError} from '#cli'; +import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; import {openAggregate, showAggregate} from '#sugar'; -import * as validators from '#validators'; import Thing from './thing.js'; @@ -21,7 +21,11 @@ import * as trackClasses from './track.js'; import * as wikiInfoClasses from './wiki-info.js'; export {default as Thing} from './thing.js'; -export {default as CacheableObject} from './cacheable-object.js'; + +export { + default as CacheableObject, + CacheableObjectPropertyValueError, +} from './cacheable-object.js'; const allClassLists = { 'album.js': albumClasses, @@ -82,6 +86,8 @@ function errorDuplicateClassNames() { function flattenClassLists() { for (const classes of Object.values(allClassLists)) { for (const [name, constructor] of Object.entries(classes)) { + if (typeof constructor !== 'function') continue; + if (!(constructor.prototype instanceof Thing)) continue; allClasses[name] = constructor; } } @@ -119,7 +125,7 @@ function descriptorAggregateHelper({ } function evaluatePropertyDescriptors() { - const opts = {...allClasses, validators}; + const opts = {...allClasses}; return descriptorAggregateHelper({ message: `Errors evaluating Thing class property descriptors`, @@ -129,8 +135,21 @@ function evaluatePropertyDescriptors() { throw new Error(`Missing [Thing.getPropertyDescriptors] function`); } - constructor.propertyDescriptors = - constructor[Thing.getPropertyDescriptors](opts); + const results = constructor[Thing.getPropertyDescriptors](opts); + + for (const [key, value] of Object.entries(results)) { + if (Array.isArray(value)) { + results[key] = compositeFrom({ + annotation: `${constructor.name}.${key}`, + compose: false, + steps: value, + }); + } else if (value.toResolvedComposition) { + results[key] = compositeFrom(value.toResolvedComposition()); + } + } + + constructor.propertyDescriptors = results; }, showFailedClasses(failedClasses) { diff --git a/src/data/things/language.js b/src/data/things/language.js index 7755c505..646eb6d1 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,11 +1,17 @@ +import {Tag} from '#html'; +import {isLanguageCode} from '#validators'; + +import { + externalFunction, + flag, + simpleString, +} from '#composite/wiki-properties'; + +import CacheableObject from './cacheable-object.js'; import Thing from './thing.js'; export class Language extends Thing { - static [Thing.getPropertyDescriptors] = ({ - validators: { - isLanguageCode, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose // General language code. This is used to identify the language distinctly @@ -18,7 +24,7 @@ export class Language extends Thing { // Human-readable name. This should be the language's own native name, not // localized to any other language. - name: Thing.common.simpleString(), + name: simpleString(), // Language code specific to JavaScript's Internationalization (Intl) API. // Usually this will be the same as the language's general code, but it @@ -40,7 +46,7 @@ export class Language extends Thing { // with languages that are currently in development and not ready for // formal release, or which are just kept hidden as "experimental zones" // for wiki development or content testing. - hidden: Thing.common.flag(false), + hidden: flag(false), // Mapping of translation keys to values (strings). Generally, don't // access this object directly - use methods instead. @@ -68,7 +74,7 @@ export class Language extends Thing { // Update only - escapeHTML: Thing.common.externalFunction(), + escapeHTML: externalFunction(), // Expose only @@ -95,6 +101,7 @@ export class Language extends Thing { }, }, + // TODO: This currently isn't used. Is it still needed? strings_htmlEscaped: { flags: {expose: true}, expose: { @@ -124,8 +131,8 @@ export class Language extends Thing { }; } - $(key, args = {}) { - return this.formatString(key, args); + $(...args) { + return this.formatString(...args); } assertIntlAvailable(property) { @@ -139,20 +146,22 @@ export class Language extends Thing { return this.intl_pluralCardinal.select(value); } - formatString(key, args = {}) { - if (this.strings && !this.strings_htmlEscaped) { - throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`); - } + formatString(...args) { + const hasOptions = + typeof args.at(-1) === 'object' && + args.at(-1) !== null; - return this.formatStringHelper(this.strings_htmlEscaped, key, args); - } + const key = + (hasOptions ? args.slice(0, -1) : args) + .filter(Boolean) + .join('.'); - formatStringNoHTMLEscape(key, args = {}) { - return this.formatStringHelper(this.strings, key, args); - } + const options = + (hasOptions + ? args.at(-1) + : null); - formatStringHelper(strings, key, args = {}) { - if (!strings) { + if (!this.strings) { throw new Error(`Strings unavailable`); } @@ -160,30 +169,94 @@ export class Language extends Thing { throw new Error(`Invalid key ${key} accessed`); } - const template = strings[key]; - - // Convert the keys on the args dict from camelCase to CONSTANT_CASE. - // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut - // like, who cares, dude?) Also, this is an array, 8ecause it's handy - // for the iterating we're a8out to do. - const processedArgs = Object.entries(args).map(([k, v]) => [ - k.replace(/[A-Z]/g, '_$&').toUpperCase(), - v, - ]); - - // Replacement time! Woot. Reduce comes in handy here! - const output = processedArgs.reduce( - (x, [k, v]) => x.replaceAll(`{${k}}`, v), - template - ); + const template = this.strings[key]; + + let output; + + if (hasOptions) { + // Convert the keys on the options dict from camelCase to CONSTANT_CASE. + // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut + // like, who cares, dude?) Also, this is an array, 8ecause it's handy + // for the iterating we're a8out to do. Also strip HTML from arguments + // that are literal strings - real HTML content should always be proper + // HTML objects (see html.js). + const processedOptions = + Object.entries(options).map(([k, v]) => [ + k.replace(/[A-Z]/g, '_$&').toUpperCase(), + this.#sanitizeStringArg(v), + ]); + + // Replacement time! Woot. Reduce comes in handy here! + output = + processedOptions.reduce( + (x, [k, v]) => x.replaceAll(`{${k}}`, v), + template); + } else { + // Without any options provided, just use the template as-is. This will + // still error if the template expected arguments, and otherwise will be + // the right value. + output = template; + } // Post-processing: if any expected arguments *weren't* replaced, that // is almost definitely an error. - if (output.match(/\{[A-Z_]+\}/)) { + if (output.match(/\{[A-Z][A-Z0-9_]*\}/)) { throw new Error(`Args in ${key} were missing - output: ${output}`); } - return output; + // Last caveat: Wrap the output in an HTML tag so that it doesn't get + // treated as unsanitized HTML if *it* gets passed as an argument to + // *another* formatString call. + return this.#wrapSanitized(output); + } + + // Escapes HTML special characters so they're displayed as-are instead of + // treated by the browser as a tag. This does *not* have an effect on actual + // html.Tag objects, which are treated as sanitized by default (so that they + // can be nested inside strings at all). + #sanitizeStringArg(arg) { + const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); + + if (!escapeHTML) { + throw new Error(`escapeHTML unavailable`); + } + + if (typeof arg !== 'string') { + return arg.toString(); + } + + return escapeHTML(arg); + } + + // Wraps the output of a formatting function in a no-name-nor-attributes + // HTML tag, which will indicate to other calls to formatString that this + // content is a string *that may contain HTML* and doesn't need to + // sanitized any further. It'll still .toString() to just the string + // contents, if needed. + #wrapSanitized(output) { + return new Tag(null, null, output); + } + + // Similar to the above internal methods, but this one is public. + // It should be used when embedding content that may not have previously + // been sanitized directly into an HTML tag or template's contents. + // The templating engine usually handles this on its own, as does passing + // a value (sanitized or not) directly as an argument to formatString, + // but if you used a custom validation function ({validate: v => v.isHTML} + // instead of {type: 'string'} / {type: 'html'}) and are embedding the + // contents of a slot directly, it should be manually sanitized with this + // function first. + sanitize(arg) { + const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); + + if (!escapeHTML) { + throw new Error(`escapeHTML unavailable`); + } + + return ( + (typeof arg === 'string' + ? new Tag(null, null, escapeHTML(arg)) + : arg)); } formatDate(date) { @@ -252,19 +325,32 @@ export class Language extends Thing { // Conjunction list: A, B, and C formatConjunctionList(array) { this.assertIntlAvailable('intl_listConjunction'); - return this.intl_listConjunction.format(array.map(arr => arr.toString())); + return this.#wrapSanitized( + this.intl_listConjunction.format( + array.map(item => this.#sanitizeStringArg(item)))); } // Disjunction lists: A, B, or C formatDisjunctionList(array) { this.assertIntlAvailable('intl_listDisjunction'); - return this.intl_listDisjunction.format(array.map(arr => arr.toString())); + return this.#wrapSanitized( + this.intl_listDisjunction.format( + array.map(item => this.#sanitizeStringArg(item)))); } // Unit lists: A, B, C formatUnitList(array) { this.assertIntlAvailable('intl_listUnit'); - return this.intl_listUnit.format(array.map(arr => arr.toString())); + return this.#wrapSanitized( + this.intl_listUnit.format( + array.map(item => this.#sanitizeStringArg(item)))); + } + + // Lists without separator: A B C + formatListWithoutSeparator(array) { + return this.#wrapSanitized( + array.map(item => this.#sanitizeStringArg(item)) + .join(' ')); } // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js index 43911410..36da0299 100644 --- a/src/data/things/news-entry.js +++ b/src/data/things/news-entry.js @@ -1,16 +1,24 @@ +import { + directory, + name, + simpleDate, + simpleString, +} from '#composite/wiki-properties'; + import Thing from './thing.js'; export class NewsEntry extends Thing { static [Thing.referenceType] = 'news-entry'; + static [Thing.friendlyName] = `News Entry`; static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: Thing.common.name('Unnamed News Entry'), - directory: Thing.common.directory(), - date: Thing.common.simpleDate(), + name: name('Unnamed News Entry'), + directory: directory(), + date: simpleDate(), - content: Thing.common.simpleString(), + content: simpleString(), // Expose only diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index 3d8d474c..ab9c5f98 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -1,16 +1,21 @@ +import {isName} from '#validators'; + +import { + directory, + name, + simpleString, +} from '#composite/wiki-properties'; + import Thing from './thing.js'; export class StaticPage extends Thing { static [Thing.referenceType] = 'static'; + static [Thing.friendlyName] = `Static Page`; - static [Thing.getPropertyDescriptors] = ({ - validators: { - isName, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: Thing.common.name('Unnamed Static Page'), + name: name('Unnamed Static Page'), nameShort: { flags: {update: true, expose: true}, @@ -22,8 +27,8 @@ export class StaticPage extends Thing { }, }, - directory: Thing.common.directory(), - content: Thing.common.simpleString(), - stylesheet: Thing.common.simpleString(), + directory: directory(), + content: simpleString(), + stylesheet: simpleString(), }); } diff --git a/src/data/things/thing.js b/src/data/things/thing.js index c2876f56..def7e914 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1,399 +1,19 @@ -// Thing: base class for wiki data types, providing wiki-specific utility -// functions on top of essential CacheableObject behavior. +// Thing: base class for wiki data types, providing interfaces generally useful +// to all wiki data objects on top of foundational CacheableObject behavior. import {inspect} from 'node:util'; -import {color} from '#cli'; -import find from '#find'; -import {empty} from '#sugar'; -import {getKebabCase} from '#wiki-data'; - -import { - isAdditionalFileList, - isBoolean, - isCommentary, - isColor, - isContributionList, - isDate, - isDirectory, - isFileExtension, - isName, - isString, - isURL, - validateArrayItems, - validateInstanceOf, - validateReference, - validateReferenceList, -} from '#validators'; +import {colors} from '#cli'; import CacheableObject from './cacheable-object.js'; export default class Thing extends CacheableObject { - static referenceType = Symbol('Thing.referenceType'); + static referenceType = Symbol.for('Thing.referenceType'); + static friendlyName = Symbol.for(`Thing.friendlyName`); static getPropertyDescriptors = Symbol('Thing.getPropertyDescriptors'); static getSerializeDescriptors = Symbol('Thing.getSerializeDescriptors'); - // Regularly reused property descriptors, for ease of access and generally - // duplicating less code across wiki data types. These are specialized utility - // functions, so check each for how its own arguments behave! - static common = { - name: (defaultName) => ({ - flags: {update: true, expose: true}, - update: {validate: isName, default: defaultName}, - }), - - color: () => ({ - flags: {update: true, expose: true}, - update: {validate: isColor}, - }), - - directory: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: { - dependencies: ['name'], - transform(directory, {name}) { - if (directory === null && name === null) return null; - else if (directory === null) return getKebabCase(name); - else return directory; - }, - }, - }), - - urls: () => ({ - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)}, - expose: {transform: (value) => value ?? []}, - }), - - // A file extension! Or the default, if provided when calling this. - fileExtension: (defaultFileExtension = null) => ({ - flags: {update: true, expose: true}, - update: {validate: isFileExtension}, - expose: {transform: (value) => value ?? defaultFileExtension}, - }), - - // Straightforward flag descriptor for a variety of property purposes. - // Provide a default value, true or false! - flag: (defaultValue = false) => { - if (typeof defaultValue !== 'boolean') { - throw new TypeError(`Always set explicit defaults for flags!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: defaultValue}, - }; - }, - - // General date type, used as the descriptor for a bunch of properties. - // This isn't dynamic though - it won't inherit from a date stored on - // another object, for example. - simpleDate: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDate}, - }), - - // General string type. This should probably generally be avoided in favor - // of more specific validation, but using it makes it easy to find where we - // might want to improve later, and it's a useful shorthand meanwhile. - simpleString: () => ({ - flags: {update: true, expose: true}, - update: {validate: isString}, - }), - - // External function. These should only be used as dependencies for other - // properties, so they're left unexposed. - externalFunction: () => ({ - flags: {update: true}, - update: {validate: (t) => typeof t === 'function'}, - }), - - // Super simple "contributions by reference" list, used for a variety of - // properties (Artists, Cover Artists, etc). This is the property which is - // externally provided, in the form: - // - // [ - // {who: 'Artist Name', what: 'Viola'}, - // {who: 'artist:john-cena', what: null}, - // ... - // ] - // - // ...processed from YAML, spreadsheet, or any other kind of input. - contribsByRef: () => ({ - flags: {update: true, expose: true}, - update: {validate: isContributionList}, - }), - - // Artist commentary! Generally present on tracks and albums. - commentary: () => ({ - flags: {update: true, expose: true}, - update: {validate: isCommentary}, - }), - - // This is a somewhat more involved data structure - it's for additional - // or "bonus" files associated with albums or tracks (or anything else). - // It's got this form: - // - // [ - // {title: 'Booklet', files: ['Booklet.pdf']}, - // { - // title: 'Wallpaper', - // description: 'Cool Wallpaper!', - // files: ['1440x900.png', '1920x1080.png'] - // }, - // {title: 'Alternate Covers', description: null, files: [...]}, - // ... - // ] - // - additionalFiles: () => ({ - flags: {update: true, expose: true}, - update: {validate: isAdditionalFileList}, - expose: { - transform: (additionalFiles) => - additionalFiles ?? [], - }, - }), - - // A reference list! Keep in mind this is for general references to wiki - // objects of (usually) other Thing subclasses, not specifically leitmotif - // references in tracks (although that property uses referenceList too!). - // - // The underlying function validateReferenceList expects a string like - // 'artist' or 'track', but this utility keeps from having to hard-code the - // string in multiple places by referencing the value saved on the class - // instead. - referenceList: (thingClass) => { - const {[Thing.referenceType]: referenceType} = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList(referenceType)}, - }; - }, - - // Corresponding function for a single reference. - singleReference: (thingClass) => { - const {[Thing.referenceType]: referenceType} = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: validateReference(referenceType)}, - }; - }, - - // Corresponding dynamic property to referenceList, which takes the values - // in the provided property and searches the specified wiki data for - // matching actual Thing-subclass objects. - dynamicThingsFromReferenceList: ( - referenceListProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [referenceListProperty, thingDataProperty], - compute: ({ - [referenceListProperty]: refs, - [thingDataProperty]: thingData, - }) => - refs && thingData - ? refs - .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) - .filter(Boolean) - : [], - }, - }), - - // Corresponding function for a single reference. - dynamicThingFromSingleReference: ( - singleReferenceProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [singleReferenceProperty, thingDataProperty], - compute: ({ - [singleReferenceProperty]: ref, - [thingDataProperty]: thingData, - }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null), - }, - }), - - // Corresponding dynamic property to contribsByRef, which takes the values - // in the provided property and searches the object's artistData for - // matching actual Artist objects. The computed structure has the same form - // as contribsByRef, but with Artist objects instead of string references: - // - // [ - // {who: (an Artist), what: 'Viola'}, - // {who: (an Artist), what: null}, - // ... - // ] - // - // Contributions whose "who" values don't match anything in artistData are - // filtered out. (So if the list is all empty, chances are that either the - // reference list is somehow messed up, or artistData isn't being provided - // properly.) - dynamicContribs: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: ['artistData', contribsByRefProperty], - compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => - contribsByRef && artistData - ? contribsByRef - .map(({who: ref, what}) => ({ - who: find.artist(ref, artistData), - what, - })) - .filter(({who}) => who) - : [], - }, - }), - - // Dynamically inherit a contribution list from some other object, if it - // hasn't been overridden on this object. This is handy for solo albums - // where all tracks have the same artist, for example. - dynamicInheritContribs: ( - // If this property is explicitly false, the contribution list returned - // will always be empty. - nullerProperty, - - // Property holding contributions on the current object. - contribsByRefProperty, - - // Property holding corresponding "default" contributions on the parent - // object, which will fallen back to if the object doesn't have its own - // contribs. - parentContribsByRefProperty, - - // Data array to search in and "find" function to locate parent object - // (which will be passed the child object and the wiki data array). - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - expose: { - dependencies: [ - contribsByRefProperty, - thingDataProperty, - nullerProperty, - 'artistData', - ].filter(Boolean), - - compute({ - [Thing.instance]: thing, - [nullerProperty]: nuller, - [contribsByRefProperty]: contribsByRef, - [thingDataProperty]: thingData, - artistData, - }) { - if (!artistData) return []; - if (nuller === false) return []; - const refs = - contribsByRef ?? - findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty]; - if (!refs) return []; - return refs - .map(({who: ref, what}) => ({ - who: find.artist(ref, artistData), - what, - })) - .filter(({who}) => who); - }, - }, - }), - - // Nice 'n simple shorthand for an exposed-only flag which is true when any - // contributions are present in the specified property. - contribsPresent: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: [contribsByRefProperty], - compute({ - [contribsByRefProperty]: contribsByRef, - }) { - return !empty(contribsByRef); - }, - } - }), - - // Neat little shortcut for "reversing" the reference lists stored on other - // things - for example, tracks specify a "referenced tracks" property, and - // you would use this to compute a corresponding "referenced *by* tracks" - // property. Naturally, the passed ref list property is of the things in the - // wiki data provided, not the requesting Thing itself. - reverseReferenceList: (thingDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [thingDataProperty], - - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => - thingData?.filter(t => t[referencerRefListProperty].includes(thing)) ?? [], - }, - }), - - // Corresponding function for single references. Note that the return value - // is still a list - this is for matching all the objects whose single - // reference (in the given property) matches this Thing. - reverseSingleReference: (thingDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [thingDataProperty], - - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => - thingData?.filter((t) => t[referencerRefListProperty] === thing) ?? [], - }, - }), - - // General purpose wiki data constructor, for properties like artistData, - // trackData, etc. - wikiData: (thingClass) => ({ - flags: {update: true}, - update: { - validate: validateArrayItems(validateInstanceOf(thingClass)), - }, - }), - - // This one's kinda tricky: it parses artist "references" from the - // commentary content, and finds the matching artist for each reference. - // This is mostly useful for credits and listings on artist pages. - commentatorArtists: () => ({ - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({artistData, commentary}) => - artistData && commentary - ? Array.from( - new Set( - Array.from( - commentary - .replace(/<\/?b>/g, '') - .matchAll(/<i>(?<who>.*?):<\/i>/g) - ).map(({groups: {who}}) => - find.artist(who, artistData, {mode: 'quiet'}) - ) - ) - ) - : [], - }, - }), - }; - // Default custom inspect function, which may be overridden by Thing // subclasses. This will be used when displaying aggregate errors and other // command-line logging - it's the place to provide information useful in @@ -402,8 +22,8 @@ export default class Thing extends CacheableObject { const cname = this.constructor.name; return ( - (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) + - (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '') + (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) + + (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '') ); } diff --git a/src/data/things/track.js b/src/data/things/track.js index e176acb4..db325a17 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -1,460 +1,330 @@ import {inspect} from 'node:util'; -import {color} from '#cli'; +import {colors} from '#cli'; +import {input} from '#composite'; import find from '#find'; -import {empty} from '#sugar'; +import { + isColor, + isContributionList, + isDate, + isFileExtension, +} from '#validators'; + +import {withPropertyFromObject} from '#composite/data'; +import {withResolvedContribs} from '#composite/wiki-data'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + additionalFiles, + commentary, + commentatorArtists, + contributionList, + directory, + duration, + flag, + name, + referenceList, + reverseReferenceList, + simpleDate, + singleReference, + simpleString, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import { + exitWithoutUniqueCoverArt, + inheritFromOriginalRelease, + trackReverseReferenceList, + withAlbum, + withAlwaysReferenceByDirectory, + withContainingTrackSection, + withHasUniqueCoverArt, + withOtherReleases, + withPropertyFromAlbum, +} from '#composite/things/track'; + +import CacheableObject from './cacheable-object.js'; import Thing from './thing.js'; export class Track extends Thing { static [Thing.referenceType] = 'track'; - static [Thing.getPropertyDescriptors] = ({ - Album, - ArtTag, - Artist, - Flash, - - validators: { - isBoolean, - isColor, - isDate, - isDuration, - isFileExtension, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({ // Update & expose - name: Thing.common.name('Unnamed Track'), - directory: Thing.common.directory(), - - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration}, - }, - - urls: Thing.common.urls(), - dateFirstReleased: Thing.common.simpleDate(), - - artistContribsByRef: Thing.common.contribsByRef(), - contributorContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), - - referencedTracksByRef: Thing.common.referenceList(Track), - sampledTracksByRef: Thing.common.referenceList(Track), - artTagsByRef: Thing.common.referenceList(ArtTag), - - hasCoverArt: { - flags: {update: true, expose: true}, - - update: { - validate(value) { - if (value !== false) { - throw new TypeError(`Expected false or null`); - } - - return true; - }, - }, - - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (hasCoverArt, { - albumData, - coverArtistContribsByRef, - [Track.instance]: track, - }) => - Track.hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ), - }, - }, - - coverArtFileExtension: { - flags: {update: true, expose: true}, - - update: {validate: isFileExtension}, - - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (coverArtFileExtension, { - albumData, - coverArtistContribsByRef, - hasCoverArt, - [Track.instance]: track, - }) => - coverArtFileExtension ?? - (Track.hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) - ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension - : Track.findAlbum(track, albumData)?.coverArtFileExtension) ?? - 'jpg', - }, - }, - - originalReleaseTrackByRef: Thing.common.singleReference(Track), - - dataSourceAlbumByRef: Thing.common.singleReference(Album), - - commentary: Thing.common.commentary(), - lyrics: Thing.common.simpleString(), - additionalFiles: Thing.common.additionalFiles(), - sheetMusicFiles: Thing.common.additionalFiles(), - midiProjectFiles: Thing.common.additionalFiles(), + name: name('Unnamed Track'), + directory: directory(), + + duration: duration(), + urls: urls(), + dateFirstReleased: simpleDate(), + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', + property: input.value('color'), + }), + + exposeDependencyOrContinue({dependency: '#trackSection.color'}), + + withPropertyFromAlbum({ + property: input.value('color'), + }), + + exposeDependency({dependency: '#album.color'}), + ], + + alwaysReferenceByDirectory: [ + withAlwaysReferenceByDirectory(), + exposeDependency({dependency: '#alwaysReferenceByDirectory'}), + ], + + // Disables presenting the track as though it has its own unique artwork. + // This flag should only be used in select circumstances, i.e. to override + // an album's trackCoverArtists. This flag supercedes that property, as well + // as the track's own coverArtists. + disableUniqueCoverArt: flag(), + + // File extension for track's corresponding media file. This represents the + // track's unique cover artwork, if any, and does not inherit the extension + // of the album's main artwork. It does inherit trackCoverArtFileExtension, + // if present on the album. + coverArtFileExtension: [ + exitWithoutUniqueCoverArt(), + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtFileExtension'), + }), + + exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), + + exposeConstant({ + value: input.value('jpg'), + }), + ], + + // Date of cover art release. Like coverArtFileExtension, this represents + // only the track's own unique cover artwork, if any. This exposes only as + // the track's own coverArtDate or its album's trackArtDate, so if neither + // is specified, this value is null. + coverArtDate: [ + withHasUniqueCoverArt(), + + exitWithoutDependency({ + dependency: '#hasUniqueCoverArt', + mode: input.value('falsy'), + }), + + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withPropertyFromAlbum({ + property: input.value('trackArtDate'), + }), + + exposeDependency({dependency: '#album.trackArtDate'}), + ], + + commentary: commentary(), + lyrics: simpleString(), + + additionalFiles: additionalFiles(), + sheetMusicFiles: additionalFiles(), + midiProjectFiles: additionalFiles(), + + originalReleaseTrack: singleReference({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + + // Internal use only - for directly identifying an album inside a track's + // util.inspect display, if it isn't indirectly available (by way of being + // included in an album's track list). + dataSourceAlbum: singleReference({ + class: input.value(Album), + find: input.value(find.album), + data: 'albumData', + }), + + artistContribs: [ + inheritFromOriginalRelease({ + property: input.value('artistContribs'), + }), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + }).outputs({ + '#resolvedContribs': '#artistContribs', + }), + + exposeDependencyOrContinue({ + dependency: '#artistContribs', + mode: input.value('empty'), + }), + + withPropertyFromAlbum({ + property: input.value('artistContribs'), + }), + + exposeDependency({dependency: '#album.artistContribs'}), + ], + + contributorContribs: [ + inheritFromOriginalRelease({ + property: input.value('contributorContribs'), + }), + + contributionList(), + ], + + // Cover artists aren't inherited from the original release, since it + // typically varies by release and isn't defined by the musical qualities + // of the track. + coverArtistContribs: [ + exitWithoutUniqueCoverArt({ + value: input.value([]), + }), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + }).outputs({ + '#resolvedContribs': '#coverArtistContribs', + }), + + exposeDependencyOrContinue({ + dependency: '#coverArtistContribs', + mode: input.value('empty'), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtistContribs'), + }), + + exposeDependency({dependency: '#album.trackCoverArtistContribs'}), + ], + + referencedTracks: [ + inheritFromOriginalRelease({ + property: input.value('referencedTracks'), + }), + + referenceList({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + ], + + sampledTracks: [ + inheritFromOriginalRelease({ + property: input.value('sampledTracks'), + }), + + referenceList({ + class: input.value(Track), + find: input.value(find.track), + data: 'trackData', + }), + ], + + artTags: referenceList({ + class: input.value(ArtTag), + find: input.value(find.artTag), + data: 'artTagData', + }), // Update only - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + artistData: wikiData(Artist), + artTagData: wikiData(ArtTag), + flashData: wikiData(Flash), + trackData: wikiData(Track), // Expose only - commentatorArtists: Thing.common.commentatorArtists(), - - album: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData'], - compute: ({[Track.instance]: track, albumData}) => - albumData?.find((album) => album.tracks.includes(track)) ?? null, - }, - }, - - // Note - this is an internal property used only to help identify a track. - // It should not be assumed in general that the album and dataSourceAlbum match - // (i.e. a track may dynamically be moved from one album to another, at - // which point dataSourceAlbum refers to where it was originally from, and is - // not generally relevant information). It's also not guaranteed that - // dataSourceAlbum is available (depending on the Track creator to optionally - // provide dataSourceAlbumByRef). - dataSourceAlbum: Thing.common.dynamicThingFromSingleReference( - 'dataSourceAlbumByRef', - 'albumData', - find.album - ), - - date: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - compute: ({albumData, dateFirstReleased, [Track.instance]: track}) => - dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null, - }, - }, - - color: { - flags: {update: true, expose: true}, - - update: {validate: isColor}, - - expose: { - dependencies: ['albumData'], - - transform: (color, {albumData, [Track.instance]: track}) => - color ?? - Track.findAlbum(track, albumData) - ?.trackSections.find(({tracks}) => tracks.includes(track)) - ?.color ?? null, - }, - }, - - coverArtDate: { - flags: {update: true, expose: true}, - - update: {validate: isDate}, - - expose: { - dependencies: [ - 'albumData', - 'coverArtistContribsByRef', - 'dateFirstReleased', - 'hasCoverArt', - ], - transform: (coverArtDate, { - albumData, - coverArtistContribsByRef, - dateFirstReleased, - hasCoverArt, - [Track.instance]: track, - }) => - (Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt) - ? coverArtDate ?? - dateFirstReleased ?? - Track.findAlbum(track, albumData)?.trackArtDate ?? - Track.findAlbum(track, albumData)?.date ?? - null - : null), - }, - }, - - hasUniqueCoverArt: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef', 'hasCoverArt'], - compute: ({ - albumData, - coverArtistContribsByRef, - hasCoverArt, - [Track.instance]: track, - }) => - Track.hasUniqueCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ), - }, - }, - - originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( - 'originalReleaseTrackByRef', - 'trackData', - find.track - ), - - otherReleases: { - flags: {expose: true}, - - expose: { - dependencies: ['originalReleaseTrackByRef', 'trackData'], - - compute: ({ - originalReleaseTrackByRef: t1origRef, - trackData, - [Track.instance]: t1, - }) => { - if (!trackData) { - return []; - } - - const t1orig = find.track(t1origRef, trackData); - - return [ - t1orig, - ...trackData.filter((t2) => { - const {originalReleaseTrack: t2orig} = t2; - return t2 !== t1 && t2orig && (t2orig === t1orig || t2orig === t1); - }), - ].filter(Boolean); - }, - }, - }, - - artistContribs: - Track.inheritFromOriginalRelease('artistContribs', [], - Thing.common.dynamicInheritContribs( - null, - 'artistContribsByRef', - 'artistContribsByRef', - 'albumData', - Track.findAlbum)), - - contributorContribs: - Track.inheritFromOriginalRelease('contributorContribs', [], - Thing.common.dynamicContribs('contributorContribsByRef')), - - // Cover artists aren't inherited from the original release, since it - // typically varies by release and isn't defined by the musical qualities - // of the track. - coverArtistContribs: - Thing.common.dynamicInheritContribs( - 'hasCoverArt', - 'coverArtistContribsByRef', - 'trackCoverArtistContribsByRef', - 'albumData', - Track.findAlbum), - - referencedTracks: - Track.inheritFromOriginalRelease('referencedTracks', [], - Thing.common.dynamicThingsFromReferenceList( - 'referencedTracksByRef', - 'trackData', - find.track)), - - sampledTracks: - Track.inheritFromOriginalRelease('sampledTracks', [], - Thing.common.dynamicThingsFromReferenceList( - 'sampledTracksByRef', - 'trackData', - find.track)), - - // Specifically exclude re-releases from this list - while it's useful to - // get from a re-release to the tracks it references, re-releases aren't - // generally relevant from the perspective of the tracks being referenced. - // Filtering them from data here hides them from the corresponding field - // on the site (obviously), and has the bonus of not counting them when - // counting the number of times a track has been referenced, for use in - // the "Tracks - by Times Referenced" listing page (or other data - // processing). - referencedByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({trackData, [Track.instance]: track}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.referencedTracks?.includes(track)) - : [], - }, - }, - - // For the same reasoning, exclude re-releases from sampled tracks too. - sampledByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({trackData, [Track.instance]: track}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.sampledTracks?.includes(track)) - : [], - }, - }, - - featuredInFlashes: Thing.common.reverseReferenceList( - 'flashData', - 'featuredTracks' - ), - - artTags: Thing.common.dynamicThingsFromReferenceList( - 'artTagsByRef', - 'artTagData', - find.artTag - ), - }); - - // This is a quick utility function for now, since the same code is reused in - // several places. Ideally it wouldn't be - we'd just reuse the `album` - // property - but support for that hasn't been coded yet :P - static findAlbum = (track, albumData) => - albumData?.find((album) => album.tracks.includes(track)); - - // Another reused utility function. This one's logic is a bit more complicated. - static hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) { - if (!empty(coverArtistContribsByRef)) { - return true; - } + commentatorArtists: commentatorArtists(), - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; - } + album: [ + withAlbum(), + exposeDependency({dependency: '#album'}), + ], - return false; - } + date: [ + exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), - static hasUniqueCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) { - if (!empty(coverArtistContribsByRef)) { - return true; - } + withPropertyFromAlbum({ + property: input.value('date'), + }), - if (hasCoverArt === false) { - return false; - } + exposeDependency({dependency: '#album.date'}), + ], - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; - } + hasUniqueCoverArt: [ + withHasUniqueCoverArt(), + exposeDependency({dependency: '#hasUniqueCoverArt'}), + ], - return false; - } + otherReleases: [ + withOtherReleases(), + exposeDependency({dependency: '#otherReleases'}), + ], - static inheritFromOriginalRelease( - originalProperty, - originalMissingValue, - ownPropertyDescriptor - ) { - return { - flags: {expose: true}, - - expose: { - dependencies: [ - ...ownPropertyDescriptor.expose.dependencies, - 'originalReleaseTrackByRef', - 'trackData', - ], - - compute(dependencies) { - const { - originalReleaseTrackByRef, - trackData, - } = dependencies; - - if (originalReleaseTrackByRef) { - if (!trackData) return originalMissingValue; - const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); - if (!original) return originalMissingValue; - return original[originalProperty]; - } - - return ownPropertyDescriptor.expose.compute(dependencies); - }, - }, - }; - } + referencedByTracks: trackReverseReferenceList({ + list: input.value('referencedTracks'), + }), - [inspect.custom]() { - const base = Thing.prototype[inspect.custom].apply(this); + sampledByTracks: trackReverseReferenceList({ + list: input.value('sampledTracks'), + }), - const rereleasePart = - (this.originalReleaseTrackByRef - ? `${color.yellow('[rerelease]')} ` - : ``); + featuredInFlashes: reverseReferenceList({ + data: 'flashData', + list: input.value('featuredTracks'), + }), + }); - const {album, dataSourceAlbum} = this; + [inspect.custom](depth) { + const parts = []; - const albumName = - (album - ? album.name - : dataSourceAlbum?.name); + parts.push(Thing.prototype[inspect.custom].apply(this)); - const albumIndex = - albumName && - (album - ? album.tracks.indexOf(this) - : dataSourceAlbum.tracks.indexOf(this)); + if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) { + parts.unshift(`${colors.yellow('[rerelease]')} `); + } - const trackNum = - albumName && + let album; + if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) { + const albumName = album.name; + const albumIndex = album.tracks.indexOf(this); + const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`); + parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`); + } - const albumPart = - albumName - ? ` (${color.yellow(trackNum)} in ${color.green(albumName)})` - : ``; - - return rereleasePart + base + albumPart; + return parts.join(''); } } diff --git a/src/data/things/validators.js b/src/data/things/validators.js index fc953c2a..ee301f15 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -1,7 +1,7 @@ import {inspect as nodeInspect} from 'node:util'; -import {color, ENABLE_COLOR} from '#cli'; -import {withAggregate} from '#sugar'; +import {colors, ENABLE_COLOR} from '#cli'; +import {empty, typeAppearance, withAggregate} from '#sugar'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); @@ -9,13 +9,13 @@ function inspect(value) { // Basic types (primitives) -function a(noun) { +export function a(noun) { return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; } -function isType(value, type) { +export function isType(value, type) { if (typeof value !== type) - throw new TypeError(`Expected ${a(type)}, got ${typeof value}`); + throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); return true; } @@ -132,7 +132,7 @@ export function isObject(value) { export function isArray(value) { if (typeof value !== 'object' || value === null || !Array.isArray(value)) - throw new TypeError(`Expected an array, got ${value}`); + throw new TypeError(`Expected an array, got ${typeAppearance(value)}`); return true; } @@ -174,7 +174,8 @@ function validateArrayItemsHelper(itemValidator) { throw new Error(`Expected validator to return true`); } } catch (error) { - error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`; + error.message = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)}) ${error.message}`; + error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index; throw error; } }; @@ -264,7 +265,7 @@ export function validateProperties(spec) { try { specValidator(value); } catch (error) { - error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`; + error.message = `(key: ${colors.green(specKey)}, value: ${inspect(value)}) ${error.message}`; throw error; } }); @@ -308,7 +309,7 @@ export const isTrackSection = validateProperties({ color: optional(isColor), dateOriginallyReleased: optional(isDate), isDefaultTrackSection: optional(isBoolean), - tracksByRef: optional(validateReferenceList('track')), + tracks: optional(validateReferenceList('track')), }); export const isTrackSectionList = validateArrayItems(isTrackSection); @@ -404,6 +405,76 @@ export function validateReferenceList(type = '') { return validateArrayItems(validateReference(type)); } +const validateWikiData_cache = {}; + +export function validateWikiData({ + referenceType = '', + allowMixedTypes = false, +}) { + if (referenceType && allowMixedTypes) { + throw new TypeError(`Don't specify both referenceType and allowMixedTypes`); + } + + validateWikiData_cache[referenceType] ??= {}; + validateWikiData_cache[referenceType][allowMixedTypes] ??= new WeakMap(); + + const isArrayOfObjects = validateArrayItems(isObject); + + return (array) => { + const subcache = validateWikiData_cache[referenceType][allowMixedTypes]; + if (subcache.has(array)) return subcache.get(array); + + let OK = false; + + try { + isArrayOfObjects(array); + + if (empty(array)) { + OK = true; return true; + } + + const allRefTypes = + new Set(array.map(object => + object.constructor[Symbol.for('Thing.referenceType')])); + + if (allRefTypes.has(undefined)) { + if (allRefTypes.size === 1) { + throw new TypeError(`Expected array of wiki data objects, got array of other objects`); + } else { + throw new TypeError(`Expected array of wiki data objects, got mixed items`); + } + } + + if (allRefTypes.size > 1) { + if (allowMixedTypes) { + OK = true; return true; + } + + const types = () => Array.from(allRefTypes).join(', '); + + if (referenceType) { + if (allRefTypes.has(referenceType)) { + allRefTypes.remove(referenceType); + throw new TypeError(`Expected array of only ${referenceType}, also got other types: ${types()}`) + } else { + throw new TypeError(`Expected array of only ${referenceType}, got other types: ${types()}`); + } + } + + throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`); + } + + if (referenceType && !allRefTypes.has(referenceType)) { + throw new TypeError(`Expected array of ${referenceType}, got array of ${allRefTypes[0]}`) + } + + OK = true; return true; + } finally { + subcache.set(array, OK); + } + }; +} + // Compositional utilities export function oneOf(...checks) { diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index e906cab1..6286a267 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,20 +1,25 @@ +import {input} from '#composite'; import find from '#find'; +import {isLanguageCode, isName, isURL} from '#validators'; + +import { + color, + flag, + name, + referenceList, + simpleString, + wikiData, +} from '#composite/wiki-properties'; import Thing from './thing.js'; export class WikiInfo extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Group, + static [Thing.friendlyName] = `Wiki Info`; - validators: { - isLanguageCode, - isName, - isURL, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose - name: Thing.common.name('Unnamed Wiki'), + name: name('Unnamed Wiki'), // Displayed in nav bar. nameShort: { @@ -27,12 +32,12 @@ export class WikiInfo extends Thing { }, }, - color: Thing.common.color(), + color: color(), // One-line description used for <meta rel="description"> tag. - description: Thing.common.simpleString(), + description: simpleString(), - footerContent: Thing.common.simpleString(), + footerContent: simpleString(), defaultLanguage: { flags: {update: true, expose: true}, @@ -44,25 +49,21 @@ export class WikiInfo extends Thing { update: {validate: isURL}, }, - divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), + divideTrackListsByGroups: referenceList({ + class: input.value(Group), + find: input.value(find.group), + data: 'groupData', + }), // Feature toggles - enableFlashesAndGames: Thing.common.flag(false), - enableListings: Thing.common.flag(false), - enableNews: Thing.common.flag(false), - enableArtTagUI: Thing.common.flag(false), - enableGroupUI: Thing.common.flag(false), + enableFlashesAndGames: flag(false), + enableListings: flag(false), + enableNews: flag(false), + enableArtTagUI: flag(false), + enableGroupUI: flag(false), // Update only - groupData: Thing.common.wikiData(Group), - - // Expose only - - divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList( - 'divideTrackListsByGroupsByRef', - 'groupData', - find.group - ), + groupData: wikiData(Group), }); } |