From 95bd943d62473e53de11cd6368e540cd48e4231a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 12 Feb 2022 17:38:27 -0400 Subject: bam (Thing subclasses: several steps, one file) --- src/data/cacheable-object.js | 309 +++++++++++++++++ src/data/things.js | 783 ++++++++++++++++++++++++++++++++++++++++++ src/data/validators.js | 327 ++++++++++++++++++ src/thing/album.js | 270 --------------- src/thing/art-tag.js | 37 -- src/thing/artist.js | 48 --- src/thing/cacheable-object.js | 305 ---------------- src/thing/flash.js | 129 ------- src/thing/group.js | 73 ---- src/thing/homepage-layout.js | 99 ------ src/thing/news-entry.js | 49 --- src/thing/static-page.js | 52 --- src/thing/structures.js | 1 - src/thing/thing.js | 62 ---- src/thing/track.js | 173 ---------- src/thing/validators.js | 327 ------------------ src/thing/wiki-info.js | 90 ----- src/upd8.js | 36 +- src/util/sugar.js | 3 +- test/cacheable-object.js | 2 +- test/data-validators.js | 2 +- 21 files changed, 1444 insertions(+), 1733 deletions(-) create mode 100644 src/data/cacheable-object.js create mode 100644 src/data/things.js create mode 100644 src/data/validators.js delete mode 100644 src/thing/album.js delete mode 100644 src/thing/art-tag.js delete mode 100644 src/thing/artist.js delete mode 100644 src/thing/cacheable-object.js delete mode 100644 src/thing/flash.js delete mode 100644 src/thing/group.js delete mode 100644 src/thing/homepage-layout.js delete mode 100644 src/thing/news-entry.js delete mode 100644 src/thing/static-page.js delete mode 100644 src/thing/structures.js delete mode 100644 src/thing/thing.js delete mode 100644 src/thing/track.js delete mode 100644 src/thing/validators.js delete mode 100644 src/thing/wiki-info.js diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js new file mode 100644 index 0000000..9928095 --- /dev/null +++ b/src/data/cacheable-object.js @@ -0,0 +1,309 @@ +// 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; + const allowNull = update?.allowNull; + + return (newValue) => { + const oldValue = this.#propertyUpdateValues[property]; + + if (newValue === undefined) { + throw new ValueError(`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}`; + throw error; + } + } + + this.#propertyUpdateValues[property] = newValue; + this.#invalidateCachesDependentUpon(property); + }; + } + + #getUpdatePropertyValidateFunction(property) { + const descriptor = this.#getPropertyDescriptor(property); + } + + #getPropertyDescriptor(property) { + return this.constructor.propertyDescriptors[property]; + } + + #invalidateCachesDependentUpon(property) { + for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || []) { + 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`); + } + + const dependencyKeys = expose.dependencies || []; + const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]); + const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f()) + .concat([[this.constructor.instance, this]])); + + 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 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 new file mode 100644 index 0000000..6617601 --- /dev/null +++ b/src/data/things.js @@ -0,0 +1,783 @@ +// 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 { + isBoolean, + isColor, + isCommentary, + isCountingNumber, + isContributionList, + isDate, + isDimensions, + isDirectory, + isDuration, + isInstance, + isFileExtension, + isLanguageCode, + isName, + isNumber, + isURL, + isString, + oneOf, + validateArrayItems, + validateInstanceOf, + validateReference, + validateReferenceList, +} from './validators.js'; + +import { + getKebabCase, +} from '../util/wiki-data.js'; + +import find from '../util/find.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 {} + +// 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)} + }), + + // 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} + }), + + // 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} + }), + + // 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 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, {wikiData: {artistData}}), + what + })) + .filter(({ who }) => who)) + : []) + ) + } + }), + + // General purpose wiki data constructor, for properties like artistData, + // trackData, etc. + wikiData: (thingClass) => ({ + flags: {update: true}, + update: { + validate: validateArrayItems(validateInstanceOf(thingClass)) + } + }) +}; + +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}`; +}; + +// -> 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(), + coverArtDate: Thing.common.simpleDate(), + trackArtDate: Thing.common.simpleDate(), + dateAddedToWiki: Thing.common.simpleDate(), + + artistContribsByRef: Thing.common.contribsByRef(), + coverArtistContribsByRef: Thing.common.contribsByRef(), + trackCoverArtistContribsByRef: Thing.common.contribsByRef(), + wallpaperArtistContribsByRef: Thing.common.contribsByRef(), + bannerArtistContribsByRef: Thing.common.contribsByRef(), + + groupsByRef: { + flags: {update: true, expose: true}, + + update: { + validate: validateReferenceList('group') + } + }, + + artTagsByRef: { + flags: {update: true, expose: true}, + + update: { + validate: validateReferenceList('tag') + } + }, + + trackGroups: { + flags: {update: true, expose: true}, + + update: { + validate: validateArrayItems(validateInstanceOf(TrackGroup)) + } + }, + + wallpaperStyle: Thing.common.simpleString(), + + wallpaperFileExtension: { + flags: {update: true, expose: true}, + update: {validate: isFileExtension} + }, + + bannerStyle: Thing.common.simpleString(), + + bannerFileExtension: { + flags: {update: true, expose: true}, + update: {validate: isFileExtension} + }, + + bannerDimensions: { + flags: {update: true, expose: true}, + update: {validate: isDimensions} + }, + + hasTrackArt: Thing.common.flag(true), + isMajorRelease: Thing.common.flag(false), + isListedOnHomepage: Thing.common.flag(true), + + commentary: { + flags: {update: true, expose: true}, + update: {validate: isCommentary} + }, + + // Update only + + artistData: Thing.common.wikiData(Artist), + trackData: Thing.common.wikiData(Track), + + // Expose only + + // Previously known as: (album).artists + artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), + + tracks: { + flags: {expose: true}, + + expose: { + dependencies: ['trackGroups', 'trackData'], + compute: ({ trackGroups, trackData }) => ( + (trackGroups && trackData + ? (trackGroups + .flatMap(group => group.tracksByRef ?? []) + .map(ref => find.track(ref, {wikiData: {trackData}})) + .filter(Boolean)) + : []) + ) + } + }, +}; + +TrackGroup.propertyDescriptors = { + // Update & expose + + name: Thing.common.name('Unnamed Track Group'), + color: Thing.common.color(), + + dateOriginallyReleased: Thing.common.simpleDate(), + + tracksByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('track')} + }, + + isDefaultTrackGroup: Thing.common.flag(false), + + // Update only + + 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, {wikiData: {trackData}})) + .filter(Boolean)) + : []) + ) + } + }, +}; + +// -> Track + +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(), + coverArtDate: Thing.common.simpleDate(), + + hasCoverArt: Thing.common.flag(true), + hasURLs: Thing.common.flag(true), + + referencedTracksByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('track')} + }, + + artistContribsByRef: Thing.common.contribsByRef(), + contributorContribsByRef: Thing.common.contribsByRef(), + coverArtistContribsByRef: Thing.common.contribsByRef(), + + artTagsByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('tag')} + }, + + // Previously known as: (track).aka + originalReleaseTrackByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReference('track')} + }, + + commentary: { + flags: {update: true, expose: true}, + update: {validate: isCommentary} + }, + + lyrics: Thing.common.simpleString(), + + // Update only + + albumData: Thing.common.wikiData(Album), + artistData: Thing.common.wikiData(Artist), + artTagData: Thing.common.wikiData(ArtTag), + + // Expose only + + album: { + flags: {expose: true}, + + expose: { + dependencies: ['albumData'], + compute: ({ [Track.instance]: track, albumData }) => ( + albumData?.find(album => album.tracks.includes(track)) ?? null) + } + }, + + date: { + flags: {expose: true}, + + expose: { + dependencies: ['albumData', 'dateFirstReleased'], + compute: ({ albumData, dateFirstReleased, [Track.instance]: track }) => ( + dateFirstReleased ?? + albumData?.find(album => album.tracks.includes(track))?.date ?? + null + ) + } + }, + + // Previously known as: (track).artists + artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), + + artTags: { + flags: {expose: true}, + + expose: { + dependencies: ['artTagsByRef', 'artTagData'], + + compute: ({ artTagsByRef, artTagData }) => ( + (artTagsByRef && artTagData + ? (artTagsByRef + .map(ref => find.tag(ref, {wikiData: {tagData: artTagData}})) + .filter(Boolean)) + : []) + ) + } + } +}; + +// -> Artist + +Artist.propertyDescriptors = { + // Update & expose + + name: Thing.common.name('Unnamed Artist'), + directory: Thing.common.directory(), + urls: Thing.common.urls(), + + aliasRefs: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('artist')} + }, + + contextNotes: Thing.common.simpleString(), +}; + +// -> Group + +Group.propertyDescriptors = { + // Update & expose + + name: Thing.common.name('Unnamed Group'), + directory: Thing.common.directory(), + + description: Thing.common.simpleString(), + + urls: Thing.common.urls(), + + // Expose only + + descriptionShort: { + flags: {expose: true}, + + expose: { + dependencies: ['description'], + compute: ({ description }) => description.split('
')[0] + } + } +}; + +GroupCategory.propertyDescriptors = { + // Update & expose + + name: Thing.common.name('Unnamed Group Category'), + color: Thing.common.color(), + + groupsByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('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), +}; + +// -> 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 }) { + return body.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(value) { + throw new Error(`'type' property validator must be overridden`); + } + } + }, + + color: Thing.common.color(), +}; + +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: { + flags: {update: true, expose: true}, + update: {validate: validateReference('group')} + }, + + sourceAlbumsByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('album')} + }, + + countAlbumsFromGroup: { + flags: {update: true, expose: true}, + update: {validate: isCountingNumber} + }, + + actionLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isString)} + }, +}; + +// -> 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.toString() + } + }, + + date: Thing.common.simpleDate(), + + coverArtFileExtension: { + flags: {update: true, expose: true}, + update: {validate: isFileExtension} + }, + + featuredTracksByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('track')} + }, + + contributorContribsByRef: Thing.common.contribsByRef(), + urls: Thing.common.urls(), +}; + +FlashAct.propertyDescriptors = { + // Update & expose + + name: Thing.common.name('Unnamed Flash Act'), + color: Thing.common.color(), + anchor: Thing.common.simpleString(), + jump: Thing.common.simpleString(), + jumpColor: Thing.common.color(), + + flashesByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('flash')} + }, +}; + +// WikiInfo + +WikiInfo.propertyDescriptors = { + // Update & expose + + name: Thing.common.name('Unnamed Wiki'), + + // Displayed in nav bar. + shortName: { + 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} + }, + + // Feature toggles + + enableArtistAvatars: Thing.common.flag(false), + 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), +}; diff --git a/src/data/validators.js b/src/data/validators.js new file mode 100644 index 0000000..8392222 --- /dev/null +++ b/src/data/validators.js @@ -0,0 +1,327 @@ +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 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) { + isObject(value); + + if (!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 (![1 + 3, 1 + 4, 1 + 6, 1 + 8].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 isContribution(contrib) { + // TODO: Use better object validation for this (supporting aggregates etc) + + isObject(contrib); + + isArtistRef(contrib.who); + + if (contrib.what !== null) { + isStringNonEmpty(contrib.what); + } + + return true; +} + +export const isContributionList = validateArrayItems(isContribution); + +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/thing/album.js b/src/thing/album.js deleted file mode 100644 index ba75352..0000000 --- a/src/thing/album.js +++ /dev/null @@ -1,270 +0,0 @@ -import CacheableObject from './cacheable-object.js'; -import Thing from './thing.js'; - -import { - isBoolean, - isColor, - isCommentary, - isContributionList, - isDate, - isDimensions, - isDirectory, - isInstance, - isFileExtension, - isName, - isURL, - isString, - validateArrayItems, - validateInstanceOf, - validateReference, - validateReferenceList, -} from './validators.js'; - -import Artist from './artist.js'; -import ArtTag from './art-tag.js'; -import Track from './track.js'; - -import find from '../util/find.js'; - -export class TrackGroup extends CacheableObject { - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - update: {default: 'Unnamed Track Group', validate: isName} - }, - - color: { - flags: {update: true, expose: true}, - update: {validate: isColor} - }, - - dateOriginallyReleased: { - flags: {update: true, expose: true}, - update: {validate: isDate} - }, - - tracksByRef: { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList('track')} - }, - - isDefaultTrackGroup: { - flags: {update: true, expose: true}, - update: {validate: isBoolean} - }, - - // Update only - - trackData: { - flags: {update: true}, - update: {validate: validateArrayItems(item => isInstance(item, Track))} - }, - - // Expose only - - tracks: { - flags: {expose: true}, - - expose: { - dependencies: ['tracksByRef', 'trackData'], - compute: ({ tracksByRef, trackData }) => ( - (tracksByRef && trackData - ? (tracksByRef - .map(ref => find.track(ref, {wikiData: {trackData}})) - .filter(Boolean)) - : []) - ) - } - }, - }; -} - -export default class Album extends Thing { - static [Thing.referenceType] = 'album'; - - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - update: {default: 'Unnamed Album', validate: isName} - }, - - color: { - flags: {update: true, expose: true}, - update: {validate: isColor} - }, - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: Thing.directoryExpose - }, - - urls: { - flags: {update: true, expose: true}, - - update: { - validate: validateArrayItems(isURL) - } - }, - - date: { - flags: {update: true, expose: true}, - update: {validate: isDate} - }, - - coverArtDate: { - flags: {update: true, expose: true}, - update: {validate: isDate} - }, - - trackArtDate: { - flags: {update: true, expose: true}, - update: {validate: isDate} - }, - - dateAddedToWiki: { - flags: {update: true, expose: true}, - - update: {validate: isDate} - }, - - artistContribsByRef: { - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }, - - coverArtistContribsByRef: { - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }, - - trackCoverArtistContribsByRef: { - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }, - - wallpaperArtistContribsByRef: { - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }, - - bannerArtistContribsByRef: { - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }, - - groupsByRef: { - flags: {update: true, expose: true}, - - update: { - validate: validateReferenceList('group') - } - }, - - artTagsByRef: { - flags: {update: true, expose: true}, - - update: { - validate: validateReferenceList('tag') - } - }, - - trackGroups: { - flags: {update: true, expose: true}, - - update: { - validate: validateArrayItems(validateInstanceOf(TrackGroup)) - } - }, - - wallpaperStyle: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - wallpaperFileExtension: { - flags: {update: true, expose: true}, - update: {validate: isFileExtension} - }, - - bannerStyle: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - bannerFileExtension: { - flags: {update: true, expose: true}, - update: {validate: isFileExtension} - }, - - bannerDimensions: { - flags: {update: true, expose: true}, - update: {validate: isDimensions} - }, - - hasTrackArt: { - flags: {update: true, expose: true}, - - update: { - default: true, - validate: isBoolean - } - }, - - isMajorRelease: { - flags: {update: true, expose: true}, - - update: { - default: false, - validate: isBoolean - } - }, - - isListedOnHomepage: { - flags: {update: true, expose: true}, - - update: { - default: true, - validate: isBoolean - } - }, - - commentary: { - flags: {update: true, expose: true}, - update: {validate: isCommentary} - }, - - // Update only - - artistData: Thing.genWikiDataProperty(Artist), - trackData: Thing.genWikiDataProperty(Track), - - // Expose only - - // Previously known as: (album).artists - artistContribs: { - flags: {expose: true}, - expose: Thing.genContribsExpose('artistContribsByRef') - }, - - tracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackGroups', 'trackData'], - compute: ({ trackGroups, trackData }) => ( - (trackGroups && trackData - ? (trackGroups - .flatMap(group => group.tracksByRef ?? []) - .map(ref => find.track(ref, {wikiData: {trackData}})) - .filter(Boolean)) - : []) - ) - } - }, - }; -} diff --git a/src/thing/art-tag.js b/src/thing/art-tag.js deleted file mode 100644 index 4b09d88..0000000 --- a/src/thing/art-tag.js +++ /dev/null @@ -1,37 +0,0 @@ -import Thing from './thing.js'; - -import { - isBoolean, - isColor, - isDirectory, - isName, -} from './validators.js'; - -export default class ArtTag extends Thing { - static [Thing.referenceType] = 'tag'; - - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - update: {validate: isName} - }, - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: Thing.directoryExpose - }, - - color: { - flags: {update: true, expose: true}, - update: {validate: isColor} - }, - - isContentWarning: { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: false} - }, - }; -} diff --git a/src/thing/artist.js b/src/thing/artist.js deleted file mode 100644 index bbb2a93..0000000 --- a/src/thing/artist.js +++ /dev/null @@ -1,48 +0,0 @@ -import Thing from './thing.js'; - -import { - isDirectory, - isName, - isString, - isURL, - validateArrayItems, - validateReferenceList, -} from './validators.js'; - -export default class Artist extends Thing { - static [Thing.referenceType] = 'artist'; - - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - - update: { - default: 'Unnamed Artist', - validate: isName - } - }, - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: Thing.directoryExpose - }, - - urls: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)} - }, - - aliasRefs: { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList('artist')} - }, - - contextNotes: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - }; -} diff --git a/src/thing/cacheable-object.js b/src/thing/cacheable-object.js deleted file mode 100644 index 9af4160..0000000 --- a/src/thing/cacheable-object.js +++ /dev/null @@ -1,305 +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() { - 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; - const allowNull = update?.allowNull; - - return (newValue) => { - const oldValue = this.#propertyUpdateValues[property]; - - if (newValue === undefined) { - throw new ValueError(`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}`; - throw error; - } - } - - this.#propertyUpdateValues[property] = newValue; - this.#invalidateCachesDependentUpon(property); - }; - } - - #getUpdatePropertyValidateFunction(property) { - const descriptor = this.#getPropertyDescriptor(property); - } - - #getPropertyDescriptor(property) { - return this.constructor.propertyDescriptors[property]; - } - - #invalidateCachesDependentUpon(property) { - for (const invalidate of this.#propertyUpdateCacheInvalidators[property] || []) { - 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`); - } - - const dependencyKeys = expose.dependencies || []; - const dependencyGetters = dependencyKeys.map(key => () => [key, this.#propertyUpdateValues[key]]); - const getAllDependencies = () => Object.fromEntries(dependencyGetters.map(f => f()) - .concat([[this.constructor.instance, this]])); - - 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 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/thing/flash.js b/src/thing/flash.js deleted file mode 100644 index 4eac65a..0000000 --- a/src/thing/flash.js +++ /dev/null @@ -1,129 +0,0 @@ -import Thing from './thing.js'; - -import { - isColor, - isContributionList, - isDate, - isDirectory, - isFileExtension, - isName, - isNumber, - isString, - isURL, - oneOf, - validateArrayItems, - validateReferenceList, -} from './validators.js'; - -export default class Flash extends Thing { - static [Thing.referenceType] = 'flash'; - - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - - update: { - default: 'Unnamed Flash', - validate: isName - } - }, - - 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.toString() - } - }, - - date: { - flags: {update: true, expose: true}, - update: {validate: isDate} - }, - - coverArtFileExtension: { - flags: {update: true, expose: true}, - update: {validate: isFileExtension} - }, - - featuredTracksByRef: { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList('track')} - }, - - contributorContribsByRef: { - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }, - - urls: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)} - }, - }; -} - -export class FlashAct extends Thing { - static [Thing.referenceType] = 'flash-act'; - - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - - update: { - default: 'Unnamed Flash Act', - validate: isName - } - }, - - color: { - flags: {update: true, expose: true}, - update: {validate: isColor} - }, - - anchor: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - jump: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - jumpColor: { - flags: {update: true, expose: true}, - update: {validate: isColor} - }, - - flashesByRef: { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList('flash')} - }, - }; -} diff --git a/src/thing/group.js b/src/thing/group.js deleted file mode 100644 index 3b92e95..0000000 --- a/src/thing/group.js +++ /dev/null @@ -1,73 +0,0 @@ -import CacheableObject from './cacheable-object.js'; -import Thing from './thing.js'; - -import { - isColor, - isDirectory, - isName, - isString, - isURL, - validateArrayItems, - validateReferenceList, -} from './validators.js'; - -export class GroupCategory extends CacheableObject { - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - update: {default: 'Unnamed Group Category', validate: isName} - }, - - color: { - flags: {update: true, expose: true}, - update: {validate: isColor} - }, - - groupsByRef: { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList('group')} - }, - }; -} - -export default class Group extends Thing { - static [Thing.referenceType] = 'group'; - - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - update: {default: 'Unnamed Group', validate: isName} - }, - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: Thing.directoryExpose - }, - - description: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - urls: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)} - }, - - // Expose only - - descriptionShort: { - flags: {expose: true}, - - expose: { - dependencies: ['description'], - compute: ({ description }) => description.split('
')[0] - } - } - }; -} diff --git a/src/thing/homepage-layout.js b/src/thing/homepage-layout.js deleted file mode 100644 index 4717391..0000000 --- a/src/thing/homepage-layout.js +++ /dev/null @@ -1,99 +0,0 @@ -import CacheableObject from './cacheable-object.js'; - -import { - isColor, - isCountingNumber, - isName, - isString, - oneOf, - validateArrayItems, - validateInstanceOf, - validateReference, - validateReferenceList, -} from './validators.js'; - -export class HomepageLayoutRow extends CacheableObject { - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - update: {validate: isName} - }, - - type: { - flags: {update: true, expose: true}, - - update: { - validate(value) { - throw new Error(`'type' property validator must be overridden`); - } - } - }, - - color: { - flags: {update: true, expose: true}, - update: {validate: isColor} - }, - }; -} - -export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { - static 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: { - flags: {update: true, expose: true}, - update: {validate: validateReference('group')} - }, - - sourceAlbumsByRef: { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList('album')} - }, - - countAlbumsFromGroup: { - flags: {update: true, expose: true}, - update: {validate: isCountingNumber} - }, - - actionLinks: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isString)} - }, - } -} - -export default class HomepageLayout extends CacheableObject { - static propertyDescriptors = { - // Update & expose - - sidebarContent: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - rows: { - flags: {update: true, expose: true}, - - update: { - validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)) - } - }, - }; -} diff --git a/src/thing/news-entry.js b/src/thing/news-entry.js deleted file mode 100644 index 2db2f37..0000000 --- a/src/thing/news-entry.js +++ /dev/null @@ -1,49 +0,0 @@ -import Thing from './thing.js'; - -import { - isDate, - isDirectory, - isName, -} from './validators.js'; - -export default class NewsEntry extends Thing { - static [Thing.referenceType] = 'news-entry'; - - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - update: {validate: isName} - }, - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: Thing.directoryExpose - }, - - date: { - flags: {update: true, expose: true}, - update: {validate: isDate} - }, - - content: { - flags: {update: true, expose: true}, - }, - - // Expose only - - contentShort: { - flags: {expose: true}, - - expose: { - dependencies: ['content'], - - compute({ content }) { - return body.split('
')[0]; - } - } - }, - }; -} diff --git a/src/thing/static-page.js b/src/thing/static-page.js deleted file mode 100644 index e2b5150..0000000 --- a/src/thing/static-page.js +++ /dev/null @@ -1,52 +0,0 @@ -import Thing from './thing.js'; - -import { - isBoolean, - isDirectory, - isName, - isString, -} from './validators.js'; - -export default class StaticPage extends Thing { - static [Thing.referenceType] = 'static'; - - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - update: {validate: isName, default: 'Unnamed Static Page'} - }, - - nameShort: { - flags: {update: true, expose: true}, - update: {validate: isName}, - - expose: { - dependencies: ['name'], - transform: (value, { name }) => value ?? name - } - }, - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: Thing.directoryExpose - }, - - content: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - stylesheet: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - showInNavigationBar: { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: true} - }, - }; -} diff --git a/src/thing/structures.js b/src/thing/structures.js deleted file mode 100644 index 364ba14..0000000 --- a/src/thing/structures.js +++ /dev/null @@ -1 +0,0 @@ -// Generic structure utilities common across various Thing types. diff --git a/src/thing/thing.js b/src/thing/thing.js deleted file mode 100644 index 2d6def6..0000000 --- a/src/thing/thing.js +++ /dev/null @@ -1,62 +0,0 @@ -// Base class for Things. No, we will not come up with a better name. -// Sorry not sorry! :) - -import CacheableObject from './cacheable-object.js'; - -import { - validateArrayItems, -} from './validators.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 directoryExpose = { - dependencies: ['name'], - transform(directory, { name }) { - if (directory === null && name === null) - return null; - else if (directory === null) - return getKebabCase(name); - else - return directory; - } - }; - - static genContribsExpose(contribsByRefProperty) { - return { - dependencies: ['artistData', contribsByRefProperty], - compute: ({ artistData, [contribsByRefProperty]: contribsByRef }) => ( - (contribsByRef && artistData - ? (contribsByRef - .map(({ who: ref, what }) => ({ - who: find.artist(ref, {wikiData: {artistData}}), - what - })) - .filter(({ who }) => who)) - : []) - ) - }; - } - - static genWikiDataProperty(thingClass) { - return { - flags: {update: true}, - update: { - validate: validateArrayItems(x => x instanceof thingClass) - } - }; - } - - 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/thing/track.js b/src/thing/track.js deleted file mode 100644 index 3edabc9..0000000 --- a/src/thing/track.js +++ /dev/null @@ -1,173 +0,0 @@ -import Thing from './thing.js'; - -import { - isBoolean, - isColor, - isCommentary, - isContributionList, - isDate, - isDirectory, - isDuration, - isName, - isURL, - isString, - validateArrayItems, - validateReference, - validateReferenceList, -} from './validators.js'; - -import Album from './album.js'; -import Artist from './artist.js'; -import ArtTag from './art-tag.js'; - -import find from '../util/find.js'; - -export default class Track extends Thing { - static [Thing.referenceType] = 'track'; - - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - - update: { - default: 'Unnamed Track', - validate: isName - } - }, - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: Thing.directoryExpose - }, - - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration} - }, - - urls: { - flags: {update: true, expose: true}, - - update: { - validate: validateArrayItems(isURL) - } - }, - - dateFirstReleased: { - flags: {update: true, expose: true}, - update: {validate: isDate} - }, - - coverArtDate: { - flags: {update: true, expose: true}, - update: {validate: isDate} - }, - - hasCoverArt: { - flags: {update: true, expose: true}, - update: {default: true, validate: isBoolean} - }, - - hasURLs: { - flags: {update: true, expose: true}, - update: {default: true, validate: isBoolean} - }, - - referencedTracksByRef: { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList('track')} - }, - - artistContribsByRef: { - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }, - - contributorContribsByRef: { - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }, - - coverArtistContribsByRef: { - flags: {update: true, expose: true}, - update: {validate: isContributionList} - }, - - artTagsByRef: { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList('tag')} - }, - - // Previously known as: (track).aka - originalReleaseTrackByRef: { - flags: {update: true, expose: true}, - update: {validate: validateReference('track')} - }, - - commentary: { - flags: {update: true, expose: true}, - update: {validate: isCommentary} - }, - - lyrics: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - // Update only - - albumData: Thing.genWikiDataProperty(Album), - artistData: Thing.genWikiDataProperty(Artist), - artTagData: Thing.genWikiDataProperty(ArtTag), - - // Expose only - - album: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData'], - compute: ({ [this.instance]: track, albumData }) => ( - albumData?.find(album => album.tracks.includes(track)) ?? null) - } - }, - - date: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - compute: ({ albumData, dateFirstReleased, [this.instance]: track }) => ( - dateFirstReleased ?? - albumData?.find(album => album.tracks.includes(track))?.date ?? - null - ) - } - }, - - // Previously known as: (track).artists - artistContribs: { - flags: {expose: true}, - expose: Thing.genContribsExpose('artistContribsByRef') - }, - - artTags: { - flags: {expose: true}, - - expose: { - dependencies: ['artTagsByRef', 'artTagData'], - - compute: ({ artTagsByRef, artTagData }) => ( - (artTagsByRef && artTagData - ? (artTagsByRef - .map(ref => find.tag(ref, {wikiData: {tagData: artTagData}})) - .filter(Boolean)) - : []) - ) - } - } - }; -} diff --git a/src/thing/validators.js b/src/thing/validators.js deleted file mode 100644 index 8392222..0000000 --- a/src/thing/validators.js +++ /dev/null @@ -1,327 +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 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) { - isObject(value); - - if (!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 (![1 + 3, 1 + 4, 1 + 6, 1 + 8].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 isContribution(contrib) { - // TODO: Use better object validation for this (supporting aggregates etc) - - isObject(contrib); - - isArtistRef(contrib.who); - - if (contrib.what !== null) { - isStringNonEmpty(contrib.what); - } - - return true; -} - -export const isContributionList = validateArrayItems(isContribution); - -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/thing/wiki-info.js b/src/thing/wiki-info.js deleted file mode 100644 index b805bf7..0000000 --- a/src/thing/wiki-info.js +++ /dev/null @@ -1,90 +0,0 @@ -import CacheableObject from './cacheable-object.js'; - -import { - isBoolean, - isColor, - isLanguageCode, - isName, - isString, - isURL, -} from './validators.js'; - -export default class WikiInfo extends CacheableObject { - static propertyDescriptors = { - // Update & expose - - name: { - flags: {update: true, expose: true}, - update: {validate: isName, default: 'Unnamed Wiki'} - }, - - // Displayed in nav bar. - shortName: { - flags: {update: true, expose: true}, - update: {validate: isName}, - - expose: { - dependencies: ['name'], - transform: (value, { name }) => value ?? name - } - }, - - color: { - flags: {update: true, expose: true}, - update: {validate: isColor} - }, - - // One-line description used for tag. - description: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - footerContent: { - flags: {update: true, expose: true}, - update: {validate: isString} - }, - - defaultLanguage: { - flags: {update: true, expose: true}, - update: {validate: isLanguageCode} - }, - - canonicalBase: { - flags: {update: true, expose: true}, - update: {validate: isURL} - }, - - // Feature toggles - - enableArtistAvatars: { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: false} - }, - - enableFlashesAndGames: { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: false} - }, - - enableListings: { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: false} - }, - - enableNews: { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: false} - }, - - enableArtTagUI: { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: false} - }, - - enableGroupUI: { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: false} - }, - }; -} diff --git a/src/upd8.js b/src/upd8.js index 2aa4eb2..de79a0f 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -91,20 +91,26 @@ import find from './util/find.js'; import * as html from './util/html.js'; import unbound_link, {getLinkThemeString} from './util/link.js'; -import Album, { TrackGroup } from './thing/album.js'; -import Artist from './thing/artist.js'; -import ArtTag from './thing/art-tag.js'; -import CacheableObject from './thing/cacheable-object.js'; -import Flash, { FlashAct } from './thing/flash.js'; -import Group, { GroupCategory } from './thing/group.js'; -import HomepageLayout, { +import CacheableObject from './data/cacheable-object.js'; + +import { + Album, + Artist, + ArtTag, + Flash, + FlashAct, + Group, + GroupCategory, + HomepageLayout, HomepageLayoutAlbumsRow, -} from './thing/homepage-layout.js'; -import NewsEntry from './thing/news-entry.js'; -import StaticPage from './thing/static-page.js'; -import Thing from './thing/thing.js'; -import Track from './thing/track.js'; -import WikiInfo from './thing/wiki-info.js'; + HomepageLayoutRow, + NewsEntry, + StaticPage, + Thing, + Track, + TrackGroup, + WikiInfo, +} from './data/things.js'; import { fancifyFlashURL, @@ -2612,7 +2618,7 @@ async function main() { call(processAggregate.close); - dataStep.save(processResults); + call(dataStep.save, processResults); return; } @@ -2696,7 +2702,7 @@ async function main() { }); } - dataStep.save(processResults); + call(dataStep.save, processResults); }); } diff --git a/src/util/sugar.js b/src/util/sugar.js index 219c3ee..d6bc3df 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -356,7 +356,8 @@ export function showAggregate(topError, {pathToFile = p => p} = {}) { const stackLine = stackLines?.find(line => line.trim().startsWith('at') && !line.includes('sugar') - && !line.includes('node:internal')); + && !line.includes('node:internal') + && !line.includes('')); const tracePart = (stackLine ? '- ' + stackLine.trim().replace(/file:\/\/(.*\.js)/, (match, pathname) => pathToFile(pathname)) : '(no stack trace)'); diff --git a/test/cacheable-object.js b/test/cacheable-object.js index 203d2af..dd93343 100644 --- a/test/cacheable-object.js +++ b/test/cacheable-object.js @@ -1,6 +1,6 @@ import test from 'tape'; -import CacheableObject from '../src/thing/cacheable-object.js'; +import CacheableObject from '../src/data/cacheable-object.js'; // Utility diff --git a/test/data-validators.js b/test/data-validators.js index 739333a..a7b9b48 100644 --- a/test/data-validators.js +++ b/test/data-validators.js @@ -24,7 +24,7 @@ import { // Compositional utilities oneOf, -} from '../src/thing/validators.js'; +} from '../src/data/validators.js'; function test(msg, fn) { _test(msg, t => { -- cgit 1.3.0-6-gf8a5