« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/things.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/things.js')
-rw-r--r--src/data/things.js783
1 files changed, 783 insertions, 0 deletions
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('<hr class="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('<hr class="split">')[0];
+            }
+        }
+    },
+};
+
+// -> StaticPage
+
+StaticPage.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Static Page'),
+
+    nameShort: {
+        flags: {update: true, expose: true},
+        update: {validate: isName},
+
+        expose: {
+            dependencies: ['name'],
+            transform: (value, { name }) => value ?? name
+        }
+    },
+
+    directory: Thing.common.directory(),
+    content: Thing.common.simpleString(),
+    stylesheet: Thing.common.simpleString(),
+    showInNavigationBar: Thing.common.flag(true),
+};
+
+// -> HomepageLayout
+
+HomepageLayout.propertyDescriptors = {
+    // Update & expose
+
+    sidebarContent: Thing.common.simpleString(),
+
+    rows: {
+        flags: {update: true, expose: true},
+
+        update: {
+            validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow))
+        }
+    },
+};
+
+HomepageLayoutRow.propertyDescriptors = {
+    // Update & expose
+
+    name: Thing.common.name('Unnamed Homepage Row'),
+
+    type: {
+        flags: {update: true, expose: true},
+
+        update: {
+            validate(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 <meta rel="description"> tag.
+    description: Thing.common.simpleString(),
+
+    footerContent: Thing.common.simpleString(),
+
+    defaultLanguage: {
+        flags: {update: true, expose: true},
+        update: {validate: isLanguageCode}
+    },
+
+    canonicalBase: {
+        flags: {update: true, expose: true},
+        update: {validate: isURL}
+    },
+
+    // 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),
+};