diff options
Diffstat (limited to 'src/data')
126 files changed, 5953 insertions, 3753 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index 010d967a..651a61cf 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -1,79 +1,3 @@ -// 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 {inspect as nodeInspect} from 'node:util'; import {colors, ENABLE_COLOR} from '#cli'; @@ -84,53 +8,21 @@ function inspect(value) { export default class CacheableObject { static propertyDescriptors = Symbol.for('CacheableObject.propertyDescriptors'); + static constructorFinalized = Symbol.for('CacheableObject.constructorFinalized'); + static propertyDependants = Symbol.for('CacheableObject.propertyDependants'); - #propertyUpdateValues = Object.create(null); - #propertyUpdateCacheInvalidators = Object.create(null); - - // Note the constructor doesn't take an initial data source. Due to a quirk - // of JavaScript, private members can't be accessed before the superclass's - // constructor is finished processing - so if we call the overridden - // update() function from inside this constructor, it will error when - // writing to private members. Pretty bad! - // - // That means initial data must be provided by following up with update() - // after constructing the new instance of the Thing (sub)class. - - constructor() { - this.#defineProperties(); - this.#initializeUpdatingPropertyValues(); - - if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return new Proxy(this, { - get: (obj, key) => { - if (!Object.hasOwn(obj, key)) { - if (key !== 'constructor') { - CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`); - } - } - return obj[key]; - }, - }); - } - } + static cacheValid = Symbol.for('CacheableObject.cacheValid'); + static updateValue = Symbol.for('CacheableObject.updateValues'); - #withEachPropertyDescriptor(callback) { - const {[CacheableObject.propertyDescriptors]: propertyDescriptors} = - this.constructor; + constructor({seal = true} = {}) { + this[CacheableObject.updateValue] = Object.create(null); + this[CacheableObject.cachedValue] = Object.create(null); + this[CacheableObject.cacheValid] = Object.create(null); + const propertyDescriptors = this.constructor[CacheableObject.propertyDescriptors]; for (const property of Reflect.ownKeys(propertyDescriptors)) { - callback(property, propertyDescriptors[property]); - } - } - - #initializeUpdatingPropertyValues() { - this.#withEachPropertyDescriptor((property, descriptor) => { - const {flags, update} = descriptor; - - if (!flags.update) { - return; - } + const {flags, update} = propertyDescriptors[property]; + if (!flags.update) continue; if ( typeof update === 'object' && @@ -141,188 +33,161 @@ export default class CacheableObject { } else { this[property] = null; } - }); + } + + if (seal) { + Object.seal(this); + } } - #defineProperties() { - if (!this.constructor[CacheableObject.propertyDescriptors]) { - throw new Error(`Expected constructor ${this.constructor.name} to provide CacheableObject.propertyDescriptors`); + static finalizeCacheableObjectPrototype() { + if (Object.hasOwn(this, CacheableObject.constructorFinalized)) { + throw new Error(`Constructor ${this.name} already finalized`); + } + + if (!this[CacheableObject.propertyDescriptors]) { + throw new Error(`Expected constructor ${this.name} to provide CacheableObject.propertyDescriptors`); } - this.#withEachPropertyDescriptor((property, descriptor) => { - const {flags} = descriptor; + this[CacheableObject.propertyDependants] = Object.create(null); + + const propertyDescriptors = this[CacheableObject.propertyDescriptors]; + for (const property of Reflect.ownKeys(propertyDescriptors)) { + const {flags, update, expose} = propertyDescriptors[property]; const definition = { configurable: false, enumerable: flags.expose, }; - if (flags.update) { - definition.set = this.#getUpdateObjectDefinitionSetterFunction(property); - } - - if (flags.expose) { - definition.get = this.#getExposeObjectDefinitionGetterFunction(property); - } - - Object.defineProperty(this, property, definition); - }); - - Object.seal(this); - } + if (flags.update) setSetter: { + definition.set = function(newValue) { + if (newValue === undefined) { + throw new TypeError(`Properties cannot be set to undefined`); + } - #getUpdateObjectDefinitionSetterFunction(property) { - const {update} = this.#getPropertyDescriptor(property); - const validate = update?.validate; + const oldValue = this[CacheableObject.updateValue][property]; - return (newValue) => { - const oldValue = this.#propertyUpdateValues[property]; + if (newValue === oldValue) { + return; + } - if (newValue === undefined) { - throw new TypeError(`Properties cannot be set to undefined`); - } + if (newValue !== null && update?.validate) { + try { + const result = update.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 (caughtError) { + throw new CacheableObjectPropertyValueError( + property, oldValue, newValue, {cause: caughtError}); + } + } - if (newValue === oldValue) { - return; - } + this[CacheableObject.updateValue][property] = newValue; - 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}`); + const dependants = this.constructor[CacheableObject.propertyDependants][property]; + if (dependants) { + for (const dependant of dependants) { + this[CacheableObject.cacheValid][dependant] = false; + } } - } catch (caughtError) { - throw new CacheableObjectPropertyValueError( - property, oldValue, newValue, {cause: caughtError}); - } + }; } - this.#propertyUpdateValues[property] = newValue; - this.#invalidateCachesDependentUpon(property); - }; - } - - #getPropertyDescriptor(property) { - return this.constructor[CacheableObject.propertyDescriptors][property]; - } + if (flags.expose) setGetter: { + if (flags.update && !expose?.transform) { + definition.get = function() { + return this[CacheableObject.updateValue][property]; + }; - #invalidateCachesDependentUpon(property) { - const invalidators = this.#propertyUpdateCacheInvalidators[property]; - if (!invalidators) { - return; - } + break setGetter; + } - for (const invalidate of invalidators) { - invalidate(); - } - } + if (flags.update && expose?.compute) { + throw new Error(`Updating property ${property} has compute function, should be formatted as transform`); + } - #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()); + if (!flags.update && !expose?.compute) { + throw new Error(`Exposed property ${property} does not update and is missing compute function`); } - }; - } 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); + definition.get = function() { + if (this[CacheableObject.cacheValid][property]) { + return this[CacheableObject.cachedValue][property]; + } - const compute = expose?.compute; - const transform = expose?.transform; + const dependencies = Object.create(null); + for (const key of expose.dependencies ?? []) { + switch (key) { + case 'this': + dependencies.this = this; + break; - 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`); - } + case 'thisProperty': + dependencies.thisProperty = property; + break; - let getAllDependencies; + default: + dependencies[key] = this[CacheableObject.updateValue][key]; + break; + } + } - if (expose.dependencies?.length > 0) { - const dependencyKeys = expose.dependencies.slice(); - const shouldReflectObject = dependencyKeys.includes('this'); - const shouldReflectProperty = dependencyKeys.includes('thisProperty'); + const value = + (flags.update + ? expose.transform(this[CacheableObject.updateValue][property], dependencies) + : expose.compute(dependencies)); - getAllDependencies = () => { - const dependencies = Object.create(null); + this[CacheableObject.cachedValue][property] = value; + this[CacheableObject.cacheValid][property] = true; - for (const key of dependencyKeys) { - dependencies[key] = this.#propertyUpdateValues[key]; - } + return value; + }; + } + + if (flags.expose) recordAsDependant: { + const dependantsMap = this[CacheableObject.propertyDependants]; - if (shouldReflectObject) { - dependencies.this = this; + if (flags.update && expose?.transform) { + if (dependantsMap[property]) { + dependantsMap[property].push(property); + } else { + dependantsMap[property] = [property]; + } } - if (shouldReflectProperty) { - dependencies.thisProperty = property; + for (const dependency of expose?.dependencies ?? []) { + switch (dependency) { + case 'this': + case 'thisProperty': + continue; + + default: { + if (dependantsMap[dependency]) { + dependantsMap[dependency].push(property); + } else { + dependantsMap[dependency] = [property]; + } + } + } } + } - return dependencies; - }; - } else { - const dependencies = Object.create(null); - Object.freeze(dependencies); - getAllDependencies = () => dependencies; + Object.defineProperty(this.prototype, property, definition); } - if (flags.update) { - return () => transform(this.#propertyUpdateValues[property], getAllDependencies()); - } else { - return () => compute(getAllDependencies()); - } + this[CacheableObject.constructorFinalized] = true; } - #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]; - } - } + static getPropertyDescriptor(property) { + return this[CacheableObject.propertyDescriptors][property]; + } - return () => { - if (!valid) { - valid = true; - return false; - } else { - return true; - } - }; + static hasPropertyDescriptor(property) { + return Object.hasOwn(this[CacheableObject.propertyDescriptors], property); } static cacheAllExposedProperties(obj) { @@ -349,30 +214,12 @@ export default class CacheableObject { } } - static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false; - static _invalidAccesses = new Set(); - - static showInvalidAccesses() { - if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return; - } - - if (!this._invalidAccesses.size) { - return; - } - - console.log(`${this._invalidAccesses.size} unique invalid accesses:`); - for (const line of this._invalidAccesses) { - console.log(` - ${line}`); - } - } - static getUpdateValue(object, key) { - if (!Object.hasOwn(object, key)) { + if (!object.constructor.hasPropertyDescriptor(key)) { return undefined; } - return object.#propertyUpdateValues[key] ?? null; + return object[CacheableObject.updateValue][key] ?? null; } static clone(object) { @@ -384,7 +231,7 @@ export default class CacheableObject { } static copyUpdateValuesOnto(source, target) { - Object.assign(target, source.#propertyUpdateValues); + Object.assign(target, source[CacheableObject.updateValue]); } } @@ -392,8 +239,22 @@ export class CacheableObjectPropertyValueError extends Error { [Symbol.for('hsmusic.aggregate.translucent')] = true; constructor(property, oldValue, newValue, options) { + let inspectOldValue, inspectNewValue; + + try { + inspectOldValue = inspect(oldValue); + } catch { + inspectOldValue = colors.red(`(couldn't inspect)`); + } + + try { + inspectNewValue = inspect(newValue); + } catch { + inspectNewValue = colors.red(`(couldn't inspect)`); + } + super( - `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`, + `Error setting ${colors.green(property)} (${inspectOldValue} -> ${inspectNewValue})`, options); this.property = property; diff --git a/src/data/checks.js b/src/data/checks.js index 8f9f0305..3fcb6d3b 100644 --- a/src/data/checks.js +++ b/src/data/checks.js @@ -4,12 +4,11 @@ import {inspect as nodeInspect} from 'node:util'; import {colors, ENABLE_COLOR} from '#cli'; import CacheableObject from '#cacheable-object'; -import {replacerSpec, parseInput} from '#replacer'; +import {replacerSpec, parseContentNodes} from '#replacer'; import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline} from '#sugar'; import Thing from '#thing'; import thingConstructors from '#things'; -import {combineWikiDataArrays, commentaryRegexCaseSensitive} from '#wiki-data'; import { annotateErrorWithIndex, @@ -50,7 +49,7 @@ export function reportDirectoryErrors(wikiData, { if (!thingData) continue; for (const thing of thingData) { - if (findSpec.include && !findSpec.include(thing)) { + if (findSpec.include && !findSpec.include(thing, thingConstructors)) { continue; } @@ -185,18 +184,34 @@ export function filterReferenceErrors(wikiData, { groups: 'group', artTags: '_artTag', referencedArtworks: '_artwork', - commentary: '_commentary', + commentary: '_content', + creditingSources: '_content', + }], + + ['artTagData', { + directDescendantArtTags: 'artTag', + }], + + ['artworkData', { + referencedArtworks: '_artwork', }], ['flashData', { - commentary: '_commentary', + commentary: '_content', + creditingSources: '_content', }], ['groupCategoryData', { groups: 'group', }], - ['homepageLayout.rows', { + ['homepageLayout.sections.rows', { + _include: row => row.type === 'album carousel', + albums: 'album', + }], + + ['homepageLayout.sections.rows', { + _include: row => row.type === 'album grid', sourceGroup: '_homepageSourceGroup', sourceAlbums: 'album', }], @@ -210,20 +225,23 @@ export function filterReferenceErrors(wikiData, { flashes: 'flash', }], - ['groupData', { - serieses: '_serieses', + ['seriesData', { + albums: 'album', }], ['trackData', { artistContribs: '_contrib', contributorContribs: '_contrib', coverArtistContribs: '_contrib', - referencedTracks: '_trackNotRerelease', - sampledTracks: '_trackNotRerelease', + referencedTracks: '_trackMainReleasesOnly', + sampledTracks: '_trackMainReleasesOnly', artTags: '_artTag', referencedArtworks: '_artwork', - originalReleaseTrack: '_trackNotRerelease', - commentary: '_commentary', + mainReleaseTrack: '_trackMainReleasesOnly', + commentary: '_content', + creditingSources: '_content', + referencingSources: '_content', + lyrics: '_content', }], ['wikiInfo', { @@ -237,21 +255,33 @@ export function filterReferenceErrors(wikiData, { const aggregate = openAggregate({message: `Errors validating between-thing references in data`}); for (const [thingDataProp, propSpec] of referenceSpec) { const thingData = getNestedProp(wikiData, thingDataProp); - const things = Array.isArray(thingData) ? thingData : [thingData]; + const things = + (Array.isArray(thingData) + ? thingData.flat(Infinity) + : [thingData]); + aggregate.nest({message: `Reference errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => { for (const thing of things) { + if (propSpec._include && !propSpec._include(thing)) { + continue; + } + nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => { for (const [property, findFnKey] of Object.entries(propSpec)) { + if (property === '_include') { + continue; + } + let value = CacheableObject.getUpdateValue(thing, property); let writeProperty = true; switch (findFnKey) { - case '_commentary': + case '_content': if (value) { value = - Array.from(value.matchAll(commentaryRegexCaseSensitive)) - .map(({groups}) => groups.artistReferences) - .map(text => text.split(',').map(text => text.trim())); + value.map(entry => + CacheableObject.getUpdateValue(entry, 'artists') ?? + []); } writeProperty = false; @@ -265,15 +295,6 @@ export function filterReferenceErrors(wikiData, { // need writing, humm...) writeProperty = false; break; - - case '_serieses': - if (value) { - // Doesn't report on which series has the error, but... - value = value.flatMap(series => series.albums); - } - - writeProperty = false; - break; } if (value === undefined) { @@ -291,15 +312,12 @@ export function filterReferenceErrors(wikiData, { case '_artwork': { const mixed = find.mixed({ - album: find.albumWithArtwork, - track: find.trackWithArtwork, + album: find.albumPrimaryArtwork, + track: find.trackPrimaryArtwork, }); const data = - combineWikiDataArrays([ - wikiData.albumData, - wikiData.trackData, - ]); + wikiData.artworkData; findFn = ref => mixed(ref.reference, data, {mode: 'error'}); @@ -310,7 +328,7 @@ export function filterReferenceErrors(wikiData, { findFn = boundFind.artTag; break; - case '_commentary': + case '_content': findFn = findArtistOrAlias; break; @@ -328,37 +346,32 @@ export function filterReferenceErrors(wikiData, { }; break; - case '_serieses': - findFn = boundFind.album; - break; - case '_trackArtwork': findFn = ref => boundFind.track(ref.reference); break; - case '_trackNotRerelease': + case '_trackMainReleasesOnly': findFn = trackRef => { const track = boundFind.track(trackRef); - const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack'); + const mainRef = track && CacheableObject.getUpdateValue(track, 'mainReleaseTrack'); - if (originalRef) { - // It's possible for the original to not actually exist, in this case. - // It should still be reported since the 'Originally Released As' field - // was present. - const original = boundFind.track(originalRef, {mode: 'quiet'}); + if (mainRef) { + // It's possible for the main release to not actually exist, in this case. + // It should still be reported since the 'Main Release' field was present. + const main = boundFind.track(mainRef, {mode: 'quiet'}); // Prefer references by name, but only if it's unambiguous. - const originalByName = - (original - ? boundFind.track(original.name, {mode: 'quiet'}) + const mainByName = + (main + ? boundFind.track(main.name, {mode: 'quiet'}) : null); const shouldBeMessage = - (originalByName - ? colors.green(original.name) - : original - ? colors.green('track:' + original.directory) - : colors.green(originalRef)); + (mainByName + ? colors.green(main.name) + : main + ? colors.green('track:' + main.directory) + : colors.green(mainRef)); throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`); } @@ -372,7 +385,11 @@ export function filterReferenceErrors(wikiData, { break; } - const suppress = fn => conditionallySuppressError(error => { + const suppress = fn => conditionallySuppressError(_error => { + // We're not suppressing any errors at the moment. + // An old suppression is kept below for reference. + + /* if (property === 'sampledTracks') { // Suppress "didn't match anything" errors in particular, just for samples. // In hsmusic-data we have a lot of "stub" sample data which don't have @@ -385,6 +402,7 @@ export function filterReferenceErrors(wikiData, { return true; } } + */ return false; }, fn); @@ -438,7 +456,7 @@ export function filterReferenceErrors(wikiData, { } } - if (findFnKey === '_commentary') { + if (findFnKey === '_content') { filter( value, {message: errorMessage}, decorateErrorWithIndex(refs => @@ -539,16 +557,33 @@ export function reportContentTextErrors(wikiData, { description: 'description', }; + const artworkShape = { + source: 'artwork source', + originDetails: 'artwork origin details', + }; + const commentaryShape = { body: 'commentary body', - artistDisplayText: 'commentary artist display text', + artistText: 'commentary artist text', annotation: 'commentary annotation', }; + const lyricsShape = { + body: 'lyrics body', + artistText: 'lyrics artist text', + annotation: 'lyrics annotation', + }; + const contentTextSpec = [ ['albumData', { additionalFiles: additionalFileShape, commentary: commentaryShape, + creditingSources: commentaryShape, + coverArtworks: artworkShape, + }], + + ['artTagData', { + description: '_content', }], ['artistData', { @@ -557,6 +592,8 @@ export function reportContentTextErrors(wikiData, { ['flashData', { commentary: commentaryShape, + creditingSources: commentaryShape, + coverArtwork: artworkShape, }], ['flashActData', { @@ -586,10 +623,12 @@ export function reportContentTextErrors(wikiData, { ['trackData', { additionalFiles: additionalFileShape, commentary: commentaryShape, - creditSources: commentaryShape, - lyrics: '_content', + creditingSources: commentaryShape, + referencingSources: commentaryShape, + lyrics: lyricsShape, midiProjectFiles: additionalFileShape, sheetMusicFiles: additionalFileShape, + trackArtworks: artworkShape, }], ['wikiInfo', { @@ -602,7 +641,7 @@ export function reportContentTextErrors(wikiData, { const findArtistOrAlias = bindFindArtistOrAlias(boundFind); function* processContent(input) { - const nodes = parseInput(input); + const nodes = parseContentNodes(input); for (const node of nodes) { const index = node.i; @@ -659,7 +698,7 @@ export function reportContentTextErrors(wikiData, { } else if (node.type === 'external-link') { try { new URL(node.data.href); - } catch (error) { + } catch { yield { index, length, message: @@ -710,8 +749,8 @@ export function reportContentTextErrors(wikiData, { for (const thing of things) { nest({message: `Content text errors in ${inspect(thing)}`}, ({nest, push}) => { - for (const [property, shape] of Object.entries(propSpec)) { - const value = thing[property]; + for (let [property, shape] of Object.entries(propSpec)) { + let value = thing[property]; if (value === undefined) { push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`)); @@ -730,6 +769,31 @@ export function reportContentTextErrors(wikiData, { const topMessage = `Content text errors` + fieldPropertyMessage; + const checkShapeEntries = (entry, callProcessContentOpts) => { + for (const [key, annotation] of Object.entries(shape)) { + const value = entry[key]; + + // TODO: This should be an undefined/null check, like above, + // but it's not, because sometimes the stuff we're checking + // here isn't actually coded as a Thing - so the properties + // might really be undefined instead of null. Terrifying and + // awful. And most of all, citation needed. + if (!value) continue; + + callProcessContent({ + ...callProcessContentOpts, + + // TODO: `nest` isn't provided by `callProcessContentOpts` + //`but `push` is - this is to match the old code, but + // what's the deal here? + nest, + + value, + message: `Error in ${colors.green(annotation)}`, + }); + } + }; + if (shape === '_content') { callProcessContent({ nest, @@ -737,26 +801,18 @@ export function reportContentTextErrors(wikiData, { value, message: topMessage, }); - } else { + } else if (Array.isArray(value)) { nest({message: topMessage}, ({push}) => { for (const [index, entry] of value.entries()) { - for (const [key, annotation] of Object.entries(shape)) { - const value = entry[key]; - - // TODO: Should this check undefined/null similar to above? - if (!value) continue; - - callProcessContent({ - nest, - push, - value, - message: `Error in ${colors.green(annotation)}`, - annotateError: error => - annotateErrorWithIndex(error, index), - }); - } + checkShapeEntries(entry, { + push, + annotateError: error => + annotateErrorWithIndex(error, index), + }); } }); + } else { + checkShapeEntries(value, {push}); } } }); @@ -765,3 +821,49 @@ export function reportContentTextErrors(wikiData, { } }); } + +export function reportOrphanedArtworks(wikiData) { + const aggregate = + openAggregate({message: `Artwork objects are orphaned`}); + + const assess = ({ + message, + filterThing, + filterContribs, + link, + }) => { + aggregate.nest({message: `Orphaned ${message}`}, ({push}) => { + const ostensibleArtworks = + wikiData.artworkData + .filter(artwork => + artwork.thing instanceof filterThing && + artwork.artistContribsFromThingProperty === filterContribs); + + const orphanedArtworks = + ostensibleArtworks + .filter(artwork => !artwork.thing[link].includes(artwork)); + + for (const artwork of orphanedArtworks) { + push(new Error(`Orphaned: ${inspect(artwork)}`)); + } + }); + }; + + const {Album, Track} = thingConstructors; + + assess({ + message: `album cover artworks`, + filterThing: Album, + filterContribs: 'coverArtistContribs', + link: 'coverArtworks', + }); + + assess({ + message: `track artworks`, + filterThing: Track, + filterContribs: 'coverArtistContribs', + link: 'trackArtworks', + }); + + aggregate.close(); +} diff --git a/src/data/composite/control-flow/flipFilter.js b/src/data/composite/control-flow/flipFilter.js new file mode 100644 index 00000000..995bacad --- /dev/null +++ b/src/data/composite/control-flow/flipFilter.js @@ -0,0 +1,36 @@ +// Flips a filter, so that each true item becomes false, and vice versa. +// Overwrites the provided dependency. +// +// See also: +// - withAvailabilityFilter + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `flipFilter`, + + inputs: { + filter: input({type: 'array'}), + }, + + outputs: ({ + [input.staticDependency('filter')]: filterDependency, + }) => [filterDependency ?? '#flippedFilter'], + + steps: () => [ + { + dependencies: [ + input('filter'), + input.staticDependency('filter'), + ], + + compute: (continuation, { + [input('filter')]: filter, + [input.staticDependency('filter')]: filterDependency, + }) => continuation({ + [filterDependency ?? '#flippedFilter']: + filter.map(item => !item), + }), + }, + ], +}); diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js index 7e137a14..778dc66b 100644 --- a/src/data/composite/control-flow/index.js +++ b/src/data/composite/control-flow/index.js @@ -10,6 +10,7 @@ export {default as exposeDependency} from './exposeDependency.js'; export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinue.js'; export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js'; export {default as exposeWhetherDependencyAvailable} from './exposeWhetherDependencyAvailable.js'; +export {default as flipFilter} from './flipFilter.js'; export {default as raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js'; export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js'; export {default as withAvailabilityFilter} from './withAvailabilityFilter.js'; diff --git a/src/data/composite/control-flow/withAvailabilityFilter.js b/src/data/composite/control-flow/withAvailabilityFilter.js index cfea998e..fd93af71 100644 --- a/src/data/composite/control-flow/withAvailabilityFilter.js +++ b/src/data/composite/control-flow/withAvailabilityFilter.js @@ -4,6 +4,7 @@ // Accepts the same mode options as withResultOfAvailabilityCheck. // // See also: +// - flipFilter // - withFilteredList // - withResultOfAvailabilityCheck // diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js index 46a3dc81..05b59445 100644 --- a/src/data/composite/data/index.js +++ b/src/data/composite/data/index.js @@ -20,6 +20,7 @@ export {default as withMappedList} from './withMappedList.js'; export {default as withSortedList} from './withSortedList.js'; export {default as withStretchedList} from './withStretchedList.js'; +export {default as withLengthOfList} from './withLengthOfList.js'; export {default as withPropertyFromList} from './withPropertyFromList.js'; export {default as withPropertiesFromList} from './withPropertiesFromList.js'; diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js index 44c1661d..15ee3373 100644 --- a/src/data/composite/data/withFilteredList.js +++ b/src/data/composite/data/withFilteredList.js @@ -2,9 +2,6 @@ // corresponding items in a list. Items which correspond to a truthy value // are kept, and the rest are excluded from the output list. // -// If the flip option is set, only items corresponding with a *falsy* value in -// the filter are kept. -// // TODO: There should be two outputs - one for the items included according to // the filter, and one for the items excluded. // @@ -22,28 +19,19 @@ export default templateCompositeFrom({ inputs: { list: input({type: 'array'}), filter: input({type: 'array'}), - - flip: input({ - type: 'boolean', - defaultValue: false, - }), }, outputs: ['#filteredList'], steps: () => [ { - dependencies: [input('list'), input('filter'), input('flip')], + dependencies: [input('list'), input('filter')], compute: (continuation, { [input('list')]: list, [input('filter')]: filter, - [input('flip')]: flip, }) => continuation({ '#filteredList': - list.filter((_item, index) => - (flip - ? !filter[index] - : filter[index])), + list.filter((_item, index) => filter[index]), }), }, ], diff --git a/src/data/composite/data/withLengthOfList.js b/src/data/composite/data/withLengthOfList.js new file mode 100644 index 00000000..e67aa887 --- /dev/null +++ b/src/data/composite/data/withLengthOfList.js @@ -0,0 +1,54 @@ +import {input, templateCompositeFrom} from '#composite'; + +function getOutputName({ + [input.staticDependency('list')]: list, +}) { + if (list && list.startsWith('#')) { + return `${list}.length`; + } else if (list) { + return `#${list}.length`; + } else { + return '#length'; + } +} + +export default templateCompositeFrom({ + annotation: `withMappedList`, + + inputs: { + list: input({type: 'array'}), + }, + + outputs: inputs => [getOutputName(inputs)], + + steps: () => [ + { + dependencies: [input.staticDependency('list')], + compute: (continuation, inputs) => + continuation({'#output': getOutputName(inputs)}), + }, + + { + dependencies: [input('list')], + compute: (continuation, { + [input('list')]: list, + }) => continuation({ + ['#value']: + (list === null + ? null + : list.length), + }), + }, + + { + dependencies: ['#output', '#value'], + + compute: (continuation, { + ['#output']: output, + ['#value']: value, + }) => continuation({ + [output]: value, + }), + }, + ], +}); diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js index 0bc63a92..cd32058e 100644 --- a/src/data/composite/data/withMappedList.js +++ b/src/data/composite/data/withMappedList.js @@ -1,12 +1,16 @@ // Applies a map function to each item in a list, just like a normal JavaScript // map. // +// Pass a filter (e.g. from withAvailabilityFilter) to process only items +// kept by the filter. Other items will be left as-is. +// // See also: // - withFilteredList // - withSortedList // import {input, templateCompositeFrom} from '#composite'; +import {stitchArrays} from '#sugar'; export default templateCompositeFrom({ annotation: `withMappedList`, @@ -14,19 +18,31 @@ export default templateCompositeFrom({ inputs: { list: input({type: 'array'}), map: input({type: 'function'}), + + filter: input({ + type: 'array', + defaultValue: null, + }), }, outputs: ['#mappedList'], steps: () => [ { - dependencies: [input('list'), input('map')], + dependencies: [input('list'), input('map'), input('filter')], compute: (continuation, { [input('list')]: list, [input('map')]: mapFn, + [input('filter')]: filter, }) => continuation({ ['#mappedList']: - list.map(mapFn), + stitchArrays({ + item: list, + keep: filter ?? Array.from(list, () => true), + }).map(({item, keep}, index) => + (keep + ? mapFn(item, index, list) + : item)), }), }, ], diff --git a/src/data/composite/data/withNearbyItemFromList.js b/src/data/composite/data/withNearbyItemFromList.js index 83a8cc21..5e165219 100644 --- a/src/data/composite/data/withNearbyItemFromList.js +++ b/src/data/composite/data/withNearbyItemFromList.js @@ -9,6 +9,10 @@ // - If the 'valuePastEdge' input is provided, that value will be output // instead of null. // +// - If the 'filter' input is provided, corresponding items will be skipped, +// and only (repeating `offset`) the next included in the filter will be +// returned. +// // Both the list and item must be provided. // // See also: @@ -16,7 +20,6 @@ // import {input, templateCompositeFrom} from '#composite'; -import {atOffset} from '#sugar'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; @@ -28,9 +31,12 @@ export default templateCompositeFrom({ inputs: { list: input({acceptsNull: false, type: 'array'}), item: input({acceptsNull: false}), - offset: input({type: 'number'}), + wrap: input({type: 'boolean', defaultValue: false}), + valuePastEdge: input({defaultValue: null}), + + filter: input({defaultValue: null, type: 'array'}), }, outputs: ['#nearbyItem'], @@ -45,29 +51,55 @@ export default templateCompositeFrom({ dependency: '#index', mode: input.value('index'), - output: input.value({ - ['#nearbyItem']: - null, - }), + output: input.value({'#nearbyItem': null}), }), { dependencies: [ input('list'), input('offset'), + input('wrap'), + input('valuePastEdge'), + + input('filter'), + '#index', ], compute: (continuation, { [input('list')]: list, [input('offset')]: offset, + [input('wrap')]: wrap, + [input('valuePastEdge')]: valuePastEdge, + + [input('filter')]: filter, + ['#index']: index, - }) => continuation({ - ['#nearbyItem']: - atOffset(list, index, offset, {wrap}), - }), + }) => { + const startIndex = index; + + do { + index += offset; + + if (wrap) { + index = index % list.length; + } else if (index < 0) { + return continuation({'#nearbyItem': valuePastEdge}); + } else if (index >= list.length) { + return continuation({'#nearbyItem': valuePastEdge}); + } + + if (filter && !filter[index]) { + continue; + } + + return continuation({'#nearbyItem': list[index]}); + } while (index !== startIndex); + + return continuation({'#nearbyItem': null}); + }, }, ], }); diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js index 65ebf77b..760095c2 100644 --- a/src/data/composite/data/withPropertyFromList.js +++ b/src/data/composite/data/withPropertyFromList.js @@ -5,11 +5,15 @@ // original list are kept null here. Objects which don't have the specified // property are retained in-place as null. // +// If the `internal` input is true, this reads the CacheableObject update value +// of each object rather than its exposed value. +// // See also: // - withPropertiesFromList // - withPropertyFromObject // +import CacheableObject from '#cacheable-object'; import {input, templateCompositeFrom} from '#composite'; function getOutputName({list, property, prefix}) { @@ -26,6 +30,7 @@ export default templateCompositeFrom({ list: input({type: 'array'}), property: input({type: 'string'}), prefix: input.staticValue({type: 'string', defaultValue: null}), + internal: input({type: 'boolean', defaultValue: false}), }, outputs: ({ @@ -37,13 +42,26 @@ export default templateCompositeFrom({ steps: () => [ { - dependencies: [input('list'), input('property')], + dependencies: [ + input('list'), + input('property'), + input('internal'), + ], + compute: (continuation, { [input('list')]: list, [input('property')]: property, + [input('internal')]: internal, }) => continuation({ ['#values']: - list.map(item => item[property] ?? null), + list.map(item => + (item === null + ? null + : internal + ? CacheableObject.getUpdateValue(item, property) + ?? null + : item[property] + ?? null)), }), }, diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js index 4f240506..7b452b99 100644 --- a/src/data/composite/data/withPropertyFromObject.js +++ b/src/data/composite/data/withPropertyFromObject.js @@ -13,6 +13,21 @@ import CacheableObject from '#cacheable-object'; import {input, templateCompositeFrom} from '#composite'; +function getOutputName({ + [input.staticDependency('object')]: object, + [input.staticValue('property')]: property, +}) { + if (object && property) { + if (object.startsWith('#')) { + return `${object}.${property}`; + } else { + return `#${object}.${property}`; + } + } else { + return '#value'; + } +} + export default templateCompositeFrom({ annotation: `withPropertyFromObject`, @@ -22,15 +37,7 @@ export default templateCompositeFrom({ internal: input({type: 'boolean', defaultValue: false}), }, - outputs: ({ - [input.staticDependency('object')]: object, - [input.staticValue('property')]: property, - }) => - (object && property - ? (object.startsWith('#') - ? [`${object}.${property}`] - : [`#${object}.${property}`]) - : ['#value']), + outputs: inputs => [getOutputName(inputs)], steps: () => [ { @@ -39,17 +46,8 @@ export default templateCompositeFrom({ input.staticValue('property'), ], - compute: (continuation, { - [input.staticDependency('object')]: object, - [input.staticValue('property')]: property, - }) => continuation({ - '#output': - (object && property - ? (object.startsWith('#') - ? `${object}.${property}` - : `#${object}.${property}`) - : '#value'), - }), + compute: (continuation, inputs) => + continuation({'#output': getOutputName(inputs)}), }, { diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js index 8b5098f0..dfc6864f 100644 --- a/src/data/composite/things/album/index.js +++ b/src/data/composite/things/album/index.js @@ -1 +1,2 @@ +export {default as withHasCoverArt} from './withHasCoverArt.js'; export {default as withTracks} from './withTracks.js'; diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/things/album/withHasCoverArt.js new file mode 100644 index 00000000..fd3f2894 --- /dev/null +++ b/src/data/composite/things/album/withHasCoverArt.js @@ -0,0 +1,64 @@ +// TODO: This shouldn't be coded as an Album-specific thing, +// or even really to do with cover artworks in particular, either. + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {fillMissingListItems, withFlattenedList, withPropertyFromList} + from '#composite/data'; + +export default templateCompositeFrom({ + annotation: 'withHasCoverArt', + + outputs: ['#hasCoverArt'], + + steps: () => [ + withResultOfAvailabilityCheck({ + from: 'coverArtistContribs', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability'], + compute: (continuation, { + ['#availability']: availability, + }) => + (availability + ? continuation.raiseOutput({ + ['#hasCoverArt']: true, + }) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'coverArtworks', + mode: input.value('empty'), + output: input.value({'#hasCoverArt': false}), + }), + + withPropertyFromList({ + list: 'coverArtworks', + property: input.value('artistContribs'), + internal: input.value(true), + }), + + // Since we're getting the update value for each artwork's artistContribs, + // it may not be set at all, and in that case won't be exposing as []. + fillMissingListItems({ + list: '#coverArtworks.artistContribs', + fill: input.value([]), + }), + + withFlattenedList({ + list: '#coverArtworks.artistContribs', + }), + + withResultOfAvailabilityCheck({ + from: '#flattenedList', + mode: input.value('empty'), + }).outputs({ + '#availability': '#hasCoverArt', + }), + ], +}); diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js index 348220e7..835ee570 100644 --- a/src/data/composite/things/album/withTracks.js +++ b/src/data/composite/things/album/withTracks.js @@ -1,7 +1,6 @@ import {input, templateCompositeFrom} from '#composite'; import {withFlattenedList, withPropertyFromList} from '#composite/data'; -import {withResolvedReferenceList} from '#composite/wiki-data'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; diff --git a/src/data/composite/things/art-tag/index.js b/src/data/composite/things/art-tag/index.js new file mode 100644 index 00000000..bbd38293 --- /dev/null +++ b/src/data/composite/things/art-tag/index.js @@ -0,0 +1,2 @@ +export {default as withAllDescendantArtTags} from './withAllDescendantArtTags.js'; +export {default as withAncestorArtTagBaobabTree} from './withAncestorArtTagBaobabTree.js'; diff --git a/src/data/composite/things/art-tag/withAllDescendantArtTags.js b/src/data/composite/things/art-tag/withAllDescendantArtTags.js new file mode 100644 index 00000000..795f96cd --- /dev/null +++ b/src/data/composite/things/art-tag/withAllDescendantArtTags.js @@ -0,0 +1,44 @@ +// Gets all the art tags which descend from this one - that means its own direct +// descendants, but also all the direct and indirect desceands of each of those! +// The results aren't specially sorted, but they won't contain any duplicates +// (for example if two descendant tags both route deeper to end up including +// some of the same tags). + +import {input, templateCompositeFrom} from '#composite'; +import {unique} from '#sugar'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withResolvedReferenceList} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; + +export default templateCompositeFrom({ + annotation: `withAllDescendantArtTags`, + + outputs: ['#allDescendantArtTags'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'directDescendantArtTags', + mode: input.value('empty'), + output: input.value({'#allDescendantArtTags': []}) + }), + + withResolvedReferenceList({ + list: 'directDescendantArtTags', + find: soupyFind.input('artTag'), + }), + + { + dependencies: ['#resolvedReferenceList'], + compute: (continuation, { + ['#resolvedReferenceList']: directDescendantArtTags, + }) => continuation({ + ['#allDescendantArtTags']: + unique([ + ...directDescendantArtTags, + ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags), + ]), + }), + }, + ], +}) diff --git a/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js new file mode 100644 index 00000000..e084a42b --- /dev/null +++ b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js @@ -0,0 +1,46 @@ +// Gets all the art tags which are ancestors of this one as a "baobab tree" - +// what you'd typically think of as roots are all up in the air! Since this +// really is backwards from the way that the art tag tree is written in data, +// chances are pretty good that there will be many of the exact same "leaf" +// nodes - art tags which don't themselves have any ancestors. In the actual +// data structure, each node is a Map, with keys for each ancestor and values +// for each ancestor's own baobab (thus a branching structure, just like normal +// trees in this regard). + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withReverseReferenceList} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; + +export default templateCompositeFrom({ + annotation: `withAncestorArtTagBaobabTree`, + + outputs: ['#ancestorArtTagBaobabTree'], + + steps: () => [ + withReverseReferenceList({ + reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'), + }).outputs({ + ['#reverseReferenceList']: '#directAncestorArtTags', + }), + + raiseOutputWithoutDependency({ + dependency: '#directAncestorArtTags', + mode: input.value('empty'), + output: input.value({'#ancestorArtTagBaobabTree': new Map()}), + }), + + { + dependencies: ['#directAncestorArtTags'], + compute: (continuation, { + ['#directAncestorArtTags']: directAncestorArtTags, + }) => continuation({ + ['#ancestorArtTagBaobabTree']: + new Map( + directAncestorArtTags + .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])), + }), + }, + ], +}); diff --git a/src/data/composite/things/artist/artistTotalDuration.js b/src/data/composite/things/artist/artistTotalDuration.js index ff709f28..b8a205fe 100644 --- a/src/data/composite/things/artist/artistTotalDuration.js +++ b/src/data/composite/things/artist/artistTotalDuration.js @@ -2,8 +2,9 @@ import {input, templateCompositeFrom} from '#composite'; import {exposeDependency} from '#composite/control-flow'; import {withFilteredList, withPropertyFromList} from '#composite/data'; -import {withContributionListSums, withReverseContributionList} +import {withContributionListSums, withReverseReferenceList} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `artistTotalDuration`, @@ -11,18 +12,16 @@ export default templateCompositeFrom({ compose: false, steps: () => [ - withReverseContributionList({ - data: 'trackData', - list: input.value('artistContribs'), + withReverseReferenceList({ + reverse: soupyReverse.input('trackArtistContributionsBy'), }).outputs({ - '#reverseContributionList': '#contributionsAsArtist', + '#reverseReferenceList': '#contributionsAsArtist', }), - withReverseContributionList({ - data: 'trackData', - list: input.value('contributorContribs'), + withReverseReferenceList({ + reverse: soupyReverse.input('trackContributorContributionsBy'), }).outputs({ - '#reverseContributionList': '#contributionsAsContributor', + '#reverseReferenceList': '#contributionsAsContributor', }), { @@ -49,18 +48,18 @@ export default templateCompositeFrom({ withPropertyFromList({ list: '#allContributions.thing', - property: input.value('isOriginalRelease'), + property: input.value('isMainRelease'), }), withFilteredList({ list: '#allContributions', - filter: '#allContributions.thing.isOriginalRelease', + filter: '#allContributions.thing.isMainRelease', }).outputs({ - '#filteredList': '#originalContributions', + '#filteredList': '#mainReleaseContributions', }), withContributionListSums({ - list: '#originalContributions', + list: '#mainReleaseContributions', }), exposeDependency({ diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js new file mode 100644 index 00000000..3693c10f --- /dev/null +++ b/src/data/composite/things/artwork/index.js @@ -0,0 +1,5 @@ +export {default as withAttachedArtwork} from './withAttachedArtwork.js'; +export {default as withContainingArtworkList} from './withContainingArtworkList.js'; +export {default as withContribsFromAttachedArtwork} from './withContribsFromAttachedArtwork.js'; +export {default as withDate} from './withDate.js'; +export {default as withPropertyFromAttachedArtwork} from './withPropertyFromAttachedArtwork.js'; diff --git a/src/data/composite/things/artwork/withAttachedArtwork.js b/src/data/composite/things/artwork/withAttachedArtwork.js new file mode 100644 index 00000000..d7c0d87b --- /dev/null +++ b/src/data/composite/things/artwork/withAttachedArtwork.js @@ -0,0 +1,43 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {flipFilter, raiseOutputWithoutDependency} + from '#composite/control-flow'; +import {withNearbyItemFromList, withPropertyFromList} from '#composite/data'; + +import withContainingArtworkList from './withContainingArtworkList.js'; + +export default templateCompositeFrom({ + annotaion: `withContribsFromMainArtwork`, + + outputs: ['#attachedArtwork'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'attachAbove', + mode: input.value('falsy'), + output: input.value({'#attachedArtwork': null}), + }), + + withContainingArtworkList(), + + withPropertyFromList({ + list: '#containingArtworkList', + property: input.value('attachAbove'), + }), + + flipFilter({ + filter: '#containingArtworkList.attachAbove', + }).outputs({ + '#containingArtworkList.attachAbove': '#filterNotAttached', + }), + + withNearbyItemFromList({ + list: '#containingArtworkList', + item: input.myself(), + offset: input.value(-1), + filter: '#filterNotAttached', + }).outputs({ + '#nearbyItem': '#attachedArtwork', + }), + ], +}); diff --git a/src/data/composite/things/artwork/withContainingArtworkList.js b/src/data/composite/things/artwork/withContainingArtworkList.js new file mode 100644 index 00000000..9c928ffd --- /dev/null +++ b/src/data/composite/things/artwork/withContainingArtworkList.js @@ -0,0 +1,46 @@ +// Gets the list of artworks which contains this one, which is functionally +// equivalent to `this.thing[this.thingProperty]`. If the exposed value is not +// a list at all (i.e. the property holds a single artwork), this composition +// outputs null. + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withContainingArtworkList`, + + outputs: ['#containingArtworkList'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'thing', + output: input.value({'#containingArtworkList': null}), + }), + + raiseOutputWithoutDependency({ + dependency: 'thingProperty', + output: input.value({'#containingArtworkList': null}), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'thingProperty', + }).outputs({ + '#value': '#containingValue', + }), + + { + dependencies: ['#containingValue'], + compute: (continuation, { + ['#containingValue']: containingValue, + }) => continuation({ + ['#containingArtworkList']: + (Array.isArray(containingValue) + ? containingValue + : null), + }), + }, + ], +}); diff --git a/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js new file mode 100644 index 00000000..e9425c95 --- /dev/null +++ b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js @@ -0,0 +1,27 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withRecontextualizedContributionList} from '#composite/wiki-data'; + +import withPropertyFromAttachedArtwork from './withPropertyFromAttachedArtwork.js'; + +export default templateCompositeFrom({ + annotaion: `withContribsFromAttachedArtwork`, + + outputs: ['#attachedArtwork.artistContribs'], + + steps: () => [ + withPropertyFromAttachedArtwork({ + property: input.value('artistContribs'), + }), + + raiseOutputWithoutDependency({ + dependency: '#attachedArtwork.artistContribs', + output: input.value({'#attachedArtwork.artistContribs': null}), + }), + + withRecontextualizedContributionList({ + list: '#attachedArtwork.artistContribs', + }), + ], +}); diff --git a/src/data/composite/things/artwork/withDate.js b/src/data/composite/things/artwork/withDate.js new file mode 100644 index 00000000..5e05b814 --- /dev/null +++ b/src/data/composite/things/artwork/withDate.js @@ -0,0 +1,41 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withDate`, + + inputs: { + from: input({ + defaultDependency: 'date', + acceptsNull: true, + }), + }, + + outputs: ['#date'], + + steps: () => [ + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: date, + }) => + (date + ? continuation.raiseOutput({'#date': date}) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'dateFromThingProperty', + output: input.value({'#date': null}), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'dateFromThingProperty', + }).outputs({ + ['#value']: '#date', + }), + ], +}) diff --git a/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js new file mode 100644 index 00000000..a2f954b9 --- /dev/null +++ b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js @@ -0,0 +1,65 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {withResultOfAvailabilityCheck} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +import withAttachedArtwork from './withAttachedArtwork.js'; + +function getOutputName({ + [input.staticValue('property')]: property, +}) { + if (property) { + return `#attachedArtwork.${property}`; + } else { + return '#value'; + } +} + +export default templateCompositeFrom({ + annotation: `withPropertyFromAttachedArtwork`, + + inputs: { + property: input({type: 'string'}), + }, + + outputs: inputs => [getOutputName(inputs)], + + steps: () => [ + { + dependencies: [input.staticValue('property')], + compute: (continuation, inputs) => + continuation({'#output': getOutputName(inputs)}), + }, + + withAttachedArtwork(), + + withResultOfAvailabilityCheck({ + from: '#attachedArtwork', + }), + + { + dependencies: ['#availability', '#output'], + compute: (continuation, { + ['#availability']: availability, + ['#output']: output, + }) => + (availability + ? continuation() + : continuation.raiseOutput({[output]: null})), + }, + + withPropertyFromObject({ + object: '#attachedArtwork', + property: input('property'), + }), + + { + dependencies: ['#value', '#output'], + compute: (continuation, { + ['#value']: value, + ['#output']: output, + }) => + continuation.raiseOutput({[output]: value}), + }, + ], +}); diff --git a/src/data/composite/things/content/contentArtists.js b/src/data/composite/things/content/contentArtists.js new file mode 100644 index 00000000..8d5db5a5 --- /dev/null +++ b/src/data/composite/things/content/contentArtists.js @@ -0,0 +1,40 @@ +import {input, templateCompositeFrom} from '#composite'; +import {validateReferenceList} from '#validators'; + +import {exitWithoutDependency, exposeDependency} + from '#composite/control-flow'; +import {withResolvedReferenceList} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; + +import withExpressedOrImplicitArtistReferences + from './helpers/withExpressedOrImplicitArtistReferences.js'; + +export default templateCompositeFrom({ + annotation: `contentArtists`, + + compose: false, + + update: { + validate: validateReferenceList('artist'), + }, + + steps: () => [ + withExpressedOrImplicitArtistReferences({ + from: input.updateValue(), + }), + + exitWithoutDependency({ + dependency: '#artistReferences', + value: input.value([]), + }), + + withResolvedReferenceList({ + list: '#artistReferences', + find: soupyFind.input('artist'), + }), + + exposeDependency({ + dependency: '#resolvedReferenceList', + }), + ], +}); diff --git a/src/data/composite/things/content/hasAnnotationPart.js b/src/data/composite/things/content/hasAnnotationPart.js new file mode 100644 index 00000000..83d175e3 --- /dev/null +++ b/src/data/composite/things/content/hasAnnotationPart.js @@ -0,0 +1,25 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {exposeDependency} from '#composite/control-flow'; + +import withHasAnnotationPart from './withHasAnnotationPart.js'; + +export default templateCompositeFrom({ + annotation: `hasAnnotationPart`, + + compose: false, + + inputs: { + part: input({type: 'string'}), + }, + + steps: () => [ + withHasAnnotationPart({ + part: input('part'), + }), + + exposeDependency({ + dependency: '#hasAnnotationPart', + }), + ], +}); diff --git a/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js new file mode 100644 index 00000000..62799d43 --- /dev/null +++ b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js @@ -0,0 +1,60 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withFilteredList, withMappedList} from '#composite/data'; +import {withContentNodes} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withExpressedOrImplicitArtistReferences`, + + inputs: { + from: input({type: 'array', acceptsNull: true}), + }, + + outputs: ['#artistReferences'], + + steps: () => [ + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: expressedArtistReferences, + }) => + (expressedArtistReferences + ? continuation.raiseOutput({'#artistReferences': expressedArtistReferences}) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'artistText', + output: input.value({'#artistReferences': null}), + }), + + withContentNodes({ + from: 'artistText', + }), + + withMappedList({ + list: '#contentNodes', + map: input.value(node => + node.type === 'tag' && + node.data.replacerKey?.data === 'artist'), + }).outputs({ + '#mappedList': '#artistTagFilter', + }), + + withFilteredList({ + list: '#contentNodes', + filter: '#artistTagFilter', + }).outputs({ + '#filteredList': '#artistTags', + }), + + withMappedList({ + list: '#artistTags', + map: input.value(node => + node.data.replacerValue[0].data), + }).outputs({ + '#mappedList': '#artistReferences', + }), + ], +}); diff --git a/src/data/composite/things/content/index.js b/src/data/composite/things/content/index.js new file mode 100644 index 00000000..4176337d --- /dev/null +++ b/src/data/composite/things/content/index.js @@ -0,0 +1,7 @@ +export {default as contentArtists} from './contentArtists.js'; +export {default as hasAnnotationPart} from './hasAnnotationPart.js'; +export {default as withAnnotationParts} from './withAnnotationParts.js'; +export {default as withHasAnnotationPart} from './withHasAnnotationPart.js'; +export {default as withSourceText} from './withSourceText.js'; +export {default as withSourceURLs} from './withSourceURLs.js'; +export {default as withWebArchiveDate} from './withWebArchiveDate.js'; diff --git a/src/data/composite/things/content/withAnnotationParts.js b/src/data/composite/things/content/withAnnotationParts.js new file mode 100644 index 00000000..6311b57a --- /dev/null +++ b/src/data/composite/things/content/withAnnotationParts.js @@ -0,0 +1,93 @@ +import {input, templateCompositeFrom} from '#composite'; +import {transposeArrays} from '#sugar'; +import {is} from '#validators'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromList} from '#composite/data'; +import {splitContentNodesAround, withContentNodes} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withAnnotationParts`, + + inputs: { + mode: input({ + validate: is('strings', 'nodes'), + }), + }, + + outputs: ['#annotationParts'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'annotation', + output: input.value({'#annotationParts': []}), + }), + + withContentNodes({ + from: 'annotation', + }), + + splitContentNodesAround({ + nodes: '#contentNodes', + around: input.value(/, */g), + }), + + { + dependencies: ['#contentNodeLists', input('mode')], + compute: (continuation, { + ['#contentNodeLists']: nodeLists, + [input('mode')]: mode, + }) => + (mode === 'nodes' + ? continuation.raiseOutput({'#annotationParts': nodeLists}) + : continuation()), + }, + + { + dependencies: ['#contentNodeLists'], + + compute: (continuation, { + ['#contentNodeLists']: nodeLists, + }) => continuation({ + ['#firstNodes']: + nodeLists.map(list => list.at(0)), + + ['#lastNodes']: + nodeLists.map(list => list.at(-1)), + }), + }, + + withPropertyFromList({ + list: '#firstNodes', + property: input.value('i'), + }).outputs({ + '#firstNodes.i': '#startIndices', + }), + + withPropertyFromList({ + list: '#lastNodes', + property: input.value('iEnd'), + }).outputs({ + '#lastNodes.iEnd': '#endIndices', + }), + + { + dependencies: [ + 'annotation', + '#startIndices', + '#endIndices', + ], + + compute: (continuation, { + ['annotation']: annotation, + ['#startIndices']: startIndices, + ['#endIndices']: endIndices, + }) => continuation({ + ['#annotationParts']: + transposeArrays([startIndices, endIndices]) + .map(([start, end]) => + annotation.slice(start, end)), + }), + }, + ], +}); diff --git a/src/data/composite/things/content/withHasAnnotationPart.js b/src/data/composite/things/content/withHasAnnotationPart.js new file mode 100644 index 00000000..4af554f3 --- /dev/null +++ b/src/data/composite/things/content/withHasAnnotationPart.js @@ -0,0 +1,43 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +import withAnnotationParts from './withAnnotationParts.js'; + +export default templateCompositeFrom({ + annotation: `withHasAnnotationPart`, + + inputs: { + part: input({type: 'string'}), + }, + + outputs: ['#hasAnnotationPart'], + + steps: () => [ + withAnnotationParts({ + mode: input.value('strings'), + }), + + raiseOutputWithoutDependency({ + dependency: '#annotationParts', + output: input.value({'#hasAnnotationPart': false}), + }), + + { + dependencies: [ + input('part'), + '#annotationParts', + ], + + compute: (continuation, { + [input('part')]: search, + ['#annotationParts']: parts, + }) => continuation({ + ['#hasAnnotationPart']: + parts.some(part => + part.toLowerCase() === + search.toLowerCase()), + }), + }, + ], +}); diff --git a/src/data/composite/things/content/withSourceText.js b/src/data/composite/things/content/withSourceText.js new file mode 100644 index 00000000..292306b7 --- /dev/null +++ b/src/data/composite/things/content/withSourceText.js @@ -0,0 +1,53 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +import withAnnotationParts from './withAnnotationParts.js'; + +export default templateCompositeFrom({ + annotation: `withSourceText`, + + outputs: ['#sourceText'], + + steps: () => [ + withAnnotationParts({ + mode: input.value('nodes'), + }), + + raiseOutputWithoutDependency({ + dependency: '#annotationParts', + output: input.value({'#sourceText': null}), + }), + + { + dependencies: ['#annotationParts'], + compute: (continuation, { + ['#annotationParts']: annotationParts, + }) => continuation({ + ['#firstPartWithExternalLink']: + annotationParts + .find(nodes => nodes + .some(node => node.type === 'external-link')) ?? + null, + }), + }, + + raiseOutputWithoutDependency({ + dependency: '#firstPartWithExternalLink', + output: input.value({'#sourceText': null}), + }), + + { + dependencies: ['annotation', '#firstPartWithExternalLink'], + compute: (continuation, { + ['annotation']: annotation, + ['#firstPartWithExternalLink']: nodes, + }) => continuation({ + ['#sourceText']: + annotation.slice( + nodes.at(0).i, + nodes.at(-1).iEnd), + }), + }, + ], +}); diff --git a/src/data/composite/things/content/withSourceURLs.js b/src/data/composite/things/content/withSourceURLs.js new file mode 100644 index 00000000..f85ff9ea --- /dev/null +++ b/src/data/composite/things/content/withSourceURLs.js @@ -0,0 +1,62 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withFilteredList, withMappedList} from '#composite/data'; + +import withAnnotationParts from './withAnnotationParts.js'; + +export default templateCompositeFrom({ + annotation: `withSourceURLs`, + + outputs: ['#sourceURLs'], + + steps: () => [ + withAnnotationParts({ + mode: input.value('nodes'), + }), + + raiseOutputWithoutDependency({ + dependency: '#annotationParts', + output: input.value({'#sourceURLs': []}), + }), + + { + dependencies: ['#annotationParts'], + compute: (continuation, { + ['#annotationParts']: annotationParts, + }) => continuation({ + ['#firstPartWithExternalLink']: + annotationParts + .find(nodes => nodes + .some(node => node.type === 'external-link')) ?? + null, + }), + }, + + raiseOutputWithoutDependency({ + dependency: '#firstPartWithExternalLink', + output: input.value({'#sourceURLs': []}), + }), + + withMappedList({ + list: '#firstPartWithExternalLink', + map: input.value(node => node.type === 'external-link'), + }).outputs({ + '#mappedList': '#externalLinkFilter', + }), + + withFilteredList({ + list: '#firstPartWithExternalLink', + filter: '#externalLinkFilter', + }).outputs({ + '#filteredList': '#externalLinks', + }), + + withMappedList({ + list: '#externalLinks', + map: input.value(node => node.data.href), + }).outputs({ + '#mappedList': '#sourceURLs', + }), + ], +}); diff --git a/src/data/composite/things/content/withWebArchiveDate.js b/src/data/composite/things/content/withWebArchiveDate.js new file mode 100644 index 00000000..3aaa4f64 --- /dev/null +++ b/src/data/composite/things/content/withWebArchiveDate.js @@ -0,0 +1,41 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: `withWebArchiveDate`, + + outputs: ['#webArchiveDate'], + + steps: () => [ + { + dependencies: ['annotation'], + + compute: (continuation, {annotation}) => + continuation({ + ['#dateText']: + annotation + ?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//) + ?.[1] ?? + null, + }), + }, + + raiseOutputWithoutDependency({ + dependency: '#dateText', + output: input.value({['#webArchiveDate']: null}), + }), + + { + dependencies: ['#dateText'], + compute: (continuation, {['#dateText']: dateText}) => + continuation({ + ['#webArchiveDate']: + new Date( + dateText.slice(0, 4) + '/' + + dateText.slice(4, 6) + '/' + + dateText.slice(6, 8)), + }), + }, + ], +}); diff --git a/src/data/composite/things/contribution/inheritFromContributionPresets.js b/src/data/composite/things/contribution/inheritFromContributionPresets.js index 82425b9c..a74e6db3 100644 --- a/src/data/composite/things/contribution/inheritFromContributionPresets.js +++ b/src/data/composite/things/contribution/inheritFromContributionPresets.js @@ -1,7 +1,7 @@ import {input, templateCompositeFrom} from '#composite'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromList, withPropertyFromObject} from '#composite/data'; +import {withPropertyFromList} from '#composite/data'; import withMatchingContributionPresets from './withMatchingContributionPresets.js'; diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js index 4a37f2cf..a678c3f5 100644 --- a/src/data/composite/things/contribution/thingPropertyMatches.js +++ b/src/data/composite/things/contribution/thingPropertyMatches.js @@ -12,19 +12,31 @@ export default templateCompositeFrom({ }, steps: () => [ + { + dependencies: ['thing', 'thingProperty'], + + compute: (continuation, {thing, thingProperty}) => + continuation({ + ['#thingProperty']: + (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork' + ? thing.artistContribsFromThingProperty + : thingProperty), + }), + }, + exitWithoutDependency({ - dependency: 'thingProperty', + dependency: '#thingProperty', value: input.value(false), }), { dependencies: [ - 'thingProperty', + '#thingProperty', input('value'), ], compute: ({ - ['thingProperty']: thingProperty, + ['#thingProperty']: thingProperty, [input('value')]: value, }) => thingProperty === value, diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js index 2ee811af..4042e78f 100644 --- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js +++ b/src/data/composite/things/contribution/thingReferenceTypeMatches.js @@ -29,10 +29,37 @@ export default templateCompositeFrom({ input('value'), ], - compute: ({ + compute: (continuation, { ['#thing.constructor']: constructor, [input('value')]: value, }) => + (constructor[Symbol.for('Thing.referenceType')] === value + ? continuation.exit(true) + : constructor[Symbol.for('Thing.referenceType')] === 'artwork' + ? continuation() + : continuation.exit(false)), + }, + + withPropertyFromObject({ + object: 'thing', + property: input.value('thing'), + }), + + withPropertyFromObject({ + object: '#thing.thing', + property: input.value('constructor'), + }), + + { + dependencies: [ + '#thing.thing.constructor', + input('value'), + ], + + compute: ({ + ['#thing.thing.constructor']: constructor, + [input('value')]: value, + }) => constructor[Symbol.for('Thing.referenceType')] === value, }, ], diff --git a/src/data/composite/things/contribution/withContainingReverseContributionList.js b/src/data/composite/things/contribution/withContainingReverseContributionList.js index 56704c8b..175d6cbb 100644 --- a/src/data/composite/things/contribution/withContainingReverseContributionList.js +++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js @@ -1,8 +1,12 @@ -// Get the artist's contribution list containing this property. +// Get the artist's contribution list containing this property. Although that +// list literally includes both dated and dateless contributions, here, if the +// current contribution is dateless, the list is filtered to only include +// dateless contributions from the same immediately nearby context. import {input, templateCompositeFrom} from '#composite'; -import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; import withContributionArtist from './withContributionArtist.js'; @@ -34,7 +38,43 @@ export default templateCompositeFrom({ object: '#artist', property: input('artistProperty'), }).outputs({ - ['#value']: '#containingReverseContributionList', + ['#value']: '#list', }), + + withResultOfAvailabilityCheck({ + from: 'date', + }).outputs({ + ['#availability']: '#hasDate', + }), + + { + dependencies: ['#hasDate', '#list'], + compute: (continuation, { + ['#hasDate']: hasDate, + ['#list']: list, + }) => + (hasDate + ? continuation.raiseOutput({ + ['#containingReverseContributionList']: + list.filter(contrib => contrib.date), + }) + : continuation({ + ['#list']: + list.filter(contrib => !contrib.date), + })), + }, + + { + dependencies: ['#list', 'thing'], + compute: (continuation, { + ['#list']: list, + ['thing']: thing, + }) => continuation({ + ['#containingReverseContributionList']: + (thing.album + ? list.filter(contrib => contrib.thing.album === thing.album) + : list), + }), + }, ], }); diff --git a/src/data/composite/things/contribution/withContributionArtist.js b/src/data/composite/things/contribution/withContributionArtist.js index 5a611c1a..5f81c716 100644 --- a/src/data/composite/things/contribution/withContributionArtist.js +++ b/src/data/composite/things/contribution/withContributionArtist.js @@ -1,8 +1,7 @@ import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {withPropertyFromObject} from '#composite/data'; import {withResolvedReference} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withContributionArtist`, @@ -17,16 +16,9 @@ export default templateCompositeFrom({ outputs: ['#artist'], steps: () => [ - withPropertyFromObject({ - object: 'thing', - property: input.value('artistData'), - internal: input.value(true), - }), - withResolvedReference({ ref: input('ref'), - data: '#thing.artistData', - find: input.value(find.artist), + find: soupyFind.input('artist'), }).outputs({ '#resolvedReference': '#artist', }), diff --git a/src/data/composite/things/flash-act/withFlashSide.js b/src/data/composite/things/flash-act/withFlashSide.js index 64daa1fb..e09f06e6 100644 --- a/src/data/composite/things/flash-act/withFlashSide.js +++ b/src/data/composite/things/flash-act/withFlashSide.js @@ -2,9 +2,10 @@ // If there's no side whose list of flash acts includes this act, the output // dependency will be null. -import {input, templateCompositeFrom} from '#composite'; +import {templateCompositeFrom} from '#composite'; import {withUniqueReferencingThing} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withFlashSide`, @@ -13,8 +14,7 @@ export default templateCompositeFrom({ steps: () => [ withUniqueReferencingThing({ - data: 'flashSideData', - list: input.value('acts'), + reverse: soupyReverse.input('flashSidesWhoseActsInclude'), }).outputs({ ['#uniqueReferencingThing']: '#flashSide', }), diff --git a/src/data/composite/things/flash/withFlashAct.js b/src/data/composite/things/flash/withFlashAct.js index 652b8bfb..87922aff 100644 --- a/src/data/composite/things/flash/withFlashAct.js +++ b/src/data/composite/things/flash/withFlashAct.js @@ -2,9 +2,10 @@ // If there's no flash whose list of flashes includes this flash, the output // dependency will be null. -import {input, templateCompositeFrom} from '#composite'; +import {templateCompositeFrom} from '#composite'; import {withUniqueReferencingThing} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withFlashAct`, @@ -13,8 +14,7 @@ export default templateCompositeFrom({ steps: () => [ withUniqueReferencingThing({ - data: 'flashActData', - list: input.value('flashes'), + reverse: soupyReverse.input('flashActsWhoseFlashesInclude'), }).outputs({ ['#uniqueReferencingThing']: '#flashAct', }), diff --git a/src/data/composite/things/track-section/index.js b/src/data/composite/things/track-section/index.js index 3202ed49..f11a2ab5 100644 --- a/src/data/composite/things/track-section/index.js +++ b/src/data/composite/things/track-section/index.js @@ -1 +1,3 @@ export {default as withAlbum} from './withAlbum.js'; +export {default as withContinueCountingFrom} from './withContinueCountingFrom.js'; +export {default as withStartCountingFrom} from './withStartCountingFrom.js'; diff --git a/src/data/composite/things/track-section/withAlbum.js b/src/data/composite/things/track-section/withAlbum.js index a4dfff0d..e257062e 100644 --- a/src/data/composite/things/track-section/withAlbum.js +++ b/src/data/composite/things/track-section/withAlbum.js @@ -1,8 +1,9 @@ // Gets the track section's album. -import {input, templateCompositeFrom} from '#composite'; +import {templateCompositeFrom} from '#composite'; import {withUniqueReferencingThing} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withAlbum`, @@ -11,8 +12,7 @@ export default templateCompositeFrom({ steps: () => [ withUniqueReferencingThing({ - data: 'albumData', - list: input.value('trackSections'), + reverse: soupyReverse.input('albumsWhoseTrackSectionsInclude'), }).outputs({ ['#uniqueReferencingThing']: '#album', }), diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js new file mode 100644 index 00000000..0ca52b6c --- /dev/null +++ b/src/data/composite/things/track-section/withContinueCountingFrom.js @@ -0,0 +1,25 @@ +import {templateCompositeFrom} from '#composite'; + +import withStartCountingFrom from './withStartCountingFrom.js'; + +export default templateCompositeFrom({ + annotation: `withContinueCountingFrom`, + + outputs: ['#continueCountingFrom'], + + steps: () => [ + withStartCountingFrom(), + + { + dependencies: ['#startCountingFrom', 'tracks'], + compute: (continuation, { + ['#startCountingFrom']: startCountingFrom, + ['tracks']: tracks, + }) => continuation({ + ['#continueCountingFrom']: + startCountingFrom + + tracks.length, + }), + }, + ], +}); diff --git a/src/data/composite/things/track-section/withStartCountingFrom.js b/src/data/composite/things/track-section/withStartCountingFrom.js new file mode 100644 index 00000000..ef345327 --- /dev/null +++ b/src/data/composite/things/track-section/withStartCountingFrom.js @@ -0,0 +1,64 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data'; + +import withAlbum from './withAlbum.js'; + +export default templateCompositeFrom({ + annotation: `withStartCountingFrom`, + + inputs: { + from: input({ + type: 'number', + defaultDependency: 'startCountingFrom', + acceptsNull: true, + }), + }, + + outputs: ['#startCountingFrom'], + + steps: () => [ + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: from, + }) => + (from === null + ? continuation() + : continuation.raiseOutput({'#startCountingFrom': from})), + }, + + withAlbum(), + + raiseOutputWithoutDependency({ + dependency: '#album', + output: input.value({'#startCountingFrom': 1}), + }), + + withPropertyFromObject({ + object: '#album', + property: input.value('trackSections'), + }), + + withNearbyItemFromList({ + list: '#album.trackSections', + item: input.myself(), + offset: input.value(-1), + }).outputs({ + '#nearbyItem': '#previousTrackSection', + }), + + raiseOutputWithoutDependency({ + dependency: '#previousTrackSection', + output: input.value({'#startCountingFrom': 1}), + }), + + withPropertyFromObject({ + object: '#previousTrackSection', + property: input.value('continueCountingFrom'), + }).outputs({ + '#previousTrackSection.continueCountingFrom': '#startCountingFrom', + }), + ], +}); diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js index 05ccaaba..e789e736 100644 --- a/src/data/composite/things/track/index.js +++ b/src/data/composite/things/track/index.js @@ -1,16 +1,17 @@ export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js'; -export {default as inheritContributionListFromOriginalRelease} from './inheritContributionListFromOriginalRelease.js'; -export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js'; -export {default as trackReverseReferenceList} from './trackReverseReferenceList.js'; -export {default as withAlbum} from './withAlbum.js'; +export {default as inheritContributionListFromMainRelease} from './inheritContributionListFromMainRelease.js'; +export {default as inheritFromMainRelease} from './inheritFromMainRelease.js'; +export {default as withAllReleases} from './withAllReleases.js'; export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js'; export {default as withContainingTrackSection} from './withContainingTrackSection.js'; +export {default as withCoverArtistContribs} from './withCoverArtistContribs.js'; export {default as withDate} from './withDate.js'; export {default as withDirectorySuffix} from './withDirectorySuffix.js'; export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js'; -export {default as withOriginalRelease} from './withOriginalRelease.js'; +export {default as withMainRelease} from './withMainRelease.js'; export {default as withOtherReleases} from './withOtherReleases.js'; export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js'; -export {default as withPropertyFromOriginalRelease} from './withPropertyFromOriginalRelease.js'; +export {default as withPropertyFromMainRelease} from './withPropertyFromMainRelease.js'; export {default as withSuffixDirectoryFromAlbum} from './withSuffixDirectoryFromAlbum.js'; export {default as withTrackArtDate} from './withTrackArtDate.js'; +export {default as withTrackNumber} from './withTrackNumber.js'; diff --git a/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js b/src/data/composite/things/track/inheritContributionListFromMainRelease.js index f4ae3ddb..89252feb 100644 --- a/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js +++ b/src/data/composite/things/track/inheritContributionListFromMainRelease.js @@ -1,5 +1,5 @@ -// Like inheritFromOriginalRelease, but tuned for contributions. -// Recontextualized contributions for this track. +// Like inheritFromMainRelease, but tuned for contributions. +// Recontextualizes contributions for this track. import {input, templateCompositeFrom} from '#composite'; @@ -9,36 +9,36 @@ import {withRecontextualizedContributionList, withRedatedContributionList} from '#composite/wiki-data'; import withDate from './withDate.js'; -import withPropertyFromOriginalRelease - from './withPropertyFromOriginalRelease.js'; +import withPropertyFromMainRelease + from './withPropertyFromMainRelease.js'; export default templateCompositeFrom({ - annotation: `inheritContributionListFromOriginalRelease`, + annotation: `inheritContributionListFromMainRelease`, steps: () => [ - withPropertyFromOriginalRelease({ + withPropertyFromMainRelease({ property: input.thisProperty(), notFoundValue: input.value([]), }), raiseOutputWithoutDependency({ - dependency: '#isRerelease', + dependency: '#isSecondaryRelease', mode: input.value('falsy'), }), withRecontextualizedContributionList({ - list: '#originalValue', + list: '#mainReleaseValue', }), withDate(), withRedatedContributionList({ - list: '#originalValue', + list: '#mainReleaseValue', date: '#date', }), exposeDependency({ - dependency: '#originalValue', + dependency: '#mainReleaseValue', }), ], }); diff --git a/src/data/composite/things/track/inheritFromOriginalRelease.js b/src/data/composite/things/track/inheritFromMainRelease.js index 38ab06be..b1cbb65e 100644 --- a/src/data/composite/things/track/inheritFromOriginalRelease.js +++ b/src/data/composite/things/track/inheritFromMainRelease.js @@ -1,9 +1,9 @@ // Early exits with the value for the same property as specified on the -// original release, if this track is a rerelease, and otherwise continues +// main release, if this track is a secondary release, and otherwise continues // without providing any further dependencies. // -// Like withOriginalRelease, this will early exit (with notFoundValue) if the -// original release is specified by reference and that reference doesn't +// Like withMainRelease, this will early exit (with notFoundValue) if the +// main release is specified by reference and that reference doesn't // resolve to anything. import {input, templateCompositeFrom} from '#composite'; @@ -11,11 +11,11 @@ import {input, templateCompositeFrom} from '#composite'; import {exposeDependency, raiseOutputWithoutDependency} from '#composite/control-flow'; -import withPropertyFromOriginalRelease - from './withPropertyFromOriginalRelease.js'; +import withPropertyFromMainRelease + from './withPropertyFromMainRelease.js'; export default templateCompositeFrom({ - annotation: `inheritFromOriginalRelease`, + annotation: `inheritFromMainRelease`, inputs: { notFoundValue: input({ @@ -24,18 +24,18 @@ export default templateCompositeFrom({ }, steps: () => [ - withPropertyFromOriginalRelease({ + withPropertyFromMainRelease({ property: input.thisProperty(), notFoundValue: input('notFoundValue'), }), raiseOutputWithoutDependency({ - dependency: '#isRerelease', + dependency: '#isSecondaryRelease', mode: input.value('falsy'), }), exposeDependency({ - dependency: '#originalValue', + dependency: '#mainReleaseValue', }), ], }); diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js deleted file mode 100644 index 65a2263d..00000000 --- a/src/data/composite/things/track/trackAdditionalNameList.js +++ /dev/null @@ -1,38 +0,0 @@ -// Compiles additional names from various sources. - -import {input, templateCompositeFrom} from '#composite'; -import {isAdditionalNameList} from '#validators'; - -import withInferredAdditionalNames from './withInferredAdditionalNames.js'; -import withSharedAdditionalNames from './withSharedAdditionalNames.js'; - -export default templateCompositeFrom({ - annotation: `trackAdditionalNameList`, - - compose: false, - - update: {validate: isAdditionalNameList}, - - steps: () => [ - withInferredAdditionalNames(), - withSharedAdditionalNames(), - - { - dependencies: [ - '#inferredAdditionalNames', - '#sharedAdditionalNames', - input.updateValue(), - ], - - compute: ({ - ['#inferredAdditionalNames']: inferredAdditionalNames, - ['#sharedAdditionalNames']: sharedAdditionalNames, - [input.updateValue()]: providedAdditionalNames, - }) => [ - ...providedAdditionalNames ?? [], - ...sharedAdditionalNames, - ...inferredAdditionalNames, - ], - }, - ], -}); diff --git a/src/data/composite/things/track/trackReverseReferenceList.js b/src/data/composite/things/track/trackReverseReferenceList.js deleted file mode 100644 index 44940ae7..00000000 --- a/src/data/composite/things/track/trackReverseReferenceList.js +++ /dev/null @@ -1,38 +0,0 @@ -// Like a normal reverse reference list ("objects which reference this object -// under a specified property"), only excluding rereleases from the possible -// outputs. While it's useful to travel from a rerelease to the tracks it -// references, rereleases aren't generally relevant from the perspective of -// the tracks *being* referenced. Apart from hiding rereleases from lists on -// the site, it also excludes keeps them from relational data processing, such -// as on the "Tracks - by Times Referenced" listing page. - -import {input, templateCompositeFrom} from '#composite'; -import {withReverseReferenceList} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `trackReverseReferenceList`, - - compose: false, - - inputs: { - list: input({type: 'string'}), - }, - - steps: () => [ - withReverseReferenceList({ - data: 'trackData', - list: input('list'), - }), - - { - flags: {expose: true}, - expose: { - dependencies: ['#reverseReferenceList'], - compute: ({ - ['#reverseReferenceList']: reverseReferenceList, - }) => - reverseReferenceList.filter(track => !track.originalReleaseTrack), - }, - }, - ], -}); diff --git a/src/data/composite/things/track/withAlbum.js b/src/data/composite/things/track/withAlbum.js deleted file mode 100644 index 03b840d4..00000000 --- a/src/data/composite/things/track/withAlbum.js +++ /dev/null @@ -1,22 +0,0 @@ -// Gets the track's album. This will early exit if albumData is missing. -// If there's no album whose list of tracks includes this track, the output -// dependency will be null. - -import {input, templateCompositeFrom} from '#composite'; - -import {withUniqueReferencingThing} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `withAlbum`, - - outputs: ['#album'], - - steps: () => [ - withUniqueReferencingThing({ - data: 'albumData', - list: input.value('tracks'), - }).outputs({ - ['#uniqueReferencingThing']: '#album', - }), - ], -}); diff --git a/src/data/composite/things/track/withAllReleases.js b/src/data/composite/things/track/withAllReleases.js new file mode 100644 index 00000000..891db102 --- /dev/null +++ b/src/data/composite/things/track/withAllReleases.js @@ -0,0 +1,46 @@ +// Gets all releases of the current track. All items of the outputs are +// distinct Track objects; one track is the main release; all else are +// secondary releases of that main release; and one item, which may be +// the main release or one of the secondary releases, is the current +// track. The results are sorted by date, and it is possible that the +// main release is not actually the earliest/first. + +import {input, templateCompositeFrom} from '#composite'; +import {sortByDate} from '#sort'; + +import {withPropertyFromObject} from '#composite/data'; + +import withMainRelease from './withMainRelease.js'; + +export default templateCompositeFrom({ + annotation: `withAllReleases`, + + outputs: ['#allReleases'], + + steps: () => [ + withMainRelease({ + selfIfMain: input.value(true), + notFoundValue: input.value([]), + }), + + // We don't talk about bruno no no + // Yes, this can perform a normal access equivalent to + // `this.secondaryReleases` from within a data composition. + // Oooooooooooooooooooooooooooooooooooooooooooooooo + withPropertyFromObject({ + object: '#mainRelease', + property: input.value('secondaryReleases'), + }), + + { + dependencies: ['#mainRelease', '#mainRelease.secondaryReleases'], + compute: (continuation, { + ['#mainRelease']: mainRelease, + ['#mainRelease.secondaryReleases']: secondaryReleases, + }) => continuation({ + ['#allReleases']: + sortByDate([mainRelease, ...secondaryReleases]), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js index e01720b4..87edf21e 100644 --- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js +++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js @@ -16,6 +16,8 @@ import { exposeUpdateValueOrContinue, } from '#composite/control-flow'; +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; + export default templateCompositeFrom({ annotation: `withAlwaysReferenceByDirectory`, @@ -26,19 +28,7 @@ export default templateCompositeFrom({ validate: input.value(isBoolean), }), - // withAlwaysReferenceByDirectory is sort of a fragile area - we can't - // find the track's album the normal way because albums' track lists - // recurse back into alwaysReferenceByDirectory! - withResolvedReference({ - ref: 'dataSourceAlbum', - data: 'albumData', - find: input.value(find.album), - }).outputs({ - '#resolvedReference': '#album', - }), - - withPropertyFromObject({ - object: '#album', + withPropertyFromAlbum({ property: input.value('alwaysReferenceTracksByDirectory'), }), @@ -51,7 +41,7 @@ export default templateCompositeFrom({ // Remaining code is for defaulting to true if this track is a rerelease of // another with the same name, so everything further depends on access to - // trackData as well as originalReleaseTrack. + // trackData as well as mainReleaseTrack. exitWithoutDependency({ dependency: 'trackData', @@ -60,45 +50,46 @@ export default templateCompositeFrom({ }), exitWithoutDependency({ - dependency: 'originalReleaseTrack', + dependency: 'mainReleaseTrack', value: input.value(false), }), - // It's necessary to use the custom trackOriginalReleasesOnly find function + // It's necessary to use the custom trackMainReleasesOnly find function // here, so as to avoid recursion issues - the find.track() function depends // on accessing each track's alwaysReferenceByDirectory, which means it'll // hit *this track* - and thus this step - and end up recursing infinitely. - // By definition, find.trackOriginalReleasesOnly excludes tracks which have - // an originalReleaseTrack update value set, which means even though it does + // By definition, find.trackMainReleasesOnly excludes tracks which have + // an mainReleaseTrack update value set, which means even though it does // still access each of tracks' `alwaysReferenceByDirectory` property, it // won't access that of *this* track - it will never proceed past the // `exitWithoutDependency` step directly above, so there's no opportunity // for recursion. withResolvedReference({ - ref: 'originalReleaseTrack', + ref: 'mainReleaseTrack', data: 'trackData', - find: input.value(find.trackOriginalReleasesOnly), + find: input.value(find.trackMainReleasesOnly), }).outputs({ - '#resolvedReference': '#originalRelease', + '#resolvedReference': '#mainRelease', }), exitWithoutDependency({ - dependency: '#originalRelease', + dependency: '#mainRelease', value: input.value(false), }), withPropertyFromObject({ - object: '#originalRelease', + object: '#mainRelease', property: input.value('name'), }), { - dependencies: ['name', '#originalRelease.name'], + dependencies: ['name', '#mainRelease.name'], compute: (continuation, { name, - ['#originalRelease.name']: originalName, + ['#mainRelease.name']: mainReleaseName, }) => continuation({ - ['#alwaysReferenceByDirectory']: name === originalName, + ['#alwaysReferenceByDirectory']: + name === mainReleaseName, }), }, ], diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js index 9bbd9bd5..3d4d081e 100644 --- a/src/data/composite/things/track/withContainingTrackSection.js +++ b/src/data/composite/things/track/withContainingTrackSection.js @@ -1,8 +1,9 @@ // Gets the track section containing this track from its album's track list. -import {input, templateCompositeFrom} from '#composite'; +import {templateCompositeFrom} from '#composite'; import {withUniqueReferencingThing} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withContainingTrackSection`, @@ -11,8 +12,7 @@ export default templateCompositeFrom({ steps: () => [ withUniqueReferencingThing({ - data: 'trackSectionData', - list: input.value('tracks'), + reverse: soupyReverse.input('trackSectionsWhichInclude'), }).outputs({ ['#uniqueReferencingThing']: '#trackSection', }), diff --git a/src/data/composite/things/track/withCoverArtistContribs.js b/src/data/composite/things/track/withCoverArtistContribs.js new file mode 100644 index 00000000..9057cfeb --- /dev/null +++ b/src/data/composite/things/track/withCoverArtistContribs.js @@ -0,0 +1,73 @@ +import {input, templateCompositeFrom} from '#composite'; +import {isContributionList} from '#validators'; + +import {exposeDependencyOrContinue} from '#composite/control-flow'; + +import { + withRecontextualizedContributionList, + withRedatedContributionList, + withResolvedContribs, +} from '#composite/wiki-data'; + +import exitWithoutUniqueCoverArt from './exitWithoutUniqueCoverArt.js'; +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; +import withTrackArtDate from './withTrackArtDate.js'; + +export default templateCompositeFrom({ + annotation: `withCoverArtistContribs`, + + inputs: { + from: input({ + defaultDependency: 'coverArtistContribs', + validate: isContributionList, + acceptsNull: true, + }), + }, + + outputs: ['#coverArtistContribs'], + + steps: () => [ + exitWithoutUniqueCoverArt({ + value: input.value([]), + }), + + withTrackArtDate(), + + withResolvedContribs({ + from: input('from'), + thingProperty: input.value('coverArtistContribs'), + artistProperty: input.value('trackCoverArtistContributions'), + date: '#trackArtDate', + }).outputs({ + '#resolvedContribs': '#coverArtistContribs', + }), + + exposeDependencyOrContinue({ + dependency: '#coverArtistContribs', + mode: input.value('empty'), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtistContribs'), + }), + + withRecontextualizedContributionList({ + list: '#album.trackCoverArtistContribs', + artistProperty: input.value('trackCoverArtistContributions'), + }), + + withRedatedContributionList({ + list: '#album.trackCoverArtistContribs', + date: '#trackArtDate', + }), + + { + dependencies: ['#album.trackCoverArtistContribs'], + compute: (continuation, { + ['#album.trackCoverArtistContribs']: coverArtistContribs, + }) => continuation({ + ['#coverArtistContribs']: coverArtistContribs, + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js index f7e65f25..85d3b92a 100644 --- a/src/data/composite/things/track/withHasUniqueCoverArt.js +++ b/src/data/composite/things/track/withHasUniqueCoverArt.js @@ -5,11 +5,18 @@ // or a placeholder. (This property is named hasUniqueCoverArt instead of // the usual hasCoverArt to emphasize that it does not inherit from the // album.) +// +// withHasUniqueCoverArt is based only around the presence of *specified* +// cover artist contributions, not whether the references to artists on those +// contributions actually resolve to anything. It completely evades interacting +// with find/replace. import {input, templateCompositeFrom} from '#composite'; -import {empty} from '#sugar'; -import {withResolvedContribs} from '#composite/wiki-data'; +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {fillMissingListItems, withFlattenedList, withPropertyFromList} + from '#composite/data'; import withPropertyFromAlbum from './withPropertyFromAlbum.js'; @@ -29,36 +36,73 @@ export default templateCompositeFrom({ : continuation()), }, - withResolvedContribs({ + withResultOfAvailabilityCheck({ from: 'coverArtistContribs', - date: input.value(null), + mode: input.value('empty'), }), { - dependencies: ['#resolvedContribs'], + dependencies: ['#availability'], compute: (continuation, { - ['#resolvedContribs']: contribsFromTrack, + ['#availability']: availability, }) => - (empty(contribsFromTrack) - ? continuation() - : continuation.raiseOutput({ + (availability + ? continuation.raiseOutput({ ['#hasUniqueCoverArt']: true, - })), + }) + : continuation()), }, withPropertyFromAlbum({ property: input.value('trackCoverArtistContribs'), + internal: input.value(true), + }), + + withResultOfAvailabilityCheck({ + from: '#album.trackCoverArtistContribs', + mode: input.value('empty'), }), { - dependencies: ['#album.trackCoverArtistContribs'], + dependencies: ['#availability'], compute: (continuation, { - ['#album.trackCoverArtistContribs']: contribsFromAlbum, + ['#availability']: availability, }) => - continuation.raiseOutput({ - ['#hasUniqueCoverArt']: - !empty(contribsFromAlbum), - }), + (availability + ? continuation.raiseOutput({ + ['#hasUniqueCoverArt']: true, + }) + : continuation()), }, + + raiseOutputWithoutDependency({ + dependency: 'trackArtworks', + mode: input.value('empty'), + output: input.value({'#hasUniqueCoverArt': false}), + }), + + withPropertyFromList({ + list: 'trackArtworks', + property: input.value('artistContribs'), + internal: input.value(true), + }), + + // Since we're getting the update value for each artwork's artistContribs, + // it may not be set at all, and in that case won't be exposing as []. + fillMissingListItems({ + list: '#trackArtworks.artistContribs', + fill: input.value([]), + }), + + withFlattenedList({ + list: '#trackArtworks.artistContribs', + }), + + withResultOfAvailabilityCheck({ + from: '#flattenedList', + mode: input.value('empty'), + }).outputs({ + '#availability': '#hasUniqueCoverArt', + }), ], }); diff --git a/src/data/composite/things/track/withOriginalRelease.js b/src/data/composite/things/track/withMainRelease.js index c7f49657..3a91edae 100644 --- a/src/data/composite/things/track/withOriginalRelease.js +++ b/src/data/composite/things/track/withMainRelease.js @@ -1,62 +1,54 @@ -// Just includes the original release of this track as a dependency. -// If this track isn't a rerelease, then it'll provide null, unless the -// {selfIfOriginal} option is set, in which case it'll provide this track -// itself. This will early exit (with notFoundValue) if the original release +// Just includes the main release of this track as a dependency. +// If this track isn't a secondary release, then it'll provide null, unless +// the {selfIfMain} option is set, in which case it'll provide this track +// itself. This will early exit (with notFoundValue) if the main release // is specified by reference and that reference doesn't resolve to anything. import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {validateWikiData} from '#validators'; import {exitWithoutDependency, withResultOfAvailabilityCheck} from '#composite/control-flow'; import {withResolvedReference} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; export default templateCompositeFrom({ - annotation: `withOriginalRelease`, + annotation: `withMainRelease`, inputs: { - selfIfOriginal: input({type: 'boolean', defaultValue: false}), - - data: input({ - validate: validateWikiData({referenceType: 'track'}), - defaultDependency: 'trackData', - }), - + selfIfMain: input({type: 'boolean', defaultValue: false}), notFoundValue: input({defaultValue: null}), }, - outputs: ['#originalRelease'], + outputs: ['#mainRelease'], steps: () => [ withResultOfAvailabilityCheck({ - from: 'originalReleaseTrack', + from: 'mainReleaseTrack', }), { dependencies: [ input.myself(), - input('selfIfOriginal'), + input('selfIfMain'), '#availability', ], compute: (continuation, { [input.myself()]: track, - [input('selfIfOriginal')]: selfIfOriginal, + [input('selfIfMain')]: selfIfMain, '#availability': availability, }) => (availability ? continuation() : continuation.raiseOutput({ - ['#originalRelease']: - (selfIfOriginal ? track : null), + ['#mainRelease']: + (selfIfMain ? track : null), })), }, withResolvedReference({ - ref: 'originalReleaseTrack', - data: input('data'), - find: input.value(find.track), + ref: 'mainReleaseTrack', + find: soupyFind.input('track'), }), exitWithoutDependency({ @@ -71,7 +63,7 @@ export default templateCompositeFrom({ ['#resolvedReference']: resolvedReference, }) => continuation({ - ['#originalRelease']: resolvedReference, + ['#mainRelease']: resolvedReference, }), }, ], diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js index f8c1c3f0..bb3e8983 100644 --- a/src/data/composite/things/track/withOtherReleases.js +++ b/src/data/composite/things/track/withOtherReleases.js @@ -1,8 +1,9 @@ -import {input, templateCompositeFrom} from '#composite'; +// Gets all releases of the current track *except* this track itself; +// in other words, all other releases of the current track. -import {exitWithoutDependency} from '#composite/control-flow'; +import {input, templateCompositeFrom} from '#composite'; -import withOriginalRelease from './withOriginalRelease.js'; +import withAllReleases from './withAllReleases.js'; export default templateCompositeFrom({ annotation: `withOtherReleases`, @@ -10,31 +11,16 @@ export default templateCompositeFrom({ outputs: ['#otherReleases'], steps: () => [ - exitWithoutDependency({ - dependency: 'trackData', - mode: input.value('empty'), - }), - - withOriginalRelease({ - selfIfOriginal: input.value(true), - notFoundValue: input.value([]), - }), + withAllReleases(), { - dependencies: [input.myself(), '#originalRelease', 'trackData'], + dependencies: [input.myself(), '#allReleases'], compute: (continuation, { [input.myself()]: thisTrack, - ['#originalRelease']: originalRelease, - trackData, + ['#allReleases']: allReleases, }) => continuation({ ['#otherReleases']: - (originalRelease === thisTrack - ? [] - : [originalRelease]) - .concat(trackData.filter(track => - track !== originalRelease && - track !== thisTrack && - track.originalReleaseTrack === originalRelease)), + allReleases.filter(track => track !== thisTrack), }), }, ], diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js index d41390fa..a203c2e7 100644 --- a/src/data/composite/things/track/withPropertyFromAlbum.js +++ b/src/data/composite/things/track/withPropertyFromAlbum.js @@ -2,17 +2,15 @@ // property name prefixed with '#album.' (by default). import {input, templateCompositeFrom} from '#composite'; -import {is} from '#validators'; import {withPropertyFromObject} from '#composite/data'; -import withAlbum from './withAlbum.js'; - export default templateCompositeFrom({ annotation: `withPropertyFromAlbum`, inputs: { property: input.staticValue({type: 'string'}), + internal: input({type: 'boolean', defaultValue: false}), }, outputs: ({ @@ -20,11 +18,21 @@ export default templateCompositeFrom({ }) => ['#album.' + property], steps: () => [ - withAlbum(), + // XXX: This is a ridiculous hack considering `defaultValue` above. + // If we were certain what was up, we'd just get around to fixing it LOL + { + dependencies: [input('internal')], + compute: (continuation, { + [input('internal')]: internal, + }) => continuation({ + ['#internal']: internal ?? false, + }), + }, withPropertyFromObject({ - object: '#album', + object: 'album', property: input('property'), + internal: '#internal', }), { diff --git a/src/data/composite/things/track/withPropertyFromOriginalRelease.js b/src/data/composite/things/track/withPropertyFromMainRelease.js index fd37f6de..393a4c63 100644 --- a/src/data/composite/things/track/withPropertyFromOriginalRelease.js +++ b/src/data/composite/things/track/withPropertyFromMainRelease.js @@ -1,8 +1,8 @@ -// Provides a value inherited from the original release, if applicable, and a -// flag indicating if this track is a rerelase or not. +// Provides a value inherited from the main release, if applicable, and a +// flag indicating if this track is a secondary release or not. // -// Like withOriginalRelease, this will early exit (with notFoundValue) if the -// original release is specified by reference and that reference doesn't +// Like withMainRelease, this will early exit (with notFoundValue) if the +// main release is specified by reference and that reference doesn't // resolve to anything. import {input, templateCompositeFrom} from '#composite'; @@ -10,10 +10,10 @@ import {input, templateCompositeFrom} from '#composite'; import {withResultOfAvailabilityCheck} from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; -import withOriginalRelease from './withOriginalRelease.js'; +import withMainRelease from './withMainRelease.js'; export default templateCompositeFrom({ - annotation: `inheritFromOriginalRelease`, + annotation: `inheritFromMainRelease`, inputs: { property: input({type: 'string'}), @@ -26,18 +26,18 @@ export default templateCompositeFrom({ outputs: ({ [input.staticValue('property')]: property, }) => - ['#isRerelease'].concat( + ['#isSecondaryRelease'].concat( (property - ? ['#original.' + property] - : ['#originalValue'])), + ? ['#mainRelease.' + property] + : ['#mainReleaseValue'])), steps: () => [ - withOriginalRelease({ + withMainRelease({ notFoundValue: input('notFoundValue'), }), withResultOfAvailabilityCheck({ - from: '#originalRelease', + from: '#mainRelease', }), { @@ -54,14 +54,14 @@ export default templateCompositeFrom({ ? continuation() : continuation.raiseOutput( Object.assign( - {'#isRerelease': false}, + {'#isSecondaryRelease': false}, (property - ? {['#original.' + property]: null} - : {'#originalValue': null})))), + ? {['#mainRelease.' + property]: null} + : {'#mainReleaseValue': null})))), }, withPropertyFromObject({ - object: '#originalRelease', + object: '#mainRelease', property: input('property'), }), @@ -77,10 +77,10 @@ export default templateCompositeFrom({ }) => continuation.raiseOutput( Object.assign( - {'#isRerelease': true}, + {'#isSecondaryRelease': true}, (property - ? {['#original.' + property]: value} - : {'#originalValue': value}))), + ? {['#mainRelease.' + property]: value} + : {'#mainReleaseValue': value}))), }, ], }); diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js index e2c4d8bc..9b7b61c7 100644 --- a/src/data/composite/things/track/withTrackArtDate.js +++ b/src/data/composite/things/track/withTrackArtDate.js @@ -1,11 +1,3 @@ -// Gets the date of cover art release. This represents only the track's own -// unique cover artwork, if any. -// -// If the 'fallback' option is false (the default), this will only output -// the track's own coverArtDate or its album's trackArtDate. If 'fallback' -// is set, and neither of these is available, it'll output the track's own -// date instead. - import {input, templateCompositeFrom} from '#composite'; import {isDate} from '#validators'; @@ -24,11 +16,6 @@ export default templateCompositeFrom({ defaultDependency: 'coverArtDate', acceptsNull: true, }), - - fallback: input({ - type: 'boolean', - defaultValue: false, - }), }, outputs: ['#trackArtDate'], @@ -57,20 +44,13 @@ export default templateCompositeFrom({ }), { - dependencies: [ - '#album.trackArtDate', - input('fallback'), - ], - + dependencies: ['#album.trackArtDate'], compute: (continuation, { ['#album.trackArtDate']: albumTrackArtDate, - [input('fallback')]: fallback, }) => (albumTrackArtDate ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate}) - : fallback - ? continuation() - : continuation.raiseOutput({'#trackArtDate': null})), + : continuation()), }, withDate().outputs({ diff --git a/src/data/composite/things/track/withTrackNumber.js b/src/data/composite/things/track/withTrackNumber.js new file mode 100644 index 00000000..61428e8c --- /dev/null +++ b/src/data/composite/things/track/withTrackNumber.js @@ -0,0 +1,50 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withIndexInList, withPropertiesFromObject} from '#composite/data'; + +import withContainingTrackSection from './withContainingTrackSection.js'; + +export default templateCompositeFrom({ + annotation: `withTrackNumber`, + + outputs: ['#trackNumber'], + + steps: () => [ + withContainingTrackSection(), + + // Zero is the fallback, not one, but in most albums the first track + // (and its intended output by this composition) will be one. + raiseOutputWithoutDependency({ + dependency: '#trackSection', + output: input.value({'#trackNumber': 0}), + }), + + withPropertiesFromObject({ + object: '#trackSection', + properties: input.value(['tracks', 'startCountingFrom']), + }), + + withIndexInList({ + list: '#trackSection.tracks', + item: input.myself(), + }), + + raiseOutputWithoutDependency({ + dependency: '#index', + output: input.value({'#trackNumber': 0}), + }), + + { + dependencies: ['#trackSection.startCountingFrom', '#index'], + compute: (continuation, { + ['#trackSection.startCountingFrom']: startCountingFrom, + ['#index']: index, + }) => continuation({ + ['#trackNumber']: + startCountingFrom + + index, + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/gobbleSoupyFind.js b/src/data/composite/wiki-data/gobbleSoupyFind.js new file mode 100644 index 00000000..aec3f5b1 --- /dev/null +++ b/src/data/composite/wiki-data/gobbleSoupyFind.js @@ -0,0 +1,39 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {withPropertyFromObject} from '#composite/data'; + +import inputSoupyFind, {getSoupyFindInputKey} from './inputSoupyFind.js'; + +export default templateCompositeFrom({ + annotation: `gobbleSoupyFind`, + + inputs: { + find: inputSoupyFind(), + }, + + outputs: ['#find'], + + steps: () => [ + { + dependencies: [input('find')], + compute: (continuation, { + [input('find')]: find, + }) => + (typeof find === 'function' + ? continuation.raiseOutput({ + ['#find']: find, + }) + : continuation({ + ['#key']: + getSoupyFindInputKey(find), + })), + }, + + withPropertyFromObject({ + object: 'find', + property: '#key', + }).outputs({ + '#value': '#find', + }), + ], +}); diff --git a/src/data/composite/wiki-data/gobbleSoupyReverse.js b/src/data/composite/wiki-data/gobbleSoupyReverse.js new file mode 100644 index 00000000..86a1061c --- /dev/null +++ b/src/data/composite/wiki-data/gobbleSoupyReverse.js @@ -0,0 +1,39 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {withPropertyFromObject} from '#composite/data'; + +import inputSoupyReverse, {getSoupyReverseInputKey} from './inputSoupyReverse.js'; + +export default templateCompositeFrom({ + annotation: `gobbleSoupyReverse`, + + inputs: { + reverse: inputSoupyReverse(), + }, + + outputs: ['#reverse'], + + steps: () => [ + { + dependencies: [input('reverse')], + compute: (continuation, { + [input('reverse')]: reverse, + }) => + (typeof reverse === 'function' + ? continuation.raiseOutput({ + ['#reverse']: reverse, + }) + : continuation({ + ['#key']: + getSoupyReverseInputKey(reverse), + })), + }, + + withPropertyFromObject({ + object: 'reverse', + property: '#key', + }).outputs({ + '#value': '#reverse', + }), + ], +}); diff --git a/src/data/composite/wiki-data/helpers/withResolvedReverse.js b/src/data/composite/wiki-data/helpers/withResolvedReverse.js new file mode 100644 index 00000000..818f60b7 --- /dev/null +++ b/src/data/composite/wiki-data/helpers/withResolvedReverse.js @@ -0,0 +1,40 @@ +// Actually execute a reverse function. + +import {input, templateCompositeFrom} from '#composite'; + +import inputWikiData from '../inputWikiData.js'; + +export default templateCompositeFrom({ + annotation: `withReverseReferenceList`, + + inputs: { + data: inputWikiData({allowMixedTypes: true}), + reverse: input({type: 'function'}), + options: input({type: 'object', defaultValue: null}), + }, + + outputs: ['#resolvedReverse'], + + steps: () => [ + { + dependencies: [ + input.myself(), + input('data'), + input('reverse'), + input('options'), + ], + + compute: (continuation, { + [input.myself()]: myself, + [input('data')]: data, + [input('reverse')]: reverseFunction, + [input('options')]: opts, + }) => continuation({ + ['#resolvedReverse']: + (data + ? reverseFunction(myself, data, opts) + : reverseFunction(myself, opts)), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/helpers/withReverseList-template.js b/src/data/composite/wiki-data/helpers/withReverseList-template.js deleted file mode 100644 index 6ffd5d70..00000000 --- a/src/data/composite/wiki-data/helpers/withReverseList-template.js +++ /dev/null @@ -1,193 +0,0 @@ -// Baseline implementation shared by or underlying reverse lists. -// -// This is a very rudimentary "these compositions have basically the same -// shape but slightly different guts midway through" kind of solution, -// and should use compositional subroutines instead, once those are ready. -// -// But, until then, this has the same effect of avoiding code duplication -// and clearly identifying differences. -// -// --- -// -// This implementation uses a global cache (via WeakMap) to attempt to speed -// up subsequent similar accesses. -// -// This has absolutely not been rigorously tested with altering properties of -// data objects in a wiki data array which is reused. If a new wiki data array -// is used, a fresh cache will always be created. -// - -import {input, templateCompositeFrom} from '#composite'; -import {sortByDate} from '#sort'; -import {stitchArrays} from '#sugar'; - -import {exitWithoutDependency, raiseOutputWithoutDependency} - from '#composite/control-flow'; -import {withFlattenedList, withMappedList} from '#composite/data'; - -import inputWikiData from '../inputWikiData.js'; - -export default function withReverseList_template({ - annotation, - - propertyInputName, - outputName, - - additionalInputs = {}, - - customCompositionSteps, -}) { - // Mapping of reference list property to WeakMap. - // Each WeakMap maps a wiki data array to another weak map, - // which in turn maps each referenced thing to an array of - // things referencing it. - const caches = new Map(); - - return templateCompositeFrom({ - annotation, - - inputs: { - data: inputWikiData({ - allowMixedTypes: true, - }), - - [propertyInputName]: input({ - type: 'string', - }), - - ...additionalInputs, - }, - - outputs: [outputName], - - steps: () => [ - // Early exit with an empty array if the data list isn't available. - exitWithoutDependency({ - dependency: input('data'), - value: input.value([]), - }), - - // Raise an empty array (don't early exit) if the data list is empty. - raiseOutputWithoutDependency({ - dependency: input('data'), - mode: input.value('empty'), - output: input.value({[outputName]: []}), - }), - - // Check for an existing cache record which corresponds to this - // property input and input('data'). If it exists, query it for the - // current thing, and raise that; if it doesn't, create it, put it - // where it needs to be, and provide it so the next steps can fill - // it in. - { - dependencies: [input(propertyInputName), input('data'), input.myself()], - - compute: (continuation, { - [input(propertyInputName)]: property, - [input('data')]: data, - [input.myself()]: myself, - }) => { - if (!caches.has(property)) { - const cache = new WeakMap(); - caches.set(property, cache); - - const cacheRecord = new WeakMap(); - cache.set(data, cacheRecord); - - return continuation({ - ['#cacheRecord']: cacheRecord, - }); - } - - const cache = caches.get(property); - - if (!cache.has(data)) { - const cacheRecord = new WeakMap(); - cache.set(data, cacheRecord); - - return continuation({ - ['#cacheRecord']: cacheRecord, - }); - } - - return continuation.raiseOutput({ - [outputName]: - cache.get(data).get(myself) ?? [], - }); - }, - }, - - ...customCompositionSteps(), - - // Actually fill in the cache record. Since we're building up a *reverse* - // reference list, track connections in terms of the referenced thing. - // Although we gather all referenced things into a set and provide that - // for sorting purposes in the next step, we *don't* reprovide the cache - // record, because we're mutating that in-place - we'll just reuse its - // existing '#cacheRecord' dependency. - { - dependencies: ['#cacheRecord', '#referencingThings', '#referencedThings'], - compute: (continuation, { - ['#cacheRecord']: cacheRecord, - ['#referencingThings']: referencingThings, - ['#referencedThings']: referencedThings, - }) => { - const allReferencedThings = new Set(); - - stitchArrays({ - referencingThing: referencingThings, - referencedThings: referencedThings, - }).forEach(({referencingThing, referencedThings}) => { - for (const referencedThing of referencedThings) { - if (cacheRecord.has(referencedThing)) { - cacheRecord.get(referencedThing).push(referencingThing); - } else { - cacheRecord.set(referencedThing, [referencingThing]); - allReferencedThings.add(referencedThing); - } - } - }); - - return continuation({ - ['#allReferencedThings']: - allReferencedThings, - }); - }, - }, - - // Sort the entries in the cache records, too, just by date - the rest of - // sorting should be handled outside of this composition, either preceding - // (changing the 'data' input) or following (sorting the output). - // Again we're mutating in place, so no need to reprovide '#cacheRecord' - // here. - { - dependencies: ['#cacheRecord', '#allReferencedThings'], - compute: (continuation, { - ['#cacheRecord']: cacheRecord, - ['#allReferencedThings']: allReferencedThings, - }) => { - for (const referencedThing of allReferencedThings) { - if (cacheRecord.has(referencedThing)) { - const referencingThings = cacheRecord.get(referencedThing); - sortByDate(referencingThings); - } - } - - return continuation(); - }, - }, - - // Then just pluck out the current object from the now-filled cache record! - { - dependencies: ['#cacheRecord', input.myself()], - compute: (continuation, { - ['#cacheRecord']: cacheRecord, - [input.myself()]: myself, - }) => continuation({ - [outputName]: - cacheRecord.get(myself) ?? [], - }), - }, - ], - }); -} diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index 51d07384..38afc2ac 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -5,23 +5,25 @@ // export {default as exitWithoutContribs} from './exitWithoutContribs.js'; +export {default as gobbleSoupyFind} from './gobbleSoupyFind.js'; +export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js'; export {default as inputNotFoundMode} from './inputNotFoundMode.js'; +export {default as inputSoupyFind} from './inputSoupyFind.js'; +export {default as inputSoupyReverse} from './inputSoupyReverse.js'; export {default as inputWikiData} from './inputWikiData.js'; +export {default as splitContentNodesAround} from './splitContentNodesAround.js'; export {default as withClonedThings} from './withClonedThings.js'; +export {default as withConstitutedArtwork} from './withConstitutedArtwork.js'; +export {default as withContentNodes} from './withContentNodes.js'; export {default as withContributionListSums} from './withContributionListSums.js'; export {default as withCoverArtDate} from './withCoverArtDate.js'; export {default as withDirectory} from './withDirectory.js'; -export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js'; export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js'; export {default as withRedatedContributionList} from './withRedatedContributionList.js'; export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js'; export {default as withResolvedContribs} from './withResolvedContribs.js'; export {default as withResolvedReference} from './withResolvedReference.js'; export {default as withResolvedReferenceList} from './withResolvedReferenceList.js'; -export {default as withResolvedSeriesList} from './withResolvedSeriesList.js'; -export {default as withReverseAnnotatedReferenceList} from './withReverseAnnotatedReferenceList.js'; -export {default as withReverseContributionList} from './withReverseContributionList.js'; export {default as withReverseReferenceList} from './withReverseReferenceList.js'; -export {default as withReverseSingleReferenceList} from './withReverseSingleReferenceList.js'; export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js'; export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js'; diff --git a/src/data/composite/wiki-data/inputSoupyFind.js b/src/data/composite/wiki-data/inputSoupyFind.js new file mode 100644 index 00000000..020f4990 --- /dev/null +++ b/src/data/composite/wiki-data/inputSoupyFind.js @@ -0,0 +1,28 @@ +import {input} from '#composite'; +import {anyOf, isFunction, isString} from '#validators'; + +function inputSoupyFind() { + return input({ + validate: + anyOf( + isFunction, + val => { + isString(val); + + if (!val.startsWith('_soupyFind:')) { + throw new Error(`Expected soupyFind.input() token`); + } + + return true; + }), + }); +} + +inputSoupyFind.input = key => + input.value('_soupyFind:' + key); + +export default inputSoupyFind; + +export function getSoupyFindInputKey(value) { + return value.slice('_soupyFind:'.length); +} diff --git a/src/data/composite/wiki-data/inputSoupyReverse.js b/src/data/composite/wiki-data/inputSoupyReverse.js new file mode 100644 index 00000000..0b0a23fe --- /dev/null +++ b/src/data/composite/wiki-data/inputSoupyReverse.js @@ -0,0 +1,32 @@ +import {input} from '#composite'; +import {anyOf, isFunction, isString} from '#validators'; + +function inputSoupyReverse() { + return input({ + validate: + anyOf( + isFunction, + val => { + isString(val); + + if (!val.startsWith('_soupyReverse:')) { + throw new Error(`Expected soupyReverse.input() token`); + } + + return true; + }), + }); +} + +inputSoupyReverse.input = key => + input.value('_soupyReverse:' + key); + +export default inputSoupyReverse; + +export function getSoupyReverseInputKey(value) { + return value.slice('_soupyReverse:'.length).replace(/\.unique$/, ''); +} + +export function doesSoupyReverseInputWantUnique(value) { + return value.endsWith('.unique'); +} diff --git a/src/data/composite/wiki-data/inputWikiData.js b/src/data/composite/wiki-data/inputWikiData.js index cf7a7c2c..b9021986 100644 --- a/src/data/composite/wiki-data/inputWikiData.js +++ b/src/data/composite/wiki-data/inputWikiData.js @@ -12,6 +12,6 @@ export default function inputWikiData({ } = {}) { return input({ validate: validateWikiData({referenceType, allowMixedTypes}), - acceptsNull: true, + defaultValue: null, }); } diff --git a/src/data/composite/wiki-data/splitContentNodesAround.js b/src/data/composite/wiki-data/splitContentNodesAround.js new file mode 100644 index 00000000..6648d8e1 --- /dev/null +++ b/src/data/composite/wiki-data/splitContentNodesAround.js @@ -0,0 +1,87 @@ +import {input, templateCompositeFrom} from '#composite'; +import {splitContentNodesAround} from '#replacer'; +import {anyOf, isFunction, validateInstanceOf} from '#validators'; + +import {withFilteredList, withMappedList, withUnflattenedList} + from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `splitContentNodesAround`, + + inputs: { + nodes: input({type: 'array'}), + + around: input({ + validate: + anyOf(isFunction, validateInstanceOf(RegExp)), + }), + }, + + outputs: ['#contentNodeLists'], + + steps: () => [ + { + dependencies: [input('nodes'), input('around')], + + compute: (continuation, { + [input('nodes')]: nodes, + [input('around')]: splitter, + }) => continuation({ + ['#nodes']: + Array.from(splitContentNodesAround(nodes, splitter)), + }), + }, + + withMappedList({ + list: '#nodes', + map: input.value(node => node.type === 'separator'), + }).outputs({ + '#mappedList': '#separatorFilter', + }), + + withMappedList({ + list: '#separatorFilter', + filter: '#separatorFilter', + map: input.value((_node, index) => index), + }), + + withFilteredList({ + list: '#mappedList', + filter: '#separatorFilter', + }).outputs({ + '#filteredList': '#separatorIndices', + }), + + { + dependencies: ['#nodes', '#separatorFilter'], + + compute: (continuation, { + ['#nodes']: nodes, + ['#separatorFilter']: separatorFilter, + }) => continuation({ + ['#nodes']: + nodes.map((node, index) => + (separatorFilter[index] + ? null + : node)), + }), + }, + + { + dependencies: ['#separatorIndices'], + compute: (continuation, { + ['#separatorIndices']: separatorIndices, + }) => continuation({ + ['#unflattenIndices']: + [0, ...separatorIndices], + }), + }, + + withUnflattenedList({ + list: '#nodes', + indices: '#unflattenIndices', + }).outputs({ + '#unflattenedList': '#contentNodeLists', + }), + ], +}); diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js new file mode 100644 index 00000000..28d719e2 --- /dev/null +++ b/src/data/composite/wiki-data/withConstitutedArtwork.js @@ -0,0 +1,60 @@ +import {input, templateCompositeFrom} from '#composite'; +import thingConstructors from '#things'; + +export default templateCompositeFrom({ + annotation: `withConstitutedArtwork`, + + inputs: { + thingProperty: input({type: 'string', acceptsNull: true}), + dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}), + fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}), + dateFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsArtistProperty: input({type: 'string', acceptsNull: true}), + artTagsFromThingProperty: input({type: 'string', acceptsNull: true}), + referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}), + }, + + outputs: ['#constitutedArtwork'], + + steps: () => [ + { + dependencies: [ + input.myself(), + input('thingProperty'), + input('dimensionsFromThingProperty'), + input('fileExtensionFromThingProperty'), + input('dateFromThingProperty'), + input('artistContribsFromThingProperty'), + input('artistContribsArtistProperty'), + input('artTagsFromThingProperty'), + input('referencedArtworksFromThingProperty'), + ], + + compute: (continuation, { + [input.myself()]: myself, + [input('thingProperty')]: thingProperty, + [input('dimensionsFromThingProperty')]: dimensionsFromThingProperty, + [input('fileExtensionFromThingProperty')]: fileExtensionFromThingProperty, + [input('dateFromThingProperty')]: dateFromThingProperty, + [input('artistContribsFromThingProperty')]: artistContribsFromThingProperty, + [input('artistContribsArtistProperty')]: artistContribsArtistProperty, + [input('artTagsFromThingProperty')]: artTagsFromThingProperty, + [input('referencedArtworksFromThingProperty')]: referencedArtworksFromThingProperty, + }) => continuation({ + ['#constitutedArtwork']: + Object.assign(new thingConstructors.Artwork, { + thing: myself, + thingProperty, + dimensionsFromThingProperty, + fileExtensionFromThingProperty, + artistContribsFromThingProperty, + artistContribsArtistProperty, + artTagsFromThingProperty, + dateFromThingProperty, + referencedArtworksFromThingProperty, + }), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/withContentNodes.js b/src/data/composite/wiki-data/withContentNodes.js new file mode 100644 index 00000000..d014d43b --- /dev/null +++ b/src/data/composite/wiki-data/withContentNodes.js @@ -0,0 +1,25 @@ +import {input, templateCompositeFrom} from '#composite'; +import {parseContentNodes} from '#replacer'; + +export default templateCompositeFrom({ + annotation: `withContentNodes`, + + inputs: { + from: input({type: 'string', acceptsNull: false}), + }, + + outputs: ['#contentNodes'], + + steps: () => [ + { + dependencies: [input('from')], + + compute: (continuation, { + [input('from')]: string, + }) => continuation({ + ['#contentNodes']: + parseContentNodes(string), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/wiki-data/withCoverArtDate.js index 0c644c77..a114d5ff 100644 --- a/src/data/composite/wiki-data/withCoverArtDate.js +++ b/src/data/composite/wiki-data/withCoverArtDate.js @@ -1,7 +1,3 @@ -// Gets the current thing's coverArtDate, or, if the 'fallback' option is set, -// the thing's date. This is always null if the thing doesn't actually have -// any coverArtistContribs. - import {input, templateCompositeFrom} from '#composite'; import {isDate} from '#validators'; @@ -18,11 +14,6 @@ export default templateCompositeFrom({ defaultDependency: 'coverArtDate', acceptsNull: true, }), - - fallback: input({ - type: 'boolean', - defaultValue: false, - }), }, outputs: ['#coverArtDate'], @@ -50,21 +41,11 @@ export default templateCompositeFrom({ }, { - dependencies: [input('fallback')], - compute: (continuation, { - [input('fallback')]: fallback, - }) => - (fallback - ? continuation() - : continuation.raiseOutput({'#coverArtDate': null})), - }, - - { dependencies: ['date'], compute: (continuation, {date}) => (date - ? continuation.raiseOutput({'#coverArtDate': date}) - : continuation.raiseOutput({'#coverArtDate': null})), + ? continuation({'#coverArtDate': date}) + : continuation({'#coverArtDate': null})), }, ], }); diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js deleted file mode 100644 index 144781a8..00000000 --- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js +++ /dev/null @@ -1,261 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {stitchArrays} from '#sugar'; -import {isCommentary} from '#validators'; -import {commentaryRegexCaseSensitive} from '#wiki-data'; - -import { - fillMissingListItems, - withFlattenedList, - withPropertiesFromList, - withUnflattenedList, -} from '#composite/data'; - -import withResolvedReferenceList from './withResolvedReferenceList.js'; - -export default templateCompositeFrom({ - annotation: `withParsedCommentaryEntries`, - - inputs: { - from: input({validate: isCommentary}), - }, - - outputs: ['#parsedCommentaryEntries'], - - steps: () => [ - { - dependencies: [input('from')], - - compute: (continuation, { - [input('from')]: commentaryText, - }) => continuation({ - ['#rawMatches']: - Array.from(commentaryText.matchAll(commentaryRegexCaseSensitive)), - }), - }, - - withPropertiesFromList({ - list: '#rawMatches', - properties: input.value([ - '0', // The entire match as a string. - 'groups', - 'index', - ]), - }).outputs({ - '#rawMatches.0': '#rawMatches.text', - '#rawMatches.groups': '#rawMatches.groups', - '#rawMatches.index': '#rawMatches.startIndex', - }), - - { - dependencies: [ - '#rawMatches.text', - '#rawMatches.startIndex', - ], - - compute: (continuation, { - ['#rawMatches.text']: text, - ['#rawMatches.startIndex']: startIndex, - }) => continuation({ - ['#rawMatches.endIndex']: - stitchArrays({text, startIndex}) - .map(({text, startIndex}) => startIndex + text.length), - }), - }, - - { - dependencies: [ - input('from'), - '#rawMatches.startIndex', - '#rawMatches.endIndex', - ], - - compute: (continuation, { - [input('from')]: commentaryText, - ['#rawMatches.startIndex']: startIndex, - ['#rawMatches.endIndex']: endIndex, - }) => continuation({ - ['#entries.body']: - stitchArrays({startIndex, endIndex}) - .map(({endIndex}, index, stitched) => - (index === stitched.length - 1 - ? commentaryText.slice(endIndex) - : commentaryText.slice( - endIndex, - stitched[index + 1].startIndex))) - .map(body => body.trim()), - }), - }, - - withPropertiesFromList({ - list: '#rawMatches.groups', - prefix: input.value('#entries'), - properties: input.value([ - 'artistReferences', - 'artistDisplayText', - 'annotation', - 'date', - 'secondDate', - 'dateKind', - 'accessDate', - 'accessKind', - ]), - }), - - // The artistReferences group will always have a value, since it's required - // for the line to match in the first place. - - { - dependencies: ['#entries.artistReferences'], - compute: (continuation, { - ['#entries.artistReferences']: artistReferenceTexts, - }) => continuation({ - ['#entries.artistReferences']: - artistReferenceTexts - .map(text => text.split(',').map(ref => ref.trim())), - }), - }, - - withFlattenedList({ - list: '#entries.artistReferences', - }), - - withResolvedReferenceList({ - list: '#flattenedList', - data: 'artistData', - find: input.value(find.artist), - notFoundMode: input.value('null'), - }), - - withUnflattenedList({ - list: '#resolvedReferenceList', - }).outputs({ - '#unflattenedList': '#entries.artists', - }), - - fillMissingListItems({ - list: '#entries.artistDisplayText', - fill: input.value(null), - }), - - fillMissingListItems({ - list: '#entries.annotation', - fill: input.value(null), - }), - - { - dependencies: ['#entries.annotation'], - compute: (continuation, { - ['#entries.annotation']: annotation, - }) => continuation({ - ['#entries.webArchiveDate']: - annotation - .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//)) - .map(match => match?.[1]) - .map(dateText => - (dateText - ? dateText.slice(0, 4) + '/' + - dateText.slice(4, 6) + '/' + - dateText.slice(6, 8) - : null)), - }), - }, - - { - dependencies: ['#entries.date'], - compute: (continuation, { - ['#entries.date']: date, - }) => continuation({ - ['#entries.date']: - date - .map(date => date ? new Date(date) : null), - }), - }, - - { - dependencies: ['#entries.secondDate'], - compute: (continuation, { - ['#entries.secondDate']: secondDate, - }) => continuation({ - ['#entries.secondDate']: - secondDate - .map(date => date ? new Date(date) : null), - }), - }, - - fillMissingListItems({ - list: '#entries.dateKind', - fill: input.value(null), - }), - - { - dependencies: ['#entries.accessDate', '#entries.webArchiveDate'], - compute: (continuation, { - ['#entries.accessDate']: accessDate, - ['#entries.webArchiveDate']: webArchiveDate, - }) => continuation({ - ['#entries.accessDate']: - stitchArrays({accessDate, webArchiveDate}) - .map(({accessDate, webArchiveDate}) => - accessDate ?? - webArchiveDate ?? - null) - .map(date => date ? new Date(date) : date), - }), - }, - - { - dependencies: ['#entries.accessKind', '#entries.webArchiveDate'], - compute: (continuation, { - ['#entries.accessKind']: accessKind, - ['#entries.webArchiveDate']: webArchiveDate, - }) => continuation({ - ['#entries.accessKind']: - stitchArrays({accessKind, webArchiveDate}) - .map(({accessKind, webArchiveDate}) => - accessKind ?? - (webArchiveDate && 'captured') ?? - null), - }), - }, - - { - dependencies: [ - '#entries.artists', - '#entries.artistDisplayText', - '#entries.annotation', - '#entries.date', - '#entries.secondDate', - '#entries.dateKind', - '#entries.accessDate', - '#entries.accessKind', - '#entries.body', - ], - - compute: (continuation, { - ['#entries.artists']: artists, - ['#entries.artistDisplayText']: artistDisplayText, - ['#entries.annotation']: annotation, - ['#entries.date']: date, - ['#entries.secondDate']: secondDate, - ['#entries.dateKind']: dateKind, - ['#entries.accessDate']: accessDate, - ['#entries.accessKind']: accessKind, - ['#entries.body']: body, - }) => continuation({ - ['#parsedCommentaryEntries']: - stitchArrays({ - artists, - artistDisplayText, - annotation, - date, - secondDate, - dateKind, - accessDate, - accessKind, - body, - }), - }), - }, - ], -}); diff --git a/src/data/composite/wiki-data/withRecontextualizedContributionList.js b/src/data/composite/wiki-data/withRecontextualizedContributionList.js index d2401eac..bcc6e486 100644 --- a/src/data/composite/wiki-data/withRecontextualizedContributionList.js +++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js @@ -10,7 +10,6 @@ import {input, templateCompositeFrom} from '#composite'; import {isStringNonEmpty} from '#validators'; -import {raiseOutputWithoutDependency} from '#composite/control-flow'; import {withClonedThings} from '#composite/wiki-data'; export default templateCompositeFrom({ diff --git a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js index 789a8844..9cc52f29 100644 --- a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js @@ -1,15 +1,13 @@ import {input, templateCompositeFrom} from '#composite'; import {stitchArrays} from '#sugar'; -import {isDate, isObject, validateArrayItems} from '#validators'; +import {isObject, validateArrayItems} from '#validators'; import {withPropertyFromList} from '#composite/data'; -import { - exitWithoutDependency, - raiseOutputWithoutDependency, - withAvailabilityFilter, -} from '#composite/control-flow'; +import {raiseOutputWithoutDependency, withAvailabilityFilter} + from '#composite/control-flow'; +import inputSoupyFind from './inputSoupyFind.js'; import inputNotFoundMode from './inputNotFoundMode.js'; import inputWikiData from './inputWikiData.js'; import raiseResolvedReferenceList from './raiseResolvedReferenceList.js'; @@ -24,17 +22,12 @@ export default templateCompositeFrom({ acceptsNull: true, }), - date: input({ - validate: isDate, - acceptsNull: true, - }), - reference: input({type: 'string', defaultValue: 'reference'}), annotation: input({type: 'string', defaultValue: 'annotation'}), thing: input({type: 'string', defaultValue: 'thing'}), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), + find: inputSoupyFind(), notFoundMode: inputNotFoundMode(), }, @@ -42,11 +35,6 @@ export default templateCompositeFrom({ outputs: ['#resolvedAnnotatedReferenceList'], steps: () => [ - exitWithoutDependency({ - dependency: input('data'), - value: input.value([]), - }), - raiseOutputWithoutDependency({ dependency: input('list'), mode: input.value('empty'), @@ -98,17 +86,6 @@ export default templateCompositeFrom({ }), }, - { - dependencies: ['#matches', input('date')], - compute: (continuation, { - ['#matches']: matches, - [input('date')]: date, - }) => continuation({ - ['#matches']: - matches.map(match => ({...match, date})), - }), - }, - withAvailabilityFilter({ from: '#resolvedReferenceList', }), diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js index fd3d8a0d..838c991f 100644 --- a/src/data/composite/wiki-data/withResolvedContribs.js +++ b/src/data/composite/wiki-data/withResolvedContribs.js @@ -110,6 +110,7 @@ export default templateCompositeFrom({ '#thingProperty', input('artistProperty'), input.myself(), + 'find', ], compute: (continuation, { @@ -117,6 +118,7 @@ export default templateCompositeFrom({ ['#thingProperty']: thingProperty, [input('artistProperty')]: artistProperty, [input.myself()]: myself, + ['find']: find, }) => continuation({ ['#contributions']: details.map(details => { @@ -127,6 +129,7 @@ export default templateCompositeFrom({ thing: myself, thingProperty: thingProperty, artistProperty: artistProperty, + find: find, }); return contrib; diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js index ea71707e..6f422194 100644 --- a/src/data/composite/wiki-data/withResolvedReference.js +++ b/src/data/composite/wiki-data/withResolvedReference.js @@ -1,16 +1,14 @@ // Resolves a reference by using the provided find function to match it -// within the provided thingData dependency. This will early exit if the -// data dependency is null. Otherwise, the data object is provided on the -// output dependency, or null, if the reference doesn't match anything or +// within the provided thingData dependency. The data object is provided on +// the output dependency, or null, if the reference doesn't match anything or // itself was null to begin with. import {input, templateCompositeFrom} from '#composite'; -import { - exitWithoutDependency, - raiseOutputWithoutDependency, -} from '#composite/control-flow'; +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import gobbleSoupyFind from './gobbleSoupyFind.js'; +import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; export default templateCompositeFrom({ @@ -20,7 +18,7 @@ export default templateCompositeFrom({ ref: input({type: 'string', acceptsNull: true}), data: inputWikiData({allowMixedTypes: false}), - find: input({type: 'function'}), + find: inputSoupyFind(), }, outputs: ['#resolvedReference'], @@ -33,24 +31,26 @@ export default templateCompositeFrom({ }), }), - exitWithoutDependency({ - dependency: input('data'), + gobbleSoupyFind({ + find: input('find'), }), { dependencies: [ input('ref'), input('data'), - input('find'), + '#find', ], compute: (continuation, { [input('ref')]: ref, [input('data')]: data, - [input('find')]: findFunction, + ['#find']: findFunction, }) => continuation({ ['#resolvedReference']: - findFunction(ref, data, {mode: 'quiet'}) ?? null, + (data + ? findFunction(ref, data, {mode: 'quiet'}) ?? null + : findFunction(ref, {mode: 'quiet'}) ?? null), }), }, ], diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js index 790a962f..9dc960dd 100644 --- a/src/data/composite/wiki-data/withResolvedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedReferenceList.js @@ -1,19 +1,18 @@ // Resolves a list of references, with each reference matched with provided -// data in the same way as withResolvedReference. This will early exit if the -// data dependency is null (even if the reference list is empty). By default -// it will filter out references which don't match, but this can be changed -// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null'). +// data in the same way as withResolvedReference. By default it will filter +// out references which don't match, but this can be changed to early exit +// ({notFoundMode: 'exit'}) or leave null in place ('null'). import {input, templateCompositeFrom} from '#composite'; import {isString, validateArrayItems} from '#validators'; -import { - exitWithoutDependency, - raiseOutputWithoutDependency, - withAvailabilityFilter, -} from '#composite/control-flow'; +import {raiseOutputWithoutDependency, withAvailabilityFilter} + from '#composite/control-flow'; +import {withMappedList} from '#composite/data'; +import gobbleSoupyFind from './gobbleSoupyFind.js'; import inputNotFoundMode from './inputNotFoundMode.js'; +import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; import raiseResolvedReferenceList from './raiseResolvedReferenceList.js'; @@ -27,7 +26,7 @@ export default templateCompositeFrom({ }), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), + find: inputSoupyFind(), notFoundMode: inputNotFoundMode(), }, @@ -35,11 +34,6 @@ export default templateCompositeFrom({ outputs: ['#resolvedReferenceList'], steps: () => [ - exitWithoutDependency({ - dependency: input('data'), - value: input.value([]), - }), - raiseOutputWithoutDependency({ dependency: input('list'), mode: input.value('empty'), @@ -48,18 +42,30 @@ export default templateCompositeFrom({ }), }), + gobbleSoupyFind({ + find: input('find'), + }), + { - dependencies: [input('list'), input('data'), input('find')], + dependencies: [input('data'), '#find'], compute: (continuation, { - [input('list')]: list, [input('data')]: data, - [input('find')]: findFunction, - }) => - continuation({ - '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})), - }), + ['#find']: findFunction, + }) => continuation({ + ['#map']: + (data + ? ref => findFunction(ref, data, {mode: 'quiet'}) + : ref => findFunction(ref, {mode: 'quiet'})), + }), }, + withMappedList({ + list: input('list'), + map: '#map', + }).outputs({ + '#mappedList': '#matches', + }), + withAvailabilityFilter({ from: '#matches', }), diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js deleted file mode 100644 index 4ac74cc3..00000000 --- a/src/data/composite/wiki-data/withResolvedSeriesList.js +++ /dev/null @@ -1,131 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {stitchArrays} from '#sugar'; -import {isSeriesList, validateThing} from '#validators'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; - -import { - fillMissingListItems, - withFlattenedList, - withUnflattenedList, - withPropertiesFromList, -} from '#composite/data'; - -import withResolvedReferenceList from './withResolvedReferenceList.js'; - -export default templateCompositeFrom({ - annotation: `withResolvedSeriesList`, - - inputs: { - group: input({ - validate: validateThing({referenceType: 'group'}), - }), - - list: input({ - validate: isSeriesList, - acceptsNull: true, - }), - }, - - outputs: ['#resolvedSeriesList'], - - steps: () => [ - raiseOutputWithoutDependency({ - dependency: input('list'), - mode: input.value('empty'), - output: input.value({ - ['#resolvedSeriesList']: [], - }), - }), - - withPropertiesFromList({ - list: input('list'), - prefix: input.value('#serieses'), - properties: input.value([ - 'name', - 'description', - 'albums', - - 'showAlbumArtists', - ]), - }), - - fillMissingListItems({ - list: '#serieses.albums', - fill: input.value([]), - }), - - withFlattenedList({ - list: '#serieses.albums', - }), - - withResolvedReferenceList({ - list: '#flattenedList', - data: 'albumData', - find: input.value(find.album), - notFoundMode: input.value('null'), - }), - - withUnflattenedList({ - list: '#resolvedReferenceList', - }).outputs({ - '#unflattenedList': '#serieses.albums', - }), - - fillMissingListItems({ - list: '#serieses.description', - fill: input.value(null), - }), - - fillMissingListItems({ - list: '#serieses.showAlbumArtists', - fill: input.value(null), - }), - - { - dependencies: [ - '#serieses.name', - '#serieses.description', - '#serieses.albums', - - '#serieses.showAlbumArtists', - ], - - compute: (continuation, { - ['#serieses.name']: name, - ['#serieses.description']: description, - ['#serieses.albums']: albums, - - ['#serieses.showAlbumArtists']: showAlbumArtists, - }) => continuation({ - ['#seriesProperties']: - stitchArrays({ - name, - description, - albums, - - showAlbumArtists, - }).map(properties => ({ - ...properties, - group: input - })) - }), - }, - - { - dependencies: ['#seriesProperties', input('group')], - compute: (continuation, { - ['#seriesProperties']: seriesProperties, - [input('group')]: group, - }) => continuation({ - ['#resolvedSeriesList']: - seriesProperties - .map(properties => ({ - ...properties, - group, - })), - }), - }, - ], -}); diff --git a/src/data/composite/wiki-data/withReverseAnnotatedReferenceList.js b/src/data/composite/wiki-data/withReverseAnnotatedReferenceList.js deleted file mode 100644 index feae9ccb..00000000 --- a/src/data/composite/wiki-data/withReverseAnnotatedReferenceList.js +++ /dev/null @@ -1,116 +0,0 @@ -// Analogous implementation for withReverseReferenceList, for annotated -// references. -// -// Unlike withReverseContributionList, this composition is responsible for -// "flipping" the directionality of references: in a forward reference list, -// `thing` points to the thing being referenced, while here, it points to the -// referencing thing. -// -// This behavior can be customized to respect reference lists which are shaped -// differently than the default and/or to customize the reversed property and -// provide a less generic label than just "thing". - -import withReverseList_template from './helpers/withReverseList-template.js'; - -import {input} from '#composite'; -import {stitchArrays} from '#sugar'; - -import { - withFlattenedList, - withMappedList, - withPropertyFromList, - withStretchedList, -} from '#composite/data'; - -export default withReverseList_template({ - annotation: `withReverseAnnotatedReferenceList`, - - propertyInputName: 'list', - outputName: '#reverseAnnotatedReferenceList', - - additionalInputs: { - forward: input({type: 'string', defaultValue: 'thing'}), - backward: input({type: 'string', defaultValue: 'thing'}), - annotation: input({type: 'string', defaultValue: 'annotation'}), - }, - - customCompositionSteps: () => [ - withPropertyFromList({ - list: input('data'), - property: input('list'), - }).outputs({ - '#values': '#referenceLists', - }), - - withPropertyFromList({ - list: '#referenceLists', - property: input.value('length'), - }), - - withFlattenedList({ - list: '#referenceLists', - }).outputs({ - '#flattenedList': '#references', - }), - - withStretchedList({ - list: input('data'), - lengths: '#referenceLists.length', - }).outputs({ - '#stretchedList': '#things', - }), - - withPropertyFromList({ - list: '#references', - property: input('annotation'), - }).outputs({ - '#values': '#annotations', - }), - - withPropertyFromList({ - list: '#references', - property: input.value('date'), - }).outputs({ - '#references.date': '#dates', - }), - - { - dependencies: [ - input('backward'), - input('annotation'), - '#things', - '#annotations', - '#dates', - ], - - compute: (continuation, { - [input('backward')]: thingProperty, - [input('annotation')]: annotationProperty, - ['#things']: things, - ['#annotations']: annotations, - ['#dates']: dates, - }) => continuation({ - '#referencingThings': - stitchArrays({ - [thingProperty]: things, - [annotationProperty]: annotations, - date: dates, - }), - }), - }, - - withPropertyFromList({ - list: '#references', - property: input('forward'), - }).outputs({ - '#values': '#individualReferencedThings', - }), - - withMappedList({ - list: '#individualReferencedThings', - map: input.value(thing => [thing]), - }).outputs({ - '#mappedList': '#referencedThings', - }), - ], -}); diff --git a/src/data/composite/wiki-data/withReverseContributionList.js b/src/data/composite/wiki-data/withReverseContributionList.js deleted file mode 100644 index 2396c3b4..00000000 --- a/src/data/composite/wiki-data/withReverseContributionList.js +++ /dev/null @@ -1,37 +0,0 @@ -// Analogous implementation for withReverseReferenceList, for contributions. - -import withReverseList_template from './helpers/withReverseList-template.js'; - -import {input} from '#composite'; - -import {withFlattenedList, withMappedList, withPropertyFromList} - from '#composite/data'; - -export default withReverseList_template({ - annotation: `withReverseContributionList`, - - propertyInputName: 'list', - outputName: '#reverseContributionList', - - customCompositionSteps: () => [ - withPropertyFromList({ - list: input('data'), - property: input('list'), - }).outputs({ - '#values': '#contributionLists', - }), - - withFlattenedList({ - list: '#contributionLists', - }).outputs({ - '#flattenedList': '#referencingThings', - }), - - withMappedList({ - list: '#referencingThings', - map: input.value(contrib => [contrib.artist]), - }).outputs({ - '#mappedList': '#referencedThings', - }), - ], -}); diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js index 70d9a58d..906f5bc5 100644 --- a/src/data/composite/wiki-data/withReverseReferenceList.js +++ b/src/data/composite/wiki-data/withReverseReferenceList.js @@ -1,44 +1,36 @@ // Check out the info on reverseReferenceList! // This is its composable form. -import withReverseList_template from './helpers/withReverseList-template.js'; +import {input, templateCompositeFrom} from '#composite'; -import {input} from '#composite'; +import gobbleSoupyReverse from './gobbleSoupyReverse.js'; +import inputSoupyReverse from './inputSoupyReverse.js'; +import inputWikiData from './inputWikiData.js'; -import {withMappedList} from '#composite/data'; +import withResolvedReverse from './helpers/withResolvedReverse.js'; -export default withReverseList_template({ +export default templateCompositeFrom({ annotation: `withReverseReferenceList`, - propertyInputName: 'list', - outputName: '#reverseReferenceList', - - customCompositionSteps: () => [ - { - dependencies: [input('list')], - compute: (continuation, { - [input('list')]: list, - }) => continuation({ - ['#referenceMap']: - thing => thing[list], - }), - }, - - withMappedList({ - list: input('data'), - map: '#referenceMap', - }).outputs({ - '#mappedList': '#referencedThings', + inputs: { + data: inputWikiData({allowMixedTypes: true}), + reverse: inputSoupyReverse(), + }, + + outputs: ['#reverseReferenceList'], + + steps: () => [ + gobbleSoupyReverse({ + reverse: input('reverse'), }), - { - dependencies: [input('data')], - compute: (continuation, { - [input('data')]: data, - }) => continuation({ - ['#referencingThings']: - data, - }), - }, + // TODO: Check that the reverse spec returns a list. + + withResolvedReverse({ + data: input('data'), + reverse: '#reverse', + }).outputs({ + '#resolvedReverse': '#reverseReferenceList', + }), ], }); diff --git a/src/data/composite/wiki-data/withReverseSingleReferenceList.js b/src/data/composite/wiki-data/withReverseSingleReferenceList.js deleted file mode 100644 index dd97dc66..00000000 --- a/src/data/composite/wiki-data/withReverseSingleReferenceList.js +++ /dev/null @@ -1,50 +0,0 @@ -// Like withReverseReferenceList, but for finding all things which reference -// the current thing by a property that contains a single reference, rather -// than within a reference list. - -import withReverseList_template from './helpers/withReverseList-template.js'; - -import {input} from '#composite'; - -import {withMappedList} from '#composite/data'; - -export default withReverseList_template({ - annotation: `withReverseSingleReferenceList`, - - propertyInputName: 'ref', - outputName: '#reverseSingleReferenceList', - - customCompositionSteps: () => [ - { - dependencies: [input('data')], - compute: (continuation, { - [input('data')]: data, - }) => continuation({ - ['#referencingThings']: - data, - }), - }, - - // This map wraps each referenced thing in a single-item array. - // Each referencing thing references exactly one thing, if any. - { - dependencies: [input('ref')], - compute: (continuation, { - [input('ref')]: ref, - }) => continuation({ - ['#singleReferenceMap']: - thing => - (thing[ref] - ? [thing[ref]] - : []), - }), - }, - - withMappedList({ - list: '#referencingThings', - map: '#singleReferenceMap', - }).outputs({ - '#mappedList': '#referencedThings', - }), - ], -}); diff --git a/src/data/composite/wiki-data/withUniqueReferencingThing.js b/src/data/composite/wiki-data/withUniqueReferencingThing.js index 61c10618..7c267038 100644 --- a/src/data/composite/wiki-data/withUniqueReferencingThing.js +++ b/src/data/composite/wiki-data/withUniqueReferencingThing.js @@ -4,48 +4,33 @@ import {input, templateCompositeFrom} from '#composite'; -import {exitWithoutDependency, raiseOutputWithoutDependency} - from '#composite/control-flow'; - +import gobbleSoupyReverse from './gobbleSoupyReverse.js'; +import inputSoupyReverse from './inputSoupyReverse.js'; import inputWikiData from './inputWikiData.js'; -import withReverseReferenceList from './withReverseReferenceList.js'; + +import withResolvedReverse from './helpers/withResolvedReverse.js'; export default templateCompositeFrom({ annotation: `withUniqueReferencingThing`, inputs: { - data: inputWikiData({allowMixedTypes: false}), - list: input({type: 'string'}), + data: inputWikiData({allowMixedTypes: true}), + reverse: inputSoupyReverse(), }, outputs: ['#uniqueReferencingThing'], steps: () => [ - // Early exit with null (not an empty array) if the data list - // isn't available. - exitWithoutDependency({ - dependency: input('data'), + gobbleSoupyReverse({ + reverse: input('reverse'), }), - withReverseReferenceList({ + withResolvedReverse({ data: input('data'), - list: input('list'), + reverse: '#reverse', + options: input.value({unique: true}), + }).outputs({ + '#resolvedReverse': '#uniqueReferencingThing', }), - - raiseOutputWithoutDependency({ - dependency: '#reverseReferenceList', - mode: input.value('empty'), - output: input.value({'#uniqueReferencingThing': null}), - }), - - { - dependencies: ['#reverseReferenceList'], - compute: (continuation, { - ['#reverseReferenceList']: reverseReferenceList, - }) => continuation({ - ['#uniqueReferencingThing']: - reverseReferenceList[0], - }), - }, ], }); diff --git a/src/data/composite/wiki-properties/additionalFiles.js b/src/data/composite/wiki-properties/additionalFiles.js deleted file mode 100644 index 6760527a..00000000 --- a/src/data/composite/wiki-properties/additionalFiles.js +++ /dev/null @@ -1,30 +0,0 @@ -// This is a somewhat more involved data structure - it's for additional -// or "bonus" files associated with albums or tracks (or anything else). -// It's got this form: -// -// [ -// {title: 'Booklet', files: ['Booklet.pdf']}, -// { -// title: 'Wallpaper', -// description: 'Cool Wallpaper!', -// files: ['1440x900.png', '1920x1080.png'] -// }, -// {title: 'Alternate Covers', description: null, files: [...]}, -// ... -// ] -// - -import {isAdditionalFileList} from '#validators'; - -// TODO: Not templateCompositeFrom. - -export default function() { - return { - flags: {update: true, expose: true}, - update: {validate: isAdditionalFileList}, - expose: { - transform: (additionalFiles) => - additionalFiles ?? [], - }, - }; -} diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js deleted file mode 100644 index c5971d4a..00000000 --- a/src/data/composite/wiki-properties/additionalNameList.js +++ /dev/null @@ -1,14 +0,0 @@ -// A list of additional names! These can be used for a variety of purposes, -// e.g. providing extra searchable titles, localizations, romanizations or -// original titles, and so on. Each item has a name and, optionally, a -// descriptive annotation. - -import {isAdditionalNameList} from '#validators'; - -export default function() { - return { - flags: {update: true, expose: true}, - update: {validate: isAdditionalNameList}, - expose: {transform: value => value ?? []}, - }; -} diff --git a/src/data/composite/wiki-properties/annotatedReferenceList.js b/src/data/composite/wiki-properties/annotatedReferenceList.js index d6364475..8e6c96a1 100644 --- a/src/data/composite/wiki-properties/annotatedReferenceList.js +++ b/src/data/composite/wiki-properties/annotatedReferenceList.js @@ -1,10 +1,7 @@ import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {combineWikiDataArrays} from '#wiki-data'; import { isContentString, - isDate, optional, validateArrayItems, validateProperties, @@ -12,7 +9,7 @@ import { } from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withResolvedAnnotatedReferenceList} +import {inputSoupyFind, inputWikiData, withResolvedAnnotatedReferenceList} from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} @@ -27,12 +24,7 @@ export default templateCompositeFrom({ ...referenceListInputDescriptions(), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), - - date: input({ - validate: isDate, - acceptsNull: true, - }), + find: inputSoupyFind(), reference: input.staticValue({type: 'string', defaultValue: 'reference'}), annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}), @@ -59,8 +51,6 @@ export default templateCompositeFrom({ withResolvedAnnotatedReferenceList({ list: input.updateValue(), - date: input('date'), - reference: input('reference'), annotation: input('annotation'), thing: input('thing'), diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js deleted file mode 100644 index cd6b7ac4..00000000 --- a/src/data/composite/wiki-properties/commentary.js +++ /dev/null @@ -1,30 +0,0 @@ -// Artist commentary! Generally present on tracks and albums. - -import {input, templateCompositeFrom} from '#composite'; -import {isCommentary} from '#validators'; - -import {exitWithoutDependency, exposeDependency} - from '#composite/control-flow'; -import {withParsedCommentaryEntries} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `commentary`, - - compose: false, - - steps: () => [ - exitWithoutDependency({ - dependency: input.updateValue({validate: isCommentary}), - mode: input.value('falsy'), - value: input.value(null), - }), - - withParsedCommentaryEntries({ - from: input.updateValue(), - }), - - exposeDependency({ - dependency: '#parsedCommentaryEntries', - }), - ], -}); diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js index c5c14769..54d3e1a5 100644 --- a/src/data/composite/wiki-properties/commentatorArtists.js +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -7,7 +7,6 @@ import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly} from '#composite/data'; -import {withParsedCommentaryEntries} from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `commentatorArtists`, @@ -21,19 +20,13 @@ export default templateCompositeFrom({ value: input.value([]), }), - withParsedCommentaryEntries({ - from: 'commentary', - }), - withPropertyFromList({ - list: '#parsedCommentaryEntries', + list: 'commentary', property: input.value('artists'), - }).outputs({ - '#parsedCommentaryEntries.artists': '#artistLists', }), withFlattenedList({ - list: '#artistLists', + list: '#commentary.artists', }).outputs({ '#flattenedList': '#artists', }), diff --git a/src/data/composite/wiki-properties/constitutibleArtwork.js b/src/data/composite/wiki-properties/constitutibleArtwork.js new file mode 100644 index 00000000..48f4211a --- /dev/null +++ b/src/data/composite/wiki-properties/constitutibleArtwork.js @@ -0,0 +1,70 @@ +// This composition does not actually inspect the values of any properties +// specified, so it's not responsible for determining whether a constituted +// artwork should exist at all. + +import {input, templateCompositeFrom} from '#composite'; +import {withEntries} from '#sugar'; +import Thing from '#thing'; +import {validateThing} from '#validators'; + +import {exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {withConstitutedArtwork} from '#composite/wiki-data'; + +const template = templateCompositeFrom({ + annotation: `constitutibleArtwork`, + + compose: false, + + inputs: { + thingProperty: input({type: 'string', acceptsNull: true}), + dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}), + fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}), + dateFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsArtistProperty: input({type: 'string', acceptsNull: true}), + artTagsFromThingProperty: input({type: 'string', acceptsNull: true}), + referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}), + }, + + steps: () => [ + exposeUpdateValueOrContinue({ + validate: input.value( + validateThing({ + referenceType: 'artwork', + })), + }), + + withConstitutedArtwork({ + thingProperty: input('thingProperty'), + dimensionsFromThingProperty: input('dimensionsFromThingProperty'), + fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'), + dateFromThingProperty: input('dateFromThingProperty'), + artistContribsFromThingProperty: input('artistContribsFromThingProperty'), + artistContribsArtistProperty: input('artistContribsArtistProperty'), + artTagsFromThingProperty: input('artTagsFromThingProperty'), + referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'), + }), + + exposeDependency({ + dependency: '#constitutedArtwork', + }), + ], +}); + +template.fromYAMLFieldSpec = function(field) { + const {[Thing.yamlDocumentSpec]: documentSpec} = this; + + const {provide} = documentSpec.fields[field].transform; + + const inputs = + withEntries(provide, entries => + entries.map(([property, value]) => [ + property, + input.value(value), + ])); + + return template(inputs); +}; + +export default template; diff --git a/src/data/composite/wiki-properties/constitutibleArtworkList.js b/src/data/composite/wiki-properties/constitutibleArtworkList.js new file mode 100644 index 00000000..dad3a957 --- /dev/null +++ b/src/data/composite/wiki-properties/constitutibleArtworkList.js @@ -0,0 +1,72 @@ +// This composition does not actually inspect the values of any properties +// specified, so it's not responsible for determining whether a constituted +// artwork should exist at all. + +import {input, templateCompositeFrom} from '#composite'; +import {withEntries} from '#sugar'; +import Thing from '#thing'; +import {validateWikiData} from '#validators'; + +import {exposeUpdateValueOrContinue} from '#composite/control-flow'; +import {withConstitutedArtwork} from '#composite/wiki-data'; + +const template = templateCompositeFrom({ + annotation: `constitutibleArtworkList`, + + compose: false, + + inputs: { + thingProperty: input({type: 'string', acceptsNull: true}), + dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}), + fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}), + dateFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsArtistProperty: input({type: 'string', acceptsNull: true}), + artTagsFromThingProperty: input({type: 'string', acceptsNull: true}), + referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}), + }, + + steps: () => [ + exposeUpdateValueOrContinue({ + validate: input.value( + validateWikiData({ + referenceType: 'artwork', + })), + }), + + withConstitutedArtwork({ + thingProperty: input('thingProperty'), + dimensionsFromThingProperty: input('dimensionsFromThingProperty'), + fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'), + dateFromThingProperty: input('dateFromThingProperty'), + artistContribsFromThingProperty: input('artistContribsFromThingProperty'), + artistContribsArtistProperty: input('artistContribsArtistProperty'), + artTagsFromThingProperty: input('artTagsFromThingProperty'), + referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'), + }), + + { + dependencies: ['#constitutedArtwork'], + compute: ({ + ['#constitutedArtwork']: constitutedArtwork, + }) => [constitutedArtwork], + }, + ], +}); + +template.fromYAMLFieldSpec = function(field) { + const {[Thing.yamlDocumentSpec]: documentSpec} = this; + + const {provide} = documentSpec.fields[field].transform; + + const inputs = + withEntries(provide, entries => + entries.map(([property, value]) => [ + property, + input.value(value), + ])); + + return template(inputs); +}; + +export default template; diff --git a/src/data/composite/wiki-properties/directory.js b/src/data/composite/wiki-properties/directory.js index 9ca2a204..1756a8e5 100644 --- a/src/data/composite/wiki-properties/directory.js +++ b/src/data/composite/wiki-properties/directory.js @@ -18,6 +18,7 @@ export default templateCompositeFrom({ name: input({ validate: isName, defaultDependency: 'name', + acceptsNull: true, }), suffix: input({ diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index b55616c0..e8f109d3 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -3,12 +3,11 @@ // Entries here may depend on entries in #composite/control-flow, // #composite/data, and #composite/wiki-data. -export {default as additionalFiles} from './additionalFiles.js'; -export {default as additionalNameList} from './additionalNameList.js'; export {default as annotatedReferenceList} from './annotatedReferenceList.js'; export {default as color} from './color.js'; -export {default as commentary} from './commentary.js'; export {default as commentatorArtists} from './commentatorArtists.js'; +export {default as constitutibleArtwork} from './constitutibleArtwork.js'; +export {default as constitutibleArtworkList} from './constitutibleArtworkList.js'; export {default as contentString} from './contentString.js'; export {default as contribsPresent} from './contribsPresent.js'; export {default as contributionList} from './contributionList.js'; @@ -21,15 +20,12 @@ export {default as flag} from './flag.js'; export {default as name} from './name.js'; export {default as referenceList} from './referenceList.js'; export {default as referencedArtworkList} from './referencedArtworkList.js'; -export {default as reverseAnnotatedReferenceList} from './reverseAnnotatedReferenceList.js'; -export {default as reverseContributionList} from './reverseContributionList.js'; export {default as reverseReferenceList} from './reverseReferenceList.js'; -export {default as reverseReferencedArtworkList} from './reverseReferencedArtworkList.js'; -export {default as reverseSingleReferenceList} from './reverseSingleReferenceList.js'; -export {default as seriesList} from './seriesList.js'; export {default as simpleDate} from './simpleDate.js'; export {default as simpleString} from './simpleString.js'; export {default as singleReference} from './singleReference.js'; +export {default as soupyFind} from './soupyFind.js'; +export {default as soupyReverse} from './soupyReverse.js'; export {default as thing} from './thing.js'; export {default as thingList} from './thingList.js'; export {default as urls} from './urls.js'; diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js index 4d4cb106..4f8207b5 100644 --- a/src/data/composite/wiki-properties/referenceList.js +++ b/src/data/composite/wiki-properties/referenceList.js @@ -11,7 +11,8 @@ import {input, templateCompositeFrom} from '#composite'; import {validateReferenceList} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withResolvedReferenceList} from '#composite/wiki-data'; +import {inputSoupyFind, inputWikiData, withResolvedReferenceList} + from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} from './helpers/reference-list-helpers.js'; @@ -25,7 +26,7 @@ export default templateCompositeFrom({ ...referenceListInputDescriptions(), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), + find: inputSoupyFind(), }, update: diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js index 819b2f43..4f243493 100644 --- a/src/data/composite/wiki-properties/referencedArtworkList.js +++ b/src/data/composite/wiki-properties/referencedArtworkList.js @@ -1,7 +1,5 @@ import {input, templateCompositeFrom} from '#composite'; import find from '#find'; -import {isDate} from '#validators'; -import {combineWikiDataArrays} from '#wiki-data'; import annotatedReferenceList from './annotatedReferenceList.js'; @@ -10,47 +8,24 @@ export default templateCompositeFrom({ compose: false, - inputs: { - date: input({ - validate: isDate, - acceptsNull: true, - }), - }, - steps: () => [ { - dependencies: [ - 'albumData', - 'trackData', - ], - - compute: (continuation, { - albumData, - trackData, - }) => continuation({ - ['#data']: - combineWikiDataArrays([ - albumData, - trackData, - ]), - }), - }, - - { compute: (continuation) => continuation({ ['#find']: find.mixed({ - track: find.trackWithArtwork, - album: find.albumWithArtwork, + track: find.trackPrimaryArtwork, + album: find.albumPrimaryArtwork, }), }), }, annotatedReferenceList({ referenceType: input.value(['album', 'track']), - data: '#data', + + data: 'artworkData', find: '#find', - date: input('date'), + + thing: input.value('artwork'), }), ], }); diff --git a/src/data/composite/wiki-properties/reverseAnnotatedReferenceList.js b/src/data/composite/wiki-properties/reverseAnnotatedReferenceList.js deleted file mode 100644 index ba7166b9..00000000 --- a/src/data/composite/wiki-properties/reverseAnnotatedReferenceList.js +++ /dev/null @@ -1,33 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseAnnotatedReferenceList} - from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `reverseAnnotatedReferenceList`, - - compose: false, - - inputs: { - data: inputWikiData({allowMixedTypes: false}), - list: input({type: 'string'}), - - forward: input({type: 'string', defaultValue: 'thing'}), - backward: input({type: 'string', defaultValue: 'thing'}), - annotation: input({type: 'string', defaultValue: 'annotation'}), - }, - - steps: () => [ - withReverseAnnotatedReferenceList({ - data: input('data'), - list: input('list'), - - forward: input('forward'), - backward: input('backward'), - annotation: input('annotation'), - }), - - exposeDependency({dependency: '#reverseAnnotatedReferenceList'}), - ], -}); diff --git a/src/data/composite/wiki-properties/reverseContributionList.js b/src/data/composite/wiki-properties/reverseContributionList.js deleted file mode 100644 index 7f3f9c81..00000000 --- a/src/data/composite/wiki-properties/reverseContributionList.js +++ /dev/null @@ -1,24 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseContributionList} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `reverseContributionList`, - - compose: false, - - inputs: { - data: inputWikiData({allowMixedTypes: false}), - list: input({type: 'string'}), - }, - - steps: () => [ - withReverseContributionList({ - data: input('data'), - list: input('list'), - }), - - exposeDependency({dependency: '#reverseContributionList'}), - ], -}); diff --git a/src/data/composite/wiki-properties/reverseReferenceList.js b/src/data/composite/wiki-properties/reverseReferenceList.js index 84ba67df..6d590a67 100644 --- a/src/data/composite/wiki-properties/reverseReferenceList.js +++ b/src/data/composite/wiki-properties/reverseReferenceList.js @@ -1,13 +1,13 @@ // Neat little shortcut for "reversing" the reference lists stored on other // things - for example, tracks specify a "referenced tracks" property, and // you would use this to compute a corresponding "referenced *by* tracks" -// property. Naturally, the passed ref list property is of the things in the -// wiki data provided, not the requesting Thing itself. +// property. import {input, templateCompositeFrom} from '#composite'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseReferenceList} from '#composite/wiki-data'; +import {inputSoupyReverse, inputWikiData, withReverseReferenceList} + from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `reverseReferenceList`, @@ -15,14 +15,14 @@ export default templateCompositeFrom({ compose: false, inputs: { - data: inputWikiData({allowMixedTypes: false}), - list: input({type: 'string'}), + data: inputWikiData({allowMixedTypes: true}), + reverse: inputSoupyReverse(), }, steps: () => [ withReverseReferenceList({ data: input('data'), - list: input('list'), + reverse: input('reverse'), }), exposeDependency({dependency: '#reverseReferenceList'}), diff --git a/src/data/composite/wiki-properties/reverseReferencedArtworkList.js b/src/data/composite/wiki-properties/reverseReferencedArtworkList.js deleted file mode 100644 index 2950bdb9..00000000 --- a/src/data/composite/wiki-properties/reverseReferencedArtworkList.js +++ /dev/null @@ -1,39 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import {combineWikiDataArrays} from '#wiki-data'; - -import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseAnnotatedReferenceList} - from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `reverseReferencedArtworkList`, - - compose: false, - - steps: () => [ - { - dependencies: [ - 'albumData', - 'trackData', - ], - - compute: (continuation, { - albumData, - trackData, - }) => continuation({ - ['#data']: - combineWikiDataArrays([ - albumData, - trackData, - ]), - }), - }, - - withReverseAnnotatedReferenceList({ - data: '#data', - list: input.value('referencedArtworks'), - }), - - exposeDependency({dependency: '#reverseAnnotatedReferenceList'}), - ], -}); diff --git a/src/data/composite/wiki-properties/reverseSingleReferenceList.js b/src/data/composite/wiki-properties/reverseSingleReferenceList.js deleted file mode 100644 index d180b12d..00000000 --- a/src/data/composite/wiki-properties/reverseSingleReferenceList.js +++ /dev/null @@ -1,24 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseSingleReferenceList} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `reverseSingleReferenceList`, - - compose: false, - - inputs: { - data: inputWikiData({allowMixedTypes: false}), - ref: input({type: 'string'}), - }, - - steps: () => [ - withReverseSingleReferenceList({ - data: input('data'), - ref: input('ref'), - }), - - exposeDependency({dependency: '#reverseSingleReferenceList'}), - ], -}); diff --git a/src/data/composite/wiki-properties/seriesList.js b/src/data/composite/wiki-properties/seriesList.js deleted file mode 100644 index 2a101b45..00000000 --- a/src/data/composite/wiki-properties/seriesList.js +++ /dev/null @@ -1,31 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import {isSeriesList, validateThing} from '#validators'; - -import {exposeDependency} from '#composite/control-flow'; -import {withResolvedSeriesList} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `seriesList`, - - compose: false, - - inputs: { - group: input({ - validate: validateThing({referenceType: 'group'}), - }), - }, - - steps: () => [ - withResolvedSeriesList({ - group: input('group'), - - list: input.updateValue({ - validate: isSeriesList, - }), - }), - - exposeDependency({ - dependency: '#resolvedSeriesList', - }), - ], -}); diff --git a/src/data/composite/wiki-properties/singleReference.js b/src/data/composite/wiki-properties/singleReference.js index db4fc9f9..f532ebbe 100644 --- a/src/data/composite/wiki-properties/singleReference.js +++ b/src/data/composite/wiki-properties/singleReference.js @@ -11,7 +11,8 @@ import {input, templateCompositeFrom} from '#composite'; import {isThingClass, validateReference} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withResolvedReference} from '#composite/wiki-data'; +import {inputSoupyFind, inputWikiData, withResolvedReference} + from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `singleReference`, @@ -21,8 +22,7 @@ export default templateCompositeFrom({ inputs: { class: input.staticValue({validate: isThingClass}), - find: input({type: 'function'}), - + find: inputSoupyFind(), data: inputWikiData({allowMixedTypes: false}), }, diff --git a/src/data/composite/wiki-properties/soupyFind.js b/src/data/composite/wiki-properties/soupyFind.js new file mode 100644 index 00000000..0f9a17e3 --- /dev/null +++ b/src/data/composite/wiki-properties/soupyFind.js @@ -0,0 +1,14 @@ +import {isObject} from '#validators'; + +import {inputSoupyFind} from '#composite/wiki-data'; + +function soupyFind() { + return { + flags: {update: true}, + update: {validate: isObject}, + }; +} + +soupyFind.input = inputSoupyFind.input; + +export default soupyFind; diff --git a/src/data/composite/wiki-properties/soupyReverse.js b/src/data/composite/wiki-properties/soupyReverse.js new file mode 100644 index 00000000..784a66b4 --- /dev/null +++ b/src/data/composite/wiki-properties/soupyReverse.js @@ -0,0 +1,37 @@ +import {isObject} from '#validators'; + +import {inputSoupyReverse} from '#composite/wiki-data'; + +function soupyReverse() { + return { + flags: {update: true}, + update: {validate: isObject}, + }; +} + +soupyReverse.input = inputSoupyReverse.input; + +soupyReverse.contributionsBy = + (bindTo, contributionsProperty) => ({ + bindTo, + + referencing: thing => thing[contributionsProperty], + referenced: contrib => [contrib.artist], + }); + +soupyReverse.artworkContributionsBy = + (bindTo, artworkProperty, {single = false} = {}) => ({ + bindTo, + + referencing: thing => + (single + ? (thing[artworkProperty] + ? thing[artworkProperty].artistContribs + : []) + : thing[artworkProperty] + .flatMap(artwork => artwork.artistContribs)), + + referenced: contrib => [contrib.artist], + }); + +export default soupyReverse; diff --git a/src/data/thing.js b/src/data/thing.js index 78ad3642..f719224d 100644 --- a/src/data/thing.js +++ b/src/data/thing.js @@ -16,9 +16,19 @@ export default class Thing extends CacheableObject { static findSpecs = Symbol.for('Thing.findSpecs'); static findThisThingOnly = Symbol.for('Thing.findThisThingOnly'); + static reverseSpecs = Symbol.for('Thing.reverseSpecs'); + static yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec'); static getYamlLoadingSpec = Symbol.for('Thing.getYamlLoadingSpec'); + static yamlSourceFilename = Symbol.for('Thing.yamlSourceFilename'); + static yamlSourceDocument = Symbol.for('Thing.yamlSourceDocument'); + static yamlSourceDocumentPlacement = Symbol.for('Thing.yamlSourceDocumentPlacement'); + + [Symbol.for('Thing.yamlSourceFilename')] = null; + [Symbol.for('Thing.yamlSourceDocument')] = null; + [Symbol.for('Thing.yamlSourceDocumentPlacement')] = null; + static isThingConstructor = Symbol.for('Thing.isThingConstructor'); static isThing = Symbol.for('Thing.isThing'); @@ -26,14 +36,15 @@ export default class Thing extends CacheableObject { // Symbol.for('Thing.isThingConstructor') in constructor static [Symbol.for('Thing.isThingConstructor')] = NaN; - static [CacheableObject.propertyDescriptors] = { + constructor() { + super({seal: false}); + // To detect: // Object.hasOwn(object, Symbol.for('Thing.isThing')) - [Symbol.for('Thing.isThing')]: { - flags: {expose: true}, - expose: {compute: () => NaN}, - }, - }; + this[Symbol.for('Thing.isThing')] = NaN; + + Object.seal(this); + } static [Symbol.for('Thing.selectAll')] = _wikiData => []; @@ -49,7 +60,7 @@ export default class Thing extends CacheableObject { if (this.name) { name = colors.green(`"${this.name}"`); } - } catch (error) { + } catch { name = colors.yellow(`couldn't get name`); } @@ -58,7 +69,7 @@ export default class Thing extends CacheableObject { if (this.directory) { reference = colors.blue(Thing.getReference(this)); } - } catch (error) { + } catch { reference = colors.yellow(`couldn't get reference`); } diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js new file mode 100644 index 00000000..398d0af5 --- /dev/null +++ b/src/data/things/additional-file.js @@ -0,0 +1,47 @@ +import {input} from '#composite'; +import Thing from '#thing'; +import {isString, validateArrayItems} from '#validators'; + +import {contentString, simpleString, thing} from '#composite/wiki-properties'; + +import {exposeConstant, exposeUpdateValueOrContinue} + from '#composite/control-flow'; + +export class AdditionalFile extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + title: simpleString(), + + description: contentString(), + + filenames: [ + exposeUpdateValueOrContinue({ + validate: input.value(validateArrayItems(isString)), + }), + + exposeConstant({ + value: input.value([]), + }), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Title': {property: 'title'}, + 'Description': {property: 'description'}, + 'Files': {property: 'filenames'}, + }, + }; + + get paths() { + if (!this.thing) return null; + if (!this.thing.getOwnAdditionalFilePath) return null; + + return ( + this.filenames.map(filename => + this.thing.getOwnAdditionalFilePath(this, filename))); + } +} diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js new file mode 100644 index 00000000..4c23f291 --- /dev/null +++ b/src/data/things/additional-name.js @@ -0,0 +1,21 @@ +import Thing from '#thing'; + +import {contentString, thing} from '#composite/wiki-properties'; + +export class AdditionalName extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + name: contentString(), + annotation: contentString(), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + 'Annotation': {property: 'annotation'}, + }, + }; +} diff --git a/src/data/things/album.js b/src/data/things/album.js index bd54a356..5132b962 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -6,18 +6,20 @@ import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import {traverse} from '#node-utils'; import {sortAlbumsTracksChronologically, sortChronologically} from '#sort'; -import {accumulateSum, empty} from '#sugar'; +import {empty} from '#sugar'; import Thing from '#thing'; -import {isColor, isDate, isDirectory, validateWikiData} from '#validators'; +import {isColor, isDate, isDirectory, isNumber} from '#validators'; import { parseAdditionalFiles, parseAdditionalNames, parseAnnotatedReferences, + parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, parseDate, parseDimensions, parseWallpaperParts, @@ -27,19 +29,14 @@ import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; -import { - exitWithoutContribs, - withDirectory, - withResolvedReference, - withCoverArtDate, -} from '#composite/wiki-data'; +import {exitWithoutContribs, withDirectory, withCoverArtDate} + from '#composite/wiki-data'; import { - additionalFiles, - additionalNameList, - commentary, color, commentatorArtists, + constitutibleArtwork, + constitutibleArtworkList, contentString, contribsPresent, contributionList, @@ -50,10 +47,10 @@ import { name, referencedArtworkList, referenceList, - reverseReferencedArtworkList, simpleDate, simpleString, - singleReference, + soupyFind, + soupyReverse, thing, thingList, urls, @@ -61,17 +58,21 @@ import { wikiData, } from '#composite/wiki-properties'; -import {withTracks} from '#composite/things/album'; -import {withAlbum} from '#composite/things/track-section'; +import {withHasCoverArt, withTracks} from '#composite/things/album'; +import {withAlbum, withContinueCountingFrom, withStartCountingFrom} + from '#composite/things/track-section'; export class Album extends Thing { static [Thing.referenceType] = 'album'; static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, ArtTag, - Artist, + Artwork, + CommentaryEntry, + CreditingSourcesEntry, Group, - Track, TrackSection, WikiInfo, }) => ({ @@ -92,13 +93,18 @@ export class Album extends Thing { }), ], + alwaysReferenceByDirectory: flag(false), alwaysReferenceTracksByDirectory: flag(false), suffixTrackDirectories: flag(false), + countTracksInArtistTotals: flag(true), + color: color(), urls: urls(), - additionalNames: additionalNameList(), + additionalNames: thingList({ + class: input.value(AdditionalName), + }), bandcampAlbumIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), @@ -108,16 +114,10 @@ export class Album extends Thing { dateAddedToWiki: simpleDate(), coverArtDate: [ - // ~~TODO: Why does this fall back, but Track.coverArtDate doesn't?~~ - // TODO: OK so it's because tracks don't *store* dates just like that. - // Really instead of fallback being a flag, it should be a date value, - // if this option is worth existing at all. withCoverArtDate({ from: input.updateValue({ validate: isDate, }), - - fallback: input.value(true), }), exposeDependency({dependency: '#coverArtDate'}), @@ -146,7 +146,11 @@ export class Album extends Thing { ], wallpaperParts: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + exitWithoutContribs({ + contribs: 'wallpaperArtistContribs', + value: input.value([]), + }), + wallpaperParts(), ], @@ -167,13 +171,56 @@ export class Album extends Thing { dimensions(), ], + wallpaperArtwork: [ + exitWithoutDependency({ + dependency: 'wallpaperArtistContribs', + mode: input.value('empty'), + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Wallpaper Artwork'), + ], + + bannerArtwork: [ + exitWithoutDependency({ + dependency: 'bannerArtistContribs', + mode: input.value('empty'), + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Banner Artwork'), + ], + + coverArtworks: [ + withHasCoverArt(), + + exitWithoutDependency({ + dependency: '#hasCoverArt', + mode: input.value('falsy'), + value: input.value([]), + }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + ], + hasTrackNumbers: flag(true), isListedOnHomepage: flag(true), isListedInGalleries: flag(true), - commentary: commentary(), - creditSources: commentary(), - additionalFiles: additionalFiles(), + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), + + additionalFiles: thingList({ + class: input.value(AdditionalFile), + }), trackSections: thingList({ class: input.value(TrackSection), @@ -185,9 +232,7 @@ export class Album extends Thing { }), coverArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), - }), + withCoverArtDate(), contributionList({ date: '#coverArtDate', @@ -206,9 +251,7 @@ export class Album extends Thing { }), wallpaperArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), - }), + withCoverArtDate(), contributionList({ date: '#coverArtDate', @@ -217,9 +260,7 @@ export class Album extends Thing { ], bannerArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), - }), + withCoverArtDate(), contributionList({ date: '#coverArtDate', @@ -229,8 +270,7 @@ export class Album extends Thing { groups: referenceList({ class: input.value(Group), - find: input.value(find.group), - data: 'groupData', + find: soupyFind.input('group'), }), artTags: [ @@ -241,8 +281,7 @@ export class Album extends Thing { referenceList({ class: input.value(ArtTag), - find: input.value(find.artTag), - data: 'artTagData', + find: soupyFind.input('artTag'), }), ], @@ -252,44 +291,20 @@ export class Album extends Thing { value: input.value([]), }), - { - dependencies: ['coverArtDate', 'date'], - compute: (continuation, { - coverArtDate, - date, - }) => continuation({ - ['#date']: - coverArtDate ?? date, - }), - }, - - referencedArtworkList({ - date: '#date', - }), + referencedArtworkList(), ], // Update only - albumData: wikiData({ - class: input.value(Album), - }), - - artistData: wikiData({ - class: input.value(Artist), - }), - - artTagData: wikiData({ - class: input.value(ArtTag), - }), - - groupData: wikiData({ - class: input.value(Group), - }), + find: soupyFind(), + reverse: soupyReverse(), - trackData: wikiData({ - class: input.value(Track), + // used for referencedArtworkList (mixedFind) + artworkData: wikiData({ + class: input.value(Artwork), }), + // used for withMatchingContributionPresets (indirectly by Contribution) wikiInfo: thing({ class: input.value(WikiInfo), }), @@ -298,7 +313,11 @@ export class Album extends Thing { commentatorArtists: commentatorArtists(), - hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasCoverArt: [ + withHasCoverArt(), + exposeDependency({dependency: '#hasCoverArt'}), + ], + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), @@ -306,15 +325,6 @@ export class Album extends Thing { withTracks(), exposeDependency({dependency: '#tracks'}), ], - - referencedByArtworks: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), - }), - - reverseReferencedArtworkList(), - ], }); static [Thing.getSerializeDescriptors] = ({ @@ -359,16 +369,110 @@ export class Album extends Thing { static [Thing.findSpecs] = { album: { - referenceTypes: ['album', 'album-commentary', 'album-gallery'], + referenceTypes: [ + 'album', + 'album-commentary', + 'album-gallery', + ], + bindTo: 'albumData', + + getMatchableNames: album => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), }, albumWithArtwork: { - referenceTypes: ['album'], + referenceTypes: [ + 'album', + 'album-referencing-artworks', + 'album-referenced-artworks', + ], + bindTo: 'albumData', include: album => album.hasCoverArt, + + getMatchableNames: album => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + }, + + albumPrimaryArtwork: { + [Thing.findThisThingOnly]: false, + + referenceTypes: [ + 'album', + 'album-referencing-artworks', + 'album-referenced-artworks', + ], + + bindTo: 'artworkData', + + include: (artwork, {Artwork, Album}) => + artwork instanceof Artwork && + artwork.thing instanceof Album && + artwork === artwork.thing.coverArtworks[0], + + getMatchableNames: ({thing: album}) => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + + getMatchableDirectories: ({thing: album}) => + [album.directory], + }, + }; + + static [Thing.reverseSpecs] = { + albumsWhoseTracksInclude: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.tracks, + }, + + albumsWhoseTrackSectionsInclude: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.trackSections, + }, + + albumsWhoseArtworksFeature: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.artTags, + }, + + albumsWhoseGroupsInclude: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.groups, + }, + + albumArtistContributionsBy: + soupyReverse.contributionsBy('albumData', 'artistContribs'), + + albumCoverArtistContributionsBy: + soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'), + + albumWallpaperArtistContributionsBy: + soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}), + + albumBannerArtistContributionsBy: + soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}), + + albumsWithCommentaryBy: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.commentatorArtists, }, }; @@ -380,6 +484,7 @@ export class Album extends Thing { 'Directory Suffix': {property: 'directorySuffix'}, 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, 'Always Reference Tracks By Directory': { property: 'alwaysReferenceTracksByDirectory', }, @@ -399,6 +504,8 @@ export class Album extends Thing { transform: String, }, + 'Count Tracks In Artist Totals': {property: 'countInArtistTotals'}, + 'Date': { property: 'date', transform: parseDate, @@ -411,6 +518,49 @@ export class Album extends Thing { 'Listed on Homepage': {property: 'isListedOnHomepage'}, 'Listed in Galleries': {property: 'isListedInGalleries'}, + 'Cover Artwork': { + property: 'coverArtworks', + transform: + parseArtwork({ + thingProperty: 'coverArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'albumCoverArtistContributions', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + }), + }, + + 'Banner Artwork': { + property: 'bannerArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'bannerArtwork', + dimensionsFromThingProperty: 'bannerDimensions', + fileExtensionFromThingProperty: 'bannerFileExtension', + dateFromThingProperty: 'date', + artistContribsFromThingProperty: 'bannerArtistContribs', + artistContribsArtistProperty: 'albumBannerArtistContributions', + }), + }, + + 'Wallpaper Artwork': { + property: 'wallpaperArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'wallpaperArtwork', + dimensionsFromThingProperty: null, + fileExtensionFromThingProperty: 'wallpaperFileExtension', + dateFromThingProperty: 'date', + artistContribsFromThingProperty: 'wallpaperArtistContribs', + artistContribsArtistProperty: 'albumWallpaperArtistContributions', + }), + }, + 'Cover Art Date': { property: 'coverArtDate', transform: parseDate, @@ -444,9 +594,10 @@ export class Album extends Thing { transform: parseContributors, }, - 'Wallpaper Style': {property: 'wallpaperStyle'}, 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + 'Wallpaper Style': {property: 'wallpaperStyle'}, + 'Wallpaper Parts': { property: 'wallpaperParts', transform: parseWallpaperParts, @@ -465,8 +616,15 @@ export class Album extends Thing { transform: parseDimensions, }, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, 'Additional Files': { property: 'additionalFiles', @@ -516,7 +674,7 @@ export class Album extends Thing { static [Thing.getYamlLoadingSpec] = ({ documentModes: {headerAndEntries}, - thingConstructors: {Album, Track, TrackSectionHelper}, + thingConstructors: {Album, Track}, }) => ({ title: `Process album files`, @@ -538,6 +696,12 @@ export class Album extends Thing { const trackSectionData = []; const trackData = []; + const artworkData = []; + const commentaryData = []; + const creditingSourceData = []; + const referencingSourceData = []; + const lyricsData = []; + for (const {header: album, entries} of results) { const trackSections = []; @@ -549,8 +713,6 @@ export class Album extends Thing { isDefaultTrackSection: true, }); - const albumRef = Thing.getReference(album); - const closeCurrentTrackSection = () => { if ( currentTrackSection.isDefaultTrackSection && @@ -577,17 +739,53 @@ export class Album extends Thing { currentTrackSectionTracks.push(entry); trackData.push(entry); - entry.dataSourceAlbum = albumRef; + // Set the track's album before accessing its list of artworks. + // The existence of its artwork objects may depend on access to + // its album's 'Default Track Cover Artists'. + entry.album = album; + + artworkData.push(...entry.trackArtworks); + commentaryData.push(...entry.commentary); + creditingSourceData.push(...entry.creditingSources); + referencingSourceData.push(...entry.referencingSources); + + // TODO: As exposed, Track.lyrics tries to inherit from the main + // release, which is impossible before the data's been linked. + // We just use the update value here. But it's icky! + lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []); } closeCurrentTrackSection(); albumData.push(album); + artworkData.push(...album.coverArtworks); + + if (album.bannerArtwork) { + artworkData.push(album.bannerArtwork); + } + + if (album.wallpaperArtwork) { + artworkData.push(album.wallpaperArtwork); + } + + commentaryData.push(...album.commentary); + creditingSourceData.push(...album.creditingSources); + album.trackSections = trackSections; } - return {albumData, trackSectionData, trackData}; + return { + albumData, + trackSectionData, + trackData, + + artworkData, + commentaryData, + creditingSourceData, + referencingSourceData, + lyricsData, + }; }, sort({albumData, trackData}) { @@ -595,13 +793,65 @@ export class Album extends Thing { sortAlbumsTracksChronologically(trackData); }, }); + + getOwnAdditionalFilePath(_file, filename) { + return [ + 'media.albumAdditionalFile', + this.directory, + filename, + ]; + } + + getOwnArtworkPath(artwork) { + if (artwork === this.bannerArtwork) { + return [ + 'media.albumBanner', + this.directory, + artwork.fileExtension, + ]; + } + + if (artwork === this.wallpaperArtwork) { + if (!empty(this.wallpaperParts)) { + return null; + } + + return [ + 'media.albumWallpaper', + this.directory, + artwork.fileExtension, + ]; + } + + // TODO: using trackCover here is obviously, badly wrong + // but we ought to refactor banners and wallpapers similarly + // (i.e. depend on those intrinsic artwork paths rather than + // accessing media.{albumBanner,albumWallpaper} from content + // or other code directly) + return [ + 'media.trackCover', + this.directory, + + (artwork.unqualifiedDirectory + ? 'cover-' + artwork.unqualifiedDirectory + : 'cover'), + + artwork.fileExtension, + ]; + } + + // As of writing, albums don't even have a `duration` property... + // so this function will never be called... but the message stands... + countOwnContributionInDurationTotals(_contrib) { + return false; + } } export class TrackSection extends Thing { static [Thing.friendlyName] = `Track Section`; static [Thing.referenceType] = `track-section`; - static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({Track}) => ({ // Update & expose name: name('Unnamed Track Section'), @@ -623,6 +873,14 @@ export class TrackSection extends Thing { exposeDependency({dependency: '#album.color'}), ], + startCountingFrom: [ + withStartCountingFrom({ + from: input.updateValue({validate: isNumber}), + }), + + exposeDependency({dependency: '#startCountingFrom'}), + ], + dateOriginallyReleased: simpleDate(), isDefaultTrackSection: flag(false), @@ -640,9 +898,7 @@ export class TrackSection extends Thing { // Update only - albumData: wikiData({ - class: input.value(Album), - }), + reverse: soupyReverse(), // Expose only @@ -674,42 +930,10 @@ export class TrackSection extends Thing { }, ], - startIndex: [ - withAlbum(), - - withPropertyFromObject({ - object: '#album', - property: input.value('trackSections'), - }), - - { - dependencies: ['#album.trackSections', input.myself()], - compute: (continuation, { - ['#album.trackSections']: trackSections, - [input.myself()]: myself, - }) => continuation({ - ['#index']: - trackSections.indexOf(myself), - }), - }, - - exitWithoutDependency({ - dependency: '#index', - mode: input.value('index'), - value: input.value(0), - }), + continueCountingFrom: [ + withContinueCountingFrom(), - { - dependencies: ['#album.trackSections', '#index'], - compute: ({ - ['#album.trackSections']: trackSections, - ['#index']: index, - }) => - accumulateSum( - trackSections - .slice(0, index) - .map(section => section.tracks.length)), - }, + exposeDependency({dependency: '#continueCountingFrom'}), ], }); @@ -727,10 +951,20 @@ export class TrackSection extends Thing { }, }; + static [Thing.reverseSpecs] = { + trackSectionsWhichInclude: { + bindTo: 'trackSectionData', + + referencing: trackSection => [trackSection], + referenced: trackSection => trackSection.tracks, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Section': {property: 'name'}, 'Color': {property: 'color'}, + 'Start Counting From': {property: 'startCountingFrom'}, 'Date Originally Released': { property: 'dateOriginallyReleased', @@ -746,38 +980,38 @@ export class TrackSection extends Thing { parts.push(Thing.prototype[inspect.custom].apply(this)); - if (depth >= 0) { + if (depth >= 0) showAlbum: { let album = null; try { album = this.album; - } catch {} + } catch { + break showAlbum; + } let first = null; try { - first = this.startIndex; + first = this.tracks.at(0).trackNumber; } catch {} - let length = null; + let last = null; try { - length = this.tracks.length; + last = this.tracks.at(-1).trackNumber; } catch {} - if (album) { - const albumName = album.name; - const albumIndex = album.trackSections.indexOf(this); + const albumName = album.name; + const albumIndex = album.trackSections.indexOf(this); - const num = - (albumIndex === -1 - ? 'indeterminate position' - : `#${albumIndex + 1}`); + const num = + (albumIndex === -1 + ? 'indeterminate position' + : `#${albumIndex + 1}`); - const range = - (albumIndex >= 0 && first !== null && length !== null - ? `: ${first + 1}-${first + length + 1}` - : ''); + const range = + (albumIndex >= 0 && first !== null && last !== null + ? `: ${first}-${last}` + : ''); - parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`); - } + parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`); } return parts.join(''); diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index 3149b310..518f616b 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,31 +1,45 @@ export const ART_TAG_DATA_FILE = 'tags.yaml'; import {input} from '#composite'; -import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort'; +import {sortAlphabetically} from '#sort'; import Thing from '#thing'; +import {unique} from '#sugar'; import {isName} from '#validators'; +import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml'; -import {exposeUpdateValueOrContinue} from '#composite/control-flow'; +import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; import { + annotatedReferenceList, color, + contentString, directory, flag, + referenceList, + reverseReferenceList, name, - wikiData, + soupyFind, + soupyReverse, + thingList, + urls, } from '#composite/wiki-properties'; +import {withAllDescendantArtTags, withAncestorArtTagBaobabTree} + from '#composite/things/art-tag'; + export class ArtTag extends Thing { static [Thing.referenceType] = 'tag'; static [Thing.friendlyName] = `Art Tag`; - static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({ // Update & expose name: name('Unnamed Art Tag'), directory: directory(), color: color(), isContentWarning: flag(false), + extraReadingURLs: urls(), nameShort: [ exposeUpdateValueOrContinue({ @@ -39,30 +53,74 @@ export class ArtTag extends Thing { }, ], - // Update only + additionalNames: thingList({ + class: input.value(AdditionalName), + }), - albumData: wikiData({ - class: input.value(Album), + description: contentString(), + + directDescendantArtTags: referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), }), - trackData: wikiData({ - class: input.value(Track), + relatedArtTags: annotatedReferenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), + + reference: input.value('artTag'), + thing: input.value('artTag'), }), + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + // Expose only - taggedInThings: { - flags: {expose: true}, + descriptionShort: [ + exitWithoutDependency({ + dependency: 'description', + mode: input.value('falsy'), + }), + + { + dependencies: ['description'], + compute: ({description}) => + description.split('<hr class="split">')[0], + }, + ], - expose: { - dependencies: ['this', 'albumData', 'trackData'], - compute: ({this: artTag, albumData, trackData}) => - sortAlbumsTracksChronologically( - [...albumData, ...trackData] - .filter(({artTags}) => artTags.includes(artTag)), - {getDate: thing => thing.coverArtDate ?? thing.date}), + directlyFeaturedInArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichFeature'), + }), + + indirectlyFeaturedInArtworks: [ + withAllDescendantArtTags(), + + { + dependencies: ['#allDescendantArtTags'], + compute: ({'#allDescendantArtTags': allDescendantArtTags}) => + unique( + allDescendantArtTags + .flatMap(artTag => artTag.directlyFeaturedInArtworks)), }, - }, + ], + + allDescendantArtTags: [ + withAllDescendantArtTags(), + exposeDependency({dependency: '#allDescendantArtTags'}), + ], + + directAncestorArtTags: reverseReferenceList({ + reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'), + }), + + ancestorArtTagBaobabTree: [ + withAncestorArtTagBaobabTree(), + exposeDependency({dependency: '#ancestorArtTagBaobabTree'}), + ], }); static [Thing.findSpecs] = { @@ -70,10 +128,19 @@ export class ArtTag extends Thing { referenceTypes: ['tag'], bindTo: 'artTagData', - getMatchableNames: tag => - (tag.isContentWarning - ? [`cw: ${tag.name}`] - : [tag.name]), + getMatchableNames: artTag => + (artTag.isContentWarning + ? [`cw: ${artTag.name}`] + : [artTag.name]), + }, + }; + + static [Thing.reverseSpecs] = { + artTagsWhichDirectlyAncestor: { + bindTo: 'artTagData', + + referencing: artTag => [artTag], + referenced: artTag => artTag.directDescendantArtTags, }, }; @@ -82,9 +149,27 @@ export class ArtTag extends Thing { 'Tag': {property: 'name'}, 'Short Name': {property: 'nameShort'}, 'Directory': {property: 'directory'}, + 'Description': {property: 'description'}, + 'Extra Reading URLs': {property: 'extraReadingURLs'}, + + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, 'Color': {property: 'color'}, 'Is CW': {property: 'isContentWarning'}, + + 'Direct Descendant Tags': {property: 'directDescendantArtTags'}, + + 'Related Tags': { + property: 'relatedArtTags', + transform: entries => + parseAnnotatedReferences(entries, { + referenceField: 'Tag', + referenceProperty: 'artTag', + }), + }, }, }; diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 8fdb8a12..5b67051c 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -5,28 +5,27 @@ import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import {sortAlphabetically} from '#sort'; -import {stitchArrays, unique} from '#sugar'; +import {stitchArrays} from '#sugar'; import Thing from '#thing'; import {isName, validateArrayItems} from '#validators'; import {getKebabCase} from '#wiki-data'; +import {parseArtwork} from '#yaml'; -import {exposeDependency} from '#composite/control-flow'; -import {withReverseContributionList} from '#composite/wiki-data'; +import {exitWithoutDependency} from '#composite/control-flow'; import { + constitutibleArtwork, contentString, directory, fileExtension, flag, name, - reverseAnnotatedReferenceList, - reverseContributionList, reverseReferenceList, singleReference, + soupyFind, + soupyReverse, urls, - wikiData, } from '#composite/wiki-properties'; import {artistTotalDuration} from '#composite/things/artist'; @@ -35,7 +34,7 @@ export class Artist extends Thing { static [Thing.referenceType] = 'artist'; static [Thing.wikiDataArray] = 'artistData'; - static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose name: name('Unnamed Artist'), @@ -47,6 +46,16 @@ export class Artist extends Thing { hasAvatar: flag(false), avatarFileExtension: fileExtension('jpg'), + avatarArtwork: [ + exitWithoutDependency({ + dependency: 'hasAvatar', + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Avatar Artwork'), + ], + aliasNames: { flags: {update: true, expose: true}, update: {validate: validateArrayItems(isName)}, @@ -57,95 +66,62 @@ export class Artist extends Thing { aliasedArtist: singleReference({ class: input.value(Artist), - find: input.value(find.artist), - data: 'artistData', + find: soupyFind.input('artist'), }), // Update only - albumData: wikiData({ - class: input.value(Album), - }), - - artistData: wikiData({ - class: input.value(Artist), - }), - - flashData: wikiData({ - class: input.value(Flash), - }), - - groupData: wikiData({ - class: input.value(Group), - }), - - trackData: wikiData({ - class: input.value(Track), - }), + find: soupyFind(), + reverse: soupyReverse(), // Expose only - trackArtistContributions: reverseContributionList({ - data: 'trackData', - list: input.value('artistContribs'), + trackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackArtistContributionsBy'), }), - trackContributorContributions: reverseContributionList({ - data: 'trackData', - list: input.value('contributorContribs'), + trackContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackContributorContributionsBy'), }), - trackCoverArtistContributions: reverseContributionList({ - data: 'trackData', - list: input.value('coverArtistContribs'), + trackCoverArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackCoverArtistContributionsBy'), }), tracksAsCommentator: reverseReferenceList({ - data: 'trackData', - list: input.value('commentatorArtists'), + reverse: soupyReverse.input('tracksWithCommentaryBy'), }), - albumArtistContributions: reverseContributionList({ - data: 'albumData', - list: input.value('artistContribs'), + albumArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumArtistContributionsBy'), }), - albumCoverArtistContributions: reverseContributionList({ - data: 'albumData', - list: input.value('coverArtistContribs'), + albumCoverArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumCoverArtistContributionsBy'), }), - albumWallpaperArtistContributions: reverseContributionList({ - data: 'albumData', - list: input.value('wallpaperArtistContribs'), + albumWallpaperArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'), }), - albumBannerArtistContributions: reverseContributionList({ - data: 'albumData', - list: input.value('bannerArtistContribs'), + albumBannerArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumBannerArtistContributionsBy'), }), albumsAsCommentator: reverseReferenceList({ - data: 'albumData', - list: input.value('commentatorArtists'), + reverse: soupyReverse.input('albumsWithCommentaryBy'), }), - flashContributorContributions: reverseContributionList({ - data: 'flashData', - list: input.value('contributorContribs'), + flashContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('flashContributorContributionsBy'), }), flashesAsCommentator: reverseReferenceList({ - data: 'flashData', - list: input.value('commentatorArtists'), + reverse: soupyReverse.input('flashesWithCommentaryBy'), }), - closelyLinkedGroups: reverseAnnotatedReferenceList({ - data: 'groupData', - list: input.value('closelyLinkedArtists'), - - forward: input.value('artist'), - backward: input.value('group'), + closelyLinkedGroups: reverseReferenceList({ + reverse: soupyReverse.input('groupsCloselyLinkedTo'), }), totalDuration: artistTotalDuration(), @@ -230,6 +206,17 @@ export class Artist extends Thing { 'URLs': {property: 'urls'}, 'Context Notes': {property: 'contextNotes'}, + // note: doesn't really work as an independent field yet + 'Avatar Artwork': { + property: 'avatarArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'avatarArtwork', + fileExtensionFromThingProperty: 'avatarFileExtension', + }), + }, + 'Has Avatar': {property: 'hasAvatar'}, 'Avatar File Extension': {property: 'avatarFileExtension'}, @@ -275,7 +262,12 @@ export class Artist extends Thing { const artistData = [...artists, ...artistAliases]; - return {artistData}; + const artworkData = + artistData + .filter(artist => artist.hasAvatar) + .map(artist => artist.avatarArtwork); + + return {artistData, artworkData}; }, sort({artistData}) { @@ -294,7 +286,7 @@ export class Artist extends Thing { let aliasedArtist; try { aliasedArtist = this.aliasedArtist.name; - } catch (_error) { + } catch { aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); } @@ -303,4 +295,12 @@ export class Artist extends Thing { return parts.join(''); } + + getOwnArtworkPath(artwork) { + return [ + 'media.artistAvatar', + this.directory, + artwork.fileExtension, + ]; + } } diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js new file mode 100644 index 00000000..57c293ca --- /dev/null +++ b/src/data/things/artwork.js @@ -0,0 +1,510 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input} from '#composite'; +import find from '#find'; +import Thing from '#thing'; + +import { + isContentString, + isContributionList, + isDate, + isDimensions, + isFileExtension, + optional, + validateArrayItems, + validateProperties, + validateReference, + validateReferenceList, +} from '#validators'; + +import { + parseAnnotatedReferences, + parseContributors, + parseDate, + parseDimensions, +} from '#yaml'; + +import {withPropertyFromObject} from '#composite/data'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + withRecontextualizedContributionList, + withResolvedAnnotatedReferenceList, + withResolvedContribs, + withResolvedReferenceList, +} from '#composite/wiki-data'; + +import { + contentString, + directory, + flag, + reverseReferenceList, + simpleString, + soupyFind, + soupyReverse, + thing, + wikiData, +} from '#composite/wiki-properties'; + +import { + withAttachedArtwork, + withContainingArtworkList, + withContribsFromAttachedArtwork, + withPropertyFromAttachedArtwork, + withDate, +} from '#composite/things/artwork'; + +export class Artwork extends Thing { + static [Thing.referenceType] = 'artwork'; + + static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({ + // Update & expose + + unqualifiedDirectory: directory({ + name: input.value(null), + }), + + thing: thing(), + thingProperty: simpleString(), + + label: simpleString(), + source: contentString(), + originDetails: contentString(), + + dateFromThingProperty: simpleString(), + + date: [ + withDate({ + from: input.updateValue({validate: isDate}), + }), + + exposeDependency({dependency: '#date'}), + ], + + fileExtensionFromThingProperty: simpleString(), + + fileExtension: [ + { + compute: (continuation) => continuation({ + ['#default']: 'jpg', + }), + }, + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + exitWithoutDependency({ + dependency: 'thing', + value: '#default', + }), + + exitWithoutDependency({ + dependency: 'fileExtensionFromThingProperty', + value: '#default', + }), + + withPropertyFromObject({ + object: 'thing', + property: 'fileExtensionFromThingProperty', + }), + + exposeDependencyOrContinue({ + dependency: '#value', + }), + + exposeDependency({ + dependency: '#default', + }), + ], + + dimensionsFromThingProperty: simpleString(), + + dimensions: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDimensions), + }), + + exitWithoutDependency({ + dependency: 'dimensionsFromThingProperty', + value: input.value(null), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'dimensionsFromThingProperty', + }).outputs({ + ['#value']: '#dimensionsFromThing', + }), + + exitWithoutDependency({ + dependency: 'dimensionsFromThingProperty', + value: input.value(null), + }), + + exposeDependencyOrContinue({ + dependency: '#dimensionsFromThing', + }), + + exposeConstant({ + value: input.value(null), + }), + ], + + attachAbove: flag(false), + + artistContribsFromThingProperty: simpleString(), + artistContribsArtistProperty: simpleString(), + + artistContribs: [ + withDate(), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: '#date', + thingProperty: input.thisProperty(), + artistProperty: 'artistContribsArtistProperty', + }), + + exposeDependencyOrContinue({ + dependency: '#resolvedContribs', + mode: input.value('empty'), + }), + + withContribsFromAttachedArtwork(), + + exposeDependencyOrContinue({ + dependency: '#attachedArtwork.artistContribs', + }), + + exitWithoutDependency({ + dependency: 'artistContribsFromThingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'artistContribsFromThingProperty', + }).outputs({ + ['#value']: '#artistContribs', + }), + + withRecontextualizedContributionList({ + list: '#artistContribs', + }), + + exposeDependency({ + dependency: '#artistContribs', + }), + ], + + artTagsFromThingProperty: simpleString(), + + artTags: [ + withResolvedReferenceList({ + list: input.updateValue({ + validate: + validateReferenceList(ArtTag[Thing.referenceType]), + }), + + find: soupyFind.input('artTag'), + }), + + exposeDependencyOrContinue({ + dependency: '#resolvedReferenceList', + mode: input.value('empty'), + }), + + withPropertyFromAttachedArtwork({ + property: input.value('artTags'), + }), + + exposeDependencyOrContinue({ + dependency: '#attachedArtwork.artTags', + }), + + exitWithoutDependency({ + dependency: 'artTagsFromThingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'artTagsFromThingProperty', + }).outputs({ + ['#value']: '#artTags', + }), + + exposeDependencyOrContinue({ + dependency: '#artTags', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + referencedArtworksFromThingProperty: simpleString(), + + referencedArtworks: [ + { + compute: (continuation) => continuation({ + ['#find']: + find.mixed({ + track: find.trackPrimaryArtwork, + album: find.albumPrimaryArtwork, + }), + }), + }, + + withResolvedAnnotatedReferenceList({ + list: input.updateValue({ + validate: + // TODO: It's annoying to hardcode this when it's really the + // same behavior as through annotatedReferenceList and through + // referenceListUpdateDescription, the latter of which isn't + // available outside of #composite/wiki-data internals. + validateArrayItems( + validateProperties({ + reference: validateReference(['album', 'track']), + annotation: optional(isContentString), + })), + }), + + data: 'artworkData', + find: '#find', + + thing: input.value('artwork'), + }), + + exposeDependencyOrContinue({ + dependency: '#resolvedAnnotatedReferenceList', + mode: input.value('empty'), + }), + + exitWithoutDependency({ + dependency: 'referencedArtworksFromThingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'referencedArtworksFromThingProperty', + }).outputs({ + ['#value']: '#referencedArtworks', + }), + + exposeDependencyOrContinue({ + dependency: '#referencedArtworks', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for referencedArtworks (mixedFind) + artworkData: wikiData({ + class: input.value(Artwork), + }), + + // Expose only + + referencedByArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichReference'), + }), + + isMainArtwork: [ + withContainingArtworkList(), + + exitWithoutDependency({ + dependency: '#containingArtworkList', + value: input.value(null), + }), + + { + dependencies: [input.myself(), '#containingArtworkList'], + compute: ({ + [input.myself()]: myself, + ['#containingArtworkList']: list, + }) => + list[0] === myself, + }, + ], + + mainArtwork: [ + withContainingArtworkList(), + + exitWithoutDependency({ + dependency: '#containingArtworkList', + value: input.value(null), + }), + + { + dependencies: ['#containingArtworkList'], + compute: ({'#containingArtworkList': list}) => + list[0], + }, + ], + + attachedArtwork: [ + withAttachedArtwork(), + + exposeDependency({ + dependency: '#attachedArtwork', + }), + ], + + attachingArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichAttach'), + }), + + groups: [ + withPropertyFromObject({ + object: 'thing', + property: input.value('groups'), + }), + + exposeDependencyOrContinue({ + dependency: '#thing.groups', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Directory': {property: 'unqualifiedDirectory'}, + 'File Extension': {property: 'fileExtension'}, + + 'Dimensions': { + property: 'dimensions', + transform: parseDimensions, + }, + + 'Label': {property: 'label'}, + 'Source': {property: 'source'}, + 'Origin Details': {property: 'originDetails'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, + + 'Attach Above': {property: 'attachAbove'}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Tags': {property: 'artTags'}, + + 'Referenced Artworks': { + property: 'referencedArtworks', + transform: parseAnnotatedReferences, + }, + }, + }; + + static [Thing.reverseSpecs] = { + artworksWhichReference: { + bindTo: 'artworkData', + + referencing: referencingArtwork => + referencingArtwork.referencedArtworks + .map(({artwork: referencedArtwork, ...referenceDetails}) => ({ + referencingArtwork, + referencedArtwork, + referenceDetails, + })), + + referenced: ({referencedArtwork}) => [referencedArtwork], + + tidy: ({referencingArtwork, referenceDetails}) => ({ + artwork: referencingArtwork, + ...referenceDetails, + }), + + date: ({artwork}) => artwork.date, + }, + + artworksWhichAttach: { + bindTo: 'artworkData', + + referencing: referencingArtwork => + (referencingArtwork.attachAbove + ? [referencingArtwork] + : []), + + referenced: referencingArtwork => + [referencingArtwork.attachedArtwork], + }, + + artworksWhichFeature: { + bindTo: 'artworkData', + + referencing: artwork => [artwork], + referenced: artwork => artwork.artTags, + }, + }; + + get path() { + if (!this.thing) return null; + if (!this.thing.getOwnArtworkPath) return null; + + return this.thing.getOwnArtworkPath(this); + } + + countOwnContributionInContributionTotals(contrib) { + if (this.attachAbove) { + return false; + } + + if (contrib.annotation?.startsWith('edits for wiki')) { + return false; + } + + return true; + } + + [inspect.custom](depth, options, inspect) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (this.thing) { + if (depth >= 0) { + const newOptions = { + ...options, + depth: + (options.depth === null + ? null + : options.depth - 1), + }; + + parts.push(` for ${inspect(this.thing, newOptions)}`); + } else { + parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`); + } + } + + return parts.join(''); + } +} diff --git a/src/data/things/content.js b/src/data/things/content.js new file mode 100644 index 00000000..ca41ccaa --- /dev/null +++ b/src/data/things/content.js @@ -0,0 +1,205 @@ +import {input} from '#composite'; +import Thing from '#thing'; +import {is, isDate} from '#validators'; +import {parseDate} from '#yaml'; + +import {contentString, simpleDate, soupyFind, thing} + from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + contentArtists, + hasAnnotationPart, + withAnnotationParts, + withHasAnnotationPart, + withSourceText, + withSourceURLs, + withWebArchiveDate, +} from '#composite/things/content'; + +export class ContentEntry extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + artists: contentArtists(), + + artistText: contentString(), + + annotation: contentString(), + + dateKind: { + flags: {update: true, expose: true}, + + update: { + validate: is(...[ + 'sometime', + 'throughout', + 'around', + ]), + }, + }, + + accessKind: [ + exitWithoutDependency({ + dependency: 'accessDate', + }), + + exposeUpdateValueOrContinue({ + validate: input.value( + is(...[ + 'captured', + 'accessed', + ])), + }), + + withWebArchiveDate(), + + withResultOfAvailabilityCheck({ + from: '#webArchiveDate', + }), + + { + dependencies: ['#availability'], + compute: (continuation, {['#availability']: availability}) => + (availability + ? continuation.exit('captured') + : continuation()), + }, + + exposeConstant({ + value: input.value('accessed'), + }), + ], + + date: simpleDate(), + + secondDate: simpleDate(), + + accessDate: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withWebArchiveDate(), + + exposeDependencyOrContinue({ + dependency: '#webArchiveDate', + }), + + exposeConstant({ + value: input.value(null), + }), + ], + + body: contentString(), + + // Update only + + find: soupyFind(), + + // Expose only + + annotationParts: [ + withAnnotationParts({ + mode: input.value('strings'), + }), + + exposeDependency({dependency: '#annotationParts'}), + ], + + sourceText: [ + withSourceText(), + exposeDependency({dependency: '#sourceText'}), + ], + + sourceURLs: [ + withSourceURLs(), + exposeDependency({dependency: '#sourceURLs'}), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Artists': {property: 'artists'}, + 'Artist Text': {property: 'artistText'}, + + 'Annotation': {property: 'annotation'}, + + 'Date Kind': {property: 'dateKind'}, + 'Access Kind': {property: 'accessKind'}, + + 'Date': {property: 'date', transform: parseDate}, + 'Second Date': {property: 'secondDate', transform: parseDate}, + 'Access Date': {property: 'accessDate', transform: parseDate}, + + 'Body': {property: 'body'}, + }, + }; +} + +export class CommentaryEntry extends ContentEntry { + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isWikiEditorCommentary: hasAnnotationPart({ + part: input.value('wiki editor'), + }), + }); +} + +export class LyricsEntry extends ContentEntry { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + originDetails: contentString(), + + // Expose only + + isWikiLyrics: hasAnnotationPart({ + part: input.value('wiki lyrics'), + }), + + hasSquareBracketAnnotations: [ + withHasAnnotationPart({ + part: input.value('wiki lyrics'), + }), + + exitWithoutDependency({ + dependency: '#hasAnnotationPart', + mode: input.value('falsy'), + value: input.value(false), + }), + + exitWithoutDependency({ + dependency: 'body', + value: input.value(false), + }), + + { + dependencies: ['body'], + compute: ({body}) => + /\[.*\]/m.test(body), + }, + ], + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, { + fields: { + 'Origin Details': {property: 'originDetails'}, + }, + }); +} + +export class CreditingSourcesEntry extends ContentEntry {} + +export class ReferencingSourcesEntry extends ContentEntry {} diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js index 2712af70..90e8eb79 100644 --- a/src/data/things/contribution.js +++ b/src/data/things/contribution.js @@ -5,11 +5,18 @@ import {colors} from '#cli'; import {input} from '#composite'; import {empty} from '#sugar'; import Thing from '#thing'; -import {isStringNonEmpty, isThing, validateReference} from '#validators'; +import {isBoolean, isStringNonEmpty, isThing, validateReference} + from '#validators'; -import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; -import {withResolvedReference} from '#composite/wiki-data'; -import {flag, simpleDate} from '#composite/wiki-properties'; +import {flag, simpleDate, soupyFind} from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; import { withFilteredList, @@ -71,7 +78,26 @@ export class Contribution extends Thing { property: input.thisProperty(), }), - flag(true), + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInContributionTotals?.(contribution) + ? true + : thing.countOwnContributionInContributionTotals + ? false + : continuation()), + }, + + exposeConstant({ + value: input.value(true), + }), ], countInDurationTotals: [ @@ -79,9 +105,43 @@ export class Contribution extends Thing { property: input.thisProperty(), }), - flag(true), + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject({ + object: 'thing', + property: input.value('duration'), + }), + + exitWithoutDependency({ + dependency: '#thing.duration', + mode: input.value('falsy'), + value: input.value(false), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInDurationTotals?.(contribution) + ? true + : thing.countOwnContributionInDurationTotals + ? false + : continuation()), + }, + + exposeConstant({ + value: input.value(true), + }), ], + // Update only + + find: soupyFind(), + // Expose only context: [ @@ -235,6 +295,21 @@ export class Contribution extends Thing { dependency: '#nearbyItem', }), ], + + groups: [ + withPropertyFromObject({ + object: 'thing', + property: input.value('groups'), + }), + + exposeDependencyOrContinue({ + dependency: '#thing.groups', + }), + + exposeConstant({ + value: input.value([]), + }), + ], }); [inspect.custom](depth, options, inspect) { @@ -256,7 +331,7 @@ export class Contribution extends Thing { let artist; try { artist = this.artist; - } catch (_error) { + } catch { // Computing artist might crash for any reason - don't distract from // other errors as a result of inspecting this contribution. } diff --git a/src/data/things/flash.js b/src/data/things/flash.js index aa6b9cd1..160221f0 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -1,13 +1,21 @@ export const FLASH_DATA_FILE = 'flashes.yaml'; import {input} from '#composite'; -import find from '#find'; import {empty} from '#sugar'; import {sortFlashesChronologically} from '#sort'; import Thing from '#thing'; import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} from '#validators'; -import {parseContributors, parseDate, parseDimensions} from '#yaml'; + +import { + parseArtwork, + parseAdditionalNames, + parseCommentary, + parseContributors, + parseCreditingSources, + parseDate, + parseDimensions, +} from '#yaml'; import {withPropertyFromObject} from '#composite/data'; @@ -20,8 +28,8 @@ import { import { color, - commentary, commentatorArtists, + constitutibleArtwork, contentString, contributionList, dimensions, @@ -30,9 +38,11 @@ import { name, referenceList, simpleDate, + soupyFind, + soupyReverse, thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; import {withFlashAct} from '#composite/things/flash'; @@ -42,9 +52,10 @@ export class Flash extends Thing { static [Thing.referenceType] = 'flash'; static [Thing.getPropertyDescriptors] = ({ - Artist, + AdditionalName, + CommentaryEntry, + CreditingSourcesEntry, Track, - FlashAct, WikiInfo, }) => ({ // Update & expose @@ -98,6 +109,10 @@ export class Flash extends Thing { coverArtDimensions: dimensions(), + coverArtwork: + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + contributorContribs: contributionList({ date: 'date', artistProperty: input.value('flashContributorContributions'), @@ -105,29 +120,29 @@ export class Flash extends Thing { featuredTracks: referenceList({ class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + find: soupyFind.input('track'), }), urls: urls(), - commentary: commentary(), - creditSources: commentary(), - - // Update only - - artistData: wikiData({ - class: input.value(Artist), + additionalNames: thingList({ + class: input.value(AdditionalName), }), - trackData: wikiData({ - class: input.value(Track), + commentary: thingList({ + class: input.value(CommentaryEntry), }), - flashActData: wikiData({ - class: input.value(FlashAct), + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), }), + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for withMatchingContributionPresets (indirectly by Contribution) wikiInfo: thing({ class: input.value(WikiInfo), }), @@ -173,6 +188,25 @@ export class Flash extends Thing { }, }; + static [Thing.reverseSpecs] = { + flashesWhichFeature: { + bindTo: 'flashData', + + referencing: flash => [flash], + referenced: flash => flash.featuredTracks, + }, + + flashContributorContributionsBy: + soupyReverse.contributionsBy('flashData', 'contributorContribs'), + + flashesWithCommentaryBy: { + bindTo: 'flashData', + + referencing: flash => [flash], + referenced: flash => flash.commentatorArtists, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Flash': {property: 'name'}, @@ -186,6 +220,22 @@ export class Flash extends Thing { transform: parseDate, }, + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Cover Artwork': { + property: 'coverArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'coverArtwork', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dimensionsFromThingProperty: 'coverArtDimensions', + }), + }, + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, 'Cover Art Dimensions': { @@ -200,12 +250,27 @@ export class Flash extends Thing { transform: parseContributors, }, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, 'Review Points': {ignore: true}, }, }; + + getOwnArtworkPath(artwork) { + return [ + 'media.flashArt', + this.directory, + artwork.fileExtension, + ]; + } } export class FlashAct extends Thing { @@ -242,19 +307,13 @@ export class FlashAct extends Thing { flashes: referenceList({ class: input.value(Flash), - find: input.value(find.flash), - data: 'flashData', + find: soupyFind.input('flash'), }), // Update only - flashData: wikiData({ - class: input.value(Flash), - }), - - flashSideData: wikiData({ - class: input.value(FlashSide), - }), + find: soupyFind(), + reverse: soupyReverse(), // Expose only @@ -271,6 +330,15 @@ export class FlashAct extends Thing { }, }; + static [Thing.reverseSpecs] = { + flashActsWhoseFlashesInclude: { + bindTo: 'flashActData', + + referencing: flashAct => [flashAct], + referenced: flashAct => flashAct.flashes, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Act': {property: 'name'}, @@ -298,15 +366,12 @@ export class FlashSide extends Thing { acts: referenceList({ class: input.value(FlashAct), - find: input.value(find.flashAct), - data: 'flashActData', + find: soupyFind.input('flashAct'), }), // Update only - flashActData: wikiData({ - class: input.value(FlashAct), - }), + find: soupyFind(), }); static [Thing.yamlDocumentSpec] = { @@ -325,6 +390,15 @@ export class FlashSide extends Thing { }, }; + static [Thing.reverseSpecs] = { + flashSidesWhoseActsInclude: { + bindTo: 'flashSideData', + + referencing: flashSide => [flashSide], + referenced: flashSide => flashSide.acts, + }, + }; + static [Thing.getYamlLoadingSpec] = ({ documentModes: {allInOne}, thingConstructors: {Flash, FlashAct}, @@ -383,7 +457,19 @@ export class FlashSide extends Thing { const flashActData = results.filter(x => x instanceof FlashAct); const flashSideData = results.filter(x => x instanceof FlashSide); - return {flashData, flashActData, flashSideData}; + const artworkData = flashData.map(flash => flash.coverArtwork); + const commentaryData = flashData.flatMap(flash => flash.commentary); + const creditingSourceData = flashData.flatMap(flash => flash.creditingSources); + + return { + flashData, + flashActData, + flashSideData, + + artworkData, + commentaryData, + creditingSourceData, + }; }, sort({flashData}) { diff --git a/src/data/things/group.js b/src/data/things/group.js index 8418cb99..0262a3a5 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,8 +1,11 @@ export const GROUP_DATA_FILE = 'groups.yaml'; +import {inspect} from 'node:util'; + +import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import Thing from '#thing'; +import {is} from '#validators'; import {parseAnnotatedReferences, parseSerieses} from '#yaml'; import { @@ -12,15 +15,16 @@ import { directory, name, referenceList, - seriesList, + soupyFind, + thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; export class Group extends Thing { static [Thing.referenceType] = 'group'; - static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({ // Update & expose name: name('Unnamed Group'), @@ -32,10 +36,7 @@ export class Group extends Thing { closelyLinkedArtists: annotatedReferenceList({ class: input.value(Artist), - find: input.value(find.artist), - data: 'artistData', - - date: input.value(null), + find: soupyFind.input('artist'), reference: input.value('artist'), thing: input.value('artist'), @@ -43,27 +44,17 @@ export class Group extends Thing { featuredAlbums: referenceList({ class: input.value(Album), - find: input.value(find.album), - data: 'albumData', + find: soupyFind.input('album'), }), - serieses: seriesList({ - group: input.myself(), + serieses: thingList({ + class: input.value(Series), }), // Update only - albumData: wikiData({ - class: input.value(Album), - }), - - artistData: wikiData({ - class: input.value(Artist), - }), - - groupCategoryData: wikiData({ - class: input.value(GroupCategory), - }), + find: soupyFind(), + reverse: soupyFind(), // Expose only @@ -83,9 +74,9 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'albumData'], - compute: ({this: group, albumData}) => - albumData?.filter((album) => album.groups.includes(group)) ?? [], + dependencies: ['this', 'reverse'], + compute: ({this: group, reverse}) => + reverse.albumsWhoseGroupsInclude(group), }, }, @@ -93,9 +84,9 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'groupCategoryData'], - compute: ({this: group, groupCategoryData}) => - groupCategoryData.find((category) => category.groups.includes(group)) + dependencies: ['this', 'reverse'], + compute: ({this: group, reverse}) => + reverse.groupCategoriesWhichInclude(group, {unique: true}) ?.color, }, }, @@ -104,9 +95,9 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'groupCategoryData'], - compute: ({this: group, groupCategoryData}) => - groupCategoryData.find((category) => category.groups.includes(group)) ?? + dependencies: ['this', 'reverse'], + compute: ({this: group, reverse}) => + reverse.groupCategoriesWhichInclude(group, {unique: true}) ?? null, }, }, @@ -119,6 +110,25 @@ export class Group extends Thing { }, }; + static [Thing.reverseSpecs] = { + groupsCloselyLinkedTo: { + bindTo: 'groupData', + + referencing: group => + group.closelyLinkedArtists + .map(({artist, ...referenceDetails}) => ({ + group, + artist, + referenceDetails, + })), + + referenced: ({artist}) => [artist], + + tidy: ({group, referenceDetails}) => + ({group, ...referenceDetails}), + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Group': {property: 'name'}, @@ -186,8 +196,9 @@ export class Group extends Thing { const groupData = results.filter(x => x instanceof Group); const groupCategoryData = results.filter(x => x instanceof GroupCategory); + const seriesData = groupData.flatMap(group => group.serieses); - return {groupData, groupCategoryData}; + return {groupData, groupCategoryData, seriesData}; }, // Groups aren't sorted at all, always preserving the order in the data @@ -210,17 +221,23 @@ export class GroupCategory extends Thing { groups: referenceList({ class: input.value(Group), - find: input.value(find.group), - data: 'groupData', + find: soupyFind.input('group'), }), // Update only - groupData: wikiData({ - class: input.value(Group), - }), + find: soupyFind(), }); + static [Thing.reverseSpecs] = { + groupCategoriesWhichInclude: { + bindTo: 'groupCategoryData', + + referencing: groupCategory => [groupCategory], + referenced: groupCategory => groupCategory.groups, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Category': {property: 'name'}, @@ -228,3 +245,73 @@ export class GroupCategory extends Thing { }, }; } + +export class Series extends Thing { + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ + // Update & expose + + name: name('Unnamed Series'), + + showAlbumArtists: { + flags: {update: true, expose: true}, + update: { + validate: + is('all', 'differing', 'none'), + }, + }, + + description: contentString(), + + group: thing({ + class: input.value(Group), + }), + + albums: referenceList({ + class: input.value(Album), + find: soupyFind.input('album'), + }), + + // Update only + + find: soupyFind(), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + + 'Description': {property: 'description'}, + + 'Show Album Artists': {property: 'showAlbumArtists'}, + + 'Albums': {property: 'albums'}, + }, + }; + + [inspect.custom](depth, options, inspect) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (depth >= 0) showGroup: { + let group = null; + try { + group = this.group; + } catch { + break showGroup; + } + + const groupName = group.name; + const groupIndex = group.serieses.indexOf(this); + + const num = + (groupIndex === -1 + ? 'indeterminate position' + : `#${groupIndex + 1}`); + + parts.push(` (${colors.yellow(num)} in ${colors.green(`"${groupName}"`)})`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index 00d6aef5..3a11c287 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -1,8 +1,11 @@ export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml'; +import {inspect} from 'node:util'; + +import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import Thing from '#thing'; +import {empty} from '#sugar'; import { anyOf, @@ -11,19 +14,26 @@ import { isString, isStringNonEmpty, validateArrayItems, - validateInstanceOf, validateReference, } from '#validators'; import {exposeDependency} from '#composite/control-flow'; import {withResolvedReference} from '#composite/wiki-data'; -import {color, contentString, name, referenceList, wikiData} - from '#composite/wiki-properties'; + +import { + color, + contentString, + name, + referenceList, + soupyFind, + thing, + thingList, +} from '#composite/wiki-properties'; export class HomepageLayout extends Thing { static [Thing.friendlyName] = `Homepage Layout`; - static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({ // Update & expose sidebarContent: contentString(), @@ -31,15 +41,12 @@ export class HomepageLayout extends Thing { navbarLinks: { flags: {update: true, expose: true}, update: {validate: validateArrayItems(isStringNonEmpty)}, + expose: {transform: value => value ?? []}, }, - rows: { - flags: {update: true, expose: true}, - - update: { - validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), - }, - }, + sections: thingList({ + class: input.value(HomepageLayoutSection), + }), }); static [Thing.yamlDocumentSpec] = { @@ -50,85 +57,230 @@ export class HomepageLayout extends Thing { 'Navbar Links': {property: 'navbarLinks'}, }, }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: { + HomepageLayout, + HomepageLayoutSection, + }, + }) => ({ + title: `Process homepage layout file`, + file: HOMEPAGE_LAYOUT_DATA_FILE, + + documentMode: allInOne, + documentThing: document => { + if (document['Homepage']) { + return HomepageLayout; + } + + if (document['Section']) { + return HomepageLayoutSection; + } + + if (document['Row']) { + switch (document['Row']) { + case 'actions': + return HomepageLayoutActionsRow; + case 'album carousel': + return HomepageLayoutAlbumCarouselRow; + case 'album grid': + return HomepageLayoutAlbumGridRow; + default: + throw new TypeError(`Unrecognized row type ${document['Row']}`); + } + } + + return null; + }, + + save(results) { + if (!empty(results) && !(results[0] instanceof HomepageLayout)) { + throw new Error(`Expected 'Homepage' document at top of homepage layout file`); + } + + const homepageLayout = results[0]; + const sections = []; + + let currentSection = null; + let currentSectionRows = []; + + const closeCurrentSection = () => { + if (currentSection) { + for (const row of currentSectionRows) { + row.section = currentSection; + } + + currentSection.rows = currentSectionRows; + sections.push(currentSection); + + currentSection = null; + currentSectionRows = []; + } + }; + + for (const entry of results.slice(1)) { + if (entry instanceof HomepageLayout) { + throw new Error(`Expected only one 'Homepage' document in total`); + } else if (entry instanceof HomepageLayoutSection) { + closeCurrentSection(); + currentSection = entry; + } else if (entry instanceof HomepageLayoutRow) { + if (currentSection) { + currentSectionRows.push(entry); + } else { + throw new Error(`Expected a 'Section' document to add following rows into`); + } + } + } + + closeCurrentSection(); + + homepageLayout.sections = sections; + + return {homepageLayout}; + }, + }); +} + +export class HomepageLayoutSection extends Thing { + static [Thing.friendlyName] = `Homepage Section`; + + static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ + // Update & expose + + name: name(`Unnamed Homepage Section`), + + color: color(), + + rows: thingList({ + class: input.value(HomepageLayoutRow), + }), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Section': {property: 'name'}, + 'Color': {property: 'color'}, + }, + }; } export class HomepageLayoutRow extends Thing { static [Thing.friendlyName] = `Homepage Row`; - static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({ // Update & expose - name: name('Unnamed Homepage Row'), + section: thing({ + class: input.value(HomepageLayoutSection), + }), + + // Update only + + find: soupyFind(), + + // Expose only type: { - flags: {update: true, expose: true}, + flags: {expose: true}, - update: { - validate() { + expose: { + compute() { throw new Error(`'type' property validator must be overridden`); }, }, }, + }); - color: color(), + static [Thing.yamlDocumentSpec] = { + fields: { + 'Row': {ignore: true}, + }, + }; - // Update only + [inspect.custom](depth) { + const parts = []; - // These wiki data arrays aren't necessarily used by every subclass, but - // to the convenience of providing these, the superclass accepts all wiki - // data arrays depended upon by any subclass. + parts.push(Thing.prototype[inspect.custom].apply(this)); - albumData: wikiData({ - class: input.value(Album), - }), + if (depth >= 0 && this.section) { + const sectionName = this.section.name; + const index = this.section.rows.indexOf(this); + const rowNum = + (index === -1 + ? 'indeterminate position' + : `#${index + 1}`); + parts.push(` (${colors.yellow(rowNum)} in ${colors.green(sectionName)})`); + } - groupData: wikiData({ - class: input.value(Group), - }), + return parts.join(''); + } +} + +export class HomepageLayoutActionsRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Actions Row`; + + static [Thing.getPropertyDescriptors] = (opts) => ({ + ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), + + // Update & expose + + actionLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isString)}, + }, + + // Expose only + + type: { + flags: {expose: true}, + expose: {compute: () => 'actions'}, + }, }); - static [Thing.yamlDocumentSpec] = { + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { fields: { - 'Row': {property: 'name'}, - 'Color': {property: 'color'}, - 'Type': {property: 'type'}, + 'Actions': {property: 'actionLinks'}, }, - }; + }); } -export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { - static [Thing.friendlyName] = `Homepage Albums Row`; +export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Album Carousel Row`; - static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ + static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), // Update & expose + albums: referenceList({ + class: input.value(Album), + find: soupyFind.input('album'), + }), + + // Expose only + type: { - flags: {update: true, expose: true}, - update: { - validate(value) { - if (value !== 'albums') { - throw new TypeError(`Expected 'albums'`); - } + flags: {expose: true}, + expose: {compute: () => 'album carousel'}, + }, + }); - return true; - }, - }, + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { + fields: { + 'Albums': {property: 'albums'}, }, + }); +} - displayStyle: { - flags: {update: true, expose: true}, +export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Album Grid Row`; - update: { - validate: is('grid', 'carousel'), - }, + static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ + ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), - expose: { - transform: (displayStyle) => - displayStyle ?? 'grid', - }, - }, + // Update & expose sourceGroup: [ { @@ -151,8 +303,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { withResolvedReference({ ref: input.updateValue(), - data: 'groupData', - find: input.value(find.group), + find: soupyFind.input('group'), }), exposeDependency({dependency: '#resolvedReference'}), @@ -160,8 +311,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { sourceAlbums: referenceList({ class: input.value(Album), - find: input.value(find.album), - data: 'albumData', + find: soupyFind.input('album'), }), countAlbumsFromGroup: { @@ -169,55 +319,19 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { update: {validate: isCountingNumber}, }, - actionLinks: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isString)}, + // Expose only + + type: { + flags: {expose: true}, + expose: {compute: () => 'album grid'}, }, }); static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { fields: { - 'Display Style': {property: 'displayStyle'}, 'Group': {property: 'sourceGroup'}, 'Count': {property: 'countAlbumsFromGroup'}, 'Albums': {property: 'sourceAlbums'}, - 'Actions': {property: 'actionLinks'}, - }, - }); - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {headerAndEntries}, // Kludge, see below - thingConstructors: { - HomepageLayout, - HomepageLayoutAlbumsRow, - }, - }) => ({ - title: `Process homepage layout file`, - - // Kludge: This benefits from the same headerAndEntries style messaging as - // albums and tracks (for example), but that document mode is designed to - // support multiple files, and only one is actually getting processed here. - files: [HOMEPAGE_LAYOUT_DATA_FILE], - - documentMode: headerAndEntries, - headerDocumentThing: HomepageLayout, - entryDocumentThing: document => { - switch (document['Type']) { - case 'albums': - return HomepageLayoutAlbumsRow; - default: - throw new TypeError(`No processDocument function for row type ${document['Type']}!`); - } - }, - - save(results) { - if (!results[0]) { - return; - } - - const {header: homepageLayout, entries: rows} = results[0]; - Object.assign(homepageLayout, {rows}); - return {homepageLayout}; }, }); } diff --git a/src/data/things/index.js b/src/data/things/index.js index f18e283a..11307b50 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -6,31 +6,42 @@ import CacheableObject from '#cacheable-object'; import {logError} from '#cli'; import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; +import {withEntries} from '#sugar'; import Thing from '#thing'; +import * as additionalFileClasses from './additional-file.js'; +import * as additionalNameClasses from './additional-name.js'; import * as albumClasses from './album.js'; import * as artTagClasses from './art-tag.js'; import * as artistClasses from './artist.js'; +import * as artworkClasses from './artwork.js'; +import * as contentClasses from './content.js'; import * as contributionClasses from './contribution.js'; import * as flashClasses from './flash.js'; import * as groupClasses from './group.js'; import * as homepageLayoutClasses from './homepage-layout.js'; import * as languageClasses from './language.js'; import * as newsEntryClasses from './news-entry.js'; +import * as sortingRuleClasses from './sorting-rule.js'; import * as staticPageClasses from './static-page.js'; import * as trackClasses from './track.js'; import * as wikiInfoClasses from './wiki-info.js'; const allClassLists = { + 'additional-file.js': additionalFileClasses, + 'additional-name.js': additionalNameClasses, 'album.js': albumClasses, 'art-tag.js': artTagClasses, 'artist.js': artistClasses, + 'artwork.js': artworkClasses, + 'content.js': contentClasses, 'contribution.js': contributionClasses, 'flash.js': flashClasses, 'group.js': groupClasses, 'homepage-layout.js': homepageLayoutClasses, 'language.js': languageClasses, 'news-entry.js': newsEntryClasses, + 'sorting-rule.js': sortingRuleClasses, 'static-page.js': staticPageClasses, 'track.js': trackClasses, 'wiki-info.js': wikiInfoClasses, @@ -79,13 +90,25 @@ function errorDuplicateClassNames() { } function flattenClassLists() { + let allClassesUnsorted = Object.create(null); + for (const classes of Object.values(allClassLists)) { for (const [name, constructor] of Object.entries(classes)) { if (typeof constructor !== 'function') continue; if (!(constructor.prototype instanceof Thing)) continue; - allClasses[name] = constructor; + allClassesUnsorted[name] = constructor; } } + + // Sort subclasses after their superclasses. + Object.assign(allClasses, + withEntries(allClassesUnsorted, entries => + entries.sort(({[1]: A}, {[1]: B}) => + (A.prototype instanceof B + ? +1 + : B.prototype instanceof A + ? -1 + : 0)))); } function descriptorAggregateHelper({ @@ -177,6 +200,20 @@ function evaluateSerializeDescriptors() { }); } +function finalizeCacheableObjectPrototypes() { + return descriptorAggregateHelper({ + message: `Errors finalizing Thing class prototypes`, + + op(constructor) { + constructor.finalizeCacheableObjectPrototype(); + }, + + showFailedClasses(failedClasses) { + logError`Failed to finalize cacheable object prototypes for classes: ${failedClasses.join(', ')}`; + }, + }); +} + if (!errorDuplicateClassNames()) process.exit(1); @@ -188,6 +225,9 @@ if (!evaluatePropertyDescriptors()) if (!evaluateSerializeDescriptors()) process.exit(1); +if (!finalizeCacheableObjectPrototypes()) + process.exit(1); + Object.assign(allClasses, {Thing}); export default allClasses; diff --git a/src/data/things/language.js b/src/data/things/language.js index e9aa58be..e3689643 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -115,7 +115,7 @@ export class Language extends Thing { }, // List of descriptors for providing to external link utilities when using - // language.formatExternalLink - refer to util/external-links.js for info. + // language.formatExternalLink - refer to #external-links for info. externalLinkSpec: { flags: {update: true, expose: true}, update: {validate: isExternalLinkSpec}, @@ -135,6 +135,7 @@ export class Language extends Thing { }, intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), + intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}), intl_number: this.#intlHelper(Intl.NumberFormat), intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}), @@ -488,22 +489,44 @@ export class Language extends Thing { // or both are undefined, that's just blank content. const hasStart = startDate !== null && startDate !== undefined; const hasEnd = endDate !== null && endDate !== undefined; - if (!hasStart || !hasEnd) { - if (startDate === endDate) { - return html.blank(); - } else if (hasStart) { - throw new Error(`Expected both start and end of date range, got only start`); - } else if (hasEnd) { - throw new Error(`Expected both start and end of date range, got only end`); - } else { - throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`); - } + if (!hasStart && !hasEnd) { + return html.blank(); + } else if (hasStart && !hasEnd) { + throw new Error(`Expected both start and end of date range, got only start`); + } else if (!hasStart && hasEnd) { + throw new Error(`Expected both start and end of date range, got only end`); } this.assertIntlAvailable('intl_date'); return this.intl_date.formatRange(startDate, endDate); } + formatYear(date) { + if (date === null || date === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_dateYear'); + return this.intl_dateYear.format(date); + } + + formatYearRange(startDate, endDate) { + // formatYearRange expects both values to be present, but if both are null + // or both are undefined, that's just blank content. + const hasStart = startDate !== null && startDate !== undefined; + const hasEnd = endDate !== null && endDate !== undefined; + if (!hasStart && !hasEnd) { + return html.blank(); + } else if (hasStart && !hasEnd) { + throw new Error(`Expected both start and end of date range, got only start`); + } else if (!hasStart && hasEnd) { + throw new Error(`Expected both start and end of date range, got only end`); + } + + this.assertIntlAvailable('intl_dateYear'); + return this.intl_dateYear.formatRange(startDate, endDate); + } + formatDateDuration({ years: numYears = 0, months: numMonths = 0, @@ -842,6 +865,18 @@ export class Language extends Thing { } } + typicallyLowerCase(string) { + // Utter nonsense implementation, so this only works on strings, + // not actual HTML content, and may rudely disrespect *intentful* + // capitalization of whatever goes into it. + + if (typeof string !== 'string') return string; + if (string.length <= 1) return string; + if (/^\S+?[A-Z]/.test(string)) return string; + + return string[0].toLowerCase() + string.slice(1); + } + // Utility function to quickly provide a useful string key // (generally a prefix) to stuff nested beneath it. encapsulate(...args) { @@ -896,6 +931,7 @@ const countHelper = (stringKey, optionName = stringKey) => Object.assign(Language.prototype, { countAdditionalFiles: countHelper('additionalFiles', 'files'), countAlbums: countHelper('albums'), + countArtTags: countHelper('artTags', 'tags'), countArtworks: countHelper('artworks'), countCommentaryEntries: countHelper('commentaryEntries', 'entries'), countContributions: countHelper('contributions'), @@ -903,6 +939,7 @@ Object.assign(Language.prototype, { countDays: countHelper('days'), countFlashes: countHelper('flashes'), countMonths: countHelper('months'), + countTimesFeatured: countHelper('timesFeatured'), countTimesReferenced: countHelper('timesReferenced'), countTimesUsed: countHelper('timesUsed'), countTracks: countHelper('tracks'), diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js new file mode 100644 index 00000000..ccc4ad89 --- /dev/null +++ b/src/data/things/sorting-rule.js @@ -0,0 +1,385 @@ +export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml'; + +import {readFile, writeFile} from 'node:fs/promises'; +import * as path from 'node:path'; + +import {chunkByProperties, compareArrays, unique} from '#sugar'; +import Thing from '#thing'; +import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators'; + +import { + compareCaseLessSensitive, + sortByDate, + sortByDirectory, + sortByName, +} from '#sort'; + +import { + documentModes, + flattenThingLayoutToDocumentOrder, + getThingLayoutForFilename, + reorderDocumentsInYAMLSourceText, +} from '#yaml'; + +import {flag} from '#composite/wiki-properties'; + +function isSelectFollowingEntry(value) { + isObject(value); + + const {length} = Object.keys(value); + if (length !== 1) { + throw new Error(`Expected object with 1 key, got ${length}`); + } + + return true; +} + +export class SortingRule extends Thing { + static [Thing.friendlyName] = `Sorting Rule`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + active: flag(true), + + message: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Message': {property: 'message'}, + 'Active': {property: 'active'}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: {DocumentSortingRule}, + }) => ({ + title: `Process sorting rules file`, + file: SORTING_RULE_DATA_FILE, + + documentMode: allInOne, + documentThing: document => + (document['Sort Documents'] + ? DocumentSortingRule + : null), + + save: (results) => ({sortingRules: results}), + }); + + check(opts) { + return this.constructor.check(this, opts); + } + + apply(opts) { + return this.constructor.apply(this, opts); + } + + static check(rule, opts) { + const result = this.apply(rule, {...opts, dry: true}); + if (!result) return true; + if (!result.changed) return true; + return false; + } + + static async apply(_rule, _opts) { + throw new Error(`Not implemented`); + } + + static async* applyAll(_rules, _opts) { + throw new Error(`Not implemented`); + } + + static async* go({dataPath, wikiData, dry}) { + const rules = wikiData.sortingRules; + const constructors = unique(rules.map(rule => rule.constructor)); + + for (const constructor of constructors) { + yield* constructor.applyAll( + rules + .filter(rule => rule.active) + .filter(rule => rule.constructor === constructor), + {dataPath, wikiData, dry}); + } + } +} + +export class ThingSortingRule extends SortingRule { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + properties: { + flags: {update: true, expose: true}, + update: { + validate: strictArrayOf(isStringNonEmpty), + }, + }, + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, { + fields: { + 'By Properties': {property: 'properties'}, + }, + }); + + sort(sortable) { + if (this.properties) { + for (const property of this.properties.slice().reverse()) { + const get = thing => thing[property]; + const lc = property.toLowerCase(); + + if (lc.endsWith('date')) { + sortByDate(sortable, {getDate: get}); + continue; + } + + if (lc.endsWith('directory')) { + sortByDirectory(sortable, {getDirectory: get}); + continue; + } + + if (lc.endsWith('name')) { + sortByName(sortable, {getName: get}); + continue; + } + + const values = sortable.map(get); + + if (values.every(v => typeof v === 'string')) { + sortable.sort((a, b) => + compareCaseLessSensitive(get(a), get(b))); + continue; + } + + if (values.every(v => typeof v === 'number')) { + sortable.sort((a, b) => get(a) - get(b)); + continue; + } + + sortable.sort((a, b) => + (get(a).toString() < get(b).toString() + ? -1 + : get(a).toString() > get(b).toString() + ? +1 + : 0)); + } + } + + return sortable; + } +} + +export class DocumentSortingRule extends ThingSortingRule { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + // TODO: glob :plead: + filename: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + message: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + + expose: { + dependencies: ['filename'], + transform: (value, {filename}) => + value ?? + `Sort ${filename}`, + }, + }, + + selectDocumentsFollowing: { + flags: {update: true, expose: true}, + + update: { + validate: + anyOf( + isSelectFollowingEntry, + strictArrayOf(isSelectFollowingEntry)), + }, + + compute: { + transform: value => + (Array.isArray(value) + ? value + : [value]), + }, + }, + + selectDocumentsUnder: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, { + fields: { + 'Sort Documents': {property: 'filename'}, + 'Select Documents Following': {property: 'selectDocumentsFollowing'}, + 'Select Documents Under': {property: 'selectDocumentsUnder'}, + }, + + invalidFieldCombinations: [ + {message: `Specify only one of these`, fields: [ + 'Select Documents Following', + 'Select Documents Under', + ]}, + ], + }); + + static async apply(rule, {wikiData, dataPath, dry}) { + const oldLayout = getThingLayoutForFilename(rule.filename, wikiData); + if (!oldLayout) return null; + + const newLayout = rule.#processLayout(oldLayout); + + const oldOrder = flattenThingLayoutToDocumentOrder(oldLayout); + const newOrder = flattenThingLayoutToDocumentOrder(newLayout); + const changed = compareArrays(oldOrder, newOrder); + + if (dry) return {changed}; + + const realPath = + path.join( + dataPath, + rule.filename.split(path.posix.sep).join(path.sep)); + + const oldSourceText = await readFile(realPath, 'utf8'); + const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder); + + await writeFile(realPath, newSourceText); + + return {changed}; + } + + static async* applyAll(rules, {wikiData, dataPath, dry}) { + rules = + rules + .slice() + .sort((a, b) => a.filename.localeCompare(b.filename, 'en')); + + for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) { + const initialLayout = getThingLayoutForFilename(filename, wikiData); + if (!initialLayout) continue; + + let currLayout = initialLayout; + let prevLayout = initialLayout; + let anyChanged = false; + + for (const rule of chunk) { + currLayout = rule.#processLayout(currLayout); + + const prevOrder = flattenThingLayoutToDocumentOrder(prevLayout); + const currOrder = flattenThingLayoutToDocumentOrder(currLayout); + + if (compareArrays(currOrder, prevOrder)) { + yield {rule, changed: false}; + } else { + anyChanged = true; + yield {rule, changed: true}; + } + + prevLayout = currLayout; + } + + if (!anyChanged) continue; + if (dry) continue; + + const newLayout = currLayout; + const newOrder = flattenThingLayoutToDocumentOrder(newLayout); + + const realPath = + path.join( + dataPath, + filename.split(path.posix.sep).join(path.sep)); + + const oldSourceText = await readFile(realPath, 'utf8'); + const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder); + + await writeFile(realPath, newSourceText); + } + } + + #processLayout(layout) { + const fresh = {...layout}; + + let sortable = null; + switch (fresh.documentMode) { + case documentModes.headerAndEntries: + sortable = fresh.entryThings = + fresh.entryThings.slice(); + break; + + case documentModes.allInOne: + sortable = fresh.things = + fresh.things.slice(); + break; + + default: + throw new Error(`Invalid document type for sorting`); + } + + if (this.selectDocumentsFollowing) { + for (const entry of this.selectDocumentsFollowing) { + const [field, value] = Object.entries(entry)[0]; + + const after = + sortable.findIndex(thing => + thing[Thing.yamlSourceDocument][field] === value); + + const different = + after + + sortable + .slice(after) + .findIndex(thing => + Object.hasOwn(thing[Thing.yamlSourceDocument], field) && + thing[Thing.yamlSourceDocument][field] !== value); + + const before = + (different === -1 + ? sortable.length + : different); + + const subsortable = + sortable.slice(after + 1, before); + + this.sort(subsortable); + + sortable.splice(after + 1, before - after - 1, ...subsortable); + } + } else if (this.selectDocumentsUnder) { + const field = this.selectDocumentsUnder; + + const indices = + Array.from(sortable.entries()) + .filter(([_index, thing]) => + Object.hasOwn(thing[Thing.yamlSourceDocument], field)) + .map(([index, _thing]) => index); + + for (const [indicesIndex, after] of indices.entries()) { + const before = + (indicesIndex === indices.length - 1 + ? sortable.length + : indices[indicesIndex + 1]); + + const subsortable = + sortable.slice(after + 1, before); + + this.sort(subsortable); + + sortable.splice(after + 1, before - after - 1, ...subsortable); + } + } else { + this.sort(sortable); + } + + return fresh; + } +} diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index 03274979..52a09c31 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -7,7 +7,7 @@ import {sortAlphabetically} from '#sort'; import Thing from '#thing'; import {isName} from '#validators'; -import {contentString, directory, name, simpleString} +import {contentString, directory, flag, name, simpleString} from '#composite/wiki-properties'; export class StaticPage extends Thing { @@ -30,9 +30,12 @@ export class StaticPage extends Thing { }, directory: directory(), - content: contentString(), + stylesheet: simpleString(), script: simpleString(), + content: contentString(), + + absoluteLinks: flag(), }); static [Thing.findSpecs] = { @@ -48,6 +51,8 @@ export class StaticPage extends Thing { 'Short Name': {property: 'nameShort'}, 'Directory': {property: 'directory'}, + 'Absolute Links': {property: 'absoluteLinks'}, + 'Style': {property: 'stylesheet'}, 'Script': {property: 'script'}, 'Content': {property: 'content'}, diff --git a/src/data/things/track.js b/src/data/things/track.js index a0d2f641..8b9420c7 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -3,7 +3,6 @@ import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import Thing from '#thing'; import {isBoolean, isColor, isContributionList, isDate, isFileExtension} from '#validators'; @@ -12,16 +11,20 @@ import { parseAdditionalFiles, parseAdditionalNames, parseAnnotatedReferences, + parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, + parseReferencingSources, parseDate, parseDimensions, parseDuration, + parseLyrics, } from '#yaml'; import {withPropertyFromObject} from '#composite/data'; import { - exitWithoutDependency, exposeConstant, exposeDependency, exposeDependencyOrContinue, @@ -36,11 +39,8 @@ import { } from '#composite/wiki-data'; import { - additionalFiles, - additionalNameList, - commentary, commentatorArtists, - contentString, + constitutibleArtworkList, contributionList, dimensions, directory, @@ -50,42 +50,49 @@ import { referenceList, referencedArtworkList, reverseReferenceList, - reverseReferencedArtworkList, simpleDate, simpleString, singleReference, + soupyFind, + soupyReverse, thing, + thingList, urls, wikiData, } from '#composite/wiki-properties'; import { exitWithoutUniqueCoverArt, - inheritContributionListFromOriginalRelease, - inheritFromOriginalRelease, - trackReverseReferenceList, - withAlbum, + inheritContributionListFromMainRelease, + inheritFromMainRelease, + withAllReleases, withAlwaysReferenceByDirectory, withContainingTrackSection, + withCoverArtistContribs, withDate, withDirectorySuffix, withHasUniqueCoverArt, - withOriginalRelease, + withMainRelease, withOtherReleases, withPropertyFromAlbum, withSuffixDirectoryFromAlbum, withTrackArtDate, + withTrackNumber, } from '#composite/things/track'; export class Track extends Thing { static [Thing.referenceType] = 'track'; static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, Album, ArtTag, - Artist, - Flash, - TrackSection, + Artwork, + CommentaryEntry, + CreditingSourcesEntry, + LyricsEntry, + ReferencingSourcesEntry, WikiInfo, }) => ({ // Update & expose @@ -122,7 +129,13 @@ export class Track extends Thing { }) ], - additionalNames: additionalNameList(), + album: thing({ + class: input.value(Album), + }), + + additionalNames: thingList({ + class: input.value(AdditionalName), + }), bandcampTrackIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), @@ -157,6 +170,18 @@ export class Track extends Thing { exposeDependency({dependency: '#alwaysReferenceByDirectory'}), ], + countInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromAlbum({ + property: input.value('countTracksInArtistTotals'), + }), + + exposeDependency({dependency: '#album.countTracksInArtistTotals'}), + ], + // Disables presenting the track as though it has its own unique artwork. // This flag should only be used in select circumstances, i.e. to override // an album's trackCoverArtists. This flag supercedes that property, as well @@ -198,6 +223,8 @@ export class Track extends Thing { coverArtDimensions: [ exitWithoutUniqueCoverArt(), + exposeUpdateValueOrContinue(), + withPropertyFromAlbum({ property: input.value('trackDimensions'), }), @@ -207,35 +234,48 @@ export class Track extends Thing { dimensions(), ], - commentary: commentary(), - creditSources: commentary(), + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), + + referencingSources: thingList({ + class: input.value(ReferencingSourcesEntry), + }), lyrics: [ - inheritFromOriginalRelease(), - contentString(), + // TODO: Inherited lyrics are literally the same objects, so of course + // their .thing properties aren't going to point back to this one, and + // certainly couldn't be recontextualized... + inheritFromMainRelease(), + + thingList({ + class: input.value(LyricsEntry), + }), ], - additionalFiles: additionalFiles(), - sheetMusicFiles: additionalFiles(), - midiProjectFiles: additionalFiles(), + additionalFiles: thingList({ + class: input.value(AdditionalFile), + }), - originalReleaseTrack: singleReference({ - class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + sheetMusicFiles: thingList({ + class: input.value(AdditionalFile), }), - // Internal use only - for directly identifying an album inside a track's - // util.inspect display, if it isn't indirectly available (by way of being - // included in an album's track list). - dataSourceAlbum: singleReference({ - class: input.value(Album), - find: input.value(find.album), - data: 'albumData', + midiProjectFiles: thingList({ + class: input.value(AdditionalFile), + }), + + mainReleaseTrack: singleReference({ + class: input.value(Track), + find: soupyFind.input('track'), }), artistContribs: [ - inheritContributionListFromOriginalRelease(), + inheritContributionListFromMainRelease(), withDate(), @@ -271,7 +311,7 @@ export class Track extends Thing { ], contributorContribs: [ - inheritContributionListFromOriginalRelease(), + inheritContributionListFromMainRelease(), withDate(), @@ -281,71 +321,45 @@ export class Track extends Thing { }), ], - // Cover artists aren't inherited from the original release, since it - // typically varies by release and isn't defined by the musical qualities - // of the track. coverArtistContribs: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), - }), - - withTrackArtDate({ - fallback: input.value(true), - }), - - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - thingProperty: input.thisProperty(), - artistProperty: input.value('trackCoverArtistContributions'), - date: '#trackArtDate', - }).outputs({ - '#resolvedContribs': '#coverArtistContribs', - }), - - exposeDependencyOrContinue({ - dependency: '#coverArtistContribs', - mode: input.value('empty'), - }), - - withPropertyFromAlbum({ - property: input.value('trackCoverArtistContribs'), - }), - - withRecontextualizedContributionList({ - list: '#album.trackCoverArtistContribs', - artistProperty: input.value('trackCoverArtistContributions'), - }), - - withRedatedContributionList({ - list: '#album.trackCoverArtistContribs', - date: '#trackArtDate', + withCoverArtistContribs({ + from: input.updateValue({ + validate: isContributionList, + }), }), - exposeDependency({dependency: '#album.trackCoverArtistContribs'}), + exposeDependency({dependency: '#coverArtistContribs'}), ], referencedTracks: [ - inheritFromOriginalRelease({ + inheritFromMainRelease({ notFoundValue: input.value([]), }), referenceList({ class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + find: soupyFind.input('track'), }), ], sampledTracks: [ - inheritFromOriginalRelease({ + inheritFromMainRelease({ notFoundValue: input.value([]), }), referenceList({ class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + find: soupyFind.input('track'), + }), + ], + + trackArtworks: [ + exitWithoutUniqueCoverArt({ + value: input.value([]), }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Track Artwork'), ], artTags: [ @@ -355,8 +369,7 @@ export class Track extends Thing { referenceList({ class: input.value(ArtTag), - find: input.value(find.artTag), - data: 'artTagData', + find: soupyFind.input('artTag'), }), ], @@ -365,41 +378,25 @@ export class Track extends Thing { value: input.value([]), }), - withTrackArtDate({ - fallback: input.value(true), - }), - - referencedArtworkList({ - date: '#trackArtDate', - }), + referencedArtworkList(), ], // Update only - albumData: wikiData({ - class: input.value(Album), - }), - - artistData: wikiData({ - class: input.value(Artist), - }), - - artTagData: wikiData({ - class: input.value(ArtTag), - }), + find: soupyFind(), + reverse: soupyReverse(), - flashData: wikiData({ - class: input.value(Flash), + // used for referencedArtworkList (mixedFind) + artworkData: wikiData({ + class: input.value(Artwork), }), + // used for withAlwaysReferenceByDirectory (for some reason) trackData: wikiData({ class: input.value(Track), }), - trackSectionData: wikiData({ - class: input.value(TrackSection), - }), - + // used for withMatchingContributionPresets (indirectly by Contribution) wikiInfo: thing({ class: input.value(WikiInfo), }), @@ -408,63 +405,75 @@ export class Track extends Thing { commentatorArtists: commentatorArtists(), - album: [ - withAlbum(), - exposeDependency({dependency: '#album'}), - ], - date: [ withDate(), exposeDependency({dependency: '#date'}), ], + trackNumber: [ + withTrackNumber(), + exposeDependency({dependency: '#trackNumber'}), + ], + hasUniqueCoverArt: [ withHasUniqueCoverArt(), exposeDependency({dependency: '#hasUniqueCoverArt'}), ], - isOriginalRelease: [ - withOriginalRelease(), + isMainRelease: [ + withMainRelease(), exposeWhetherDependencyAvailable({ - dependency: '#originalRelease', + dependency: '#mainRelease', negate: input.value(true), }), ], - isRerelease: [ - withOriginalRelease(), + isSecondaryRelease: [ + withMainRelease(), exposeWhetherDependencyAvailable({ - dependency: '#originalRelease', + dependency: '#mainRelease', }), ], + // Only has any value for main releases, because secondary releases + // are never secondary to *another* secondary release. + secondaryReleases: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'), + }), + + allReleases: [ + withAllReleases(), + exposeDependency({dependency: '#allReleases'}), + ], + otherReleases: [ withOtherReleases(), exposeDependency({dependency: '#otherReleases'}), ], - referencedByTracks: trackReverseReferenceList({ - list: input.value('referencedTracks'), + groups: [ + withPropertyFromAlbum({ + property: input.value('groups'), + }), + + exposeDependency({ + dependency: '#album.groups', + }), + ], + + referencedByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichReference'), }), - sampledByTracks: trackReverseReferenceList({ - list: input.value('sampledTracks'), + sampledByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichSample'), }), featuredInFlashes: reverseReferenceList({ - data: 'flashData', - list: input.value('featuredTracks'), + reverse: soupyReverse.input('flashesWhichFeature'), }), - - referencedByArtworks: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), - }), - - reverseReferencedArtworkList(), - ], }); static [Thing.yamlDocumentSpec] = { @@ -488,6 +497,8 @@ export class Track extends Thing { transform: String, }, + 'Count In Artist Totals': {property: 'countInArtistTotals'}, + 'Duration': { property: 'duration', transform: parseDuration, @@ -523,9 +534,25 @@ export class Track extends Thing { 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, - 'Lyrics': {property: 'lyrics'}, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Lyrics': { + property: 'lyrics', + transform: parseLyrics, + }, + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, + + 'Referencing Sources': { + property: 'referencingSources', + transform: parseReferencingSources, + }, 'Additional Files': { property: 'additionalFiles', @@ -542,7 +569,7 @@ export class Track extends Thing { transform: parseAdditionalFiles, }, - 'Originally Released As': {property: 'originalReleaseTrack'}, + 'Main Release': {property: 'mainReleaseTrack'}, 'Referenced Tracks': {property: 'referencedTracks'}, 'Sampled Tracks': {property: 'sampledTracks'}, @@ -569,34 +596,54 @@ export class Track extends Thing { transform: parseContributors, }, + 'Track Artwork': { + property: 'trackArtworks', + transform: + parseArtwork({ + thingProperty: 'trackArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'trackCoverArtistContributions', + }), + }, + 'Art Tags': {property: 'artTags'}, 'Review Points': {ignore: true}, }, invalidFieldCombinations: [ - {message: `Rereleases inherit references from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases never count in artist totals`, fields: [ + 'Main Release', + 'Count In Artist Totals', + ]}, + + {message: `Secondary releases inherit references from the main one`, fields: [ + 'Main Release', 'Referenced Tracks', ]}, - {message: `Rereleases inherit samples from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit samples from the main one`, fields: [ + 'Main Release', 'Sampled Tracks', ]}, - {message: `Rereleases inherit artists from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit artists from the main one`, fields: [ + 'Main Release', 'Artists', ]}, - {message: `Rereleases inherit contributors from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit contributors from the main one`, fields: [ + 'Main Release', 'Contributors', ]}, - {message: `Rereleases inherit lyrics from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit lyrics from the main one`, fields: [ + 'Main Release', 'Lyrics', ]}, @@ -617,6 +664,7 @@ export class Track extends Thing { static [Thing.findSpecs] = { track: { referenceTypes: ['track'], + bindTo: 'trackData', getMatchableNames: track => @@ -625,12 +673,12 @@ export class Track extends Thing { : [track.name]), }, - trackOriginalReleasesOnly: { + trackMainReleasesOnly: { referenceTypes: ['track'], bindTo: 'trackData', include: track => - !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'), + !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'), // It's still necessary to check alwaysReferenceByDirectory here, since // it may be set manually (with `Always Reference By Directory: true`), @@ -643,7 +691,12 @@ export class Track extends Thing { }, trackWithArtwork: { - referenceTypes: ['track'], + referenceTypes: [ + 'track', + 'track-referencing-artworks', + 'track-referenced-artworks', + ], + bindTo: 'trackData', include: track => @@ -654,32 +707,144 @@ export class Track extends Thing { ? [] : [track.name]), }, + + trackPrimaryArtwork: { + [Thing.findThisThingOnly]: false, + + referenceTypes: [ + 'track', + 'track-referencing-artworks', + 'track-referenced-artworks', + ], + + bindTo: 'artworkData', + + include: (artwork, {Artwork, Track}) => + artwork instanceof Artwork && + artwork.thing instanceof Track && + artwork === artwork.thing.trackArtworks[0], + + getMatchableNames: ({thing: track}) => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + + getMatchableDirectories: ({thing: track}) => + [track.directory], + }, + }; + + static [Thing.reverseSpecs] = { + tracksWhichReference: { + bindTo: 'trackData', + + referencing: track => track.isMainRelease ? [track] : [], + referenced: track => track.referencedTracks, + }, + + tracksWhichSample: { + bindTo: 'trackData', + + referencing: track => track.isMainRelease ? [track] : [], + referenced: track => track.sampledTracks, + }, + + tracksWhoseArtworksFeature: { + bindTo: 'trackData', + + referencing: track => [track], + referenced: track => track.artTags, + }, + + trackArtistContributionsBy: + soupyReverse.contributionsBy('trackData', 'artistContribs'), + + trackContributorContributionsBy: + soupyReverse.contributionsBy('trackData', 'contributorContribs'), + + trackCoverArtistContributionsBy: + soupyReverse.artworkContributionsBy('trackData', 'trackArtworks'), + + tracksWithCommentaryBy: { + bindTo: 'trackData', + + referencing: track => [track], + referenced: track => track.commentatorArtists, + }, + + tracksWhichAreSecondaryReleasesOf: { + bindTo: 'trackData', + + referencing: track => track.isSecondaryRelease ? [track] : [], + referenced: track => [track.mainReleaseTrack], + }, }; // Track YAML loading is handled in album.js. static [Thing.getYamlLoadingSpec] = null; + getOwnAdditionalFilePath(_file, filename) { + if (!this.album) return null; + + return [ + 'media.albumAdditionalFile', + this.album.directory, + filename, + ]; + } + + getOwnArtworkPath(artwork) { + if (!this.album) return null; + + return [ + 'media.trackCover', + this.album.directory, + + (artwork.unqualifiedDirectory + ? this.directory + '-' + artwork.unqualifiedDirectory + : this.directory), + + artwork.fileExtension, + ]; + } + + countOwnContributionInContributionTotals(_contrib) { + if (!this.countInArtistTotals) { + return false; + } + + if (this.isSecondaryRelease) { + return false; + } + + return true; + } + + countOwnContributionInDurationTotals(_contrib) { + if (!this.countInArtistTotals) { + return false; + } + + if (this.isSecondaryRelease) { + return false; + } + + return true; + } + [inspect.custom](depth) { const parts = []; parts.push(Thing.prototype[inspect.custom].apply(this)); - if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) { - parts.unshift(`${colors.yellow('[rerelease]')} `); + if (CacheableObject.getUpdateValue(this, 'mainReleaseTrack')) { + parts.unshift(`${colors.yellow('[secrelease]')} `); } let album; if (depth >= 0) { - try { - album = this.album; - } catch (_error) { - // Computing album might crash for any reason, which we don't want to - // distract from another error we might be trying to work out at the - // moment (for which debugging might involve inspecting this track!). - } - - album ??= this.dataSourceAlbum; + album = this.album; } if (album) { diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index ef643681..f97f9027 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,9 +1,8 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml'; import {input} from '#composite'; -import find from '#find'; import Thing from '#thing'; -import {parseContributionPresets} from '#yaml'; +import {parseContributionPresets, parseWallpaperParts} from '#yaml'; import { isBoolean, @@ -15,8 +14,17 @@ import { } from '#validators'; import {exitWithoutDependency} from '#composite/control-flow'; -import {contentString, flag, name, referenceList, wikiData} - from '#composite/wiki-properties'; + +import { + contentString, + fileExtension, + flag, + name, + referenceList, + simpleString, + soupyFind, + wallpaperParts, +} from '#composite/wiki-properties'; export class WikiInfo extends Thing { static [Thing.friendlyName] = `Wiki Info`; @@ -69,10 +77,13 @@ export class WikiInfo extends Thing { }, }, + wikiWallpaperFileExtension: fileExtension('jpg'), + wikiWallpaperStyle: simpleString(), + wikiWallpaperParts: wallpaperParts(), + divideTrackListsByGroups: referenceList({ class: input.value(Group), - find: input.value(find.group), - data: 'groupData', + find: soupyFind.input('group'), }), contributionPresets: { @@ -99,6 +110,8 @@ export class WikiInfo extends Thing { // Update only + find: soupyFind(), + searchDataAvailable: { flags: {update: true}, update: { @@ -106,28 +119,40 @@ export class WikiInfo extends Thing { default: false, }, }, - - groupData: wikiData({ - class: input.value(Group), - }), }); static [Thing.yamlDocumentSpec] = { fields: { 'Name': {property: 'name'}, 'Short Name': {property: 'nameShort'}, + 'Color': {property: 'color'}, + 'Description': {property: 'description'}, + 'Footer Content': {property: 'footerContent'}, + 'Default Language': {property: 'defaultLanguage'}, + 'Canonical Base': {property: 'canonicalBase'}, - 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + + 'Wiki Wallpaper File Extension': {property: 'wikiWallpaperFileExtension'}, + + 'Wiki Wallpaper Style': {property: 'wikiWallpaperStyle'}, + + 'Wiki Wallpaper Parts': { + property: 'wikiWallpaperParts', + transform: parseWallpaperParts, + }, + 'Enable Flashes & Games': {property: 'enableFlashesAndGames'}, 'Enable Listings': {property: 'enableListings'}, 'Enable News': {property: 'enableNews'}, 'Enable Art Tag UI': {property: 'enableArtTagUI'}, 'Enable Group UI': {property: 'enableGroupUI'}, + 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + 'Contribution Presets': { property: 'contributionPresets', transform: parseContributionPresets, diff --git a/src/data/validators.js b/src/data/validators.js deleted file mode 100644 index 84e08cb8..00000000 --- a/src/data/validators.js +++ /dev/null @@ -1,1104 +0,0 @@ -import {inspect as nodeInspect} from 'node:util'; - -import {openAggregate, withAggregate} from '#aggregate'; -import {colors, ENABLE_COLOR} from '#cli'; -import {cut, empty, matchMultiline, typeAppearance} from '#sugar'; -import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot} - from '#wiki-data'; - -function inspect(value) { - return nodeInspect(value, {colors: ENABLE_COLOR}); -} - -export function getValidatorCreator(validator) { - return validator[Symbol.for(`hsmusic.validator.creator`)] ?? null; -} - -export function getValidatorCreatorMeta(validator) { - return validator[Symbol.for(`hsmusic.validator.creatorMeta`)] ?? null; -} - -export function setValidatorCreatorMeta(validator, creator, meta) { - validator[Symbol.for(`hsmusic.validator.creator`)] = creator; - validator[Symbol.for(`hsmusic.validator.creatorMeta`)] = meta; - return validator; -} - -// Basic types (primitives) - -export function a(noun) { - return /[aeiou]/.test(noun[0]) ? `an ${noun}` : `a ${noun}`; -} - -export function validateType(type) { - const fn = value => { - if (typeof value !== type) - throw new TypeError(`Expected ${a(type)}, got ${typeAppearance(value)}`); - - return true; - }; - - setValidatorCreatorMeta(fn, validateType, {type}); - - return fn; -} - -export const isBoolean = - validateType('boolean'); - -export const isFunction = - validateType('function'); - -export const isNumber = - validateType('number'); - -export const isString = - validateType('string'); - -export const isSymbol = - validateType('symbol'); - -// Use isObject instead, which disallows null. -export const isTypeofObject = - validateType('object'); - -export function isPositive(number) { - isNumber(number); - - if (number <= 0) throw new TypeError(`Expected positive number`); - - return true; -} - -export function isNegative(number) { - isNumber(number); - - if (number >= 0) throw new TypeError(`Expected negative number`); - - return true; -} - -export function isPositiveOrZero(number) { - isNumber(number); - - if (number < 0) throw new TypeError(`Expected positive number or zero`); - - return true; -} - -export function isNegativeOrZero(number) { - isNumber(number); - - if (number > 0) throw new TypeError(`Expected negative number or zero`); - - return true; -} - -export function isInteger(number) { - isNumber(number); - - if (number % 1 !== 0) throw new TypeError(`Expected integer`); - - return true; -} - -export function isCountingNumber(number) { - isInteger(number); - isPositive(number); - - return true; -} - -export function isWholeNumber(number) { - isInteger(number); - isPositiveOrZero(number); - - return true; -} - -export function isStringNonEmpty(value) { - isString(value); - - if (value.trim().length === 0) - throw new TypeError(`Expected non-empty string`); - - return true; -} - -export function optional(validator) { - return value => - value === null || - value === undefined || - validator(value); -} - -// Complex types (non-primitives) - -export function isInstance(value, constructor) { - isObject(value); - - if (!(value instanceof constructor)) - throw new TypeError(`Expected ${constructor.name}, got ${value.constructor.name}`); - - return true; -} - -export function isDate(value) { - isInstance(value, Date); - - if (isNaN(value)) - throw new TypeError(`Expected valid date`); - - return true; -} - -export function isObject(value) { - isTypeofObject(value); - - // Note: Please remember that null is always a valid value for properties - // held by a CacheableObject. This assertion is exclusively for use in other - // contexts. - if (value === null) - throw new TypeError(`Expected an object, got null`); - - return true; -} - -export function isArray(value) { - if (typeof value !== 'object' || value === null || !Array.isArray(value)) - throw new TypeError(`Expected an array, got ${typeAppearance(value)}`); - - return true; -} - -// This one's shaped a bit different from other "is" functions. -// More like validate functions, it returns a function. -export function is(...values) { - if (Array.isArray(values)) { - values = new Set(values); - } - - if (values.size === 1) { - const expected = Array.from(values)[0]; - - return (value) => { - if (value !== expected) { - throw new TypeError(`Expected ${expected}, got ${value}`); - } - - return true; - }; - } - - const fn = (value) => { - if (!values.has(value)) { - throw new TypeError(`Expected one of ${Array.from(values).join(' ')}, got ${value}`); - } - - return true; - }; - - setValidatorCreatorMeta(fn, is, {values}); - - return fn; -} - -function validateArrayItemsHelper(itemValidator) { - return (item, index, array) => { - try { - const value = itemValidator(item, index, array); - - if (value !== true) { - throw new Error(`Expected validator to return true`); - } - } catch (caughtError) { - const indexPart = colors.yellow(`zero-index ${index}`) - const itemPart = inspect(item); - const message = `Error at ${indexPart}: ${itemPart}`; - const error = new Error(message, {cause: caughtError}); - error[Symbol.for('hsmusic.annotateError.indexInSourceArray')] = index; - throw error; - } - }; -} - -export function validateArrayItems(itemValidator) { - const helper = validateArrayItemsHelper(itemValidator); - - return (array) => { - isArray(array); - - withAggregate({message: 'Errors validating array items'}, ({call}) => { - for (let index = 0; index < array.length; index++) { - call(helper, array[index], index, array); - } - }); - - return true; - }; -} - -export function strictArrayOf(itemValidator) { - return validateArrayItems(itemValidator); -} - -export function sparseArrayOf(itemValidator) { - return validateArrayItems((item, index, array) => { - if (item === false || item === null) { - return true; - } - - return itemValidator(item, index, array); - }); -} - -export function looseArrayOf(itemValidator) { - return validateArrayItems((item, index, array) => { - if (item === false || item === null || item === undefined) { - return true; - } - - return itemValidator(item, index, array); - }); -} - -export function validateInstanceOf(constructor) { - const fn = (object) => isInstance(object, constructor); - - setValidatorCreatorMeta(fn, validateInstanceOf, {constructor}); - - return fn; -} - -// Wiki data (primitives & non-primitives) - -export function isColor(color) { - isStringNonEmpty(color); - - if (color.startsWith('#')) { - if (![4, 5, 7, 9].includes(color.length)) - throw new TypeError(`Expected #rgb, #rgba, #rrggbb, or #rrggbbaa, got length ${color.length}`); - - if (/[^0-9a-fA-F]/.test(color.slice(1))) - throw new TypeError(`Expected hexadecimal digits`); - - return true; - } - - throw new TypeError(`Unknown color format`); -} - -export function isCommentary(commentaryText) { - isContentString(commentaryText); - - const rawMatches = - Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive)); - - if (empty(rawMatches)) { - throw new TypeError(`Expected at least one commentary heading`); - } - - const niceMatches = - rawMatches.map(match => ({ - position: match.index, - length: match[0].length, - })); - - validateArrayItems(({position, length}, index) => { - if (index === 0 && position > 0) { - throw new TypeError(`Expected first commentary heading to be at top`); - } - - const ownInput = commentaryText.slice(position, position + length); - const restOfInput = commentaryText.slice(position + length); - - const upToNextLineBreak = - (restOfInput.includes('\n') - ? restOfInput.slice(0, restOfInput.indexOf('\n')) - : restOfInput); - - if (/\S/.test(upToNextLineBreak)) { - throw new TypeError( - `Expected commentary heading to occupy entire line, got extra text:\n` + - `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + - `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + - `(Check for missing "|-" in YAML, or a misshapen annotation)`); - } - - if (!commentaryRegexCaseSensitiveOneShot.test(ownInput)) { - throw new TypeError( - `Miscapitalization in commentary heading:\n` + - `${colors.red(`"${cut(ownInput, 60)}"`)}\n` + - `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`); - } - - const nextHeading = - (index === niceMatches.length - 1 - ? commentaryText.length - : niceMatches[index + 1].position); - - const upToNextHeading = - commentaryText.slice(position + length, nextHeading); - - if (!/\S/.test(upToNextHeading)) { - throw new TypeError( - `Expected commentary entry to have body text, only got a heading`); - } - - return true; - })(niceMatches); - - return true; -} - -const isArtistRef = validateReference('artist'); - -export function validateProperties(spec) { - const { - [validateProperties.validateOtherKeys]: validateOtherKeys = null, - [validateProperties.allowOtherKeys]: allowOtherKeys = false, - } = spec; - - const specEntries = Object.entries(spec); - const specKeys = Object.keys(spec); - - return (object) => { - isObject(object); - - if (Array.isArray(object)) - throw new TypeError(`Expected an object, got array`); - - withAggregate({message: `Errors validating object properties`}, ({push}) => { - const testEntries = specEntries.slice(); - - const unknownKeys = Object.keys(object).filter((key) => !specKeys.includes(key)); - if (validateOtherKeys) { - for (const key of unknownKeys) { - testEntries.push([key, validateOtherKeys]); - } - } - - for (const [specKey, specValidator] of testEntries) { - const value = object[specKey]; - try { - specValidator(value); - } catch (caughtError) { - const keyPart = colors.green(specKey); - const valuePart = inspect(value); - const message = `Error for key ${keyPart}: ${valuePart}`; - push(new Error(message, {cause: caughtError})); - } - } - - if (!validateOtherKeys && !allowOtherKeys && !empty(unknownKeys)) { - push(new Error( - `Unknown keys present (${unknownKeys.length}): [${unknownKeys.join(', ')}]`)); - } - }); - - return true; - }; -} - -validateProperties.validateOtherKeys = Symbol(); -validateProperties.allowOtherKeys = Symbol(); - -export const validateAllPropertyValues = (validator) => - validateProperties({ - [validateProperties.validateOtherKeys]: validator, - }); - -const illeaglInvisibleSpace = { - action: 'delete', -}; - -const illegalVisibleSpace = { - action: 'replace', - with: ' ', - withAnnotation: `normal space`, -}; - -const illegalContentSpec = [ - {illegal: '\u200b', annotation: `zero-width space`, ...illeaglInvisibleSpace}, - {illegal: '\u2005', annotation: `four-per-em space`, ...illegalVisibleSpace}, - {illegal: '\u205f', annotation: `medium mathematical space`, ...illegalVisibleSpace}, - {illegal: '\xa0', annotation: `non-breaking space`, ...illegalVisibleSpace}, - - { - action: 'replace', - illegal: '<a href', - annotation: `HTML-style link`, - with: '[...](...)', - withAnnotation: `markdown`, - }, -]; - -for (const entry of illegalContentSpec) { - entry.test = string => - string.startsWith(entry.illegal); - - if (entry.action === 'replace') { - entry.enact = string => - string.replaceAll(entry.illegal, entry.with); - } -} - -const illegalSequencesInContent = - illegalContentSpec - .map(entry => entry.illegal) - .map(illegal => - (illegal.length === 1 - ? `${illegal}+` - : `(?:${illegal})+`)) - .join('|'); - -const illegalContentRegexp = - new RegExp(illegalSequencesInContent, 'g'); - -const legalContentNearEndRegexp = - new RegExp(`(?<=^|${illegalSequencesInContent})(?:(?!${illegalSequencesInContent}).)+$`); - -const legalContentNearStartRegexp = - new RegExp(`^(?:(?!${illegalSequencesInContent}).)+`); - -const trimWhitespaceNearBothSidesRegexp = - /^ +| +$/gm; - -const trimWhitespaceNearEndRegexp = - / +$/gm; - -export function isContentString(content) { - isString(content); - - const mainAggregate = openAggregate({ - message: `Errors validating content string`, - translucent: 'single', - }); - - const illegalAggregate = openAggregate({ - message: `Illegal characters found in content string`, - }); - - for (const {match, where} of matchMultiline(content, illegalContentRegexp)) { - const {annotation, action, ...options} = - illegalContentSpec - .find(entry => entry.test(match[0])); - - const matchStart = match.index; - const matchEnd = match.index + match[0].length; - - const before = - content - .slice(Math.max(0, matchStart - 3), matchStart) - .match(legalContentNearEndRegexp) - ?.[0]; - - const after = - content - .slice(matchEnd, Math.min(content.length, matchEnd + 3)) - .match(legalContentNearStartRegexp) - ?.[0]; - - const beforePart = - before && `"${before}"`; - - const afterPart = - after && `"${after}"`; - - const surroundings = - (before && after - ? `between ${beforePart} and ${afterPart}` - : before - ? `after ${beforePart}` - : after - ? `before ${afterPart}` - : ``); - - const illegalPart = - colors.red( - (annotation - ? `"${match[0]}" (${annotation})` - : `"${match[0]}"`)); - - const replacement = - (action === 'replace' - ? options.enact(match[0]) - : null); - - const replaceWithPart = - (action === 'replace' - ? colors.green( - (options.withAnnotation - ? `"${replacement}" (${options.withAnnotation})` - : `"${replacement}"`)) - : null); - - const actionPart = - (action === `delete` - ? `Delete ${illegalPart}` - : action === 'replace' - ? `Replace ${illegalPart} with ${replaceWithPart}` - : `Matched ${illegalPart}`); - - const parts = [ - actionPart, - surroundings, - `(${where})`, - ].filter(Boolean); - - illegalAggregate.push(new TypeError(parts.join(` `))); - } - - const isMultiline = content.includes('\n'); - - const trimWhitespaceAggregate = openAggregate({ - message: - (isMultiline - ? `Whitespace found at end of line` - : `Whitespace found at start or end`), - }); - - const trimWhitespaceRegexp = - (isMultiline - ? trimWhitespaceNearEndRegexp - : trimWhitespaceNearBothSidesRegexp); - - for ( - const {match, lineNumber, columnNumber, containingLine} of - matchMultiline(content, trimWhitespaceRegexp, { - formatWhere: false, - getContainingLine: true, - }) - ) { - const linePart = - colors.yellow(`line ${lineNumber + 1}`); - - const where = - (match[0].length === containingLine.length - ? `as all of ${linePart}` - : columnNumber === 0 - ? (isMultiline - ? `at start of ${linePart}` - : `at start`) - : (isMultiline - ? `at end of ${linePart}` - : `at end`)); - - const whitespacePart = - colors.red(`"${match[0]}"`); - - const parts = [ - `Matched ${whitespacePart}`, - where, - ]; - - trimWhitespaceAggregate.push(new TypeError(parts.join(` `))); - } - - mainAggregate.call(() => illegalAggregate.close()); - mainAggregate.call(() => trimWhitespaceAggregate.close()); - mainAggregate.close(); - - return true; -} - -export function isThingClass(thingClass) { - isFunction(thingClass); - - // This is *expressly* no faster than an instanceof check, because it's - // deliberately still walking the prototype chain for the provided object. - // (This is necessary because the symbol we're checking is defined only on - // the Thing constructor, and not directly on each subclass.) However, it's - // preferred over an instanceof check anyway, because instanceof would - // require that the #validators module has access to #thing, which it - // currently doesn't! - if (!(Symbol.for('Thing.isThingConstructor') in thingClass)) { - throw new TypeError(`Expected a Thing constructor, missing Thing.isThingConstructor`); - } - - return true; -} - -export function isThing(thing) { - isObject(thing); - - // This *is* faster than an instanceof check, because it doesn't walk the - // prototype chain. It works because this property is set as part of every - // Thing subclass's inherited "public class fields" - it's set directly on - // every constructed Thing. - if (!Object.hasOwn(thing, Symbol.for('Thing.isThing'))) { - throw new TypeError(`Expected a Thing, missing Thing.isThing`); - } - - return true; -} - -export const isContribution = validateProperties({ - artist: isArtistRef, - annotation: optional(isStringNonEmpty), - - countInDurationTotals: optional(isBoolean), - countInContributionTotals: optional(isBoolean), -}); - -export const isContributionList = validateArrayItems(isContribution); - -export const contributionPresetPropertySpec = { - album: [ - 'artistContribs', - ], - - flash: [ - 'contributorContribs', - ], - - track: [ - 'artistContribs', - 'contributorContribs', - ], -}; - -// TODO: This validator basically constructs itself as it goes. -// This is definitely some shenanigans! -export function isContributionPresetContext(list) { - isArray(list); - - if (empty(list)) { - throw new TypeError(`Expected at least one item`); - } - - const isTarget = - is(...Object.keys(contributionPresetPropertySpec)); - - const [target, ...properties] = list; - - isTarget(target); - - const isProperty = - is(...contributionPresetPropertySpec[target]); - - const isPropertyList = - validateArrayItems(isProperty); - - isPropertyList(properties); - - return true; -} - -export const isContributionPreset = validateProperties({ - annotation: isStringNonEmpty, - context: isContributionPresetContext, - - countInDurationTotals: optional(isBoolean), - countInContributionTotals: optional(isBoolean), -}); - -export const isContributionPresetList = validateArrayItems(isContributionPreset); - -export const isAdditionalFile = validateProperties({ - title: isName, - description: optional(isContentString), - files: optional(validateArrayItems(isString)), -}); - -export const isAdditionalFileList = validateArrayItems(isAdditionalFile); - -export const isTrackSection = validateProperties({ - name: optional(isName), - color: optional(isColor), - dateOriginallyReleased: optional(isDate), - isDefaultTrackSection: optional(isBoolean), - tracks: optional(validateReferenceList('track')), -}); - -export const isTrackSectionList = validateArrayItems(isTrackSection); - -export const isSeries = validateProperties({ - name: isName, - description: optional(isContentString), - albums: optional(validateReferenceList('album')), - - showAlbumArtists: - optional(is('all', 'differing', 'none')), -}); - -export const isSeriesList = validateArrayItems(isSeries); - -export const isWallpaperPart = validateProperties({ - asset: optional(isString), - style: optional(isString), -}); - -export const isWallpaperPartList = validateArrayItems(isWallpaperPart); - -export function isDimensions(dimensions) { - isArray(dimensions); - - if (dimensions.length !== 2) throw new TypeError(`Expected 2 item array`); - - if (dimensions[0] !== null) { - isPositive(dimensions[0]); - isInteger(dimensions[0]); - } - - if (dimensions[1] !== null) { - isPositive(dimensions[1]); - isInteger(dimensions[1]); - } - - return true; -} - -export function isDirectory(directory) { - isStringNonEmpty(directory); - - if (directory.match(/[^a-zA-Z0-9_-]/)) - throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`); - - return true; -} - -export function isDuration(duration) { - isNumber(duration); - isPositiveOrZero(duration); - - return true; -} - -export function isFileExtension(string) { - isStringNonEmpty(string); - - if (string[0] === '.') - throw new TypeError(`Expected no dot (.) at the start of file extension`); - - if (string.match(/[^a-zA-Z0-9_]/)) - throw new TypeError(`Expected only alphanumeric and underscore`); - - return true; -} - -export function isLanguageCode(string) { - // TODO: This is a stub function because really we don't need a detailed - // is-language-code parser right now. - - isString(string); - - return true; -} - -export function isName(name) { - return isContentString(name); -} - -export function isURL(string) { - isStringNonEmpty(string); - - new URL(string); - - return true; -} - -export function validateReference(type) { - return (ref) => { - isStringNonEmpty(ref); - - const match = ref - .trim() - .match(/^(?:(?<typePart>\S+):(?=\S))?(?<directoryPart>.+)(?<!:)$/); - - if (!match) throw new TypeError(`Malformed reference`); - - const {groups: {typePart, directoryPart}} = match; - - if (typePart) { - if (Array.isArray(type)) { - if (!type.includes(typePart)) { - throw new TypeError( - `Expected ref to begin with one of ` + - type.map(type => `"${type}:"`).join(', ') + - `, got "${typePart}:"`); - } - } else if (typePart !== type) { - throw new TypeError( - `Expected ref to begin with "${type}:", got "${typePart}:"`); - } - - isDirectory(directoryPart); - } - - isName(ref); - - return true; - }; -} - -export function validateReferenceList(type) { - return validateArrayItems(validateReference(type)); -} - -export function validateThing({ - referenceType: expectedReferenceType = '', -} = {}) { - return (thing) => { - isThing(thing); - - if (expectedReferenceType) { - const {[Symbol.for('Thing.referenceType')]: referenceType} = - thing.constructor; - - if (referenceType !== expectedReferenceType) { - throw new TypeError(`Expected only ${expectedReferenceType}, got other type: ${referenceType}`); - } - } - - return true; - }; -} - -const validateWikiData_cache = {}; - -export function validateWikiData({ - referenceType = '', - allowMixedTypes = false, -} = {}) { - if (referenceType && allowMixedTypes) { - throw new TypeError(`Don't specify both referenceType and allowMixedTypes`); - } - - validateWikiData_cache[referenceType] ??= {}; - validateWikiData_cache[referenceType][allowMixedTypes] ??= new WeakMap(); - - const isArrayOfObjects = validateArrayItems(isObject); - - return (array) => { - const subcache = validateWikiData_cache[referenceType][allowMixedTypes]; - if (subcache.has(array)) return subcache.get(array); - - let OK = false; - - try { - isArrayOfObjects(array); - - if (empty(array)) { - OK = true; return true; - } - - const allRefTypes = new Set(); - - let foundThing = false; - let foundOtherObject = false; - - for (const object of array) { - if (Object.hasOwn(object, Symbol.for('Thing.isThing'))) { - // Early-exit if a non-Thing object has been found - nothing more can - // be learned. - if (foundOtherObject) { - throw new TypeError(`Expected array of wiki data objects, got mixed items`); - } - - foundThing = true; - allRefTypes.add(object.constructor[Symbol.for('Thing.referenceType')]); - } else { - // Early-exit if a Thing has been found - nothing more can be learned. - if (foundThing) { - throw new TypeError(`Expected array of wiki data objects, got mixed items`); - } - - foundOtherObject = true; - } - } - - if (foundOtherObject && !foundThing) { - throw new TypeError(`Expected array of wiki data objects, got array of other objects`); - } - - if (allRefTypes.size > 1) { - if (allowMixedTypes) { - OK = true; return true; - } - - const types = () => Array.from(allRefTypes).join(', '); - - if (referenceType) { - if (allRefTypes.has(referenceType)) { - allRefTypes.remove(referenceType); - throw new TypeError(`Expected array of only ${referenceType}, also got other types: ${types()}`) - } else { - throw new TypeError(`Expected array of only ${referenceType}, got other types: ${types()}`); - } - } - - throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`); - } - - const onlyRefType = Array.from(allRefTypes)[0]; - - if (referenceType && onlyRefType !== referenceType) { - throw new TypeError(`Expected array of ${referenceType}, got array of ${onlyRefType}`) - } - - OK = true; return true; - } finally { - subcache.set(array, OK); - } - }; -} - -export const isAdditionalName = validateProperties({ - name: isContentString, - annotation: optional(isContentString), -}); - -export const isAdditionalNameList = validateArrayItems(isAdditionalName); - -// Compositional utilities - -export function anyOf(...validators) { - const validConstants = new Set(); - const validConstructors = new Set(); - const validTypes = new Set(); - - const constantValidators = []; - const constructorValidators = []; - const typeValidators = []; - - const leftoverValidators = []; - - for (const validator of validators) { - const creator = getValidatorCreator(validator); - const creatorMeta = getValidatorCreatorMeta(validator); - - switch (creator) { - case is: - for (const value of creatorMeta.values) { - validConstants.add(value); - } - - constantValidators.push(validator); - break; - - case validateInstanceOf: - validConstructors.add(creatorMeta.constructor); - constructorValidators.push(validator); - break; - - case validateType: - validTypes.add(creatorMeta.type); - typeValidators.push(validator); - break; - - default: - leftoverValidators.push(validator); - break; - } - } - - return (value) => { - const errorInfo = []; - - if (validConstants.has(value)) { - return true; - } - - if (!empty(validTypes)) { - if (validTypes.has(typeof value)) { - return true; - } - } - - for (const constructor of validConstructors) { - if (value instanceof constructor) { - return true; - } - } - - for (const [i, validator] of leftoverValidators.entries()) { - try { - const result = validator(value); - - if (result !== true) { - throw new Error(`Check returned false`); - } - - return true; - } catch (error) { - errorInfo.push([validator, i, error]); - } - } - - // Don't process error messages until every validator has failed. - - const errors = []; - const prefaceErrorInfo = []; - - let offset = 0; - - if (!empty(validConstants)) { - const constants = - Array.from(validConstants); - - const gotPart = `, got ${value}`; - - prefaceErrorInfo.push([ - constantValidators, - offset++, - new TypeError( - `Expected any of ${constants.join(' ')}` + gotPart), - ]); - } - - if (!empty(validTypes)) { - const types = - Array.from(validTypes); - - const gotType = typeAppearance(value); - const gotPart = `, got ${gotType}`; - - prefaceErrorInfo.push([ - typeValidators, - offset++, - new TypeError( - `Expected any of ${types.join(', ')}` + gotPart), - ]); - } - - if (!empty(validConstructors)) { - const names = - Array.from(validConstructors) - .map(constructor => constructor.name); - - const gotName = value?.constructor?.name; - const gotPart = (gotName ? `, got ${gotName}` : ``); - - prefaceErrorInfo.push([ - constructorValidators, - offset++, - new TypeError( - `Expected any of ${names.join(', ')}` + gotPart), - ]); - } - - for (const info of errorInfo) { - info[1] += offset; - } - - for (const [validator, i, error] of prefaceErrorInfo.concat(errorInfo)) { - error.message = - (validator?.name - ? `${i + 1}. "${validator.name}": ${error.message}` - : `${i + 1}. ${error.message}`); - - error.check = - (Array.isArray(validator) && validator.length === 1 - ? validator[0] - : validator); - - errors.push(error); - } - - const total = offset + leftoverValidators.length; - throw new AggregateError(errors, - `Expected any of ${total} possible checks, ` + - `but none were true`); - }; -} diff --git a/src/data/yaml.js b/src/data/yaml.js index 64223662..9a0295b8 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -8,11 +8,14 @@ import {inspect as nodeInspect} from 'node:util'; import yaml from 'js-yaml'; import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; +import {parseContentNodes, splitContentNodesAround} from '#replacer'; import {sortByName} from '#sort'; import Thing from '#thing'; import thingConstructors from '#things'; +import {matchContentEntries, multipleLyricsDetectionRegex} from '#wiki-data'; import { + aggregateThrows, annotateErrorWithFile, decorateErrorWithIndex, decorateErrorWithAnnotation, @@ -30,8 +33,10 @@ import { atOffset, empty, filterProperties, + getNestedProp, stitchArrays, typeAppearance, + unique, withEntries, } from '#sugar'; @@ -86,6 +91,10 @@ function makeProcessDocument(thingConstructor, { // A or B. // invalidFieldCombinations = [], + + // Bouncing function used to process subdocuments: this is a function which + // in turn calls the appropriate *result of* makeProcessDocument. + processDocument: bouncer, }) { if (!thingConstructor) { throw new Error(`Missing Thing class`); @@ -95,6 +104,10 @@ function makeProcessDocument(thingConstructor, { throw new Error(`Expected fields to be provided`); } + if (!bouncer) { + throw new Error(`Missing processDocument bouncer`); + } + const knownFields = Object.keys(fieldSpecs); const ignoredFields = @@ -142,9 +155,12 @@ function makeProcessDocument(thingConstructor, { : `document`); const aggregate = openAggregate({ + ...aggregateThrows(ProcessDocumentError), message: `Errors processing ${constructorPart}` + namePart, }); + const thing = Reflect.construct(thingConstructor, []); + const documentEntries = Object.entries(document) .filter(([field]) => !ignoredFields.includes(field)); @@ -192,13 +208,50 @@ function makeProcessDocument(thingConstructor, { const fieldValues = {}; + const subdocSymbol = Symbol('subdoc'); + const subdocLayouts = {}; + + const isSubdocToken = value => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, subdocSymbol); + + const transformUtilities = { + ...thingConstructors, + + subdoc(documentType, data, { + bindInto = null, + provide = null, + } = {}) { + if (!documentType) + throw new Error(`Expected document type, got ${typeAppearance(documentType)}`); + if (!data) + throw new Error(`Expected data, got ${typeAppearance(data)}`); + if (typeof data !== 'object' || data === null) + throw new Error(`Expected data to be an object, got ${typeAppearance(data)}`); + if (typeof bindInto !== 'string' && bindInto !== null) + throw new Error(`Expected bindInto to be a string, got ${typeAppearance(bindInto)}`); + if (typeof provide !== 'object' && provide !== null) + throw new Error(`Expected provide to be an object, got ${typeAppearance(provide)}`); + + return { + [subdocSymbol]: { + documentType, + data, + bindInto, + provide, + }, + }; + }, + }; + for (const [field, documentValue] of documentEntries) { if (skippedFields.has(field)) continue; // This variable would like to certify itself as "not into capitalism". let propertyValue = (fieldSpecs[field].transform - ? fieldSpecs[field].transform(documentValue) + ? fieldSpecs[field].transform(documentValue, transformUtilities) : documentValue); // Completely blank items in a YAML list are read as null. @@ -221,10 +274,99 @@ function makeProcessDocument(thingConstructor, { } } + if (isSubdocToken(propertyValue)) { + subdocLayouts[field] = propertyValue[subdocSymbol]; + continue; + } + + if (Array.isArray(propertyValue) && propertyValue.every(isSubdocToken)) { + subdocLayouts[field] = + propertyValue + .map(token => token[subdocSymbol]); + continue; + } + fieldValues[field] = propertyValue; } - const thing = Reflect.construct(thingConstructor, []); + const subdocErrors = []; + + const followSubdocSetup = setup => { + let error = null; + + let subthing; + try { + const result = bouncer(setup.data, setup.documentType); + subthing = result.thing; + result.aggregate.close(); + } catch (caughtError) { + error = caughtError; + } + + if (subthing) { + if (setup.bindInto) { + subthing[setup.bindInto] = thing; + } + + if (setup.provide) { + Object.assign(subthing, setup.provide); + } + } + + return {error, subthing}; + }; + + for (const [field, layout] of Object.entries(subdocLayouts)) { + if (Array.isArray(layout)) { + const subthings = []; + let anySucceeded = false; + let anyFailed = false; + + for (const [index, setup] of layout.entries()) { + const {subthing, error} = followSubdocSetup(setup); + if (error) { + subdocErrors.push(new SubdocError( + {field, index}, + setup, + {cause: error})); + } + + if (subthing) { + subthings.push(subthing); + anySucceeded = true; + } else { + anyFailed = true; + } + } + + if (anySucceeded) { + fieldValues[field] = subthings; + } else if (anyFailed) { + skippedFields.add(field); + } + } else { + const setup = layout; + const {subthing, error} = followSubdocSetup(setup); + + if (error) { + subdocErrors.push(new SubdocError( + {field}, + setup, + {cause: error})); + } + + if (subthing) { + fieldValues[field] = subthing; + } else { + skippedFields.add(field); + } + } + } + + if (!empty(subdocErrors)) { + aggregate.push(new SubdocAggregateError( + subdocErrors, thingConstructor)); + } const fieldValueErrors = []; @@ -258,6 +400,8 @@ function makeProcessDocument(thingConstructor, { }); } +export class ProcessDocumentError extends AggregateError {} + export class UnknownFieldsError extends Error { constructor(fields) { super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`); @@ -345,12 +489,46 @@ export class SkippedFieldsSummaryError extends Error { : `${entries.length} fields`); super( - colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) + + colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:`)) + '\n' + lines.join('\n') + '\n' + colors.bright(colors.yellow(`See above errors for details.`))); } } +export class SubdocError extends Error { + constructor({field, index = null}, setup, options) { + const fieldText = + (index === null + ? colors.green(`"${field}"`) + : colors.yellow(`#${index + 1}`) + ' in ' + + colors.green(`"${field}"`)); + + const constructorText = + setup.documentType.name; + + if (options.cause instanceof ProcessDocumentError) { + options.cause[Symbol.for('hsmusic.aggregate.translucent')] = true; + } + + super( + `Errors processing ${constructorText} for ${fieldText} field`, + options); + } +} + +export class SubdocAggregateError extends AggregateError { + [Symbol.for('hsmusic.aggregate.translucent')] = true; + + constructor(errors, thingConstructor) { + const constructorText = + colors.green(thingConstructor.name); + + super( + errors, + `Errors processing subdocuments for ${constructorText}`); + } +} + export function parseDate(date) { return new Date(date); } @@ -433,49 +611,39 @@ export function parseContributors(entries) { }); } -export function parseAdditionalFiles(entries) { +export function parseAdditionalFiles(entries, {subdoc, AdditionalFile}) { return parseArrayEntries(entries, item => { if (typeof item !== 'object') return item; - return { - title: item['Title'], - description: item['Description'] ?? null, - files: item['Files'], - }; + return subdoc(AdditionalFile, item, {bindInto: 'thing'}); }); } -export function parseAdditionalNames(entries) { +export function parseAdditionalNames(entries, {subdoc, AdditionalName}) { return parseArrayEntries(entries, item => { - if (typeof item === 'object' && typeof item['Name'] === 'string') - return { - name: item['Name'], - annotation: item['Annotation'] ?? null, - }; + if (typeof item === 'object') { + return subdoc(AdditionalName, item, {bindInto: 'thing'}); + } if (typeof item !== 'string') return item; const match = item.match(extractAccentRegex); if (!match) return item; - return { - name: match.groups.main, - annotation: match.groups.accent ?? null, + const document = { + ['Name']: match.groups.main, + ['Annotation']: match.groups.accent ?? null, }; + + return subdoc(AdditionalName, document, {bindInto: 'thing'}); }); } -export function parseSerieses(entries) { +export function parseSerieses(entries, {subdoc, Series}) { return parseArrayEntries(entries, item => { if (typeof item !== 'object') return item; - return { - name: item['Name'], - description: item['Description'] ?? null, - albums: item['Albums'] ?? null, - - showAlbumArtists: item['Show Album Artists'] ?? null, - }; + return subdoc(Series, item, {bindInto: 'group'}); }); } @@ -613,6 +781,172 @@ export function parseAnnotatedReferences(entries, { }); } +export function parseArtwork({ + single = false, + thingProperty = null, + dimensionsFromThingProperty = null, + fileExtensionFromThingProperty = null, + dateFromThingProperty = null, + artistContribsFromThingProperty = null, + artistContribsArtistProperty = null, + artTagsFromThingProperty = null, + referencedArtworksFromThingProperty = null, +}) { + const provide = { + thingProperty, + dimensionsFromThingProperty, + fileExtensionFromThingProperty, + dateFromThingProperty, + artistContribsFromThingProperty, + artistContribsArtistProperty, + artTagsFromThingProperty, + referencedArtworksFromThingProperty, + }; + + const parseSingleEntry = (entry, {subdoc, Artwork}) => + subdoc(Artwork, entry, {bindInto: 'thing', provide}); + + const transform = (value, ...args) => + (Array.isArray(value) + ? value.map(entry => parseSingleEntry(entry, ...args)) + : single + ? parseSingleEntry(value, ...args) + : [parseSingleEntry(value, ...args)]); + + transform.provide = provide; + + return transform; +} + +export function parseContentEntriesFromSourceText(thingClass, sourceText, {subdoc}) { + function map(matchEntry) { + let artistText = null, artistReferences = null; + + const artistTextNodes = + Array.from( + splitContentNodesAround( + parseContentNodes(matchEntry.artistText), + /\|/g)); + + const separatorIndices = + artistTextNodes + .filter(node => node.type === 'separator') + .map(node => artistTextNodes.indexOf(node)); + + if (empty(separatorIndices)) { + if (artistTextNodes.length === 1 && artistTextNodes[0].type === 'text') { + artistReferences = matchEntry.artistText; + } else { + artistText = matchEntry.artistText; + } + } else { + const firstSeparatorIndex = + separatorIndices.at(0); + + const secondSeparatorIndex = + separatorIndices.at(1) ?? + artistTextNodes.length; + + artistReferences = + matchEntry.artistText.slice( + artistTextNodes.at(0).i, + artistTextNodes.at(firstSeparatorIndex - 1).iEnd); + + artistText = + matchEntry.artistText.slice( + artistTextNodes.at(firstSeparatorIndex).iEnd, + artistTextNodes.at(secondSeparatorIndex - 1).iEnd); + } + + if (artistReferences) { + artistReferences = + artistReferences + .split(',') + .map(ref => ref.trim()); + } + + return { + 'Artists': + artistReferences, + + 'Artist Text': + artistText, + + 'Annotation': + matchEntry.annotation, + + 'Date': + matchEntry.date, + + 'Second Date': + matchEntry.secondDate, + + 'Date Kind': + matchEntry.dateKind, + + 'Access Date': + matchEntry.accessDate, + + 'Access Kind': + matchEntry.accessKind, + + 'Body': + matchEntry.body, + }; + } + + const documents = + matchContentEntries(sourceText) + .map(matchEntry => + withEntries( + map(matchEntry), + entries => entries + .filter(([key, value]) => + value !== undefined && + value !== null))); + + const subdocs = + documents.map(document => + subdoc(thingClass, document, {bindInto: 'thing'})); + + return subdocs; +} + +export function parseContentEntries(thingClass, value, {subdoc}) { + if (typeof value === 'string') { + return parseContentEntriesFromSourceText(thingClass, value, {subdoc}); + } else if (Array.isArray(value)) { + return value.map(doc => subdoc(thingClass, doc, {bindInto: 'thing'})); + } else { + return value; + } +} + +export function parseCommentary(value, {subdoc, CommentaryEntry}) { + return parseContentEntries(CommentaryEntry, value, {subdoc}); +} + +export function parseCreditingSources(value, {subdoc, CreditingSourcesEntry}) { + return parseContentEntries(CreditingSourcesEntry, value, {subdoc}); +} + +export function parseReferencingSources(value, {subdoc, ReferencingSourcesEntry}) { + return parseContentEntries(ReferencingSourcesEntry, value, {subdoc}); +} + +export function parseLyrics(value, {subdoc, LyricsEntry}) { + if ( + typeof value === 'string' && + !multipleLyricsDetectionRegex.test(value) + ) { + const document = {'Body': value}; + + return [subdoc(LyricsEntry, document, {bindInto: 'thing'})]; + } + + return parseContentEntries(LyricsEntry, value, {subdoc}); +} + // documentModes: Symbols indicating sets of behavior for loading and processing // data files. export const documentModes = { @@ -688,16 +1022,23 @@ export const documentModes = { export function getAllDataSteps() { try { thingConstructors; - } catch (error) { + } catch { throw new Error(`Thing constructors aren't ready yet, can't get all data steps`); } const steps = []; + const seenLoadingFns = new Set(); + for (const thingConstructor of Object.values(thingConstructors)) { const getSpecFn = thingConstructor[Thing.getYamlLoadingSpec]; if (!getSpecFn) continue; + // Subclasses can expose literally the same static properties + // by inheritence. We don't want to double-count those! + if (seenLoadingFns.has(getSpecFn)) continue; + seenLoadingFns.add(getSpecFn); + steps.push(getSpecFn({ documentModes, thingConstructors, @@ -890,7 +1231,7 @@ export function processThingsFromDataStep(documents, dataStep) { throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`); } - fn = makeProcessDocument(thingClass, spec); + fn = makeProcessDocument(thingClass, {...spec, processDocument}); submap.set(thingClass, fn); } @@ -905,15 +1246,23 @@ export function processThingsFromDataStep(documents, dataStep) { const aggregate = openAggregate({message: `Errors processing documents`}); documents.forEach( - decorateErrorWithIndex(document => { + decorateErrorWithIndex((document, index) => { const {thing, aggregate: subAggregate} = processDocument(document, dataStep.documentThing); + thing[Thing.yamlSourceDocument] = document; + thing[Thing.yamlSourceDocumentPlacement] = + [documentModes.allInOne, index]; + result.push(thing); aggregate.call(subAggregate.close); })); - return {aggregate, result}; + return { + aggregate, + result, + things: result, + }; } case documentModes.oneDocumentTotal: { @@ -923,7 +1272,15 @@ export function processThingsFromDataStep(documents, dataStep) { const {thing, aggregate} = processDocument(documents[0], dataStep.documentThing); - return {aggregate, result: thing}; + thing[Thing.yamlSourceDocument] = documents[0]; + thing[Thing.yamlSourceDocumentPlacement] = + [documentModes.oneDocumentTotal]; + + return { + aggregate, + result: thing, + things: [thing], + }; } case documentModes.headerAndEntries: { @@ -938,6 +1295,10 @@ export function processThingsFromDataStep(documents, dataStep) { const {thing: headerThing, aggregate: headerAggregate} = processDocument(headerDocument, dataStep.headerDocumentThing); + headerThing[Thing.yamlSourceDocument] = headerDocument; + headerThing[Thing.yamlSourceDocumentPlacement] = + [documentModes.headerAndEntries, 'header']; + try { headerAggregate.close(); } catch (caughtError) { @@ -951,6 +1312,10 @@ export function processThingsFromDataStep(documents, dataStep) { const {thing: entryThing, aggregate: entryAggregate} = processDocument(entryDocument, dataStep.entryDocumentThing); + entryThing[Thing.yamlSourceDocument] = entryDocument; + entryThing[Thing.yamlSourceDocumentPlacement] = + [documentModes.headerAndEntries, 'entry', index]; + entryThings.push(entryThing); try { @@ -967,6 +1332,7 @@ export function processThingsFromDataStep(documents, dataStep) { header: headerThing, entries: entryThings, }, + things: [headerThing, ...entryThings], }; } @@ -980,7 +1346,15 @@ export function processThingsFromDataStep(documents, dataStep) { const {thing, aggregate} = processDocument(documents[0], dataStep.documentThing); - return {aggregate, result: thing}; + thing[Thing.yamlSourceDocument] = documents[0]; + thing[Thing.yamlSourceDocumentPlacement] = + [documentModes.onePerFile]; + + return { + aggregate, + result: thing, + things: [thing], + }; } default: @@ -1080,9 +1454,16 @@ export async function processThingsFromDataSteps(documentLists, fileLists, dataS file: files, documents: documentLists, }).map(({file, documents}) => { - const {result, aggregate} = + const {result, aggregate, things} = processThingsFromDataStep(documents, dataStep); + for (const thing of things) { + thing[Thing.yamlSourceFilename] = + path.relative(dataPath, file) + .split(path.sep) + .join(path.posix.sep); + } + const close = decorateErrorWithFileFromDataPath(aggregate.close, {dataPath}); aggregate.close = () => close({file}); @@ -1225,93 +1606,107 @@ export async function loadAndProcessDataDocuments(dataSteps, {dataPath}) { // Data linking! Basically, provide (portions of) wikiData to the Things which // require it - they'll expose dynamically computed properties as a result (many // of which are required for page HTML generation and other expected behavior). -export function linkWikiDataArrays(wikiData) { +export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) { const linkWikiDataSpec = new Map([ - [wikiData.albumData, [ - 'albumData', - 'artTagData', - 'artistData', - 'groupData', - 'trackData', + // entries must be present here even without any properties to explicitly + // link if the 'find' or 'reverse' properties will be implicitly linked + + ['albumData', [ + 'artworkData', 'wikiInfo', ]], - [wikiData.artTagData, [ - 'albumData', - 'trackData', - ]], + ['artTagData', [/* reverse */]], - [wikiData.artistData, [ - 'albumData', - 'artistData', - 'flashData', - 'groupData', - 'trackData', - ]], + ['artistData', [/* find, reverse */]], - [wikiData.flashData, [ - 'artistData', - 'flashActData', - 'trackData', + ['artworkData', ['artworkData']], + + ['commentaryData', [/* find */]], + + ['creditingSourceData', [/* find */]], + + ['flashData', [ 'wikiInfo', ]], - [wikiData.flashActData, [ - 'flashData', - 'flashSideData', - ]], + ['flashActData', [/* find, reverse */]], - [wikiData.flashSideData, [ - 'flashActData', - ]], + ['flashSideData', [/* find */]], - [wikiData.groupData, [ - 'albumData', - 'artistData', - 'groupCategoryData', - ]], + ['groupData', [/* find, reverse */]], - [wikiData.groupCategoryData, [ - 'groupData', - ]], + ['groupCategoryData', [/* find */]], - [wikiData.homepageLayout?.rows, [ - 'albumData', - 'groupData', - ]], + ['homepageLayout.sections.rows', [/* find */]], + + ['lyricsData', [/* find */]], - [wikiData.trackData, [ - 'albumData', - 'artTagData', - 'artistData', - 'flashData', + ['referencingSourceData', [/* find */]], + + ['seriesData', [/* find */]], + + ['trackData', [ + 'artworkData', 'trackData', - 'trackSectionData', 'wikiInfo', ]], - [wikiData.trackSectionData, [ - 'albumData', - ]], + ['trackSectionData', [/* reverse */]], - [[wikiData.wikiInfo], [ - 'groupData', - ]], + ['wikiInfo', [/* find */]], ]); - for (const [things, keys] of linkWikiDataSpec.entries()) { - if (things === undefined) continue; + const constructorHasFindMap = new Map(); + const constructorHasReverseMap = new Map(); + + const boundFind = bindFind(wikiData); + const boundReverse = bindReverse(wikiData); + + for (const [thingDataProp, keys] of linkWikiDataSpec.entries()) { + const thingData = getNestedProp(wikiData, thingDataProp); + const things = + (Array.isArray(thingData) + ? thingData.flat(Infinity) + : [thingData]); + for (const thing of things) { if (thing === undefined) continue; + + let hasFind; + if (constructorHasFindMap.has(thing.constructor)) { + hasFind = constructorHasFindMap.get(thing.constructor); + } else { + hasFind = 'find' in thing; + constructorHasFindMap.set(thing.constructor, hasFind); + } + + if (hasFind) { + thing.find = boundFind; + } + + let hasReverse; + if (constructorHasReverseMap.has(thing.constructor)) { + hasReverse = constructorHasReverseMap.get(thing.constructor); + } else { + hasReverse = 'reverse' in thing; + constructorHasReverseMap.set(thing.constructor, hasReverse); + } + + if (hasReverse) { + thing.reverse = boundReverse; + } + for (const key of keys) { if (!(key in wikiData)) continue; + thing[key] = wikiData[key]; } } } } -export function sortWikiDataArrays(dataSteps, wikiData) { +export function sortWikiDataArrays(dataSteps, wikiData, {bindFind, bindReverse}) { for (const [key, value] of Object.entries(wikiData)) { if (!Array.isArray(value)) continue; wikiData[key] = value.slice(); @@ -1327,7 +1722,7 @@ export function sortWikiDataArrays(dataSteps, wikiData) { // slices instead of the original arrays) - this is so that the object // caching system understands that it's working with a new ordering. // We still need to actually provide those updated arrays over again! - linkWikiDataArrays(wikiData); + linkWikiDataArrays(wikiData, {bindFind, bindReverse}); } // Utility function for loading all wiki data from the provided YAML data @@ -1339,6 +1734,7 @@ export function sortWikiDataArrays(dataSteps, wikiData) { export async function quickLoadAllFromYAML(dataPath, { find, bindFind, + bindReverse, getAllFindSpecs, showAggregate: customShowAggregate = showAggregate, @@ -1363,7 +1759,7 @@ export async function quickLoadAllFromYAML(dataPath, { } } - linkWikiDataArrays(wikiData); + linkWikiDataArrays(wikiData, {bindFind, bindReverse}); try { reportDirectoryErrors(wikiData, {getAllFindSpecs}); @@ -1389,7 +1785,203 @@ export async function quickLoadAllFromYAML(dataPath, { logWarn`Content text errors found.`; } - sortWikiDataArrays(dataSteps, wikiData); + sortWikiDataArrays(dataSteps, wikiData, {bindFind, bindReverse}); return wikiData; } + +export function cruddilyGetAllThings(wikiData) { + const allThings = []; + + for (const v of Object.values(wikiData)) { + if (Array.isArray(v)) { + allThings.push(...v); + } else { + allThings.push(v); + } + } + + return allThings; +} + +export function getThingLayoutForFilename(filename, wikiData) { + const things = + cruddilyGetAllThings(wikiData) + .filter(thing => + thing[Thing.yamlSourceFilename] === filename); + + if (empty(things)) { + return null; + } + + const allDocumentModes = + unique(things.map(thing => + thing[Thing.yamlSourceDocumentPlacement][0])); + + if (allDocumentModes.length > 1) { + throw new Error(`More than one document mode for documents from ${filename}`); + } + + const documentMode = allDocumentModes[0]; + + switch (documentMode) { + case documentModes.allInOne: { + return { + documentMode, + things: + things.sort((a, b) => + a[Thing.yamlSourceDocumentPlacement][1] - + b[Thing.yamlSourceDocumentPlacement][1]), + }; + } + + case documentModes.oneDocumentTotal: + case documentModes.onePerFile: { + if (things.length > 1) { + throw new Error(`More than one document for ${filename}`); + } + + return { + documentMode, + thing: things[0], + }; + } + + case documentModes.headerAndEntries: { + const headerThings = + things.filter(thing => + thing[Thing.yamlSourceDocumentPlacement][1] === 'header'); + + if (headerThings.length > 1) { + throw new Error(`More than one header document for ${filename}`); + } + + return { + documentMode, + headerThing: headerThings[0] ?? null, + entryThings: + things + .filter(thing => + thing[Thing.yamlSourceDocumentPlacement][1] === 'entry') + .sort((a, b) => + a[Thing.yamlSourceDocumentPlacement][2] - + b[Thing.yamlSourceDocumentPlacement][2]), + }; + } + + default: { + return {documentMode}; + } + } +} + +export function flattenThingLayoutToDocumentOrder(layout) { + switch (layout.documentMode) { + case documentModes.oneDocumentTotal: + case documentModes.onePerFile: { + if (layout.thing) { + return [0]; + } else { + return []; + } + } + + case documentModes.allInOne: { + const indices = + layout.things + .map(thing => thing[Thing.yamlSourceDocumentPlacement][1]); + + return indices; + } + + case documentModes.headerAndEntries: { + const entryIndices = + layout.entryThings + .map(thing => thing[Thing.yamlSourceDocumentPlacement][2]) + .map(index => index + 1); + + if (layout.headerThing) { + return [0, ...entryIndices]; + } else { + return entryIndices; + } + } + + default: { + throw new Error(`Unknown document mode`); + } + } +} + +export function* splitDocumentsInYAMLSourceText(sourceText) { + // Not multiline! + const dividerRegex = /(?:\r\n|\n|^)-{3,}(?:\r\n|\n|$)/g; + + let previousDivider = ''; + + while (true) { + const {lastIndex} = dividerRegex; + const match = dividerRegex.exec(sourceText); + if (match) { + const nextDivider = match[0]; + + yield { + previousDivider, + nextDivider, + text: sourceText.slice(lastIndex, match.index), + }; + + previousDivider = nextDivider; + } else { + const nextDivider = ''; + const lineBreak = previousDivider.match(/\r?\n/)?.[0] ?? ''; + + yield { + previousDivider, + nextDivider, + text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, lineBreak), + }; + + return; + } + } +} + +export function recombineDocumentsIntoYAMLSourceText(documents) { + const dividers = + unique( + documents + .flatMap(d => [d.previousDivider, d.nextDivider]) + .filter(Boolean)); + + const divider = dividers[0]; + + if (dividers.length > 1) { + // TODO: Accommodate mixed dividers as best as we can lol + logWarn`Found multiple dividers in this file, using only ${divider}`; + } + + let sourceText = ''; + + for (const document of documents) { + if (sourceText) { + sourceText += divider; + } + + sourceText += document.text; + } + + return sourceText; +} + +export function reorderDocumentsInYAMLSourceText(sourceText, order) { + const sourceDocuments = + Array.from(splitDocumentsInYAMLSourceText(sourceText)); + + const sortedDocuments = + Array.from( + order, + sourceIndex => sourceDocuments[sourceIndex]); + + return recombineDocumentsIntoYAMLSourceText(sortedDocuments); +} |