diff options
44 files changed, 5010 insertions, 1481 deletions
diff --git a/data-tests/index.js b/data-tests/index.js index b05de9ed..d0770907 100644 --- a/data-tests/index.js +++ b/data-tests/index.js @@ -3,7 +3,7 @@ import {fileURLToPath} from 'node:url'; import chokidar from 'chokidar'; -import {color, logError, logInfo, logWarn, parseOptions} from '#cli'; +import {colors, logError, logInfo, logWarn, parseOptions} from '#cli'; import {isMain} from '#node-utils'; import {getContextAssignments} from '#repl'; import {bindOpts, showAggregate} from '#sugar'; @@ -24,7 +24,7 @@ async function main() { } console.log(`HSMusic automated data tests`); - console.log(`${color.bright(color.yellow(`:star:`))} Now featuring quick-reloading! ${color.bright(color.cyan(`:earth:`))}`); + console.log(`${colors.bright(colors.yellow(`:star:`))} Now featuring quick-reloading! ${colors.bright(colors.cyan(`:earth:`))}`); // Watch adjacent files in data-tests directory const metaPath = fileURLToPath(import.meta.url); diff --git a/package.json b/package.json index 3b8e1771..a3f14d3a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "imports": { "#colors": "./src/util/colors.js", + "#composite": "./src/data/things/composite.js", "#content-dependencies": "./src/content/dependencies/index.js", "#content-function": "./src/content-function.js", "#cli": "./src/util/cli.js", diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index ce17ab21..51ea5927 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -44,7 +44,7 @@ export default { relations.coverArtistChronologyContributions = getChronologyRelations(album, { - contributions: album.coverArtistContribs, + contributions: album.coverArtistContribs ?? [], linkArtist: artist => relation('linkArtist', artist), diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js index f65b47c9..f92712f9 100644 --- a/src/content/dependencies/generateAlbumTrackListItem.js +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -1,4 +1,4 @@ -import {compareArrays} from '#sugar'; +import {compareArrays, empty} from '#sugar'; export default { contentDependencies: [ @@ -11,9 +11,11 @@ export default { relations(relation, track) { const relations = {}; - relations.contributionLinks = - track.artistContribs - .map(contrib => relation('linkContribution', contrib)); + if (!empty(track.artistContribs)) { + relations.contributionLinks = + track.artistContribs + .map(contrib => relation('linkContribution', contrib)); + } relations.trackLink = relation('linkTrack', track); @@ -31,10 +33,12 @@ export default { } data.showArtists = - !compareArrays( - track.artistContribs.map(c => c.who), - album.artistContribs.map(c => c.who), - {checkOrder: false}); + !empty(track.artistContribs) && + (empty(album.artistContribs) || + !compareArrays( + track.artistContribs.map(c => c.who), + album.artistContribs.map(c => c.who), + {checkOrder: false})); return data; }, diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 334c5422..7002204c 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -51,7 +51,10 @@ export default { relations.artistChronologyContributions = getChronologyRelations(track, { - contributions: [...track.artistContribs, ...track.contributorContribs], + contributions: [ + ...track.artistContribs ?? [], + ...track.contributorContribs ?? [], + ], linkArtist: artist => relation('linkArtist', artist), linkThing: track => relation('linkTrack', track), @@ -65,7 +68,7 @@ export default { relations.coverArtistChronologyContributions = getChronologyRelations(track, { - contributions: track.coverArtistContribs, + contributions: track.coverArtistContribs ?? [], linkArtist: artist => relation('linkArtist', artist), diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js index f001c3b3..65f5552b 100644 --- a/src/content/dependencies/generateTrackList.js +++ b/src/content/dependencies/generateTrackList.js @@ -1,4 +1,4 @@ -import {empty} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; export default { contentDependencies: ['linkTrack', 'linkContribution'], @@ -11,14 +11,17 @@ export default { } return { - items: tracks.map(track => ({ - trackLink: - relation('linkTrack', track), + trackLinks: + tracks + .map(track => relation('linkTrack', track)), - contributionLinks: - track.artistContribs - .map(contrib => relation('linkContribution', contrib)), - })), + contributionLinks: + tracks + .map(track => + (empty(track.artistContribs) + ? null + : track.artistContribs + .map(contrib => relation('linkContribution', contrib)))), }; }, @@ -28,22 +31,28 @@ export default { }, generate(relations, slots, {html, language}) { - return html.tag('ul', - relations.items.map(({trackLink, contributionLinks}) => - html.tag('li', - language.$('trackList.item.withArtists', { - track: trackLink, - by: - html.tag('span', {class: 'by'}, - language.$('trackList.item.withArtists.by', { - artists: - language.formatConjunctionList( - contributionLinks.map(link => - link.slots({ - showContribution: slots.showContribution, - showIcons: slots.showIcons, - }))), - })), - })))); + return ( + html.tag('ul', + stitchArrays({ + trackLink: relations.trackLinks, + contributionLinks: relations.contributionLinks, + }).map(({trackLink, contributionLinks}) => + html.tag('li', + (empty(contributionLinks) + ? trackLink + : language.$('trackList.item.withArtists', { + track: trackLink, + by: + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({ + showContribution: slots.showContribution, + showIcons: slots.showIcons, + }))), + })), + })))))); }, }; diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js index 99c1be55..cb0860f5 100644 --- a/src/content/dependencies/generateWikiHomeAlbumsRow.js +++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js @@ -16,7 +16,7 @@ export default { sprawl({albumData}, row) { const sprawl = {}; - switch (row.sourceGroupByRef) { + switch (row.sourceGroup) { case 'new-releases': sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData}); break; diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js index 3bc34845..71802050 100644 --- a/src/content/dependencies/index.js +++ b/src/content/dependencies/index.js @@ -6,7 +6,7 @@ import {fileURLToPath} from 'node:url'; import chokidar from 'chokidar'; import {ESLint} from 'eslint'; -import {color, logWarn} from '#cli'; +import {colors, logWarn} from '#cli'; import contentFunction, {ContentFunctionSpecError} from '#content-function'; import {annotateFunction} from '#sugar'; @@ -30,7 +30,6 @@ export function watchContentDependencies({ const contentDependencies = {}; let emittedReady = false; - let allDependenciesFulfilled = false; let closed = false; let _close = () => {}; @@ -77,12 +76,12 @@ export function watchContentDependencies({ // prematurely find out there aren't any nulls - before the nulls have // been entered at all!). - readdir(metaDirname).then(files => { + readdir(watchPath).then(files => { if (closed) { return; } - const filePaths = files.map(file => path.join(metaDirname, file)); + const filePaths = files.map(file => path.join(watchPath, file)); for (const filePath of filePaths) { if (filePath === metaPath) continue; const functionName = getFunctionName(filePath); @@ -91,7 +90,7 @@ export function watchContentDependencies({ } } - const watcher = chokidar.watch(metaDirname); + const watcher = chokidar.watch(watchPath); watcher.on('all', (event, filePath) => { if (!['add', 'change'].includes(event)) return; @@ -192,7 +191,7 @@ export function watchContentDependencies({ if (logging && emittedReady) { const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'}); - console.log(color.green(`[${timestamp}] Updated ${functionName}`)); + console.log(colors.green(`[${timestamp}] Updated ${functionName}`)); } contentDependencies[functionName] = fn; @@ -219,9 +218,9 @@ export function watchContentDependencies({ } if (typeof error === 'string') { - console.error(color.yellow(error)); + console.error(colors.yellow(error)); } else if (error instanceof ContentFunctionSpecError) { - console.error(color.yellow(error.message)); + console.error(colors.yellow(error.message)); } else { console.error(error); } diff --git a/src/data/things/album.js b/src/data/things/album.js index c012c243..c0042ae2 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,163 +1,242 @@ -import {empty} from '#sugar'; import find from '#find'; - -import Thing from './thing.js'; +import {empty, stitchArrays} from '#sugar'; +import {isDate, isTrackSectionList} from '#validators'; +import {filterMultipleArrays} from '#wiki-data'; + +import { + exitWithoutDependency, + exitWithoutUpdateValue, + exposeDependency, + exposeUpdateValueOrContinue, + input, + fillMissingListItems, + withFlattenedArray, + withPropertiesFromList, + withUnflattenedArray, +} from '#composite'; + +import Thing, { + additionalFiles, + commentary, + color, + commentatorArtists, + contribsPresent, + contributionList, + dimensions, + directory, + exitWithoutContribs, + fileExtension, + flag, + name, + referenceList, + simpleDate, + simpleString, + urls, + wikiData, + withResolvedReferenceList, +} 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, + name: name('Unnamed Album'), + color: color(), + directory: directory(), + urls: urls(), + + date: simpleDate(), + trackArtDate: simpleDate(), + dateAddedToWiki: simpleDate(), + + coverArtDate: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + exposeUpdateValueOrContinue(), + exposeDependency({ + dependency: 'date', + update: {validate: isDate}, + }), + ], + + 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: [ + exitWithoutDependency({dependency: 'trackData', value: []}), + exitWithoutUpdateValue({value: [], mode: 'empty'}), + + withPropertiesFromList({ + list: input.updateValue(), + prefix: input.value('#sections'), + properties: input.value([ + 'tracks', + 'dateOriginallyReleased', + 'isDefaultTrackSection', + 'color', + ]), + }), + + fillMissingListItems({list: '#sections.tracks', value: []}), + fillMissingListItems({list: '#sections.isDefaultTrackSection', value: false}), + fillMissingListItems({list: '#sections.color', dependency: 'color'}), + + withFlattenedArray({ + from: '#sections.tracks', + into: '#trackRefs', + intoIndices: '#sections.startIndex', + }), + + { + dependencies: ['#trackRefs'], + compute: ({'#trackRefs': tracks}, continuation) => { + console.log(tracks); + return continuation(); + } }, - 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)) ?? - [], - })); + withResolvedReferenceList({ + list: '#trackRefs', + data: 'trackData', + notFoundMode: 'null', + find: find.track, + into: '#tracks', + }), + + withUnflattenedArray({ + from: '#tracks', + fromIndices: '#sections.startIndex', + into: '#sections.tracks', + }), + + { + flags: {update: true, expose: true}, + + update: {validate: isTrackSectionList}, + + expose: { + dependencies: [ + '#sections.tracks', + '#sections.color', + '#sections.dateOriginallyReleased', + '#sections.isDefaultTrackSection', + '#sections.startIndex', + ], + + transform(trackSections, { + '#sections.tracks': tracks, + '#sections.color': color, + '#sections.dateOriginallyReleased': dateOriginallyReleased, + '#sections.isDefaultTrackSection': isDefaultTrackSection, + '#sections.startIndex': startIndex, + }) { + filterMultipleArrays( + tracks, color, dateOriginallyReleased, isDefaultTrackSection, startIndex, + tracks => !empty(tracks)); + + return stitchArrays({ + tracks, + color, + dateOriginallyReleased, + isDefaultTrackSection, + startIndex, + }); + } }, }, - }, + ], + + artistContribs: contributionList(), + coverArtistContribs: contributionList(), + trackCoverArtistContribs: contributionList(), + wallpaperArtistContribs: contributionList(), + bannerArtistContribs: contributionList(), + + groups: referenceList({ + class: Group, + find: find.group, + data: 'groupData', + }), + + artTags: referenceList({ + class: ArtTag, + find: find.artTag, + data: 'artTagData', + }), - coverArtFileExtension: Thing.common.fileExtension('jpg'), - trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), + // Update only - wallpaperStyle: Thing.common.simpleString(), - wallpaperFileExtension: Thing.common.fileExtension('jpg'), + artistData: wikiData(Artist), + artTagData: wikiData(ArtTag), + groupData: wikiData(Group), + trackData: wikiData(Track), - bannerStyle: Thing.common.simpleString(), - bannerFileExtension: Thing.common.fileExtension('jpg'), - bannerDimensions: { - flags: {update: true, expose: true}, - update: {validate: isDimensions}, - }, + // Expose only - hasTrackNumbers: Thing.common.flag(true), - isListedOnHomepage: Thing.common.flag(true), - isListedInGalleries: Thing.common.flag(true), + commentatorArtists: commentatorArtists(), - commentary: Thing.common.commentary(), - additionalFiles: Thing.common.additionalFiles(), + hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), + hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), - // Update only + tracks: [ + exitWithoutDependency({dependency: 'trackData', value: []}), + exitWithoutDependency({dependency: 'trackSections', mode: 'empty', value: []}), - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - groupData: Thing.common.wikiData(Group), - trackData: Thing.common.wikiData(Track), + { + dependencies: ['trackSections'], + compute: ({trackSections}, continuation) => + continuation({ + '#trackRefs': trackSections + .flatMap(section => section.tracks ?? []), + }), + }, - // Expose only + withResolvedReferenceList({ + list: '#trackRefs', + data: 'trackData', + find: find.track, + }), - 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 - ), + exposeDependency({dependency: '#resolvedReferenceList'}), + ], }); static [Thing.getSerializeDescriptors] = ({ @@ -202,9 +281,9 @@ export class Album extends Thing { export class TrackSectionHelper extends Thing { 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 Group'), + 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..7e466555 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,35 +1,45 @@ +import {exposeUpdateValueOrContinue} from '#composite'; import {sortAlbumsTracksChronologically} from '#wiki-data'; +import {isName} from '#validators'; -import Thing from './thing.js'; +import Thing, { + color, + directory, + flag, + name, + wikiData, +} from './thing.js'; export class ArtTag extends Thing { static [Thing.referenceType] = '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(), - expose: { + { dependencies: ['name'], - transform: (value, {name}) => - value ?? name.replace(/ \(.*?\)$/, ''), + compute: ({name}) => + name.replace(/ \([^)]*?\)$/, ''), }, - }, + + { + flags: {update: true, expose: true}, + validate: {isName}, + }, + ], // Update only - albumData: Thing.common.wikiData(Album), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + trackData: wikiData(Track), // Expose only @@ -37,8 +47,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 522ca5f9..7a9dbd3c 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -1,29 +1,30 @@ import find from '#find'; - -import Thing from './thing.js'; +import {isName, validateArrayItems} from '#validators'; + +import Thing, { + directory, + fileExtension, + flag, + name, + simpleString, + singleReference, + urls, + wikiData, +} 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 +32,23 @@ export class Artist extends Thing { expose: {transform: (names) => names ?? []}, }, - isAlias: Thing.common.flag(), - aliasedArtistRef: Thing.common.singleReference(Artist), + isAlias: flag(), + + aliasedArtist: singleReference({ + class: Artist, + find: 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 +60,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 +76,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)) ?? [], }, @@ -103,18 +97,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] = ({ @@ -148,15 +140,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..4bc3668d 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) { @@ -185,7 +181,7 @@ export default class CacheableObject { } } catch (error) { error.message = [ - `Property ${color.green(property)}`, + `Property ${colors.green(property)}`, `(${inspect(this[property])} -> ${inspect(newValue)}):`, error.message ].join(' '); @@ -250,20 +246,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 +350,12 @@ export default class CacheableObject { console.log(` - ${line}`); } } + + static getUpdateValue(object, key) { + if (!Object.hasOwn(object, key)) { + return undefined; + } + + return object.#propertyUpdateValues[key] ?? null; + } } diff --git a/src/data/things/composite.js b/src/data/things/composite.js new file mode 100644 index 00000000..c33fc03c --- /dev/null +++ b/src/data/things/composite.js @@ -0,0 +1,1831 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {oneOf} from '#validators'; +import {TupleMap} from '#wiki-data'; + +import { + empty, + filterProperties, + openAggregate, + decorateErrorWithIndex, +} from '#sugar'; + +// Composes multiple compositional "steps" and a "base" to form a property +// descriptor out of modular building blocks. This is an extension to the +// more general-purpose CacheableObject property descriptor syntax, and +// aims to make modular data processing - which lends to declarativity - +// much easier, without fundamentally altering much of the typical syntax +// or terminology, nor building on it to an excessive degree. +// +// Think of a composition as being a chain of steps which lead into a final +// base property, which is usually responsible for returning the value that +// will actually get exposed when the property being described is accessed. +// +// == The compositional base: == +// +// The final item in a compositional list is its base, and it identifies +// the essential qualities of the property descriptor. The compositional +// steps preceding it may exit early, in which case the expose function +// defined on the base won't be called; or they will provide dependencies +// that the base may use to compute the final value that gets exposed for +// this property. +// +// The base indicates the capabilities of the composition as a whole. +// It should be {expose: true}, since that's the only area that preceding +// compositional steps (currently) can actually influence. If it's also +// {update: true}, then the composition as a whole accepts an update value +// just like normal update-flag property descriptors - meaning it can be +// set with `thing.someProperty = value` and that value will be paseed +// into each (implementing) step's transform() function, as well as the +// base. Bases usually aren't {compose: true}, but can be - check out the +// section on "nesting compositions" for details about that. +// +// Every composition always has exactly one compositional base, and it's +// always the last item in the composition list. All items preceding it +// are compositional steps, described below. +// +// == Compositional steps: == +// +// Compositional steps are, in essence, typical property descriptors with +// the extra flag {compose: true}. They operate on existing dependencies, +// and are typically dynamically constructed by "utility" functions (but +// can also be manually declared within the step list of a composition). +// Compositional steps serve two purposes: +// +// 1. exit early, if some condition is matched, returning and exposing +// some value directly from that step instead of continuing further +// down the step list; +// +// 2. and/or provide new, dynamically created "private" dependencies which +// can be accessed by further steps down the list, or at the base at +// the bottom, modularly supplying information that will contribute to +// the final value exposed for this property. +// +// Usually it's just one of those two, but it's fine for a step to perform +// both jobs if the situation benefits. +// +// Compositional steps are the real "modular" or "compositional" part of +// this data processing style - they're designed to be combined together +// in dynamic, versatile ways, as each property demands it. You usually +// define a compositional step to be returned by some ordinary static +// property-descriptor-returning function (customarily namespaced under +// the relevant Thing class's static `composite` field) - that lets you +// reuse it in multiple compositions later on. +// +// Compositional steps are implemented with "continuation passing style", +// meaning the connection to the next link on the chain is passed right to +// each step's compute (or transform) function, and the implementation gets +// to decide whether to continue on that chain or exit early by returning +// some other value. +// +// Every step along the chain, apart from the base at the bottom, has to +// have the {compose: true} step. That means its compute() or transform() +// function will be passed an extra argument at the end, `continuation`. +// To provide new dependencies to items further down the chain, just pass +// them directly to this continuation() function, customarily with a hash +// ('#') prefixing each name - for example: +// +// compute({..some dependencies..}, continuation) { +// return continuation({ +// '#excitingProperty': (..a value made from dependencies..), +// }); +// } +// +// Performing an early exit is as simple as returning some other value, +// instead of the continuation. You may also use `continuation.exit(value)` +// to perform the exact same kind of early exit - it's just a different +// syntax that might fit in better in certain longer compositions. +// +// It may be fine to simply provide new dependencies under a hard-coded +// name, such as '#excitingProperty' above, but if you're writing a utility +// that dynamically returns the compositional step and you suspect you +// might want to use this step multiple times in a single composition, +// it's customary to accept a name for the result. +// +// Here's a detailed example showing off early exit, dynamically operating +// on a provided dependency name, and then providing a result in another +// also-provided dependency name: +// +// withResolvedContribs = ({ +// from: contribsByRefDependency, +// into: outputDependency, +// }) => ({ +// flags: {expose: true, compose: true}, +// expose: { +// dependencies: [contribsByRefDependency, 'artistData'], +// compute({ +// [contribsByRefDependency]: contribsByRef, +// artistData, +// }, continuation) { +// if (!artistData) return null; /* early exit! */ +// return continuation({ +// [outputDependency]: /* this is the important part */ +// (..resolve contributions one way or another..), +// }); +// }, +// }, +// }); +// +// And how you might work that into a composition: +// +// Track.coverArtists = +// compositeFrom([ +// doSomethingWhichMightEarlyExit(), +// +// withResolvedContribs({ +// from: 'coverArtistContribsByRef', +// into: '#coverArtistContribs', +// }), +// +// { +// flags: {expose: true}, +// expose: { +// dependencies: ['#coverArtistContribs'], +// compute: ({'#coverArtistContribs': coverArtistContribs}) => +// coverArtistContribs.map(({who}) => who), +// }, +// }, +// ]); +// +// One last note! A super common code pattern when creating more complex +// compositions is to have several steps which *only* expose and compose. +// As a syntax shortcut, you can skip the outer section. It's basically +// like writing out just the {expose: {...}} part. Remember that this +// indicates that the step you're defining is compositional, so you have +// to specify the flags manually for the base, even if this property isn't +// going to get an {update: true} flag. +// +// == Cache-safe dependency names: == +// +// [Disclosure: The caching engine hasn't actually been implemented yet. +// As such, this section is subject to change, and simply provides sound +// forward-facing advice and interfaces.] +// +// It's a good idea to write individual compositional steps in such a way +// that they're "cache-safe" - meaning the same input (dependency) values +// will always result in the same output (continuation or early exit). +// +// In order to facilitate this, compositional step descriptors may specify +// unique `mapDependencies`, `mapContinuation`, and `options` values. +// +// Consider the `withResolvedContribs` example adjusted to make use of +// two of these options below: +// +// withResolvedContribs = ({ +// from: contribsByRefDependency, +// into: outputDependency, +// }) => ({ +// flags: {expose: true, compose: true}, +// expose: { +// dependencies: ['artistData'], +// mapDependencies: {contribsByRef: contribsByRefDependency}, +// mapContinuation: {outputDependency}, +// compute({ +// contribsByRef, /* no longer in square brackets */ +// artistData, +// }, continuation) { +// if (!artistData) return null; +// return continuation({ +// outputDependency: /* no longer in square brackets */ +// (..resolve contributions one way or another..), +// }); +// }, +// }, +// }); +// +// With a little destructuring and restructuring JavaScript sugar, the +// above can be simplified some more: +// +// withResolvedContribs = ({from, to}) => ({ +// flags: {expose: true, compose: true}, +// expose: { +// dependencies: ['artistData'], +// mapDependencies: {from}, +// mapContinuation: {into}, +// compute({artistData, from: contribsByRef}, continuation) { +// if (!artistData) return null; +// return continuation({ +// into: (..resolve contributions one way or another..), +// }); +// }, +// }, +// }); +// +// These two properties let you separate the name-mapping behavior (for +// dependencies and the continuation) from the main body of the compute +// function. That means the compute function will *always* get inputs in +// the same form (dependencies 'artistData' and 'from' above), and will +// *always* provide its output in the same form (early return or 'to'). +// +// Thanks to that, this `compute` function is cache-safe! Its outputs can +// be cached corresponding to each set of mapped inputs. So it won't matter +// whether the `from` dependency is named `coverArtistContribsByRef` or +// `contributorContribsByRef` or something else - the compute function +// doesn't care, and only expects that value to be provided via its `from` +// argument. Likewise, it doesn't matter if the output should be sent to +// '#coverArtistContribs` or `#contributorContribs` or some other name; +// the mapping is handled automatically outside, and compute will always +// output its value to the continuation's `to`. +// +// Note that `mapDependencies` and `mapContinuation` should be objects of +// the same "shape" each run - that is, the values will change depending on +// outside context, but the keys are always the same. You shouldn't use +// `mapDependencies` to dynamically select more or fewer dependencies. +// If you need to dynamically select a range of dependencies, just specify +// them in the `dependencies` array like usual. The caching engine will +// understand that differently named `dependencies` indicate separate +// input-output caches should be used. +// +// The 'options' property makes it possible to specify external arguments +// that fundamentally change the behavior of the `compute` function, while +// still remaining cache-safe. It indicates that the caching engine should +// use a completely different input-to-output cache for each permutation +// of the 'options' values. This way, those functions are still cacheable +// at all; they'll just be cached separately for each set of option values. +// Values on the 'options' property will always be provided in compute's +// dependencies under '#options' (to avoid name conflicts with other +// dependencies). +// +// == To compute or to transform: == +// +// A compositional step can work directly on a property's stored update +// value, transforming it in place and either early exiting with it or +// passing it on (via continuation) to the next item(s) in the +// compositional step list. (If needed, these can provide dependencies +// the same way as compute functions too - just pass that object after +// the updated (or same) transform value in your call to continuation().) +// +// But in order to make them more versatile, compositional steps have an +// extra trick up their sleeve. If a compositional step implements compute +// and *not* transform, it can still be used in a composition targeting a +// property which updates! These retain their full dependency-providing and +// early exit functionality - they just won't be provided the update value. +// If a compute-implementing step returns its continuation, then whichever +// later step (or the base) next implements transform() will receive the +// update value that had so far been running - as well as any dependencies +// the compute() step returned, of course! +// +// Please note that a compositional step which transforms *should not* +// specify, in its flags, {update: true}. Just provide the transform() +// function in its expose descriptor; it will be automatically detected +// and used when appropriate. +// +// It's actually possible for a step to specify both transform and compute, +// in which case the transform() implementation will only be selected if +// the composition's base is {update: true}. It's not exactly known why you +// would want to specify unique-but-related transform and compute behavior, +// but the basic possibility was too cool to skip out on. +// +// == Nesting compositions: == +// +// Compositional steps are so convenient that you just might want to bundle +// them together, and form a whole new step-shaped unit of its own! +// +// In order to allow for this while helping to ensure internal dependencies +// remain neatly isolated from the composition which nests your bundle, +// the compositeFrom() function will accept and adapt to a base that +// specifies the {compose: true} flag, just like the steps preceding it. +// +// The continuation function that gets provided to the base will be mildly +// special - after all, nothing follows the base within the composition's +// own list! Instead of appending dependencies alongside any previously +// provided ones to be available to the next step, the base's continuation +// function should be used to define "exports" of the composition as a +// whole. It's similar to the usual behavior of the continuation, just +// expanded to the scope of the composition instead of following steps. +// +// For example, suppose your composition (which you expect to include in +// other compositions) brings about several private, hash-prefixed +// dependencies to contribute to its own results. Those dependencies won't +// end up "bleeding" into the dependency list of whichever composition is +// nesting this one - they will totally disappear once all the steps in +// the nested composition have finished up. +// +// To "export" the results of processing all those dependencies (provided +// that's something you want to do and this composition isn't used purely +// for a conditional early-exit), you'll want to define them in the +// continuation passed to the base. (Customarily, those should start with +// a hash just like the exports from any other compositional step; they're +// still dynamically provided dependencies!) +// +// Another way to "export" dependencies is by using calling *any* step's +// `continuation.raise()` function. This is sort of like early exiting, +// but instead of quitting out the whole entire property, it will just +// break out of the current, nested composition's list of steps, acting +// as though the composition had finished naturally. The dependencies +// passed to `raise` will be the ones which get exported. +// +// Since `raise` is another way to export dependencies, if you're using +// dynamic export names, you should specify `mapContinuation` on the step +// calling `continuation.raise` as well. +// +// An important note on `mapDependencies` here: A nested composition gets +// free access to all the ordinary properties defined on the thing it's +// working on, but if you want it to depend on *private* dependencies - +// ones prefixed with '#' - which were provided by some other compositional +// step preceding wherever this one gets nested, then you *have* to use +// `mapDependencies` to gain access. Check out the section on "cache-safe +// dependency names" for information on this syntax! +// +// Also - on rare occasion - you might want to make a reusable composition +// that itself causes the composition *it's* nested in to raise. If that's +// the case, give `composition.raiseAbove()` a go! This effectively means +// kicking out of *two* layers of nested composition - the one including +// the step with the `raiseAbove` call, and the composition which that one +// is nested within. You don't need to use `raiseAbove` if the reusable +// utility function just returns a single compositional step, but if you +// want to make use of other compositional steps, it gives you access to +// the same conditional-raise capabilities. +// +// Have some syntax sugar! Since nested compositions are defined by having +// the base be {compose: true}, the composition will infer as much if you +// don't specifying the base's flags at all. Simply use the same shorthand +// syntax as for other compositional steps, and it'll work out cleanly! +// + +const globalCompositeCache = {}; + +export function input(nameOrDescription) { + if (typeof nameOrDescription === 'string') { + return Symbol.for(`hsmusic.composite.input:${nameOrDescription}`); + } else { + return { + symbol: Symbol.for('hsmusic.composite.input'), + shape: 'input', + value: nameOrDescription, + }; + } +} + +input.symbol = Symbol.for('hsmusic.composite.input'); + +input.updateValue = () => Symbol.for('hsmusic.composite.input.updateValue'); +input.value = value => ({symbol: input.symbol, shape: 'input.value', value}); +input.dependency = name => Symbol.for(`hsmusic.composite.input.dependency:${name}`); +input.staticDependency = name => Symbol.for(`hsmusic.composite.input.staticDependency:${name}`); +input.staticValue = name => Symbol.for(`hsmusic.composite.input.staticValue:${name}`); + +function isInputToken(token) { + 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 ${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 ${token}`); + } + + if (typeof token === 'object') { + return token.value; + } else { + return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null; + } +} + +export function templateCompositeFrom(description) { + const compositeName = + (description.annotation + ? description.annotation + : `unnamed composite`); + + const descriptionAggregate = openAggregate({message: `Errors in description for ${compositeName}`}); + + if ('steps' in description) { + if (Array.isArray(description.steps)) { + descriptionAggregate.push(new TypeError(`Wrap steps array in a function`)); + } else if (typeof description.steps !== 'function') { + descriptionAggregate.push(new TypeError(`Expected steps to be a function (returning an array)`)); + } + } + + descriptionAggregate.nest({message: `Errors in input descriptions for ${compositeName}`}, ({push}) => { + const missingCallsToInput = []; + const wrongCallsToInput = []; + + for (const [name, value] of Object.entries(description.inputs ?? {})) { + if (!isInputToken(value)) { + missingCallsToInput.push(name); + continue; + } + + if (getInputTokenShape(value) !== 'input') { + 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(), got ${shape}`)); + } + }); + + descriptionAggregate.nest({message: `Errors in output descriptions for ${compositeName}`}, ({map, push}) => { + const wrongType = []; + const notPrivate = []; + + const missingDependenciesDefault = []; + const wrongDependenciesType = []; + const wrongDefaultType = []; + + for (const [name, value] of Object.entries(description.outputs ?? {})) { + if (typeof value === 'object') { + if (!('dependencies' in value && 'default' in value)) { + missingDependenciesDefault.push(name); + continue; + } + + if (!Array.isArray(value.dependencies)) { + wrongDependenciesType.push(name); + } + + if (typeof value.default !== 'function') { + wrongDefaultType.push(name); + } + + continue; + } + + if (typeof value !== 'string') { + wrongType.push(name); + continue; + } + + if (!value.startsWith('#')) { + notPrivate.push(name); + continue; + } + } + + for (const name of wrongType) { + const type = typeof description.outputs[name]; + push(new Error(`${name}: Expected string, got ${type}`)); + } + + for (const name of notPrivate) { + const into = description.outputs[name]; + push(new Error(`${name}: Expected "#" at start, got ${into}`)); + } + + for (const name of missingDependenciesDefault) { + push(new Error(`${name}: Expected both dependencies & default`)); + } + + for (const name of wrongDependenciesType) { + const {dependencies} = description.outputs[name]; + push(new Error(`${name}: Expected dependencies to be array, got ${dependencies}`)); + } + + for (const name of wrongDefaultType) { + const type = typeof description.outputs[name].default; + push(new Error(`${name}: Expected default to be function, got ${type}`)); + } + + for (const [name, value] of Object.entries(description.outputs ?? {})) { + if (typeof value !== 'object') continue; + + map( + description.outputs[name].dependencies, + decorateErrorWithIndex(dependency => { + if (!isInputToken(dependency)) { + throw new Error(`Expected call to input.staticValue or input.staticDependency, got ${dependency}`); + } + + const shape = getInputTokenShape(dependency); + if (shape !== 'input.staticValue' && shape !== 'input.staticDependency') { + throw new Error(`Expected call to input.staticValue or input.staticDependency, got ${shape}`); + } + }), + {message: `${name}: Errors in dependencies`}); + } + }); + + descriptionAggregate.close(); + + const expectedInputNames = + (description.inputs + ? Object.keys(description.inputs) + : []); + + const expectedOutputNames = + (description.outputs + ? Object.keys(description.outputs) + : []); + + const instantiate = (inputOptions = {}) => { + const inputOptionsAggregate = openAggregate({message: `Errors in input options passed to ${compositeName}`}); + + 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 = description.inputs[name].value; + if (!inputDescription) return true; + if ('defaultValue' in inputDescription) return false; + if ('defaultDependency' in inputDescription) return false; + if (inputDescription.null === true) return false; + return true; + }); + + const wrongTypeInputNames = []; + const wrongInputCallInputNames = []; + + for (const [name, value] of Object.entries(inputOptions)) { + if (misplacedInputNames.includes(name)) { + continue; + } + + if (typeof value !== 'string' && !isInputToken(value)) { + wrongTypeInputNames.push(name); + continue; + } + } + + if (!empty(misplacedInputNames)) { + inputOptionsAggregate.push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`)); + } + + if (!empty(missingInputNames)) { + inputOptionsAggregate.push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`)); + } + + for (const name of wrongTypeInputNames) { + const type = typeof inputOptions[name]; + inputOptionsAggregate.push(new Error(`${name}: Expected string or input() call, got ${type}`)); + } + + inputOptionsAggregate.close(); + + const outputOptions = {}; + + const instantiatedTemplate = { + symbol: templateCompositeFrom.symbol, + + outputs(providedOptions) { + const outputOptionsAggregate = openAggregate({message: `Errors in output options passed to ${compositeName}`}); + + const misplacedOutputNames = []; + const wrongTypeOutputNames = []; + // const notPrivateOutputNames = []; + + 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 (!value.startsWith('#')) { + notPrivateOutputNames.push(name); + continue; + } + */ + } + + if (!empty(misplacedOutputNames)) { + outputOptionsAggregate.push(new Error(`Unexpected output names: ${misplacedOutputNames}`)); + } + + for (const name of wrongTypeOutputNames) { + const type = typeof providedOptions[name]; + outputOptionsAggregate.push(new Error(`${name}: Expected string, got ${type}`)); + } + + /* + for (const name of notPrivateOutputNames) { + const into = providedOptions[name]; + outputOptionsAggregate.push(new Error(`${name}: Expected "#" at start, got ${into}`)); + } + */ + + outputOptionsAggregate.close(); + + Object.assign(outputOptions, providedOptions); + return instantiatedTemplate; + }, + + toDescription() { + const finalDescription = {}; + + if ('annotation' in description) { + finalDescription.annotation = description.annotation; + } + + if ('update' in description) { + finalDescription.update = description.update; + } + + if ('inputs' in description) { + const finalInputs = {}; + + for (const [name, description_] of Object.entries(description.inputs)) { + const description = description_; + if (name in inputOptions) { + if (typeof inputOptions[name] === 'string') { + finalInputs[name] = input.dependency(inputOptions[name]); + } else { + finalInputs[name] = inputOptions[name]; + } + } else if (description.defaultValue) { + finalInputs[name] = input.value(defaultValue); + } else if (description.defaultDependency) { + finalInputs[name] = input.dependency(defaultValue); + } else { + finalInputs[name] = input.value(null); + } + } + + finalDescription.inputs = finalInputs; + } + + if ('outputs' in description) { + const finalOutputs = {}; + + for (const [name, defaultDependency] of Object.entries(description.outputs)) { + if (name in outputOptions) { + finalOutputs[name] = outputOptions[name]; + } else { + finalOutputs[name] = defaultDependency; + } + } + + 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 ${compositeName}`}); + + const steps = ownDescription.steps(); + + const resolvedSteps = + aggregate.map( + steps, + decorateErrorWithIndex(step => + (step.symbol === templateCompositeFrom.symbol + ? 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 function compositeFrom(description) { + const {annotation, steps: composition} = 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: 0, colors: true, compact: true, breakLength: Infinity}) + : value))); + } else { + console.log(label, result); + } + } + }; + + const base = composition.at(-1); + const steps = composition.slice(); + + const aggregate = openAggregate({ + message: + `Errors preparing composition` + + (annotation ? ` (${annotation})` : ''), + }); + + const baseExposes = + (base.flags + ? base.flags.expose + : true); + + const baseUpdates = + (base.flags + ? base.flags.update + : false); + + const baseComposes = + (base.flags + ? base.flags.compose + : true); + + if (!baseExposes) { + aggregate.push(new TypeError(`All steps, including base, must expose`)); + } + + const exposeDependencies = new Set(); + + let anyStepsCompute = false; + let anyStepsTransform = false; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; + const message = + `Errors in step #${i + 1}` + + (isBase ? ` (base)` : ``) + + (step.annotation ? ` (${step.annotation})` : ``); + + aggregate.nest({message}, ({push}) => { + if (step.flags) { + let flagsErrored = false; + + if (!step.flags.compose && !isBase) { + push(new TypeError(`All steps but base must compose`)); + flagsErrored = true; + } + + if (!step.flags.expose) { + push(new TypeError(`All steps must expose`)); + flagsErrored = true; + } + + if (flagsErrored) { + return; + } + } + + const expose = + (step.flags + ? step.expose + : step); + + const stepComputes = !!expose?.compute; + const stepTransforms = !!expose?.transform; + + if ( + stepTransforms && !stepComputes && + !baseUpdates && !baseComposes + ) { + push(new TypeError(`Steps which only transform can't be composed with a non-updating base`)); + return; + } + + if (stepComputes) { + anyStepsCompute = true; + } + + if (stepTransforms) { + anyStepsTransform = true; + } + + // Unmapped dependencies are exposed on the final composition only if + // they're "public", i.e. pointing to update values of other properties + // on the CacheableObject. + for (const dependency of expose?.dependencies ?? []) { + if (typeof dependency === 'string' && dependency.startsWith('#')) { + continue; + } + + exposeDependencies.add(dependency); + } + + // Mapped dependencies are always exposed on the final composition. + // These are explicitly for reading values which are named outside of + // the current compositional step. + for (const dependency of Object.values(expose?.mapDependencies ?? {})) { + exposeDependencies.add(dependency); + } + }); + } + + if (!baseComposes && !baseUpdates && !anyStepsCompute) { + aggregate.push(new TypeError(`Expected at least one step to compute`)); + } + + aggregate.close(); + + function _filterDependencies(availableDependencies, { + dependencies, + mapDependencies, + options, + }) { + if (!dependencies && !mapDependencies && !options) { + return null; + } + + const filteredDependencies = + (dependencies + ? filterProperties(availableDependencies, dependencies) + : {}); + + if (mapDependencies) { + for (const [into, from] of Object.entries(mapDependencies)) { + filteredDependencies[into] = availableDependencies[from] ?? null; + } + } + + if (options) { + filteredDependencies['#options'] = options; + } + + return filteredDependencies; + } + + function _assignDependencies(continuationAssignment, {mapContinuation}) { + if (!mapContinuation) { + return continuationAssignment; + } + + const assignDependencies = {}; + + for (const [from, into] of Object.entries(mapContinuation)) { + assignDependencies[into] = continuationAssignment[from] ?? null; + } + + return assignDependencies; + } + + 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 (baseComposes) { + 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.raise = makeRaiseLike('raise'); + continuation.raiseAbove = makeRaiseLike('raiseAbove'); + } + + return {continuation, continuationStorage}; + } + + const continuationSymbol = Symbol.for('compositeFrom: continuation symbol'); + const noTransformSymbol = Symbol.for('compositeFrom: no-transform symbol'); + + function _computeOrTransform(initialValue, initialDependencies, continuationIfApplicable) { + const expectingTransform = initialValue !== noTransformSymbol; + + let valueSoFar = + (expectingTransform + ? initialValue + : undefined); + + const availableDependencies = {...initialDependencies}; + + 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; + } + } + + continue; + } + + const callingTransformForThisStep = + expectingTransform && expose.transform; + + let continuationStorage; + + const filteredDependencies = _filterDependencies(availableDependencies, expose); + + debug(() => [ + `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, + `with dependencies:`, filteredDependencies]); + + let result; + + const getExpectedEvaluation = () => + (callingTransformForThisStep + ? (filteredDependencies + ? ['transform', valueSoFar, filteredDependencies] + : ['transform', valueSoFar]) + : (filteredDependencies + ? ['compute', filteredDependencies] + : ['compute'])); + + const naturalEvaluate = () => { + const [name, ...args] = getExpectedEvaluation(); + let continuation; + ({continuation, continuationStorage} = _prepareContinuation(callingTransformForThisStep)); + return expose[name](...args, continuation); + } + + 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 (baseComposes) { + 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 (baseComposes) { + return continuationIfApplicable.exit(providedValue); + } else { + return providedValue; + } + } + + const {providedValue, providedDependencies} = continuationStorage; + + const continuingWithValue = + (expectingTransform + ? (callingTransformForThisStep + ? providedValue ?? null + : valueSoFar ?? null) + : undefined); + + const continuingWithDependencies = + (providedDependencies + ? _assignDependencies(providedDependencies, expose) + : null); + + const continuationArgs = []; + if (continuingWithValue !== undefined) continuationArgs.push(continuingWithValue); + if (continuingWithDependencies !== null) continuationArgs.push(continuingWithDependencies); + + debug(() => { + const base = `step #${i+1} - result: ` + returnedWith; + const parts = []; + + if (callingTransformForThisStep) { + if (continuingWithValue === undefined) { + parts.push(`(no value)`); + } else { + parts.push(`value:`, providedValue); + } + } + + if (continuingWithDependencies !== null) { + parts.push(`deps:`, continuingWithDependencies); + } else { + parts.push(`(no deps)`); + } + + if (empty(parts)) { + return base; + } else { + return [base + ' ->', ...parts]; + } + }); + + switch (returnedWith) { + case 'raise': + debug(() => + (isBase + ? colors.bright(`end composition - raise (base: explicit)`) + : colors.bright(`end composition - raise`))); + return continuationIfApplicable(...continuationArgs); + + case 'raiseAbove': + debug(() => colors.bright(`end composition - raiseAbove`)); + return continuationIfApplicable.raise(...continuationArgs); + + case 'continuation': + if (isBase) { + debug(() => colors.bright(`end composition - raise (inferred)`)); + return continuationIfApplicable(...continuationArgs); + } else { + Object.assign(availableDependencies, continuingWithDependencies); + break; + } + } + } + } + + const constructedDescriptor = {}; + + if (annotation) { + constructedDescriptor.annotation = annotation; + } + + constructedDescriptor.flags = { + update: baseUpdates, + expose: baseExposes, + compose: baseComposes, + }; + + if (baseUpdates) { + constructedDescriptor.update = base.update; + } + + if (baseExposes) { + const expose = constructedDescriptor.expose = {}; + expose.dependencies = Array.from(exposeDependencies); + + const transformFn = + (value, initialDependencies, continuationIfApplicable) => + _computeOrTransform(value, initialDependencies, continuationIfApplicable); + + const computeFn = + (initialDependencies, continuationIfApplicable) => + _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable); + + if (baseComposes) { + if (anyStepsTransform) expose.transform = transformFn; + if (anyStepsCompute) expose.compute = computeFn; + if (base.cacheComposition) expose.cache = base.cacheComposition; + } else if (baseUpdates) { + expose.transform = transformFn; + } else { + expose.compute = computeFn; + } + } + + 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; +} + +// Exposes a dependency exactly as it is; this is typically the base of a +// composition which was created to serve as one property's descriptor. +// Since this serves as a base, specify a value for {update} to indicate +// that the property as a whole updates (and some previous compositional +// step works with that update value). Set {update: true} to only enable +// the update flag, or set update to an object to specify a descriptor +// (e.g. for custom value validation). +// +// Please note that this *doesn't* verify that the dependency exists, so +// if you provide the wrong name or it hasn't been set by a previous +// compositional step, the property will be exposed as undefined instead +// of null. +// +export function exposeDependency({ + dependency, + update = false, +}) { + return { + annotation: `exposeDependency`, + flags: {expose: true, update: !!update}, + + expose: { + mapDependencies: {dependency}, + compute: ({dependency}) => dependency, + }, + + update: + (typeof update === 'object' + ? update + : null), + }; +} + +// Exposes a constant value exactly as it is; like exposeDependency, this +// is typically the base of a composition serving as a particular property +// descriptor. It generally follows steps which will conditionally early +// exit with some other value, with the exposeConstant base serving as the +// fallback default value. Like exposeDependency, set {update} to true or +// an object to indicate that the property as a whole updates. +export function exposeConstant({ + value, + update = false, +}) { + return { + annotation: `exposeConstant`, + flags: {expose: true, update: !!update}, + + expose: { + options: {value}, + compute: ({'#options': {value}}) => value, + }, + + update: + (typeof update === 'object' + ? update + : null), + }; +} + +// Checks the availability of a dependency and provides the result to later +// steps under '#availability' (by default). This is mainly intended for use +// by the more specific utilities, which you should consider using instead. +// Customize {mode} to select one of these modes, or default to 'null': +// +// * 'null': Check that the value isn't null (and not undefined either). +// * 'empty': Check that the value is neither null nor an empty array. +// This will outright error for undefined. +// * 'falsy': Check that the value isn't false when treated as a boolean +// (nor an empty array). Keep in mind this will also be false +// for values like zero and the empty string! +// + +const availabilityCheckModeInput = { + validate: oneOf('null', 'empty', 'falsy'), + defaultValue: 'null', +}; + +export const withResultOfAvailabilityCheck = templateCompositeFrom({ + annotation: `withResultOfAvailabilityCheck`, + + inputs: { + from: input(), + mode: input(availabilityCheckModeInput), + }, + + outputs: { + into: '#availability', + }, + + steps: () => [ + { + dependencies: [input('from'), input('mode')], + + compute: (continuation, { + [input('from')]: dependency, + [input('mode')]: mode, + }) => { + let availability; + + switch (mode) { + case 'null': + availability = value !== null && value !== undefined; + break; + + case 'empty': + availability = !empty(value); + break; + + case 'falsy': + availability = !!value && (!Array.isArray(value) || !empty(value)); + break; + } + + return continuation({into: availability}); + }, + }, + ], +}); + +// Exposes a dependency as it is, or continues if it's unavailable. +// See withResultOfAvailabilityCheck for {mode} options! +export const exposeDependencyOrContinue = templateCompositeFrom({ + annotation: `exposeDependencyOrContinue`, + + inputs: { + dependency: input(), + mode: input(availabilityCheckModeInput), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('dependency')], + compute: (continuation, { + ['#availability']: availability, + [input('dependency')]: dependency, + }) => + (availability + ? continuation.exit(dependency) + : continuation()), + }, + ], +}); + +// Exposes the update value of an {update: true} property as it is, +// or continues if it's unavailable. See withResultOfAvailabilityCheck +// for {mode} options! +export const exposeUpdateValueOrContinue = templateCompositeFrom({ + annotation: `exposeUpdateValueOrContinue`, + + inputs: { + mode: input(availabilityCheckModeInput), + }, + + steps: () => [ + exposeDependencyOrContinue({ + dependency: input.updateValue(), + mode: input('mode'), + }), + ], +}); + +// Early exits if a dependency isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export const exitWithoutDependency = templateCompositeFrom({ + annotation: `exitWithoutDependency`, + + inputs: { + dependency: input(), + mode: input(availabilityCheckModeInput), + value: input({null: true}), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('value')], + continuation: (continuation, { + ['#availability']: availability, + [input('value')]: value, + }) => + (availability + ? continuation() + : continuation.exit(value)), + }, + ], +}); + +// Early exits if this property's update value isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export const exitWithoutUpdateValue = templateCompositeFrom({ + annotation: `exitWithoutUpdateValue`, + + inputs: { + mode: input(availabilityCheckModeInput), + value: input({defaultValue: null}), + }, + + steps: () => [ + exitWithoutDependency({ + dependency: input.updateValue(), + mode: input('mode'), + }), + ], +}); + +// Raises if a dependency isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export const raiseOutputWithoutDependency = templateCompositeFrom({ + annotation: `raiseOutputWithoutDependency`, + + inputs: { + dependency: input(), + mode: input(availabilityCheckModeInput), + output: input({defaultValue: {}}), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('dependency'), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('output')], + compute: (continuation, { + ['#availability']: availability, + [input('output')]: output, + }) => + (availability + ? continuation() + : continuation.raiseOutputAbove(output)), + }, + ], +}); + +// Raises if this property's update value isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export const raiseOutputWithoutUpdateValue = templateCompositeFrom({ + annotation: `raiseOutputWithoutUpdateValue`, + + inputs: { + mode: input(availabilityCheckModeInput), + output: input({defaultValue: {}}), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input.updateValue(), + mode: input('mode'), + }), + + { + dependencies: ['#availability', input('output')], + compute: (continuation, { + ['#availability']: availability, + [input('output')]: output, + }) => + (availability + ? continuation() + : continuation.raiseOutputAbove(output)), + }, + ], +}); + +// Gets a property of some object (in a dependency) and provides that value. +// If the object itself is null, or the object doesn't have the listed property, +// the provided dependency will also be null. +export const withPropertyFromObject = templateCompositeFrom({ + annotation: `withPropertyFromObject`, + + inputs: { + object: input({type: 'object', null: true}), + property: input({type: 'string'}), + }, + + outputs: { + dependencies: [ + input.staticDependency('object'), + input.staticValue('property'), + ], + + compute: ({ + [input.staticDependency('object')]: object, + [input.staticValue('property')]: property, + }) => { + return ( + (object && property + ? (object.startsWith('#') + ? `${object}.${property}` + : `#${object}.${property}`) + : '#value')); + }, + }, + + steps: () => [ + { + dependencies: [input('object'), input('property')], + compute: (continuation, { + [input('object')]: object, + [input('property')]: property, + }) => + (object === null + ? continuation({into: null}) + : continuation({into: object[property] ?? null})), + }, + ], +}); + +// Gets the listed properties from some object, providing each property's value +// as a dependency prefixed with the same name as the object (by default). +// If the object itself is null, all provided dependencies will be null; +// if it's missing only select properties, those will be provided as null. +export function withPropertiesFromObject({ + object, + properties, + prefix = + (object.startsWith('#') + ? object + : `#${object}`), +}) { + return { + annotation: `withPropertiesFromObject`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {object}, + options: {prefix, properties}, + + compute: ({object, '#options': {prefix, properties}}, continuation) => + continuation( + Object.fromEntries( + properties.map(property => [ + `${prefix}.${property}`, + (object === null || object === undefined + ? null + : object[property] ?? null), + ]))), + }, + }; +} + +// Gets a property from each of a list of objects (in a dependency) and +// provides the results. This doesn't alter any list indices, so positions +// which were null in the original list are kept null here. Objects which don't +// have the specified property are retained in-place as null. +export function withPropertyFromList({ + list, + property, + into = null, +}) { + into ??= + (list.startsWith('#') + ? `${list}.${property}` + : `#${list}.${property}`); + + return { + annotation: `withPropertyFromList`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + mapContinuation: {into}, + options: {property}, + + compute({list, '#options': {property}}, continuation) { + if (list === undefined || empty(list)) { + return continuation({into: []}); + } + + return continuation({ + into: + list.map(item => + (item === null || item === undefined + ? null + : item[property] ?? null)), + }); + }, + }, + }; +} + +// Gets the listed properties from each of a list of objects, providing lists +// of property values each into a dependency prefixed with the same name as the +// list (by default). Like withPropertyFromList, this doesn't alter indices. +export function withPropertiesFromList({ + list, + properties, + prefix = + (list.startsWith('#') + ? list + : `#${list}`), +}) { + return { + annotation: `withPropertiesFromList`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + options: {prefix, properties}, + + compute({list, '#options': {prefix, properties}}, continuation) { + const lists = + Object.fromEntries( + properties.map(property => [`${prefix}.${property}`, []])); + + for (const item of list) { + for (const property of properties) { + lists[`${prefix}.${property}`].push( + (item === null || item === undefined + ? null + : item[property] ?? null)); + } + } + + return continuation(lists); + } + } + } +} + +// Replaces items of a list, which are null or undefined, with some fallback +// value, either a constant (set {value}) or from a dependency ({dependency}). +// By default, this replaces the passed dependency. +export function fillMissingListItems({ + list, + value, + dependency, + into = list, +}) { + if (value !== undefined && dependency !== undefined) { + throw new TypeError(`Don't provide both value and dependency`); + } + + if (value === undefined && dependency === undefined) { + throw new TypeError(`Missing value or dependency`); + } + + if (dependency) { + return { + annotation: `fillMissingListItems.fromDependency`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list, dependency}, + mapContinuation: {into}, + + compute: ({list, dependency}, continuation) => + continuation({ + into: list.map(item => item ?? dependency), + }), + }, + }; + } else { + return { + annotation: `fillMissingListItems.fromValue`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + mapContinuation: {into}, + options: {value}, + + compute: ({list, '#options': {value}}, continuation) => + continuation({ + into: list.map(item => item ?? value), + }), + }, + }; + } +} + +// Flattens an array with one level of nested arrays, providing as dependencies +// both the flattened array as well as the original starting indices of each +// successive source array. +export function withFlattenedArray({ + from, + into = '#flattenedArray', + intoIndices = '#flattenedIndices', +}) { + return { + annotation: `withFlattenedArray`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {from}, + mapContinuation: {into, intoIndices}, + + compute({from: sourceArray}, continuation) { + const into = sourceArray.flat(); + const intoIndices = []; + + let lastEndIndex = 0; + for (const {length} of sourceArray) { + intoIndices.push(lastEndIndex); + lastEndIndex += length; + } + + return continuation({into, intoIndices}); + }, + }, + }; +} + +// After mapping the contents of a flattened array in-place (being careful to +// retain the original indices by replacing unmatched results with null instead +// of filtering them out), this function allows for recombining them. It will +// filter out null and undefined items by default (pass {filter: false} to +// disable this). +export function withUnflattenedArray({ + from, + fromIndices = '#flattenedIndices', + into = '#unflattenedArray', + filter = true, +}) { + return { + annotation: `withUnflattenedArray`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {from, fromIndices}, + mapContinuation: {into}, + compute({from, fromIndices}, continuation) { + const arrays = []; + + for (let i = 0; i < fromIndices.length; i++) { + const startIndex = fromIndices[i]; + const endIndex = + (i === fromIndices.length - 1 + ? from.length + : fromIndices[i + 1]); + + const values = from.slice(startIndex, endIndex); + arrays.push( + (filter + ? values.filter(value => value !== null && value !== undefined) + : values)); + } + + return continuation({into: arrays}); + }, + }, + }; +} diff --git a/src/data/things/flash.js b/src/data/things/flash.js index 6eb5234f..eb16d29e 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -1,25 +1,32 @@ import find from '#find'; -import Thing from './thing.js'; +import { + isColor, + isDirectory, + isNumber, + isString, + oneOf, +} from '#validators'; + +import Thing, { + color, + contributionList, + fileExtension, + name, + referenceList, + simpleDate, + simpleString, + urls, + wikiData, +} 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,39 +54,35 @@ export class Flash extends Thing { }, }, - date: Thing.common.simpleDate(), + date: simpleDate(), - coverArtFileExtension: Thing.common.fileExtension('jpg'), + coverArtFileExtension: fileExtension('jpg'), - contributorContribsByRef: Thing.common.contribsByRef(), + contributorContribs: contributionList(), - featuredTracksByRef: Thing.common.referenceList(Track), + featuredTracks: referenceList({ + class: Track, + find: find.track, + data: 'trackData', + }), - urls: Thing.common.urls(), + urls: urls(), // Update only - artistData: Thing.common.wikiData(Artist), - trackData: Thing.common.wikiData(Track), - flashActData: Thing.common.wikiData(FlashAct), + artistData: wikiData(Artist), + trackData: wikiData(Track), + flashActData: wikiData(FlashAct), // Expose only - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), - - featuredTracks: Thing.common.dynamicThingsFromReferenceList( - 'featuredTracksByRef', - 'trackData', - find.track - ), - act: { flags: {expose: true}, expose: { - dependencies: ['flashActData'], + dependencies: ['this', 'flashActData'], - compute: ({flashActData, [Flash.instance]: flash}) => + compute: ({this: flash, flashActData}) => flashActData.find((act) => act.flashes.includes(flash)) ?? null, }, }, @@ -88,9 +91,9 @@ export class Flash extends Thing { flags: {expose: true}, expose: { - dependencies: ['flashActData'], + dependencies: ['this', 'flashActData'], - compute: ({flashActData, [Flash.instance]: flash}) => + compute: ({this: flash, flashActData}) => flashActData.find((act) => act.flashes.includes(flash))?.color ?? null, }, }, @@ -111,17 +114,13 @@ export class Flash extends Thing { } export class FlashAct extends Thing { - static [Thing.getPropertyDescriptors] = ({ - validators: { - isColor, - }, - }) => ({ + 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'), + color: color(), + anchor: simpleString(), + jump: simpleString(), jumpColor: { flags: {update: true, expose: true}, @@ -133,18 +132,14 @@ export class FlashAct extends Thing { } }, - flashesByRef: Thing.common.referenceList(Flash), + flashes: referenceList({ + class: Flash, + data: 'flashData', + find: find.flash, + }), // 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..f53fa48e 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,33 +1,41 @@ import find from '#find'; -import Thing from './thing.js'; +import Thing, { + color, + directory, + name, + referenceList, + simpleString, + urls, + wikiData, +} 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: Album, + find: 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 +49,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 +59,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 +70,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 +80,20 @@ export class Group extends Thing { } export class GroupCategory extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Group, - }) => ({ + 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: Group, + find: 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..007e0236 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -1,20 +1,36 @@ import find from '#find'; -import Thing from './thing.js'; +import { + compositeFrom, + exposeDependency, + input, +} from '#composite'; + +import { + is, + isCountingNumber, + isString, + isStringNonEmpty, + oneOf, + validateArrayItems, + validateInstanceOf, + validateReference, +} from '#validators'; + +import Thing, { + color, + name, + referenceList, + simpleString, + wikiData, + withResolvedReference, +} from './thing.js'; export class HomepageLayout extends Thing { - static [Thing.getPropertyDescriptors] = ({ - HomepageLayoutRow, - - validators: { - isStringNonEmpty, - validateArrayItems, - validateInstanceOf, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ // Update & expose - sidebarContent: Thing.common.simpleString(), + sidebarContent: simpleString(), navbarLinks: { flags: {update: true, expose: true}, @@ -32,13 +48,10 @@ export class HomepageLayout extends Thing { } export class HomepageLayoutRow extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Album, - Group, - }) => ({ + 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 +63,20 @@ 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.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), // Update & expose @@ -104,8 +107,36 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { }, }, - sourceGroupByRef: Thing.common.singleReference(Group), - sourceAlbumsByRef: Thing.common.referenceList(Album), + sourceGroup: compositeFrom(`HomepageLayoutAlbumsRow.sourceGroup`, [ + { + 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', + update: input.value({ + validate: + oneOf( + is('new-releases', 'new-additions'), + validateReference(Group[Thing.referenceType])), + }), + }), + ]), + + sourceAlbums: referenceList({ + class: Album, + find: find.album, + data: 'albumData', + }), countAlbumsFromGroup: { flags: {update: true, expose: true}, @@ -116,19 +147,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..4d8d9d1f 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'; @@ -82,6 +82,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 +121,7 @@ function descriptorAggregateHelper({ } function evaluatePropertyDescriptors() { - const opts = {...allClasses, validators}; + const opts = {...allClasses}; return descriptorAggregateHelper({ message: `Errors evaluating Thing class property descriptors`, @@ -129,8 +131,16 @@ function evaluatePropertyDescriptors() { throw new Error(`Missing [Thing.getPropertyDescriptors] function`); } - constructor.propertyDescriptors = - constructor[Thing.getPropertyDescriptors](opts); + const results = constructor[Thing.getPropertyDescriptors](opts); + + for (const [key, value] of Object.entries(results)) { + if (Array.isArray(value)) { + results[key] = compositeFrom(`${constructor.name}.${key}`, value); + continue; + } + } + + constructor.propertyDescriptors = results; }, showFailedClasses(failedClasses) { diff --git a/src/data/things/language.js b/src/data/things/language.js index afa9f1ee..a325d6a6 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,16 +1,16 @@ -import Thing from './thing.js'; - import {Tag} from '#html'; import {isLanguageCode} from '#validators'; import CacheableObject from './cacheable-object.js'; +import Thing, { + externalFunction, + flag, + simpleString, +} 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 @@ -23,7 +23,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 @@ -45,7 +45,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. @@ -73,7 +73,7 @@ export class Language extends Thing { // Update only - escapeHTML: Thing.common.externalFunction({expose: true}), + escapeHTML: externalFunction(), // Expose only @@ -192,7 +192,7 @@ export class Language extends Thing { // html.Tag objects, which are treated as sanitized by default (so that they // can be nested inside strings at all). #sanitizeStringArg(arg) { - const escapeHTML = this.escapeHTML; + const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); if (!escapeHTML) { throw new Error(`escapeHTML unavailable`); @@ -224,7 +224,7 @@ export class Language extends Thing { // contents of a slot directly, it should be manually sanitized with this // function first. sanitize(arg) { - const escapeHTML = this.escapeHTML; + const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); if (!escapeHTML) { throw new Error(`escapeHTML unavailable`); diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js index 43911410..6984874e 100644 --- a/src/data/things/news-entry.js +++ b/src/data/things/news-entry.js @@ -1,4 +1,9 @@ -import Thing from './thing.js'; +import Thing, { + directory, + name, + simpleDate, + simpleString, +} from './thing.js'; export class NewsEntry extends Thing { static [Thing.referenceType] = 'news-entry'; @@ -6,11 +11,11 @@ export class NewsEntry extends Thing { 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..0133e0b6 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -1,16 +1,18 @@ -import Thing from './thing.js'; +import {isName} from '#validators'; + +import Thing, { + directory, + name, + simpleString, +} from './thing.js'; export class StaticPage extends Thing { static [Thing.referenceType] = 'static'; - 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 +24,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 5705ee7e..a5f0b78d 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -3,10 +3,24 @@ import {inspect} from 'node:util'; -import {color} from '#cli'; +import {colors} from '#cli'; import find from '#find'; -import {empty} from '#sugar'; -import {getKebabCase} from '#wiki-data'; +import {stitchArrays, unique} from '#sugar'; +import {filterMultipleArrays, getKebabCase} from '#wiki-data'; +import {oneOf} from '#validators'; + +import { + compositeFrom, + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + input, + raiseOutputWithoutDependency, + templateCompositeFrom, + withResultOfAvailabilityCheck, + withPropertiesFromList, +} from '#composite'; import { isAdditionalFileList, @@ -15,10 +29,13 @@ import { isColor, isContributionList, isDate, + isDimensions, isDirectory, + isDuration, isFileExtension, isName, isString, + isType, isURL, validateArrayItems, validateInstanceOf, @@ -34,388 +51,695 @@ export default class Thing extends CacheableObject { 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}, - }), + // Default custom inspect function, which may be overridden by Thing + // subclasses. This will be used when displaying aggregate errors and other + // command-line logging - it's the place to provide information useful in + // identifying the Thing being presented. + [inspect.custom]() { + const cname = this.constructor.name; - color: () => ({ - flags: {update: true, expose: true}, - update: {validate: isColor}, - }), + return ( + (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) + + (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '') + ); + } - 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; - }, + static getReference(thing) { + if (!thing.constructor[Thing.referenceType]) { + throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`); + } + + if (!thing.directory) { + throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); + } + + return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; + } +} + +// Property descriptor templates +// +// 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! + +export function name(defaultName) { + return { + flags: {update: true, expose: true}, + update: {validate: isName, default: defaultName}, + }; +} + +export function color() { + return { + flags: {update: true, expose: true}, + update: {validate: isColor}, + }; +} + +export function directory() { + return { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + expose: { + dependencies: ['name'], + transform(directory, {name}) { + if (directory === null && name === null) return null; + else if (directory === null) return getKebabCase(name); + else return directory; }, - }), + }, + }; +} - urls: () => ({ - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)}, - expose: {transform: (value) => value ?? []}, - }), +export function urls() { + return { + 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}, - }), +// A file extension! Or the default, if provided when calling this. +export function fileExtension(defaultFileExtension = null) { + return { + flags: {update: true, expose: true}, + update: {validate: isFileExtension}, + expose: {transform: (value) => value ?? defaultFileExtension}, + }; +} + +// Plain ol' image dimensions. This is a two-item array of positive integers, +// corresponding to width and height respectively. +export function dimensions() { + return { + flags: {update: true, expose: true}, + update: {validate: isDimensions}, + }; +} + +// Duration! This is a number of seconds, possibly floating point, always +// at minimum zero. +export function duration() { + return { + flags: {update: true, expose: true}, + update: {validate: isDuration}, + }; +} + +// Straightforward flag descriptor for a variety of property purposes. +// Provide a default value, true or false! +export function flag(defaultValue = false) { + // TODO: ^ Are you actually kidding me + if (typeof defaultValue !== 'boolean') { + throw new TypeError(`Always set explicit defaults for flags!`); + } + + return { + flags: {update: true, expose: true}, + update: {validate: isBoolean, default: defaultValue}, + }; +} - // 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. +export function simpleDate() { + return { + 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. +export function simpleString() { + return { + 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. +export function externalFunction() { + return { + flags: {update: true}, + update: {validate: (t) => typeof t === 'function'}, + }; +} + +// Strong 'n sturdy contribution list, rolling a list of references (provided +// as this property's update value) and the resolved results (as get exposed) +// into one property. Update value will look something like this: +// +// [ +// {who: 'Artist Name', what: 'Viola'}, +// {who: 'artist:john-cena', what: null}, +// ... +// ] +// +// ...typically as processed from YAML, spreadsheet, or elsewhere. +// Exposes as the same, but with the "who" replaced with matches found in +// artistData - which means this always depends on an `artistData` property +// also existing on this object! +// +export function contributionList() { + return compositeFrom({ + annotation: `contributionList`, + + update: {validate: isContributionList}, + + steps: () => [ + withResolvedContribs({from: input.updateValue()}), + exposeDependencyOrContinue({dependency: '#resolvedContribs'}), + exposeConstant({value: []}), + ], + }); +} + +// Artist commentary! Generally present on tracks and albums. +export function commentary() { + return { + 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: [...]}, +// ... +// ] +// +export function additionalFiles() { + return { + flags: {update: true, expose: true}, + update: {validate: isAdditionalFileList}, + expose: { + transform: (additionalFiles) => + additionalFiles ?? [], }, + }; +} - // 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}, - }), +const thingClassInput = { + validate(thingClass) { + isType(thingClass, 'function'); - // 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}, - }), + if (!Object.hasOwn(thingClass, Thing.referenceType)) { + throw new TypeError(`Expected a Thing constructor, missing Thing.referenceType`); + } - // External function. These should only be used as dependencies for other - // properties, so they're left unexposed. - externalFunction: ({expose = false} = {}) => ({ - flags: {update: true, expose}, - update: {validate: (t) => typeof t === 'function'}, + return true; + }, +}; + +// 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. +export const referenceList = templateCompositeFrom({ + annotation: `referenceList`, + + compose: false, + + inputs: { + class: input(thingClassInput), + find: input({type: 'function'}), + + // todo: validate + data: input(), + }, + + update: { + dependencies: [ + input.staticValue('class'), + ], + + compute({ + [input.staticValue('class')]: thingClass, + }) { + const {[Thing.referenceType]: referenceType} = thingClass; + return {validate: validateReferenceList(referenceType)}; + }, + }, + + steps: () => [ + withResolvedReferenceList({ + list: input.updateValue(), + data: input('data'), + find: input('find'), }), - // 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}, + exposeDependency({dependency: '#resolvedReferenceList'}), + ], +}); + +// Corresponding function for a single reference. +export const singleReference = templateCompositeFrom({ + annotation: `singleReference`, + + compose: false, + + inputs: { + class: input(thingClassInput), + find: input({type: 'function'}), + + // todo: validate + data: input(), + }, + + update: { + dependencies: [ + input.staticValue('class'), + ], + + compute({ + [input.staticValue('class')]: thingClass, + }) { + const {[Thing.referenceType]: referenceType} = thingClass; + return {validate: validateReference(referenceType)}; + }, + }, + + steps: () => [ + withResolvedReference({ + ref: input.updateValue(), + data: input('data'), + find: input('findFunction'), }), - // Artist commentary! Generally present on tracks and albums. - commentary: () => ({ - flags: {update: true, expose: true}, - update: {validate: isCommentary}, + exposeDependency({dependency: '#resolvedReference'}), + ], +}); + +// Nice 'n simple shorthand for an exposed-only flag which is true when any +// contributions are present in the specified property. +export const contribsPresent = templateCompositeFrom({ + annotation: `contribsPresent`, + + compose: false, + + inputs: { + contribs: input({type: 'string'}), + }, + + steps: () => [ + withResultOfAvailabilityCheck({ + fromDependency: input('contribs'), + mode: input.value('empty'), }), - // 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 ?? [], - }, + exposeDependency({dependency: '#availability'}), + ], +}); + +// 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. +export const reverseReferenceList = templateCompositeFrom({ + annotation: `reverseReferenceList`, + + compose: false, + + inputs: { + // todo: validate + data: input(), + + list: input({type: 'string'}), + }, + + steps: () => [ + withReverseReferenceList({ + data: input('data'), + list: input('list'), }), - // 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)}, - }; + exposeDependency({dependency: '#reverseReferenceList'}), + ], +}); + +// General purpose wiki data constructor, for properties like artistData, +// trackData, etc. +export function wikiData(thingClass) { + return { + flags: {update: true}, + update: { + validate: validateArrayItems(validateInstanceOf(thingClass)), }, + }; +} - // 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)}, - }; +// This one's kinda tricky: it parses artist "references" from the +// commentary content, and finds the matching artist for each reference. +// This is mostly useful for credits and listings on artist pages. +export const commentatorArtists = templateCompositeFrom({ + annotation: `commentatorArtists`, + + compose: false, + + steps: () => [ + exitWithoutDependency({ + dependency: 'commentary', + mode: input.value('falsy'), + value: input.value([]), + }), + + { + dependencies: ['commentary'], + compute: (continuation, {commentary}) => + continuation({ + '#artistRefs': + Array.from( + commentary + .replace(/<\/?b>/g, '') + .matchAll(/<i>(?<who>.*?):<\/i>/g)) + .map(({groups: {who}}) => who), + }), }, - // 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 - ) => ({ + withResolvedReferenceList({ + list: '#artistRefs', + data: 'artistData', + find: input.value(find.artist), + }).outputs({ + '#resolvedReferenceList': '#artists', + }), + + { flags: {expose: true}, expose: { - dependencies: [referenceListProperty, thingDataProperty], - compute: ({ - [referenceListProperty]: refs, - [thingDataProperty]: thingData, - }) => - refs && thingData - ? refs - .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) - .filter(Boolean) - : [], + dependencies: ['#artists'], + compute: ({'#artists': artists}) => + unique(artists), }, + }, + ], +}); + +// Compositional utilities + +// Resolves the contribsByRef contained in the provided dependency, +// providing (named by the second argument) the result. "Resolving" +// means mapping the "who" reference of each contribution to an artist +// object, and filtering out those whose "who" doesn't match any artist. +export const withResolvedContribs = templateCompositeFrom({ + annotation: `withResolvedContribs`, + + inputs: { + // todo: validate + from: input(), + + findFunction: input({type: 'function'}), + + notFoundMode: input({ + validate: oneOf('exit', 'filter', 'null'), + defaultValue: 'null', }), + }, - // Corresponding function for a single reference. - dynamicThingFromSingleReference: ( - singleReferenceProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, + outputs: { + into: '#resolvedContribs', + }, - expose: { - dependencies: [singleReferenceProperty, thingDataProperty], - compute: ({ - [singleReferenceProperty]: ref, - [thingDataProperty]: thingData, - }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null), - }, + steps: () => [ + raiseOutputWithoutDependency({ + dependency: input('from'), + mode: input.value('empty'), + output: input.value({into: []}), }), - // 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) - : [], - }, + withPropertiesFromList({ + list: input('from'), + properties: input.value(['who', 'what']), + prefix: input.value('#contribs'), }), - // 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); - }, + withResolvedReferenceList({ + list: '#contribs.who', + data: 'artistData', + into: '#contribs.who', + find: input('find'), + notFoundMode: input('notFoundMode'), + }), + + { + dependencies: ['#contribs.who', '#contribs.what'], + + compute(continuation, { + ['#contribs.who']: who, + ['#contribs.what']: what, + }) { + filterMultipleArrays(who, what, (who, _what) => who); + return continuation({ + '#composition.into': stitchArrays({who, what}), + }); }, + }, + ], +}); + +// Shorthand for exiting if the contribution list (usually a property's update +// value) resolves to empty - ensuring that the later computed results are only +// returned if these contributions are present. +export const exitWithoutContribs = templateCompositeFrom({ + annotation: `exitWithoutContribs`, + + inputs: { + // todo: validate + contribs: input(), + + value: input({null: true}), + }, + + steps: () => [ + withResolvedContribs({ + from: input('contribs'), }), - // 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); - }, - } + withResultOfAvailabilityCheck({ + from: '#resolvedContribs', + mode: input.value('empty'), }), - // 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}, + { + dependencies: ['#availability', input('value')], + compute: (continuation, { + ['#availability']: availability, + [input('value')]: value, + }) => + (availability + ? continuation() + : continuation.exit(value)), + }, + ], +}); + +// Resolves a reference by using the provided find function to match it +// within the provided thingData dependency. This will early exit if the +// data dependency is null, or, if notFoundMode is set to 'exit', if the find +// function doesn't match anything for the reference. Otherwise, the data +// object is provided on the output dependency; or null, if the reference +// doesn't match anything or itself was null to begin with. +export const withResolvedReference = templateCompositeFrom({ + annotation: `withResolvedReference`, + + inputs: { + // todo: validate + ref: input(), + + // todo: validate + data: input(), + + find: input({type: 'function'}), + + notFoundMode: input({ + validate: oneOf('null', 'exit'), + defaultValue: 'null', + }), + }, - expose: { - dependencies: [thingDataProperty], + outputs: { + into: '#resolvedReference', + }, - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => - thingData?.filter(t => t[referencerRefListProperty].includes(thing)) ?? [], - }, + steps: () => [ + raiseOutputWithoutDependency({ + dependency: input('ref'), + output: input.value({into: null}), }), - // 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], + exitWithoutDependency({ + dependency: input('data'), + }), - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => - thingData?.filter((t) => t[referencerRefListProperty] === thing) ?? [], + { + dependencies: [ + input('ref'), + input('data'), + input('find'), + input('notFoundMode'), + ], + + compute({ + [input('ref')]: ref, + [input('data')]: data, + [input('find')]: findFunction, + [input('notFoundMode')]: notFoundMode, + }, continuation) { + const match = findFunction(ref, data, {mode: 'quiet'}); + + if (match === null && notFoundMode === 'exit') { + return continuation.exit(null); + } + + return continuation.raise({match}); }, + }, + ], +}); + +// Resolves a list of references, with each reference matched with provided +// data in the same way as withResolvedReference. This will early exit if the +// data dependency is null (even if the reference list is empty). By default +// it will filter out references which don't match, but this can be changed +// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null'). +export const withResolvedReferenceList = templateCompositeFrom({ + annotation: `withResolvedReferenceList`, + + inputs: { + // todo: validate + list: input(), + + // todo: validate + data: input(), + + find: input({type: 'function'}), + + notFoundMode: input({ + validate: oneOf('exit', 'filter', 'null'), + defaultValue: 'filter', }), + }, - // General purpose wiki data constructor, for properties like artistData, - // trackData, etc. - wikiData: (thingClass) => ({ - flags: {update: true}, - update: { - validate: validateArrayItems(validateInstanceOf(thingClass)), - }, + outputs: { + into: '#resolvedReferenceList', + }, + + steps: () => [ + exitWithoutDependency({ + dependency: input('data'), + value: input.value([]), }), - // 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}, + raiseOutputWithoutDependency({ + dependency: input('list'), + mode: input.value('empty'), + output: input.value({into: []}), + }), - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({artistData, commentary}) => - artistData && commentary - ? Array.from( - new Set( - Array.from( - commentary - .replace(/<\/?b>/g, '') - .matchAll(/<i>(?<who>.*?):<\/i>/g) - ).map(({groups: {who}}) => - find.artist(who, artistData, {mode: 'quiet'}) - ) - ) - ) - : [], + { + dependencies: [input('list'), input('data'), input('find')], + compute: ({ + [input('list')]: list, + [input('data')]: data, + [input('find')]: findFunction, + }, continuation) => + continuation({ + '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})), + }), + }, + + { + dependencies: ['#matches'], + compute: ({'#matches': matches}, continuation) => + (matches.every(match => match) + ? continuation.raise({'#continuation.into': matches}) + : continuation()), + }, + + { + dependencies: ['#matches', input('notFoundMode')], + compute({ + ['#matches']: matches, + [input('notFoundMode')]: notFoundMode, + }, continuation) { + switch (notFoundMode) { + case 'exit': + return continuation.exit([]); + + case 'filter': + matches = matches.filter(match => match); + return continuation.raise({'#continuation.into': matches}); + + case 'null': + matches = matches.map(match => match ?? null); + return continuation.raise({'#continuation.into': matches}); + + default: + throw new TypeError(`Expected notFoundMode to be exit, filter, or null`); + } }, - }), - }; + }, + ], +}); - // Default custom inspect function, which may be overridden by Thing - // subclasses. This will be used when displaying aggregate errors and other - // command-line logging - it's the place to provide information useful in - // identifying the Thing being presented. - [inspect.custom]() { - const cname = this.constructor.name; +// Check out the info on reverseReferenceList! +// This is its composable form. +export const withReverseReferenceList = templateCompositeFrom({ + annotation: `withReverseReferenceList`, - return ( - (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) + - (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '') - ); - } + inputs: { + // todo: validate + data: input(), - static getReference(thing) { - if (!thing.constructor[Thing.referenceType]) { - throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`); - } + list: input({type: 'string'}), + }, - if (!thing.directory) { - throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); - } + outputs: { + into: '#reverseReferenceList', + }, - return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; - } -} + steps: () => [ + exitWithoutDependency({ + dependency: '#composition.data', + value: [], + }), + + { + dependencies: [ + 'this', + '#composition.data', + '#composition.refListProperty', + ], + + compute: ({ + this: thisThing, + '#composition.data': data, + '#composition.refListProperty': refListProperty, + }, continuation) => + continuation({ + '#composition.into': + data.filter(thing => thing[refListProperty].includes(thisThing)), + }), + }, + ], +}); diff --git a/src/data/things/track.js b/src/data/things/track.js index 14510d96..28caf1de 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -1,330 +1,302 @@ import {inspect} from 'node:util'; -import {color} from '#cli'; +import {colors} from '#cli'; import find from '#find'; import {empty} from '#sugar'; -import Thing from './thing.js'; +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + input, + raiseOutputWithoutDependency, + templateCompositeFrom, + withPropertyFromObject, +} from '#composite'; + +import { + isColor, + isContributionList, + isDate, + isFileExtension, + oneOf, +} from '#validators'; + +import CacheableObject from './cacheable-object.js'; + +import Thing, { + additionalFiles, + commentary, + commentatorArtists, + contributionList, + directory, + duration, + flag, + name, + referenceList, + reverseReferenceList, + simpleDate, + singleReference, + simpleString, + urls, + wikiData, + withResolvedContribs, + withResolvedReference, + withReverseReferenceList, +} 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(), + name: name('Unnamed Track'), + directory: directory(), - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration}, - }, + duration: duration(), + urls: urls(), + dateFirstReleased: simpleDate(), + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), - urls: Thing.common.urls(), - dateFirstReleased: Thing.common.simpleDate(), + withContainingTrackSection(), + withPropertyFromObject({object: '#trackSection', property: 'color'}), + exposeDependencyOrContinue({dependency: '#trackSection.color'}), + + withPropertyFromAlbum({property: 'color'}), + exposeDependency({dependency: '#album.color'}), + ], // Controls how find.track works - it'll never be matched by a reference // just to the track's name, which means you don't have to always reference // some *other* (much more commonly referenced) track by directory instead // of more naturally by name. - alwaysReferenceByDirectory: { - flags: {update: true, expose: true}, - - // Deliberately defaults to null - this will fall back to false in most - // cases. - update: {validate: isBoolean, default: null}, - - expose: { - dependencies: ['name', 'originalReleaseTrackByRef', 'trackData'], - - transform(value, { - name, - originalReleaseTrackByRef, - trackData, - [Track.instance]: thisTrack, - }) { - if (value !== null) return value; - - const original = - find.track( - originalReleaseTrackByRef, - trackData.filter(track => track !== thisTrack), - {quiet: true}); - - if (!original) return false; - - return name === original.name; - } + alwaysReferenceByDirectory: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + excludeFromList({ + list: 'trackData', + item: input.myself(), + }), + + withOriginalRelease({ + data: '#trackData', + }), + + exitWithoutDependency({ + dependency: '#originalRelease', + value: input.value(false), + }), + + withPropertyFromObject({ + object: '#originalRelease', + property: input.value('name'), + }), + + { + dependencies: ['name', '#originalRelease.name'], + compute({name, '#originalRelease.name': originalName}) => + name === originalName, }, - }, - - artistContribsByRef: Thing.common.contribsByRef(), - contributorContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), + ], + + // 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(), + + withPropertyFromAlbum({property: 'trackCoverArtFileExtension'}), + exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), + + exposeConstant({ + value: 'jpg', + update: {validate: isFileExtension}, + }), + ], + + // Date of cover art release. Like coverArtFileExtension, this represents + // only the track's own unique cover artwork, if any. This exposes only as + // the track's own coverArtDate or its album's trackArtDate, so if neither + // is specified, this value is null. + coverArtDate: [ + withHasUniqueCoverArt(), + exitWithoutDependency({dependency: '#hasUniqueCoverArt', mode: 'falsy'}), + + exposeUpdateValueOrContinue(), + + withPropertyFromAlbum({property: 'trackArtDate'}), + exposeDependency({ + dependency: '#album.trackArtDate', + update: {validate: isDate}, + }), + ], + + commentary: commentary(), + lyrics: simpleString(), + + additionalFiles: additionalFiles(), + sheetMusicFiles: additionalFiles(), + midiProjectFiles: additionalFiles(), + + originalReleaseTrack: singleReference({ + class: Track, + find: 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: Album, + find: find.album, + data: 'albumData', + }), + + artistContribs: [ + inheritFromOriginalRelease({property: 'artistContribs'}), + + withResolvedContribs({ + from: input.updateValue(), + }).outputs({ + '#resolvedContribs': '#artistContribs', + }), + + exposeDependencyOrContinue({dependency: '#artistContribs'}), + + withPropertyFromAlbum({property: 'artistContribs'}), + exposeDependency({ + dependency: '#album.artistContribs', + update: {validate: isContributionList}, + }), + ], + + contributorContribs: [ + inheritFromOriginalRelease({property: 'contributorContribs'}), + contributionList(), + ], - 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(), + // 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(), + + withResolvedContribs({ + from: input.updateValue(), + }).outputs({ + '#resolvedContribs': '#coverArtistContribs', + }), + + exposeDependencyOrContinue({dependency: '#coverArtistContribs'}), + + withPropertyFromAlbum({property: 'trackCoverArtistContribs'}), + exposeDependency({ + dependency: '#album.trackCoverArtistContribs', + update: {validate: isContributionList}, + }), + ], + + referencedTracks: [ + inheritFromOriginalRelease({property: 'referencedTracks'}), + referenceList({ + class: Track, + find: find.track, + data: 'trackData', + }), + ], + + sampledTracks: [ + inheritFromOriginalRelease({property: 'sampledTracks'}), + referenceList({ + class: Track, + find: find.track, + data: 'trackData', + }), + ], + + artTags: referenceList({ + class: ArtTag, + find: find.artTag, + data: 'artTagData', + }), // Update only - 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); + commentatorArtists: commentatorArtists(), + + album: [ + withAlbum(), + exposeDependency({dependency: '#album'}), + ], + + date: [ + exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), + withPropertyFromAlbum({property: 'date'}), + exposeDependency({dependency: '#album.date'}), + ], + + // Whether or not the track has "unique" cover artwork - a cover which is + // specifically associated with this track in particular, rather than with + // the track's album as a whole. This is typically used to select between + // displaying the track artwork and a fallback, such as the album artwork + // or a placeholder. (This property is named hasUniqueCoverArt instead of + // the usual hasCoverArt to emphasize that it does not inherit from the + // album.) + hasUniqueCoverArt: [ + withHasUniqueCoverArt(), + exposeDependency({dependency: '#hasUniqueCoverArt'}), + ], + + otherReleases: [ + exitWithoutDependency({dependency: 'trackData', mode: 'empty'}), + withOriginalRelease({selfIfOriginal: true}), + + { + flags: {expose: true}, + expose: { + dependencies: ['this', 'trackData', '#originalRelease'], + compute: ({ + this: thisTrack, + trackData, + '#originalRelease': originalRelease, + }) => + (originalRelease === thisTrack + ? [] + : [originalRelease]) + .concat(trackData.filter(track => + track !== originalRelease && + track !== thisTrack && + track.originalReleaseTrack === originalRelease)), }, }, - }, - - 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 @@ -334,162 +306,370 @@ export class Track extends Thing { // 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)) - : [], - }, - }, + referencedByTracks: trackReverseReferenceList({ + list: 'referencedTracks', + }), // 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 - ), + sampledByTracks: trackReverseReferenceList({ + list: 'sampledTracks', + }), + + featuredInFlashes: reverseReferenceList({ + data: 'flashData', + list: 'featuredTracks', + }), }); - // 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; - } - - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; - } - - return false; - } + [inspect.custom](depth) { + const parts = []; - static hasUniqueCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) { - if (!empty(coverArtistContribsByRef)) { - return true; - } + parts.push(Thing.prototype[inspect.custom].apply(this)); - if (hasCoverArt === false) { - return false; + if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) { + parts.unshift(`${colors.yellow('[rerelease]')} `); } - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; + 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)})`); } - return false; + return parts.join(''); } +} - 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); - }, +// Early exits with a value inherited from the original release, if +// this track is a rerelease, and otherwise continues with no further +// dependencies provided. If allowOverride is true, then the continuation +// will also be called if the original release exposed the requested +// property as null. +export const inheritFromOriginalRelease = templateCompositeFrom({ + annotation: `Track.inheritFromOriginalRelease`, + + inputs: { + property: input({type: 'string'}), + allowOverride: input({type: 'boolean', defaultValue: false}), + }, + + steps: () => [ + withOriginalRelease(), + + { + dependencies: [ + '#originalRelease', + input('property'), + input('allowOverride'), + ], + + compute: (continuation, { + ['#originalRelease']: originalRelease, + [input('property')]: originalProperty, + [input('allowOverride')]: allowOverride, + }) => { + if (!originalRelease) return continuation(); + + const value = originalRelease[originalProperty]; + if (allowOverride && value === null) return continuation(); + + return continuation.exit(value); }, - }; - } - - [inspect.custom]() { - const base = Thing.prototype[inspect.custom].apply(this); + }, + ], +}); + +// Gets the track's album. This will early exit if albumData is missing. +// By default, if there's no album whose list of tracks includes this track, +// the output dependency will be null; set {notFoundMode: 'exit'} to early +// exit instead. +export const withAlbum = templateCompositeFrom({ + annotation: `Track.withAlbum`, + + inputs: { + notFoundMode: input({ + validate: oneOf('exit', 'null'), + defaultValue: 'null', + }), + }, + + outputs: { + into: '#album', + }, + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'albumData', + mode: input.value('empty'), + output: input.value({into: null}), + }), + + { + dependencies: ['this', 'albumData'], + compute: (continuation, {this: track, albumData}) => + continuation({ + '#album': albumData.find(album => album.tracks.includes(track)), + }), + }, - const rereleasePart = - (this.originalReleaseTrackByRef - ? `${color.yellow('[rerelease]')} ` - : ``); + raiseOutputWithoutDependency({ + dependency: '#album', + output: input.value({into: null}), + }), - const {album, dataSourceAlbum} = this; + { + dependencies: ['#album'], + compute: (continuation, {'#album': album}) => + continuation({into: album}), + }, + ], +}); + +// Gets a single property from this track's album, providing it as the same +// property name prefixed with '#album.' (by default). If the track's album +// isn't available, then by default, the property will be provided as null; +// set {notFoundMode: 'exit'} to early exit instead. +export const withPropertyFromAlbum = templateCompositeFrom({ + annotation: `withPropertyFromAlbum`, + + inputs: { + property: input.staticValue({type: 'string'}), + + notFoundMode: input({ + validate: oneOf('exit', 'null'), + defaultValue: 'null', + }), + }, + + outputs: { + dependencies: [input.staticValue('property')], + compute: ({ + [input.staticValue('property')]: property, + }) => ['#album.' + property], + }, + + steps: () => [ + withAlbum({ + notFoundMode: input('notFoundMode'), + }), + + withPropertyFromObject({ + object: '#album', + property: input('property'), + }), + + { + dependencies: ['#value', input.staticValue('property')], + compute: (continuation, { + ['#value']: value, + [input.staticValue('property')]: property, + }) => continuation({ + ['#album.' + property]: value, + }), + }, + ], +}); + +// Gets the track section containing this track from its album's track list. +// If notFoundMode is set to 'exit', this will early exit if the album can't be +// found or if none of its trackSections includes the track for some reason. +export const withContainingTrackSection = templateCompositeFrom({ + annotation: `withContainingTrackSection`, + + inputs: { + notFoundMode: input({ + validate: oneOf('exit', 'null'), + defaultValue: 'null', + }), + }, + + outputs: { + into: '#trackSection', + }, + + steps: () => [ + withPropertyFromAlbum({ + property: input.value('trackSections'), + notFoundMode: input('notFoundMode'), + }), + + { + dependencies: [ + input.myself(), + input('notFoundMode'), + '#album.trackSections', + ], + + compute(continuation, { + [input.myself()]: track, + [input('notFoundMode')]: notFoundMode, + ['#album.trackSections']: trackSections, + }) { + if (!trackSections) { + return continuation({into: null}); + } - const albumName = - (album - ? album.name - : dataSourceAlbum?.name); + const trackSection = + trackSections.find(({tracks}) => tracks.includes(track)); - const albumIndex = - albumName && - (album - ? album.tracks.indexOf(this) - : dataSourceAlbum.tracks.indexOf(this)); + if (trackSection) { + return continuation({into: trackSection}); + } else if (notFoundMode === 'exit') { + return continuation.exit(null); + } else { + return continuation({into: null}); + } + }, + }, + ], +}); + +// Just includes the original release of this track as a dependency. +// If this track isn't a rerelease, then it'll provide null, unless the +// {selfIfOriginal} option is set, in which case it'll provide this track +// itself. Note that this will early exit if the original release is +// specified by reference and that reference doesn't resolve to anything. +// Outputs to '#originalRelease' by default. +export const withOriginalRelease = templateCompositeFrom({ + annotation: `withOriginalRelease`, + + inputs: { + selfIfOriginal: input({type: 'boolean', defaultValue: false}), + + // todo: validate + data: input({defaultDependency: 'trackData'}), + }, + + outputs: { + into: '#originalRelease', + }, + + steps: () => [ + withResolvedReference({ + ref: 'originalReleaseTrack', + data: input('data'), + find: input.value(find.track), + notFoundMode: input.value('exit'), + }).outputs({ + '#resolvedReference': '#originalRelease', + }), + + { + dependencies: [ + input.myself(), + input('selfIfOriginal'), + '#originalRelease', + ], + + compute: (continuation, { + [input.myself()]: track, + [input('selfIfOriginal')]: selfIfOriginal, + ['#originalRelease']: originalRelease, + }) => + continuation({ + into: + (originalRelease ?? + (selfIfOriginal + ? track + : null)), + }), + }, + ], +}); + +// The algorithm for checking if a track has unique cover art is used in a +// couple places, so it's defined in full as a compositional step. +export const withHasUniqueCoverArt = templateCompositeFrom({ + annotation: 'withHasUniqueCoverArt', + + outputs: { + into: '#hasUniqueCoverArt', + }, + + steps: () => [ + { + dependencies: ['disableUniqueCoverArt'], + compute: (continuation, {disableUniqueCoverArt}) => + (disableUniqueCoverArt + ? continuation.raiseOutput({into: false}) + : continuation()), + }, - const trackNum = - albumName && - (albumIndex === -1 - ? '#?' - : `#${albumIndex + 1}`); + withResolvedContribs + .inputs({from: 'coverArtistContribs'}) + .outputs({into: '#coverArtistContribs'}), + + { + dependencies: ['#coverArtistContribs'], + compute: (continuation, { + ['#coverArtistContribs']: contribsFromTrack, + }) => + (empty(contribsFromTrack) + ? continuation() + : continuation.raiseOutput({into: true})), + }, - const albumPart = - albumName - ? ` (${color.yellow(trackNum)} in ${color.green(albumName)})` - : ``; + withPropertyFromAlbum({property: 'trackCoverArtistContribs'}), - return rereleasePart + base + albumPart; - } -} + { + dependencies: ['#album.trackCoverArtistContribs'], + compute: (continuation, { + ['#album.trackCoverArtistContribs']: contribsFromAlbum, + }) => + continuation({ + into: !empty(contribsFromAlbum), + }), + }, + ], +}); + +// Shorthand for checking if the track has unique cover art and exposing a +// fallback value if it isn't. +export const exitWithoutUniqueCoverArt = templateCompositeFrom({ + annotation: `exitWithoutUniqueCoverArt`, + + inputs: { + value: input({null: true}), + }, + + steps: () => [ + withHasUniqueCoverArt(), + + exitWithoutDependency({ + dependency: '#hasUniqueCoverArt', + mode: 'falsy', + value: input('value'), + }), + ], +}); + +export const trackReverseReferenceList = templateCompositeFrom({ + annotation: `trackReverseReferenceList`, + + inputs: { + list: input({type: 'string'}), + }, + + steps: () => [ + withReverseReferenceList({ + data: 'trackData', + list: input('list'), + }), + + { + flags: {expose: true}, + expose: { + dependencies: ['#reverseReferenceList'], + compute: ({ + ['#reverseReferenceList']: reverseReferenceList, + }) => + reverseReferenceList.filter(track => !track.originalReleaseTrack), + }, + }, + ], +}); diff --git a/src/data/things/validators.js b/src/data/things/validators.js index 5748eacf..f0d1d9fd 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -1,6 +1,6 @@ import {inspect as nodeInspect} from 'node:util'; -import {color, ENABLE_COLOR} from '#cli'; +import {colors, ENABLE_COLOR} from '#cli'; import {withAggregate} from '#sugar'; function inspect(value) { @@ -174,7 +174,7 @@ function validateArrayItemsHelper(itemValidator) { throw new Error(`Expected validator to return true`); } } catch (error) { - error.message = `(index: ${color.yellow(`#${index}`)}, item: ${inspect(item)}) ${error.message}`; + error.message = `(index: ${colors.yellow(`#${index}`)}, item: ${inspect(item)}) ${error.message}`; throw error; } }; @@ -264,7 +264,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 +308,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); diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index e906cab1..7c2de324 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,20 +1,20 @@ import find from '#find'; +import {isLanguageCode, isName, isURL} from '#validators'; -import Thing from './thing.js'; +import Thing, { + color, + flag, + name, + referenceList, + simpleString, + wikiData, +} from './thing.js'; export class WikiInfo extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Group, - - 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 +27,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 +44,21 @@ export class WikiInfo extends Thing { update: {validate: isURL}, }, - divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), + divideTrackListsByGroups: referenceList({ + class: Group, + find: find.group, + data: 'groupData', + }), // Feature toggles - enableFlashesAndGames: Thing.common.flag(false), - enableListings: Thing.common.flag(false), - enableNews: Thing.common.flag(false), - enableArtTagUI: Thing.common.flag(false), - enableGroupUI: Thing.common.flag(false), + enableFlashesAndGames: flag(false), + enableListings: flag(false), + enableNews: flag(false), + enableArtTagUI: flag(false), + enableGroupUI: flag(false), // Update only - groupData: Thing.common.wikiData(Group), - - // Expose only - - divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList( - 'divideTrackListsByGroupsByRef', - 'groupData', - find.group - ), + groupData: wikiData(Group), }); } diff --git a/src/data/yaml.js b/src/data/yaml.js index 07e0a3d2..c799be5f 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -7,10 +7,10 @@ import {inspect as nodeInspect} from 'node:util'; import yaml from 'js-yaml'; -import {color, ENABLE_COLOR, logInfo, logWarn} from '#cli'; +import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; import find, {bindFind} from '#find'; import {traverse} from '#node-utils'; -import T from '#things'; +import T, {CacheableObject, Thing} from '#things'; import { conditionallySuppressError, @@ -137,7 +137,7 @@ function makeProcessDocument( const name = document[nameField]; error.message = name ? `(name: ${inspect(name)}) ${error.message}` - : `(${color.dim(`no name found`)}) ${error.message}`; + : `(${colors.dim(`no name found`)}) ${error.message}`; throw error; } }; @@ -195,7 +195,7 @@ function makeProcessDocument( const thing = Reflect.construct(thingClass, []); - withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({call}) => { + withAggregate({message: `Errors applying ${colors.green(thingClass.name)} properties`}, ({call}) => { for (const [property, value] of Object.entries(sourceProperties)) { call(() => (thing[property] = value)); } @@ -228,7 +228,7 @@ makeProcessDocument.FieldCombinationsError = class FieldCombinationsError extend makeProcessDocument.FieldCombinationError = class FieldCombinationError extends Error { constructor(fields, message) { const fieldNames = Object.keys(fields); - const combinePart = `Don't combine ${fieldNames.map(field => color.red(field)).join(', ')}`; + const combinePart = `Don't combine ${fieldNames.map(field => colors.red(field)).join(', ')}`; const messagePart = (typeof message === 'function' @@ -278,11 +278,11 @@ export const processAlbumDocument = makeProcessDocument(T.Album, { coverArtFileExtension: 'Cover Art File Extension', trackCoverArtFileExtension: 'Track Art File Extension', - wallpaperArtistContribsByRef: 'Wallpaper Artists', + wallpaperArtistContribs: 'Wallpaper Artists', wallpaperStyle: 'Wallpaper Style', wallpaperFileExtension: 'Wallpaper File Extension', - bannerArtistContribsByRef: 'Banner Artists', + bannerArtistContribs: 'Banner Artists', bannerStyle: 'Banner Style', bannerFileExtension: 'Banner File Extension', bannerDimensions: 'Banner Dimensions', @@ -290,11 +290,11 @@ export const processAlbumDocument = makeProcessDocument(T.Album, { commentary: 'Commentary', additionalFiles: 'Additional Files', - artistContribsByRef: 'Artists', - coverArtistContribsByRef: 'Cover Artists', - trackCoverArtistContribsByRef: 'Default Track Cover Artists', - groupsByRef: 'Groups', - artTagsByRef: 'Art Tags', + artistContribs: 'Artists', + coverArtistContribs: 'Cover Artists', + trackCoverArtistContribs: 'Default Track Cover Artists', + groups: 'Groups', + artTags: 'Art Tags', }, }); @@ -316,6 +316,10 @@ export const processTrackDocument = makeProcessDocument(T.Track, { 'Date First Released': (value) => new Date(value), 'Cover Art Date': (value) => new Date(value), + 'Has Cover Art': (value) => + (value === true ? false : + value === false ? true : + value), 'Artists': parseContributors, 'Contributors': parseContributors, @@ -336,7 +340,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, { dateFirstReleased: 'Date First Released', coverArtDate: 'Cover Art Date', coverArtFileExtension: 'Cover Art File Extension', - hasCoverArt: 'Has Cover Art', + disableUniqueCoverArt: 'Has Cover Art', // This gets transformed to flip true/false. alwaysReferenceByDirectory: 'Always Reference By Directory', @@ -346,13 +350,13 @@ export const processTrackDocument = makeProcessDocument(T.Track, { sheetMusicFiles: 'Sheet Music Files', midiProjectFiles: 'MIDI Project Files', - originalReleaseTrackByRef: 'Originally Released As', - referencedTracksByRef: 'Referenced Tracks', - sampledTracksByRef: 'Sampled Tracks', - artistContribsByRef: 'Artists', - contributorContribsByRef: 'Contributors', - coverArtistContribsByRef: 'Cover Artists', - artTagsByRef: 'Art Tags', + originalReleaseTrack: 'Originally Released As', + referencedTracks: 'Referenced Tracks', + sampledTracks: 'Sampled Tracks', + artistContribs: 'Artists', + contributorContribs: 'Contributors', + coverArtistContribs: 'Cover Artists', + artTags: 'Art Tags', }, invalidFieldCombinations: [ @@ -422,8 +426,8 @@ export const processFlashDocument = makeProcessDocument(T.Flash, { date: 'Date', coverArtFileExtension: 'Cover Art File Extension', - featuredTracksByRef: 'Featured Tracks', - contributorContribsByRef: 'Contributors', + featuredTracks: 'Featured Tracks', + contributorContribs: 'Contributors', }, }); @@ -468,7 +472,7 @@ export const processGroupDocument = makeProcessDocument(T.Group, { description: 'Description', urls: 'URLs', - featuredAlbumsByRef: 'Featured Albums', + featuredAlbums: 'Featured Albums', }, }); @@ -499,7 +503,7 @@ export const processWikiInfoDocument = makeProcessDocument(T.WikiInfo, { footerContent: 'Footer Content', defaultLanguage: 'Default Language', canonicalBase: 'Canonical Base', - divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups', + divideTrackListsByGroups: 'Divide Track Lists By Groups', enableFlashesAndGames: 'Enable Flashes & Games', enableListings: 'Enable Listings', enableNews: 'Enable News', @@ -534,9 +538,9 @@ export const homepageLayoutRowTypeProcessMapping = { albums: makeProcessHomepageLayoutRowDocument(T.HomepageLayoutAlbumsRow, { propertyFieldMapping: { displayStyle: 'Display Style', - sourceGroupByRef: 'Group', + sourceGroup: 'Group', countAlbumsFromGroup: 'Count', - sourceAlbumsByRef: 'Albums', + sourceAlbums: 'Albums', actionLinks: 'Actions', }, }), @@ -769,13 +773,13 @@ export const dataSteps = [ let currentTrackSection = { name: `Default Track Section`, isDefaultTrackSection: true, - tracksByRef: [], + tracks: [], }; - const albumRef = T.Thing.getReference(album); + const albumRef = Thing.getReference(album); const closeCurrentTrackSection = () => { - if (!empty(currentTrackSection.tracksByRef)) { + if (!empty(currentTrackSection.tracks)) { trackSections.push(currentTrackSection); } }; @@ -789,7 +793,7 @@ export const dataSteps = [ color: entry.color, dateOriginallyReleased: entry.dateOriginallyReleased, isDefaultTrackSection: false, - tracksByRef: [], + tracks: [], }; continue; @@ -797,9 +801,9 @@ export const dataSteps = [ trackData.push(entry); - entry.dataSourceAlbumByRef = albumRef; + entry.dataSourceAlbum = albumRef; - currentTrackSection.tracksByRef.push(T.Thing.getReference(entry)); + currentTrackSection.tracks.push(Thing.getReference(entry)); } closeCurrentTrackSection(); @@ -823,12 +827,12 @@ export const dataSteps = [ const artistData = results; const artistAliasData = results.flatMap((artist) => { - const origRef = T.Thing.getReference(artist); + const origRef = Thing.getReference(artist); return artist.aliasNames?.map((name) => { const alias = new T.Artist(); alias.name = name; alias.isAlias = true; - alias.aliasedArtistRef = origRef; + alias.aliasedArtist = origRef; alias.artistData = artistData; return alias; }) ?? []; @@ -852,7 +856,7 @@ export const dataSteps = [ save(results) { let flashAct; - let flashesByRef = []; + let flashRefs = []; if (results[0] && !(results[0] instanceof T.FlashAct)) { throw new Error(`Expected an act at top of flash data file`); @@ -861,18 +865,18 @@ export const dataSteps = [ for (const thing of results) { if (thing instanceof T.FlashAct) { if (flashAct) { - Object.assign(flashAct, {flashesByRef}); + Object.assign(flashAct, {flashes: flashRefs}); } flashAct = thing; - flashesByRef = []; + flashRefs = []; } else { - flashesByRef.push(T.Thing.getReference(thing)); + flashRefs.push(Thing.getReference(thing)); } } if (flashAct) { - Object.assign(flashAct, {flashesByRef}); + Object.assign(flashAct, {flashes: flashRefs}); } const flashData = results.filter((x) => x instanceof T.Flash); @@ -895,7 +899,7 @@ export const dataSteps = [ save(results) { let groupCategory; - let groupsByRef = []; + let groupRefs = []; if (results[0] && !(results[0] instanceof T.GroupCategory)) { throw new Error(`Expected a category at top of group data file`); @@ -904,18 +908,18 @@ export const dataSteps = [ for (const thing of results) { if (thing instanceof T.GroupCategory) { if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); + Object.assign(groupCategory, {groups: groupRefs}); } groupCategory = thing; - groupsByRef = []; + groupRefs = []; } else { - groupsByRef.push(T.Thing.getReference(thing)); + groupRefs.push(Thing.getReference(thing)); } } if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); + Object.assign(groupCategory, {groups: groupRefs}); } const groupData = results.filter((x) => x instanceof T.Group); @@ -1007,7 +1011,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { } catch (error) { error.message += (error.message.includes('\n') ? '\n' : ' ') + - `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})`; + `(file: ${colors.bright(colors.blue(path.relative(dataPath, x.file)))})`; throw error; } }; @@ -1030,7 +1034,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { // just without the callbacks. Thank you. const filterBlankDocuments = documents => { const aggregate = openAggregate({ - message: `Found blank documents - check for extra '${color.cyan(`---`)}'`, + message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`, }); const filteredDocuments = @@ -1074,10 +1078,10 @@ export async function loadAndProcessDataDocuments({dataPath}) { if (count === 1) { const range = `#${start + 1}`; - parts.push(`${count} document (${color.yellow(range)}), `); + parts.push(`${count} document (${colors.yellow(range)}), `); } else { const range = `#${start + 1}-${end + 1}`; - parts.push(`${count} documents (${color.yellow(range)}), `); + parts.push(`${count} documents (${colors.yellow(range)}), `); } if (previous === null) { @@ -1087,7 +1091,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { } else { const previousDescription = Object.entries(previous).at(0).join(': '); const nextDescription = Object.entries(next).at(0).join(': '); - parts.push(`between "${color.cyan(previousDescription)}" and "${color.cyan(nextDescription)}"`); + parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`); } aggregate.push(new Error(parts.join(''))); @@ -1318,13 +1322,27 @@ export async function loadAndProcessDataDocuments({dataPath}) { // Data linking! Basically, provide (portions of) wikiData to the Things which // require it - they'll expose dynamically computed properties as a result (many -// of which are required for page HTML generation). -export function linkWikiDataArrays(wikiData) { +// of which are required for page HTML generation and other expected behavior). +// +// The XXX_decacheWikiData option should be used specifically to mark +// points where you *aren't* replacing any of the arrays under wikiData with +// new values, and are using linkWikiDataArrays to instead "decache" data +// properties which depend on any of them. It's currently not possible for +// a CacheableObject to depend directly on the value of a property exposed +// on some other CacheableObject, so when those values change, you have to +// manually decache before the object will realize its cache isn't valid +// anymore. +export function linkWikiDataArrays(wikiData, { + XXX_decacheWikiData = false, +} = {}) { function assignWikiData(things, ...keys) { + if (things === undefined) return; for (let i = 0; i < things.length; i++) { const thing = things[i]; for (let j = 0; j < keys.length; j++) { const key = keys[j]; + if (!(key in wikiData)) continue; + if (XXX_decacheWikiData) thing[key] = []; thing[key] = wikiData[key]; } } @@ -1342,7 +1360,7 @@ export function linkWikiDataArrays(wikiData) { assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData'); assignWikiData(WD.flashActData, 'flashData'); assignWikiData(WD.artTagData, 'albumData', 'trackData'); - assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData'); + assignWikiData(WD.homepageLayout?.rows, 'albumData', 'groupData'); } export function sortWikiDataArrays(wikiData) { @@ -1379,7 +1397,7 @@ export function filterDuplicateDirectories(wikiData) { const aggregate = openAggregate({message: `Duplicate directories found`}); for (const thingDataProp of deduplicateSpec) { const thingData = wikiData[thingDataProp]; - aggregate.nest({message: `Duplicate directories found in ${color.green('wikiData.' + thingDataProp)}`}, ({call}) => { + aggregate.nest({message: `Duplicate directories found in ${colors.green('wikiData.' + thingDataProp)}`}, ({call}) => { const directoryPlaces = Object.create(null); const duplicateDirectories = []; @@ -1405,7 +1423,7 @@ export function filterDuplicateDirectories(wikiData) { const places = directoryPlaces[directory]; call(() => { throw new Error( - `Duplicate directory ${color.green(directory)}:\n` + + `Duplicate directory ${colors.green(directory)}:\n` + places.map((thing) => ` - ` + inspect(thing)).join('\n') ); }); @@ -1446,45 +1464,45 @@ export function filterDuplicateDirectories(wikiData) { export function filterReferenceErrors(wikiData) { const referenceSpec = [ ['wikiInfo', processWikiInfoDocument, { - divideTrackListsByGroupsByRef: 'group', + divideTrackListsByGroups: 'group', }], ['albumData', processAlbumDocument, { - artistContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - trackCoverArtistContribsByRef: '_contrib', - wallpaperArtistContribsByRef: '_contrib', - bannerArtistContribsByRef: '_contrib', - groupsByRef: 'group', - artTagsByRef: 'artTag', + artistContribs: '_contrib', + coverArtistContribs: '_contrib', + trackCoverArtistContribs: '_contrib', + wallpaperArtistContribs: '_contrib', + bannerArtistContribs: '_contrib', + groups: 'group', + artTags: 'artTag', }], ['trackData', processTrackDocument, { - artistContribsByRef: '_contrib', - contributorContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - referencedTracksByRef: '_trackNotRerelease', - sampledTracksByRef: '_trackNotRerelease', - artTagsByRef: 'artTag', - originalReleaseTrackByRef: '_trackNotRerelease', + artistContribs: '_contrib', + contributorContribs: '_contrib', + coverArtistContribs: '_contrib', + referencedTracks: '_trackNotRerelease', + sampledTracks: '_trackNotRerelease', + artTags: 'artTag', + originalReleaseTrack: '_trackNotRerelease', }], ['groupCategoryData', processGroupCategoryDocument, { - groupsByRef: 'group', + groups: 'group', }], ['homepageLayout.rows', undefined, { - sourceGroupByRef: 'group', - sourceAlbumsByRef: 'album', + sourceGroup: '_homepageSourceGroup', + sourceAlbums: 'album', }], ['flashData', processFlashDocument, { - contributorContribsByRef: '_contrib', - featuredTracksByRef: 'track', + contributorContribs: '_contrib', + featuredTracks: 'track', }], ['flashActData', processFlashActDocument, { - flashesByRef: 'flash', + flashes: 'flash', }], ]; @@ -1500,7 +1518,7 @@ export function filterReferenceErrors(wikiData) { for (const [thingDataProp, providedProcessDocumentFn, propSpec] of referenceSpec) { const thingData = getNestedProp(wikiData, thingDataProp); - aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({nest}) => { + aggregate.nest({message: `Reference errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => { const things = Array.isArray(thingData) ? thingData : [thingData]; for (const thing of things) { @@ -1516,10 +1534,10 @@ export function filterReferenceErrors(wikiData) { nest({message: `Reference errors in ${inspect(thing)}`}, ({push, filter}) => { for (const [property, findFnKey] of Object.entries(propSpec)) { - const value = thing[property]; + const value = CacheableObject.getUpdateValue(thing, property); if (value === undefined) { - push(new TypeError(`Property ${color.red(property)} isn't valid for ${color.green(thing.constructor.name)}`)); + push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`)); continue; } @@ -1536,23 +1554,34 @@ export function filterReferenceErrors(wikiData) { if (alias) { // No need to check if the original exists here. Aliases are automatically // created from a field on the original, so the original certainly exists. - const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'}); - throw new Error(`Reference ${color.red(contribRef.who)} is to an alias, should be ${color.green(original.name)}`); + const original = alias.aliasedArtist; + throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`); } return boundFind.artist(contribRef.who); }; break; + case '_homepageSourceGroup': + findFn = groupRef => { + if (groupRef === 'new-additions' || groupRef === 'new-releases') { + return true; + } + + return boundFind.group(groupRef); + }; + break; + case '_trackNotRerelease': findFn = trackRef => { const track = find.track(trackRef, wikiData.trackData, {mode: 'error'}); + const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack'); - if (track?.originalReleaseTrackByRef) { + if (originalRef) { // It's possible for the original to not actually exist, in this case. // It should still be reported since the 'Originally Released As' field // was present. - const original = find.track(track.originalReleaseTrackByRef, wikiData.trackData, {mode: 'quiet'}); + const original = find.track(originalRef, wikiData.trackData, {mode: 'quiet'}); // Prefer references by name, but only if it's unambiguous. const originalByName = @@ -1562,12 +1591,12 @@ export function filterReferenceErrors(wikiData) { const shouldBeMessage = (originalByName - ? color.green(original.name) + ? colors.green(original.name) : original - ? color.green('track:' + original.directory) - : color.green(track.originalReleaseTrackByRef)); + ? colors.green('track:' + original.directory) + : colors.green(originalRef)); - throw new Error(`Reference ${color.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`); + throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`); } return track; @@ -1580,7 +1609,7 @@ export function filterReferenceErrors(wikiData) { } const suppress = fn => conditionallySuppressError(error => { - if (property === 'sampledTracksByRef') { + if (property === 'sampledTracks') { // Suppress "didn't match anything" errors in particular, just for samples. // In hsmusic-data we have a lot of "stub" sample data which don't have // corresponding tracks yet, so it won't be useful to report such reference @@ -1598,13 +1627,13 @@ export function filterReferenceErrors(wikiData) { const fieldPropertyMessage = (processDocumentFn?.propertyFieldMapping?.[property] - ? ` in field ${color.green(processDocumentFn.propertyFieldMapping[property])}` - : ` in property ${color.green(property)}`); + ? ` in field ${colors.green(processDocumentFn.propertyFieldMapping[property])}` + : ` in property ${colors.green(property)}`); const findFnMessage = (findFnKey.startsWith('_') ? `` - : ` (${color.green('find.' + findFnKey)})`); + : ` (${colors.green('find.' + findFnKey)})`); const errorMessage = (Array.isArray(value) diff --git a/src/find.js b/src/find.js index 966629e3..66f705e4 100644 --- a/src/find.js +++ b/src/find.js @@ -1,6 +1,6 @@ import {inspect} from 'node:util'; -import {color, logWarn} from '#cli'; +import {colors, logWarn} from '#cli'; function warnOrThrow(mode, message) { if (mode === 'error') { @@ -66,7 +66,7 @@ function findHelper(keys, findFns = {}) { const found = key ? byDirectory(ref, data, mode) : byName(ref, data, mode); if (!found) { - warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`); + warnOrThrow(mode, `Didn't match anything for ${colors.bright(fullRef)}`); } cacheForThisData[fullRef] = found; @@ -103,7 +103,7 @@ function matchName(ref, data, mode) { if (ref !== thing.name) { warnOrThrow(mode, - `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}`); + `Bad capitalization: ${colors.red(ref)} -> ${colors.green(thing.name)}`); } return thing; diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 18d1964d..e99b9602 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -93,8 +93,11 @@ import * as path from 'node:path'; import dimensionsOf from 'image-size'; +import {delay, empty, queue} from '#sugar'; +import {CacheableObject} from '#things'; + import { - color, + colors, fileIssue, logError, logInfo, @@ -110,8 +113,6 @@ import { traverse, } from '#node-utils'; -import {delay, empty, queue} from '#sugar'; - export const defaultMagickThreads = 8; export function getThumbnailsAvailableForDimensions([width, height]) { @@ -625,8 +626,8 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) { wikiData.albumData .flatMap(album => [ album.hasCoverArt && fromRoot.to('media.albumCover', album.directory, album.coverArtFileExtension), - !empty(album.bannerArtistContribsByRef) && fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension), - !empty(album.wallpaperArtistContribsByRef) && fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension), + !empty(CacheableObject.getUpdateValue(album, 'bannerArtistContribs')) && fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension), + !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) && fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension), ]) .filter(Boolean), @@ -679,14 +680,14 @@ export async function verifyImagePaths(mediaPath, {urls, wikiData}) { if (!empty(missing)) { logWarn`** Some image files are missing! (${missing.length + ' files'}) **`; for (const file of missing) { - console.warn(color.yellow(` - `) + file); + console.warn(colors.yellow(` - `) + file); } } if (!empty(misplaced)) { logWarn`** Some image files are misplaced! (${misplaced.length + ' files'}) **`; for (const file of misplaced) { - console.warn(color.yellow(` - `) + file); + console.warn(colors.yellow(` - `) + file); } } diff --git a/src/listing-spec.js b/src/listing-spec.js index fe36fc01..2b33744a 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -1,14 +1,4 @@ -import {accumulateSum, empty, showAggregate} from '#sugar'; - -import { - chunkByProperties, - getArtistNumContributions, - getTotalDuration, - sortAlphabetically, - sortByDate, - sortChronologically, - sortFlashesChronologically, -} from '#wiki-data'; +import {empty, showAggregate} from '#sugar'; const listingSpec = []; diff --git a/src/page/flash.js b/src/page/flash.js index b9d27d0f..7df74158 100644 --- a/src/page/flash.js +++ b/src/page/flash.js @@ -1,5 +1,3 @@ -import {empty} from '#sugar'; - export const description = `flash & game pages`; export function condition({wikiData}) { diff --git a/src/repl.js b/src/repl.js index 9ab4ddf0..ead01567 100644 --- a/src/repl.js +++ b/src/repl.js @@ -11,7 +11,7 @@ import {generateURLs, urlSpec} from '#urls'; import {quickLoadAllFromYAML} from '#yaml'; import _find, {bindFind} from '#find'; -import thingConstructors from '#things'; +import thingConstructors, {CacheableObject} from '#things'; import * as serialize from '#serialize'; import * as sugar from '#sugar'; import * as wikiDataUtils from '#wiki-data'; @@ -63,6 +63,7 @@ export async function getContextAssignments({ WD: wikiData, ...thingConstructors, + CacheableObject, language, ...sugar, diff --git a/src/upd8.js b/src/upd8.js index df172e48..92e89eaf 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -38,6 +38,7 @@ import {fileURLToPath} from 'node:url'; import wrap from 'word-wrap'; +import {displayCompositeCacheAnalysis} from '#composite'; import {processLanguageFile} from '#language'; import {isMain, traverse} from '#node-utils'; import bootRepl from '#repl'; @@ -47,7 +48,7 @@ import {generateURLs, urlSpec} from '#urls'; import {sortByName} from '#wiki-data'; import { - color, + colors, decorateTime, logWarn, logInfo, @@ -279,7 +280,7 @@ async function main() { const indentWrap = (spaces, str) => wrap(str, {width: 60 - spaces, indent: ' '.repeat(spaces)}); const showOptions = (msg, options) => { - console.log(color.bright(msg)); + console.log(colors.bright(msg)); const entries = Object.entries(options); const sortedOptions = sortByName(entries @@ -310,13 +311,13 @@ async function main() { console.log(''); } - console.log(color.bright(` --` + name) + + console.log(colors.bright(` --` + name) + (aliases.length - ? ` (or: ${aliases.map(alias => color.bright(`--` + alias)).join(', ')})` + ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})` : '') + (descriptor.help ? '' - : color.dim(' (no help provided)'))); + : colors.dim(' (no help provided)'))); if (wrappedHelp) { console.log(wrappedHelp); @@ -336,7 +337,7 @@ async function main() { }; console.log( - color.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) + + colors.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) + `static wiki software cataloguing collaborative creation\n`); console.log(indentWrap(0, @@ -496,7 +497,7 @@ async function main() { { const logThings = (thingDataProp, label) => - logInfo` - ${wikiData[thingDataProp]?.length ?? color.red('(Missing!)')} ${color.normal(color.dim(label))}`; + logInfo` - ${wikiData[thingDataProp]?.length ?? colors.red('(Missing!)')} ${colors.normal(colors.dim(label))}`; try { logInfo`Loaded data and processed objects:`; logThings('albumData', 'albums'); @@ -612,6 +613,10 @@ async function main() { // which are only available after the initial linking. sortWikiDataArrays(wikiData); + console.log( + CacheableObject.getUpdateValue(wikiData.albumData[0], 'trackSections'), + wikiData.albumData[0].trackSections); + if (precacheData) { progressCallAll('Caching all data values', Object.entries(wikiData) .filter(([key]) => @@ -625,6 +630,11 @@ async function main() { .map(thing => () => CacheableObject.cacheAllExposedProperties(thing))); } + if (noBuild) { + displayCompositeCacheAnalysis(); + if (precacheData) return; + } + const internalDefaultLanguage = await processLanguageFile( path.join(__dirname, DEFAULT_STRINGS_FILE)); @@ -755,7 +765,9 @@ async function main() { logInfo`Done preloading filesizes!`; - if (noBuild) return; + if (noBuild) { + return; + } const developersComment = `<!--\n` + [ diff --git a/src/util/cli.js b/src/util/cli.js index e8c8c79f..4c08c085 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -17,7 +17,7 @@ export const ENABLE_COLOR = const C = (n) => ENABLE_COLOR ? (text) => `\x1b[${n}m${text}\x1b[0m` : (text) => text; -export const color = { +export const colors = { bright: C('1'), dim: C('2'), normal: C('22'), @@ -335,8 +335,8 @@ export function fileIssue({ topMessage = `This shouldn't happen.`, } = {}) { if (topMessage) { - console.error(color.red(`${topMessage} Please let the HSMusic developers know:`)); + console.error(colors.red(`${topMessage} Please let the HSMusic developers know:`)); } - console.error(color.red(`- https://hsmusic.wiki/feedback/`)); - console.error(color.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`)); + console.error(colors.red(`- https://hsmusic.wiki/feedback/`)); + console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`)); } diff --git a/src/util/html.js b/src/util/html.js index f0c7bfdf..c7395fbf 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -242,7 +242,7 @@ export class Tag { this.selfClosing && !(value === null || value === undefined || - !Boolean(value) || + !value || Array.isArray(value) && value.filter(Boolean).length === 0) ) { throw new Error(`Tag <${this.tagName}> is self-closing but got content`); diff --git a/src/util/replacer.js b/src/util/replacer.js index c5289cc5..647d1f0e 100644 --- a/src/util/replacer.js +++ b/src/util/replacer.js @@ -5,7 +5,6 @@ // function, which converts nodes parsed here into actual HTML, links, etc // for embedding in a wiki webpage. -import {logError, logWarn} from '#cli'; import * as html from '#html'; import {escapeRegex} from '#sugar'; diff --git a/src/util/sugar.js b/src/util/sugar.js index 5b1f3193..ebb7d61e 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -6,7 +6,7 @@ // It will likely only do exactly what I want it to, and only in the cases I // decided were relevant enough to 8other handling. -import {color} from './cli.js'; +import {colors} from './cli.js'; // Apparently JavaScript doesn't come with a function to split an array into // chunks! Weird. Anyway, this is an awesome place to use a generator, even @@ -168,12 +168,24 @@ export function setIntersection(set1, set2) { return intersection; } -export function filterProperties(obj, properties) { - const set = new Set(properties); - return Object.fromEntries( - Object - .entries(obj) - .filter(([key]) => set.has(key))); +export function filterProperties(object, properties) { + if (typeof object !== 'object' || object === null) { + throw new TypeError(`Expected object to be an object, got ${object}`); + } + + if (!Array.isArray(properties)) { + throw new TypeError(`Expected properties to be an array, got ${properties}`); + } + + const filteredObject = {}; + + for (const property of properties) { + if (Object.hasOwn(object, property)) { + filteredObject[property] = object[property]; + } + } + + return filteredObject; } export function queue(array, max = 50) { @@ -554,10 +566,10 @@ export function showAggregate(topError, { .trim() .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) : '(no stack trace)'; - header += ` ${color.dim(tracePart)}`; + header += ` ${colors.dim(tracePart)}`; } - const bar = level % 2 === 0 ? '\u2502' : color.dim('\u254e'); - const head = level % 2 === 0 ? '\u257f' : color.dim('\u257f'); + const bar = level % 2 === 0 ? '\u2502' : colors.dim('\u254e'); + const head = level % 2 === 0 ? '\u257f' : colors.dim('\u257f'); if (error instanceof AggregateError) { return ( @@ -593,7 +605,7 @@ export function decorateErrorWithIndex(fn) { try { return fn(x, index, array); } catch (error) { - error.message = `(${color.yellow(`#${index + 1}`)}) ${error.message}`; + error.message = `(${colors.yellow(`#${index + 1}`)}) ${error.message}`; throw error; } }; diff --git a/src/util/urls.js b/src/util/urls.js index d2b303e9..11b9b8b0 100644 --- a/src/util/urls.js +++ b/src/util/urls.js @@ -237,27 +237,6 @@ export function getPagePathname({ : to('localized.' + pagePath[0], ...pagePath.slice(1))); } -export function getPagePathnameAcrossLanguages({ - defaultLanguage, - languages, - pagePath, - urls, -}) { - return withEntries(languages, entries => entries - .filter(([key, language]) => key !== 'default' && !language.hidden) - .map(([_key, language]) => [ - language.code, - getPagePathname({ - baseDirectory: - (language === defaultLanguage - ? '' - : language.code), - pagePath, - urls, - }), - ])); -} - // Needed for the rare path arguments which themselves contains one or more // slashes, e.g. for listings, with arguments like 'albums/by-name'. export function getPageSubdirectoryPrefix({ diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index ad2f82fb..ac652b27 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -1,6 +1,6 @@ // Utility functions for interacting with wiki data. -import {accumulateSum, empty, stitchArrays, unique} from './sugar.js'; +import {accumulateSum, empty, unique} from './sugar.js'; // Generic value operations @@ -874,3 +874,71 @@ export function filterItemsForCarousel(items) { .filter(item => item.artTags.every(tag => !tag.isContentWarning)) .slice(0, maxCarouselLayoutItems + 1); } + +// Ridiculous caching support nonsense + +export class TupleMap { + static maxNestedTupleLength = 25; + + #store = [undefined, null, null, null]; + + #lifetime(value) { + if (Array.isArray(value) && value.length <= TupleMap.maxNestedTupleLength) { + return 'tuple'; + } else if ( + typeof value === 'object' && value !== null || + typeof value === 'function' + ) { + return 'weak'; + } else { + return 'strong'; + } + } + + #getSubstoreShallow(value, store) { + const lifetime = this.#lifetime(value); + const mapIndex = {weak: 1, strong: 2, tuple: 3}[lifetime]; + + let map = store[mapIndex]; + if (map === null) { + map = store[mapIndex] = + (lifetime === 'weak' ? new WeakMap() + : lifetime === 'strong' ? new Map() + : lifetime === 'tuple' ? new TupleMap() + : null); + } + + if (map.has(value)) { + return map.get(value); + } else { + const substore = [undefined, null, null, null]; + map.set(value, substore); + return substore; + } + } + + #getSubstoreDeep(tuple, store = this.#store) { + if (tuple.length === 0) { + return store; + } else { + const [first, ...rest] = tuple; + return this.#getSubstoreDeep(rest, this.#getSubstoreShallow(first, store)); + } + } + + get(tuple) { + const store = this.#getSubstoreDeep(tuple); + return store[0]; + } + + has(tuple) { + const store = this.#getSubstoreDeep(tuple); + return store[0] !== undefined; + } + + set(tuple, value) { + const store = this.#getSubstoreDeep(tuple); + store[0] = value; + return value; + } +} diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index 730686db..3986de32 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -10,11 +10,9 @@ import {quickEvaluate} from '#content-function'; import * as html from '#html'; import * as pageSpecs from '#page-specs'; import {serializeThings} from '#serialize'; -import {empty} from '#sugar'; import { getPagePathname, - getPagePathnameAcrossLanguages, getURLsFrom, getURLsFromRoot, } from '#urls'; @@ -333,13 +331,6 @@ export async function go({ return; } - const localizedPathnames = getPagePathnameAcrossLanguages({ - defaultLanguage, - languages, - pagePath: servePath, - urls, - }); - const bound = bindUtilities({ absoluteTo, cachebust, diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index 79c8defd..09316999 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -21,13 +21,11 @@ import { logError, logInfo, logWarn, - progressCallAll, progressPromiseAll, } from '#cli'; import { getPagePathname, - getPagePathnameAcrossLanguages, getURLsFrom, getURLsFromRoot, } from '#urls'; @@ -94,7 +92,6 @@ export async function go({ srcRootPath, thumbsCache, urls, - urlSpec, wikiData, cachebust, @@ -271,13 +268,6 @@ export async function go({ ...pageWrites.map(page => () => { const pagePath = page.path; - const localizedPathnames = getPagePathnameAcrossLanguages({ - defaultLanguage, - languages, - pagePath, - urls, - }); - const pathname = getPagePathname({ baseDirectory, pagePath, @@ -480,14 +470,9 @@ async function writeFavicon({ } async function writeSharedFilesAndPages({ - language, outputPath, - urls, - wikiData, wikiDataJSON, }) { - const {groupData, wikiInfo} = wikiData; - return progressPromiseAll(`Writing files & pages shared across languages.`, [ wikiDataJSON && writeFile( diff --git a/test/lib/index.js b/test/lib/index.js index b9cc82f8..6eaaa656 100644 --- a/test/lib/index.js +++ b/test/lib/index.js @@ -1,3 +1,4 @@ export * from './content-function.js'; export * from './generic-mock.js'; +export * from './wiki-data.js'; export * from './strict-match-error.js'; diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js new file mode 100644 index 00000000..c4083a56 --- /dev/null +++ b/test/lib/wiki-data.js @@ -0,0 +1,24 @@ +import {linkWikiDataArrays} from '#yaml'; + +export function linkAndBindWikiData(wikiData) { + linkWikiDataArrays(wikiData); + + return { + // Mutate to make the below functions aware of new data objects, or of + // reordering the existing ones. Don't mutate arrays such as trackData + // in-place; assign completely new arrays to this wikiData object instead. + wikiData, + + // Use this after you've mutated wikiData to assign new data arrays. + // It'll automatically relink everything on wikiData so all the objects + // are caught up to date. + linkWikiDataArrays: + linkWikiDataArrays.bind(null, wikiData), + + // Use this if you HAVEN'T mutated wikiData and just need to decache + // indirect dependencies on exposed properties of other data objects. + // See documentation on linkWikiDataArarys (in yaml.js) for more info. + XXX_decacheWikiData: + linkWikiDataArrays.bind(null, wikiData, {XXX_decacheWikiData: true}), + }; +} diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js new file mode 100644 index 00000000..0695fdb6 --- /dev/null +++ b/test/unit/data/things/album.js @@ -0,0 +1,387 @@ +import t from 'tap'; + +import {linkAndBindWikiData} from '#test-lib'; +import thingConstructors from '#things'; + +const { + Album, + Artist, + Track, +} = thingConstructors; + +function stubArtistAndContribs() { + const artist = new Artist(); + artist.name = `Test Artist`; + + const contribs = [{who: `Test Artist`, what: null}]; + const badContribs = [{who: `Figment of Your Imagination`, what: null}]; + + return {artist, contribs, badContribs}; +} + +function stubTrack(directory = 'foo') { + const track = new Track(); + track.directory = directory; + + return track; +} + +t.test(`Album.bannerDimensions`, t => { + t.plan(4); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.bannerDimensions, null, + `Album.bannerDimensions #1: defaults to null`); + + album.bannerDimensions = [1200, 275]; + + t.equal(album.bannerDimensions, null, + `Album.bannerDimensions #2: is null if bannerArtistContribs empty`); + + album.bannerArtistContribs = badContribs; + + t.equal(album.bannerDimensions, null, + `Album.bannerDimensions #3: is null if bannerArtistContribs resolves empty`); + + album.bannerArtistContribs = contribs; + + t.same(album.bannerDimensions, [1200, 275], + `Album.bannerDimensions #4: is own value`); +}); + +t.test(`Album.bannerFileExtension`, t => { + t.plan(5); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.bannerFileExtension, null, + `Album.bannerFileExtension #1: defaults to null`); + + album.bannerFileExtension = 'png'; + + t.equal(album.bannerFileExtension, null, + `Album.bannerFileExtension #2: is null if bannerArtistContribs empty`); + + album.bannerArtistContribs = badContribs; + + t.equal(album.bannerFileExtension, null, + `Album.bannerFileExtension #3: is null if bannerArtistContribs resolves empty`); + + album.bannerArtistContribs = contribs; + + t.equal(album.bannerFileExtension, 'png', + `Album.bannerFileExtension #4: is own value`); + + album.bannerFileExtension = null; + + t.equal(album.bannerFileExtension, 'jpg', + `Album.bannerFileExtension #5: defaults to jpg`); +}); + +t.test(`Album.bannerStyle`, t => { + t.plan(4); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.bannerStyle, null, + `Album.bannerStyle #1: defaults to null`); + + album.bannerStyle = `opacity: 0.5`; + + t.equal(album.bannerStyle, null, + `Album.bannerStyle #2: is null if bannerArtistContribs empty`); + + album.bannerArtistContribs = badContribs; + + t.equal(album.bannerStyle, null, + `Album.bannerStyle #3: is null if bannerArtistContribs resolves empty`); + + album.bannerArtistContribs = contribs; + + t.equal(album.bannerStyle, `opacity: 0.5`, + `Album.bannerStyle #4: is own value`); +}); + +t.test(`Album.coverArtDate`, t => { + t.plan(6); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #1: defaults to null`); + + album.date = new Date('2012-10-25'); + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #2: is null if coverArtistContribs empty (1/2)`); + + album.coverArtDate = new Date('2011-04-13'); + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #3: is null if coverArtistContribs empty (2/2)`); + + album.coverArtistContribs = contribs; + + t.same(album.coverArtDate, new Date('2011-04-13'), + `Album.coverArtDate #4: is own value`); + + album.coverArtDate = null; + + t.same(album.coverArtDate, new Date(`2012-10-25`), + `Album.coverArtDate #5: inherits album release date`); + + album.coverArtistContribs = badContribs; + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #6: is null if coverArtistContribs resolves empty`); +}); + +t.test(`Album.coverArtFileExtension`, t => { + t.plan(5); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.coverArtFileExtension, null, + `Album.coverArtFileExtension #1: is null if coverArtistContribs empty (1/2)`); + + album.coverArtFileExtension = 'png'; + + t.equal(album.coverArtFileExtension, null, + `Album.coverArtFileExtension #2: is null if coverArtistContribs empty (2/2)`); + + album.coverArtFileExtension = null; + album.coverArtistContribs = contribs; + + t.equal(album.coverArtFileExtension, 'jpg', + `Album.coverArtFileExtension #3: defaults to jpg`); + + album.coverArtFileExtension = 'png'; + + t.equal(album.coverArtFileExtension, 'png', + `Album.coverArtFileExtension #4: is own value`); + + album.coverArtistContribs = badContribs; + + t.equal(album.coverArtFileExtension, null, + `Album.coverArtFileExtension #5: is null if coverArtistContribs resolves empty`); +}); + +t.test(`Album.tracks`, t => { + t.plan(4); + + const album = new Album(); + const track1 = stubTrack('track1'); + const track2 = stubTrack('track2'); + const track3 = stubTrack('track3'); + + linkAndBindWikiData({ + albumData: [album], + trackData: [track1, track2, track3], + }); + + t.same(album.tracks, [], + `Album.tracks #1: defaults to empty array`); + + album.trackSections = [ + {tracks: ['track:track1', 'track:track2', 'track:track3']}, + ]; + + t.same(album.tracks, [track1, track2, track3], + `Album.tracks #2: pulls tracks from one track section`); + + album.trackSections = [ + {tracks: ['track:track1']}, + {tracks: ['track:track2', 'track:track3']}, + ]; + + t.same(album.tracks, [track1, track2, track3], + `Album.tracks #3: pulls tracks from multiple track sections`); + + album.trackSections = [ + {tracks: ['track:track1', 'track:does-not-exist']}, + {tracks: ['track:this-one-neither', 'track:track2']}, + {tracks: ['track:effectively-empty-section']}, + {tracks: ['track:track3']}, + ]; + + t.same(album.tracks, [track1, track2, track3], + `Album.tracks #4: filters out references without matches`); +}); + +t.test(`Album.trackSections`, t => { + t.plan(6); + + const album = new Album(); + const track1 = stubTrack('track1'); + const track2 = stubTrack('track2'); + const track3 = stubTrack('track3'); + const track4 = stubTrack('track4'); + + linkAndBindWikiData({ + albumData: [album], + trackData: [track1, track2, track3, track4], + }); + + album.trackSections = [ + {tracks: ['track:track1', 'track:track2']}, + {tracks: ['track:track3', 'track:track4']}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1, track2]}, + {tracks: [track3, track4]}, + ], `Album.trackSections #1: exposes tracks`); + + t.match(album.trackSections, [ + {tracks: [track1, track2], startIndex: 0}, + {tracks: [track3, track4], startIndex: 2}, + ], `Album.trackSections #2: exposes startIndex`); + + album.color = '#123456'; + + album.trackSections = [ + {tracks: ['track:track1'], color: null}, + {tracks: ['track:track2'], color: '#abcdef'}, + {tracks: ['track:track3'], color: null}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1], color: '#123456'}, + {tracks: [track2], color: '#abcdef'}, + {tracks: [track3], color: '#123456'}, + ], `Album.trackSections #3: exposes color, inherited from album`); + + album.trackSections = [ + {tracks: ['track:track1'], dateOriginallyReleased: null}, + {tracks: ['track:track2'], dateOriginallyReleased: new Date('2009-04-11')}, + {tracks: ['track:track3'], dateOriginallyReleased: null}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1], dateOriginallyReleased: null}, + {tracks: [track2], dateOriginallyReleased: new Date('2009-04-11')}, + {tracks: [track3], dateOriginallyReleased: null}, + ], `Album.trackSections #4: exposes dateOriginallyReleased, if present`); + + album.trackSections = [ + {tracks: ['track:track1'], isDefaultTrackSection: true}, + {tracks: ['track:track2'], isDefaultTrackSection: false}, + {tracks: ['track:track3'], isDefaultTrackSection: null}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1], isDefaultTrackSection: true}, + {tracks: [track2], isDefaultTrackSection: false}, + {tracks: [track3], isDefaultTrackSection: false}, + ], `Album.trackSections #5: exposes isDefaultTrackSection, defaults to false`); + + album.trackSections = [ + {tracks: ['track:track1', 'track:track2', 'track:snooping'], color: '#112233'}, + {tracks: ['track:track3', 'track:as-usual'], color: '#334455'}, + {tracks: [], color: '#bbbbba'}, + {tracks: ['track:icy', 'track:chilly', 'track:frigid'], color: '#556677'}, + {tracks: ['track:track4'], color: '#778899'}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1, track2], color: '#112233'}, + {tracks: [track3], color: '#334455'}, + {tracks: [track4], color: '#778899'}, + ], `Album.trackSections #6: filters out references without matches & empty sections`); +}); + +t.test(`Album.wallpaperFileExtension`, t => { + t.plan(5); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.wallpaperFileExtension, null, + `Album.wallpaperFileExtension #1: defaults to null`); + + album.wallpaperFileExtension = 'png'; + + t.equal(album.wallpaperFileExtension, null, + `Album.wallpaperFileExtension #2: is null if wallpaperArtistContribs empty`); + + album.wallpaperArtistContribs = contribs; + + t.equal(album.wallpaperFileExtension, 'png', + `Album.wallpaperFileExtension #3: is own value`); + + album.wallpaperFileExtension = null; + + t.equal(album.wallpaperFileExtension, 'jpg', + `Album.wallpaperFileExtension #4: defaults to jpg`); + + album.wallpaperArtistContribs = badContribs; + + t.equal(album.wallpaperFileExtension, null, + `Album.wallpaperFileExtension #5: is null if wallpaperArtistContribs resolves empty`); +}); + +t.test(`Album.wallpaperStyle`, t => { + t.plan(4); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.wallpaperStyle, null, + `Album.wallpaperStyle #1: defaults to null`); + + album.wallpaperStyle = `opacity: 0.5`; + + t.equal(album.wallpaperStyle, null, + `Album.wallpaperStyle #2: is null if wallpaperArtistContribs empty`); + + album.wallpaperArtistContribs = badContribs; + + t.equal(album.wallpaperStyle, null, + `Album.wallpaperStyle #3: is null if wallpaperArtistContribs resolves empty`); + + album.wallpaperArtistContribs = contribs; + + t.equal(album.wallpaperStyle, `opacity: 0.5`, + `Album.wallpaperStyle #4: is own value`); +}); diff --git a/test/unit/data/things/art-tag.js b/test/unit/data/things/art-tag.js new file mode 100644 index 00000000..561c93ef --- /dev/null +++ b/test/unit/data/things/art-tag.js @@ -0,0 +1,71 @@ +import t from 'tap'; + +import {linkAndBindWikiData} from '#test-lib'; +import thingConstructors from '#things'; + +const { + Album, + Artist, + ArtTag, + Track, +} = thingConstructors; + +function stubAlbum(tracks, directory = 'bar') { + const album = new Album(); + album.directory = directory; + + const trackRefs = tracks.map(t => Thing.getReference(t)); + album.trackSections = [{tracks: trackRefs}]; + + return album; +} + +function stubTrack(directory = 'foo') { + const track = new Track(); + track.directory = directory; + + return track; +} + +function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') { + const track = stubTrack(trackDirectory); + const album = stubAlbum([track], albumDirectory); + + return {track, album}; +} + +function stubArtist(artistName = `Test Artist`) { + const artist = new Artist(); + artist.name = artistName; + + return artist; +} + +function stubArtistAndContribs(artistName = `Test Artist`) { + const artist = stubArtist(artistName); + const contribs = [{who: artistName, what: null}]; + const badContribs = [{who: `Figment of Your Imagination`, what: null}]; + + return {artist, contribs, badContribs}; +} + +t.test(`ArtTag.nameShort`, t => { + t.plan(3); + + const artTag = new ArtTag(); + + artTag.name = `Dave Strider`; + + t.equal(artTag.nameShort, `Dave Strider`, + `ArtTag #1: defaults to name`); + + artTag.name = `Dave Strider (Homestuck)`; + + t.equal(artTag.nameShort, `Dave Strider`, + `ArtTag #2: trims parenthical part at end`); + + artTag.name = `This (And) That (Then)`; + + t.equal(artTag.nameShort, `This (And) That`, + `ArtTag #2: doesn't trim midlde parenthical part`); +}); diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 383e3e3f..5e7fd829 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -1,74 +1,586 @@ import t from 'tap'; + +import {linkAndBindWikiData} from '#test-lib'; import thingConstructors from '#things'; const { Album, + Artist, + Flash, + FlashAct, Thing, Track, - TrackGroup, } = thingConstructors; -function stubAlbum(tracks) { +function stubAlbum(tracks, directory = 'bar') { const album = new Album(); - album.trackSections = [ - { - tracksByRef: tracks.map(t => Thing.getReference(t)), - }, - ]; - album.trackData = tracks; + album.directory = directory; + + const trackRefs = tracks.map(t => Thing.getReference(t)); + album.trackSections = [{tracks: trackRefs}]; + return album; } -t.test(`Track.coverArtDate`, t => { +function stubTrack(directory = 'foo') { + const track = new Track(); + track.directory = directory; + + return track; +} + +function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') { + const track = stubTrack(trackDirectory); + const album = stubAlbum([track], albumDirectory); + + return {track, album}; +} + +function stubArtist(artistName = `Test Artist`) { + const artist = new Artist(); + artist.name = artistName; + + return artist; +} + +function stubArtistAndContribs(artistName = `Test Artist`) { + const artist = stubArtist(artistName); + const contribs = [{who: artistName, what: null}]; + const badContribs = [{who: `Figment of Your Imagination`, what: null}]; + + return {artist, contribs, badContribs}; +} + +function stubFlashAndAct(directory = 'zam') { + const flash = new Flash(); + flash.directory = directory; + + const flashAct = new FlashAct(); + flashAct.flashes = [Thing.getReference(flash)]; + + return {flash, flashAct}; +} + +t.test(`Track.album`, t => { + t.plan(6); + + // Note: These asserts use manual albumData/trackData relationships + // to illustrate more specifically the properties which are expected to + // be relevant for this case. Other properties use the same underlying + // get-album behavior as Track.album so aren't tested as aggressively. + + const track1 = stubTrack('track1'); + const track2 = stubTrack('track2'); + const album1 = new Album(); + const album2 = new Album(); + + t.equal(track1.album, null, + `album #1: defaults to null`); + + track1.albumData = [album1, album2]; + track2.albumData = [album1, album2]; + album1.trackData = [track1, track2]; + album2.trackData = [track1, track2]; + album1.trackSections = [{tracks: ['track:track1']}]; + album2.trackSections = [{tracks: ['track:track2']}]; + + t.equal(track1.album, album1, + `album #2: is album when album's trackSections matches track`); + + track1.albumData = [album2, album1]; + + t.equal(track1.album, album1, + `album #3: is album when albumData is in different order`); + + track1.albumData = []; + + t.equal(track1.album, null, + `album #4: is null when track missing albumData`); + + album1.trackData = []; + track1.albumData = [album1, album2]; + + t.equal(track1.album, null, + `album #5: is null when album missing trackData`); + + album1.trackData = [track1, track2]; + album1.trackSections = [{tracks: ['track:track2']}]; + + // XXX_decacheWikiData + track1.albumData = []; + track1.albumData = [album1, album2]; + + t.equal(track1.album, null, + `album #6: is null when album's trackSections don't match track`); +}); + +t.test(`Track.color`, t => { t.plan(5); - // Priority order is as follows, with the last (trackCoverArtDate) being - // greatest priority. - const albumDate = new Date('2010-10-10'); - const albumTrackArtDate = new Date('2012-12-12'); - const trackDateFirstReleased = new Date('2008-08-08'); - const trackCoverArtDate = new Date('2009-09-09'); + const {track, album} = stubTrackAndAlbum(); + + const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + trackData: [track], + }); + + t.equal(track.color, null, + `color #1: defaults to null`); + + album.color = '#abcdef'; + album.trackSections = [{ + color: '#beeeef', + tracks: [Thing.getReference(track)], + }]; + XXX_decacheWikiData(); + + t.equal(track.color, '#beeeef', + `color #2: inherits from track section before album`); + + // Replace the album with a completely fake one. This isn't realistic, since + // in correct data, Album.tracks depends on Albums.trackSections and so the + // track's album will always have a corresponding track section. But if that + // connection breaks for some future reason (with the album still present), + // Track.color should still inherit directly from the album. + wikiData.albumData = [ + new Proxy({ + color: '#abcdef', + tracks: [track], + trackSections: [ + {color: '#baaaad', tracks: []}, + ], + }, {getPrototypeOf: () => Album.prototype}), + ]; + + linkWikiDataArrays(); + + t.equal(track.color, '#abcdef', + `color #3: inherits from album without matching track section`); + + track.color = '#123456'; + + t.equal(track.color, '#123456', + `color #4: is own value`); + + t.throws(() => { track.color = '#aeiouw'; }, TypeError, + `color #5: must be set to valid color`); +}); + +t.test(`Track.commentatorArtists`, t => { + t.plan(6); const track = new Track(); - track.directory = 'foo'; + const artist1 = stubArtist(`SnooPING`); + const artist2 = stubArtist(`ASUsual`); + const artist3 = stubArtist(`Icy`); + + linkAndBindWikiData({ + trackData: [track], + artistData: [artist1, artist2, artist3], + }); + + track.commentary = + `<i>SnooPING:</i>\n` + + `Wow.\n`; + + t.same(track.commentatorArtists, [artist1], + `Track.commentatorArtists #1: works with one commentator`); + + track.commentary += + `<i>ASUsual:</i>\n` + + `Yes!\n`; + + t.same(track.commentatorArtists, [artist1, artist2], + `Track.commentatorArtists #2: works with two commentators`); + + track.commentary += + `<i><b>Icy:</b></i>\n` + + `Incredible.\n`; + + t.same(track.commentatorArtists, [artist1, artist2, artist3], + `Track.commentatorArtists #3: works with boldface name`); + + track.commentary = + `<i>Icy:</i> (project manager)\n` + + `Very good track.\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #4: works with parenthical accent`); + + track.commentary += + `<i>SNooPING ASUsual Icy:</i>\n` + + `WITH ALL THREE POWERS COMBINED...`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #5: ignores artist names not found`); + + track.commentary += + `<i>Icy:</i>\n` + + `I'm back!\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #6: ignores duplicate artist`); +}); + +t.test(`Track.coverArtDate`, t => { + t.plan(8); + + const {track, album} = stubTrackAndAlbum(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + trackData: [track], + }); + + track.coverArtistContribs = contribs; + + t.equal(track.coverArtDate, null, + `coverArtDate #1: defaults to null`); + + album.trackArtDate = new Date('2012-12-12'); + + XXX_decacheWikiData(); + + t.same(track.coverArtDate, new Date('2012-12-12'), + `coverArtDate #2: inherits album trackArtDate`); + + track.coverArtDate = new Date('2009-09-09'); + + t.same(track.coverArtDate, new Date('2009-09-09'), + `coverArtDate #3: is own value`); + + track.coverArtistContribs = []; + + t.equal(track.coverArtDate, null, + `coverArtDate #4: is null if track coverArtistContribs empty`); + + album.trackCoverArtistContribs = contribs; + + XXX_decacheWikiData(); + + t.same(track.coverArtDate, new Date('2009-09-09'), + `coverArtDate #5: is not null if album trackCoverArtistContribs specified`); + + album.trackCoverArtistContribs = badContribs; + + XXX_decacheWikiData(); + + t.equal(track.coverArtDate, null, + `coverArtDate #6: is null if album trackCoverArtistContribs resolves empty`); + + track.coverArtistContribs = badContribs; + + t.equal(track.coverArtDate, null, + `coverArtDate #7: is null if track coverArtistContribs resolves empty`); - const album = stubAlbum([track]); + track.coverArtistContribs = contribs; + track.disableUniqueCoverArt = true; - track.albumData = [album]; + t.equal(track.coverArtDate, null, + `coverArtDate #8: is null if track disables unique cover artwork`); +}); + +t.test(`Track.coverArtFileExtension`, t => { + t.plan(8); + + const {track, album} = stubTrackAndAlbum(); + const {artist, contribs} = stubArtistAndContribs(); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + trackData: [track], + }); + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #1: defaults to null`); + + track.coverArtistContribs = contribs; + + t.equal(track.coverArtFileExtension, 'jpg', + `coverArtFileExtension #2: is jpg if has cover art and not further specified`); + + track.coverArtistContribs = []; + + album.coverArtistContribs = contribs; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #3: only has value for unique cover art`); + + track.coverArtistContribs = contribs; + + album.trackCoverArtFileExtension = 'png'; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, 'png', + `coverArtFileExtension #4: inherits album trackCoverArtFileExtension (1/2)`); + + track.coverArtFileExtension = 'gif'; + + t.equal(track.coverArtFileExtension, 'gif', + `coverArtFileExtension #5: is own value (1/2)`); + + track.coverArtistContribs = []; + + album.trackCoverArtistContribs = contribs; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, 'gif', + `coverArtFileExtension #6: is own value (2/2)`); + + track.coverArtFileExtension = null; + + t.equal(track.coverArtFileExtension, 'png', + `coverArtFileExtension #7: inherits album trackCoverArtFileExtension (2/2)`); + + track.disableUniqueCoverArt = true; + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #8: is null if track disables unique cover art`); +}); + +t.test(`Track.date`, t => { + t.plan(3); + + const {track, album} = stubTrackAndAlbum(); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + trackData: [track], + }); + + t.equal(track.date, null, + `date #1: defaults to null`); + + album.date = new Date('2012-12-12'); + XXX_decacheWikiData(); + + t.same(track.date, album.date, + `date #2: inherits from album`); + + track.dateFirstReleased = new Date('2009-09-09'); + + t.same(track.date, new Date('2009-09-09'), + `date #3: is own dateFirstReleased`); +}); + +t.test(`Track.featuredInFlashes`, t => { + t.plan(2); + + const {track, album} = stubTrackAndAlbum('track1'); + + const {flash: flash1, flashAct: flashAct1} = stubFlashAndAct('flash1'); + const {flash: flash2, flashAct: flashAct2} = stubFlashAndAct('flash2'); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + trackData: [track], + flashData: [flash1, flash2], + flashActData: [flashAct1, flashAct2], + }); + + t.same(track.featuredInFlashes, [], + `featuredInFlashes #1: defaults to empty array`); + + flash1.featuredTracks = ['track:track1']; + flash2.featuredTracks = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track.featuredInFlashes, [flash1, flash2], + `featuredInFlashes #2: matches flashes' featuredTracks`); +}); + +t.test(`Track.hasUniqueCoverArt`, t => { + t.plan(7); + + const {track, album} = stubTrackAndAlbum(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + trackData: [track], + }); - // 1. coverArtDate defaults to null + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #1: defaults to false`); - t.equal(track.coverArtDate, null); + album.trackCoverArtistContribs = contribs; + XXX_decacheWikiData(); - // 2. coverArtDate inherits album release date + t.equal(track.hasUniqueCoverArt, true, + `hasUniqueCoverArt #2: is true if album specifies trackCoverArtistContribs`); - album.date = albumDate; + track.disableUniqueCoverArt = true; - // XXX clear cache so change in album's property is reflected - track.albumData = []; - track.albumData = [album]; + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #3: is false if disableUniqueCoverArt is true (1/2)`); - t.equal(track.coverArtDate, albumDate); + track.disableUniqueCoverArt = false; + + album.trackCoverArtistContribs = badContribs; + XXX_decacheWikiData(); + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #4: is false if album's trackCoverArtistContribs resolve empty`); + + track.coverArtistContribs = contribs; + + t.equal(track.hasUniqueCoverArt, true, + `hasUniqueCoverArt #5: is true if track specifies coverArtistContribs`); + + track.disableUniqueCoverArt = true; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #6: is false if disableUniqueCoverArt is true (2/2)`); + + track.disableUniqueCoverArt = false; + + track.coverArtistContribs = badContribs; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #7: is false if track's coverArtistContribs resolve empty`); +}); + +t.test(`Track.originalReleaseTrack`, t => { + t.plan(3); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + + const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2], + trackData: [track1, track2], + }); + + t.equal(track2.originalReleaseTrack, null, + `originalReleaseTrack #1: defaults to null`); + + track2.originalReleaseTrack = 'track:track1'; + + t.equal(track2.originalReleaseTrack, track1, + `originalReleaseTrack #2: is resolved from own value`); + + track2.trackData = []; + + t.equal(track2.originalReleaseTrack, null, + `originalReleaseTrack #3: is null when track missing trackData`); +}); + +t.test(`Track.otherReleases`, t => { + t.plan(6); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + const {track: track3, album: album3} = stubTrackAndAlbum('track3'); + const {track: track4, album: album4} = stubTrackAndAlbum('track4'); + + const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2, album3, album4], + trackData: [track1, track2, track3, track4], + }); + + t.same(track1.otherReleases, [], + `otherReleases #1: defaults to empty array`); + + track2.originalReleaseTrack = 'track:track1'; + track3.originalReleaseTrack = 'track:track1'; + track4.originalReleaseTrack = 'track:track1'; + XXX_decacheWikiData(); + + t.same(track1.otherReleases, [track2, track3, track4], + `otherReleases #2: otherReleases of original release are its rereleases`); + + wikiData.trackData = [track1, track3, track2, track4]; + linkWikiDataArrays(); + + t.same(track1.otherReleases, [track3, track2, track4], + `otherReleases #3: otherReleases matches trackData order`); + + wikiData.trackData = [track3, track2, track1, track4]; + linkWikiDataArrays(); + + t.same(track2.otherReleases, [track1, track3, track4], + `otherReleases #4: otherReleases of rerelease are original track then other rereleases (1/3)`); + + t.same(track3.otherReleases, [track1, track2, track4], + `otherReleases #5: otherReleases of rerelease are original track then other rereleases (2/3)`); + + t.same(track4.otherReleases, [track1, track3, track2], + `otherReleases #6: otherReleases of rerelease are original track then other rereleases (3/3)`); +}); + +t.test(`Track.referencedByTracks`, t => { + t.plan(4); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + const {track: track3, album: album3} = stubTrackAndAlbum('track3'); + const {track: track4, album: album4} = stubTrackAndAlbum('track4'); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2, album3, album4], + trackData: [track1, track2, track3, track4], + }); + + t.same(track1.referencedByTracks, [], + `referencedByTracks #1: defaults to empty array`); + + track2.referencedTracks = ['track:track1']; + track3.referencedTracks = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2, track3], + `referencedByTracks #2: matches tracks' referencedTracks`); + + track4.sampledTracks = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2, track3], + `referencedByTracks #3: doesn't match tracks' sampledTracks`); + + track3.originalReleaseTrack = 'track:track2'; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2], + `referencedByTracks #4: doesn't include re-releases`); +}); - // 3. coverArtDate inherits album trackArtDate +t.test(`Track.sampledByTracks`, t => { + t.plan(4); - album.trackArtDate = albumTrackArtDate; + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + const {track: track3, album: album3} = stubTrackAndAlbum('track3'); + const {track: track4, album: album4} = stubTrackAndAlbum('track4'); - // XXX clear cache again - track.albumData = []; - track.albumData = [album]; + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2, album3, album4], + trackData: [track1, track2, track3, track4], + }); - t.equal(track.coverArtDate, albumTrackArtDate); + t.same(track1.sampledByTracks, [], + `sampledByTracks #1: defaults to empty array`); - // 4. coverArtDate is overridden dateFirstReleased + track2.sampledTracks = ['track:track1']; + track3.sampledTracks = ['track:track1']; + XXX_decacheWikiData(); - track.dateFirstReleased = trackDateFirstReleased; + t.same(track1.sampledByTracks, [track2, track3], + `sampledByTracks #2: matches tracks' sampledTracks`); - t.equal(track.coverArtDate, trackDateFirstReleased); + track4.referencedTracks = ['track:track1']; + XXX_decacheWikiData(); - // 5. coverArtDate is overridden coverArtDate + t.same(track1.sampledByTracks, [track2, track3], + `sampledByTracks #3: doesn't match tracks' referencedTracks`); - track.coverArtDate = trackCoverArtDate; + track3.originalReleaseTrack = 'track:track2'; + XXX_decacheWikiData(); - t.equal(track.coverArtDate, trackCoverArtDate); + t.same(track1.sampledByTracks, [track2], + `sampledByTracks #4: doesn't include re-releases`); }); |