diff options
Diffstat (limited to 'src/data/things')
-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 | 350 | ||||
-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 | 367 | ||||
-rw-r--r-- | src/data/things/wiki-info.js | 68 |
15 files changed, 2859 insertions, 0 deletions
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/things/cacheable-object.js b/src/data/things/cacheable-object.js new file mode 100644 index 00000000..6a210cc1 --- /dev/null +++ b/src/data/things/cacheable-object.js @@ -0,0 +1,350 @@ +// Generally extendable class for caching properties and handling dependencies, +// with a few key properties: +// +// 1) The behavior of every property is defined by its descriptor, which is a +// static value stored on the subclass (all instances share the same property +// descriptors). +// +// 1a) Additional properties may not be added past the time of object +// construction, and attempts to do so (including externally setting a +// property name which has no corresponding descriptor) will throw a +// TypeError. (This is done via an Object.seal(this) call after a newly +// created instance defines its own properties according to the descriptor +// on its constructor class.) +// +// 2) Properties may have two flags set: update and expose. Properties which +// update are provided values from the external. Properties which expose +// provide values to the external, generally dependent on other update +// properties (within the same object). +// +// 2a) Properties may be flagged as both updating and exposing. This is so +// that the same name may be used for both "output" and "input". +// +// 3) Exposed properties have values which are computations dependent on other +// properties, as described by a `compute` function on the descriptor. +// Depended-upon properties are explicitly listed on the descriptor next to +// this function, and are only provided as arguments to the function once +// listed. +// +// 3a) An exposed property may depend only upon updating properties, not other +// exposed properties (within the same object). This is to force the +// general complexity of a single object to be fairly simple: inputs +// directly determine outputs, with the only in-between step being the +// `compute` function, no multiple-layer dependencies. Note that this is +// only true within a given object - externally, values provided to one +// object's `update` may be (and regularly are) the exposed values of +// another object. +// +// 3b) If a property both updates and exposes, it is automatically regarded as +// a dependancy. (That is, its exposed value will depend on the value it is +// updated with.) Rather than a required `compute` function, these have an +// optional `transform` function, which takes the update value as its first +// argument and then the usual key-value dependencies as its second. If no +// `transform` function is provided, the expose value is the same as the +// update value. +// +// 4) Exposed properties are cached; that is, if no depended-upon properties are +// updated, the value of an exposed property is not recomputed. +// +// 4a) The cache for an exposed property is invalidated as soon as any of its +// dependencies are updated, but the cache itself is lazy: the exposed +// value will not be recomputed until it is again accessed. (Likewise, an +// exposed value won't be computed for the first time until it is first +// accessed.) +// +// 5) Updating a property may optionally apply validation checks before passing, +// declared by a `validate` function on the `update` block. This function +// should either throw an error (e.g. TypeError) or return false if the value +// is invalid. +// +// 6) Objects do not expect all updating properties to be provided at once. +// Incomplete objects are deliberately supported and enabled. +// +// 6a) The default value for every updating property is null; undefined is not +// accepted as a property value under any circumstances (it always errors). +// However, this default may be overridden by specifying a `default` value +// on a property's `update` block. (This value will be checked against +// the property's validate function.) Note that a property may always be +// updated to null, even if the default is non-null. (Null always bypasses +// the validate check.) +// +// 6b) It's required by the external consumer of an object to determine whether +// or not the object is ready for use (within the larger program). This is +// convenienced by the static CacheableObject.listAccessibleProperties() +// 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 {inspect as nodeInspect} from 'util'; + +function inspect(value) { + return nodeInspect(value, {colors: ENABLE_COLOR}); +} + +export default class CacheableObject { + static instance = Symbol('CacheableObject `this` instance'); + + #propertyUpdateValues = Object.create(null); + #propertyUpdateCacheInvalidators = Object.create(null); + + /* + // Note the constructor doesn't take an initial data source. Due to a quirk + // of JavaScript, private members can't be accessed before the superclass's + // constructor is finished processing - so if we call the overridden + // update() function from inside this constructor, it will error when + // writing to private members. Pretty bad! + // + // That means initial data must be provided by following up with update() + // after constructing the new instance of the Thing (sub)class. + */ + + constructor() { + this.#defineProperties(); + this.#initializeUpdatingPropertyValues(); + + if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { + return new Proxy(this, { + get: (obj, key) => { + if (!Object.hasOwn(obj, key)) { + if (key !== 'constructor') { + CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`); + } + } + return obj[key]; + }, + }); + } + } + + #initializeUpdatingPropertyValues() { + for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) { + const {flags, update} = descriptor; + + if (!flags.update) { + continue; + } + + if (update?.default) { + this[property] = update?.default; + } else { + this[property] = null; + } + } + } + + #defineProperties() { + if (!this.constructor.propertyDescriptors) { + throw new Error(`Expected constructor ${this.constructor.name} to define propertyDescriptors`); + } + + for (const [property, descriptor] of Object.entries(this.constructor.propertyDescriptors)) { + const {flags} = descriptor; + + const definition = { + configurable: false, + enumerable: true, + }; + + if (flags.update) { + definition.set = this.#getUpdateObjectDefinitionSetterFunction(property); + } + + if (flags.expose) { + definition.get = this.#getExposeObjectDefinitionGetterFunction(property); + } + + Object.defineProperty(this, property, definition); + } + + Object.seal(this); + } + + #getUpdateObjectDefinitionSetterFunction(property) { + const {update} = this.#getPropertyDescriptor(property); + const validate = update?.validate; + + return (newValue) => { + const oldValue = this.#propertyUpdateValues[property]; + + if (newValue === undefined) { + throw new TypeError(`Properties cannot be set to undefined`); + } + + if (newValue === oldValue) { + return; + } + + if (newValue !== null && validate) { + try { + const result = validate(newValue); + if (result === undefined) { + throw new TypeError(`Validate function returned undefined`); + } else if (result !== true) { + throw new TypeError(`Validation failed for value ${newValue}`); + } + } catch (error) { + error.message = [ + `Property ${color.green(property)}`, + `(${inspect(this[property])} -> ${inspect(newValue)}):`, + error.message + ].join(' '); + throw error; + } + } + + this.#propertyUpdateValues[property] = newValue; + this.#invalidateCachesDependentUpon(property); + }; + } + + #getPropertyDescriptor(property) { + return this.constructor.propertyDescriptors[property]; + } + + #invalidateCachesDependentUpon(property) { + const invalidators = this.#propertyUpdateCacheInvalidators[property]; + if (!invalidators) { + return; + } + + for (const invalidate of invalidators) { + invalidate(); + } + } + + #getExposeObjectDefinitionGetterFunction(property) { + const {flags} = this.#getPropertyDescriptor(property); + const compute = this.#getExposeComputeFunction(property); + + if (compute) { + let cachedValue; + const checkCacheValid = this.#getExposeCheckCacheValidFunction(property); + return () => { + if (checkCacheValid()) { + return cachedValue; + } else { + return (cachedValue = compute()); + } + }; + } else if (!flags.update && !compute) { + throw new Error(`Exposed property ${property} does not update and is missing compute function`); + } else { + return () => this.#propertyUpdateValues[property]; + } + } + + #getExposeComputeFunction(property) { + const {flags, expose} = this.#getPropertyDescriptor(property); + + const compute = expose?.compute; + const transform = expose?.transform; + + if (flags.update && !transform) { + return null; + } else if (flags.update && compute) { + throw new Error(`Updating property ${property} has compute function, should be formatted as transform`); + } else if (!flags.update && !compute) { + throw new Error(`Exposed property ${property} does not update and is missing compute function`); + } + + let getAllDependencies; + + const dependencyKeys = expose.dependencies; + if (dependencyKeys?.length > 0) { + const reflectionEntry = [this.constructor.instance, this]; + const dependencyGetters = dependencyKeys + .map(key => () => [key, this.#propertyUpdateValues[key]]); + + getAllDependencies = () => + Object.fromEntries(dependencyGetters + .map(f => f()) + .concat([reflectionEntry])); + } else { + const allDependencies = {[this.constructor.instance]: this}; + Object.freeze(allDependencies); + getAllDependencies = () => allDependencies; + } + + if (flags.update) { + return () => transform(this.#propertyUpdateValues[property], getAllDependencies()); + } else { + return () => compute(getAllDependencies()); + } + } + + #getExposeCheckCacheValidFunction(property) { + const {flags, expose} = this.#getPropertyDescriptor(property); + + let valid = false; + + const invalidate = () => { + valid = false; + }; + + const dependencyKeys = new Set(expose?.dependencies); + + if (flags.update) { + dependencyKeys.add(property); + } + + for (const key of dependencyKeys) { + if (this.#propertyUpdateCacheInvalidators[key]) { + this.#propertyUpdateCacheInvalidators[key].push(invalidate); + } else { + this.#propertyUpdateCacheInvalidators[key] = [invalidate]; + } + } + + return () => { + if (!valid) { + valid = true; + return false; + } else { + return true; + } + }; + } + + static cacheAllExposedProperties(obj) { + if (!(obj instanceof CacheableObject)) { + console.warn('Not a CacheableObject:', obj); + return; + } + + const {propertyDescriptors} = obj.constructor; + + if (!propertyDescriptors) { + console.warn('Missing property descriptors:', obj); + return; + } + + for (const [property, descriptor] of Object.entries(propertyDescriptors)) { + const {flags} = descriptor; + + if (!flags.expose) { + continue; + } + + obj[property]; + } + } + + static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false; + static _invalidAccesses = new Set(); + + static showInvalidAccesses() { + if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { + return; + } + + if (!this._invalidAccesses.size) { + return; + } + + console.log(`${this._invalidAccesses.size} unique invalid accesses:`); + for (const line of this._invalidAccesses) { + console.log(` - ${line}`); + } + } +} 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/things/validators.js b/src/data/things/validators.js new file mode 100644 index 00000000..cc603d48 --- /dev/null +++ b/src/data/things/validators.js @@ -0,0 +1,367 @@ +import {withAggregate} from '../../util/sugar.js'; + +import {color, ENABLE_COLOR} from '../../util/cli.js'; + +import {inspect as nodeInspect} from 'util'; + +function inspect(value) { + return nodeInspect(value, {colors: ENABLE_COLOR}); +} + +// Basic types (primitives) + +function a(noun) { + return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; +} + +function isType(value, type) { + if (typeof value !== type) + throw new TypeError(`Expected ${a(type)}, got ${typeof value}`); + + return true; +} + +export function isBoolean(value) { + return isType(value, 'boolean'); +} + +export function isNumber(value) { + return isType(value, 'number'); +} + +export function isPositive(number) { + isNumber(number); + + if (number <= 0) throw new TypeError(`Expected positive number`); + + return true; +} + +export function isNegative(number) { + isNumber(number); + + if (number >= 0) throw new TypeError(`Expected negative number`); + + return true; +} + +export function isPositiveOrZero(number) { + isNumber(number); + + if (number < 0) throw new TypeError(`Expected positive number or zero`); + + return true; +} + +export function isNegativeOrZero(number) { + isNumber(number); + + if (number > 0) throw new TypeError(`Expected negative number or zero`); + + return true; +} + +export function isInteger(number) { + isNumber(number); + + if (number % 1 !== 0) throw new TypeError(`Expected integer`); + + return true; +} + +export function isCountingNumber(number) { + isInteger(number); + isPositive(number); + + return true; +} + +export function isWholeNumber(number) { + isInteger(number); + isPositiveOrZero(number); + + return true; +} + +export function isString(value) { + return isType(value, 'string'); +} + +export function isStringNonEmpty(value) { + isString(value); + + if (value.trim().length === 0) + throw new TypeError(`Expected non-empty string`); + + return true; +} + +// Complex types (non-primitives) + +export function isInstance(value, constructor) { + isObject(value); + + if (!(value instanceof constructor)) + throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`); + + return true; +} + +export function isDate(value) { + return isInstance(value, Date); +} + +export function isObject(value) { + isType(value, 'object'); + + // Note: Please remember that null is always a valid value for properties + // held by a CacheableObject. This assertion is exclusively for use in other + // contexts. + if (value === null) throw new TypeError(`Expected an object, got null`); + + return true; +} + +export function isArray(value) { + if (typeof value !== 'object' || value === null || !Array.isArray(value)) + throw new TypeError(`Expected an array, got ${value}`); + + return true; +} + +function validateArrayItemsHelper(itemValidator) { + return (item, index) => { + try { + const value = itemValidator(item); + + if (value !== true) { + throw new Error(`Expected validator to return true`); + } + } catch (error) { + error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`; + throw error; + } + }; +} + +export function validateArrayItems(itemValidator) { + const fn = validateArrayItemsHelper(itemValidator); + + return (array) => { + isArray(array); + + withAggregate({message: 'Errors validating array items'}, ({wrap}) => { + array.forEach(wrap(fn)); + }); + + return true; + }; +} + +export function validateInstanceOf(constructor) { + return (object) => isInstance(object, constructor); +} + +// Wiki data (primitives & non-primitives) + +export function isColor(color) { + isStringNonEmpty(color); + + if (color.startsWith('#')) { + if (![4, 5, 7, 9].includes(color.length)) + throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`); + + if (/[^0-9a-fA-F]/.test(color.slice(1))) + throw new TypeError(`Expected hexadecimal digits`); + + return true; + } + + throw new TypeError(`Unknown color format`); +} + +export function isCommentary(commentary) { + return isString(commentary); +} + +const isArtistRef = validateReference('artist'); + +export function validateProperties(spec) { + const specEntries = Object.entries(spec); + const specKeys = Object.keys(spec); + + return (object) => { + isObject(object); + + if (Array.isArray(object)) + throw new TypeError(`Expected an object, got array`); + + withAggregate({message: `Errors validating object properties`}, ({call}) => { + for (const [specKey, specValidator] of specEntries) { + call(() => { + const value = object[specKey]; + try { + specValidator(value); + } catch (error) { + error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`; + throw error; + } + }); + } + + const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key)); + if (unknownKeys.length > 0) { + call(() => { + throw new Error(`Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`); + }); + } + }); + + return true; + }; +} + +export const isContribution = validateProperties({ + who: isArtistRef, + what: (value) => + value === undefined || + value === null || + isStringNonEmpty(value), +}); + +export const isContributionList = validateArrayItems(isContribution); + +export const isAdditionalFile = validateProperties({ + title: isString, + description: (value) => + value === undefined || + value === null || + isString(value), + files: validateArrayItems(isString), +}); + +export const isAdditionalFileList = validateArrayItems(isAdditionalFile); + +export function isDimensions(dimensions) { + isArray(dimensions); + + if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`); + + isPositive(dimensions[0]); + isInteger(dimensions[0]); + isPositive(dimensions[1]); + isInteger(dimensions[1]); + + return true; +} + +export function isDirectory(directory) { + isStringNonEmpty(directory); + + if (directory.match(/[^a-zA-Z0-9_-]/)) + throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`); + + return true; +} + +export function isDuration(duration) { + isNumber(duration); + isPositiveOrZero(duration); + + return true; +} + +export function isFileExtension(string) { + isStringNonEmpty(string); + + if (string[0] === '.') + throw new TypeError(`Expected no dot (.) at the start of file extension`); + + if (string.match(/[^a-zA-Z0-9_]/)) + throw new TypeError(`Expected only alphanumeric and underscore`); + + return true; +} + +export function isLanguageCode(string) { + // TODO: This is a stub function because really we don't need a detailed + // is-language-code parser right now. + + isString(string); + + return true; +} + +export function isName(name) { + return isString(name); +} + +export function isURL(string) { + isStringNonEmpty(string); + + new URL(string); + + return true; +} + +export function validateReference(type = 'track') { + return (ref) => { + isStringNonEmpty(ref); + + const match = ref + .trim() + .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/); + + if (!match) throw new TypeError(`Malformed reference`); + + const {groups: {typePart, directoryPart}} = match; + + if (typePart) { + if (typePart !== type) + throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`); + + isDirectory(directoryPart); + } + + isName(ref); + + return true; + }; +} + +export function validateReferenceList(type = '') { + return validateArrayItems(validateReference(type)); +} + +// Compositional utilities + +export function oneOf(...checks) { + return (value) => { + const errorMeta = []; + + for (let i = 0, check; (check = checks[i]); i++) { + try { + const result = check(value); + + if (result !== true) { + throw new Error(`Check returned false`); + } + + return true; + } catch (error) { + errorMeta.push([check, i, error]); + } + } + + // Don't process error messages until every check has failed. + const errors = []; + for (const [check, i, error] of errorMeta) { + error.message = check.name + ? `(#${i} "${check.name}") ${error.message}` + : `(#${i}) ${error.message}`; + error.check = check; + errors.push(error); + } + throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`); + }; +} 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 + ), + }); +} |