diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/misc-templates.js | 30 | ||||
-rw-r--r-- | src/static/site.css | 5 | ||||
-rw-r--r-- | src/strings-default.json | 2 | ||||
-rw-r--r-- | src/thing/album.js | 284 | ||||
-rw-r--r-- | src/thing/art-tag.js | 37 | ||||
-rw-r--r-- | src/thing/artist.js | 48 | ||||
-rw-r--r-- | src/thing/cacheable-object.js | 271 | ||||
-rw-r--r-- | src/thing/flash.js | 129 | ||||
-rw-r--r-- | src/thing/group.js | 73 | ||||
-rw-r--r-- | src/thing/homepage-layout.js | 99 | ||||
-rw-r--r-- | src/thing/news-entry.js | 49 | ||||
-rw-r--r-- | src/thing/structures.js | 31 | ||||
-rw-r--r-- | src/thing/thing.js | 74 | ||||
-rw-r--r-- | src/thing/track.js | 117 | ||||
-rw-r--r-- | src/thing/validators.js | 314 | ||||
-rwxr-xr-x | src/upd8.js | 1473 | ||||
-rw-r--r-- | src/util/cli.js | 37 | ||||
-rw-r--r-- | src/util/find.js | 2 | ||||
-rw-r--r-- | src/util/sugar.js | 200 |
19 files changed, 2461 insertions, 814 deletions
diff --git a/src/misc-templates.js b/src/misc-templates.js index 578c4e59..090c437d 100644 --- a/src/misc-templates.js +++ b/src/misc-templates.js @@ -375,3 +375,33 @@ export function generatePreviousNextLinks(current, { }) ].filter(Boolean).join(', '); } + +// Footer stuff + +export function getFooterLocalizationLinks(pathname, { + languages, + paths, + strings, + to +}) { + const { toPath } = paths; + const keySuffix = toPath[0].replace(/^localized\./, '.'); + const toArgs = toPath.slice(1); + + const links = Object.entries(languages) + .filter(([ code ]) => code !== 'default') + .map(([ code, strings ]) => strings) + .sort(( + { json: { 'meta.languageName': a } }, + { json: { 'meta.languageName': b } } + ) => a < b ? -1 : a > b ? 1 : 0) + .map(strings => html.tag('span', html.tag('a', { + href: (strings.code === languages.default.code + ? to('localizedDefaultLanguage' + keySuffix, ...toArgs) + : to('localizedWithBaseDirectory' + keySuffix, strings.code, ...toArgs)) + }, strings.json['meta.languageName']))); + + return html.tag('div', + {class: 'footer-localization-links'}, + strings('misc.uiLanguage', {languages: links.join('\n')})); +} diff --git a/src/static/site.css b/src/static/site.css index 65d4d343..c88343e5 100644 --- a/src/static/site.css +++ b/src/static/site.css @@ -173,6 +173,11 @@ footer > :last-child { margin-bottom: 0; } +.footer-localization-links > span:not(:last-child)::after { + content: " \00b7 "; + font-weight: 800; +} + .nowrap { white-space: nowrap; } diff --git a/src/strings-default.json b/src/strings-default.json index b80c99f6..86fb73fc 100644 --- a/src/strings-default.json +++ b/src/strings-default.json @@ -1,5 +1,6 @@ { "meta.languageCode": "en", + "meta.languageName": "English", "count.tracks": "{TRACKS}", "count.tracks.withUnit.zero": "", "count.tracks.withUnit.one": "{TRACKS} track", @@ -148,6 +149,7 @@ "misc.contentWarnings": "cw: {WARNINGS}", "misc.contentWarnings.reveal": "click to show", "misc.albumGridDetails": "({TRACKS}, {TIME})", + "misc.uiLanguage": "UI Language: {LANGUAGES}", "homepage.title": "{TITLE}", "homepage.news.title": "News", "homepage.news.entry.viewRest": "(View rest of entry!)", diff --git a/src/thing/album.js b/src/thing/album.js index e99cfc36..8a9fde2c 100644 --- a/src/thing/album.js +++ b/src/thing/album.js @@ -1,62 +1,252 @@ +import CacheableObject from './cacheable-object.js'; import Thing from './thing.js'; +import find from '../util/find.js'; import { - validateDirectory, - validateReference -} from './structures.js'; + isBoolean, + isColor, + isCommentary, + isContributionList, + isDate, + isDimensions, + isDirectory, + isFileExtension, + isName, + isURL, + isString, + validateArrayItems, + validateInstanceOf, + validateReference, + validateReferenceList, +} from './validators.js'; -import { - showAggregate, - withAggregate -} from '../util/sugar.js'; +export class TrackGroup extends CacheableObject { + static propertyDescriptors = { + // Update & expose -export default class Album extends Thing { - #directory = null; - #tracks = []; + 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))} + }, - static updateError = { - directory: Thing.extendPropertyError('directory'), - tracks: Thing.extendPropertyError('tracks') + // Expose only + + tracks: { + flags: {expose: true}, + + expose: { + dependencies: ['tracksByRef', 'trackData'], + compute: ({ tracksByRef, trackData }) => ( + tracksByRef.map(ref => find.track(ref, {wikiData: {trackData}}))) + } + } }; +} + +export default class Album extends Thing { + static [Thing.referenceType] = 'album'; + + static propertyDescriptors = { + // Update & expose - update(source) { - const err = this.constructor.updateError; + name: { + flags: {update: true, expose: true}, + update: {default: 'Unnamed Album', validate: isName} + }, - withAggregate(({ nest, filter, throws }) => { + color: { + flags: {update: true, expose: true}, + update: {validate: isColor} + }, - if (source.directory) { - nest(throws(err.directory), ({ call }) => { - if (call(validateDirectory, source.directory)) { - this.#directory = source.directory; - } - }); + directory: { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + expose: Thing.directoryExpose + }, + + urls: { + flags: {update: true, expose: true}, + + update: { + validate: validateArrayItems(isURL) } + }, - if (source.tracks) - this.#tracks = filter(source.tracks, validateReference('track'), throws(err.tracks)); - }); - } + date: { + flags: {update: true, expose: true}, + update: {validate: isDate} + }, - get directory() { return this.#directory; } - get tracks() { return this.#tracks; } -} + coverArtDate: { + flags: {update: true, expose: true}, + update: {validate: isDate} + }, -const album = new Album(); - -console.log('tracks (before):', album.tracks); - -try { - album.update({ - directory: 'oh yes', - tracks: [ - 'lol', - 123, - 'track:oh-yeah', - 'group:what-am-i-doing-here' - ] - }); -} catch (error) { - showAggregate(error); -} + 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') + } + }, -console.log('tracks (after):', album.tracks); + 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} + }, + + // Expose only + + /* + tracks: { + flags: {expose: true}, + + expose: { + dependencies: ['trackReferences', 'wikiData'], + compute: ({trackReferences, wikiData}) => ( + trackReferences.map(ref => find.track(ref, {wikiData}))) + } + }, + */ + + // Update only + + /* + wikiData: { + flags: {update: true} + } + */ + }; +} diff --git a/src/thing/art-tag.js b/src/thing/art-tag.js new file mode 100644 index 00000000..4b09d885 --- /dev/null +++ b/src/thing/art-tag.js @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000..bbb2a935 --- /dev/null +++ b/src/thing/artist.js @@ -0,0 +1,48 @@ +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 new file mode 100644 index 00000000..3c14101c --- /dev/null +++ b/src/thing/cacheable-object.js @@ -0,0 +1,271 @@ +// 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 { + #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(); + } + + #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())); + + 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; + } + }; + } +} diff --git a/src/thing/flash.js b/src/thing/flash.js new file mode 100644 index 00000000..4eac65ad --- /dev/null +++ b/src/thing/flash.js @@ -0,0 +1,129 @@ +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 new file mode 100644 index 00000000..3b92e957 --- /dev/null +++ b/src/thing/group.js @@ -0,0 +1,73 @@ +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('<hr class="split">')[0] + } + } + }; +} diff --git a/src/thing/homepage-layout.js b/src/thing/homepage-layout.js new file mode 100644 index 00000000..47173917 --- /dev/null +++ b/src/thing/homepage-layout.js @@ -0,0 +1,99 @@ +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 new file mode 100644 index 00000000..2db2f37c --- /dev/null +++ b/src/thing/news-entry.js @@ -0,0 +1,49 @@ +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('<hr class="split">')[0]; + } + } + }, + }; +} diff --git a/src/thing/structures.js b/src/thing/structures.js index 89c9bd39..364ba149 100644 --- a/src/thing/structures.js +++ b/src/thing/structures.js @@ -1,32 +1 @@ // Generic structure utilities common across various Thing types. - -export function validateDirectory(directory) { - if (typeof directory !== 'string') - throw new TypeError(`Expected a string, got ${directory}`); - - if (directory.length === 0) - throw new TypeError(`Expected directory to be non-zero length`); - - if (directory.match(/[^a-zA-Z0-9\-]/)) - throw new TypeError(`Expected only letters, numbers, and dash, got "${directory}"`); - - return true; -} - -export function validateReference(type = '') { - return ref => { - if (typeof ref !== 'string') - throw new TypeError(`Expected a string, got ${ref}`); - - if (type) { - if (!ref.includes(':')) - throw new TypeError(`Expected ref to begin with "${type}:", but no type specified (ref: ${ref})`); - - const typePart = ref.split(':')[0]; - if (typePart !== type) - throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:" (ref: ${ref})`); - } - - return true; - }; -} diff --git a/src/thing/thing.js b/src/thing/thing.js index c2465e32..54a278d1 100644 --- a/src/thing/thing.js +++ b/src/thing/thing.js @@ -1,66 +1,32 @@ // Base class for Things. No, we will not come up with a better name. // Sorry not sorry! :) -// -// NB: Since these methods all involve processing a variety of input data, some -// of which will pass and some of which may fail, any failures should be thrown -// together as an AggregateError. See util/sugar.js for utility functions to -// make writing code around this easier! -export default class Thing { - constructor(source, { - wikiData - } = {}) { - if (source) { - this.update(source); - } +import CacheableObject from './cacheable-object.js'; - if (wikiData && this.checkComplete()) { - this.postprocess({wikiData}); - } - } +import { getKebabCase } from '../util/wiki-data.js'; - static PropertyError = class extends AggregateError { - #key = this.constructor.key; - get key() { return this.#key; } +export default class Thing extends CacheableObject { + static referenceType = Symbol('Thing.referenceType'); - constructor(errors) { - super(errors, ''); - this.message = `${errors.length} error(s) in property "${this.#key}"`; + 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 extendPropertyError(key) { - const cls = class extends this.PropertyError { - static #key = key; - static get key() { return this.#key; } - }; + static getReference(thing) { + if (!thing.constructor[Thing.referenceType]) + throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`); - Object.defineProperty(cls, 'name', {value: `PropertyError:${key}`}); - return cls; - } + if (!thing.directory) + throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); - // Called when instantiating a thing, and when its data is updated for any - // reason. (Which currently includes no reasons, but hey, future-proofing!) - // - // Don't expect source to be a complete object, even on the first call - the - // method checkComplete() will prevent incomplete resources from being mixed - // with the rest. - update(source) {} - - // Called when collecting the full list of available things of that type - // for wiki data; this method determine whether or not to include it. - // - // This should return whether or not the object is complete enough to be - // used across the wiki - not whether every optional attribute is provided! - // (That is, attributes required for postprocessing & basic page generation - // are all present.) - checkComplete() {} - - // Called when adding the thing to the wiki data list, and when its source - // data is updated (provided checkComplete() passes). - // - // This should generate any cached object references, across other wiki - // data; for example, building an array of actual track objects - // corresponding to an album's track list ('track:cool-track' strings). - postprocess({wikiData}) {} + return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; + } } diff --git a/src/thing/track.js b/src/thing/track.js new file mode 100644 index 00000000..75df109a --- /dev/null +++ b/src/thing/track.js @@ -0,0 +1,117 @@ +import Thing from './thing.js'; + +import { + isBoolean, + isColor, + isCommentary, + isContributionList, + isDate, + isDirectory, + isDuration, + isName, + isURL, + isString, + validateArrayItems, + validateReference, + validateReferenceList, +} from './validators.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')} + }, + + 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 + + // Expose only + }; +} diff --git a/src/thing/validators.js b/src/thing/validators.js new file mode 100644 index 00000000..49463473 --- /dev/null +++ b/src/thing/validators.js @@ -0,0 +1,314 @@ +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) + +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 { + itemValidator(item); + } 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 isName(name) { + return isString(name); +} + +export function isURL(string) { + isStringNonEmpty(string); + + new URL(string); + + return true; +} + +export function validateReference(type = 'track') { + return ref => { + isStringNonEmpty(ref); + + const match = ref.trim().match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/); + + if (!match) + throw new TypeError(`Malformed reference`); + + const { groups: { typePart, directoryPart } } = match; + + if (typePart && typePart !== type) + throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:"`); + + if (typePart) + isDirectory(directoryPart); + + isName(ref); + + return true; + }; +} + +export function validateReferenceList(type = '') { + return validateArrayItems(validateReference(type)); +} + +// Compositional utilities + +export function oneOf(...checks) { + return value => { + const errorMeta = []; + + for (let i = 0, check; check = checks[i]; i++) { + try { + const result = check(value); + + if (result !== true) { + throw new Error(`Check returned false`); + } + + return true; + } catch (error) { + errorMeta.push([check, i, error]); + } + } + + // Don't process error messages until every check has failed. + const errors = []; + for (const [ check, i, error ] of errorMeta) { + error.message = (check.name + ? `(#${i} "${check.name}") ${error.message}` + : `(#${i}) ${error.message}`); + error.check = check; + errors.push(error); + } + throw new AggregateError(errors, `Expected one of ${checks.length} possible checks, but none were true`); + }; +} diff --git a/src/upd8.js b/src/upd8.js index f78a21c4..0ea998a2 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -17,6 +17,9 @@ // going to 8e in. May8e JSON, 8ut more likely some weird custom format // which will 8e a lot easier to edit. // +// Like three years later oh god: SURPISE! We went with the latter, but +// they're YAML now. Probably. Assuming that hasn't changed, yet. +// // 3. Generate the page files! They're just static index.html files, and are // what gh-pages (or wherever this is hosted) will show to clients. // Hopefully pretty minimalistic HTML, 8ut like, shrug. They'll reference @@ -28,40 +31,6 @@ // Oh yeah, like. Just run this through some relatively recent version of // node.js and you'll 8e fine. ...Within the project root. O8viously. -// HEY FUTURE ME!!!!!!!! Don't forget to implement artist pages! Those are, -// like, the coolest idea you've had yet, so DO NOT FORGET. (Remem8er, link -// from track listings, etc!) --- Thanks, past me. To futurerer me: an al8um -// listing page (a list of all the al8ums)! Make sure to sort these 8y date - -// we'll need a new field for al8ums. - -// ^^^^^^^^ DID THAT! 8ut also, artist images. Pro8a8ly stolen from the fandom -// wiki (I found half those images anywayz). - -// TRACK ART CREDITS. This is a must. - -// 2020-08-23 -// ATTENTION ALL 8*TCHES AND OTHER GENDER TRUCKERS: AS IT TURNS OUT, THIS CODE -// ****SUCKS****. I DON'T THINK ANYTHING WILL EVER REDEEM IT, 8UT THAT DOESN'T -// MEAN WE CAN'T TAKE SOME ACTION TO MAKE WRITING IT A LITTLE LESS TERRI8LE. -// We're gonna start defining STRUCTURES to make things suck less!!!!!!!! -// No classes 8ecause those are a huge pain and like, pro8a8ly 8ad performance -// or whatever -- just some standard structures that should 8e followed -// wherever reasona8le. Only one I need today is the contri8 one 8ut let's put -// any new general-purpose structures here too, ok? -// -// Contri8ution: {who, what, date, thing}. D8 and thing are the new fields. -// -// Use these wisely, which is to say all the time and instead of whatever -// terri8le new pseudo structure you're trying to invent!!!!!!!! -// -// Upd8 2021-01-03: Soooooooo we didn't actually really end up using these, -// lol? Well there's still only one anyway. Kinda ended up doing a 8ig refactor -// of all the o8ject structures today. It's not *especially* relevant 8ut feels -// worth mentioning? I'd get rid of this comment 8lock 8ut I like it too much! -// Even though I haven't actually reread it, lol. 8ut yeah, hopefully in the -// spirit of this "make things more consistent" attitude I 8rought up 8ack in -// August, stuff's lookin' 8etter than ever now. W00t! - import * as path from 'path'; import { promisify } from 'util'; import { fileURLToPath } from 'url'; @@ -75,6 +44,8 @@ import fixWS from 'fix-whitespace'; // It stands for "HTML Entities", apparently. Cursed. import he from 'he'; +import yaml from 'js-yaml'; + import { // This is the dum8est name for a function possi8le. Like, SURE, fine, may8e // the UNIX people had some valid reason to go with the weird truncated @@ -109,6 +80,8 @@ import { unlink } from 'fs/promises'; +import { inspect as nodeInspect } from 'util'; + import genThumbs from './gen-thumbs.js'; import { listingSpec, listingTargetSpec } from './listing-spec.js'; import urlSpec from './url-spec.js'; @@ -118,6 +91,18 @@ 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 Flash, { FlashAct } from './thing/flash.js'; +import Group, { GroupCategory } from './thing/group.js'; +import HomepageLayout, { + HomepageLayoutAlbumsRow, +} from './thing/homepage-layout.js'; +import NewsEntry from './thing/news-entry.js'; +import Thing from './thing/thing.js'; +import Track from './thing/track.js'; + import { fancifyFlashURL, fancifyURL, @@ -129,6 +114,7 @@ import { getAlbumStylesheet, getArtistString, getFlashGridHTML, + getFooterLocalizationLinks, getGridHTML, getRevealStringFromTags, getRevealStringFromWarnings, @@ -137,12 +123,14 @@ import { } from './misc-templates.js'; import { + color, decorateTime, logWarn, logInfo, logError, parseOptions, - progressPromiseAll + progressPromiseAll, + ENABLE_COLOR } from './util/cli.js'; import { @@ -184,11 +172,16 @@ import { import { bindOpts, - call, + filterAggregateAsync, filterEmptyLines, + mapAggregate, + mapAggregateAsync, + openAggregate, queue, + showAggregate, splitArray, unique, + withAggregate, withEntries } from './util/sugar.js'; @@ -208,13 +201,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CACHEBUST = 7; +// MAKE THESE END IN YAML const WIKI_INFO_FILE = 'wiki-info.txt'; -const HOMEPAGE_INFO_FILE = 'homepage.txt'; -const ARTIST_DATA_FILE = 'artists.txt'; -const FLASH_DATA_FILE = 'flashes.txt'; -const NEWS_DATA_FILE = 'news.txt'; -const TAG_DATA_FILE = 'tags.txt'; -const GROUP_DATA_FILE = 'groups.txt'; +const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml'; +const ARTIST_DATA_FILE = 'artists.yaml'; +const FLASH_DATA_FILE = 'flashes.yaml'; +const NEWS_DATA_FILE = 'news.yaml'; +const ART_TAG_DATA_FILE = 'tags.yaml'; +const GROUP_DATA_FILE = 'groups.yaml'; const STATIC_PAGE_DATA_FILE = 'static-pages.txt'; const DEFAULT_STRINGS_FILE = 'strings-default.json'; @@ -235,6 +229,10 @@ const STATIC_DIRECTORY = 'static'; // read from and processed to compose the majority of album and track data. const DATA_ALBUM_DIRECTORY = 'album'; +function inspect(value) { + return nodeInspect(value, {colors: ENABLE_COLOR}); +} + // Shared varia8les! These are more efficient to access than a shared varia8le // (or at least I h8pe so), and are easier to pass across functions than a // 8unch of specific arguments. @@ -274,20 +272,11 @@ function splitLines(text) { return text.split(/\r\n|\r|\n/); } -function* getSections(lines) { - // ::::) - const isSeparatorLine = line => /^-{8,}/.test(line); - yield* splitArray(lines, isSeparatorLine); -} - -function getBasicField(lines, name) { - const line = lines.find(line => line.startsWith(name + ':')); - return line && line.slice(name.length + 1).trim(); -} +function parseDimensions(string) { + if (!string) { + return null; + } -function getDimensionsField(lines, name) { - const string = getBasicField(lines, name); - if (!string) return string; const parts = string.split(/[x,* ]+/g); if (parts.length !== 2) throw new Error(`Invalid dimensions: ${string} (expected width & height)`); const nums = parts.map(part => Number(part.trim())); @@ -295,52 +284,7 @@ function getDimensionsField(lines, name) { return nums; } -function getBooleanField(lines, name) { - // The ?? oper8tor (which is just, hilariously named, lol) can 8e used to - // specify a default! - const value = getBasicField(lines, name); - switch (value) { - case 'yes': - case 'true': - return true; - case 'no': - case 'false': - return false; - default: - return null; - } -} - -function getListField(lines, name) { - let startIndex = lines.findIndex(line => line.startsWith(name + ':')); - // If callers want to default to an empty array, they should stick - // "|| []" after the call. - if (startIndex === -1) { - return null; - } - // We increment startIndex 8ecause we don't want to include the - // "heading" line (e.g. "URLs:") in the actual data. - startIndex++; - let endIndex = lines.findIndex((line, index) => index >= startIndex && !line.startsWith('- ')); - if (endIndex === -1) { - endIndex = lines.length; - } - if (endIndex === startIndex) { - // If there is no list that comes after the heading line, treat the - // heading line itself as the comma-separ8ted array value, using - // the 8asic field function to do that. (It's l8 and my 8rain is - // sleepy. Please excuse any unhelpful comments I may write, or may - // have already written, in this st8. Thanks!) - const value = getBasicField(lines, name); - return value && value.split(',').map(val => val.trim()); - } - const listLines = lines.slice(startIndex, endIndex); - return listLines.map(line => line.slice(2)); -}; - -function getContributionField(section, name) { - let contributors = getListField(section, name); - +function parseContributors(contributors) { if (!contributors) { return null; } @@ -375,27 +319,6 @@ function getContributionField(section, name) { return contributors; }; -function getMultilineField(lines, name) { - // All this code is 8asically the same as the getListText - just with a - // different line prefix (four spaces instead of a dash and a space). - let startIndex = lines.findIndex(line => line.startsWith(name + ':')); - if (startIndex === -1) { - return null; - } - startIndex++; - let endIndex = lines.findIndex((line, index) => index >= startIndex && line.length && !line.startsWith(' ')); - if (endIndex === -1) { - endIndex = lines.length; - } - // If there aren't any content lines, don't return anything! - if (endIndex === startIndex) { - return null; - } - // We also join the lines instead of returning an array. - const listLines = lines.slice(startIndex, endIndex); - return listLines.map(line => line.slice(4)).join('\n'); -}; - const replacerSpec = { 'album': { find: 'album', @@ -737,321 +660,380 @@ function transformLyrics(text, { return outLines.join('\n'); } -function getCommentaryField(lines) { - const text = getMultilineField(lines, 'Commentary'); - if (text) { - const lines = text.split('\n'); - if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) { - return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`}; - } - return text; - } else { - return null; - } -}; - -async function processAlbumDataFile(file) { - let contents; - try { - contents = await readFile(file, 'utf-8'); - } catch (error) { - // This function can return "error o8jects," which are really just - // ordinary o8jects with an error message attached. I'm not 8othering - // with error codes here or anywhere in this function; while this would - // normally 8e 8ad coding practice, it doesn't really matter here, - // 8ecause this isn't an API getting consumed 8y other services (e.g. - // translaction functions). If we return an error, the caller will just - // print the attached message in the output summary. - return {error: `Could not read ${file} (${error.code}).`}; - } - - // We're pro8a8ly supposed to, like, search for a header somewhere in the - // al8um contents, to make sure it's trying to 8e the intended structure - // and is a valid utf-8 (or at least ASCII) file. 8ut like, whatever. - // We'll just return more specific errors if it's missing necessary data - // fields. +// Use parseErrorFactory to declare different "types" of errors. By storing the +// factory itself in an accessible location, the type of error may be detected +// by comparing against its own factory property. +function parseErrorFactory(annotation) { + return function factory(data = null) { + return { + error: true, + annotation, + data, + factory + }; + }; +} - const contentLines = contents.split(/\r\n|\r|\n/); +function parseField(object, key, steps) { + let value = object[key]; - // In this line of code I defeat the purpose of using a generator in the - // first place. Sorry!!!!!!!! - const sections = Array.from(getSections(contentLines)); + for (const step of steps) { + try { + value = step(value); + } catch (error) { + throw parseField.stepError({ + stepName: step.name, + stepError: error + }); + } + } - const albumSection = sections[0]; - const album = {}; + return value; +} - album.name = getBasicField(albumSection, 'Album'); +parseField.stepError = parseErrorFactory('step failed'); - if (!album.name) { - return {error: `The file "${path.relative(dataPath, file)}" is missing the "Album" field - maybe this is a misplaced file instead of album data?`}; +function assertFieldPresent(value) { + if (value === undefined || value === null) { + throw assertFieldPresent.missingField(); + } else { + return value; } +} - album.artists = getContributionField(albumSection, 'Artists') || getContributionField(albumSection, 'Artist'); - album.wallpaperArtists = getContributionField(albumSection, 'Wallpaper Art'); - album.wallpaperStyle = getMultilineField(albumSection, 'Wallpaper Style'); - album.wallpaperFileExtension = getBasicField(albumSection, 'Wallpaper File Extension') || 'jpg'; - album.bannerArtists = getContributionField(albumSection, 'Banner Art'); - album.bannerStyle = getMultilineField(albumSection, 'Banner Style'); - album.bannerFileExtension = getBasicField(albumSection, 'Banner File Extension') || 'jpg'; - album.bannerDimensions = getDimensionsField(albumSection, 'Banner Dimensions'); - album.date = getBasicField(albumSection, 'Date'); - album.trackArtDate = getBasicField(albumSection, 'Track Art Date') || album.date; - album.coverArtDate = getBasicField(albumSection, 'Cover Art Date') || album.date; - album.dateAdded = getBasicField(albumSection, 'Date Added'); - album.coverArtists = getContributionField(albumSection, 'Cover Art'); - album.hasTrackArt = getBooleanField(albumSection, 'Has Track Art') ?? true; - album.trackCoverArtists = getContributionField(albumSection, 'Track Art'); - album.artTags = getListField(albumSection, 'Art Tags') || []; - album.commentary = getCommentaryField(albumSection); - album.urls = getListField(albumSection, 'URLs') || []; - album.groups = getListField(albumSection, 'Groups') || []; - album.directory = getBasicField(albumSection, 'Directory'); - album.isMajorRelease = getBooleanField(albumSection, 'Major Release') ?? false; - album.isListedOnHomepage = getBooleanField(albumSection, 'Listed on Homepage') ?? true; +assertFieldPresent.missingField = parseErrorFactory('missing field'); - if (album.artists && album.artists.error) { - return {error: `${album.artists.error} (in ${album.name})`}; +function assertValidDate(dateString, {optional = false} = {}) { + if (dateString && isNaN(Date.parse(dateString))) { + throw assertValidDate.invalidDate(); } + return value; +} - if (album.coverArtists && album.coverArtists.error) { - return {error: `${album.coverArtists.error} (in ${album.name})`}; - } +assertValidDate.invalidDate = parseErrorFactory('invalid date'); - if (album.commentary && album.commentary.error) { - return {error: `${album.commentary.error} (in ${album.name})`}; +function parseCommentary(text) { + if (text) { + const lines = String(text).split('\n'); + if (!lines[0].replace(/<\/b>/g, '').includes(':</i>')) { + return {error: `An entry is missing commentary citation: "${lines[0].slice(0, 40)}..."`}; + } + return text; + } else { + return null; } +} - if (album.trackCoverArtists && album.trackCoverArtists.error) { - return {error: `${album.trackCoverArtists.error} (in ${album.name})`}; - } +// General function for inputting a single document (usually loaded from YAML) +// and outputting an instance of a provided Thing subclass. +// +// makeProcessDocument is a factory function: the returned function will take a +// document and apply the configuration passed to makeProcessDocument in order +// to construct a Thing subclass. +function makeProcessDocument(thingClass, { + // Optional early step for transforming field values before providing them + // to the Thing's update() method. This is useful when the input format + // (i.e. values in the document) differ from the format the actual Thing + // expects. + // + // Each key and value are a field name (not an update() property) and a + // function which takes the value for that field and returns the value which + // will be passed on to update(). + fieldTransformations = {}, - if (!album.coverArtists) { - return {error: `The album "${album.name}" is missing the "Cover Art" field.`}; - } + // Mapping of Thing.update() source properties to field names. + // + // Note this is property -> field, not field -> property. This is a + // shorthand convenience because properties are generally typical + // camel-cased JS properties, while fields may contain whitespace and be + // more easily represented as quoted strings. + propertyFieldMapping, + + // Completely ignored fields. These won't throw an unknown field error if + // they're present in a document, but they won't be used for Thing property + // generation, either. Useful for stuff that's present in data files but not + // yet implemented as part of a Thing's data model! + ignoredFields = [] +}) { + if (!propertyFieldMapping) { + throw new Error(`Expected propertyFieldMapping to be provided`); + } + + const knownFields = Object.values(propertyFieldMapping); + + // Invert the property-field mapping, since it'll come in handy for + // assigning update() source values later. + const fieldPropertyMapping = Object.fromEntries( + (Object.entries(propertyFieldMapping) + .map(([ property, field ]) => [field, property]))); + + const decorateErrorWithName = fn => { + const nameField = propertyFieldMapping['name']; + if (!nameField) return fn; + + return document => { + try { + return fn(document); + } catch (error) { + const name = document[nameField]; + error.message = (name + ? `(name: ${inspect(name)}) ${error.message}` + : `(${color.dim(`no name found`)}) ${error.message}`); + throw error; + } + }; + }; - album.color = ( - getBasicField(albumSection, 'Color') || - getBasicField(albumSection, 'FG') - ); + return decorateErrorWithName(document => { + const documentEntries = Object.entries(document) + .filter(([ field ]) => !ignoredFields.includes(field)); - if (!album.name) { - return {error: `Expected "Album" (name) field!`}; - } + const unknownFields = documentEntries + .map(([ field ]) => field) + .filter(field => !knownFields.includes(field)); - if (!album.date) { - return {error: `Expected "Date" field! (in ${album.name})`}; - } + if (unknownFields.length) { + throw new makeProcessDocument.UnknownFieldsError(unknownFields); + } - if (!album.dateAdded) { - return {error: `Expected "Date Added" field! (in ${album.name})`}; - } + const fieldValues = {}; - if (isNaN(Date.parse(album.date))) { - return {error: `Invalid Date field: "${album.date}" (in ${album.name})`}; - } + for (const [ field, value ] of documentEntries) { + if (Object.hasOwn(fieldTransformations, field)) { + fieldValues[field] = fieldTransformations[field](value); + } else { + fieldValues[field] = value; + } + } - if (isNaN(Date.parse(album.trackArtDate))) { - return {error: `Invalid Track Art Date field: "${album.trackArtDate}" (in ${album.name})`}; - } + const sourceProperties = {}; - if (isNaN(Date.parse(album.coverArtDate))) { - return {error: `Invalid Cover Art Date field: "${album.coverArtDate}" (in ${album.name})`}; - } + for (const [ field, value ] of Object.entries(fieldValues)) { + const property = fieldPropertyMapping[field]; + sourceProperties[property] = value; + } - if (isNaN(Date.parse(album.dateAdded))) { - return {error: `Invalid Date Added field: "${album.dateAdded}" (in ${album.name})`}; - } + const thing = Reflect.construct(thingClass, []); - album.date = new Date(album.date); - album.trackArtDate = new Date(album.trackArtDate); - album.coverArtDate = new Date(album.coverArtDate); - album.dateAdded = new Date(album.dateAdded); + withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({ call }) => { + for (const [ property, value ] of Object.entries(sourceProperties)) { + call(() => (thing[property] = value)); + } + }); - if (!album.directory) { - album.directory = getKebabCase(album.name); - } + return thing; + }); +} - album.tracks = []; +makeProcessDocument.UnknownFieldsError = class UnknownFieldsError extends Error { + constructor(fields) { + super(`Unknown fields present: ${fields.join(', ')}`); + this.fields = fields; + } +}; - // will be overwritten if a group section is found! - album.trackGroups = null; +const processAlbumDocument = makeProcessDocument(Album, { + fieldTransformations: { + 'Artists': parseContributors, + 'Cover Artists': parseContributors, + 'Default Track Cover Artists': parseContributors, + 'Wallpaper Artists': parseContributors, + 'Banner Artists': parseContributors, - let group = null; - let trackIndex = 0; + 'Date': value => new Date(value), + 'Date Added': value => new Date(value), + 'Cover Art Date': value => new Date(value), + 'Default Track Cover Art Date': value => new Date(value), - for (const section of sections.slice(1)) { - // Just skip empty sections. Sometimes I paste a 8unch of dividers, - // and this lets the empty sections doing that creates (temporarily) - // exist without raising an error. - if (!section.filter(Boolean).length) { - continue; - } + 'Banner Dimensions': parseDimensions, + }, - const groupName = getBasicField(section, 'Group'); - if (groupName) { - group = { - name: groupName, - color: ( - getBasicField(section, 'Color') || - getBasicField(section, 'FG') || - album.color - ), - originalDate: getBasicField(section, 'Original Date'), - startIndex: trackIndex, - tracks: [] - }; - if (group.originalDate) { - if (isNaN(Date.parse(group.originalDate))) { - return {error: `The track group "${group.name}" has an invalid "Original Date" field: "${group.originalDate}"`}; - } - group.originalDate = new Date(group.originalDate); - group.date = group.originalDate; + propertyFieldMapping: { + name: 'Album', + + color: 'Color', + directory: 'Directory', + urls: 'URLs', + + artistContribsByRef: 'Artists', + coverArtistContribsByRef: 'Cover Artists', + trackCoverArtistContribsByRef: 'Default Track Cover Artists', + + wallpaperArtistContribsByRef: 'Wallpaper Artists', + wallpaperStyle: 'Wallpaper Style', + wallpaperFileExtension: 'Wallpaper File Extension', + + bannerArtistContribsByRef: 'Banner Artists', + bannerStyle: 'Banner Style', + bannerFileExtension: 'Banner File Extension', + bannerDimensions: 'Banner Dimensions', + + date: 'Date', + trackArtDate: 'Default Track Cover Art Date', + coverArtDate: 'Cover Art Date', + dateAddedToWiki: 'Date Added', + + hasTrackArt: 'Has Track Art', + isMajorRelease: 'Major Release', + isListedOnHomepage: 'Listed on Homepage', + + aka: 'Also Released As', + groupsByRef: 'Groups', + artTagsByRef: 'Art Tags', + commentary: 'Commentary', + } +}); + +function processAlbumEntryDocuments(documents) { + // Slightly separate meanings: tracks is the array of Track objects (and + // only Track objects); trackGroups is the array of TrackGroup objects, + // organizing (by string reference) the Track objects within the Album. + // tracks is returned for collating with the rest of wiki data; trackGroups + // is directly set on the album object. + const tracks = []; + const trackGroups = []; + + // We can't mutate an array once it's set as a property value, so prepare + // the tracks that will show up in a track list all the way before actually + // applying it. + let currentTracksByRef = null; + let currentTrackGroupDoc = null; + + function closeCurrentTrackGroup() { + if (currentTracksByRef) { + let trackGroup; + + if (currentTrackGroupDoc) { + trackGroup = processTrackGroupDocument(currentTrackGroupDoc); } else { - group.date = album.date; - } - if (album.trackGroups) { - album.trackGroups.push(group); - } else { - album.trackGroups = [group]; + trackGroup = new TrackGroup(); + trackGroup.isDefaultTrackGroup = true; } + + trackGroup.tracksByRef = currentTracksByRef; + trackGroups.push(trackGroup); + } + } + + for (const doc of documents) { + if (doc['Group']) { + closeCurrentTrackGroup(); + currentTracksByRef = []; + currentTrackGroupDoc = doc; continue; } - trackIndex++; + const track = processTrackDocument(doc); + tracks.push(track); - const track = {}; + const ref = Thing.getReference(track); + if (currentTracksByRef) { + currentTracksByRef.push(ref); + } else { + currentTracksByRef = [ref]; + } + } - track.name = getBasicField(section, 'Track'); - track.commentary = getCommentaryField(section); - track.lyrics = getMultilineField(section, 'Lyrics'); - track.originalDate = getBasicField(section, 'Original Date'); - track.coverArtDate = getBasicField(section, 'Cover Art Date') || track.originalDate || album.trackArtDate; - track.references = getListField(section, 'References') || []; - track.artists = getContributionField(section, 'Artists') || getContributionField(section, 'Artist'); - track.coverArtists = getContributionField(section, 'Track Art'); - track.artTags = getListField(section, 'Art Tags') || []; - track.contributors = getContributionField(section, 'Contributors') || []; - track.directory = getBasicField(section, 'Directory'); - track.aka = getBasicField(section, 'AKA'); + closeCurrentTrackGroup(); - if (!track.name) { - return {error: `A track section is missing the "Track" (name) field (in ${album.name}, previous: ${album.tracks[album.tracks.length - 1]?.name}).`}; - } + return {tracks, trackGroups}; +} - let durationString = getBasicField(section, 'Duration') || '0:00'; - track.duration = getDurationInSeconds(durationString); +const processTrackGroupDocument = makeProcessDocument(TrackGroup, { + fieldTransformations: { + 'Date Originally Released': value => new Date(value), + }, - if (track.contributors.error) { - return {error: `${track.contributors.error} (in ${track.name}, ${album.name})`}; - } + propertyFieldMapping: { + name: 'Group', + color: 'Color', + dateOriginallyReleased: 'Date Originally Released', + } +}); - if (track.commentary && track.commentary.error) { - return {error: `${track.commentary.error} (in ${track.name}, ${album.name})`}; - } +const processTrackDocument = makeProcessDocument(Track, { + fieldTransformations: { + 'Duration': getDurationInSeconds, - if (!track.artists) { - // If an al8um has an artist specified (usually 8ecause it's a solo - // al8um), let tracks inherit that artist. We won't display the - // "8y <artist>" string on the al8um listing. - if (album.artists) { - track.artists = album.artists; - } else { - return {error: `The track "${track.name}" is missing the "Artist" field (in ${album.name}).`}; - } - } + 'Date First Released': value => new Date(value), + 'Cover Art Date': value => new Date(value), - if (!track.coverArtists) { - if (getBasicField(section, 'Track Art') !== 'none' && album.hasTrackArt) { - if (album.trackCoverArtists) { - track.coverArtists = album.trackCoverArtists; - } else { - return {error: `The track "${track.name}" is missing the "Track Art" field (in ${album.name}).`}; - } - } - } + 'Artists': parseContributors, + 'Contributors': parseContributors, + 'Cover Artists': parseContributors, + }, - if (track.coverArtists && track.coverArtists.length && track.coverArtists[0] === 'none') { - track.coverArtists = null; - } + propertyFieldMapping: { + name: 'Track', - if (!track.directory) { - track.directory = getKebabCase(track.name); - } + directory: 'Directory', + duration: 'Duration', + urls: 'URLs', - if (track.originalDate) { - if (isNaN(Date.parse(track.originalDate))) { - return {error: `The track "${track.name}"'s has an invalid "Original Date" field: "${track.originalDate}"`}; - } - track.originalDate = new Date(track.originalDate); - track.date = new Date(track.originalDate); - } else if (group && group.originalDate) { - track.originalDate = group.originalDate; - track.date = group.originalDate; - } else { - track.date = album.date; - } + coverArtDate: 'Cover Art Date', + dateFirstReleased: 'Date First Released', + hasCoverArt: 'Has Cover Art', + hasURLs: 'Has URLs', - track.coverArtDate = new Date(track.coverArtDate); + referencedTracksByRef: 'Referenced Tracks', + artistContribsByRef: 'Artists', + contributorContribsByRef: 'Contributors', + coverArtistContribsByRef: 'Cover Artists', + artTagsByRef: 'Art Tags', + originalReleaseTrackByRef: 'Originally Released As', - const hasURLs = getBooleanField(section, 'Has URLs') ?? true; + commentary: 'Commentary', + lyrics: 'Lyrics' + }, - track.urls = hasURLs && (getListField(section, 'URLs') || []).filter(Boolean); + ignoredFields: ['Sampled Tracks'] +}); - if (hasURLs && !track.urls.length) { - return {error: `The track "${track.name}" should have at least one URL specified.`}; - } +const processArtistDocument = makeProcessDocument(Artist, { + propertyFieldMapping: { + name: 'Artist', - // 8ack-reference the al8um o8ject! This is very useful for when - // we're outputting the track pages. - track.album = album; + directory: 'Directory', + urls: 'URLs', - if (group) { - track.color = group.color; - group.tracks.push(track); - } else { - track.color = album.color; - } + aliasRefs: 'Aliases', - album.tracks.push(track); - } + contextNotes: 'Context Notes' + }, - return album; -} + ignoredFields: ['Dead URLs'] +}); -async function processArtistDataFile(file) { - let contents; - try { - contents = await readFile(file, 'utf-8'); - } catch (error) { - return {error: `Could not read ${file} (${error.code}).`}; - } +const processFlashDocument = makeProcessDocument(Flash, { + fieldTransformations: { + 'Date': value => new Date(value), - const contentLines = splitLines(contents); - const sections = Array.from(getSections(contentLines)); + 'Contributors': parseContributors, + }, - return sections.filter(s => s.filter(Boolean).length).map(section => { - const name = getBasicField(section, 'Artist'); - const urls = (getListField(section, 'URLs') || []).filter(Boolean); - const alias = getBasicField(section, 'Alias'); - const hasAvatar = getBooleanField(section, 'Has Avatar') ?? false; - const note = getMultilineField(section, 'Note'); - let directory = getBasicField(section, 'Directory'); + propertyFieldMapping: { + name: 'Flash', - if (!name) { - return {error: 'Expected "Artist" (name) field!'}; - } + directory: 'Directory', + page: 'Page', + date: 'Date', + coverArtFileExtension: 'Cover Art File Extension', - if (!directory) { - directory = getKebabCase(name); - } + featuredTracksByRef: 'Featured Tracks', + contributorContribsByRef: 'Contributors', + urls: 'URLs' + }, +}); - if (alias) { - return {name, directory, alias}; - } else { - return {name, directory, urls, note, hasAvatar}; - } - }); -} +const processFlashActDocument = makeProcessDocument(FlashAct, { + propertyFieldMapping: { + name: 'Act', + color: 'Color', + anchor: 'Anchor', + jump: 'Jump', + jumpColor: 'Jump Color' + } +}); async function processFlashDataFile(file) { let contents; @@ -1113,101 +1095,43 @@ async function processFlashDataFile(file) { }); } -async function processNewsDataFile(file) { - let contents; - try { - contents = await readFile(file, 'utf-8'); - } catch (error) { - return {error: `Could not read ${file} (${error.code}).`}; - } - - const contentLines = splitLines(contents); - const sections = Array.from(getSections(contentLines)); - - return sections.map(section => { - const name = getBasicField(section, 'Name'); - if (!name) { - return {error: 'Expected "Name" field!'}; - } - - const directory = getBasicField(section, 'Directory') || getBasicField(section, 'ID'); - if (!directory) { - return {error: 'Expected "Directory" field!'}; - } - - let body = getMultilineField(section, 'Body'); - if (!body) { - return {error: 'Expected "Body" field!'}; - } - - let date = getBasicField(section, 'Date'); - if (!date) { - return {error: 'Expected "Date" field!'}; - } - - if (isNaN(Date.parse(date))) { - return {error: `Invalid date field: "${date}"`}; - } - - date = new Date(date); - - let bodyShort = body.split('<hr class="split">')[0]; - - return { - name, - directory, - body, - bodyShort, - date - }; - }); -} +const processNewsEntryDocument = makeProcessDocument(NewsEntry, { + fieldTransformations: { + 'Date': value => new Date(value) + }, -async function processTagDataFile(file) { - let contents; - try { - contents = await readFile(file, 'utf-8'); - } catch (error) { - if (error.code === 'ENOENT') { - return []; - } else { - return {error: `Could not read ${file} (${error.code}).`}; - } + propertyFieldMapping: { + name: 'Name', + directory: 'Directory', + date: 'Date', + content: 'Content', } +}); - const contentLines = splitLines(contents); - const sections = Array.from(getSections(contentLines)); - - return sections.map(section => { - let isCW = false; - - let name = getBasicField(section, 'Tag'); - if (!name) { - name = getBasicField(section, 'CW'); - isCW = true; - if (!name) { - return {error: 'Expected "Tag" or "CW" field!'}; - } - } - - let color; - if (!isCW) { - color = getBasicField(section, 'Color'); - if (!color) { - return {error: 'Expected "Color" field!'}; - } - } +const processArtTagDocument = makeProcessDocument(ArtTag, { + propertyFieldMapping: { + name: 'Tag', + directory: 'Directory', + color: 'Color', + isContentWarning: 'Is CW' + } +}); - const directory = getKebabCase(name); +const processGroupDocument = makeProcessDocument(Group, { + propertyFieldMapping: { + name: 'Group', + directory: 'Directory', + description: 'Description', + urls: 'URLs', + } +}); - return { - name, - directory, - isCW, - color - }; - }); -} +const processGroupCategoryDocument = makeProcessDocument(GroupCategory, { + propertyFieldMapping: { + name: 'Category', + color: 'Color', + } +}); async function processGroupDataFile(file) { let contents; @@ -1378,75 +1302,61 @@ async function processWikiInfoFile(file) { }; } -async function processHomepageInfoFile(file) { - let contents; - try { - contents = await readFile(file, 'utf-8'); - } catch (error) { - return {error: `Could not read ${file} (${error.code}).`}; - } - - const contentLines = splitLines(contents); - const sections = Array.from(getSections(contentLines)); - - const [ firstSection, ...rowSections ] = sections; - - const sidebar = getMultilineField(firstSection, 'Sidebar'); +const processHomepageLayoutDocument = makeProcessDocument(HomepageLayout, { + propertyFieldMapping: { + sidebarContent: 'Sidebar Content' + }, - const validRowTypes = ['albums']; + ignoredFields: ['Homepage'] +}); - const rows = rowSections.map(section => { - const name = getBasicField(section, 'Row'); - if (!name) { - return {error: 'Expected "Row" (name) field!'}; - } +const homepageLayoutRowBaseSpec = { +}; - const color = getBasicField(section, 'Color'); +const makeProcessHomepageLayoutRowDocument = (rowClass, spec) => makeProcessDocument(rowClass, { + ...spec, - const type = getBasicField(section, 'Type'); - if (!type) { - return {error: 'Expected "Type" field!'}; - } + propertyFieldMapping: { + name: 'Row', + color: 'Color', + type: 'Type', + ...spec.propertyFieldMapping, + } +}); - if (!validRowTypes.includes(type)) { - return {error: `Expected "Type" field to be one of: ${validRowTypes.join(', ')}`}; +const homepageLayoutRowTypeProcessMapping = { + albums: makeProcessHomepageLayoutRowDocument(HomepageLayoutAlbumsRow, { + propertyFieldMapping: { + sourceGroupByRef: 'Group', + countAlbumsFromGroup: 'Count', + sourceAlbumsByRef: 'Albums', + actionLinks: 'Actions' } + }) +}; - const row = {name, color, type}; - - switch (type) { - case 'albums': { - const group = getBasicField(section, 'Group') || null; - const albums = getListField(section, 'Albums') || []; - - if (!group && !albums) { - return {error: 'Expected "Group" and/or "Albums" field!'}; - } - - let groupCount = getBasicField(section, 'Count'); - if (group && !groupCount) { - return {error: 'Expected "Count" field!'}; - } - - if (groupCount) { - if (isNaN(parseInt(groupCount))) { - return {error: `Invalid Count field: "${groupCount}"`}; - } +function processHomepageLayoutRowDocument(document) { + const type = document['Type']; - groupCount = parseInt(groupCount); - } + const match = Object.entries(homepageLayoutRowTypeProcessMapping) + .find(([ key ]) => key === type); - const actions = getListField(section, 'Actions') || []; - - return {...row, group, groupCount, albums, actions}; - } - } - }); + if (!match) { + throw new TypeError(`No processDocument function for row type ${type}!`); + } - return {sidebar, rows}; + return match[1](document); } function getDurationInSeconds(string) { + if (typeof string === 'number') { + return string; + } + + if (typeof string !== 'string') { + throw new TypeError(`Expected a string or number, got ${string}`); + } + const parts = string.split(':').map(n => parseInt(n)) if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2] @@ -1726,15 +1636,35 @@ writePage.to = ({ }) => (targetFullKey, ...args) => { const [ groupKey, subKey ] = targetFullKey.split('.'); let path = paths.subdirectoryPrefix; + + let from; + let to; + // When linking to *outside* the localized area of the site, we need to // make sure the result is correctly relative to the 8ase directory. - if (groupKey !== 'localized' && baseDirectory) { - path += urls.from('localizedWithBaseDirectory.' + pageSubKey).to(targetFullKey, ...args); + if (groupKey !== 'localized' && groupKey !== 'localizedDefaultLanguage' && baseDirectory) { + from = 'localizedWithBaseDirectory.' + pageSubKey; + to = targetFullKey; + } else if (groupKey === 'localizedDefaultLanguage' && baseDirectory) { + // Special case for specifically linking *from* a page with base + // directory *to* a page without! Used for the language switcher and + // hopefully nothing else oh god. + from = 'localizedWithBaseDirectory.' + pageSubKey; + to = 'localized.' + subKey; + } else if (groupKey === 'localizedDefaultLanguage') { + // Linking to the default, except surprise, we're already IN the default + // (no baseDirectory set). + from = 'localized.' + pageSubKey; + to = 'localized.' + subKey; } else { // If we're linking inside the localized area (or there just is no // 8ase directory), the 8ase directory doesn't matter. - path += urls.from('localized.' + pageSubKey).to(targetFullKey, ...args); + from = 'localized.' + pageSubKey; + to = targetFullKey; } + + path += urls.from(from).to(to, ...args); + return path; }; @@ -1792,8 +1722,12 @@ writePage.html = (pageFn, { footer.classes ??= []; footer.content ??= (wikiInfo.footer ? transformMultiline(wikiInfo.footer) : ''); + footer.content += '\n' + getFooterLocalizationLinks(paths.pathname, { + languages, paths, strings, to + }); + const canonical = (wikiInfo.canonicalBase - ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathanme) + ? wikiInfo.canonicalBase + (paths.pathname === '/' ? '' : paths.pathname) : ''); const collapseSidebars = (sidebarLeft.collapse !== false) && (sidebarRight.collapse !== false); @@ -1876,10 +1810,10 @@ writePage.html = (pageFn, { cur.toCurrentPage ? '' : cur.toHome ? to('localized.home') : cur.path ? to(...cur.path) : - cur.href ? call(() => { + cur.href ? (() => { logWarn`Using legacy href format nav link in ${paths.pathname}`; return cur.href; - }) : + })() : null) }; if (attributes.href === null) { @@ -2035,6 +1969,7 @@ writePage.paths = (baseDirectory, fullKey, directory = '', { const outputFile = path.join(outputDirectory, file); return { + toPath: [fullKey, directory], pathname, subdirectoryPrefix, outputDirectory, outputFile @@ -2353,6 +2288,7 @@ async function main() { logInfo`Writing all languages.`; } + /* WD.wikiInfo = await processWikiInfoFile(path.join(dataPath, WIKI_INFO_FILE)); if (WD.wikiInfo.error) { console.log(`\x1b[31;1m${WD.wikiInfo.error}\x1b[0m`); @@ -2377,23 +2313,7 @@ async function main() { } else { languages.default = defaultStrings; } - - WD.homepageInfo = await processHomepageInfoFile(path.join(dataPath, HOMEPAGE_INFO_FILE)); - - if (WD.homepageInfo.error) { - console.log(`\x1b[31;1m${WD.homepageInfo.error}\x1b[0m`); - return; - } - - { - const errors = WD.homepageInfo.rows.filter(obj => obj.error); - if (errors.length) { - for (const error of errors) { - console.log(`\x1b[31;1m${error.error}\x1b[0m`); - } - return; - } - } + */ // 8ut wait, you might say, how do we know which al8um these data files // correspond to???????? You wouldn't dare suggest we parse the actual @@ -2419,101 +2339,405 @@ async function main() { // avoiding that in our code 8ecause, again, we want to avoid assuming the // format of the returned paths here - they're only meant to 8e used for // reading as-is. - const albumDataFiles = await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY)); + const albumDataFiles = await findFiles(path.join(dataPath, DATA_ALBUM_DIRECTORY), f => path.extname(f) === '.yaml'); - // Technically, we could do the data file reading and output writing at the - // same time, 8ut that kinda makes the code messy, so I'm not 8othering - // with it. - WD.albumData = await progressPromiseAll(`Reading & processing album files.`, albumDataFiles.map(processAlbumDataFile)); + const documentModes = { + onePerFile: Symbol('Document mode: One per file'), + headerAndEntries: Symbol('Document mode: Header and entries'), + allInOne: Symbol('Document mode: All in one') + }; - { - const errors = WD.albumData.filter(obj => obj.error); - if (errors.length) { - for (const error of errors) { - console.log(`\x1b[31;1m${error.error}\x1b[0m`); + const dataSteps = [ + { + title: `Process album files`, + files: albumDataFiles, + + documentMode: documentModes.headerAndEntries, + processHeaderDocument: processAlbumDocument, + processEntryDocument(document) { + return ('Group' in document + ? processTrackGroupDocument(document) + : processTrackDocument(document)); + }, + + // processEntryDocuments: processAlbumEntryDocuments, + + save(results) { + const albumData = []; + const trackData = []; + + for (const { header: album, entries } of results) { + // We can't mutate an array once it's set as a property + // value, so prepare the tracks and track groups that will + // show up in a track list all the way before actually + // applying them. + const trackGroups = []; + let currentTracksByRef = null; + let currentTrackGroup = null; + + function closeCurrentTrackGroup() { + if (currentTracksByRef) { + let trackGroup; + + if (currentTrackGroup) { + trackGroup = currentTrackGroup; + } else { + trackGroup = new TrackGroup(); + trackGroup.name = `Default Track Group`; + trackGroup.isDefaultTrackGroup = true; + } + + trackGroup.tracksByRef = currentTracksByRef; + trackGroups.push(trackGroup); + } + } + + for (const entry of entries) { + if (entry instanceof TrackGroup) { + closeCurrentTrackGroup(); + currentTracksByRef = []; + currentTrackGroup = entry; + continue; + } + + trackData.push(entry); + + const ref = Thing.getReference(entry); + if (currentTracksByRef) { + currentTracksByRef.push(ref); + } else { + currentTracksByRef = [ref]; + } + } + + closeCurrentTrackGroup(); + + album.trackGroups = trackGroups; + albumData.push(album); + } + + sortByDate(albumData); + sortByDate(trackData); + + Object.assign(wikiData, {albumData, trackData}); } - return; - } - } + }, - sortByDate(WD.albumData); + { + title: `Process artists file`, + files: [path.join(dataPath, ARTIST_DATA_FILE)], - WD.artistData = await processArtistDataFile(path.join(dataPath, ARTIST_DATA_FILE)); - if (WD.artistData.error) { - console.log(`\x1b[31;1m${WD.artistData.error}\x1b[0m`); - return; - } + documentMode: documentModes.allInOne, + processDocument: processArtistDocument, - { - const errors = WD.artistData.filter(obj => obj.error); - if (errors.length) { - for (const error of errors) { - console.log(`\x1b[31;1m${error.error}\x1b[0m`); + save(results) { + wikiData.artistData = results; } - return; - } - } + }, + + // TODO: WD.wikiInfo.features.flashesAndGames && + { + title: `Process flashes file`, + files: [path.join(dataPath, FLASH_DATA_FILE)], + + documentMode: documentModes.allInOne, + processDocument(document) { + return ('Act' in document + ? processFlashActDocument(document) + : processFlashDocument(document)); + }, + + save(results) { + let flashAct; + let flashesByRef = []; + + if (results[0] && !(results[0] instanceof FlashAct)) { + throw new Error(`Expected an act at top of flash data file`); + } - WD.artistAliasData = WD.artistData.filter(x => x.alias); - WD.artistData = WD.artistData.filter(x => !x.alias); + for (const thing of results) { + if (thing instanceof FlashAct) { + if (flashAct) { + Object.assign(flashAct, {flashesByRef}); + } - WD.trackData = getAllTracks(WD.albumData); + flashAct = thing; + flashesByRef = []; + } else { + flashesByRef.push(Thing.getReference(thing)); + } + } - if (WD.wikiInfo.features.flashesAndGames) { - WD.flashData = await processFlashDataFile(path.join(dataPath, FLASH_DATA_FILE)); - if (WD.flashData.error) { - console.log(`\x1b[31;1m${WD.flashData.error}\x1b[0m`); - return; - } + if (flashAct) { + Object.assign(flashAct, {flashesByRef}); + } - const errors = WD.flashData.filter(obj => obj.error); - if (errors.length) { - for (const error of errors) { - console.log(`\x1b[31;1m${error.error}\x1b[0m`); + wikiData.flashData = results.filter(x => x instanceof Flash); + wikiData.flashActData = results.filter(x => x instanceof FlashAct); } - return; - } - } + }, - WD.flashActData = WD.flashData?.filter(x => x.act8r8k); - WD.flashData = WD.flashData?.filter(x => !x.act8r8k); + { + title: `Process groups file`, + files: [path.join(dataPath, GROUP_DATA_FILE)], + + documentMode: documentModes.allInOne, + processDocument(document) { + return ('Category' in document + ? processGroupCategoryDocument(document) + : processGroupDocument(document)); + }, + + save(results) { + let groupCategory; + let groupsByRef = []; + + if (results[0] && !(results[0] instanceof GroupCategory)) { + throw new Error(`Expected a category at top of group data file`); + } - WD.tagData = await processTagDataFile(path.join(dataPath, TAG_DATA_FILE)); - if (WD.tagData.error) { - console.log(`\x1b[31;1m${WD.tagData.error}\x1b[0m`); - return; + for (const thing of results) { + if (thing instanceof GroupCategory) { + if (groupCategory) { + Object.assign(groupCategory, {groupsByRef}); + } + + groupCategory = thing; + groupsByRef = []; + } else { + groupsByRef.push(Thing.getReference(thing)); + } + } + + if (groupCategory) { + Object.assign(groupCategory, {groupsByRef}); + } + + wikiData.groupData = results.filter(x => x instanceof Group); + wikiData.groupCategoryData = results.filter(x => x instanceof GroupCategory); + } + }, + + { + title: `Process homepage layout file`, + files: [path.join(dataPath, HOMEPAGE_LAYOUT_DATA_FILE)], + + documentMode: documentModes.headerAndEntries, + processHeaderDocument: processHomepageLayoutDocument, + processEntryDocument: processHomepageLayoutRowDocument, + + save(results) { + if (!results[0]) { + return; + } + + const { header: homepageLayout, entries: rows } = results[0]; + Object.assign(homepageLayout, {rows}); + Object.assign(wikiData, {homepageLayout}); + } + }, + + // TODO: WD.wikiInfo.features.news && + { + title: `Process news data file`, + files: [path.join(dataPath, NEWS_DATA_FILE)], + + documentMode: documentModes.allInOne, + processDocument: processNewsEntryDocument, + + save(results) { + sortByDate(results); + results.reverse(); + + wikiData.newsData = results; + } + }, + + { + title: `Process art tags file`, + files: [path.join(dataPath, ART_TAG_DATA_FILE)], + + documentMode: documentModes.allInOne, + processDocument: processArtTagDocument, + + save(results) { + results.sort(sortByName); + + wikiData.tagData = results; + } + }, + ]; + + const processDataAggregate = openAggregate({message: `Errors processing data files`}); + + function decorateErrorWithFile(fn) { + return (x, index, array) => { + try { + return fn(x, index, array); + } catch (error) { + error.message += ( + (error.message.includes('\n') ? '\n' : ' ') + + `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})` + ); + throw error; + } + }; } - { - const errors = WD.tagData.filter(obj => obj.error); - if (errors.length) { - for (const error of errors) { - console.log(`\x1b[31;1m${error.error}\x1b[0m`); + function decorateErrorWithIndex(fn) { + return (x, index, array) => { + try { + return fn(x, index, array); + } catch (error) { + error.message = `(${color.yellow(`#${index + 1}`)}) ${error.message}`; + throw error; } - return; } } - WD.tagData.sort(sortByName); + for (const dataStep of dataSteps) { + await processDataAggregate.nestAsync( + {message: `Errors during data step: ${dataStep.title}`}, + async ({call, callAsync, map, mapAsync, nest}) => { + const { documentMode } = dataStep; - WD.groupData = await processGroupDataFile(path.join(dataPath, GROUP_DATA_FILE)); - if (WD.groupData.error) { - console.log(`\x1b[31;1m${WD.groupData.error}\x1b[0m`); - return; + if (!(Object.values(documentModes).includes(documentMode))) { + throw new Error(`Invalid documentMode: ${documentMode.toString()}`); + } + + if (documentMode === documentModes.allInOne) { + if (dataStep.files.length !== 1) { + throw new Error(`Expected 1 file for all-in-one documentMode, not ${files.length}`); + } + + const file = dataStep.files[0]; + + const readResult = await callAsync(readFile, file); + + if (!readResult) { + return; + } + + const yamlResult = call(yaml.loadAll, readResult); + + if (!yamlResult) { + return; + } + + const { + result: processResults, + aggregate: processAggregate + } = mapAggregate( + yamlResult, + decorateErrorWithIndex(dataStep.processDocument), + {message: `Errors processing documents`} + ); + + call(processAggregate.close); + + dataStep.save(processResults); + + return; + } + + const readResults = await mapAsync( + dataStep.files, + file => (readFile(file, 'utf-8') + .then(contents => ({file, contents}))), + { + message: `Errors reading data files`, + promiseAll: array => progressPromiseAll(`Data step: ${dataStep.title} (reading data files)`, array) + }); + + const yamlResults = map( + readResults, + decorateErrorWithFile( + ({ file, contents }) => ({file, documents: yaml.loadAll(contents)})), + {message: `Errors parsing data files as valid YAML`}); + + let processResults; + + if (documentMode === documentModes.headerAndEntries) { + nest({message: `Errors processing data files as valid documents`}, ({ call, map }) => { + processResults = []; + + yamlResults.forEach(({ file, documents }) => { + const [ headerDocument, ...entryDocuments ] = documents; + + const header = call( + decorateErrorWithFile( + ({ document }) => dataStep.processHeaderDocument(document)), + {file, document: headerDocument}); + + // Don't continue processing files whose header + // document is invalid - the entire file is excempt + // from data in this case. + if (!header) { + return; + } + + const entries = map( + entryDocuments.map(document => ({file, document})), + decorateErrorWithFile( + decorateErrorWithIndex( + ({ document }) => dataStep.processEntryDocument(document))), + {message: `Errors processing entry documents`}); + + // Entries may be incomplete (i.e. any errored + // documents won't have a processed output + // represented here) - this is intentional! By + // principle, partial output is preferred over + // erroring an entire file. + processResults.push({header, entries}); + }); + }); + } + + if (documentMode === documentModes.onePerFile) { + throw new Error('TODO: onePerFile not yet implemented'); + } + + dataStep.save(processResults); + }); } { - const errors = WD.groupData.filter(obj => obj.error); - if (errors.length) { - for (const error of errors) { - console.log(`\x1b[31;1m${error.error}\x1b[0m`); - } - return; + try { + logInfo`Loaded data and processed objects:`; + logInfo` - ${wikiData.albumData.length} albums`; + logInfo` - ${wikiData.trackData.length} tracks`; + logInfo` - ${wikiData.artistData.length} artists`; + if (wikiData.flashData) + logInfo` - ${wikiData.flashData.length} flashes (${wikiData.flashActData.length} acts)`; + logInfo` - ${wikiData.groupData.length} groups (${wikiData.groupCategoryData.length} categories)`; + logInfo` - ${wikiData.tagData.length} art tags`; + if (wikiData.newsData) + logInfo` - ${wikiData.newsData.length} news entries`; + if (wikiData.homepageLayout) + logInfo` - ${1} homepage layout (${wikiData.homepageLayout.rows.length} rows)`; + } catch (error) { + console.error(`Error showing data summary:`, error); + } + + let errorless = true; + try { + processDataAggregate.close(); + } catch (error) { + showAggregate(error, {pathToFile: f => path.relative(__dirname, f)}); + logWarn`The above errors were detected while processing data files.`; + logWarn`If the remaining valid data is complete enough, the wiki will`; + logWarn`still build - but all errored data will be skipped.`; + logWarn`(Resolve errors for more complete output!)`; + errorless = false; + } + + if (errorless) { + logInfo`All data processed without any errors - nice!`; + logInfo`(This means all source files will be fully accounted for during page generation.)`; } } - WD.groupCategoryData = WD.groupData.filter(x => x.isCategory); - WD.groupData = WD.groupData.filter(x => x.isGroup); + process.exit(); WD.staticPageData = await processStaticPageDataFile(path.join(dataPath, STATIC_PAGE_DATA_FILE)); if (WD.staticPageData.error) { @@ -2531,25 +2755,6 @@ async function main() { } } - if (WD.wikiInfo.features.news) { - WD.newsData = await processNewsDataFile(path.join(dataPath, NEWS_DATA_FILE)); - if (WD.newsData.error) { - console.log(`\x1b[31;1m${WD.newsData.error}\x1b[0m`); - return; - } - - const errors = WD.newsData.filter(obj => obj.error); - if (errors.length) { - for (const error of errors) { - console.log(`\x1b[31;1m${error.error}\x1b[0m`); - } - return; - } - - sortByDate(WD.newsData); - WD.newsData.reverse(); - } - { const tagNames = new Set([...WD.trackData, ...WD.albumData].flatMap(thing => thing.artTags)); diff --git a/src/util/cli.js b/src/util/cli.js index 7f84be7c..b6335726 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -3,18 +3,47 @@ // A 8unch of these depend on process.stdout 8eing availa8le, so they won't // work within the 8rowser. +const { process } = globalThis; + +export const ENABLE_COLOR = process && ( + (process.env.CLICOLOR_FORCE && process.env.CLICOLOR_FORCE === '1') + ?? (process.env.CLICOLOR && process.env.CLICOLOR === '1' && process.stdout.hasColors && process.stdout.hasColors()) + ?? (process.stdout.hasColors ? process.stdout.hasColors() : true)); + +const C = n => (ENABLE_COLOR + ? text => `\x1b[${n}m${text}\x1b[0m` + : text => text); + +export const color = { + bright: C('1'), + dim: C('2'), + black: C('30'), + red: C('31'), + green: C('32'), + yellow: C('33'), + blue: C('34'), + magenta: C('35'), + cyan: C('36'), + white: C('37') +}; + const logColor = color => (literals, ...values) => { const w = s => process.stdout.write(s); - w(`\x1b[${color}m`); + const wc = text => { + if (ENABLE_COLOR) w(text); + }; + + wc(`\x1b[${color}m`); for (let i = 0; i < literals.length; i++) { w(literals[i]); if (values[i] !== undefined) { - w(`\x1b[1m`); + wc(`\x1b[1m`); w(String(values[i])); - w(`\x1b[0;${color}m`); + wc(`\x1b[0;${color}m`); } } - w(`\x1b[0m\n`); + wc(`\x1b[0m`); + w('\n'); }; export const logInfo = logColor(2); diff --git a/src/util/find.js b/src/util/find.js index 5f69bbec..423046b3 100644 --- a/src/util/find.js +++ b/src/util/find.js @@ -7,7 +7,7 @@ function findHelper(keys, dataProp, findFns = {}) { const byDirectory = findFns.byDirectory || matchDirectory; const byName = findFns.byName || matchName; - const keyRefRegex = new RegExp(`^((${keys.join('|')}):)?(.*)$`); + const keyRefRegex = new RegExp(`^((${keys.join('|')}):(?:\S))?(.*)$`); return (fullRef, {wikiData}) => { if (!fullRef) return null; diff --git a/src/util/sugar.js b/src/util/sugar.js index 38c8047f..219c3eec 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -6,6 +6,8 @@ // It will likely only do exactly what I want it to, and only in the cases I // decided were relevant enough to 8other handling. +import { color } from './cli.js'; + // Apparently JavaScript doesn't come with a function to split an array into // chunks! Weird. Anyway, this is an awesome place to use a generator, even // though we don't really make use of the 8enefits of generators any time we @@ -33,11 +35,6 @@ export const unique = arr => Array.from(new Set(arr)); // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice. export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj))); -// Nothin' more to it than what it says. Runs a function in-place. Provides an -// altern8tive syntax to the usual IIFEs (e.g. (() => {})()) when you want to -// open a scope and run some statements while inside an existing expression. -export const call = fn => fn(); - export function queue(array, max = 50) { if (max === 0) { return array.map(fn => fn()); @@ -133,14 +130,33 @@ export function openAggregate({ } }; + aggregate.wrapAsync = fn => (...args) => { + return fn(...args).then( + value => value, + error => { + errors.push(error); + return (typeof returnOnFail === 'function' + ? returnOnFail(...args) + : returnOnFail); + }); + }; + aggregate.call = (fn, ...args) => { return aggregate.wrap(fn)(...args); }; + aggregate.callAsync = (fn, ...args) => { + return aggregate.wrapAsync(fn)(...args); + }; + aggregate.nest = (...args) => { return aggregate.call(() => withAggregate(...args)); }; + aggregate.nestAsync = (...args) => { + return aggregate.callAsync(() => withAggregateAsync(...args)); + }; + aggregate.map = (...args) => { const parent = aggregate; const { result, aggregate: child } = mapAggregate(...args); @@ -148,6 +164,13 @@ export function openAggregate({ return result; }; + aggregate.mapAsync = async (...args) => { + const parent = aggregate; + const { result, aggregate: child } = await mapAggregateAsync(...args); + parent.call(child.close); + return result; + }; + aggregate.filter = (...args) => { const parent = aggregate; const { result, aggregate: child } = filterAggregate(...args); @@ -183,6 +206,19 @@ export function aggregateThrows(errorClass) { // use aggregate.close() to throw the error. (This aggregate may be passed to a // parent aggregate: `parent.call(aggregate.close)`!) export function mapAggregate(array, fn, aggregateOpts) { + return _mapAggregate('sync', null, array, fn, aggregateOpts); +} + +export function mapAggregateAsync(array, fn, { + promiseAll = Promise.all, + ...aggregateOpts +} = {}) { + return _mapAggregate('async', promiseAll, array, fn, aggregateOpts); +} + +// Helper function for mapAggregate which holds code common between sync and +// async versions. +export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) { const failureSymbol = Symbol(); const aggregate = openAggregate({ @@ -190,10 +226,16 @@ export function mapAggregate(array, fn, aggregateOpts) { ...aggregateOpts }); - const result = array.map(aggregate.wrap(fn)) - .filter(value => value !== failureSymbol); - - return {result, aggregate}; + if (mode === 'sync') { + const result = array.map(aggregate.wrap(fn)) + .filter(value => value !== failureSymbol); + return {result, aggregate}; + } else { + return promiseAll(array.map(aggregate.wrapAsync(fn))).then(values => { + const result = values.filter(value => value !== failureSymbol); + return {result, aggregate}; + }); + } } // Performs an ordinary array filter with the given function, collating into a @@ -204,6 +246,19 @@ export function mapAggregate(array, fn, aggregateOpts) { // // As with mapAggregate, the returned aggregate property is not yet closed. export function filterAggregate(array, fn, aggregateOpts) { + return _filterAggregate('sync', null, array, fn, aggregateOpts); +} + +export async function filterAggregateAsync(array, fn, { + promiseAll = Promise.all, + ...aggregateOpts +} = {}) { + return _filterAggregate('async', promiseAll, array, fn, aggregateOpts); +} + +// Helper function for filterAggregate which holds code common between sync and +// async versions. +function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) { const failureSymbol = Symbol(); const aggregate = openAggregate({ @@ -211,62 +266,121 @@ export function filterAggregate(array, fn, aggregateOpts) { ...aggregateOpts }); - const result = array.map(aggregate.wrap((x, ...rest) => ({ - input: x, - output: fn(x, ...rest) - }))) - .filter(value => { - // Filter out results which match the failureSymbol, i.e. errored - // inputs. - if (value === failureSymbol) return false; - - // Always keep results which match the overridden returnOnFail - // value, if provided. - if (value === aggregateOpts.returnOnFail) return true; - - // Otherwise, filter according to the returned value of the wrapped - // function. - return value.output; - }) - .map(value => { - // Then turn the results back into their corresponding input, or, if - // provided, the overridden returnOnFail value. - return (value === aggregateOpts.returnOnFail - ? value - : value.input); - }); + function filterFunction(value) { + // Filter out results which match the failureSymbol, i.e. errored + // inputs. + if (value === failureSymbol) return false; + + // Always keep results which match the overridden returnOnFail + // value, if provided. + if (value === aggregateOpts.returnOnFail) return true; + + // Otherwise, filter according to the returned value of the wrapped + // function. + return value.output; + } + + function mapFunction(value) { + // Then turn the results back into their corresponding input, or, if + // provided, the overridden returnOnFail value. + return (value === aggregateOpts.returnOnFail + ? value + : value.input); + } + + function wrapperFunction(x, ...rest) { + return { + input: x, + output: fn(x, ...rest) + }; + } - return {result, aggregate}; + if (mode === 'sync') { + const result = array + .map(aggregate.wrap((input, index, array) => { + const output = fn(input, index, array); + return {input, output}; + })) + .filter(filterFunction) + .map(mapFunction); + + return {result, aggregate}; + } else { + return promiseAll(array.map(aggregate.wrapAsync(async (input, index, array) => { + const output = await fn(input, index, array); + return {input, output}; + }))).then(values => { + const result = values + .filter(filterFunction) + .map(mapFunction); + + return {result, aggregate}; + }); + } } // Totally sugar function for opening an aggregate, running the provided // function with it, then closing the function and returning the result (if // there's no throw). export function withAggregate(aggregateOpts, fn) { + return _withAggregate('sync', aggregateOpts, fn); +} + +export function withAggregateAsync(aggregateOpts, fn) { + return _withAggregate('async', aggregateOpts, fn); +} + +export function _withAggregate(mode, aggregateOpts, fn) { if (typeof aggregateOpts === 'function') { fn = aggregateOpts; aggregateOpts = {}; } const aggregate = openAggregate(aggregateOpts); - const result = fn(aggregate); - aggregate.close(); - return result; + + if (mode === 'sync') { + const result = fn(aggregate); + aggregate.close(); + return result; + } else { + return fn(aggregate).then(result => { + aggregate.close(); + return result; + }); + } } -export function showAggregate(topError) { - const recursive = error => { - const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`; +export function showAggregate(topError, {pathToFile = p => p} = {}) { + const recursive = (error, {level}) => { + const stackLines = error.stack?.split('\n'); + const stackLine = stackLines?.find(line => + line.trim().startsWith('at') + && !line.includes('sugar') + && !line.includes('node:internal')); + const tracePart = (stackLine + ? '- ' + stackLine.trim().replace(/file:\/\/(.*\.js)/, (match, pathname) => pathToFile(pathname)) + : '(no stack trace)'); + + const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'} ${color.dim(tracePart)}`; + const bar = (level % 2 === 0 + ? '\u2502' + : color.dim('\u254e')); + const head = (level % 2 === 0 + ? '\u257f' + : color.dim('\u257f')); + if (error instanceof AggregateError) { return header + '\n' + (error.errors - .map(recursive) + .map(error => recursive(error, {level: level + 1})) .flatMap(str => str.split('\n')) - .map(line => ` | ` + line) + .map((line, i, lines) => (i === 0 + ? ` ${head} ${line}` + : ` ${bar} ${line}`)) .join('\n')); } else { return header; } }; - console.log(recursive(topError)); + console.error(recursive(topError, {level: 0})); } |