diff options
Diffstat (limited to 'src/data')
-rw-r--r-- | src/data/things.js | 1882 | ||||
-rw-r--r-- | src/data/things/album.js | 243 | ||||
-rw-r--r-- | src/data/things/art-tag.js | 42 | ||||
-rw-r--r-- | src/data/things/artist.js | 163 | ||||
-rw-r--r-- | src/data/things/cacheable-object.js (renamed from src/data/cacheable-object.js) | 2 | ||||
-rw-r--r-- | src/data/things/flash.js | 150 | ||||
-rw-r--r-- | src/data/things/group.js | 94 | ||||
-rw-r--r-- | src/data/things/homepage-layout.js | 114 | ||||
-rw-r--r-- | src/data/things/index.js | 173 | ||||
-rw-r--r-- | src/data/things/language.js | 321 | ||||
-rw-r--r-- | src/data/things/news-entry.js | 27 | ||||
-rw-r--r-- | src/data/things/static-page.js | 30 | ||||
-rw-r--r-- | src/data/things/thing.js | 385 | ||||
-rw-r--r-- | src/data/things/track.js | 332 | ||||
-rw-r--r-- | src/data/things/validators.js (renamed from src/data/validators.js) | 4 | ||||
-rw-r--r-- | src/data/things/wiki-info.js | 68 | ||||
-rw-r--r-- | src/data/yaml.js | 82 |
17 files changed, 2180 insertions, 1932 deletions
diff --git a/src/data/things.js b/src/data/things.js deleted file mode 100644 index 2037faca..00000000 --- a/src/data/things.js +++ /dev/null @@ -1,1882 +0,0 @@ -// 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, - isFileExtension, - isLanguageCode, - isName, - isNumber, - isURL, - isString, - oneOf, - validateArrayItems, - validateInstanceOf, - validateReference, - validateReferenceList, -} from './validators.js'; - -import * as S from './serialize.js'; - -import { - getKebabCase, - sortAlbumsTracksChronologically, -} from '../util/wiki-data.js'; - -import find from '../util/find.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 -// define the classes and set their static fields in two separate steps so that -// every class coexists from the outset, and can be directly referenced in field -// definitions later. - -// This list also acts as a quick table of contents for this JS file - use -// ctrl+F or similar to skip to a section. - -// -> Thing -export class Thing extends CacheableObject {} - -// -> Album -export class Album extends Thing {} -export class TrackGroup extends CacheableObject {} - -// -> Track -export class Track extends Thing {} - -// -> Artist -export class Artist extends Thing {} - -// -> Group -export class Group extends Thing {} -export class GroupCategory extends CacheableObject {} - -// -> ArtTag -export class ArtTag extends Thing {} - -// -> NewsEntry -export class NewsEntry extends Thing {} - -// -> StaticPage -export class StaticPage extends Thing {} - -// -> HomepageLayout -export class HomepageLayout extends CacheableObject {} -export class HomepageLayoutRow extends CacheableObject {} -export class HomepageLayoutAlbumsRow extends HomepageLayoutRow {} - -// -> Flash -export class Flash extends Thing {} -export class FlashAct extends CacheableObject {} - -// -> WikiInfo -export class WikiInfo extends CacheableObject {} - -// -> Language -export class Language extends CacheableObject {} - -// Before initializing property descriptors, set additional independent -// constants on the classes (which are referenced later). - -Thing.referenceType = Symbol('Thing.referenceType'); - -Album[Thing.referenceType] = 'album'; -Track[Thing.referenceType] = 'track'; -Artist[Thing.referenceType] = 'artist'; -Group[Thing.referenceType] = 'group'; -ArtTag[Thing.referenceType] = 'tag'; -NewsEntry[Thing.referenceType] = 'news-entry'; -StaticPage[Thing.referenceType] = 'static'; -Flash[Thing.referenceType] = 'flash'; - -// -> Thing: base class for wiki data types, providing wiki-specific utility -// functions on top of essential CacheableObject behavior. - -// 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! -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!`); - } - - 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!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList(referenceType)}, - }; - }, - - // Corresponding function for a single reference. - singleReference: (thingClass) => { - const {[Thing.referenceType]: referenceType} = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: validateReference(referenceType)}, - }; - }, - - // Corresponding dynamic property to referenceList, which takes the values - // in the provided property and searches the specified wiki data for - // matching actual Thing-subclass objects. - dynamicThingsFromReferenceList: ( - referenceListProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [referenceListProperty, thingDataProperty], - compute: ({ - [referenceListProperty]: refs, - [thingDataProperty]: thingData, - }) => - refs && thingData - ? refs - .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) - .filter(Boolean) - : [], - }, - }), - - // Corresponding function for a single reference. - dynamicThingFromSingleReference: ( - singleReferenceProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [singleReferenceProperty, thingDataProperty], - compute: ({ - [singleReferenceProperty]: ref, - [thingDataProperty]: thingData, - }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null), - }, - }), - - // Corresponding dynamic property to contribsByRef, which takes the values - // in the provided property and searches the object's artistData for - // matching actual Artist objects. The computed structure has the same form - // as contribsByRef, but with Artist objects instead of string references: - // - // [ - // {who: (an Artist), what: 'Viola'}, - // {who: (an Artist), what: null}, - // ... - // ] - // - // Contributions whose "who" values don't match anything in artistData are - // filtered out. (So if the list is all empty, chances are that either the - // reference list is somehow messed up, or artistData isn't being provided - // properly.) - dynamicContribs: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: ['artistData', contribsByRefProperty], - compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => - contribsByRef && artistData - ? contribsByRef - .map(({who: ref, what}) => ({ - who: find.artist(ref, artistData), - what, - })) - .filter(({who}) => who) - : [], - }, - }), - - // Dynamically inherit a contribution list from some other object, if it - // hasn't been overridden on this object. This is handy for solo albums - // where all tracks have the same artist, for example. - // - // 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: (thingDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [thingDataProperty], - - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => - thingData?.filter(t => t[referencerRefListProperty].includes(thing)) ?? [], - }, - }), - - // Corresponding function for single references. Note that the return value - // is still a list - this is for matching all the objects whose single - // reference (in the given property) matches this Thing. - reverseSingleReference: (thingDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [thingDataProperty], - - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => - thingData?.filter((t) => t[referencerRefListProperty] === thing) ?? [], - }, - }), - - // General purpose wiki data constructor, for properties like artistData, - // trackData, etc. - wikiData: (thingClass) => ({ - flags: {update: true}, - update: { - validate: validateArrayItems(validateInstanceOf(thingClass)), - }, - }), - - // This one's kinda tricky: it parses artist "references" from the - // commentary content, and finds the matching artist for each reference. - // This is mostly useful for credits and listings on artist pages. - commentatorArtists: () => ({ - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({artistData, commentary}) => - artistData && commentary - ? Array.from( - new Set( - Array.from( - commentary - .replace(/<\/?b>/g, '') - .matchAll(/<i>(?<who>.*?):<\/i>/g) - ).map(({groups: {who}}) => - find.artist(who, artistData, {mode: 'quiet'}) - ) - ) - ) - : [], - }, - }), -}; - -// 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}`; -}; - -// 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))})` : '') - ); -}; - -// -> Album - -Album.propertyDescriptors = { - // 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'], - 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(), - - groupsByRef: Thing.common.referenceList(Group), - artTagsByRef: Thing.common.referenceList(ArtTag), - - trackGroups: { - flags: {update: true, expose: true}, - - update: { - validate: validateArrayItems(validateInstanceOf(TrackGroup)), - }, - }, - - 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}, - }, - - 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), - - commentary: Thing.common.commentary(), - additionalFiles: Thing.common.additionalFiles(), - - // Update only - - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - groupData: Thing.common.wikiData(Group), - trackData: Thing.common.wikiData(Track), - - // Expose only - - artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), - coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), - trackCoverArtistContribs: Thing.common.dynamicContribs( - 'trackCoverArtistContribsByRef' - ), - wallpaperArtistContribs: Thing.common.dynamicContribs( - 'wallpaperArtistContribsByRef' - ), - bannerArtistContribs: Thing.common.dynamicContribs( - 'bannerArtistContribsByRef' - ), - - commentatorArtists: Thing.common.commentatorArtists(), - - tracks: { - flags: {expose: true}, - - 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, -}; - -TrackGroup.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Track Group'), - - color: { - flags: {update: true, expose: true}, - - update: {validate: isColor}, - - expose: { - dependencies: ['album'], - - transform(color, {album}) { - return color ?? album?.color ?? null; - }, - }, - }, - - dateOriginallyReleased: Thing.common.simpleDate(), - - tracksByRef: Thing.common.referenceList(Track), - - isDefaultTrackGroup: Thing.common.flag(false), - - // Update only - - album: { - flags: {update: true}, - update: {validate: validateInstanceOf(Album)}, - }, - - trackData: Thing.common.wikiData(Track), - - // Expose only - - tracks: { - flags: {expose: true}, - - expose: { - dependencies: ['tracksByRef', 'trackData'], - compute: ({tracksByRef, trackData}) => - tracksByRef && trackData - ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean) - : [], - }, - }, - - 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), - }, - }, -}; - -// -> Track - -// 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 -Track.findAlbum = (track, albumData) => { - 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.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Track'), - directory: Thing.common.directory(), - - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration}, - }, - - urls: Thing.common.urls(), - dateFirstReleased: Thing.common.simpleDate(), - - hasURLs: Thing.common.flag(true), - - artistContribsByRef: Thing.common.contribsByRef(), - contributorContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), - - referencedTracksByRef: Thing.common.referenceList(Track), - sampledTracksByRef: Thing.common.referenceList(Track), - artTagsByRef: Thing.common.referenceList(ArtTag), - - hasCoverArt: { - flags: {update: true, expose: true}, - - update: {validate: isBoolean}, - - 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', - }, - }, - - // Previously known as: (track).aka - originalReleaseTrackByRef: Thing.common.singleReference(Track), - - dataSourceAlbumByRef: Thing.common.singleReference(Album), - - commentary: Thing.common.commentary(), - lyrics: Thing.common.simpleString(), - additionalFiles: Thing.common.additionalFiles(), - - // 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), - - // 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: {expose: true}, - - expose: { - dependencies: ['albumData'], - - compute: ({albumData, [Track.instance]: track}) => - Track.findAlbum(track, albumData)?.trackGroups.find((tg) => - tg.tracks.includes(track) - )?.color ?? null, - }, - }, - - coverArtDate: { - flags: {update: true, expose: true}, - - 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); - }, - }, - }, - - // 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 - ), - - sampledTracks: Thing.common.dynamicThingsFromReferenceList( - 'sampledTracksByRef', - 'trackData', - find.track - ), - - // Specifically exclude re-releases from this list - while it's useful to - // get from a re-release to the tracks it references, re-releases aren't - // generally relevant from the perspective of the tracks being referenced. - // Filtering them from data here hides them from the corresponding field - // on the site (obviously), and has the bonus of not counting them when - // counting the number of times a track has been referenced, for use in - // the "Tracks - by Times Referenced" listing page (or other data - // processing). - referencedByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({trackData, [Track.instance]: track}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.referencedTracks?.includes(track)) - : [], - }, - }, - - // For the same reasoning, exclude re-releases from sampled tracks too. - sampledByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({trackData, [Track.instance]: track}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.sampledTracks?.includes(track)) - : [], - }, - }, - - // 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); - - 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; -}; - -// -> Artist - -Artist.filterByContrib = (thingDataProperty, contribsProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [thingDataProperty], - - compute: ({ - [thingDataProperty]: thingData, - [Artist.instance]: artist - }) => - thingData?.filter(thing => - thing[contribsProperty] - .some(contrib => contrib.who === artist)) ?? [], - }, -}); - -Artist.propertyDescriptors = { - // Update & expose - - 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'), - - 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}, - - 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'], - - compute: ({trackData, [Artist.instance]: artist}) => - trackData?.filter((track) => - [ - ...track.artistContribs, - ...track.contributorContribs, - ...track.coverArtistContribs, - ].some(({who}) => who === artist)) ?? [], - }, - }, - - tracksAsCommentator: { - 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'], - - compute: ({albumData, [Artist.instance]: artist}) => - albumData?.filter(({commentatorArtists}) => - commentatorArtists.includes(artist)) ?? [], - }, - }, - - flashesAsContributor: Artist.filterByContrib( - 'flashData', - 'contributorContribs' - ), -}; - -Artist[S.serializeDescriptors] = { - name: S.id, - directory: S.id, - urls: S.id, - contextNotes: S.id, - - hasAvatar: S.id, - avatarFileExtension: S.id, - - aliasNames: S.id, - - tracksAsArtist: S.toRefs, - tracksAsContributor: S.toRefs, - tracksAsCoverArtist: S.toRefs, - tracksAsCommentator: S.toRefs, - - albumsAsAlbumArtist: S.toRefs, - albumsAsCoverArtist: S.toRefs, - albumsAsWallpaperArtist: S.toRefs, - albumsAsBannerArtist: S.toRefs, - albumsAsCommentator: S.toRefs, - - flashesAsContributor: S.toRefs, -}; - -// -> Group - -Group.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Group'), - directory: Thing.common.directory(), - - description: Thing.common.simpleString(), - - urls: Thing.common.urls(), - - // Update only - - albumData: Thing.common.wikiData(Album), - groupCategoryData: Thing.common.wikiData(GroupCategory), - - // Expose only - - descriptionShort: { - flags: {expose: true}, - - expose: { - dependencies: ['description'], - compute: ({description}) => description.split('<hr class="split">')[0], - }, - }, - - albums: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData'], - compute: ({albumData, [Group.instance]: group}) => - albumData?.filter((album) => album.groups.includes(group)) ?? [], - }, - }, - - color: { - flags: {expose: true}, - - expose: { - dependencies: ['groupCategoryData'], - - compute: ({groupCategoryData, [Group.instance]: group}) => - groupCategoryData.find((category) => category.groups.includes(group)) - ?.color, - }, - }, - - category: { - flags: {expose: true}, - - expose: { - dependencies: ['groupCategoryData'], - compute: ({groupCategoryData, [Group.instance]: group}) => - groupCategoryData.find((category) => category.groups.includes(group)) ?? - null, - }, - }, -}; - -GroupCategory.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Group Category'), - color: Thing.common.color(), - - groupsByRef: Thing.common.referenceList(Group), - - // Update only - - groupData: Thing.common.wikiData(Group), - - // Expose only - - groups: Thing.common.dynamicThingsFromReferenceList( - 'groupsByRef', - 'groupData', - find.group - ), -}; - -// -> ArtTag - -ArtTag.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Art Tag'), - directory: Thing.common.directory(), - color: Thing.common.color(), - isContentWarning: Thing.common.flag(false), - - // Update only - - albumData: Thing.common.wikiData(Album), - trackData: Thing.common.wikiData(Track), - - // Expose only - - // Previously known as: (tag).things - taggedInThings: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'trackData'], - compute: ({albumData, trackData, [ArtTag.instance]: artTag}) => - sortAlbumsTracksChronologically( - [...albumData, ...trackData] - .filter(({artTags}) => artTags.includes(artTag)), - {getDate: o => o.coverArtDate}), - }, - }, -}; - -// -> NewsEntry - -NewsEntry.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed News Entry'), - directory: Thing.common.directory(), - date: Thing.common.simpleDate(), - - content: Thing.common.simpleString(), - - // Expose only - - contentShort: { - flags: {expose: true}, - - expose: { - dependencies: ['content'], - - compute: ({content}) => content.split('<hr class="split">')[0], - }, - }, -}; - -// -> StaticPage - -StaticPage.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Static Page'), - - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, - - 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), -}; - -// -> HomepageLayout - -HomepageLayout.propertyDescriptors = { - // Update & expose - - sidebarContent: Thing.common.simpleString(), - - rows: { - flags: {update: true, expose: true}, - - update: { - validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), - }, - }, -}; - -HomepageLayoutRow.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Homepage Row'), - - type: { - flags: {update: true, expose: true}, - - update: { - validate() { - throw new Error(`'type' property validator must be overridden`); - }, - }, - }, - - color: Thing.common.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), -}; - -HomepageLayoutAlbumsRow.propertyDescriptors = { - ...HomepageLayoutRow.propertyDescriptors, - - // Update & expose - - 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), - - countAlbumsFromGroup: { - flags: {update: true, expose: true}, - update: {validate: isCountingNumber}, - }, - - actionLinks: { - 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 - ), -}; - -// -> 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; - }, - }, - }, - - page: { - flags: {update: true, expose: true}, - update: {validate: oneOf(isString, isNumber)}, - - expose: { - transform: (value) => (value === null ? null : value.toString()), - }, - }, - - date: Thing.common.simpleDate(), - - coverArtFileExtension: Thing.common.fileExtension('jpg'), - - contributorContribsByRef: Thing.common.contribsByRef(), - - featuredTracksByRef: Thing.common.referenceList(Track), - - urls: Thing.common.urls(), - - // Update only - - artistData: Thing.common.wikiData(Artist), - trackData: Thing.common.wikiData(Track), - flashActData: Thing.common.wikiData(FlashAct), - - // Expose only - - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), - - featuredTracks: Thing.common.dynamicThingsFromReferenceList( - 'featuredTracksByRef', - 'trackData', - find.track - ), - - act: { - flags: {expose: true}, - - expose: { - dependencies: ['flashActData'], - - compute: ({flashActData, [Flash.instance]: flash}) => - flashActData.find((act) => act.flashes.includes(flash)) ?? null, - }, - }, - - color: { - flags: {expose: true}, - - expose: { - dependencies: ['flashActData'], - - 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, -}; - -FlashAct.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Flash Act'), - color: Thing.common.color(), - anchor: Thing.common.simpleString(), - jump: Thing.common.simpleString(), - - jumpColor: { - flags: {update: true, expose: true}, - update: {validate: isColor}, - expose: { - dependencies: ['color'], - transform: (jumpColor, {color}) => - jumpColor ?? color, - } - }, - - flashesByRef: Thing.common.referenceList(Flash), - - // Update only - - flashData: Thing.common.wikiData(Flash), - - // Expose only - - flashes: Thing.common.dynamicThingsFromReferenceList( - 'flashesByRef', - 'flashData', - find.flash - ), -}; - -// -> WikiInfo - -WikiInfo.propertyDescriptors = { - // Update & expose - - name: Thing.common.name('Unnamed Wiki'), - - // Displayed in nav bar. - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, - - expose: { - dependencies: ['name'], - transform: (value, {name}) => value ?? name, - }, - }, - - color: Thing.common.color(), - - // One-line description used for <meta rel="description"> tag. - description: Thing.common.simpleString(), - - footerContent: Thing.common.simpleString(), - - defaultLanguage: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode}, - }, - - canonicalBase: { - flags: {update: true, expose: true}, - update: {validate: isURL}, - }, - - 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), - - // Update only - - groupData: Thing.common.wikiData(Group), - - // Expose only - - 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]); - }, - }, -}); - -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; - } - }, - }, - }, - - // 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 - - escapeHTML: Thing.common.externalFunction(), - - // 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'}), - - validKeys: { - flags: {expose: true}, - - 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} = {}) { - return this.$( - 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; - }, - - 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 - ); - - 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'), -}); diff --git a/src/data/things/album.js b/src/data/things/album.js new file mode 100644 index 00000000..4890aaaa --- /dev/null +++ b/src/data/things/album.js @@ -0,0 +1,243 @@ +import Thing from './thing.js'; + +import find from '../../util/find.js'; + +export class Album extends Thing { + static [Thing.referenceType] = 'album'; + + static [Thing.getPropertyDescriptors] = ({ + ArtTag, + Artist, + Group, + Track, + TrackGroup, + + validators: { + isDate, + isDimensions, + validateArrayItems, + validateInstanceOf, + }, + }) => ({ + // 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'], + 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(), + + groupsByRef: Thing.common.referenceList(Group), + artTagsByRef: Thing.common.referenceList(ArtTag), + + trackGroups: { + flags: {update: true, expose: true}, + + update: { + validate: validateArrayItems(validateInstanceOf(TrackGroup)), + }, + }, + + 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}, + }, + + 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), + + commentary: Thing.common.commentary(), + additionalFiles: Thing.common.additionalFiles(), + + // Update only + + artistData: Thing.common.wikiData(Artist), + artTagData: Thing.common.wikiData(ArtTag), + groupData: Thing.common.wikiData(Group), + trackData: Thing.common.wikiData(Track), + + // Expose only + + artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), + coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), + trackCoverArtistContribs: Thing.common.dynamicContribs( + 'trackCoverArtistContribsByRef' + ), + wallpaperArtistContribs: Thing.common.dynamicContribs( + 'wallpaperArtistContribsByRef' + ), + bannerArtistContribs: Thing.common.dynamicContribs( + 'bannerArtistContribsByRef' + ), + + commentatorArtists: Thing.common.commentatorArtists(), + + tracks: { + flags: {expose: true}, + + 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 + ), + }); + + static [Thing.getSerializeDescriptors] = ({ + serialize: S, + }) => ({ + 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, + }); +} + +export class TrackGroup extends Thing { + static [Thing.getPropertyDescriptors] = ({ + isColor, + Track, + + validators: { + validateInstanceOf, + }, + }) => ({ + // Update & expose + + name: Thing.common.name('Unnamed Track Group'), + + color: { + flags: {update: true, expose: true}, + + update: {validate: isColor}, + + expose: { + dependencies: ['album'], + + transform(color, {album}) { + return color ?? album?.color ?? null; + }, + }, + }, + + dateOriginallyReleased: Thing.common.simpleDate(), + + tracksByRef: Thing.common.referenceList(Track), + + isDefaultTrackGroup: Thing.common.flag(false), + + // Update only + + album: { + flags: {update: true}, + update: {validate: validateInstanceOf(Album)}, + }, + + trackData: Thing.common.wikiData(Track), + + // Expose only + + tracks: { + flags: {expose: true}, + + expose: { + dependencies: ['tracksByRef', 'trackData'], + compute: ({tracksByRef, trackData}) => + tracksByRef && trackData + ? tracksByRef.map((ref) => find.track(ref, trackData)).filter(Boolean) + : [], + }, + }, + + 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), + }, + }, + }) +} diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js new file mode 100644 index 00000000..0f888a20 --- /dev/null +++ b/src/data/things/art-tag.js @@ -0,0 +1,42 @@ +import Thing from './thing.js'; + +import { + sortAlbumsTracksChronologically, +} from '../../util/wiki-data.js'; + +export class ArtTag extends Thing { + static [Thing.referenceType] = 'tag'; + + 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), + + // Update only + + albumData: Thing.common.wikiData(Album), + trackData: Thing.common.wikiData(Track), + + // Expose only + + // Previously known as: (tag).things + taggedInThings: { + flags: {expose: true}, + + expose: { + dependencies: ['albumData', 'trackData'], + compute: ({albumData, trackData, [ArtTag.instance]: artTag}) => + sortAlbumsTracksChronologically( + [...albumData, ...trackData] + .filter(({artTags}) => artTags.includes(artTag)), + {getDate: o => o.coverArtDate}), + }, + }, + }); +} diff --git a/src/data/things/artist.js b/src/data/things/artist.js new file mode 100644 index 00000000..303f33f3 --- /dev/null +++ b/src/data/things/artist.js @@ -0,0 +1,163 @@ +import Thing from './thing.js'; + +import find from '../../util/find.js'; + +export class Artist extends Thing { + static [Thing.referenceType] = 'artist'; + + static [Thing.getPropertyDescriptors] = ({ + Album, + Flash, + Track, + + validators: { + isName, + validateArrayItems, + }, + }) => ({ + // Update & expose + + 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'), + + 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}, + + 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'], + + compute: ({trackData, [Artist.instance]: artist}) => + trackData?.filter((track) => + [ + ...track.artistContribs, + ...track.contributorContribs, + ...track.coverArtistContribs, + ].some(({who}) => who === artist)) ?? [], + }, + }, + + tracksAsCommentator: { + 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'], + + compute: ({albumData, [Artist.instance]: artist}) => + albumData?.filter(({commentatorArtists}) => + commentatorArtists.includes(artist)) ?? [], + }, + }, + + flashesAsContributor: Artist.filterByContrib( + 'flashData', + 'contributorContribs' + ), + }); + + static [Thing.getSerializeDescriptors] = ({ + serialize: S, + }) => ({ + name: S.id, + directory: S.id, + urls: S.id, + contextNotes: S.id, + + hasAvatar: S.id, + avatarFileExtension: S.id, + + aliasNames: S.id, + + tracksAsArtist: S.toRefs, + tracksAsContributor: S.toRefs, + tracksAsCoverArtist: S.toRefs, + tracksAsCommentator: S.toRefs, + + albumsAsAlbumArtist: S.toRefs, + albumsAsCoverArtist: S.toRefs, + albumsAsWallpaperArtist: S.toRefs, + albumsAsBannerArtist: S.toRefs, + albumsAsCommentator: S.toRefs, + + flashesAsContributor: S.toRefs, + }); + + static filterByContrib = (thingDataProperty, contribsProperty) => ({ + flags: {expose: true}, + + expose: { + dependencies: [thingDataProperty], + + compute: ({ + [thingDataProperty]: thingData, + [Artist.instance]: artist + }) => + thingData?.filter(thing => + thing[contribsProperty] + .some(contrib => contrib.who === artist)) ?? [], + }, + }); +} diff --git a/src/data/cacheable-object.js b/src/data/things/cacheable-object.js index 04e029f0..6a210cc1 100644 --- a/src/data/cacheable-object.js +++ b/src/data/things/cacheable-object.js @@ -74,7 +74,7 @@ // function, which provides a mapping of exposed property names to whether // or not their dependencies are yet met. -import {color, ENABLE_COLOR} from '../util/cli.js'; +import {color, ENABLE_COLOR} from '../../util/cli.js'; import {inspect as nodeInspect} from 'util'; diff --git a/src/data/things/flash.js b/src/data/things/flash.js new file mode 100644 index 00000000..1383fa83 --- /dev/null +++ b/src/data/things/flash.js @@ -0,0 +1,150 @@ +import Thing from './thing.js'; + +import find from '../../util/find.js'; + +export class Flash extends Thing { + static [Thing.referenceType] = 'flash'; + + static [Thing.getPropertyDescriptors] = ({ + Artist, + Track, + FlashAct, + + validators: { + isDirectory, + isNumber, + isString, + oneOf, + }, + }) => ({ + // 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)}, + + expose: { + transform: (value) => (value === null ? null : value.toString()), + }, + }, + + date: Thing.common.simpleDate(), + + coverArtFileExtension: Thing.common.fileExtension('jpg'), + + contributorContribsByRef: Thing.common.contribsByRef(), + + featuredTracksByRef: Thing.common.referenceList(Track), + + urls: Thing.common.urls(), + + // Update only + + artistData: Thing.common.wikiData(Artist), + trackData: Thing.common.wikiData(Track), + flashActData: Thing.common.wikiData(FlashAct), + + // Expose only + + contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + + featuredTracks: Thing.common.dynamicThingsFromReferenceList( + 'featuredTracksByRef', + 'trackData', + find.track + ), + + act: { + flags: {expose: true}, + + expose: { + dependencies: ['flashActData'], + + compute: ({flashActData, [Flash.instance]: flash}) => + flashActData.find((act) => act.flashes.includes(flash)) ?? null, + }, + }, + + color: { + flags: {expose: true}, + + expose: { + dependencies: ['flashActData'], + + compute: ({flashActData, [Flash.instance]: flash}) => + flashActData.find((act) => act.flashes.includes(flash))?.color ?? null, + }, + }, + }); + + static [Thing.getSerializeDescriptors] = ({ + serialize: S, + }) => ({ + name: S.id, + page: S.id, + directory: S.id, + date: S.id, + contributors: S.toContribRefs, + tracks: S.toRefs, + urls: S.id, + color: S.id, + }); +} + +export class FlashAct extends Thing { + static [Thing.getPropertyDescriptors] = ({ + validators: { + isColor, + }, + }) => ({ + // Update & expose + + name: Thing.common.name('Unnamed Flash Act'), + color: Thing.common.color(), + anchor: Thing.common.simpleString(), + jump: Thing.common.simpleString(), + + jumpColor: { + flags: {update: true, expose: true}, + update: {validate: isColor}, + expose: { + dependencies: ['color'], + transform: (jumpColor, {color}) => + jumpColor ?? color, + } + }, + + flashesByRef: Thing.common.referenceList(Flash), + + // Update only + + flashData: Thing.common.wikiData(Flash), + + // Expose only + + flashes: Thing.common.dynamicThingsFromReferenceList( + 'flashesByRef', + 'flashData', + find.flash + ), + }) +} diff --git a/src/data/things/group.js b/src/data/things/group.js new file mode 100644 index 00000000..26fe9a55 --- /dev/null +++ b/src/data/things/group.js @@ -0,0 +1,94 @@ +import Thing from './thing.js'; + +import find from '../../util/find.js'; + +export class Group extends Thing { + static [Thing.referenceType] = 'group'; + + static [Thing.getPropertyDescriptors] = ({ + Album, + }) => ({ + // Update & expose + + name: Thing.common.name('Unnamed Group'), + directory: Thing.common.directory(), + + description: Thing.common.simpleString(), + + urls: Thing.common.urls(), + + // Update only + + albumData: Thing.common.wikiData(Album), + groupCategoryData: Thing.common.wikiData(GroupCategory), + + // Expose only + + descriptionShort: { + flags: {expose: true}, + + expose: { + dependencies: ['description'], + compute: ({description}) => description.split('<hr class="split">')[0], + }, + }, + + albums: { + flags: {expose: true}, + + expose: { + dependencies: ['albumData'], + compute: ({albumData, [Group.instance]: group}) => + albumData?.filter((album) => album.groups.includes(group)) ?? [], + }, + }, + + color: { + flags: {expose: true}, + + expose: { + dependencies: ['groupCategoryData'], + + compute: ({groupCategoryData, [Group.instance]: group}) => + groupCategoryData.find((category) => category.groups.includes(group)) + ?.color, + }, + }, + + category: { + flags: {expose: true}, + + expose: { + dependencies: ['groupCategoryData'], + compute: ({groupCategoryData, [Group.instance]: group}) => + groupCategoryData.find((category) => category.groups.includes(group)) ?? + null, + }, + }, + }); +} + +export class GroupCategory extends Thing { + static [Thing.getPropertyDescriptors] = ({ + Group, + }) => ({ + // Update & expose + + name: Thing.common.name('Unnamed Group Category'), + color: Thing.common.color(), + + groupsByRef: Thing.common.referenceList(Group), + + // Update only + + groupData: Thing.common.wikiData(Group), + + // Expose only + + groups: Thing.common.dynamicThingsFromReferenceList( + 'groupsByRef', + 'groupData', + find.group + ), + }); +} diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js new file mode 100644 index 00000000..5948ff46 --- /dev/null +++ b/src/data/things/homepage-layout.js @@ -0,0 +1,114 @@ +import Thing from './thing.js'; + +import find from '../../util/find.js'; + +export class HomepageLayout extends Thing { + static [Thing.getPropertyDescriptors] = ({ + HomepageLayoutRow, + + validators: { + validateArrayItems, + validateInstanceOf, + }, + }) => ({ + // Update & expose + + sidebarContent: Thing.common.simpleString(), + + rows: { + flags: {update: true, expose: true}, + + update: { + validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), + }, + }, + }) +} + +export class HomepageLayoutRow extends Thing { + static [Thing.getPropertyDescriptors] = ({ + Album, + Group, + }) => ({ + // Update & expose + + name: Thing.common.name('Unnamed Homepage Row'), + + type: { + flags: {update: true, expose: true}, + + update: { + validate() { + throw new Error(`'type' property validator must be overridden`); + }, + }, + }, + + color: Thing.common.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), + }); +} + +export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { + static [Thing.getPropertyDescriptors] = (opts, { + Album, + Group, + + validators: { + isCountingNumber, + isString, + validateArrayItems, + }, + } = opts) => ({ + ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), + + // Update & expose + + 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), + + countAlbumsFromGroup: { + flags: {update: true, expose: true}, + update: {validate: isCountingNumber}, + }, + + actionLinks: { + 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 new file mode 100644 index 00000000..11b6b1a9 --- /dev/null +++ b/src/data/things/index.js @@ -0,0 +1,173 @@ +import {logError} from '../../util/cli.js'; +import {openAggregate, showAggregate} from '../../util/sugar.js'; + +import * as path from 'path'; +import {fileURLToPath} from 'url'; + +import Thing from './thing.js'; +import * as validators from './validators.js'; +import * as serialize from '../serialize.js'; + +import * as albumClasses from './album.js'; +import * as artTagClasses from './art-tag.js'; +import * as artistClasses from './artist.js'; +import * as flashClasses from './flash.js'; +import * as groupClasses from './group.js'; +import * as homepageLayoutClasses from './homepage-layout.js'; +import * as languageClasses from './language.js'; +import * as newsEntryClasses from './news-entry.js'; +import * as staticPageClasses from './static-page.js'; +import * as trackClasses from './track.js'; +import * as wikiInfoClasses from './wiki-info.js'; + +const allClassLists = { + 'album.js': albumClasses, + 'art-tag.js': artTagClasses, + 'artist.js': artistClasses, + 'flash.js': flashClasses, + 'group.js': groupClasses, + 'homepage-layout.js': homepageLayoutClasses, + 'language.js': languageClasses, + 'news-entry.js': newsEntryClasses, + 'static-page.js': staticPageClasses, + 'track.js': trackClasses, + 'wiki-info.js': wikiInfoClasses, +}; + +let allClasses = Object.create(null); + +// src/data/things/index.js -> src/ +const __dirname = path.dirname( + path.resolve( + fileURLToPath(import.meta.url), + '../..')); + +function niceShowAggregate(error, ...opts) { + showAggregate(error, { + pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), + ...opts, + }); +} + +function errorDuplicateClassNames() { + const locationDict = Object.create(null); + + for (const [location, classes] of Object.entries(allClassLists)) { + for (const className of Object.keys(classes)) { + if (className in locationDict) { + locationDict[className].push(location); + } else { + locationDict[className] = [location]; + } + } + } + + let success = true; + + for (const [className, locations] of Object.entries(locationDict)) { + if (locations.length === 1) { + continue; + } + + logError`Thing class name ${`"${className}"`} is defined more than once: ${locations.join(', ')}`; + success = false; + } + + return success; +} + +function flattenClassLists() { + for (const classes of Object.values(allClassLists)) { + for (const [name, constructor] of Object.entries(classes)) { + allClasses[name] = constructor; + } + } +} + +function descriptorAggregateHelper({ + showFailedClasses, + message, + op, +}) { + const failureSymbol = Symbol(); + const aggregate = openAggregate({ + message, + returnOnFail: failureSymbol, + }); + + const failedClasses = []; + + for (const [name, constructor] of Object.entries(allClasses)) { + const result = aggregate.call(op, constructor); + + if (result === failureSymbol) { + failedClasses.push(name); + } + } + + try { + aggregate.close(); + return true; + } catch (error) { + niceShowAggregate(error); + showFailedClasses(failedClasses); + return false; + } +} + +function evaluatePropertyDescriptors() { + const opts = {...allClasses, validators}; + + return descriptorAggregateHelper({ + message: `Errors evaluating Thing class property descriptors`, + + op(constructor) { + if (!constructor[Thing.getPropertyDescriptors]) { + throw new Error(`Missing [Thing.getPropertyDescriptors] function`); + } + + constructor.propertyDescriptors = + constructor[Thing.getPropertyDescriptors](opts); + }, + + showFailedClasses(failedClasses) { + logError`Failed to evaluate property descriptors for classes: ${failedClasses.join(', ')}`; + }, + }); +} + +function evaluateSerializeDescriptors() { + const opts = {...allClasses, serialize}; + + return descriptorAggregateHelper({ + message: `Errors evaluating Thing class serialize descriptors`, + + op(constructor) { + if (!constructor[Thing.getSerializeDescriptors]) { + return; + } + + constructor[serialize.serializeDescriptors] = + constructor[Thing.getSerializeDescriptors](opts); + }, + + showFailedClasses(failedClasses) { + logError`Failed to evaluate serialize descriptors for classes: ${failedClasses.join(', ')}`; + }, + }); +} + +if (!errorDuplicateClassNames()) + process.exit(1); + +flattenClassLists(); + +if (!evaluatePropertyDescriptors()) + process.exit(1); + +if (!evaluateSerializeDescriptors()) + process.exit(1); + +Object.assign(allClasses, {Thing}); + +export default allClasses; diff --git a/src/data/things/language.js b/src/data/things/language.js new file mode 100644 index 00000000..21524993 --- /dev/null +++ b/src/data/things/language.js @@ -0,0 +1,321 @@ +import Thing from './thing.js'; + +export class Language extends Thing { + static [Thing.getPropertyDescriptors] = ({ + validators: { + isLanguageCode, + }, + }) => ({ + // 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'}, + }, + + // Update only + + escapeHTML: Thing.common.externalFunction(), + + // Expose only + + intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), + intl_number: this.#intlHelper(Intl.NumberFormat), + intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), + intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}), + intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}), + intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}), + intl_pluralOrdinal: this.#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 ?? {}), + ]) + ), + }, + }, + + 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)]) + ); + }, + }, + }, + }); + + static #intlHelper (constructor, opts) { + return { + flags: {expose: true}, + expose: { + dependencies: ['code', 'intlCode'], + compute: ({code, intlCode}) => { + const constructCode = intlCode ?? code; + if (!constructCode) return null; + return Reflect.construct(constructor, [constructCode, opts]); + }, + }, + }; + } + + $(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; + } + + 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 + ); + + 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}); + } + } + +} + +const countHelper = (stringKey, argName = stringKey) => + function(value, {unit = false} = {}) { + return this.formatString( + unit + ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) + : `count.${stringKey}`, + {[argName]: this.formatNumber(value)}); + }; + +// TODO: These are hard-coded. Is there a better way? +Object.assign(Language.prototype, { + 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'), +}); diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js new file mode 100644 index 00000000..43911410 --- /dev/null +++ b/src/data/things/news-entry.js @@ -0,0 +1,27 @@ +import Thing from './thing.js'; + +export class NewsEntry extends Thing { + static [Thing.referenceType] = 'news-entry'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + name: Thing.common.name('Unnamed News Entry'), + directory: Thing.common.directory(), + date: Thing.common.simpleDate(), + + content: Thing.common.simpleString(), + + // Expose only + + contentShort: { + flags: {expose: true}, + + expose: { + dependencies: ['content'], + + compute: ({content}) => content.split('<hr class="split">')[0], + }, + }, + }); +} diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js new file mode 100644 index 00000000..226e0b61 --- /dev/null +++ b/src/data/things/static-page.js @@ -0,0 +1,30 @@ +import Thing from './thing.js'; + +export class StaticPage extends Thing { + static [Thing.referenceType] = 'static'; + + static [Thing.getPropertyDescriptors] = ({ + validators: { + isName, + }, + }) => ({ + // Update & expose + + name: Thing.common.name('Unnamed Static Page'), + + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, + + 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), + }); +} diff --git a/src/data/things/thing.js b/src/data/things/thing.js new file mode 100644 index 00000000..b9fa60c6 --- /dev/null +++ b/src/data/things/thing.js @@ -0,0 +1,385 @@ +// Thing: base class for wiki data types, providing wiki-specific utility +// functions on top of essential CacheableObject behavior. + +import CacheableObject from './cacheable-object.js'; + +import { + isAdditionalFileList, + isBoolean, + isCommentary, + isColor, + isContributionList, + isDate, + isDirectory, + isFileExtension, + isName, + isString, + isURL, + validateArrayItems, + validateInstanceOf, + validateReference, + validateReferenceList, +} from './validators.js'; + +import {inspect} from 'util'; +import {color} from '../../util/cli.js'; +import {getKebabCase} from '../../util/wiki-data.js'; + +import find from '../../util/find.js'; + +export default class Thing extends CacheableObject { + static referenceType = Symbol('Thing.referenceType'); + + static getPropertyDescriptors = Symbol('Thing.getPropertyDescriptors'); + static getSerializeDescriptors = Symbol('Thing.getSerializeDescriptors'); + + // Regularly reused property descriptors, for ease of access and generally + // duplicating less code across wiki data types. These are specialized utility + // functions, so check each for how its own arguments behave! + static common = { + name: (defaultName) => ({ + flags: {update: true, expose: true}, + update: {validate: isName, default: defaultName}, + }), + + color: () => ({ + flags: {update: true, expose: true}, + update: {validate: isColor}, + }), + + directory: () => ({ + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + expose: { + dependencies: ['name'], + transform(directory, {name}) { + if (directory === null && name === null) return null; + else if (directory === null) return getKebabCase(name); + else return directory; + }, + }, + }), + + urls: () => ({ + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isURL)}, + }), + + // A file extension! Or the default, if provided when calling this. + fileExtension: (defaultFileExtension = null) => ({ + flags: {update: true, expose: true}, + update: {validate: isFileExtension}, + expose: {transform: (value) => value ?? defaultFileExtension}, + }), + + // Straightforward flag descriptor for a variety of property purposes. + // Provide a default value, true or false! + flag: (defaultValue = false) => { + if (typeof defaultValue !== 'boolean') { + throw new TypeError(`Always set explicit defaults for flags!`); + } + + return { + flags: {update: true, expose: true}, + update: {validate: isBoolean, default: defaultValue}, + }; + }, + + // General date type, used as the descriptor for a bunch of properties. + // This isn't dynamic though - it won't inherit from a date stored on + // another object, for example. + simpleDate: () => ({ + flags: {update: true, expose: true}, + update: {validate: isDate}, + }), + + // General string type. This should probably generally be avoided in favor + // of more specific validation, but using it makes it easy to find where we + // might want to improve later, and it's a useful shorthand meanwhile. + simpleString: () => ({ + flags: {update: true, expose: true}, + update: {validate: isString}, + }), + + // External function. These should only be used as dependencies for other + // properties, so they're left unexposed. + externalFunction: () => ({ + flags: {update: true}, + update: {validate: (t) => typeof t === 'function'}, + }), + + // Super simple "contributions by reference" list, used for a variety of + // properties (Artists, Cover Artists, etc). This is the property which is + // externally provided, in the form: + // + // [ + // {who: 'Artist Name', what: 'Viola'}, + // {who: 'artist:john-cena', what: null}, + // ... + // ] + // + // ...processed from YAML, spreadsheet, or any other kind of input. + contribsByRef: () => ({ + flags: {update: true, expose: true}, + update: {validate: isContributionList}, + }), + + // Artist commentary! Generally present on tracks and albums. + commentary: () => ({ + flags: {update: true, expose: true}, + update: {validate: isCommentary}, + }), + + // This is a somewhat more involved data structure - it's for additional + // or "bonus" files associated with albums or tracks (or anything else). + // It's got this form: + // + // [ + // {title: 'Booklet', files: ['Booklet.pdf']}, + // { + // title: 'Wallpaper', + // description: 'Cool Wallpaper!', + // files: ['1440x900.png', '1920x1080.png'] + // }, + // {title: 'Alternate Covers', description: null, files: [...]}, + // ... + // ] + // + additionalFiles: () => ({ + flags: {update: true, expose: true}, + update: {validate: isAdditionalFileList}, + }), + + // A reference list! Keep in mind this is for general references to wiki + // objects of (usually) other Thing subclasses, not specifically leitmotif + // references in tracks (although that property uses referenceList too!). + // + // The underlying function validateReferenceList expects a string like + // 'artist' or 'track', but this utility keeps from having to hard-code the + // string in multiple places by referencing the value saved on the class + // instead. + referenceList: (thingClass) => { + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); + } + + return { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList(referenceType)}, + }; + }, + + // Corresponding function for a single reference. + singleReference: (thingClass) => { + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); + } + + return { + flags: {update: true, expose: true}, + update: {validate: validateReference(referenceType)}, + }; + }, + + // Corresponding dynamic property to referenceList, which takes the values + // in the provided property and searches the specified wiki data for + // matching actual Thing-subclass objects. + dynamicThingsFromReferenceList: ( + referenceListProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, + + expose: { + dependencies: [referenceListProperty, thingDataProperty], + compute: ({ + [referenceListProperty]: refs, + [thingDataProperty]: thingData, + }) => + refs && thingData + ? refs + .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) + .filter(Boolean) + : [], + }, + }), + + // Corresponding function for a single reference. + dynamicThingFromSingleReference: ( + singleReferenceProperty, + thingDataProperty, + findFn + ) => ({ + flags: {expose: true}, + + expose: { + dependencies: [singleReferenceProperty, thingDataProperty], + compute: ({ + [singleReferenceProperty]: ref, + [thingDataProperty]: thingData, + }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null), + }, + }), + + // Corresponding dynamic property to contribsByRef, which takes the values + // in the provided property and searches the object's artistData for + // matching actual Artist objects. The computed structure has the same form + // as contribsByRef, but with Artist objects instead of string references: + // + // [ + // {who: (an Artist), what: 'Viola'}, + // {who: (an Artist), what: null}, + // ... + // ] + // + // Contributions whose "who" values don't match anything in artistData are + // filtered out. (So if the list is all empty, chances are that either the + // reference list is somehow messed up, or artistData isn't being provided + // properly.) + dynamicContribs: (contribsByRefProperty) => ({ + flags: {expose: true}, + expose: { + dependencies: ['artistData', contribsByRefProperty], + compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => + contribsByRef && artistData + ? contribsByRef + .map(({who: ref, what}) => ({ + who: find.artist(ref, artistData), + what, + })) + .filter(({who}) => who) + : [], + }, + }), + + // Dynamically inherit a contribution list from some other object, if it + // hasn't been overridden on this object. This is handy for solo albums + // where all tracks have the same artist, for example. + // + // 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: (thingDataProperty, referencerRefListProperty) => ({ + flags: {expose: true}, + + expose: { + dependencies: [thingDataProperty], + + compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => + thingData?.filter(t => t[referencerRefListProperty].includes(thing)) ?? [], + }, + }), + + // Corresponding function for single references. Note that the return value + // is still a list - this is for matching all the objects whose single + // reference (in the given property) matches this Thing. + reverseSingleReference: (thingDataProperty, referencerRefListProperty) => ({ + flags: {expose: true}, + + expose: { + dependencies: [thingDataProperty], + + compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => + thingData?.filter((t) => t[referencerRefListProperty] === thing) ?? [], + }, + }), + + // General purpose wiki data constructor, for properties like artistData, + // trackData, etc. + wikiData: (thingClass) => ({ + flags: {update: true}, + update: { + validate: validateArrayItems(validateInstanceOf(thingClass)), + }, + }), + + // This one's kinda tricky: it parses artist "references" from the + // commentary content, and finds the matching artist for each reference. + // This is mostly useful for credits and listings on artist pages. + commentatorArtists: () => ({ + flags: {expose: true}, + + expose: { + dependencies: ['artistData', 'commentary'], + + compute: ({artistData, commentary}) => + artistData && commentary + ? Array.from( + new Set( + Array.from( + commentary + .replace(/<\/?b>/g, '') + .matchAll(/<i>(?<who>.*?):<\/i>/g) + ).map(({groups: {who}}) => + find.artist(who, artistData, {mode: 'quiet'}) + ) + ) + ) + : [], + }, + }), + }; + + // Default custom inspect function, which may be overridden by Thing + // subclasses. This will be used when displaying aggregate errors and other + // command-line logging - it's the place to provide information useful in + // identifying the Thing being presented. + [inspect.custom]() { + const cname = this.constructor.name; + + return ( + (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) + + (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '') + ); + } + + 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}`; + } +} diff --git a/src/data/things/track.js b/src/data/things/track.js new file mode 100644 index 00000000..d2930ff1 --- /dev/null +++ b/src/data/things/track.js @@ -0,0 +1,332 @@ +import Thing from './thing.js'; + +import {inspect} from 'util'; +import {color} from '../../util/cli.js'; + +import find from '../../util/find.js'; + +export class Track extends Thing { + static [Thing.referenceType] = 'track'; + + static [Thing.getPropertyDescriptors] = ({ + Album, + ArtTag, + Artist, + Flash, + + validators: { + isBoolean, + isDate, + isDuration, + isFileExtension, + }, + }) => ({ + // Update & expose + + name: Thing.common.name('Unnamed Track'), + directory: Thing.common.directory(), + + duration: { + flags: {update: true, expose: true}, + update: {validate: isDuration}, + }, + + urls: Thing.common.urls(), + dateFirstReleased: Thing.common.simpleDate(), + + hasURLs: Thing.common.flag(true), + + artistContribsByRef: Thing.common.contribsByRef(), + contributorContribsByRef: Thing.common.contribsByRef(), + coverArtistContribsByRef: Thing.common.contribsByRef(), + + referencedTracksByRef: Thing.common.referenceList(Track), + sampledTracksByRef: Thing.common.referenceList(Track), + artTagsByRef: Thing.common.referenceList(ArtTag), + + hasCoverArt: { + flags: {update: true, expose: true}, + + update: {validate: isBoolean}, + + 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', + }, + }, + + // Previously known as: (track).aka + originalReleaseTrackByRef: Thing.common.singleReference(Track), + + dataSourceAlbumByRef: Thing.common.singleReference(Album), + + commentary: Thing.common.commentary(), + lyrics: Thing.common.simpleString(), + additionalFiles: Thing.common.additionalFiles(), + + // 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), + + // 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: {expose: true}, + + expose: { + dependencies: ['albumData'], + + compute: ({albumData, [Track.instance]: track}) => + Track.findAlbum(track, albumData)?.trackGroups.find((tg) => + tg.tracks.includes(track) + )?.color ?? null, + }, + }, + + coverArtDate: { + flags: {update: true, expose: true}, + + 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); + }, + }, + }, + + // 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 + ), + + sampledTracks: Thing.common.dynamicThingsFromReferenceList( + 'sampledTracksByRef', + 'trackData', + find.track + ), + + // Specifically exclude re-releases from this list - while it's useful to + // get from a re-release to the tracks it references, re-releases aren't + // generally relevant from the perspective of the tracks being referenced. + // Filtering them from data here hides them from the corresponding field + // on the site (obviously), and has the bonus of not counting them when + // counting the number of times a track has been referenced, for use in + // the "Tracks - by Times Referenced" listing page (or other data + // processing). + referencedByTracks: { + flags: {expose: true}, + + expose: { + dependencies: ['trackData'], + + compute: ({trackData, [Track.instance]: track}) => + trackData + ? trackData + .filter((t) => !t.originalReleaseTrack) + .filter((t) => t.referencedTracks?.includes(track)) + : [], + }, + }, + + // For the same reasoning, exclude re-releases from sampled tracks too. + sampledByTracks: { + flags: {expose: true}, + + expose: { + dependencies: ['trackData'], + + compute: ({trackData, [Track.instance]: track}) => + trackData + ? trackData + .filter((t) => !t.originalReleaseTrack) + .filter((t) => t.sampledTracks?.includes(track)) + : [], + }, + }, + + // Previously known as: (track).flashes + featuredInFlashes: Thing.common.reverseReferenceList( + 'flashData', + 'featuredTracks' + ), + + artTags: Thing.common.dynamicThingsFromReferenceList( + 'artTagsByRef', + 'artTagData', + find.artTag + ), + }); + + // This is a quick utility function for now, since the same code is reused in + // several places. Ideally it wouldn't be - we'd just reuse the `album` + // property - but support for that hasn't been coded yet :P + static findAlbum = (track, albumData) => + albumData?.find((album) => album.tracks.includes(track)); + + // Another reused utility function. This one's logic is a bit more complicated. + static hasCoverArt = ( + track, + albumData, + coverArtistContribsByRef, + hasCoverArt + ) => ( + hasCoverArt ?? + (coverArtistContribsByRef?.length > 0 || null) ?? + Track.findAlbum(track, albumData)?.hasTrackArt ?? + true + ); + + [inspect.custom]() { + 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}`; + + return albumName + ? base + ` (${color.yellow(trackNum)} in ${color.green(albumName)})` + : base; + } +} diff --git a/src/data/validators.js b/src/data/things/validators.js index 5c357c83..cc603d48 100644 --- a/src/data/validators.js +++ b/src/data/things/validators.js @@ -1,6 +1,6 @@ -import {withAggregate} from '../util/sugar.js'; +import {withAggregate} from '../../util/sugar.js'; -import {color, ENABLE_COLOR} from '../util/cli.js'; +import {color, ENABLE_COLOR} from '../../util/cli.js'; import {inspect as nodeInspect} from 'util'; diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js new file mode 100644 index 00000000..adf085e5 --- /dev/null +++ b/src/data/things/wiki-info.js @@ -0,0 +1,68 @@ +import Thing from './thing.js'; + +import find from '../../util/find.js'; + +export class WikiInfo extends Thing { + static [Thing.getPropertyDescriptors] = ({ + Group, + + validators: { + isLanguageCode, + isName, + isURL, + }, + }) => ({ + // Update & expose + + name: Thing.common.name('Unnamed Wiki'), + + // Displayed in nav bar. + nameShort: { + flags: {update: true, expose: true}, + update: {validate: isName}, + + expose: { + dependencies: ['name'], + transform: (value, {name}) => value ?? name, + }, + }, + + color: Thing.common.color(), + + // One-line description used for <meta rel="description"> tag. + description: Thing.common.simpleString(), + + footerContent: Thing.common.simpleString(), + + defaultLanguage: { + flags: {update: true, expose: true}, + update: {validate: isLanguageCode}, + }, + + canonicalBase: { + flags: {update: true, expose: true}, + update: {validate: isURL}, + }, + + 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), + + // Update only + + groupData: Thing.common.wikiData(Group), + + // Expose only + + divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList( + 'divideTrackListsByGroupsByRef', + 'groupData', + find.group + ), + }); +} diff --git a/src/data/yaml.js b/src/data/yaml.js index 6ba19c06..ab97ab76 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -7,23 +7,7 @@ import yaml from 'js-yaml'; import {readFile} from 'fs/promises'; import {inspect as nodeInspect} from 'util'; -import { - Album, - Artist, - ArtTag, - Flash, - FlashAct, - Group, - GroupCategory, - HomepageLayout, - HomepageLayoutAlbumsRow, - NewsEntry, - StaticPage, - Thing, - Track, - TrackGroup, - WikiInfo, -} from './things.js'; +import T from './things/index.js'; import {color, ENABLE_COLOR, logInfo, logWarn} from '../util/cli.js'; @@ -101,6 +85,10 @@ function makeProcessDocument( ignoredFields = [], } ) { + if (!thingClass) { + throw new Error(`Missing Thing class`); + } + if (!propertyFieldMapping) { throw new Error(`Expected propertyFieldMapping to be provided`); } @@ -178,7 +166,7 @@ makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error } }; -export const processAlbumDocument = makeProcessDocument(Album, { +export const processAlbumDocument = makeProcessDocument(T.Album, { fieldTransformations: { 'Artists': parseContributors, 'Cover Artists': parseContributors, @@ -238,7 +226,7 @@ export const processAlbumDocument = makeProcessDocument(Album, { }, }); -export const processTrackGroupDocument = makeProcessDocument(TrackGroup, { +export const processTrackGroupDocument = makeProcessDocument(T.TrackGroup, { fieldTransformations: { 'Date Originally Released': (value) => new Date(value), }, @@ -250,7 +238,7 @@ export const processTrackGroupDocument = makeProcessDocument(TrackGroup, { }, }); -export const processTrackDocument = makeProcessDocument(Track, { +export const processTrackDocument = makeProcessDocument(T.Track, { fieldTransformations: { 'Duration': getDurationInSeconds, @@ -292,7 +280,7 @@ export const processTrackDocument = makeProcessDocument(Track, { }, }); -export const processArtistDocument = makeProcessDocument(Artist, { +export const processArtistDocument = makeProcessDocument(T.Artist, { propertyFieldMapping: { name: 'Artist', @@ -309,7 +297,7 @@ export const processArtistDocument = makeProcessDocument(Artist, { ignoredFields: ['Dead URLs'], }); -export const processFlashDocument = makeProcessDocument(Flash, { +export const processFlashDocument = makeProcessDocument(T.Flash, { fieldTransformations: { 'Date': (value) => new Date(value), @@ -330,7 +318,7 @@ export const processFlashDocument = makeProcessDocument(Flash, { }, }); -export const processFlashActDocument = makeProcessDocument(FlashAct, { +export const processFlashActDocument = makeProcessDocument(T.FlashAct, { propertyFieldMapping: { name: 'Act', color: 'Color', @@ -340,7 +328,7 @@ export const processFlashActDocument = makeProcessDocument(FlashAct, { }, }); -export const processNewsEntryDocument = makeProcessDocument(NewsEntry, { +export const processNewsEntryDocument = makeProcessDocument(T.NewsEntry, { fieldTransformations: { 'Date': (value) => new Date(value), }, @@ -353,7 +341,7 @@ export const processNewsEntryDocument = makeProcessDocument(NewsEntry, { }, }); -export const processArtTagDocument = makeProcessDocument(ArtTag, { +export const processArtTagDocument = makeProcessDocument(T.ArtTag, { propertyFieldMapping: { name: 'Tag', directory: 'Directory', @@ -362,7 +350,7 @@ export const processArtTagDocument = makeProcessDocument(ArtTag, { }, }); -export const processGroupDocument = makeProcessDocument(Group, { +export const processGroupDocument = makeProcessDocument(T.Group, { propertyFieldMapping: { name: 'Group', directory: 'Directory', @@ -371,14 +359,14 @@ export const processGroupDocument = makeProcessDocument(Group, { }, }); -export const processGroupCategoryDocument = makeProcessDocument(GroupCategory, { +export const processGroupCategoryDocument = makeProcessDocument(T.GroupCategory, { propertyFieldMapping: { name: 'Category', color: 'Color', }, }); -export const processStaticPageDocument = makeProcessDocument(StaticPage, { +export const processStaticPageDocument = makeProcessDocument(T.StaticPage, { propertyFieldMapping: { name: 'Name', nameShort: 'Short Name', @@ -391,7 +379,7 @@ export const processStaticPageDocument = makeProcessDocument(StaticPage, { }, }); -export const processWikiInfoDocument = makeProcessDocument(WikiInfo, { +export const processWikiInfoDocument = makeProcessDocument(T.WikiInfo, { propertyFieldMapping: { name: 'Name', nameShort: 'Short Name', @@ -409,7 +397,7 @@ export const processWikiInfoDocument = makeProcessDocument(WikiInfo, { }, }); -export const processHomepageLayoutDocument = makeProcessDocument(HomepageLayout, { +export const processHomepageLayoutDocument = makeProcessDocument(T.HomepageLayout, { propertyFieldMapping: { sidebarContent: 'Sidebar Content', }, @@ -431,7 +419,7 @@ export function makeProcessHomepageLayoutRowDocument(rowClass, spec) { } export const homepageLayoutRowTypeProcessMapping = { - albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, { + albums: makeProcessHomepageLayoutRowDocument(T.HomepageLayoutAlbumsRow, { propertyFieldMapping: { sourceGroupByRef: 'Group', countAlbumsFromGroup: 'Count', @@ -678,7 +666,7 @@ export const dataSteps = [ let currentTracksByRef = null; let currentTrackGroup = null; - const albumRef = Thing.getReference(album); + const albumRef = T.Thing.getReference(album); const closeCurrentTrackGroup = () => { if (currentTracksByRef) { @@ -687,7 +675,7 @@ export const dataSteps = [ if (currentTrackGroup) { trackGroup = currentTrackGroup; } else { - trackGroup = new TrackGroup(); + trackGroup = new T.TrackGroup(); trackGroup.name = `Default Track Group`; trackGroup.isDefaultTrackGroup = true; } @@ -699,7 +687,7 @@ export const dataSteps = [ }; for (const entry of entries) { - if (entry instanceof TrackGroup) { + if (entry instanceof T.TrackGroup) { closeCurrentTrackGroup(); currentTracksByRef = []; currentTrackGroup = entry; @@ -710,7 +698,7 @@ export const dataSteps = [ entry.dataSourceAlbumByRef = albumRef; - const trackRef = Thing.getReference(entry); + const trackRef = T.Thing.getReference(entry); if (currentTracksByRef) { currentTracksByRef.push(trackRef); } else { @@ -739,9 +727,9 @@ export const dataSteps = [ const artistData = results; const artistAliasData = results.flatMap((artist) => { - const origRef = Thing.getReference(artist); + const origRef = T.Thing.getReference(artist); return artist.aliasNames?.map((name) => { - const alias = new Artist(); + const alias = new T.Artist(); alias.name = name; alias.isAlias = true; alias.aliasedArtistRef = origRef; @@ -770,12 +758,12 @@ export const dataSteps = [ let flashAct; let flashesByRef = []; - if (results[0] && !(results[0] instanceof FlashAct)) { + if (results[0] && !(results[0] instanceof T.FlashAct)) { throw new Error(`Expected an act at top of flash data file`); } for (const thing of results) { - if (thing instanceof FlashAct) { + if (thing instanceof T.FlashAct) { if (flashAct) { Object.assign(flashAct, {flashesByRef}); } @@ -783,7 +771,7 @@ export const dataSteps = [ flashAct = thing; flashesByRef = []; } else { - flashesByRef.push(Thing.getReference(thing)); + flashesByRef.push(T.Thing.getReference(thing)); } } @@ -791,8 +779,8 @@ export const dataSteps = [ Object.assign(flashAct, {flashesByRef}); } - const flashData = results.filter((x) => x instanceof Flash); - const flashActData = results.filter((x) => x instanceof FlashAct); + const flashData = results.filter((x) => x instanceof T.Flash); + const flashActData = results.filter((x) => x instanceof T.FlashAct); return {flashData, flashActData}; }, @@ -813,12 +801,12 @@ export const dataSteps = [ let groupCategory; let groupsByRef = []; - if (results[0] && !(results[0] instanceof GroupCategory)) { + if (results[0] && !(results[0] instanceof T.GroupCategory)) { throw new Error(`Expected a category at top of group data file`); } for (const thing of results) { - if (thing instanceof GroupCategory) { + if (thing instanceof T.GroupCategory) { if (groupCategory) { Object.assign(groupCategory, {groupsByRef}); } @@ -826,7 +814,7 @@ export const dataSteps = [ groupCategory = thing; groupsByRef = []; } else { - groupsByRef.push(Thing.getReference(thing)); + groupsByRef.push(T.Thing.getReference(thing)); } } @@ -834,8 +822,8 @@ export const dataSteps = [ Object.assign(groupCategory, {groupsByRef}); } - const groupData = results.filter((x) => x instanceof Group); - const groupCategoryData = results.filter((x) => x instanceof GroupCategory); + const groupData = results.filter((x) => x instanceof T.Group); + const groupCategoryData = results.filter((x) => x instanceof T.GroupCategory); return {groupData, groupCategoryData}; }, |