From 690a7b53a72ac71f9f76260fa50c634566c4e984 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 28 Nov 2022 23:25:05 -0400 Subject: divide things.js into modular files (hilariously) --- .eslintrc.json | 1 - src/data/cacheable-object.js | 350 ------- src/data/things.js | 1882 ----------------------------------- src/data/things/album.js | 243 +++++ src/data/things/art-tag.js | 42 + src/data/things/artist.js | 163 +++ src/data/things/cacheable-object.js | 350 +++++++ src/data/things/flash.js | 150 +++ src/data/things/group.js | 94 ++ src/data/things/homepage-layout.js | 114 +++ src/data/things/index.js | 173 ++++ src/data/things/language.js | 321 ++++++ src/data/things/news-entry.js | 27 + src/data/things/static-page.js | 30 + src/data/things/thing.js | 385 +++++++ src/data/things/track.js | 332 ++++++ src/data/things/validators.js | 367 +++++++ src/data/things/wiki-info.js | 68 ++ src/data/validators.js | 367 ------- src/data/yaml.js | 82 +- src/misc-templates.js | 4 +- src/static/client.js | 2 + src/static/lazy-loading.js | 2 + src/upd8.js | 8 +- src/util/link.js | 27 +- src/util/sugar.js | 6 +- 26 files changed, 2915 insertions(+), 2675 deletions(-) delete mode 100644 src/data/cacheable-object.js delete mode 100644 src/data/things.js create mode 100644 src/data/things/album.js create mode 100644 src/data/things/art-tag.js create mode 100644 src/data/things/artist.js create mode 100644 src/data/things/cacheable-object.js create mode 100644 src/data/things/flash.js create mode 100644 src/data/things/group.js create mode 100644 src/data/things/homepage-layout.js create mode 100644 src/data/things/index.js create mode 100644 src/data/things/language.js create mode 100644 src/data/things/news-entry.js create mode 100644 src/data/things/static-page.js create mode 100644 src/data/things/thing.js create mode 100644 src/data/things/track.js create mode 100644 src/data/things/validators.js create mode 100644 src/data/things/wiki-info.js delete mode 100644 src/data/validators.js diff --git a/.eslintrc.json b/.eslintrc.json index e0b8431..f104d38 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,5 @@ { "env": { - "browser": true, "es2021": true, "node": true }, diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js deleted file mode 100644 index 04e029f..0000000 --- a/src/data/cacheable-object.js +++ /dev/null @@ -1,350 +0,0 @@ -// 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.js b/src/data/things.js deleted file mode 100644 index 2037fac..0000000 --- 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>/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('
')[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('
')[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 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 - // 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 0000000..4890aaa --- /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 0000000..0f888a2 --- /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 0000000..303f33f --- /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 0000000..6a210cc --- /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 0000000..1383fa8 --- /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 0000000..26fe9a5 --- /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('
')[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 0000000..5948ff4 --- /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 0000000..11b6b1a --- /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 0000000..2152499 --- /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 + // 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 0000000..4391141 --- /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('
')[0], + }, + }, + }); +} diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js new file mode 100644 index 0000000..226e0b6 --- /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 0000000..b9fa60c --- /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>/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 0000000..d2930ff --- /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 0000000..cc603d4 --- /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(/^(?:(?\S+):(?=\S))?(?.+)(? { + 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 0000000..adf085e --- /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 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/validators.js b/src/data/validators.js deleted file mode 100644 index 5c357c8..0000000 --- a/src/data/validators.js +++ /dev/null @@ -1,367 +0,0 @@ -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(/^(?:(?\S+):(?=\S))?(?.+)(? { - 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/yaml.js b/src/data/yaml.js index 6ba19c0..ab97ab7 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}; }, diff --git a/src/misc-templates.js b/src/misc-templates.js index 0a33648..83aae19 100644 --- a/src/misc-templates.js +++ b/src/misc-templates.js @@ -2,7 +2,7 @@ // These are made available right on a page spec's ({wikiData, language, ...}) // args object! -import {Track, Album} from './data/things.js'; +import T from './data/things/index.js'; import { empty, @@ -173,7 +173,7 @@ function unbound_generateChronologyLinks(currentThing, { // right function to use here. const args = [thingsUnsorted, {getDate: (t) => t[dateKey]}]; const things = ( - thingsUnsorted.every(t => t instanceof Album || t instanceof Track) + thingsUnsorted.every(t => t instanceof T.Album || t instanceof T.Track) ? sortAlbumsTracksChronologically(...args) : sortChronologically(...args)); diff --git a/src/static/client.js b/src/static/client.js index e9286ab..b5ed686 100644 --- a/src/static/client.js +++ b/src/static/client.js @@ -1,3 +1,5 @@ +/* eslint-env browser */ + // This is the JS file that gets loaded on the client! It's only really used for // the random track feature right now - the idea is we only use it for stuff // that cannot 8e done at static-site compile time, 8y its fundamentally diff --git a/src/static/lazy-loading.js b/src/static/lazy-loading.js index b5b0a36..b04ad7c 100644 --- a/src/static/lazy-loading.js +++ b/src/static/lazy-loading.js @@ -1,3 +1,5 @@ +/* eslint-env browser */ + // Lazy loading! Roll your own. Woot. // This file includes a 8unch of fall8acks and stuff like that, and is written // with fairly Olden JavaScript(TM), so as to work on pretty much any 8rowser diff --git a/src/upd8.js b/src/upd8.js index 6d63b1b..89984c0 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -61,11 +61,11 @@ import * as html from './util/html.js'; import {getColors} from './util/colors.js'; import {findFiles} from './util/io.js'; -import CacheableObject from './data/cacheable-object.js'; +import CacheableObject from './data/things/cacheable-object.js'; import {serializeThings} from './data/serialize.js'; -import {Language} from './data/things.js'; +import T from './data/things/index.js'; import { filterDuplicateDirectories, @@ -1625,7 +1625,7 @@ async function processLanguageFile(file) { delete json['meta.baseDirectory']; } - const language = new Language(); + const language = new T.Language(); language.code = code; language.intlCode = intlCode; language.name = name; @@ -1834,7 +1834,7 @@ async function main() { const niceShowAggregate = (error, ...opts) => { showAggregate(error, { showTraces: showAggregateTraces, - pathToFile: (f) => path.relative(__dirname, f), + pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), ...opts, }); }; diff --git a/src/util/link.js b/src/util/link.js index 41ac913..bc3bd50 100644 --- a/src/util/link.js +++ b/src/util/link.js @@ -11,16 +11,7 @@ import * as html from './html.js'; -import { - Album, - Artist, - ArtTag, - Flash, - Group, - NewsEntry, - StaticPage, - Track, -} from '../data/things.js'; +import T from '../data/things/index.js'; export function unbound_getLinkThemeString(color, { getColors, @@ -98,14 +89,14 @@ const linkIndex = (key, conf) => // the given thing based on what it's an instance of. This is used for the // link.anything() function. const linkAnythingMapping = [ - [Album, 'album'], - [Artist, 'artist'], - [ArtTag, 'tag'], - [Flash, 'flash'], - [Group, 'groupInfo'], - [NewsEntry, 'newsEntry'], - [StaticPage, 'staticPage'], - [Track, 'track'], + [T.Album, 'album'], + [T.Artist, 'artist'], + [T.ArtTag, 'tag'], + [T.Flash, 'flash'], + [T.Group, 'groupInfo'], + [T.NewsEntry, 'newsEntry'], + [T.StaticPage, 'staticPage'], + [T.Track, 'track'], ]; const link = { diff --git a/src/util/sugar.js b/src/util/sugar.js index 808a7e1..e8fdf93 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -405,7 +405,7 @@ export function _withAggregate(mode, aggregateOpts, fn) { } export function showAggregate(topError, { - pathToFile = (p) => p, + pathToFileURL = f => f, showTraces = true, } = {}) { const recursive = (error, {level}) => { @@ -429,9 +429,7 @@ export function showAggregate(topError, { ? '- ' + stackLine .trim() - .replace(/file:\/\/(.*\.js)/, (match, pathname) => - pathToFile(pathname) - ) + .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) : '(no stack trace)'; header += ` ${color.dim(tracePart)}`; } -- cgit 1.3.0-6-gf8a5