diff options
Diffstat (limited to 'src/data/things.js')
-rw-r--r-- | src/data/things.js | 2722 |
1 files changed, 1442 insertions, 1280 deletions
diff --git a/src/data/things.js b/src/data/things.js index 6a5cdb5e..fa7a8d54 100644 --- a/src/data/things.js +++ b/src/data/things.js @@ -1,45 +1,45 @@ +/** @format */ + // things.js: class definitions for various object types used across the wiki, // most of which correspond to an output page, such as Track, Album, Artist import CacheableObject from './cacheable-object.js'; import { - isAdditionalFileList, - isBoolean, - isColor, - isCommentary, - isCountingNumber, - isContributionList, - isDate, - isDimensions, - isDirectory, - isDuration, - isInstance, - isFileExtension, - isLanguageCode, - isName, - isNumber, - isURL, - isString, - isWholeNumber, - oneOf, - validateArrayItems, - validateInstanceOf, - validateReference, - validateReferenceList, + isAdditionalFileList, + isBoolean, + isColor, + isCommentary, + isCountingNumber, + isContributionList, + isDate, + isDimensions, + isDirectory, + isDuration, + isFileExtension, + isLanguageCode, + isName, + isNumber, + isURL, + isString, + oneOf, + validateArrayItems, + validateInstanceOf, + validateReference, + validateReferenceList, } from './validators.js'; import * as S from './serialize.js'; import { - getKebabCase, - sortAlbumsTracksChronologically, + getKebabCase, + sortAlbumsTracksChronologically, } from '../util/wiki-data.js'; import find from '../util/find.js'; -import { inspect } from 'util'; -import { color } from '../util/cli.js'; +import {inspect} from 'util'; +import {color} from '../util/cli.js'; // Stub classes (and their exports) at the top of the file - these are // referenced later when we actually define static class fields. We deliberately @@ -112,551 +112,579 @@ Flash[Thing.referenceType] = 'flash'; // duplicating less code across wiki data types. These are specialized utility // functions, so check each for how its own arguments behave! Thing.common = { - name: (defaultName) => ({ - flags: {update: true, expose: true}, - update: {validate: isName, default: defaultName} - }), - - color: () => ({ - flags: {update: true, expose: true}, - update: {validate: isColor} - }), - - directory: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: { - dependencies: ['name'], - transform(directory, { name }) { - if (directory === null && name === null) - return null; - else if (directory === null) - return getKebabCase(name); - else - return directory; - } - } - }), - - urls: () => ({ - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)} - }), - - // A file extension! Or the default, if provided when calling this. - fileExtension: (defaultFileExtension = null) => ({ - flags: {update: true, expose: true}, - update: {validate: isFileExtension}, - expose: {transform: value => value ?? defaultFileExtension} - }), - - // Straightforward flag descriptor for a variety of property purposes. - // Provide a default value, true or false! - flag: (defaultValue = false) => { - if (typeof defaultValue !== 'boolean') { - throw new TypeError(`Always set explicit defaults for flags!`); - } + name: (defaultName) => ({ + flags: {update: true, expose: true}, + update: {validate: isName, default: defaultName}, + }), + + color: () => ({ + flags: {update: true, expose: true}, + update: {validate: isColor}, + }), + + directory: () => ({ + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + expose: { + dependencies: ['name'], + transform(directory, {name}) { + if (directory === null && name === null) return null; + else if (directory === null) return getKebabCase(name); + else return directory; + }, + }, + }), + + urls: () => ({ + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isURL)}, + }), + + // A file extension! Or the default, if provided when calling this. + fileExtension: (defaultFileExtension = null) => ({ + flags: {update: true, expose: true}, + update: {validate: isFileExtension}, + expose: {transform: (value) => value ?? defaultFileExtension}, + }), + + // Straightforward flag descriptor for a variety of property purposes. + // Provide a default value, true or false! + flag: (defaultValue = false) => { + if (typeof defaultValue !== 'boolean') { + throw new TypeError(`Always set explicit defaults for flags!`); + } - return { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: defaultValue} - }; - }, + return { + flags: {update: true, expose: true}, + update: {validate: isBoolean, default: defaultValue}, + }; + }, + + // General date type, used as the descriptor for a bunch of properties. + // This isn't dynamic though - it won't inherit from a date stored on + // another object, for example. + simpleDate: () => ({ + flags: {update: true, expose: true}, + update: {validate: isDate}, + }), + + // General string type. This should probably generally be avoided in favor + // of more specific validation, but using it makes it easy to find where we + // might want to improve later, and it's a useful shorthand meanwhile. + simpleString: () => ({ + flags: {update: true, expose: true}, + update: {validate: isString}, + }), + + // External function. These should only be used as dependencies for other + // properties, so they're left unexposed. + externalFunction: () => ({ + flags: {update: true}, + update: {validate: (t) => typeof t === 'function'}, + }), + + // Super simple "contributions by reference" list, used for a variety of + // properties (Artists, Cover Artists, etc). This is the property which is + // externally provided, in the form: + // + // [ + // {who: 'Artist Name', what: 'Viola'}, + // {who: 'artist:john-cena', what: null}, + // ... + // ] + // + // ...processed from YAML, spreadsheet, or any other kind of input. + contribsByRef: () => ({ + flags: {update: true, expose: true}, + update: {validate: isContributionList}, + }), + + // Artist commentary! Generally present on tracks and albums. + commentary: () => ({ + flags: {update: true, expose: true}, + update: {validate: isCommentary}, + }), + + // This is a somewhat more involved data structure - it's for additional + // or "bonus" files associated with albums or tracks (or anything else). + // It's got this form: + // + // [ + // {title: 'Booklet', files: ['Booklet.pdf']}, + // { + // title: 'Wallpaper', + // description: 'Cool Wallpaper!', + // files: ['1440x900.png', '1920x1080.png'] + // }, + // {title: 'Alternate Covers', description: null, files: [...]}, + // ... + // ] + // + additionalFiles: () => ({ + flags: {update: true, expose: true}, + update: {validate: isAdditionalFileList}, + }), + + // 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!` + ); + } - // General date type, used as the descriptor for a bunch of properties. - // This isn't dynamic though - it won't inherit from a date stored on - // another object, for example. - simpleDate: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDate} - }), - - // General string type. This should probably generally be avoided in favor - // of more specific validation, but using it makes it easy to find where we - // might want to improve later, and it's a useful shorthand meanwhile. - simpleString: () => ({ - flags: {update: true, expose: true}, - update: {validate: isString} - }), - - // External function. These should only be used as dependencies for other - // properties, so they're left unexposed. - externalFunction: () => ({ - flags: {update: true}, - update: {validate: t => typeof t === 'function'} - }), - - // Super simple "contributions by reference" list, used for a variety of - // properties (Artists, Cover Artists, etc). This is the property which is - // externally provided, in the form: - // - // [ - // {who: 'Artist Name', what: 'Viola'}, - // {who: 'artist:john-cena', what: null}, - // ... - // ] - // - // ...processed from YAML, spreadsheet, or any other kind of input. - contribsByRef: () => ({ - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }), - - // Artist commentary! Generally present on tracks and albums. - commentary: () => ({ - flags: {update: true, expose: true}, - update: {validate: isCommentary} - }), - - // This is a somewhat more involved data structure - it's for additional - // or "bonus" files associated with albums or tracks (or anything else). - // It's got this form: - // - // [ - // {title: 'Booklet', files: ['Booklet.pdf']}, - // { - // title: 'Wallpaper', - // description: 'Cool Wallpaper!', - // files: ['1440x900.png', '1920x1080.png'] - // }, - // {title: 'Alternate Covers', description: null, files: [...]}, - // ... - // ] - // - additionalFiles: () => ({ - flags: {update: true, expose: true}, - update: {validate: isAdditionalFileList} - }), - - // A reference list! Keep in mind this is for general references to wiki - // objects of (usually) other Thing subclasses, not specifically leitmotif - // references in tracks (although that property uses referenceList too!). - // - // The underlying function validateReferenceList expects a string like - // 'artist' or 'track', but this utility keeps from having to hard-code the - // string in multiple places by referencing the value saved on the class - // instead. - referenceList: thingClass => { - const { [Thing.referenceType]: referenceType } = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } + return { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList(referenceType)}, + }; + }, + + // Corresponding function for a single reference. + singleReference: (thingClass) => { + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error( + `The passed constructor ${thingClass.name} doesn't define Thing.referenceType!` + ); + } - return { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList(referenceType)} - }; - }, + return { + flags: {update: true, expose: true}, + update: {validate: validateReference(referenceType)}, + }; + }, + + // Corresponding dynamic property to referenceList, which takes the values + // in the provided property and searches the specified wiki data for + // matching actual Thing-subclass objects. + dynamicThingsFromReferenceList: ( + referenceListProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, - // 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!`); - } + expose: { + dependencies: [referenceListProperty, thingDataProperty], + compute: ({ + [referenceListProperty]: refs, + [thingDataProperty]: thingData, + }) => + refs && thingData + ? refs + .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) + .filter(Boolean) + : [], + }, + }), + + // Corresponding function for a single reference. + dynamicThingFromSingleReference: ( + singleReferenceProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, - return { - flags: {update: true, expose: true}, - update: {validate: validateReference(referenceType)} - }; - }, + expose: { + dependencies: [singleReferenceProperty, thingDataProperty], + compute: ({ + [singleReferenceProperty]: ref, + [thingDataProperty]: thingData, + }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null), + }, + }), + + // Corresponding dynamic property to contribsByRef, which takes the values + // in the provided property and searches the object's artistData for + // matching actual Artist objects. The computed structure has the same form + // as contribsByRef, but with Artist objects instead of string references: + // + // [ + // {who: (an Artist), what: 'Viola'}, + // {who: (an Artist), what: null}, + // ... + // ] + // + // Contributions whose "who" values don't match anything in artistData are + // filtered out. (So if the list is all empty, chances are that either the + // reference list is somehow messed up, or artistData isn't being provided + // properly.) + dynamicContribs: (contribsByRefProperty) => ({ + flags: {expose: true}, + expose: { + dependencies: ['artistData', contribsByRefProperty], + compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => + contribsByRef && artistData + ? contribsByRef + .map(({who: ref, what}) => ({ + who: find.artist(ref, artistData), + what, + })) + .filter(({who}) => who) + : [], + }, + }), + + // Dynamically inherit a contribution list from some other object, if it + // hasn't been overridden on this object. This is handy for solo albums + // where all tracks have the same artist, for example. + // + // Note: The arguments of this function aren't currently final! The final + // format will look more like (contribsByRef, parentContribsByRef), e.g. + // ('artistContribsByRef', '@album/artistContribsByRef'). + dynamicInheritContribs: ( + contribsByRefProperty, + parentContribsByRefProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, + expose: { + dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'], + compute({ + [Thing.instance]: thing, + [contribsByRefProperty]: contribsByRef, + [thingDataProperty]: thingData, + artistData, + }) { + if (!artistData) 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); + }, + }, + }), + + // 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: (wikiDataProperty, referencerRefListProperty) => ({ + flags: {expose: true}, - // Corresponding dynamic property to referenceList, which takes the values - // in the provided property and searches the specified wiki data for - // matching actual Thing-subclass objects. - dynamicThingsFromReferenceList: ( - referenceListProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [referenceListProperty, thingDataProperty], - compute: ({ [referenceListProperty]: refs, [thingDataProperty]: thingData }) => ( - (refs && thingData - ? (refs - .map(ref => findFn(ref, thingData, {mode: 'quiet'})) - .filter(Boolean)) - : []) - ) - } - }), - - // Corresponding function for a single reference. - dynamicThingFromSingleReference: ( - singleReferenceProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [singleReferenceProperty, thingDataProperty], - compute: ({ [singleReferenceProperty]: ref, [thingDataProperty]: thingData }) => ( - (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null) - ) - } - }), - - // Corresponding dynamic property to contribsByRef, which takes the values - // in the provided property and searches the object's artistData for - // matching actual Artist objects. The computed structure has the same form - // as contribsByRef, but with Artist objects instead of string references: - // - // [ - // {who: (an Artist), what: 'Viola'}, - // {who: (an Artist), what: null}, - // ... - // ] - // - // Contributions whose "who" values don't match anything in artistData are - // filtered out. (So if the list is all empty, chances are that either the - // reference list is somehow messed up, or artistData isn't being provided - // properly.) - dynamicContribs: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: ['artistData', contribsByRefProperty], - compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) => ( - ((contribsByRef && artistData) - ? (contribsByRef - .map(({ who: ref, what }) => ({ - who: find.artist(ref, artistData), - what - })) - .filter(({ who }) => who)) - : []) - ) - } - }), - - // Dynamically inherit a contribution list from some other object, if it - // hasn't been overridden on this object. This is handy for solo albums - // where all tracks have the same artist, for example. - // - // Note: The arguments of this function aren't currently final! The final - // format will look more like (contribsByRef, parentContribsByRef), e.g. - // ('artistContribsByRef', '@album/artistContribsByRef'). - dynamicInheritContribs: ( - contribsByRefProperty, - parentContribsByRefProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - expose: { - dependencies: [contribsByRefProperty, thingDataProperty, 'artistData'], - compute({ - [Thing.instance]: thing, - [contribsByRefProperty]: contribsByRef, - [thingDataProperty]: thingData, - artistData - }) { - if (!artistData) 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)); - } - } - }), - - // 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: (wikiDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [wikiDataProperty], - - compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => ( - (wikiData - ? wikiData.filter(t => t[referencerRefListProperty]?.includes(thing)) - : []) + expose: { + dependencies: [wikiDataProperty], + + compute: ({[wikiDataProperty]: wikiData, [Thing.instance]: thing}) => + wikiData + ? wikiData.filter((t) => + t[referencerRefListProperty]?.includes(thing) ) - } - }), + : [], + }, + }), - // Corresponding function for single references. Note that the return value - // is still a list - this is for matching all the objects whose single - // reference (in the given property) matches this Thing. - reverseSingleReference: (wikiDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, + // 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: (wikiDataProperty, referencerRefListProperty) => ({ + flags: {expose: true}, - expose: { - dependencies: [wikiDataProperty], + expose: { + dependencies: [wikiDataProperty], - compute: ({ [wikiDataProperty]: wikiData, [Thing.instance]: thing }) => ( - wikiData?.filter(t => t[referencerRefListProperty] === thing)) - } - }), - - // General purpose wiki data constructor, for properties like artistData, - // trackData, etc. - wikiData: (thingClass) => ({ - flags: {update: true}, - update: { - validate: validateArrayItems(validateInstanceOf(thingClass)) - } - }), - - // This one's kinda tricky: it parses artist "references" from the - // commentary content, and finds the matching artist for each reference. - // This is mostly useful for credits and listings on artist pages. - commentatorArtists: () => ({ - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({ artistData, commentary }) => ( - (artistData && commentary - ? Array.from(new Set((Array - .from(commentary - .replace(/<\/?b>/g, '') - .matchAll(/<i>(?<who>.*?):<\/i>/g)) - .map(({ groups: {who} }) => find.artist(who, artistData, {mode: 'quiet'}))))) - : [])) - } - }), + compute: ({[wikiDataProperty]: wikiData, [Thing.instance]: thing}) => + wikiData?.filter((t) => t[referencerRefListProperty] === thing), + }, + }), + + // General purpose wiki data constructor, for properties like artistData, + // trackData, etc. + wikiData: (thingClass) => ({ + flags: {update: true}, + update: { + validate: validateArrayItems(validateInstanceOf(thingClass)), + }, + }), + + // This one's kinda tricky: it parses artist "references" from the + // commentary content, and finds the matching artist for each reference. + // This is mostly useful for credits and listings on artist pages. + commentatorArtists: () => ({ + flags: {expose: true}, + + expose: { + dependencies: ['artistData', 'commentary'], + + compute: ({artistData, commentary}) => + artistData && commentary + ? Array.from( + new Set( + Array.from( + commentary + .replace(/<\/?b>/g, '') + .matchAll(/<i>(?<who>.*?):<\/i>/g) + ).map(({groups: {who}}) => + find.artist(who, artistData, {mode: 'quiet'}) + ) + ) + ) + : [], + }, + }), }; // Get a reference to a thing (e.g. track:showtime-piano-refrain), using its // constructor's [Thing.referenceType] as the prefix. This will throw an error // if the thing's directory isn't yet provided/computable. -Thing.getReference = function(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}`; +Thing.getReference = function (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}`; }; // Default custom inspect function, which may be overridden by Thing subclasses. // This will be used when displaying aggregate errors and other in command-line // logging - it's the place to provide information useful in identifying the // Thing being presented. -Thing.prototype[inspect.custom] = function() { - const cname = this.constructor.name; - - return (this.name - ? `${cname} ${color.green(`"${this.name}"`)}` - : `${cname}`) + (this.directory - ? ` (${color.blue(Thing.getReference(this))})` - : ''); +Thing.prototype[inspect.custom] = function () { + const cname = this.constructor.name; + + return ( + (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) + + (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '') + ); }; // -> Album Album.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Album'), - color: Thing.common.color(), - directory: Thing.common.directory(), - urls: Thing.common.urls(), + 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(), + date: Thing.common.simpleDate(), + trackArtDate: Thing.common.simpleDate(), + dateAddedToWiki: Thing.common.simpleDate(), - coverArtDate: { - flags: {update: true, expose: true}, + coverArtDate: { + flags: {update: true, expose: true}, - update: {validate: isDate}, + update: {validate: isDate}, - expose: { - dependencies: ['date'], - transform: (coverArtDate, { date }) => coverArtDate ?? date ?? null - } + expose: { + dependencies: ['date'], + transform: (coverArtDate, {date}) => coverArtDate ?? date ?? null, }, + }, - artistContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), - trackCoverArtistContribsByRef: Thing.common.contribsByRef(), - wallpaperArtistContribsByRef: Thing.common.contribsByRef(), - bannerArtistContribsByRef: Thing.common.contribsByRef(), + 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), + groupsByRef: Thing.common.referenceList(Group), + artTagsByRef: Thing.common.referenceList(ArtTag), - trackGroups: { - flags: {update: true, expose: true}, + trackGroups: { + flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(validateInstanceOf(TrackGroup)) - } + update: { + validate: validateArrayItems(validateInstanceOf(TrackGroup)), }, + }, - coverArtFileExtension: Thing.common.fileExtension('jpg'), - trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), + coverArtFileExtension: Thing.common.fileExtension('jpg'), + trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), - wallpaperStyle: Thing.common.simpleString(), - wallpaperFileExtension: Thing.common.fileExtension('jpg'), - - bannerStyle: Thing.common.simpleString(), - bannerFileExtension: Thing.common.fileExtension('jpg'), - bannerDimensions: { - flags: {update: true, expose: true}, - update: {validate: isDimensions} - }, + wallpaperStyle: Thing.common.simpleString(), + wallpaperFileExtension: Thing.common.fileExtension('jpg'), - hasCoverArt: Thing.common.flag(true), - hasTrackArt: Thing.common.flag(true), - hasTrackNumbers: Thing.common.flag(true), - isMajorRelease: Thing.common.flag(false), - isListedOnHomepage: Thing.common.flag(true), + bannerStyle: Thing.common.simpleString(), + bannerFileExtension: Thing.common.fileExtension('jpg'), + bannerDimensions: { + flags: {update: true, expose: true}, + update: {validate: isDimensions}, + }, - commentary: Thing.common.commentary(), - additionalFiles: Thing.common.additionalFiles(), + hasCoverArt: Thing.common.flag(true), + hasTrackArt: Thing.common.flag(true), + hasTrackNumbers: Thing.common.flag(true), + isMajorRelease: Thing.common.flag(false), + isListedOnHomepage: Thing.common.flag(true), - // Update only + commentary: Thing.common.commentary(), + additionalFiles: Thing.common.additionalFiles(), - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - groupData: Thing.common.wikiData(Group), - trackData: Thing.common.wikiData(Track), + // Update only - // Expose only + artistData: Thing.common.wikiData(Artist), + artTagData: Thing.common.wikiData(ArtTag), + groupData: Thing.common.wikiData(Group), + trackData: Thing.common.wikiData(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'), + // Expose only - commentatorArtists: Thing.common.commentatorArtists(), + 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' + ), - tracks: { - flags: {expose: true}, + commentatorArtists: Thing.common.commentatorArtists(), - expose: { - dependencies: ['trackGroups', 'trackData'], - compute: ({ trackGroups, trackData }) => ( - (trackGroups && trackData - ? (trackGroups - .flatMap(group => group.tracksByRef ?? []) - .map(ref => find.track(ref, trackData, {mode: 'quiet'})) - .filter(Boolean)) - : []) - ) - } - }, - - groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group), + tracks: { + flags: {expose: true}, - artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + expose: { + dependencies: ['trackGroups', 'trackData'], + compute: ({trackGroups, trackData}) => + trackGroups && trackData + ? trackGroups + .flatMap((group) => group.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 + ), }; Album[S.serializeDescriptors] = { - name: S.id, - color: S.id, - directory: S.id, - urls: S.id, - - date: S.id, - coverArtDate: S.id, - trackArtDate: S.id, - dateAddedToWiki: S.id, - - artistContribs: S.toContribRefs, - coverArtistContribs: S.toContribRefs, - trackCoverArtistContribs: S.toContribRefs, - wallpaperArtistContribs: S.toContribRefs, - bannerArtistContribs: S.toContribRefs, - - coverArtFileExtension: S.id, - trackCoverArtFileExtension: S.id, - wallpaperStyle: S.id, - wallpaperFileExtension: S.id, - bannerStyle: S.id, - bannerFileExtension: S.id, - bannerDimensions: S.id, - - hasTrackArt: S.id, - isMajorRelease: S.id, - isListedOnHomepage: S.id, - - commentary: S.id, - additionalFiles: S.id, - - tracks: S.toRefs, - groups: S.toRefs, - artTags: S.toRefs, - commentatorArtists: S.toRefs, + name: S.id, + color: S.id, + directory: S.id, + urls: S.id, + + date: S.id, + coverArtDate: S.id, + trackArtDate: S.id, + dateAddedToWiki: S.id, + + artistContribs: S.toContribRefs, + coverArtistContribs: S.toContribRefs, + trackCoverArtistContribs: S.toContribRefs, + wallpaperArtistContribs: S.toContribRefs, + bannerArtistContribs: S.toContribRefs, + + coverArtFileExtension: S.id, + trackCoverArtFileExtension: S.id, + wallpaperStyle: S.id, + wallpaperFileExtension: S.id, + bannerStyle: S.id, + bannerFileExtension: S.id, + bannerDimensions: S.id, + + hasTrackArt: S.id, + isMajorRelease: S.id, + isListedOnHomepage: S.id, + + commentary: S.id, + additionalFiles: S.id, + + tracks: S.toRefs, + groups: S.toRefs, + artTags: S.toRefs, + commentatorArtists: S.toRefs, }; TrackGroup.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Track Group'), + name: Thing.common.name('Unnamed Track Group'), - color: { - flags: {update: true, expose: true}, + color: { + flags: {update: true, expose: true}, - update: {validate: isColor}, + update: {validate: isColor}, - expose: { - dependencies: ['album'], + expose: { + dependencies: ['album'], - transform(color, { album }) { - return color ?? album?.color ?? null; - } - } + transform(color, {album}) { + return color ?? album?.color ?? null; + }, }, + }, - dateOriginallyReleased: Thing.common.simpleDate(), + dateOriginallyReleased: Thing.common.simpleDate(), - tracksByRef: Thing.common.referenceList(Track), + tracksByRef: Thing.common.referenceList(Track), - isDefaultTrackGroup: Thing.common.flag(false), + isDefaultTrackGroup: Thing.common.flag(false), - // Update only + // Update only - album: { - flags: {update: true}, - update: {validate: validateInstanceOf(Album)} - }, + album: { + flags: {update: true}, + update: {validate: validateInstanceOf(Album)}, + }, - trackData: Thing.common.wikiData(Track), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - tracks: { - flags: {expose: true}, + tracks: { + flags: {expose: true}, - expose: { - dependencies: ['tracksByRef', 'trackData'], - compute: ({ tracksByRef, trackData }) => ( - (tracksByRef && trackData - ? (tracksByRef - .map(ref => find.track(ref, trackData)) - .filter(Boolean)) - : []) - ) - } + expose: { + dependencies: ['tracksByRef', 'trackData'], + compute: ({tracksByRef, trackData}) => + tracksByRef && trackData + ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean) + : [], }, + }, - startIndex: { - flags: {expose: true}, + startIndex: { + flags: {expose: true}, - expose: { - dependencies: ['album'], - compute: ({ album, [TrackGroup.instance]: trackGroup }) => (album.trackGroups - .slice(0, album.trackGroups.indexOf(trackGroup)) - .reduce((acc, tg) => acc + tg.tracks.length, 0)) - } + expose: { + dependencies: ['album'], + compute: ({album, [TrackGroup.instance]: trackGroup}) => + album.trackGroups + .slice(0, album.trackGroups.indexOf(trackGroup)) + .reduce((acc, tg) => acc + tg.tracks.length, 0), }, + }, }; // -> Track @@ -665,1059 +693,1193 @@ TrackGroup.propertyDescriptors = { // several places. Ideally it wouldn't be - we'd just reuse the `album` property // - but support for that hasn't been coded yet :P Track.findAlbum = (track, albumData) => { - return albumData?.find(album => album.tracks.includes(track)); + return albumData?.find((album) => album.tracks.includes(track)); }; // Another reused utility function. This one's logic is a bit more complicated. -Track.hasCoverArt = (track, albumData, coverArtistContribsByRef, hasCoverArt) => { - return ( - hasCoverArt ?? - (coverArtistContribsByRef?.length > 0 || null) ?? - Track.findAlbum(track, albumData)?.hasTrackArt ?? - true); +Track.hasCoverArt = ( + track, + albumData, + coverArtistContribsByRef, + hasCoverArt +) => { + return ( + hasCoverArt ?? + (coverArtistContribsByRef?.length > 0 || null) ?? + Track.findAlbum(track, albumData)?.hasTrackArt ?? + true + ); }; Track.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Track'), - directory: Thing.common.directory(), + name: Thing.common.name('Unnamed Track'), + directory: Thing.common.directory(), - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration} - }, + duration: { + flags: {update: true, expose: true}, + update: {validate: isDuration}, + }, - urls: Thing.common.urls(), - dateFirstReleased: Thing.common.simpleDate(), + urls: Thing.common.urls(), + dateFirstReleased: Thing.common.simpleDate(), - hasURLs: Thing.common.flag(true), + hasURLs: Thing.common.flag(true), - artistContribsByRef: Thing.common.contribsByRef(), - contributorContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), + artistContribsByRef: Thing.common.contribsByRef(), + contributorContribsByRef: Thing.common.contribsByRef(), + coverArtistContribsByRef: Thing.common.contribsByRef(), - referencedTracksByRef: Thing.common.referenceList(Track), - artTagsByRef: Thing.common.referenceList(ArtTag), + referencedTracksByRef: Thing.common.referenceList(Track), + artTagsByRef: Thing.common.referenceList(ArtTag), - hasCoverArt: { - flags: {update: true, expose: true}, + hasCoverArt: { + flags: {update: true, expose: true}, - update: {validate: isBoolean}, + update: {validate: isBoolean}, - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (hasCoverArt, { albumData, coverArtistContribsByRef, [Track.instance]: track }) => ( - Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt)) - } + expose: { + dependencies: ['albumData', 'coverArtistContribsByRef'], + transform: ( + hasCoverArt, + {albumData, coverArtistContribsByRef, [Track.instance]: track} + ) => + Track.hasCoverArt( + track, + albumData, + coverArtistContribsByRef, + hasCoverArt + ), }, + }, - coverArtFileExtension: { - flags: {update: true, expose: true}, + coverArtFileExtension: { + flags: {update: true, expose: true}, - update: {validate: isFileExtension}, + 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') + 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', }, + }, - // Previously known as: (track).aka - originalReleaseTrackByRef: Thing.common.singleReference(Track), + // Previously known as: (track).aka + originalReleaseTrackByRef: Thing.common.singleReference(Track), - dataSourceAlbumByRef: Thing.common.singleReference(Album), + dataSourceAlbumByRef: Thing.common.singleReference(Album), - commentary: Thing.common.commentary(), - lyrics: Thing.common.simpleString(), - additionalFiles: Thing.common.additionalFiles(), + commentary: Thing.common.commentary(), + lyrics: Thing.common.simpleString(), + additionalFiles: Thing.common.additionalFiles(), - // Update only + // 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: Thing.common.wikiData(Album), + artistData: Thing.common.wikiData(Artist), + artTagData: Thing.common.wikiData(ArtTag), + flashData: Thing.common.wikiData(Flash), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - commentatorArtists: Thing.common.commentatorArtists(), + commentatorArtists: Thing.common.commentatorArtists(), - album: { - flags: {expose: true}, + album: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], - compute: ({ [Track.instance]: track, albumData }) => ( - albumData?.find(album => album.tracks.includes(track)) ?? null) - } - }, + 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}, - // 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 - ) - } + expose: { + dependencies: ['albumData', 'dateFirstReleased'], + compute: ({albumData, dateFirstReleased, [Track.instance]: track}) => + dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null, }, + }, - color: { - flags: {expose: true}, + color: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], + expose: { + dependencies: ['albumData'], - compute: ({ albumData, [Track.instance]: track }) => ( - (Track.findAlbum(track, albumData)?.trackGroups - .find(tg => tg.tracks.includes(track))?.color) - ?? null - ) - } + compute: ({albumData, [Track.instance]: track}) => + Track.findAlbum(track, albumData)?.trackGroups.find((tg) => + tg.tracks.includes(track) + )?.color ?? null, }, + }, - coverArtDate: { - flags: {update: true, expose: true}, + coverArtDate: { + flags: {update: true, expose: true}, - update: {validate: isDate}, + update: {validate: isDate}, - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - transform: (coverArtDate, { albumData, dateFirstReleased, [Track.instance]: track }) => ( - coverArtDate ?? - dateFirstReleased ?? - Track.findAlbum(track, albumData)?.trackArtDate ?? - Track.findAlbum(track, albumData)?.date ?? - null - ) - } - }, - - 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); - } - } - }, + expose: { + dependencies: ['albumData', 'dateFirstReleased'], + transform: ( + coverArtDate, + {albumData, dateFirstReleased, [Track.instance]: track} + ) => + coverArtDate ?? + dateFirstReleased ?? + Track.findAlbum(track, albumData)?.trackArtDate ?? + Track.findAlbum(track, albumData)?.date ?? + null, + }, + }, + + originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( + 'originalReleaseTrackByRef', + 'trackData', + find.track + ), + + otherReleases: { + flags: {expose: true}, - // Previously known as: (track).artists - artistContribs: Thing.common.dynamicInheritContribs('artistContribsByRef', 'artistContribsByRef', 'albumData', Track.findAlbum), - - // Previously known as: (track).contributors - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), - - // Previously known as: (track).coverArtists - coverArtistContribs: Thing.common.dynamicInheritContribs('coverArtistContribsByRef', 'trackCoverArtistContribsByRef', 'albumData', Track.findAlbum), - - // Previously known as: (track).references - referencedTracks: Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), - - // Specifically exclude re-releases from this list - while it's useful to - // get from a re-release to the tracks it references, re-releases aren't - // generally relevant from the perspective of the tracks being referenced. - // Filtering them from data here hides them from the corresponding field - // on the site (obviously), and has the bonus of not counting them when - // counting the number of times a track has been referenced, for use in - // the "Tracks - by Times Referenced" listing page (or other data - // processing). - referencedByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({ trackData, [Track.instance]: track }) => (trackData - ? (trackData - .filter(t => !t.originalReleaseTrack) - .filter(t => t.referencedTracks?.includes(track))) - : []) + expose: { + dependencies: ['originalReleaseTrackByRef', 'trackData'], + + compute: ({ + originalReleaseTrackByRef: t1origRef, + trackData, + [Track.instance]: t1, + }) => { + if (!trackData) { + return []; } - }, - // Previously known as: (track).flashes - featuredInFlashes: Thing.common.reverseReferenceList('flashData', 'featuredTracks'), + 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); + }, + }, + }, + + // Previously known as: (track).artists + artistContribs: Thing.common.dynamicInheritContribs( + 'artistContribsByRef', + 'artistContribsByRef', + 'albumData', + Track.findAlbum + ), + + // Previously known as: (track).contributors + contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + + // Previously known as: (track).coverArtists + coverArtistContribs: Thing.common.dynamicInheritContribs( + 'coverArtistContribsByRef', + 'trackCoverArtistContribsByRef', + 'albumData', + Track.findAlbum + ), + + // Previously known as: (track).references + referencedTracks: Thing.common.dynamicThingsFromReferenceList( + 'referencedTracksByRef', + 'trackData', + find.track + ), + + // Specifically exclude re-releases from this list - while it's useful to + // get from a re-release to the tracks it references, re-releases aren't + // generally relevant from the perspective of the tracks being referenced. + // Filtering them from data here hides them from the corresponding field + // on the site (obviously), and has the bonus of not counting them when + // counting the number of times a track has been referenced, for use in + // the "Tracks - by Times Referenced" listing page (or other data + // processing). + referencedByTracks: { + flags: {expose: true}, - artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + expose: { + dependencies: ['trackData'], + + compute: ({trackData, [Track.instance]: track}) => + trackData + ? trackData + .filter((t) => !t.originalReleaseTrack) + .filter((t) => t.referencedTracks?.includes(track)) + : [], + }, + }, + + // Previously known as: (track).flashes + featuredInFlashes: Thing.common.reverseReferenceList( + 'flashData', + 'featuredTracks' + ), + + artTags: Thing.common.dynamicThingsFromReferenceList( + 'artTagsByRef', + 'artTagData', + find.artTag + ), }; -Track.prototype[inspect.custom] = function() { - const base = Thing.prototype[inspect.custom].apply(this); +Track.prototype[inspect.custom] = function () { + const base = Thing.prototype[inspect.custom].apply(this); - const { album, dataSourceAlbum } = this; - const albumName = (album ? album.name : dataSourceAlbum?.name); - const albumIndex = albumName && (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this)); - const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`); + const {album, dataSourceAlbum} = this; + const albumName = album ? album.name : dataSourceAlbum?.name; + const albumIndex = + albumName && + (album ? album.tracks.indexOf(this) : dataSourceAlbum.tracks.indexOf(this)); + const trackNum = albumIndex === -1 ? '#?' : `#${albumIndex + 1}`; - return (albumName - ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})` - : base); + return albumName + ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})` + : base; }; // -> Artist Artist.filterByContrib = (thingDataProperty, contribsProperty) => ({ - flags: {expose: true}, + flags: {expose: true}, - expose: { - dependencies: [thingDataProperty], + expose: { + dependencies: [thingDataProperty], - compute: ({ [thingDataProperty]: thingData, [Artist.instance]: artist }) => ( - thingData?.filter(({ [contribsProperty]: contribs }) => ( - contribs?.some(contrib => contrib.who === artist)))) - } + compute: ({[thingDataProperty]: thingData, [Artist.instance]: artist}) => + thingData?.filter(({[contribsProperty]: contribs}) => + contribs?.some((contrib) => contrib.who === artist) + ), + }, }); Artist.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Artist'), - directory: Thing.common.directory(), - urls: Thing.common.urls(), - contextNotes: Thing.common.simpleString(), + name: Thing.common.name('Unnamed Artist'), + directory: Thing.common.directory(), + urls: Thing.common.urls(), + contextNotes: Thing.common.simpleString(), - hasAvatar: Thing.common.flag(false), - avatarFileExtension: Thing.common.fileExtension('jpg'), + hasAvatar: Thing.common.flag(false), + avatarFileExtension: Thing.common.fileExtension('jpg'), - aliasNames: { - flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(isName) - } + aliasNames: { + flags: {update: true, expose: true}, + update: { + validate: validateArrayItems(isName), }, + }, - isAlias: Thing.common.flag(), - aliasedArtistRef: Thing.common.singleReference(Artist), - - // Update only - - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), - - // Expose only - - aliasedArtist: { - flags: {expose: true}, + isAlias: Thing.common.flag(), + aliasedArtistRef: Thing.common.singleReference(Artist), - expose: { - dependencies: ['artistData', 'aliasedArtistRef'], - compute: ({ artistData, aliasedArtistRef }) => ( - (aliasedArtistRef && artistData - ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'}) - : null) - ) - } - }, - - tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'), - tracksAsContributor: Artist.filterByContrib('trackData', 'contributorContribs'), - tracksAsCoverArtist: Artist.filterByContrib('trackData', 'coverArtistContribs'), + // Update only - tracksAsAny: { - flags: {expose: true}, + albumData: Thing.common.wikiData(Album), + artistData: Thing.common.wikiData(Artist), + flashData: Thing.common.wikiData(Flash), + trackData: Thing.common.wikiData(Track), - expose: { - dependencies: ['trackData'], + // Expose only - compute: ({ trackData, [Artist.instance]: artist }) => ( - trackData?.filter(track => ( - [ - ...track.artistContribs, - ...track.contributorContribs, - ...track.coverArtistContribs - ].some(({ who }) => who === artist)))) - } - }, + aliasedArtist: { + flags: {expose: true}, - tracksAsCommentator: { - 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: Artist.filterByContrib( + 'trackData', + 'contributorContribs' + ), + tracksAsCoverArtist: Artist.filterByContrib( + 'trackData', + 'coverArtistContribs' + ), + + tracksAsAny: { + flags: {expose: true}, - expose: { - dependencies: ['trackData'], + expose: { + dependencies: ['trackData'], - compute: ({ trackData, [Artist.instance]: artist }) => ( - trackData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist))) - } + compute: ({trackData, [Artist.instance]: artist}) => + trackData?.filter((track) => + [ + ...track.artistContribs, + ...track.contributorContribs, + ...track.coverArtistContribs, + ].some(({who}) => who === artist) + ), }, + }, - albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'), - albumsAsCoverArtist: Artist.filterByContrib('albumData', 'coverArtistContribs'), - albumsAsWallpaperArtist: Artist.filterByContrib('albumData', 'wallpaperArtistContribs'), - albumsAsBannerArtist: Artist.filterByContrib('albumData', 'bannerArtistContribs'), + tracksAsCommentator: { + flags: {expose: true}, - albumsAsCommentator: { - flags: {expose: true}, + expose: { + dependencies: ['trackData'], + + compute: ({trackData, [Artist.instance]: artist}) => + trackData.filter(({commentatorArtists}) => + commentatorArtists?.includes(artist) + ), + }, + }, + + albumsAsAlbumArtist: Artist.filterByContrib('albumData', 'artistContribs'), + albumsAsCoverArtist: Artist.filterByContrib( + 'albumData', + 'coverArtistContribs' + ), + albumsAsWallpaperArtist: Artist.filterByContrib( + 'albumData', + 'wallpaperArtistContribs' + ), + albumsAsBannerArtist: Artist.filterByContrib( + 'albumData', + 'bannerArtistContribs' + ), + + albumsAsCommentator: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], + expose: { + dependencies: ['albumData'], - compute: ({ albumData, [Artist.instance]: artist }) => ( - albumData.filter(({ commentatorArtists }) => commentatorArtists?.includes(artist))) - } + compute: ({albumData, [Artist.instance]: artist}) => + albumData.filter(({commentatorArtists}) => + commentatorArtists?.includes(artist) + ), }, + }, - flashesAsContributor: Artist.filterByContrib('flashData', 'contributorContribs'), + flashesAsContributor: Artist.filterByContrib( + 'flashData', + 'contributorContribs' + ), }; Artist[S.serializeDescriptors] = { - name: S.id, - directory: S.id, - urls: S.id, - contextNotes: S.id, + name: S.id, + directory: S.id, + urls: S.id, + contextNotes: S.id, - hasAvatar: S.id, - avatarFileExtension: S.id, + hasAvatar: S.id, + avatarFileExtension: S.id, - aliasNames: S.id, + aliasNames: S.id, - tracksAsArtist: S.toRefs, - tracksAsContributor: S.toRefs, - tracksAsCoverArtist: S.toRefs, - tracksAsCommentator: S.toRefs, + tracksAsArtist: S.toRefs, + tracksAsContributor: S.toRefs, + tracksAsCoverArtist: S.toRefs, + tracksAsCommentator: S.toRefs, - albumsAsAlbumArtist: S.toRefs, - albumsAsCoverArtist: S.toRefs, - albumsAsWallpaperArtist: S.toRefs, - albumsAsBannerArtist: S.toRefs, - albumsAsCommentator: S.toRefs, + albumsAsAlbumArtist: S.toRefs, + albumsAsCoverArtist: S.toRefs, + albumsAsWallpaperArtist: S.toRefs, + albumsAsBannerArtist: S.toRefs, + albumsAsCommentator: S.toRefs, - flashesAsContributor: S.toRefs, + flashesAsContributor: S.toRefs, }; // -> Group Group.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Group'), - directory: Thing.common.directory(), + name: Thing.common.name('Unnamed Group'), + directory: Thing.common.directory(), - description: Thing.common.simpleString(), + description: Thing.common.simpleString(), - urls: Thing.common.urls(), + urls: Thing.common.urls(), - // Update only + // Update only - albumData: Thing.common.wikiData(Album), - groupCategoryData: Thing.common.wikiData(GroupCategory), + albumData: Thing.common.wikiData(Album), + groupCategoryData: Thing.common.wikiData(GroupCategory), - // Expose only + // Expose only - descriptionShort: { - flags: {expose: true}, + descriptionShort: { + flags: {expose: true}, - expose: { - dependencies: ['description'], - compute: ({ description }) => description.split('<hr class="split">')[0] - } + expose: { + dependencies: ['description'], + compute: ({description}) => description.split('<hr class="split">')[0], }, + }, - albums: { - flags: {expose: true}, + albums: { + flags: {expose: true}, - expose: { - dependencies: ['albumData'], - compute: ({ albumData, [Group.instance]: group }) => ( - albumData?.filter(album => album.groups.includes(group)) ?? []) - } + expose: { + dependencies: ['albumData'], + compute: ({albumData, [Group.instance]: group}) => + albumData?.filter((album) => album.groups.includes(group)) ?? [], }, + }, - color: { - flags: {expose: true}, + color: { + flags: {expose: true}, - expose: { - dependencies: ['groupCategoryData'], + expose: { + dependencies: ['groupCategoryData'], - compute: ({ groupCategoryData, [Group.instance]: group }) => ( - groupCategoryData.find(category => category.groups.includes(group))?.color ?? null) - } + compute: ({groupCategoryData, [Group.instance]: group}) => + groupCategoryData.find((category) => category.groups.includes(group)) + ?.color ?? null, }, + }, - category: { - flags: {expose: true}, + category: { + flags: {expose: true}, - expose: { - dependencies: ['groupCategoryData'], - compute: ({ groupCategoryData, [Group.instance]: group }) => ( - groupCategoryData.find(category => category.groups.includes(group)) ?? null) - } + expose: { + dependencies: ['groupCategoryData'], + compute: ({groupCategoryData, [Group.instance]: group}) => + groupCategoryData.find((category) => category.groups.includes(group)) ?? + null, }, + }, }; GroupCategory.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Group Category'), - color: Thing.common.color(), + name: Thing.common.name('Unnamed Group Category'), + color: Thing.common.color(), - groupsByRef: Thing.common.referenceList(Group), + groupsByRef: Thing.common.referenceList(Group), - // Update only + // Update only - groupData: Thing.common.wikiData(Group), + groupData: Thing.common.wikiData(Group), - // Expose only + // Expose only - groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group), + groups: Thing.common.dynamicThingsFromReferenceList( + 'groupsByRef', + 'groupData', + find.group + ), }; // -> ArtTag ArtTag.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Art Tag'), - directory: Thing.common.directory(), - color: Thing.common.color(), - isContentWarning: Thing.common.flag(false), + name: Thing.common.name('Unnamed Art Tag'), + directory: Thing.common.directory(), + color: Thing.common.color(), + isContentWarning: Thing.common.flag(false), - // Update only + // Update only - albumData: Thing.common.wikiData(Album), - trackData: Thing.common.wikiData(Track), + albumData: Thing.common.wikiData(Album), + trackData: Thing.common.wikiData(Track), - // Expose only + // Expose only - // Previously known as: (tag).things - taggedInThings: { - flags: {expose: true}, + // Previously known as: (tag).things + taggedInThings: { + flags: {expose: true}, - expose: { - dependencies: ['albumData', 'trackData'], - compute: ({ albumData, trackData, [ArtTag.instance]: artTag }) => ( - sortAlbumsTracksChronologically( - ([...albumData, ...trackData] - .filter(thing => thing.artTags?.includes(artTag))), - {getDate: o => o.coverArtDate})) - } - } + expose: { + dependencies: ['albumData', 'trackData'], + compute: ({albumData, trackData, [ArtTag.instance]: artTag}) => + sortAlbumsTracksChronologically( + [...albumData, ...trackData].filter((thing) => + thing.artTags?.includes(artTag) + ), + {getDate: (o) => o.coverArtDate} + ), + }, + }, }; // -> NewsEntry NewsEntry.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed News Entry'), - directory: Thing.common.directory(), - date: Thing.common.simpleDate(), + name: Thing.common.name('Unnamed News Entry'), + directory: Thing.common.directory(), + date: Thing.common.simpleDate(), - content: Thing.common.simpleString(), + content: Thing.common.simpleString(), - // Expose only + // Expose only - contentShort: { - flags: {expose: true}, + contentShort: { + flags: {expose: true}, - expose: { - dependencies: ['content'], + expose: { + dependencies: ['content'], - compute: ({ content }) => content.split('<hr class="split">')[0] - } + compute: ({content}) => content.split('<hr class="split">')[0], }, + }, }; // -> StaticPage StaticPage.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Static Page'), + name: Thing.common.name('Unnamed Static Page'), - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, - expose: { - dependencies: ['name'], - transform: (value, { name }) => value ?? name - } + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, }, + }, - directory: Thing.common.directory(), - content: Thing.common.simpleString(), - stylesheet: Thing.common.simpleString(), - showInNavigationBar: Thing.common.flag(true), + directory: Thing.common.directory(), + content: Thing.common.simpleString(), + stylesheet: Thing.common.simpleString(), + showInNavigationBar: Thing.common.flag(true), }; // -> HomepageLayout HomepageLayout.propertyDescriptors = { - // Update & expose + // Update & expose - sidebarContent: Thing.common.simpleString(), + sidebarContent: Thing.common.simpleString(), - rows: { - flags: {update: true, expose: true}, + rows: { + flags: {update: true, expose: true}, - update: { - validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)) - } + update: { + validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), }, + }, }; HomepageLayoutRow.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Homepage Row'), + name: Thing.common.name('Unnamed Homepage Row'), - type: { - flags: {update: true, expose: true}, + type: { + flags: {update: true, expose: true}, - update: { - validate(value) { - throw new Error(`'type' property validator must be overridden`); - } - } + update: { + validate() { + throw new Error(`'type' property validator must be overridden`); + }, }, + }, - color: Thing.common.color(), + color: Thing.common.color(), - // Update only + // 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), + // 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), }; HomepageLayoutAlbumsRow.propertyDescriptors = { - ...HomepageLayoutRow.propertyDescriptors, + ...HomepageLayoutRow.propertyDescriptors, - // Update & expose + // Update & expose - type: { - flags: {update: true, expose: true}, - update: { - validate(value) { - if (value !== 'albums') { - throw new TypeError(`Expected 'albums'`); - } - - return true; - } + type: { + flags: {update: true, expose: true}, + update: { + validate(value) { + if (value !== 'albums') { + throw new TypeError(`Expected 'albums'`); } + + return true; + }, }, + }, - sourceGroupByRef: Thing.common.singleReference(Group), - sourceAlbumsByRef: Thing.common.referenceList(Album), + sourceGroupByRef: Thing.common.singleReference(Group), + sourceAlbumsByRef: Thing.common.referenceList(Album), - countAlbumsFromGroup: { - flags: {update: true, expose: true}, - update: {validate: isCountingNumber} - }, + countAlbumsFromGroup: { + flags: {update: true, expose: true}, + update: {validate: isCountingNumber}, + }, - actionLinks: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isString)} - }, + actionLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isString)}, + }, - // Expose only + // Expose only - sourceGroup: Thing.common.dynamicThingFromSingleReference('sourceGroupByRef', 'groupData', find.group), - sourceAlbums: Thing.common.dynamicThingsFromReferenceList('sourceAlbumsByRef', 'albumData', find.album), + sourceGroup: Thing.common.dynamicThingFromSingleReference( + 'sourceGroupByRef', + 'groupData', + find.group + ), + sourceAlbums: Thing.common.dynamicThingsFromReferenceList( + 'sourceAlbumsByRef', + 'albumData', + find.album + ), }; // -> Flash Flash.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Flash'), - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - - // Flashes expose directory differently from other Things! Their - // default directory is dependent on the page number (or ID), not - // the name. - expose: { - dependencies: ['page'], - transform(directory, { page }) { - if (directory === null && page === null) - return null; - else if (directory === null) - return page; - else - return directory; - } - } + // Update & expose + + name: Thing.common.name('Unnamed Flash'), + + directory: { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + + // Flashes expose directory differently from other Things! Their + // default directory is dependent on the page number (or ID), not + // the name. + expose: { + dependencies: ['page'], + transform(directory, {page}) { + if (directory === null && page === null) return null; + else if (directory === null) return page; + else return directory; + }, }, + }, - page: { - flags: {update: true, expose: true}, - update: {validate: oneOf(isString, isNumber)}, + page: { + flags: {update: true, expose: true}, + update: {validate: oneOf(isString, isNumber)}, - expose: { - transform: value => (value === null ? null : value.toString()) - } + expose: { + transform: (value) => (value === null ? null : value.toString()), }, + }, - date: Thing.common.simpleDate(), + date: Thing.common.simpleDate(), - coverArtFileExtension: Thing.common.fileExtension('jpg'), + coverArtFileExtension: Thing.common.fileExtension('jpg'), - contributorContribsByRef: Thing.common.contribsByRef(), + contributorContribsByRef: Thing.common.contribsByRef(), - featuredTracksByRef: Thing.common.referenceList(Track), + featuredTracksByRef: Thing.common.referenceList(Track), - urls: Thing.common.urls(), + urls: Thing.common.urls(), - // Update only + // Update only - artistData: Thing.common.wikiData(Artist), - trackData: Thing.common.wikiData(Track), - flashActData: Thing.common.wikiData(FlashAct), + artistData: Thing.common.wikiData(Artist), + trackData: Thing.common.wikiData(Track), + flashActData: Thing.common.wikiData(FlashAct), - // Expose only + // Expose only - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), - featuredTracks: Thing.common.dynamicThingsFromReferenceList('featuredTracksByRef', 'trackData', find.track), + featuredTracks: Thing.common.dynamicThingsFromReferenceList( + 'featuredTracksByRef', + 'trackData', + find.track + ), - act: { - flags: {expose: true}, + act: { + flags: {expose: true}, - expose: { - dependencies: ['flashActData'], + expose: { + dependencies: ['flashActData'], - compute: ({ flashActData, [Flash.instance]: flash }) => ( - flashActData.find(act => act.flashes.includes(flash)) ?? null) - } + compute: ({flashActData, [Flash.instance]: flash}) => + flashActData.find((act) => act.flashes.includes(flash)) ?? null, }, + }, - color: { - flags: {expose: true}, + color: { + flags: {expose: true}, - expose: { - dependencies: ['flashActData'], + expose: { + dependencies: ['flashActData'], - compute: ({ flashActData, [Flash.instance]: flash }) => ( - flashActData.find(act => act.flashes.includes(flash))?.color ?? null) - } + compute: ({flashActData, [Flash.instance]: flash}) => + flashActData.find((act) => act.flashes.includes(flash))?.color ?? null, }, + }, }; Flash[S.serializeDescriptors] = { - name: S.id, - page: S.id, - directory: S.id, - date: S.id, - contributors: S.toContribRefs, - tracks: S.toRefs, - urls: S.id, - color: S.id, + name: S.id, + page: S.id, + directory: S.id, + date: S.id, + contributors: S.toContribRefs, + tracks: S.toRefs, + urls: S.id, + color: S.id, }; FlashAct.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Flash Act'), - color: Thing.common.color(), - anchor: Thing.common.simpleString(), - jump: Thing.common.simpleString(), - jumpColor: Thing.common.color(), + name: Thing.common.name('Unnamed Flash Act'), + color: Thing.common.color(), + anchor: Thing.common.simpleString(), + jump: Thing.common.simpleString(), + jumpColor: Thing.common.color(), - flashesByRef: Thing.common.referenceList(Flash), + flashesByRef: Thing.common.referenceList(Flash), - // Update only + // Update only - flashData: Thing.common.wikiData(Flash), + flashData: Thing.common.wikiData(Flash), - // Expose only + // Expose only - flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash), + flashes: Thing.common.dynamicThingsFromReferenceList( + 'flashesByRef', + 'flashData', + find.flash + ), }; // -> WikiInfo WikiInfo.propertyDescriptors = { - // Update & expose + // Update & expose - name: Thing.common.name('Unnamed Wiki'), + name: Thing.common.name('Unnamed Wiki'), - // Displayed in nav bar. - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, + // Displayed in nav bar. + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, - expose: { - dependencies: ['name'], - transform: (value, { name }) => value ?? name - } + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, }, + }, - color: Thing.common.color(), + color: Thing.common.color(), - // One-line description used for <meta rel="description"> tag. - description: Thing.common.simpleString(), + // One-line description used for <meta rel="description"> tag. + description: Thing.common.simpleString(), - footerContent: Thing.common.simpleString(), + footerContent: Thing.common.simpleString(), - defaultLanguage: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode} - }, + defaultLanguage: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, - canonicalBase: { - flags: {update: true, expose: true}, - update: {validate: isURL} - }, + canonicalBase: { + flags: {update: true, expose: true}, + update: {validate: isURL}, + }, - divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), + divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), - // 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), + // 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), - // Update only + // Update only - groupData: Thing.common.wikiData(Group), + groupData: Thing.common.wikiData(Group), - // Expose only + // Expose only - divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group), + divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList( + 'divideTrackListsByGroupsByRef', + 'groupData', + find.group + ), }; // -> Language const intlHelper = (constructor, opts) => ({ - flags: {expose: true}, - expose: { - dependencies: ['code', 'intlCode'], - compute: ({ code, intlCode }) => { - const constructCode = intlCode ?? code; - if (!constructCode) return null; - return Reflect.construct(constructor, [constructCode, opts]); - } - } + flags: {expose: true}, + expose: { + dependencies: ['code', 'intlCode'], + compute: ({code, intlCode}) => { + const constructCode = intlCode ?? code; + if (!constructCode) return null; + return Reflect.construct(constructor, [constructCode, opts]); + }, + }, }); Language.propertyDescriptors = { - // Update & expose - - // General language code. This is used to identify the language distinctly - // from other languages (similar to how "Directory" operates in many data - // objects). - code: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode} - }, - - // Human-readable name. This should be the language's own native name, not - // localized to any other language. - name: Thing.common.simpleString(), - - // Language code specific to JavaScript's Internationalization (Intl) API. - // Usually this will be the same as the language's general code, but it - // may be overridden to provide Intl constructors an alternative value. - intlCode: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode}, - expose: { - dependencies: ['code'], - transform: (intlCode, { code }) => intlCode ?? code - } - }, - - // Flag which represents whether or not to hide a language from general - // access. If a language is hidden, its portion of the website will still - // be built (with all strings localized to the language), but it won't be - // included in controls for switching languages or the <link rel=alternate> - // tags used for search engine optimization. This flag is intended for use - // 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), - - // Mapping of translation keys to values (strings). Generally, don't - // access this object directly - use methods instead. - strings: { - flags: {update: true, expose: true}, - update: {validate: t => typeof t === 'object'}, - expose: { - dependencies: ['inheritedStrings'], - transform(strings, { inheritedStrings }) { - if (strings || inheritedStrings) { - return {...inheritedStrings ?? {}, ...strings ?? {}}; - } else { - return null; - } - } + // Update & expose + + // General language code. This is used to identify the language distinctly + // from other languages (similar to how "Directory" operates in many data + // objects). + code: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, + + // Human-readable name. This should be the language's own native name, not + // localized to any other language. + name: Thing.common.simpleString(), + + // Language code specific to JavaScript's Internationalization (Intl) API. + // Usually this will be the same as the language's general code, but it + // may be overridden to provide Intl constructors an alternative value. + intlCode: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + expose: { + dependencies: ['code'], + transform: (intlCode, {code}) => intlCode ?? code, + }, + }, + + // Flag which represents whether or not to hide a language from general + // access. If a language is hidden, its portion of the website will still + // be built (with all strings localized to the language), but it won't be + // included in controls for switching languages or the <link rel=alternate> + // tags used for search engine optimization. This flag is intended for use + // 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), + + // Mapping of translation keys to values (strings). Generally, don't + // access this object directly - use methods instead. + strings: { + flags: {update: true, expose: true}, + update: {validate: (t) => typeof t === 'object'}, + expose: { + dependencies: ['inheritedStrings'], + transform(strings, {inheritedStrings}) { + if (strings || inheritedStrings) { + return {...(inheritedStrings ?? {}), ...(strings ?? {})}; + } else { + return null; } + }, }, + }, - // May be provided to specify "default" strings, generally (but not - // necessarily) inherited from another Language object. - inheritedStrings: { - flags: {update: true, expose: true}, - update: {validate: t => typeof t === 'object'} - }, + // May be provided to specify "default" strings, generally (but not + // necessarily) inherited from another Language object. + inheritedStrings: { + flags: {update: true, expose: true}, + update: {validate: (t) => typeof t === 'object'}, + }, - // Update only + // Update only - escapeHTML: Thing.common.externalFunction(), + escapeHTML: Thing.common.externalFunction(), - // Expose only + // Expose only - intl_date: intlHelper(Intl.DateTimeFormat, {full: true}), - intl_number: intlHelper(Intl.NumberFormat), - intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}), - intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}), - intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}), - intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}), - intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}), + intl_date: intlHelper(Intl.DateTimeFormat, {full: true}), + intl_number: intlHelper(Intl.NumberFormat), + intl_listConjunction: intlHelper(Intl.ListFormat, {type: 'conjunction'}), + intl_listDisjunction: intlHelper(Intl.ListFormat, {type: 'disjunction'}), + intl_listUnit: intlHelper(Intl.ListFormat, {type: 'unit'}), + intl_pluralCardinal: intlHelper(Intl.PluralRules, {type: 'cardinal'}), + intl_pluralOrdinal: intlHelper(Intl.PluralRules, {type: 'ordinal'}), - validKeys: { - flags: {expose: true}, - - expose: { - dependencies: ['strings', 'inheritedStrings'], - compute: ({ strings, inheritedStrings }) => Array.from(new Set([ - ...Object.keys(inheritedStrings ?? {}), - ...Object.keys(strings ?? {}) - ])) - } - }, + validKeys: { + flags: {expose: true}, - strings_htmlEscaped: { - flags: {expose: true}, - expose: { - dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], - compute({ strings, inheritedStrings, escapeHTML }) { - if (!(strings || inheritedStrings) || !escapeHTML) return null; - const allStrings = {...inheritedStrings ?? {}, ...strings ?? {}}; - return Object.fromEntries(Object.entries(allStrings) - .map(([ k, v ]) => [k, escapeHTML(v)])); - } - } - }, + expose: { + dependencies: ['strings', 'inheritedStrings'], + compute: ({strings, inheritedStrings}) => + Array.from( + new Set([ + ...Object.keys(inheritedStrings ?? {}), + ...Object.keys(strings ?? {}), + ]) + ), + }, + }, + + strings_htmlEscaped: { + flags: {expose: true}, + expose: { + dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], + compute({strings, inheritedStrings, escapeHTML}) { + if (!(strings || inheritedStrings) || !escapeHTML) return null; + const allStrings = {...(inheritedStrings ?? {}), ...(strings ?? {})}; + return Object.fromEntries( + Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)]) + ); + }, + }, + }, }; -const countHelper = (stringKey, argName = stringKey) => function(value, {unit = false} = {}) { +const countHelper = (stringKey, argName = stringKey) => + function (value, {unit = false} = {}) { return this.$( - (unit - ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) - : `count.${stringKey}`), - {[argName]: this.formatNumber(value)}); -}; + unit + ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) + : `count.${stringKey}`, + {[argName]: this.formatNumber(value)} + ); + }; Object.assign(Language.prototype, { - $(key, args = {}) { - return this.formatString(key, args); - }, - - assertIntlAvailable(property) { - if (!this[property]) { - throw new Error(`Intl API ${property} unavailable`); - } - }, - - getUnitForm(value) { - this.assertIntlAvailable('intl_pluralCardinal'); - return this.intl_pluralCardinal.select(value); - }, - - formatString(key, args = {}) { - if (this.strings && !this.strings_htmlEscaped) { - throw new Error(`HTML-escaped strings unavailable - please ensure escapeHTML function is provided`); - } - - return this.formatStringHelper(this.strings_htmlEscaped, key, args); - }, - - formatStringNoHTMLEscape(key, args = {}) { - return this.formatStringHelper(this.strings, key, args); - }, - - formatStringHelper(strings, key, args = {}) { - if (!strings) { - throw new Error(`Strings unavailable`); - } - - if (!this.validKeys.includes(key)) { - throw new Error(`Invalid key ${key} accessed`); - } - - const template = strings[key]; - - // Convert the keys on the args dict from camelCase to CONSTANT_CASE. - // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut - // like, who cares, dude?) Also, this is an array, 8ecause it's handy - // for the iterating we're a8out to do. - const processedArgs = Object.entries(args) - .map(([ k, v ]) => [k.replace(/[A-Z]/g, '_$&').toUpperCase(), v]); - - // Replacement time! Woot. Reduce comes in handy here! - const output = processedArgs.reduce( - (x, [ k, v ]) => x.replaceAll(`{${k}}`, v), - template); - - // Post-processing: if any expected arguments *weren't* replaced, that - // is almost definitely an error. - if (output.match(/\{[A-Z_]+\}/)) { - throw new Error(`Args in ${key} were missing - output: ${output}`); - } - - return output; - }, + $(key, args = {}) { + return this.formatString(key, args); + }, - formatDate(date) { - this.assertIntlAvailable('intl_date'); - return this.intl_date.format(date); - }, - - formatDateRange(startDate, endDate) { - this.assertIntlAvailable('intl_date'); - return this.intl_date.formatRange(startDate, endDate); - }, - - formatDuration(secTotal, {approximate = false, unit = false} = {}) { - if (secTotal === 0) { - return this.formatString('count.duration.missing'); - } - - const hour = Math.floor(secTotal / 3600); - const min = Math.floor((secTotal - hour * 3600) / 60); - const sec = Math.floor(secTotal - hour * 3600 - min * 60); - - const pad = val => val.toString().padStart(2, '0'); - - const stringSubkey = unit ? '.withUnit' : ''; - - const duration = (hour > 0 - ? this.formatString('count.duration.hours' + stringSubkey, { - hours: hour, - minutes: pad(min), - seconds: pad(sec) - }) - : this.formatString('count.duration.minutes' + stringSubkey, { - minutes: min, - seconds: pad(sec) - })); - - return (approximate - ? this.formatString('count.duration.approximate', {duration}) - : duration); - }, - - formatIndex(value) { - this.assertIntlAvailable('intl_pluralOrdinal'); - return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); - }, - - formatNumber(value) { - this.assertIntlAvailable('intl_number'); - return this.intl_number.format(value); - }, - - formatWordCount(value) { - const num = this.formatNumber(value > 1000 - ? Math.floor(value / 100) / 10 - : value); + assertIntlAvailable(property) { + if (!this[property]) { + throw new Error(`Intl API ${property} unavailable`); + } + }, + + getUnitForm(value) { + this.assertIntlAvailable('intl_pluralCardinal'); + return this.intl_pluralCardinal.select(value); + }, + + formatString(key, args = {}) { + if (this.strings && !this.strings_htmlEscaped) { + throw new Error( + `HTML-escaped strings unavailable - please ensure escapeHTML function is provided` + ); + } - const words = (value > 1000 - ? this.formatString('count.words.thousand', {words: num}) - : this.formatString('count.words', {words: num})); + return this.formatStringHelper(this.strings_htmlEscaped, key, args); + }, - return this.formatString('count.words.withUnit.' + this.getUnitForm(value), {words}); - }, + formatStringNoHTMLEscape(key, args = {}) { + return this.formatStringHelper(this.strings, key, args); + }, - // Conjunction list: A, B, and C - formatConjunctionList(array) { - this.assertIntlAvailable('intl_listConjunction'); - return this.intl_listConjunction.format(array); - }, + formatStringHelper(strings, key, args = {}) { + if (!strings) { + throw new Error(`Strings unavailable`); + } - // Disjunction lists: A, B, or C - formatDisjunctionList(array) { - this.assertIntlAvailable('intl_listDisjunction'); - return this.intl_listDisjunction.format(array); - }, + if (!this.validKeys.includes(key)) { + throw new Error(`Invalid key ${key} accessed`); + } - // Unit lists: A, B, C - formatUnitList(array) { - this.assertIntlAvailable('intl_listUnit'); - return this.intl_listUnit.format(array); - }, + const template = strings[key]; + + // Convert the keys on the args dict from camelCase to CONSTANT_CASE. + // (This isn't an OUTRAGEOUSLY versatile algorithm for doing that, 8ut + // like, who cares, dude?) Also, this is an array, 8ecause it's handy + // for the iterating we're a8out to do. + const processedArgs = Object.entries(args).map(([k, v]) => [ + k.replace(/[A-Z]/g, '_$&').toUpperCase(), + v, + ]); + + // Replacement time! Woot. Reduce comes in handy here! + const output = processedArgs.reduce( + (x, [k, v]) => x.replaceAll(`{${k}}`, v), + template + ); + + // Post-processing: if any expected arguments *weren't* replaced, that + // is almost definitely an error. + if (output.match(/\{[A-Z_]+\}/)) { + throw new Error(`Args in ${key} were missing - output: ${output}`); + } - // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB - formatFileSize(bytes) { - if (!bytes) return ''; + return output; + }, - bytes = parseInt(bytes); - if (isNaN(bytes)) return ''; + formatDate(date) { + this.assertIntlAvailable('intl_date'); + return this.intl_date.format(date); + }, - const round = exp => Math.round(bytes / 10 ** (exp - 1)) / 10; + formatDateRange(startDate, endDate) { + this.assertIntlAvailable('intl_date'); + return this.intl_date.formatRange(startDate, endDate); + }, - if (bytes >= 10 ** 12) { - return this.formatString('count.fileSize.terabytes', {terabytes: round(12)}); - } else if (bytes >= 10 ** 9) { - return this.formatString('count.fileSize.gigabytes', {gigabytes: round(9)}); - } else if (bytes >= 10 ** 6) { - return this.formatString('count.fileSize.megabytes', {megabytes: round(6)}); - } else if (bytes >= 10 ** 3) { - return this.formatString('count.fileSize.kilobytes', {kilobytes: round(3)}); - } else { - return this.formatString('count.fileSize.bytes', {bytes}); - } - }, + formatDuration(secTotal, {approximate = false, unit = false} = {}) { + if (secTotal === 0) { + return this.formatString('count.duration.missing'); + } - // TODO: These are hard-coded. Is there a better way? - countAdditionalFiles: countHelper('additionalFiles', 'files'), - countAlbums: countHelper('albums'), - countCommentaryEntries: countHelper('commentaryEntries', 'entries'), - countContributions: countHelper('contributions'), - countCoverArts: countHelper('coverArts'), - countTimesReferenced: countHelper('timesReferenced'), - countTimesUsed: countHelper('timesUsed'), - countTracks: countHelper('tracks'), + const hour = Math.floor(secTotal / 3600); + const min = Math.floor((secTotal - hour * 3600) / 60); + const sec = Math.floor(secTotal - hour * 3600 - min * 60); + + const pad = (val) => val.toString().padStart(2, '0'); + + const stringSubkey = unit ? '.withUnit' : ''; + + const duration = + hour > 0 + ? this.formatString('count.duration.hours' + stringSubkey, { + hours: hour, + minutes: pad(min), + seconds: pad(sec), + }) + : this.formatString('count.duration.minutes' + stringSubkey, { + minutes: min, + seconds: pad(sec), + }); + + return approximate + ? this.formatString('count.duration.approximate', {duration}) + : duration; + }, + + formatIndex(value) { + this.assertIntlAvailable('intl_pluralOrdinal'); + return this.formatString( + 'count.index.' + this.intl_pluralOrdinal.select(value), + { + index: value, + } + ); + }, + + formatNumber(value) { + this.assertIntlAvailable('intl_number'); + return this.intl_number.format(value); + }, + + formatWordCount(value) { + const num = this.formatNumber( + value > 1000 ? Math.floor(value / 100) / 10 : value + ); + + const words = + value > 1000 + ? this.formatString('count.words.thousand', {words: num}) + : this.formatString('count.words', {words: num}); + + return this.formatString( + 'count.words.withUnit.' + this.getUnitForm(value), + {words} + ); + }, + + // Conjunction list: A, B, and C + formatConjunctionList(array) { + this.assertIntlAvailable('intl_listConjunction'); + return this.intl_listConjunction.format(array); + }, + + // Disjunction lists: A, B, or C + formatDisjunctionList(array) { + this.assertIntlAvailable('intl_listDisjunction'); + return this.intl_listDisjunction.format(array); + }, + + // Unit lists: A, B, C + formatUnitList(array) { + this.assertIntlAvailable('intl_listUnit'); + return this.intl_listUnit.format(array); + }, + + // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB + formatFileSize(bytes) { + if (!bytes) return ''; + + bytes = parseInt(bytes); + if (isNaN(bytes)) return ''; + + const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10; + + if (bytes >= 10 ** 12) { + return this.formatString('count.fileSize.terabytes', { + terabytes: round(12), + }); + } else if (bytes >= 10 ** 9) { + return this.formatString('count.fileSize.gigabytes', { + gigabytes: round(9), + }); + } else if (bytes >= 10 ** 6) { + return this.formatString('count.fileSize.megabytes', { + megabytes: round(6), + }); + } else if (bytes >= 10 ** 3) { + return this.formatString('count.fileSize.kilobytes', { + kilobytes: round(3), + }); + } else { + return this.formatString('count.fileSize.bytes', {bytes}); + } + }, + + // TODO: These are hard-coded. Is there a better way? + countAdditionalFiles: countHelper('additionalFiles', 'files'), + countAlbums: countHelper('albums'), + countCommentaryEntries: countHelper('commentaryEntries', 'entries'), + countContributions: countHelper('contributions'), + countCoverArts: countHelper('coverArts'), + countTimesReferenced: countHelper('timesReferenced'), + countTimesUsed: countHelper('timesUsed'), + countTracks: countHelper('tracks'), }); |