diff options
Diffstat (limited to 'src/data')
172 files changed, 7203 insertions, 6188 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index a089e325..0071c60d 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -12,28 +12,15 @@ export default class CacheableObject { static propertyDependants = Symbol.for('CacheableObject.propertyDependants'); static cacheValid = Symbol.for('CacheableObject.cacheValid'); + static cachedValue = Symbol.for('CacheableObject.cachedValue'); static updateValue = Symbol.for('CacheableObject.updateValues'); 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)) { - const {flags, update} = propertyDescriptors[property]; - if (!flags.update) continue; - - if ( - typeof update === 'object' && - update !== null && - 'default' in update - ) { - this[property] = update?.default; - } else { - this[property] = null; - } - } + this[CacheableObject.updateValue] = + Object.create(this[CacheableObject.updateValue]); if (seal) { Object.seal(this); @@ -49,9 +36,31 @@ export default class CacheableObject { throw new Error(`Expected constructor ${this.name} to provide CacheableObject.propertyDescriptors`); } + const propertyDescriptors = this[CacheableObject.propertyDescriptors]; + + // Finalize prototype update value + + this.prototype[CacheableObject.updateValue] = + Object.create( + Object.getPrototypeOf(this.prototype)[CacheableObject.updateValue] ?? + null); + + for (const property of Reflect.ownKeys(propertyDescriptors)) { + const {flags, update} = propertyDescriptors[property]; + if (!flags.update) continue; + + if (typeof update === 'object' && update !== null && 'default' in update) { + validatePropertyValue(property, null, update.default, update); + this.prototype[CacheableObject.updateValue][property] = update.default; + } else { + this.prototype[CacheableObject.updateValue][property] = null; + } + } + + // Finalize prototype property descriptors + this[CacheableObject.propertyDependants] = Object.create(null); - const propertyDescriptors = this[CacheableObject.propertyDescriptors]; for (const property of Reflect.ownKeys(propertyDescriptors)) { const {flags, update, expose} = propertyDescriptors[property]; @@ -73,17 +82,7 @@ export default class CacheableObject { } 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}); - } + validatePropertyValue(property, oldValue, newValue, update); } this[CacheableObject.updateValue][property] = newValue; @@ -121,18 +120,14 @@ export default class CacheableObject { const dependencies = Object.create(null); for (const key of expose.dependencies ?? []) { - switch (key) { - case 'this': - dependencies.this = this; - break; - - case 'thisProperty': - dependencies.thisProperty = property; - break; - - default: - dependencies[key] = this[CacheableObject.updateValue][key]; - break; + if (key === 'this') { + dependencies.this = this; + } else if (key === 'thisProperty') { + dependencies.thisProperty = property; + } else if (key.startsWith('_')) { + dependencies[key] = this[CacheableObject.updateValue][key.slice(1)]; + } else { + dependencies[key] = this[key]; } } @@ -151,27 +146,11 @@ export default class CacheableObject { if (flags.expose) recordAsDependant: { const dependantsMap = this[CacheableObject.propertyDependants]; - if (flags.update && expose?.transform) { - if (dependantsMap[property]) { - dependantsMap[property].push(property); + for (const dependency of dependenciesOf(property, propertyDescriptors)) { + if (dependantsMap[dependency]) { + dependantsMap[dependency].push(property); } else { - dependantsMap[property] = [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]; - } - } + dependantsMap[dependency] = [property]; } } } @@ -187,7 +166,7 @@ export default class CacheableObject { } static hasPropertyDescriptor(property) { - return Object.hasOwn(this[CacheableObject.propertyDescriptors], property); + return property in this[CacheableObject.propertyDescriptors]; } static cacheAllExposedProperties(obj) { @@ -243,13 +222,13 @@ export class CacheableObjectPropertyValueError extends Error { try { inspectOldValue = inspect(oldValue); - } catch (error) { + } catch { inspectOldValue = colors.red(`(couldn't inspect)`); } try { inspectNewValue = inspect(newValue); - } catch (error) { + } catch { inspectNewValue = colors.red(`(couldn't inspect)`); } @@ -260,3 +239,60 @@ export class CacheableObjectPropertyValueError extends Error { this.property = property; } } + +// good ol' module-scope utility functions + +function validatePropertyValue(property, oldValue, newValue, update) { + 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}); + } +} + +function* dependenciesOf(property, propertyDescriptors, cycle = []) { + const descriptor = propertyDescriptors[property]; + + if (descriptor?.flags?.update) { + yield property; + } + + const dependencies = descriptor?.expose?.dependencies; + if (!dependencies) return; + + for (const dependency of dependencies) { + if (dependency === 'this') continue; + if (dependency === 'thisProperty') continue; + + if (dependency.startsWith('_')) { + yield dependency.slice(1); + continue; + } + + if (dependency === property) { + throw new Error( + `property ${dependency} directly depends on its own computed value`); + } + + if (cycle.includes(dependency)) { + const subcycle = cycle.slice(cycle.indexOf(dependency)); + const supercycle = cycle.slice(0, cycle.indexOf(dependency)); + throw new Error( + `property ${dependency} indirectly depends on its own computed value\n` + + ` via: ` + subcycle.map(p => p + ' -> ').join('') + property + ' -> ' + dependency + + (supercycle.length + ? '\n in: ' + supercycle.join(' -> ') + : '')); + } + + cycle.push(property); + yield* dependenciesOf(dependency, propertyDescriptors, cycle); + cycle.pop(); + } +} diff --git a/src/data/checks.js b/src/data/checks.js index 52024144..0a0e7f52 100644 --- a/src/data/checks.js +++ b/src/data/checks.js @@ -4,13 +4,14 @@ 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 { + annotateError, annotateErrorWithIndex, conditionallySuppressError, decorateErrorWithIndex, @@ -59,7 +60,7 @@ export function reportDirectoryErrors(wikiData, { : [thing.directory]); for (const directory of directories) { - if (directory === null || directory === undefined) { + if (directory === '' || directory === null || directory === undefined) { missingDirectoryThings.add(thing); continue; } @@ -165,6 +166,79 @@ function getFieldPropertyMessage(yamlDocumentSpec, property) { return fieldPropertyMessage; } +function decoAnnotateFindErrors(findFn) { + function annotateMultipleNameMatchesIncludingUnfortunatelyUnsecondary(error) { + const matches = error[Symbol.for('hsmusic.find.multipleNameMatches')]; + if (!matches) return; + + const notSoSecondary = + matches + .map(match => match.thing ?? match) + .filter(match => + match.isTrack && + match.isMainRelease && + CacheableObject.getUpdateValue(match, 'mainRelease')); + + if (empty(notSoSecondary)) return; + + let {message} = error; + message += (message.includes('\n') ? '\n\n' : '\n'); + message += colors.bright(colors.yellow('<!>')) + ' '; + message += colors.yellow(`Some of these tracks are meant to be secondary releases,`) + '\n'; + message += ' '.repeat(4); + message += colors.yellow(`but another error is keeping that from processing correctly!`) + '\n'; + message += ' '.repeat(4); + message += colors.yellow(`Probably look for an error to do with "Main Release", first.`); + Object.assign(error, {message}); + } + + return (...args) => { + try { + return findFn(...args); + } catch (caughtError) { + throw annotateError(caughtError, ...[ + annotateMultipleNameMatchesIncludingUnfortunatelyUnsecondary, + ]); + } + }; +} + +function decoSuppressFindErrors(findFn, {property}) { + void property; + + return 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 + // corresponding tracks yet, so it won't be useful to report such reference + // errors until we take the time to address that. But other errors, like + // malformed reference strings or miscapitalized existing tracks, should + // still be reported, as samples of existing tracks *do* display on the + // website! + if (error.message.includes(`Didn't match anything`)) { + return true; + } + } + */ + + return false; + }, findFn); +} + +function decoFindThing(findFn, thing) { + return (ref, opts) => { + if (opts) { + return findFn(ref, {...opts, from: thing}); + } else { + return findFn(ref, {from: thing}); + } + }; +} + // Warn about references across data which don't match anything. This involves // using the find() functions on all references, setting it to 'error' mode, and // collecting everything in a structured logged (which gets logged if there are @@ -185,16 +259,20 @@ export function filterReferenceErrors(wikiData, { artTags: '_artTag', referencedArtworks: '_artwork', commentary: '_content', - creditSources: '_content', + creditingSources: '_content', }], ['artTagData', { directDescendantArtTags: 'artTag', }], + ['artworkData', { + referencedArtworks: '_artwork', + }], + ['flashData', { commentary: '_content', - creditSources: '_content', + creditingSources: '_content', }], ['groupCategoryData', { @@ -217,25 +295,27 @@ export function filterReferenceErrors(wikiData, { featuredTracks: 'track', }], - ['flashActData', { - flashes: 'flash', + ['musicVideoData', { + artistContribs: '_contrib', + contributorContribs: '_contrib', }], - ['groupData', { - serieses: '_serieses', + ['seriesData', { + albums: 'album', }], ['trackData', { artistContribs: '_contrib', contributorContribs: '_contrib', coverArtistContribs: '_contrib', - referencedTracks: '_trackMainReleasesOnly', - sampledTracks: '_trackMainReleasesOnly', + referencedTracks: '_trackReference', + sampledTracks: '_trackReference', artTags: '_artTag', referencedArtworks: '_artwork', - mainReleaseTrack: '_trackMainReleasesOnly', + mainRelease: '_mainRelease', commentary: '_content', - creditSources: '_content', + creditingSources: '_content', + referencingSources: '_content', lyrics: '_content', }], @@ -290,15 +370,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) { @@ -350,19 +421,112 @@ export function filterReferenceErrors(wikiData, { }; break; - case '_serieses': - findFn = boundFind.album; + case '_mainRelease': + findFn = ref => { + // Mocking what's going on in `withMainRelease`. + + if (ref === 'same name single') { + // Accessing the current thing here. + try { + return boundFind.albumSinglesOnly(thing.name, { + fuzz: { + capitalization: true, + kebab: true, + }, + }); + } catch (caughtError) { + throw new Error( + `Didn't match a single with the same name`, + {cause: caughtError}); + } + } + + let track, trackError; + let album, albumError; + + try { + track = boundFind.trackMainReleasesOnly(ref); + } catch (caughtError) { + trackError = new Error( + `Didn't match a track`, {cause: caughtError}); + } + + try { + album = boundFind.album(ref); + } catch (caughtError) { + albumError = new Error( + `Didn't match an album`, {cause: caughtError}); + } + + if (track && album) { + if (album.tracks.includes(track)) { + return track; + } else { + throw new Error( + `Unrelated album and track matched for reference "${ref}". Please resolve:\n` + + `- ${inspect(track)}\n` + + `- ${inspect(album)}\n` + + `Returning null for this reference.`); + } + } + + if (track) { + return track; + } + + if (album) { + // At this point verification depends on the thing itself, + // which is currently in lexical scope, but if this code + // gets refactored, there might be trouble here... + + if (thing.mainReleaseTrack === null) { + if (album === thing.album) { + throw new Error( + `Matched album for reference "${ref}":\n` + + `- ` + inspect(album) + `\n` + + `...but this is the album that includes this secondary release, itself.\n` + + `Please resolve by pointing to aonther album here, or by removing this\n` + + `Main Release field, if this track is meant to be the main release.`); + } else { + throw new Error( + `Matched album for reference "${ref}":\n` + + `- ` + inspect(album) + `\n` + + `...but none of its tracks automatically match this secondary release.\n` + + `Please resolve by specifying the track here, instead of the album.`); + } + } else { + return album; + } + } + + const aggregateCause = + new AggregateError([albumError, trackError]); + + aggregateCause[Symbol.for('hsmusic.aggregate.translucent')] = true; + + throw new Error(`Trouble matching "${ref}"`, { + cause: aggregateCause, + }); + } + break; case '_trackArtwork': findFn = ref => boundFind.track(ref.reference); break; - case '_trackMainReleasesOnly': + case '_trackReference': findFn = trackRef => { - const track = boundFind.track(trackRef); - const mainRef = track && CacheableObject.getUpdateValue(track, 'mainReleaseTrack'); + let track = boundFind.trackReference(trackRef, {mode: 'quiet', from: thing}); + if (track) { + return track; + } + // Will error normally, if this can't unambiguously resolve + // or doesn't match any track. + track = boundFind.track(trackRef); + + const mainRef = CacheableObject.getUpdateValue(track, 'mainRelease'); 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. @@ -393,27 +557,9 @@ export function filterReferenceErrors(wikiData, { break; } - 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 - // corresponding tracks yet, so it won't be useful to report such reference - // errors until we take the time to address that. But other errors, like - // malformed reference strings or miscapitalized existing tracks, should - // still be reported, as samples of existing tracks *do* display on the - // website! - if (error.message.includes(`Didn't match anything`)) { - return true; - } - } - */ - - return false; - }, fn); + findFn = decoSuppressFindErrors(findFn, {property}); + findFn = decoAnnotateFindErrors(findFn); + findFn = decoFindThing(findFn, thing); const fieldPropertyMessage = getFieldPropertyMessage( @@ -469,10 +615,10 @@ export function filterReferenceErrors(wikiData, { value, {message: errorMessage}, decorateErrorWithIndex(refs => (refs.length === 1 - ? suppress(findFn)(refs[0]) + ? findFn(refs[0]) : filterAggregate( refs, {message: `Errors in entry's artist references`}, - decorateErrorWithIndex(suppress(findFn))) + decorateErrorWithIndex(findFn)) .aggregate .close()))); @@ -484,19 +630,18 @@ export function filterReferenceErrors(wikiData, { if (Array.isArray(value)) { newPropertyValue = filter( value, {message: errorMessage}, - decorateErrorWithIndex(suppress(findFn))); + decorateErrorWithIndex(findFn)); break determineNewPropertyValue; } - nest({message: errorMessage}, - suppress(({call}) => { - try { - call(findFn, value); - } catch (error) { - newPropertyValue = null; - throw error; - } - })); + nest({message: errorMessage}, ({call}) => { + try { + call(findFn, value); + } catch (error) { + newPropertyValue = null; + throw error; + } + }); } if (writeProperty) { @@ -520,7 +665,11 @@ export class ContentNodeError extends Error { message, }) { const headingLine = - `(${where}) ${message}`; + (message.includes('\n\n') + ? `(${where})\n\n` + message + '\n' + : message.includes('\n') + ? `(${where})\n` + message + : `(${where}) ${message}`); const textUpToNode = containingLine.slice(0, columnNumber); @@ -565,15 +714,20 @@ 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', - artistDisplayText: 'lyrics artist display text', + artistText: 'lyrics artist text', annotation: 'lyrics annotation', }; @@ -581,6 +735,8 @@ export function reportContentTextErrors(wikiData, { ['albumData', { additionalFiles: additionalFileShape, commentary: commentaryShape, + creditingSources: commentaryShape, + coverArtworks: artworkShape, }], ['artTagData', { @@ -593,6 +749,8 @@ export function reportContentTextErrors(wikiData, { ['flashData', { commentary: commentaryShape, + creditingSources: commentaryShape, + coverArtwork: artworkShape, }], ['flashActData', { @@ -622,10 +780,12 @@ export function reportContentTextErrors(wikiData, { ['trackData', { additionalFiles: additionalFileShape, commentary: commentaryShape, - creditSources: commentaryShape, + creditingSources: commentaryShape, + referencingSources: commentaryShape, lyrics: lyricsShape, midiProjectFiles: additionalFileShape, sheetMusicFiles: additionalFileShape, + trackArtworks: artworkShape, }], ['wikiInfo', { @@ -634,11 +794,19 @@ export function reportContentTextErrors(wikiData, { }], ]; - const boundFind = bindFind(wikiData, {mode: 'error'}); + const boundFind = + bindFind(wikiData, { + mode: 'error', + fuzz: { + capitalization: true, + kebab: true, + }, + }); + const findArtistOrAlias = bindFindArtistOrAlias(boundFind); function* processContent(input) { - const nodes = parseInput(input); + const nodes = parseContentNodes(input); for (const node of nodes) { const index = node.i; @@ -675,6 +843,9 @@ export function reportContentTextErrors(wikiData, { break; } + findFn = decoSuppressFindErrors(findFn, {property: null}); + findFn = decoAnnotateFindErrors(findFn); + const findRef = (replacerKeyImplied ? replacerValue @@ -695,7 +866,7 @@ export function reportContentTextErrors(wikiData, { } else if (node.type === 'external-link') { try { new URL(node.data.href); - } catch (error) { + } catch { yield { index, length, message: @@ -766,6 +937,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, @@ -773,26 +969,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}); } } }); diff --git a/src/data/composite.js b/src/data/composite.js index f31c4069..8ac906c7 100644 --- a/src/data/composite.js +++ b/src/data/composite.js @@ -17,7 +17,7 @@ const _valueIntoToken = shape => : typeof value === 'string' ? Symbol.for(`hsmusic.composite.${shape}:${value}`) : { - symbol: Symbol.for(`hsmusic.composite.input`), + symbol: Symbol.for(`hsmusic.composite.${shape.split('.')[0]}`), shape, value, }); @@ -36,6 +36,10 @@ input.updateValue = _valueIntoToken('input.updateValue'); input.staticDependency = _valueIntoToken('input.staticDependency'); input.staticValue = _valueIntoToken('input.staticValue'); +// Only valid in positional inputs. This is replaced with +// equivalent input.value() token in prepared inputs. +export const V = _valueIntoToken('V'); + function isInputToken(token) { if (token === null) { return false; @@ -48,27 +52,39 @@ function isInputToken(token) { } } +function isConciseInputToken(token) { + if (token === null) { + return false; + } else if (typeof token === 'object') { + return token.symbol === Symbol.for('hsmusic.composite.V'); + } else if (typeof token === 'symbol') { + return token.description.startsWith('hsmusic.composite.V'); + } else { + return false; + } +} + function getInputTokenShape(token) { - if (!isInputToken(token)) { + if (!isInputToken(token) && !isConciseInputToken(token)) { throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); } if (typeof token === 'object') { return token.shape; } else { - return token.description.match(/hsmusic\.composite\.(input.*?)(:|$)/)[1]; + return token.description.match(/hsmusic\.composite\.(input.*?|V)(:|$)/)[1]; } } function getInputTokenValue(token) { - if (!isInputToken(token)) { + if (!isInputToken(token) && !isConciseInputToken(token)) { throw new TypeError(`Expected an input token, got ${typeAppearance(token)}`); } if (typeof token === 'object') { return token.value; } else { - return token.description.match(/hsmusic\.composite\.input.*?:(.*)/)?.[1] ?? null; + return token.description.match(/hsmusic\.composite\.(?:input.*?|V):(.*)/)?.[1] ?? null; } } @@ -214,76 +230,161 @@ export function templateCompositeFrom(description) { ? Object.keys(description.inputs) : []); - const instantiate = (inputOptions = {}) => { + const optionalInputNames = + expectedInputNames.filter(name => { + const inputDescription = getInputTokenValue(description.inputs[name]); + if (!inputDescription) return false; + if ('defaultValue' in inputDescription) return true; + if ('defaultDependency' in inputDescription) return true; + return false; + }); + + const instantiate = (...args) => { + const preparedInputs = {}; + withAggregate({message: `Errors in input options passed to ${compositionName}`}, ({push}) => { - const providedInputNames = Object.keys(inputOptions); + const [positionalInputs, namedInputs] = + (typeof args.at(-1) === 'object' && + !isInputToken(args.at(-1)) && + !isConciseInputToken(args.at(-1)) + ? [args.slice(0, -1), args.at(-1)] + : [args, {}]); - const misplacedInputNames = - providedInputNames - .filter(name => !expectedInputNames.includes(name)); + const expresslyProvidedInputNames = Object.keys(namedInputs); + const positionallyProvidedInputNames = []; + const remainingInputNames = expectedInputNames.slice(); - const missingInputNames = - expectedInputNames - .filter(name => !providedInputNames.includes(name)) - .filter(name => { - const inputDescription = getInputTokenValue(description.inputs[name]); - if (!inputDescription) return true; - if ('defaultValue' in inputDescription) return false; - if ('defaultDependency' in inputDescription) return false; - return true; - }); + const apparentInputRoutes = {}; - const wrongTypeInputNames = []; + const wrongTypeInputPositions = []; + const namedAndPositionalConflictInputPositions = []; - const expectedStaticValueInputNames = []; - const expectedStaticDependencyInputNames = []; - const expectedValueProvidingTokenInputNames = []; + const maximumPositionalInputs = expectedInputNames.length; + const lastPossiblePositionalIndex = maximumPositionalInputs - 1; - const validateFailedErrors = []; + for (const [index, value] of positionalInputs.entries()) { + if (!isInputToken(value) && !isConciseInputToken(value)) { + if (typeof value === 'object' && value !== null) { + wrongTypeInputPositions.push(index); + continue; + } else if (typeof value !== 'string') { + wrongTypeInputPositions.push(index); + continue; + } + } + + if (index > lastPossiblePositionalIndex) { + continue; + } + + const correspondingName = remainingInputNames.shift(); + if (expresslyProvidedInputNames.includes(correspondingName)) { + namedAndPositionalConflictInputPositions.push(index); + continue; + } + + preparedInputs[correspondingName] = + (isConciseInputToken(value) + ? input.value(getInputTokenValue(value)) + : value); + + apparentInputRoutes[correspondingName] = `${correspondingName} (i = ${index})`; + positionallyProvidedInputNames.push(correspondingName); + } - for (const [name, value] of Object.entries(inputOptions)) { + const misplacedInputNames = + expresslyProvidedInputNames + .filter(name => !expectedInputNames.includes(name)); + + const wrongTypeInputNames = []; + const skippedInputNames = []; + const passedInputNames = []; + const nameProvidedInputNames = []; + + for (const [name, value] of Object.entries(namedInputs)) { if (misplacedInputNames.includes(name)) { continue; } + // Concise input tokens, V(...), end up here too. if (typeof value !== 'string' && !isInputToken(value)) { wrongTypeInputNames.push(name); continue; } + const index = remainingInputNames.indexOf(name); + if (index === 0) { + passedInputNames.push(remainingInputNames.shift()); + } else if (index === -1) { + // This input isn't misplaced, so it's an expected name, + // and SHOULD be in the list of remaining input names. + // But it isn't if it itself has already been skipped! + // And if so, that's already been tracked. + } else { + const til = remainingInputNames.splice(0, index); + passedInputNames.push(...til); + + const skipped = + til.filter(name => + !optionalInputNames.includes(name) || + expresslyProvidedInputNames.includes(name)); + + if (!empty(skipped)) { + skippedInputNames.push({skipped, before: name}); + } + + passedInputNames.push(remainingInputNames.shift()); + } + + preparedInputs[name] = value; + apparentInputRoutes[name] = name; + nameProvidedInputNames.push(name); + } + + const totalProvidedInputNames = + unique([ + ...expresslyProvidedInputNames, + ...positionallyProvidedInputNames, + ]); + + const missingInputNames = + expectedInputNames + .filter(name => !totalProvidedInputNames.includes(name)) + .filter(name => !optionalInputNames.includes(name)); + + const expectedStaticValueInputNames = []; + const expectedStaticDependencyInputNames = []; + const expectedValueProvidingTokenInputNames = []; + const validateFailedErrors = []; + + for (const [name, value] of Object.entries(preparedInputs)) { const descriptionShape = getInputTokenShape(description.inputs[name]); const tokenShape = (isInputToken(value) ? getInputTokenShape(value) : null); const tokenValue = (isInputToken(value) ? getInputTokenValue(value) : null); - switch (descriptionShape) { - case'input.staticValue': - if (tokenShape !== 'input.value') { - expectedStaticValueInputNames.push(name); - continue; - } - break; - - case 'input.staticDependency': - if (typeof value !== 'string' && tokenShape !== 'input.dependency') { - expectedStaticDependencyInputNames.push(name); - continue; - } - break; - - case 'input': - if (typeof value !== 'string' && ![ - 'input', - 'input.value', - 'input.dependency', - 'input.myself', - 'input.thisProperty', - 'input.updateValue', - ].includes(tokenShape)) { - expectedValueProvidingTokenInputNames.push(name); - continue; - } - break; + if (descriptionShape === 'input.staticValue') { + if (tokenShape !== 'input.value') { + expectedStaticValueInputNames.push(name); + continue; + } + } else if (descriptionShape === 'input.staticDependency') { + if (typeof value !== 'string' && tokenShape !== 'input.dependency') { + expectedStaticDependencyInputNames.push(name); + continue; + } + } else { + if (typeof value !== 'string' && ![ + 'input', + 'input.value', + 'input.dependency', + 'input.myself', + 'input.thisProperty', + 'input.updateValue', + ].includes(tokenShape)) { + expectedValueProvidingTokenInputNames.push(name); + continue; + } } if (tokenShape === 'input.value') { @@ -296,6 +397,11 @@ export function templateCompositeFrom(description) { } } + const inputAppearance = name => + (isInputToken(preparedInputs[name]) + ? `${getInputTokenShape(preparedInputs[name])}() call` + : `dependency name`); + if (!empty(misplacedInputNames)) { push(new Error(`Unexpected input names: ${misplacedInputNames.join(', ')}`)); } @@ -304,29 +410,53 @@ export function templateCompositeFrom(description) { push(new Error(`Required these inputs: ${missingInputNames.join(', ')}`)); } - const inputAppearance = name => - (isInputToken(inputOptions[name]) - ? `${getInputTokenShape(inputOptions[name])}() call` - : `dependency name`); + if (positionalInputs.length > maximumPositionalInputs) { + push(new Error(`Too many positional inputs provided (${positionalInputs.length} > ${maximumPositionalInputs}`)); + } + + for (const index of namedAndPositionalConflictInputPositions) { + const conflictingName = positionalInputNames[index]; + push(new Error(`${name}: Provided as both named and positional (i = ${index}) input`)); + } + + for (const {skipped, before} of skippedInputNames) { + push(new Error(`Expected ${skipped.join(', ')} before ${before}`)); + } for (const name of expectedStaticDependencyInputNames) { - const appearance = inputAppearance(name); - push(new Error(`${name}: Expected dependency name, got ${appearance}`)); + const appearance = inputAppearance(preparedInputs[name]); + const route = apparentInputRoutes[name]; + push(new Error(`${route}: Expected dependency name, got ${appearance}`)); } for (const name of expectedStaticValueInputNames) { - const appearance = inputAppearance(name) - push(new Error(`${name}: Expected input.value() call, got ${appearance}`)); + const appearance = inputAppearance(preparedInputs[name]); + const route = apparentInputRoutes[name]; + push(new Error(`${route}: Expected input.value() call, got ${appearance}`)); } for (const name of expectedValueProvidingTokenInputNames) { - const appearance = getInputTokenShape(inputOptions[name]); - push(new Error(`${name}: Expected dependency name or value-providing input() call, got ${appearance}`)); + const appearance = getInputTokenShape(preparedInputs[name]); + const route = apparentInputRoutes[name]; + push(new Error(`${route}: Expected dependency name or value-providing input() call, got ${appearance}`)); } for (const name of wrongTypeInputNames) { - const type = typeAppearance(inputOptions[name]); - push(new Error(`${name}: Expected dependency name or input() call, got ${type}`)); + if (isConciseInputToken(namedInputs[name])) { + push(new Error(`${name}: Use input.value() instead of V() for named inputs`)); + } else { + const type = typeAppearance(namedInputs[name]); + push(new Error(`${name}: Expected dependency name or input() call, got ${type}`)); + } + } + + for (const index of wrongTypeInputPositions) { + const type = typeAppearance(positionalInputs[index]); + if (type === 'object') { + push(new Error(`i = ${index}: Got object - all named dependencies must be passed together, in last argument`)); + } else { + push(new Error(`i = ${index}: Expected dependency name or input() call, got ${type}`)); + } } for (const error of validateFailedErrors) { @@ -338,13 +468,13 @@ export function templateCompositeFrom(description) { if ('inputs' in description) { for (const [name, token] of Object.entries(description.inputs)) { const tokenValue = getInputTokenValue(token); - if (name in inputOptions) { - if (typeof inputOptions[name] === 'string') { - inputMapping[name] = input.dependency(inputOptions[name]); + if (name in preparedInputs) { + if (typeof preparedInputs[name] === 'string') { + inputMapping[name] = input.dependency(preparedInputs[name]); } else { // This is always an input token, since only a string or // an input token is a valid input option (asserted above). - inputMapping[name] = inputOptions[name]; + inputMapping[name] = preparedInputs[name]; } } else if (tokenValue.defaultValue) { inputMapping[name] = input.value(tokenValue.defaultValue); @@ -713,8 +843,9 @@ export function compositeFrom(description) { stepExposeDescriptions .flatMap(expose => expose?.dependencies ?? []) .map(dependency => { - if (typeof dependency === 'string') + if (typeof dependency === 'string') { return (dependency.startsWith('#') ? null : dependency); + } const tokenShape = getInputTokenShape(dependency); const tokenValue = getInputTokenValue(dependency); @@ -886,7 +1017,7 @@ export function compositeFrom(description) { } }); - withAggregate({message: `Errors in input values provided to ${compositionName}`}, ({push}) => { + withAggregate({message: `Errors validating input values provided to ${compositionName}`}, ({push}) => { for (const {dynamic, name, value, description} of stitchArrays({ dynamic: inputsMayBeDynamicValue, name: inputNames, @@ -896,9 +1027,10 @@ export function compositeFrom(description) { if (!dynamic) continue; try { validateInputValue(value, description); - } catch (error) { - error.message = `${name}: ${error.message}`; - push(error); + } catch (caughtError) { + push(new Error( + `Error validating input ${name}: ` + inspect(value, {compact: true}), + {cause: caughtError})); } } }); @@ -1416,7 +1548,7 @@ export function compositeFrom(description) { export function displayCompositeCacheAnalysis() { const showTimes = (cache, key) => { - const times = cache.times[key].slice().sort(); + const times = cache.times[key].toSorted(); const all = times; const worst10pc = times.slice(-times.length / 10); diff --git a/src/data/composite/control-flow/exitWithoutDependency.js b/src/data/composite/control-flow/exitWithoutDependency.js index c660a7ef..598f2ec2 100644 --- a/src/data/composite/control-flow/exitWithoutDependency.js +++ b/src/data/composite/control-flow/exitWithoutDependency.js @@ -11,8 +11,8 @@ export default templateCompositeFrom({ inputs: { dependency: input({acceptsNull: true}), - mode: inputAvailabilityCheckMode(), value: input({defaultValue: null}), + mode: inputAvailabilityCheckMode(), }, steps: () => [ diff --git a/src/data/composite/control-flow/exitWithoutUpdateValue.js b/src/data/composite/control-flow/exitWithoutUpdateValue.js index 244b3233..5104a8c0 100644 --- a/src/data/composite/control-flow/exitWithoutUpdateValue.js +++ b/src/data/composite/control-flow/exitWithoutUpdateValue.js @@ -10,15 +10,27 @@ export default templateCompositeFrom({ annotation: `exitWithoutUpdateValue`, inputs: { - mode: inputAvailabilityCheckMode(), value: input({defaultValue: null}), + mode: inputAvailabilityCheckMode(), + + validate: input({ + type: 'function', + defaultValue: null, + }), }, + update: ({ + [input.staticValue('validate')]: validate, + }) => + (validate + ? {validate} + : {}), + steps: () => [ exitWithoutDependency({ dependency: input.updateValue(), - mode: input('mode'), value: input('value'), + mode: input('mode'), }), ], }); diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js index 778dc66b..61bfa08e 100644 --- a/src/data/composite/control-flow/index.js +++ b/src/data/composite/control-flow/index.js @@ -11,6 +11,7 @@ export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinu export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js'; export {default as exposeWhetherDependencyAvailable} from './exposeWhetherDependencyAvailable.js'; export {default as flipFilter} from './flipFilter.js'; +export {default as inputAvailabilityCheckMode} from './inputAvailabilityCheckMode.js'; // A helper, technically... 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/data/helpers/property-from-helpers.js b/src/data/composite/data/helpers/property-from-helpers.js new file mode 100644 index 00000000..00251f3b --- /dev/null +++ b/src/data/composite/data/helpers/property-from-helpers.js @@ -0,0 +1,14 @@ +export function getOutputName({property, from, prefix = null}) { + if (property && prefix) { + return `${prefix}.${property}`; + } else if (property && from) { + if (from.startsWith('_')) { + return `${from.slice(1)}.${property}`; + } else { + return `${from}.${property}`; + } + } else { + if (!property) throw new Error(`guard property outside getOutputName(), c'mon`); + if (!from) throw new Error(`guard from in getOutputName(), c'mon`); + } +} \ No newline at end of file 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/withLengthOfList.js b/src/data/composite/data/withLengthOfList.js new file mode 100644 index 00000000..7e8fd17f --- /dev/null +++ b/src/data/composite/data/withLengthOfList.js @@ -0,0 +1,56 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {getOutputName} from './helpers/property-from-helpers.js'; + +export default templateCompositeFrom({ + annotation: `withMappedList`, + + inputs: { + list: input({type: 'array'}), + }, + + outputs: ({ + [input.staticDependency('list')]: list, + }) => [ + (list + ? getOutputName({property: 'length', from: list}) + : '#length'), + ], + + steps: () => [ + { + dependencies: [input.staticDependency('list')], + compute: (continuation, { + [input.staticDependency('list')]: list, + }) => continuation({ + '#output': + (list + ? getOutputName({property: 'length', from: list}) + : '#length'), + }), + }, + + { + 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/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js index fb4134bc..791165b3 100644 --- a/src/data/composite/data/withPropertiesFromList.js +++ b/src/data/composite/data/withPropertiesFromList.js @@ -12,6 +12,8 @@ import {input, templateCompositeFrom} from '#composite'; import {isString, validateArrayItems} from '#validators'; +import {getOutputName} from './helpers/property-from-helpers.js'; + export default templateCompositeFrom({ annotation: `withPropertiesFromList`, @@ -32,11 +34,7 @@ export default templateCompositeFrom({ }) => (properties ? properties.map(property => - (prefix - ? `${prefix}.${property}` - : list - ? `${list}.${property}` - : `#list.${property}`)) + getOutputName({property, from: list || '#list', prefix})) : ['#lists']), steps: () => [ @@ -73,11 +71,7 @@ export default templateCompositeFrom({ ? continuation( Object.fromEntries( properties.map(property => [ - (prefix - ? `${prefix}.${property}` - : list - ? `${list}.${property}` - : `#list.${property}`), + getOutputName({property, from: list || '#list', prefix}), lists[property], ]))) : continuation({'#lists': lists})), diff --git a/src/data/composite/data/withPropertiesFromObject.js b/src/data/composite/data/withPropertiesFromObject.js index 21726b58..f600df0d 100644 --- a/src/data/composite/data/withPropertiesFromObject.js +++ b/src/data/composite/data/withPropertiesFromObject.js @@ -11,6 +11,8 @@ import {input, templateCompositeFrom} from '#composite'; import {isString, validateArrayItems} from '#validators'; +import {getOutputName} from './helpers/property-from-helpers.js'; + export default templateCompositeFrom({ annotation: `withPropertiesFromObject`, @@ -32,11 +34,7 @@ export default templateCompositeFrom({ }) => (properties ? properties.map(property => - (prefix - ? `${prefix}.${property}` - : object - ? `${object}.${property}` - : `#object.${property}`)) + getOutputName({property, from: object || '#object', prefix})) : ['#object']), steps: () => [ @@ -71,11 +69,7 @@ export default templateCompositeFrom({ ? continuation( Object.fromEntries( entries.map(([property, value]) => [ - (prefix - ? `${prefix}.${property}` - : object - ? `${object}.${property}` - : `#object.${property}`), + getOutputName({property, from: object || '#object', prefix}), value ?? null, ]))) : continuation({ diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js index 760095c2..485dd197 100644 --- a/src/data/composite/data/withPropertyFromList.js +++ b/src/data/composite/data/withPropertyFromList.js @@ -16,12 +16,7 @@ import CacheableObject from '#cacheable-object'; import {input, templateCompositeFrom} from '#composite'; -function getOutputName({list, property, prefix}) { - if (!property) return `#values`; - if (prefix) return `${prefix}.${property}`; - if (list) return `${list}.${property}`; - return `#list.${property}`; -} +import {getOutputName} from './helpers/property-from-helpers.js'; export default templateCompositeFrom({ annotation: `withPropertyFromList`, @@ -37,8 +32,11 @@ export default templateCompositeFrom({ [input.staticDependency('list')]: list, [input.staticValue('property')]: property, [input.staticValue('prefix')]: prefix, - }) => - [getOutputName({list, property, prefix})], + }) => [ + (property + ? getOutputName({property, from: list || '#list', prefix}) + : '#values'), + ], steps: () => [ { @@ -78,7 +76,9 @@ export default templateCompositeFrom({ [input.staticValue('prefix')]: prefix, }) => continuation({ ['#outputName']: - getOutputName({list, property, prefix}), + (property + ? getOutputName({property, from: list || '#list', prefix}) + : '#values'), }), }, diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js index 7b452b99..7f8c4449 100644 --- a/src/data/composite/data/withPropertyFromObject.js +++ b/src/data/composite/data/withPropertyFromObject.js @@ -13,20 +13,7 @@ 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'; - } -} +import {getOutputName} from './helpers/property-from-helpers.js'; export default templateCompositeFrom({ annotation: `withPropertyFromObject`, @@ -37,7 +24,14 @@ export default templateCompositeFrom({ internal: input({type: 'boolean', defaultValue: false}), }, - outputs: inputs => [getOutputName(inputs)], + outputs: ({ + [input.staticDependency('object')]: object, + [input.staticValue('property')]: property, + }) => [ + (property + ? getOutputName({property, from: object || '#object'}) + : '#value'), + ], steps: () => [ { @@ -46,8 +40,15 @@ export default templateCompositeFrom({ input.staticValue('property'), ], - compute: (continuation, inputs) => - continuation({'#output': getOutputName(inputs)}), + compute: (continuation, { + [input.staticDependency('object')]: object, + [input.staticValue('property')]: property, + }) => continuation({ + '#output': + (property + ? getOutputName({property, from: object || '#object'}) + : '#value'), + }), }, { diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js deleted file mode 100644 index dfc6864f..00000000 --- a/src/data/composite/things/album/index.js +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index fd3f2894..00000000 --- a/src/data/composite/things/album/withHasCoverArt.js +++ /dev/null @@ -1,64 +0,0 @@ -// 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 deleted file mode 100644 index 835ee570..00000000 --- a/src/data/composite/things/album/withTracks.js +++ /dev/null @@ -1,29 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {withFlattenedList, withPropertyFromList} from '#composite/data'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; - -export default templateCompositeFrom({ - annotation: `withTracks`, - - outputs: ['#tracks'], - - steps: () => [ - raiseOutputWithoutDependency({ - dependency: 'trackSections', - output: input.value({'#tracks': []}), - }), - - withPropertyFromList({ - list: 'trackSections', - property: input.value('tracks'), - }), - - withFlattenedList({ - list: '#trackSections.tracks', - }).outputs({ - ['#flattenedList']: '#tracks', - }), - ], -}); diff --git a/src/data/composite/things/art-tag/index.js b/src/data/composite/things/art-tag/index.js deleted file mode 100644 index bbd38293..00000000 --- a/src/data/composite/things/art-tag/index.js +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 795f96cd..00000000 --- a/src/data/composite/things/art-tag/withAllDescendantArtTags.js +++ /dev/null @@ -1,44 +0,0 @@ -// 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 deleted file mode 100644 index e084a42b..00000000 --- a/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js +++ /dev/null @@ -1,46 +0,0 @@ -// 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 deleted file mode 100644 index b8a205fe..00000000 --- a/src/data/composite/things/artist/artistTotalDuration.js +++ /dev/null @@ -1,69 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exposeDependency} from '#composite/control-flow'; -import {withFilteredList, withPropertyFromList} from '#composite/data'; -import {withContributionListSums, withReverseReferenceList} - from '#composite/wiki-data'; -import {soupyReverse} from '#composite/wiki-properties'; - -export default templateCompositeFrom({ - annotation: `artistTotalDuration`, - - compose: false, - - steps: () => [ - withReverseReferenceList({ - reverse: soupyReverse.input('trackArtistContributionsBy'), - }).outputs({ - '#reverseReferenceList': '#contributionsAsArtist', - }), - - withReverseReferenceList({ - reverse: soupyReverse.input('trackContributorContributionsBy'), - }).outputs({ - '#reverseReferenceList': '#contributionsAsContributor', - }), - - { - dependencies: [ - '#contributionsAsArtist', - '#contributionsAsContributor', - ], - - compute: (continuation, { - ['#contributionsAsArtist']: artistContribs, - ['#contributionsAsContributor']: contributorContribs, - }) => continuation({ - ['#allContributions']: [ - ...artistContribs, - ...contributorContribs, - ], - }), - }, - - withPropertyFromList({ - list: '#allContributions', - property: input.value('thing'), - }), - - withPropertyFromList({ - list: '#allContributions.thing', - property: input.value('isMainRelease'), - }), - - withFilteredList({ - list: '#allContributions', - filter: '#allContributions.thing.isMainRelease', - }).outputs({ - '#filteredList': '#mainReleaseContributions', - }), - - withContributionListSums({ - list: '#mainReleaseContributions', - }), - - exposeDependency({ - dependency: '#contributionListDuration', - }), - ], -}); diff --git a/src/data/composite/things/artist/index.js b/src/data/composite/things/artist/index.js deleted file mode 100644 index 55514c71..00000000 --- a/src/data/composite/things/artist/index.js +++ /dev/null @@ -1 +0,0 @@ -export {default as artistTotalDuration} from './artistTotalDuration.js'; diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js index 3693c10f..2cd3c388 100644 --- a/src/data/composite/things/artwork/index.js +++ b/src/data/composite/things/artwork/index.js @@ -1,5 +1 @@ -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 deleted file mode 100644 index d7c0d87b..00000000 --- a/src/data/composite/things/artwork/withAttachedArtwork.js +++ /dev/null @@ -1,43 +0,0 @@ -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/withContribsFromAttachedArtwork.js b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js deleted file mode 100644 index 36abb3fe..00000000 --- a/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js +++ /dev/null @@ -1,28 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; -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 deleted file mode 100644 index 5e05b814..00000000 --- a/src/data/composite/things/artwork/withDate.js +++ /dev/null @@ -1,41 +0,0 @@ -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 deleted file mode 100644 index a2f954b9..00000000 --- a/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js +++ /dev/null @@ -1,65 +0,0 @@ -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/commentary-entry/index.js b/src/data/composite/things/commentary-entry/index.js deleted file mode 100644 index 091bae1a..00000000 --- a/src/data/composite/things/commentary-entry/index.js +++ /dev/null @@ -1 +0,0 @@ -export {default as withWebArchiveDate} from './withWebArchiveDate.js'; diff --git a/src/data/composite/things/content/hasAnnotationPart.js b/src/data/composite/things/content/hasAnnotationPart.js new file mode 100644 index 00000000..93aaf5e5 --- /dev/null +++ b/src/data/composite/things/content/hasAnnotationPart.js @@ -0,0 +1,25 @@ +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `hasAnnotationPart`, + + compose: false, + + inputs: { + part: input({type: 'string'}), + }, + + steps: () => [ + { + dependencies: [input('part'), 'annotationParts'], + + compute: ({ + [input('part')]: search, + ['annotationParts']: parts, + }) => + parts.some(part => + part.toLowerCase() === + search.toLowerCase()), + }, + ], +}); diff --git a/src/data/composite/things/content/index.js b/src/data/composite/things/content/index.js new file mode 100644 index 00000000..27bf7c53 --- /dev/null +++ b/src/data/composite/things/content/index.js @@ -0,0 +1,4 @@ +export {default as hasAnnotationPart} from './hasAnnotationPart.js'; +export {default as withAnnotationPartNodeLists} from './withAnnotationPartNodeLists.js'; +export {default as withExpressedOrImplicitArtistReferences} from './withExpressedOrImplicitArtistReferences.js'; +export {default as withWebArchiveDate} from './withWebArchiveDate.js'; diff --git a/src/data/composite/things/content/withAnnotationPartNodeLists.js b/src/data/composite/things/content/withAnnotationPartNodeLists.js new file mode 100644 index 00000000..fc304594 --- /dev/null +++ b/src/data/composite/things/content/withAnnotationPartNodeLists.js @@ -0,0 +1,28 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {splitContentNodesAround, withContentNodes} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withAnnotationPartNodeLists`, + + outputs: ['#annotationPartNodeLists'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'annotation', + output: input.value({'#annotationPartNodeLists': []}), + }), + + withContentNodes({ + from: 'annotation', + }), + + splitContentNodesAround({ + nodes: '#contentNodes', + around: input.value(/, */g), + }).outputs({ + '#contentNodeLists': '#annotationPartNodeLists', + }), + ], +}); diff --git a/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js b/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js new file mode 100644 index 00000000..69da8c75 --- /dev/null +++ b/src/data/composite/things/content/withExpressedOrImplicitArtistReferences.js @@ -0,0 +1,61 @@ +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 => + 'artist:' + + node.data.replacerValue[0].data), + }).outputs({ + '#mappedList': '#artistReferences', + }), + ], +}); diff --git a/src/data/composite/things/commentary-entry/withWebArchiveDate.js b/src/data/composite/things/content/withWebArchiveDate.js index 3aaa4f64..3aaa4f64 100644 --- a/src/data/composite/things/commentary-entry/withWebArchiveDate.js +++ b/src/data/composite/things/content/withWebArchiveDate.js diff --git a/src/data/composite/things/contribution/hasAnnotationFront.js b/src/data/composite/things/contribution/hasAnnotationFront.js new file mode 100644 index 00000000..6969268b --- /dev/null +++ b/src/data/composite/things/contribution/hasAnnotationFront.js @@ -0,0 +1,29 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {exitWithoutDependency} from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: `hasAnnotationFront`, + + inputs: { + front: input({type: 'string'}), + }, + + compose: false, + + steps: () => [ + exitWithoutDependency({ + dependency: 'annotationFront', + value: input.value(false), + }), + + { + dependencies: ['annotationFront', input('front')], + compute: ({ + ['annotationFront']: present, + [input('front')]: expected, + }) => + present === expected, + }, + ], +}); diff --git a/src/data/composite/things/contribution/index.js b/src/data/composite/things/contribution/index.js index 9b22be2e..b03ebfd2 100644 --- a/src/data/composite/things/contribution/index.js +++ b/src/data/composite/things/contribution/index.js @@ -1,7 +1,4 @@ +export {default as hasAnnotationFront} from './hasAnnotationFront.js'; export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js'; -export {default as thingPropertyMatches} from './thingPropertyMatches.js'; -export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js'; export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js'; -export {default as withContributionArtist} from './withContributionArtist.js'; export {default as withContributionContext} from './withContributionContext.js'; -export {default as withMatchingContributionPresets} from './withMatchingContributionPresets.js'; diff --git a/src/data/composite/things/contribution/inheritFromContributionPresets.js b/src/data/composite/things/contribution/inheritFromContributionPresets.js index a74e6db3..1cefae1b 100644 --- a/src/data/composite/things/contribution/inheritFromContributionPresets.js +++ b/src/data/composite/things/contribution/inheritFromContributionPresets.js @@ -3,29 +3,18 @@ import {input, templateCompositeFrom} from '#composite'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; import {withPropertyFromList} from '#composite/data'; -import withMatchingContributionPresets - from './withMatchingContributionPresets.js'; - export default templateCompositeFrom({ annotation: `inheritFromContributionPresets`, - inputs: { - property: input({type: 'string'}), - }, - steps: () => [ - withMatchingContributionPresets().outputs({ - '#matchingContributionPresets': '#presets', - }), - raiseOutputWithoutDependency({ - dependency: '#presets', + dependency: 'matchingPresets', mode: input.value('empty'), }), withPropertyFromList({ - list: '#presets', - property: input('property'), + list: 'matchingPresets', + property: input.thisProperty(), }), { @@ -52,10 +41,8 @@ export default templateCompositeFrom({ compute: (continuation, { ['#values']: values, ['#index']: index, - }) => continuation({ - ['#value']: - values[index], - }), + }) => + continuation.exit(values[index]), }, ], }); diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js deleted file mode 100644 index 1e9019b8..00000000 --- a/src/data/composite/things/contribution/thingPropertyMatches.js +++ /dev/null @@ -1,46 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exitWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - -export default templateCompositeFrom({ - annotation: `thingPropertyMatches`, - - compose: false, - - inputs: { - value: input({type: 'string'}), - }, - - steps: () => [ - { - dependencies: ['thing', 'thingProperty'], - - compute: (continuation, {thing, thingProperty}) => - continuation({ - ['#thingProperty']: - (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork' - ? thing.artistContribsFromThingProperty - : thingProperty), - }), - }, - - exitWithoutDependency({ - dependency: '#thingProperty', - value: input.value(false), - }), - - { - dependencies: [ - '#thingProperty', - input('value'), - ], - - compute: ({ - ['#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 deleted file mode 100644 index 4042e78f..00000000 --- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js +++ /dev/null @@ -1,66 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exitWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - -export default templateCompositeFrom({ - annotation: `thingReferenceTypeMatches`, - - compose: false, - - inputs: { - value: input({type: 'string'}), - }, - - steps: () => [ - exitWithoutDependency({ - dependency: 'thing', - value: input.value(false), - }), - - withPropertyFromObject({ - object: 'thing', - property: input.value('constructor'), - }), - - { - dependencies: [ - '#thing.constructor', - input('value'), - ], - - 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 175d6cbb..d8288b17 100644 --- a/src/data/composite/things/contribution/withContainingReverseContributionList.js +++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js @@ -9,43 +9,19 @@ import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; -import withContributionArtist from './withContributionArtist.js'; - export default templateCompositeFrom({ annotation: `withContainingReverseContributionList`, - inputs: { - artistProperty: input({ - defaultDependency: 'artistProperty', - acceptsNull: true, - }), - }, - outputs: ['#containingReverseContributionList'], steps: () => [ - raiseOutputWithoutDependency({ - dependency: input('artistProperty'), - output: input.value({ - ['#containingReverseContributionList']: - null, - }), - }), - - withContributionArtist(), + raiseOutputWithoutDependency('artistProperty'), - withPropertyFromObject({ - object: '#artist', - property: input('artistProperty'), - }).outputs({ - ['#value']: '#list', - }), + withPropertyFromObject('artist', 'artistProperty') + .outputs({'#value': '#list'}), - withResultOfAvailabilityCheck({ - from: 'date', - }).outputs({ - ['#availability']: '#hasDate', - }), + withResultOfAvailabilityCheck({from: 'date'}) + .outputs({'#availability': '#hasDate'}), { dependencies: ['#hasDate', '#list'], diff --git a/src/data/composite/things/contribution/withContributionArtist.js b/src/data/composite/things/contribution/withContributionArtist.js deleted file mode 100644 index 5f81c716..00000000 --- a/src/data/composite/things/contribution/withContributionArtist.js +++ /dev/null @@ -1,26 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {withResolvedReference} from '#composite/wiki-data'; -import {soupyFind} from '#composite/wiki-properties'; - -export default templateCompositeFrom({ - annotation: `withContributionArtist`, - - inputs: { - ref: input({ - type: 'string', - defaultDependency: 'artist', - }), - }, - - outputs: ['#artist'], - - steps: () => [ - withResolvedReference({ - ref: input('ref'), - find: soupyFind.input('artist'), - }).outputs({ - '#resolvedReference': '#artist', - }), - ], -}); diff --git a/src/data/composite/things/contribution/withMatchingContributionPresets.js b/src/data/composite/things/contribution/withMatchingContributionPresets.js deleted file mode 100644 index 09454164..00000000 --- a/src/data/composite/things/contribution/withMatchingContributionPresets.js +++ /dev/null @@ -1,70 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - -import withContributionContext from './withContributionContext.js'; - -export default templateCompositeFrom({ - annotation: `withMatchingContributionPresets`, - - outputs: ['#matchingContributionPresets'], - - steps: () => [ - withPropertyFromObject({ - object: 'thing', - property: input.value('wikiInfo'), - internal: input.value(true), - }), - - raiseOutputWithoutDependency({ - dependency: '#thing.wikiInfo', - output: input.value({ - '#matchingContributionPresets': null, - }), - }), - - withPropertyFromObject({ - object: '#thing.wikiInfo', - property: input.value('contributionPresets'), - }).outputs({ - '#thing.wikiInfo.contributionPresets': '#contributionPresets', - }), - - raiseOutputWithoutDependency({ - dependency: '#contributionPresets', - mode: input.value('empty'), - output: input.value({ - '#matchingContributionPresets': [], - }), - }), - - withContributionContext(), - - { - dependencies: [ - '#contributionPresets', - '#contributionTarget', - '#contributionProperty', - 'annotation', - ], - - compute: (continuation, { - ['#contributionPresets']: presets, - ['#contributionTarget']: target, - ['#contributionProperty']: property, - ['annotation']: annotation, - }) => continuation({ - ['#matchingContributionPresets']: - presets - .filter(preset => - preset.context[0] === target && - preset.context.slice(1).includes(property) && - // For now, only match if the annotation is a complete match. - // Partial matches (e.g. because the contribution includes "two" - // annotations, separated by commas) don't count. - preset.annotation === annotation), - }) - }, - ], -}); diff --git a/src/data/composite/things/flash-act/index.js b/src/data/composite/things/flash-act/index.js deleted file mode 100644 index 40fecd2f..00000000 --- a/src/data/composite/things/flash-act/index.js +++ /dev/null @@ -1 +0,0 @@ -export {default as withFlashSide} from './withFlashSide.js'; diff --git a/src/data/composite/things/flash-act/withFlashSide.js b/src/data/composite/things/flash-act/withFlashSide.js deleted file mode 100644 index e09f06e6..00000000 --- a/src/data/composite/things/flash-act/withFlashSide.js +++ /dev/null @@ -1,22 +0,0 @@ -// Gets the flash act's side. This will early exit if flashSideData is missing. -// If there's no side whose list of flash acts includes this act, the output -// dependency will be null. - -import {templateCompositeFrom} from '#composite'; - -import {withUniqueReferencingThing} from '#composite/wiki-data'; -import {soupyReverse} from '#composite/wiki-properties'; - -export default templateCompositeFrom({ - annotation: `withFlashSide`, - - outputs: ['#flashSide'], - - steps: () => [ - withUniqueReferencingThing({ - reverse: soupyReverse.input('flashSidesWhoseActsInclude'), - }).outputs({ - ['#uniqueReferencingThing']: '#flashSide', - }), - ], -}); diff --git a/src/data/composite/things/flash/index.js b/src/data/composite/things/flash/index.js deleted file mode 100644 index 63ac13da..00000000 --- a/src/data/composite/things/flash/index.js +++ /dev/null @@ -1 +0,0 @@ -export {default as withFlashAct} from './withFlashAct.js'; diff --git a/src/data/composite/things/flash/withFlashAct.js b/src/data/composite/things/flash/withFlashAct.js deleted file mode 100644 index 87922aff..00000000 --- a/src/data/composite/things/flash/withFlashAct.js +++ /dev/null @@ -1,22 +0,0 @@ -// Gets the flash's act. This will early exit if flashActData is missing. -// If there's no flash whose list of flashes includes this flash, the output -// dependency will be null. - -import {templateCompositeFrom} from '#composite'; - -import {withUniqueReferencingThing} from '#composite/wiki-data'; -import {soupyReverse} from '#composite/wiki-properties'; - -export default templateCompositeFrom({ - annotation: `withFlashAct`, - - outputs: ['#flashAct'], - - steps: () => [ - withUniqueReferencingThing({ - 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 deleted file mode 100644 index f11a2ab5..00000000 --- a/src/data/composite/things/track-section/index.js +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index e257062e..00000000 --- a/src/data/composite/things/track-section/withAlbum.js +++ /dev/null @@ -1,20 +0,0 @@ -// Gets the track section's album. - -import {templateCompositeFrom} from '#composite'; - -import {withUniqueReferencingThing} from '#composite/wiki-data'; -import {soupyReverse} from '#composite/wiki-properties'; - -export default templateCompositeFrom({ - annotation: `withAlbum`, - - outputs: ['#album'], - - steps: () => [ - withUniqueReferencingThing({ - 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 deleted file mode 100644 index e034b7a5..00000000 --- a/src/data/composite/things/track-section/withContinueCountingFrom.js +++ /dev/null @@ -1,25 +0,0 @@ -import {input, 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 deleted file mode 100644 index ef345327..00000000 --- a/src/data/composite/things/track-section/withStartCountingFrom.js +++ /dev/null @@ -1,64 +0,0 @@ -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/exitWithoutUniqueCoverArt.js b/src/data/composite/things/track/exitWithoutUniqueCoverArt.js deleted file mode 100644 index f47086d9..00000000 --- a/src/data/composite/things/track/exitWithoutUniqueCoverArt.js +++ /dev/null @@ -1,26 +0,0 @@ -// Shorthand for checking if the track has unique cover art and exposing a -// fallback value if it isn't. - -import {input, templateCompositeFrom} from '#composite'; - -import {exitWithoutDependency} from '#composite/control-flow'; - -import withHasUniqueCoverArt from './withHasUniqueCoverArt.js'; - -export default templateCompositeFrom({ - annotation: `exitWithoutUniqueCoverArt`, - - inputs: { - value: input({defaultValue: null}), - }, - - steps: () => [ - withHasUniqueCoverArt(), - - exitWithoutDependency({ - dependency: '#hasUniqueCoverArt', - mode: input.value('falsy'), - value: input('value'), - }), - ], -}); diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js index e789e736..c200df19 100644 --- a/src/data/composite/things/track/index.js +++ b/src/data/composite/things/track/index.js @@ -1,17 +1,2 @@ -export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.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 withMainRelease} from './withMainRelease.js'; -export {default as withOtherReleases} from './withOtherReleases.js'; -export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.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/inheritContributionListFromMainRelease.js b/src/data/composite/things/track/inheritContributionListFromMainRelease.js index 89252feb..8db50060 100644 --- a/src/data/composite/things/track/inheritContributionListFromMainRelease.js +++ b/src/data/composite/things/track/inheritContributionListFromMainRelease.js @@ -5,40 +5,37 @@ import {input, templateCompositeFrom} from '#composite'; import {exposeDependency, raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; import {withRecontextualizedContributionList, withRedatedContributionList} from '#composite/wiki-data'; -import withDate from './withDate.js'; -import withPropertyFromMainRelease - from './withPropertyFromMainRelease.js'; - export default templateCompositeFrom({ annotation: `inheritContributionListFromMainRelease`, steps: () => [ - withPropertyFromMainRelease({ - property: input.thisProperty(), - notFoundValue: input.value([]), - }), - raiseOutputWithoutDependency({ - dependency: '#isSecondaryRelease', + dependency: 'isSecondaryRelease', mode: input.value('falsy'), }), - withRecontextualizedContributionList({ - list: '#mainReleaseValue', + withPropertyFromObject({ + object: 'mainReleaseTrack', + property: input.thisProperty(), + }).outputs({ + '#value': '#contributions', }), - withDate(), + withRecontextualizedContributionList({ + list: '#contributions', + }), withRedatedContributionList({ - list: '#mainReleaseValue', - date: '#date', + list: '#contributions', + date: 'date', }), exposeDependency({ - dependency: '#mainReleaseValue', + dependency: '#contributions', }), ], }); diff --git a/src/data/composite/things/track/inheritFromMainRelease.js b/src/data/composite/things/track/inheritFromMainRelease.js index b1cbb65e..ca532bc7 100644 --- a/src/data/composite/things/track/inheritFromMainRelease.js +++ b/src/data/composite/things/track/inheritFromMainRelease.js @@ -1,41 +1,29 @@ // Early exits with the value for the same property as specified on the // main release, if this track is a secondary release, and otherwise continues // without providing any further dependencies. -// -// 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'; import {exposeDependency, raiseOutputWithoutDependency} from '#composite/control-flow'; - -import withPropertyFromMainRelease - from './withPropertyFromMainRelease.js'; +import {withPropertyFromObject} from '#composite/data'; export default templateCompositeFrom({ annotation: `inheritFromMainRelease`, - inputs: { - notFoundValue: input({ - defaultValue: null, - }), - }, - steps: () => [ - withPropertyFromMainRelease({ - property: input.thisProperty(), - notFoundValue: input('notFoundValue'), - }), - raiseOutputWithoutDependency({ - dependency: '#isSecondaryRelease', + dependency: 'isSecondaryRelease', mode: input.value('falsy'), }), + withPropertyFromObject({ + object: 'mainReleaseTrack', + property: input.thisProperty(), + }), + exposeDependency({ - dependency: '#mainReleaseValue', + dependency: '#value', }), ], }); 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/withAllReleases.js b/src/data/composite/things/track/withAllReleases.js deleted file mode 100644 index b93bf753..00000000 --- a/src/data/composite/things/track/withAllReleases.js +++ /dev/null @@ -1,47 +0,0 @@ -// 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 {exitWithoutDependency} from '#composite/control-flow'; -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 deleted file mode 100644 index 60faeaf4..00000000 --- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js +++ /dev/null @@ -1,97 +0,0 @@ -// Controls how find.track works - it'll never be matched by a reference -// just to the track's name, which means you don't have to always reference -// some *other* (much more commonly referenced) track by directory instead -// of more naturally by name. - -import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {isBoolean} from '#validators'; - -import {withPropertyFromObject} from '#composite/data'; -import {withResolvedReference} from '#composite/wiki-data'; -import {soupyFind} from '#composite/wiki-properties'; - -import { - exitWithoutDependency, - exposeDependencyOrContinue, - exposeUpdateValueOrContinue, -} from '#composite/control-flow'; - -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; - -export default templateCompositeFrom({ - annotation: `withAlwaysReferenceByDirectory`, - - outputs: ['#alwaysReferenceByDirectory'], - - steps: () => [ - exposeUpdateValueOrContinue({ - validate: input.value(isBoolean), - }), - - withPropertyFromAlbum({ - property: input.value('alwaysReferenceTracksByDirectory'), - }), - - // Falsy mode means this exposes true if the album's property is true, - // but continues if the property is false (which is also the default). - exposeDependencyOrContinue({ - dependency: '#album.alwaysReferenceTracksByDirectory', - mode: input.value('falsy'), - }), - - // 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 mainReleaseTrack. - - exitWithoutDependency({ - dependency: 'trackData', - mode: input.value('empty'), - value: input.value(false), - }), - - exitWithoutDependency({ - dependency: 'mainReleaseTrack', - value: input.value(false), - }), - - // 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.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: 'mainReleaseTrack', - data: 'trackData', - find: input.value(find.trackMainReleasesOnly), - }).outputs({ - '#resolvedReference': '#mainRelease', - }), - - exitWithoutDependency({ - dependency: '#mainRelease', - value: input.value(false), - }), - - withPropertyFromObject({ - object: '#mainRelease', - property: input.value('name'), - }), - - { - dependencies: ['name', '#mainRelease.name'], - compute: (continuation, { - name, - ['#mainRelease.name']: mainReleaseName, - }) => continuation({ - ['#alwaysReferenceByDirectory']: - name === mainReleaseName, - }), - }, - ], -}); diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js deleted file mode 100644 index 3d4d081e..00000000 --- a/src/data/composite/things/track/withContainingTrackSection.js +++ /dev/null @@ -1,20 +0,0 @@ -// Gets the track section containing this track from its album's track list. - -import {templateCompositeFrom} from '#composite'; - -import {withUniqueReferencingThing} from '#composite/wiki-data'; -import {soupyReverse} from '#composite/wiki-properties'; - -export default templateCompositeFrom({ - annotation: `withContainingTrackSection`, - - outputs: ['#trackSection'], - - steps: () => [ - withUniqueReferencingThing({ - 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 deleted file mode 100644 index 9057cfeb..00000000 --- a/src/data/composite/things/track/withCoverArtistContribs.js +++ /dev/null @@ -1,73 +0,0 @@ -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/withDate.js b/src/data/composite/things/track/withDate.js deleted file mode 100644 index b5a770e9..00000000 --- a/src/data/composite/things/track/withDate.js +++ /dev/null @@ -1,34 +0,0 @@ -// Gets the track's own date. This is either its dateFirstReleased property -// or, if unset, the album's date. - -import {input, templateCompositeFrom} from '#composite'; - -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; - -export default templateCompositeFrom({ - annotation: `withDate`, - - outputs: ['#date'], - - steps: () => [ - { - dependencies: ['dateFirstReleased'], - compute: (continuation, {dateFirstReleased}) => - (dateFirstReleased - ? continuation.raiseOutput({'#date': dateFirstReleased}) - : continuation()), - }, - - withPropertyFromAlbum({ - property: input.value('date'), - }), - - { - dependencies: ['#album.date'], - compute: (continuation, {['#album.date']: albumDate}) => - (albumDate - ? continuation.raiseOutput({'#date': albumDate}) - : continuation.raiseOutput({'#date': null})), - }, - ], -}) diff --git a/src/data/composite/things/track/withDirectorySuffix.js b/src/data/composite/things/track/withDirectorySuffix.js deleted file mode 100644 index c063e158..00000000 --- a/src/data/composite/things/track/withDirectorySuffix.js +++ /dev/null @@ -1,36 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; - -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; -import withSuffixDirectoryFromAlbum from './withSuffixDirectoryFromAlbum.js'; - -export default templateCompositeFrom({ - annotation: `withDirectorySuffix`, - - outputs: ['#directorySuffix'], - - steps: () => [ - withSuffixDirectoryFromAlbum(), - - raiseOutputWithoutDependency({ - dependency: '#suffixDirectoryFromAlbum', - mode: input.value('falsy'), - output: input.value({['#directorySuffix']: null}), - }), - - withPropertyFromAlbum({ - property: input.value('directorySuffix'), - }), - - { - dependencies: ['#album.directorySuffix'], - compute: (continuation, { - ['#album.directorySuffix']: directorySuffix, - }) => continuation({ - ['#directorySuffix']: - directorySuffix, - }), - }, - ], -}); diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js deleted file mode 100644 index 85d3b92a..00000000 --- a/src/data/composite/things/track/withHasUniqueCoverArt.js +++ /dev/null @@ -1,108 +0,0 @@ -// Whether or not the track has "unique" cover artwork - a cover which is -// specifically associated with this track in particular, rather than with -// the track's album as a whole. This is typically used to select between -// displaying the track artwork and a fallback, such as the album artwork -// 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 {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} - from '#composite/control-flow'; -import {fillMissingListItems, withFlattenedList, withPropertyFromList} - from '#composite/data'; - -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; - -export default templateCompositeFrom({ - annotation: 'withHasUniqueCoverArt', - - outputs: ['#hasUniqueCoverArt'], - - steps: () => [ - { - dependencies: ['disableUniqueCoverArt'], - compute: (continuation, {disableUniqueCoverArt}) => - (disableUniqueCoverArt - ? continuation.raiseOutput({ - ['#hasUniqueCoverArt']: false, - }) - : continuation()), - }, - - withResultOfAvailabilityCheck({ - from: 'coverArtistContribs', - mode: input.value('empty'), - }), - - { - dependencies: ['#availability'], - compute: (continuation, { - ['#availability']: availability, - }) => - (availability - ? continuation.raiseOutput({ - ['#hasUniqueCoverArt']: true, - }) - : continuation()), - }, - - withPropertyFromAlbum({ - property: input.value('trackCoverArtistContribs'), - internal: input.value(true), - }), - - withResultOfAvailabilityCheck({ - from: '#album.trackCoverArtistContribs', - mode: input.value('empty'), - }), - - { - dependencies: ['#availability'], - compute: (continuation, { - ['#availability']: availability, - }) => - (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/withMainRelease.js b/src/data/composite/things/track/withMainRelease.js deleted file mode 100644 index 3a91edae..00000000 --- a/src/data/composite/things/track/withMainRelease.js +++ /dev/null @@ -1,70 +0,0 @@ -// 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 {exitWithoutDependency, withResultOfAvailabilityCheck} - from '#composite/control-flow'; -import {withResolvedReference} from '#composite/wiki-data'; -import {soupyFind} from '#composite/wiki-properties'; - -export default templateCompositeFrom({ - annotation: `withMainRelease`, - - inputs: { - selfIfMain: input({type: 'boolean', defaultValue: false}), - notFoundValue: input({defaultValue: null}), - }, - - outputs: ['#mainRelease'], - - steps: () => [ - withResultOfAvailabilityCheck({ - from: 'mainReleaseTrack', - }), - - { - dependencies: [ - input.myself(), - input('selfIfMain'), - '#availability', - ], - - compute: (continuation, { - [input.myself()]: track, - [input('selfIfMain')]: selfIfMain, - '#availability': availability, - }) => - (availability - ? continuation() - : continuation.raiseOutput({ - ['#mainRelease']: - (selfIfMain ? track : null), - })), - }, - - withResolvedReference({ - ref: 'mainReleaseTrack', - find: soupyFind.input('track'), - }), - - exitWithoutDependency({ - dependency: '#resolvedReference', - value: input('notFoundValue'), - }), - - { - dependencies: ['#resolvedReference'], - - compute: (continuation, { - ['#resolvedReference']: resolvedReference, - }) => - continuation({ - ['#mainRelease']: resolvedReference, - }), - }, - ], -}); diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js deleted file mode 100644 index 0639742f..00000000 --- a/src/data/composite/things/track/withOtherReleases.js +++ /dev/null @@ -1,30 +0,0 @@ -// Gets all releases of the current track *except* this track itself; -// in other words, all other releases of the current track. - -import {input, templateCompositeFrom} from '#composite'; - -import {exitWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - -import withAllReleases from './withAllReleases.js'; - -export default templateCompositeFrom({ - annotation: `withOtherReleases`, - - outputs: ['#otherReleases'], - - steps: () => [ - withAllReleases(), - - { - dependencies: [input.myself(), '#allReleases'], - compute: (continuation, { - [input.myself()]: thisTrack, - ['#allReleases']: allReleases, - }) => continuation({ - ['#otherReleases']: - allReleases.filter(track => track !== thisTrack), - }), - }, - ], -}); diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js deleted file mode 100644 index a203c2e7..00000000 --- a/src/data/composite/things/track/withPropertyFromAlbum.js +++ /dev/null @@ -1,48 +0,0 @@ -// Gets a single property from this track's album, providing it as the same -// property name prefixed with '#album.' (by default). - -import {input, templateCompositeFrom} from '#composite'; - -import {withPropertyFromObject} from '#composite/data'; - -export default templateCompositeFrom({ - annotation: `withPropertyFromAlbum`, - - inputs: { - property: input.staticValue({type: 'string'}), - internal: input({type: 'boolean', defaultValue: false}), - }, - - outputs: ({ - [input.staticValue('property')]: property, - }) => ['#album.' + property], - - steps: () => [ - // 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', - property: input('property'), - internal: '#internal', - }), - - { - dependencies: ['#value', input.staticValue('property')], - compute: (continuation, { - ['#value']: value, - [input.staticValue('property')]: property, - }) => continuation({ - ['#album.' + property]: value, - }), - }, - ], -}); diff --git a/src/data/composite/things/track/withPropertyFromMainRelease.js b/src/data/composite/things/track/withPropertyFromMainRelease.js deleted file mode 100644 index 393a4c63..00000000 --- a/src/data/composite/things/track/withPropertyFromMainRelease.js +++ /dev/null @@ -1,86 +0,0 @@ -// Provides a value inherited from the main release, if applicable, and a -// flag indicating if this track is a secondary release or not. -// -// 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'; - -import {withResultOfAvailabilityCheck} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - -import withMainRelease from './withMainRelease.js'; - -export default templateCompositeFrom({ - annotation: `inheritFromMainRelease`, - - inputs: { - property: input({type: 'string'}), - - notFoundValue: input({ - defaultValue: null, - }), - }, - - outputs: ({ - [input.staticValue('property')]: property, - }) => - ['#isSecondaryRelease'].concat( - (property - ? ['#mainRelease.' + property] - : ['#mainReleaseValue'])), - - steps: () => [ - withMainRelease({ - notFoundValue: input('notFoundValue'), - }), - - withResultOfAvailabilityCheck({ - from: '#mainRelease', - }), - - { - dependencies: [ - '#availability', - input.staticValue('property'), - ], - - compute: (continuation, { - ['#availability']: availability, - [input.staticValue('property')]: property, - }) => - (availability - ? continuation() - : continuation.raiseOutput( - Object.assign( - {'#isSecondaryRelease': false}, - (property - ? {['#mainRelease.' + property]: null} - : {'#mainReleaseValue': null})))), - }, - - withPropertyFromObject({ - object: '#mainRelease', - property: input('property'), - }), - - { - dependencies: [ - '#value', - input.staticValue('property'), - ], - - compute: (continuation, { - ['#value']: value, - [input.staticValue('property')]: property, - }) => - continuation.raiseOutput( - Object.assign( - {'#isSecondaryRelease': true}, - (property - ? {['#mainRelease.' + property]: value} - : {'#mainReleaseValue': value}))), - }, - ], -}); diff --git a/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js deleted file mode 100644 index 7159a3f4..00000000 --- a/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js +++ /dev/null @@ -1,53 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {withResultOfAvailabilityCheck} from '#composite/control-flow'; - -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; - -export default templateCompositeFrom({ - annotation: `withSuffixDirectoryFromAlbum`, - - inputs: { - flagValue: input({ - defaultDependency: 'suffixDirectoryFromAlbum', - acceptsNull: true, - }), - }, - - outputs: ['#suffixDirectoryFromAlbum'], - - steps: () => [ - withResultOfAvailabilityCheck({ - from: 'suffixDirectoryFromAlbum', - }), - - { - dependencies: [ - '#availability', - 'suffixDirectoryFromAlbum' - ], - - compute: (continuation, { - ['#availability']: availability, - ['suffixDirectoryFromAlbum']: flagValue, - }) => - (availability - ? continuation.raiseOutput({['#suffixDirectoryFromAlbum']: flagValue}) - : continuation()), - }, - - withPropertyFromAlbum({ - property: input.value('suffixTrackDirectories'), - }), - - { - dependencies: ['#album.suffixTrackDirectories'], - compute: (continuation, { - ['#album.suffixTrackDirectories']: suffixTrackDirectories, - }) => continuation({ - ['#suffixDirectoryFromAlbum']: - suffixTrackDirectories, - }), - }, - ], -}); diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js deleted file mode 100644 index 9b7b61c7..00000000 --- a/src/data/composite/things/track/withTrackArtDate.js +++ /dev/null @@ -1,60 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import {isDate} from '#validators'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; - -import withDate from './withDate.js'; -import withHasUniqueCoverArt from './withHasUniqueCoverArt.js'; -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; - -export default templateCompositeFrom({ - annotation: `withTrackArtDate`, - - inputs: { - from: input({ - validate: isDate, - defaultDependency: 'coverArtDate', - acceptsNull: true, - }), - }, - - outputs: ['#trackArtDate'], - - steps: () => [ - withHasUniqueCoverArt(), - - raiseOutputWithoutDependency({ - dependency: '#hasUniqueCoverArt', - mode: input.value('falsy'), - output: input.value({'#trackArtDate': null}), - }), - - { - dependencies: [input('from')], - compute: (continuation, { - [input('from')]: from, - }) => - (from - ? continuation.raiseOutput({'#trackArtDate': from}) - : continuation()), - }, - - withPropertyFromAlbum({ - property: input.value('trackArtDate'), - }), - - { - dependencies: ['#album.trackArtDate'], - compute: (continuation, { - ['#album.trackArtDate']: albumTrackArtDate, - }) => - (albumTrackArtDate - ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate}) - : continuation()), - }, - - withDate().outputs({ - '#date': '#trackArtDate', - }), - ], -}); diff --git a/src/data/composite/things/track/withTrackNumber.js b/src/data/composite/things/track/withTrackNumber.js deleted file mode 100644 index 61428e8c..00000000 --- a/src/data/composite/things/track/withTrackNumber.js +++ /dev/null @@ -1,50 +0,0 @@ -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/constituteFrom.js b/src/data/composite/wiki-data/constituteFrom.js new file mode 100644 index 00000000..b919d5cd --- /dev/null +++ b/src/data/composite/wiki-data/constituteFrom.js @@ -0,0 +1,31 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {inputAvailabilityCheckMode,} from '#composite/control-flow'; + +import constituteOrContinue from './constituteOrContinue.js'; + +export default templateCompositeFrom({ + annotation: `constituteFrom`, + + inputs: { + object: input({type: 'object', acceptsNull: true}), + property: input({type: 'string', acceptsNull: true}), + else: input({defaultValue: null}), + mode: inputAvailabilityCheckMode(), + }, + + compose: false, + + steps: () => [ + constituteOrContinue({ + object: input('object'), + property: input('property'), + mode: input('mode'), + }), + + { + dependencies: [input('else')], + compute: ({[input('else')]: fallback}) => fallback, + }, + ], +}); diff --git a/src/data/composite/wiki-data/constituteOrContinue.js b/src/data/composite/wiki-data/constituteOrContinue.js new file mode 100644 index 00000000..92b941ba --- /dev/null +++ b/src/data/composite/wiki-data/constituteOrContinue.js @@ -0,0 +1,34 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {withPropertyFromObject} from '#composite/data'; + +import { + exposeDependencyOrContinue, + inputAvailabilityCheckMode, + raiseOutputWithoutDependency, +} from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: `constituteFrom`, + + inputs: { + object: input({type: 'object', acceptsNull: true}), + property: input({type: 'string', acceptsNull: true}), + mode: inputAvailabilityCheckMode(), + }, + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: input('property'), + }), + + withPropertyFromObject({ + object: input('object'), + property: input('property'), + }), + + exposeDependencyOrContinue({ + dependency: '#value', + }), + ], +}); diff --git a/src/data/composite/wiki-data/gobbleSoupyFind.js b/src/data/composite/wiki-data/gobbleSoupyFind.js index aec3f5b1..98d5f5c9 100644 --- a/src/data/composite/wiki-data/gobbleSoupyFind.js +++ b/src/data/composite/wiki-data/gobbleSoupyFind.js @@ -30,7 +30,7 @@ export default templateCompositeFrom({ }, withPropertyFromObject({ - object: 'find', + 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 index 86a1061c..26052f28 100644 --- a/src/data/composite/wiki-data/gobbleSoupyReverse.js +++ b/src/data/composite/wiki-data/gobbleSoupyReverse.js @@ -30,7 +30,7 @@ export default templateCompositeFrom({ }, withPropertyFromObject({ - object: 'reverse', + object: '_reverse', property: '#key', }).outputs({ '#value': '#reverse', diff --git a/src/data/composite/wiki-data/helpers/withSimpleDirectory.js b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js index 08ca3bfc..0b225847 100644 --- a/src/data/composite/wiki-data/helpers/withSimpleDirectory.js +++ b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js @@ -15,7 +15,7 @@ export default templateCompositeFrom({ inputs: { directory: input({ validate: isDirectory, - defaultDependency: 'directory', + defaultDependency: '_directory', acceptsNull: true, }), diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index 005c68c0..41f34d21 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -4,17 +4,21 @@ // #composite/data. // +export {default as constituteFrom} from './constituteFrom.js'; +export {default as constituteOrContinue} from './constituteOrContinue.js'; export {default as exitWithoutContribs} from './exitWithoutContribs.js'; export {default as gobbleSoupyFind} from './gobbleSoupyFind.js'; export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js'; +export {default as inputFindOptions} from './inputFindOptions.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 withRecontextualizedContributionList} from './withRecontextualizedContributionList.js'; export {default as withRedatedContributionList} from './withRedatedContributionList.js'; @@ -22,7 +26,6 @@ export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnot 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 withReverseReferenceList} from './withReverseReferenceList.js'; export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js'; export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js'; diff --git a/src/data/composite/wiki-data/inputFindOptions.js b/src/data/composite/wiki-data/inputFindOptions.js new file mode 100644 index 00000000..07ed4bce --- /dev/null +++ b/src/data/composite/wiki-data/inputFindOptions.js @@ -0,0 +1,5 @@ +import {input} from '#composite'; + +export default function inputFindOptions() { + return input({type: 'object', 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..f12bd8fc --- /dev/null +++ b/src/data/composite/wiki-data/splitContentNodesAround.js @@ -0,0 +1,100 @@ +import {input, templateCompositeFrom} from '#composite'; +import {splitContentNodesAround} from '#replacer'; +import {anyOf, isFunction, validateInstanceOf} from '#validators'; + +import {withAvailabilityFilter} from '#composite/control-flow'; +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', + map: input.value((_node, index) => index), + filter: '#separatorFilter', + }), + + 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', + }), + + withAvailabilityFilter({ + from: '#contentNodeLists', + mode: input.value('empty'), + }), + + withFilteredList({ + list: '#contentNodeLists', + filter: '#availabilityFilter', + }).outputs({ + '#filteredList': '#contentNodeLists', + }), + ], +}); diff --git a/src/data/composite/wiki-data/withClonedThings.js b/src/data/composite/wiki-data/withClonedThings.js index 9af6aa84..36c3ba54 100644 --- a/src/data/composite/wiki-data/withClonedThings.js +++ b/src/data/composite/wiki-data/withClonedThings.js @@ -3,9 +3,9 @@ // 'assignEach' input is provided, each new thing is assigned the // corresponding properties. -import CacheableObject from '#cacheable-object'; import {input, templateCompositeFrom} from '#composite'; -import {isObject, sparseArrayOf} from '#validators'; +import Thing from '#thing'; +import {isObject, isThingClass, sparseArrayOf} from '#validators'; import {withMappedList} from '#composite/data'; @@ -15,6 +15,16 @@ export default templateCompositeFrom({ inputs: { things: input({type: 'array'}), + reclass: input({ + validate: isThingClass, + defaultValue: null, + }), + + reclassUnder: input({ + validate: isThingClass, + defaultValue: null, + }), + assign: input({ type: 'object', defaultValue: null, @@ -46,15 +56,29 @@ export default templateCompositeFrom({ }, { - dependencies: ['#assignmentMap'], + dependencies: [input('reclass'), input('reclassUnder')], + compute: (continuation, { + [input('reclass')]: reclass, + [input('reclassUnder')]: reclassUnder, + }) => continuation({ + ['#cloneOperation']: + (reclassUnder && reclass + ? source => reclassUnder.clone(source, {as: reclass}) + : reclass + ? source => Thing.clone(source, {as: reclass}) + : source => Thing.clone(source)), + }), + }, + + { + dependencies: ['#assignmentMap', '#cloneOperation'], compute: (continuation, { ['#assignmentMap']: assignmentMap, + ['#cloneOperation']: cloneOperation, }) => continuation({ ['#cloningMap']: (thing, index) => - Object.assign( - CacheableObject.clone(thing), - assignmentMap(index)), + Object.assign(cloneOperation(thing), assignmentMap(index)), }), }, diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js index 6187d55b..28d719e2 100644 --- a/src/data/composite/wiki-data/withConstitutedArtwork.js +++ b/src/data/composite/wiki-data/withConstitutedArtwork.js @@ -1,6 +1,5 @@ import {input, templateCompositeFrom} from '#composite'; import thingConstructors from '#things'; -import {isContributionList} from '#validators'; export default templateCompositeFrom({ annotation: `withConstitutedArtwork`, 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 deleted file mode 100644 index a114d5ff..00000000 --- a/src/data/composite/wiki-data/withCoverArtDate.js +++ /dev/null @@ -1,51 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import {isDate} from '#validators'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; - -import withResolvedContribs from './withResolvedContribs.js'; - -export default templateCompositeFrom({ - annotation: `withCoverArtDate`, - - inputs: { - from: input({ - validate: isDate, - defaultDependency: 'coverArtDate', - acceptsNull: true, - }), - }, - - outputs: ['#coverArtDate'], - - steps: () => [ - withResolvedContribs({ - from: 'coverArtistContribs', - date: input.value(null), - }), - - raiseOutputWithoutDependency({ - dependency: '#resolvedContribs', - mode: input.value('empty'), - output: input.value({'#coverArtDate': null}), - }), - - { - dependencies: [input('from')], - compute: (continuation, { - [input('from')]: from, - }) => - (from - ? continuation.raiseOutput({'#coverArtDate': from}) - : continuation()), - }, - - { - dependencies: ['date'], - compute: (continuation, {date}) => - (date - ? continuation({'#coverArtDate': date}) - : continuation({'#coverArtDate': null})), - }, - ], -}); diff --git a/src/data/composite/wiki-data/withDirectory.js b/src/data/composite/wiki-data/withDirectory.js index f3bedf2e..e7c3960e 100644 --- a/src/data/composite/wiki-data/withDirectory.js +++ b/src/data/composite/wiki-data/withDirectory.js @@ -17,13 +17,13 @@ export default templateCompositeFrom({ inputs: { directory: input({ validate: isDirectory, - defaultDependency: 'directory', + defaultDependency: '_directory', acceptsNull: true, }), name: input({ validate: isName, - defaultDependency: 'name', + defaultDependency: '_name', acceptsNull: true, }), diff --git a/src/data/composite/wiki-data/withRecontextualizedContributionList.js b/src/data/composite/wiki-data/withRecontextualizedContributionList.js index bcc6e486..66ac056a 100644 --- a/src/data/composite/wiki-data/withRecontextualizedContributionList.js +++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js @@ -1,14 +1,15 @@ // Clones all the contributions in a list, with thing and thingProperty both // updated to match the current thing. Overwrites the provided dependency. -// Optionally updates artistProperty as well. Doesn't do anything if -// the provided dependency is null. +// Optionally updates artistProperty, and optionally reclasses as another +// kind of contribution. Does nothing if the provided dependency is null. // // See also: // - withRedatedContributionList // import {input, templateCompositeFrom} from '#composite'; -import {isStringNonEmpty} from '#validators'; +import thingConstructors from '#thing'; +import {isStringNonEmpty, isThingClass} from '#validators'; import {withClonedThings} from '#composite/wiki-data'; @@ -21,6 +22,11 @@ export default templateCompositeFrom({ acceptsNull: true, }), + reclass: input({ + validate: isThingClass, + defaultValue: null, + }), + artistProperty: input({ validate: isStringNonEmpty, defaultValue: null, @@ -77,6 +83,8 @@ export default templateCompositeFrom({ withClonedThings({ things: input('list'), + reclass: input('reclass'), + reclassUnder: input.value(thingConstructors.Contribution), assign: '#assignment', }).outputs({ '#clonedThings': '#newContributions', diff --git a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js index 9cc52f29..71bc56ac 100644 --- a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js @@ -7,6 +7,7 @@ import {withPropertyFromList} from '#composite/data'; import {raiseOutputWithoutDependency, withAvailabilityFilter} from '#composite/control-flow'; +import inputFindOptions from './inputFindOptions.js'; import inputSoupyFind from './inputSoupyFind.js'; import inputNotFoundMode from './inputNotFoundMode.js'; import inputWikiData from './inputWikiData.js'; @@ -22,13 +23,14 @@ export default templateCompositeFrom({ acceptsNull: true, }), + data: inputWikiData({allowMixedTypes: true}), + find: inputSoupyFind(), + findOptions: inputFindOptions(), + reference: input({type: 'string', defaultValue: 'reference'}), annotation: input({type: 'string', defaultValue: 'annotation'}), thing: input({type: 'string', defaultValue: 'thing'}), - data: inputWikiData({allowMixedTypes: true}), - find: inputSoupyFind(), - notFoundMode: inputNotFoundMode(), }, @@ -61,6 +63,7 @@ export default templateCompositeFrom({ list: '#references', data: input('data'), find: input('find'), + findOptions: input('findOptions'), notFoundMode: input.value('null'), }), diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js index 838c991f..3bbe1f81 100644 --- a/src/data/composite/wiki-data/withResolvedContribs.js +++ b/src/data/composite/wiki-data/withResolvedContribs.js @@ -7,7 +7,8 @@ import {input, templateCompositeFrom} from '#composite'; import {filterMultipleArrays, stitchArrays} from '#sugar'; import thingConstructors from '#things'; -import {isContributionList, isDate, isStringNonEmpty} from '#validators'; +import {isContributionList, isDate, isStringNonEmpty, isThingClass} + from '#validators'; import {raiseOutputWithoutDependency, withAvailabilityFilter} from '#composite/control-flow'; @@ -25,9 +26,15 @@ export default templateCompositeFrom({ acceptsNull: true, }), + class: input({ + validate: isThingClass, + defaultValue: null, + }), + date: input({ validate: isDate, acceptsNull: true, + defaultDependency: 'date', }), notFoundMode: inputNotFoundMode(), @@ -75,27 +82,33 @@ export default templateCompositeFrom({ withPropertiesFromList({ list: input('from'), - properties: input.value(['artist', 'annotation']), + properties: input.value(['artist', 'artistText', 'annotation']), prefix: input.value('#contribs'), }), { dependencies: [ '#contribs.artist', + '#contribs.artistText', '#contribs.annotation', input('date'), ], compute(continuation, { ['#contribs.artist']: artist, + ['#contribs.artistText']: artistText, ['#contribs.annotation']: annotation, [input('date')]: date, }) { - filterMultipleArrays(artist, annotation, (artist, _annotation) => artist); + filterMultipleArrays( + artist, + artistText, + annotation, + (artist, _artistText, _annotation) => artist); return continuation({ ['#details']: - stitchArrays({artist, annotation}) + stitchArrays({artist, artistText, annotation}) .map(details => ({ ...details, date: date ?? null, @@ -105,24 +118,37 @@ export default templateCompositeFrom({ }, { + dependencies: [input('class')], + compute: (continuation, { + [input('class')]: classInput, + }) => continuation({ + ['#contributionConstructor']: + classInput ?? + thingConstructors.Contribution, + }), + }, + + { dependencies: [ '#details', '#thingProperty', + '#contributionConstructor', input('artistProperty'), input.myself(), - 'find', + '_find', ], compute: (continuation, { ['#details']: details, ['#thingProperty']: thingProperty, + ['#contributionConstructor']: contributionConstructor, [input('artistProperty')]: artistProperty, [input.myself()]: myself, - ['find']: find, + ['_find']: find, }) => continuation({ ['#contributions']: details.map(details => { - const contrib = new thingConstructors.Contribution(); + const contrib = Reflect.construct(contributionConstructor, []); Object.assign(contrib, { ...details, diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js index 6f422194..58523a65 100644 --- a/src/data/composite/wiki-data/withResolvedReference.js +++ b/src/data/composite/wiki-data/withResolvedReference.js @@ -8,6 +8,7 @@ import {input, templateCompositeFrom} from '#composite'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; import gobbleSoupyFind from './gobbleSoupyFind.js'; +import inputFindOptions from './inputFindOptions.js'; import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; @@ -17,8 +18,9 @@ export default templateCompositeFrom({ inputs: { ref: input({type: 'string', acceptsNull: true}), - data: inputWikiData({allowMixedTypes: false}), + data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), + findOptions: inputFindOptions(), }, outputs: ['#resolvedReference'], @@ -36,21 +38,36 @@ export default templateCompositeFrom({ }), { + dependencies: [input('findOptions'), input.myself()], + compute: (continuation, { + [input('findOptions')]: findOptions, + [input.myself()]: myself, + }) => continuation({ + ['#findOptions']: + (findOptions + ? {...findOptions, mode: 'quiet', from: myself} + : {mode: 'quiet', from: myself}), + }), + }, + + { dependencies: [ input('ref'), input('data'), '#find', + '#findOptions', ], compute: (continuation, { [input('ref')]: ref, [input('data')]: data, ['#find']: findFunction, + ['#findOptions']: findOptions, }) => continuation({ ['#resolvedReference']: (data - ? findFunction(ref, data, {mode: 'quiet'}) ?? null - : findFunction(ref, {mode: 'quiet'}) ?? null), + ? findFunction(ref, data, findOptions) ?? null + : findFunction(ref, findOptions) ?? null), }), }, ], diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js index 9dc960dd..23f3c365 100644 --- a/src/data/composite/wiki-data/withResolvedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedReferenceList.js @@ -11,6 +11,7 @@ import {raiseOutputWithoutDependency, withAvailabilityFilter} import {withMappedList} from '#composite/data'; import gobbleSoupyFind from './gobbleSoupyFind.js'; +import inputFindOptions from './inputFindOptions.js'; import inputNotFoundMode from './inputNotFoundMode.js'; import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; @@ -27,6 +28,7 @@ export default templateCompositeFrom({ data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), + findOptions: inputFindOptions(), notFoundMode: inputNotFoundMode(), }, @@ -47,15 +49,29 @@ export default templateCompositeFrom({ }), { - dependencies: [input('data'), '#find'], + dependencies: [input('findOptions'), input.myself()], + compute: (continuation, { + [input('findOptions')]: findOptions, + [input.myself()]: myself, + }) => continuation({ + ['#findOptions']: + (findOptions + ? {...findOptions, mode: 'quiet', from: myself} + : {mode: 'quiet', from: myself}), + }), + }, + + { + dependencies: [input('data'), '#find', '#findOptions'], compute: (continuation, { [input('data')]: data, ['#find']: findFunction, + ['#findOptions']: findOptions, }) => continuation({ ['#map']: (data - ? ref => findFunction(ref, data, {mode: 'quiet'}) - : ref => findFunction(ref, {mode: 'quiet'})), + ? ref => findFunction(ref, data, findOptions) + : ref => findFunction(ref, findOptions)), }), }, diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js deleted file mode 100644 index deaab466..00000000 --- a/src/data/composite/wiki-data/withResolvedSeriesList.js +++ /dev/null @@ -1,130 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import {stitchArrays} from '#sugar'; -import {isSeriesList, validateThing} from '#validators'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; - -import { - fillMissingListItems, - withFlattenedList, - withUnflattenedList, - withPropertiesFromList, -} from '#composite/data'; - -import inputSoupyFind from './inputSoupyFind.js'; -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', - find: inputSoupyFind.input('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-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 8e6c96a1..918f8567 100644 --- a/src/data/composite/wiki-properties/annotatedReferenceList.js +++ b/src/data/composite/wiki-properties/annotatedReferenceList.js @@ -9,8 +9,13 @@ import { } from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputSoupyFind, inputWikiData, withResolvedAnnotatedReferenceList} - from '#composite/wiki-data'; + +import { + inputFindOptions, + inputSoupyFind, + inputWikiData, + withResolvedAnnotatedReferenceList, +} from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} from './helpers/reference-list-helpers.js'; @@ -25,6 +30,7 @@ export default templateCompositeFrom({ data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), + findOptions: inputFindOptions(), reference: input.staticValue({type: 'string', defaultValue: 'reference'}), annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}), @@ -51,12 +57,13 @@ export default templateCompositeFrom({ withResolvedAnnotatedReferenceList({ list: input.updateValue(), + data: input('data'), + find: input('find'), + findOptions: input('findOptions'), + reference: input('reference'), annotation: input('annotation'), thing: input('thing'), - - data: input('data'), - find: input('find'), }), exposeDependency({dependency: '#resolvedAnnotatedReferenceList'}), diff --git a/src/data/composite/wiki-properties/canonicalBase.js b/src/data/composite/wiki-properties/canonicalBase.js new file mode 100644 index 00000000..81740d6c --- /dev/null +++ b/src/data/composite/wiki-properties/canonicalBase.js @@ -0,0 +1,16 @@ +import {isURL} from '#validators'; + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isURL}, + expose: { + transform: (value) => + (value === null + ? null + : value.endsWith('/') + ? value + : value + '/'), + }, + }; +} diff --git a/src/data/composite/wiki-properties/color.js b/src/data/composite/wiki-properties/color.js index 1bc9888b..e7fe472a 100644 --- a/src/data/composite/wiki-properties/color.js +++ b/src/data/composite/wiki-properties/color.js @@ -1,12 +1,26 @@ // A color! This'll be some CSS-ready value. +import {input, templateCompositeFrom} from '#composite'; import {isColor} from '#validators'; -// TODO: Not templateCompositeFrom. +export default templateCompositeFrom({ + annotation: 'color', -export default function() { - return { - flags: {update: true, expose: true}, - update: {validate: isColor}, - }; -} + compose: false, + + inputs: { + default: input({validate: isColor, defaultValue: null}), + }, + + update: { + validate: isColor, + }, + + steps: () => [ + { + dependencies: [input('default')], + transform: (value, {[input('default')]: defaultValue}) => + value ?? defaultValue, + }, + ], +}); \ No newline at end of file diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js index 54d3e1a5..44dee028 100644 --- a/src/data/composite/wiki-properties/commentatorArtists.js +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -14,10 +14,9 @@ export default templateCompositeFrom({ compose: false, steps: () => [ - exitWithoutDependency({ - dependency: 'commentary', - mode: input.value('falsy'), + exitWithoutDependency('commentary', { value: input.value([]), + mode: input.value('falsy'), }), withPropertyFromList({ diff --git a/src/data/composite/wiki-properties/contribsPresent.js b/src/data/composite/wiki-properties/contribsPresent.js deleted file mode 100644 index 24f302a5..00000000 --- a/src/data/composite/wiki-properties/contribsPresent.js +++ /dev/null @@ -1,30 +0,0 @@ -// Nice 'n simple shorthand for an exposed-only flag which is true when any -// contributions are present in the specified property. - -import {input, templateCompositeFrom} from '#composite'; -import {isContributionList} from '#validators'; - -import {exposeDependency, withResultOfAvailabilityCheck} - from '#composite/control-flow'; - -export default templateCompositeFrom({ - annotation: `contribsPresent`, - - compose: false, - - inputs: { - contribs: input.staticDependency({ - validate: isContributionList, - acceptsNull: true, - }), - }, - - steps: () => [ - withResultOfAvailabilityCheck({ - from: input('contribs'), - mode: input.value('empty'), - }), - - exposeDependency({dependency: '#availability'}), - ], -}); diff --git a/src/data/composite/wiki-properties/contributionList.js b/src/data/composite/wiki-properties/contributionList.js index d9a6b417..76c296a6 100644 --- a/src/data/composite/wiki-properties/contributionList.js +++ b/src/data/composite/wiki-properties/contributionList.js @@ -15,7 +15,8 @@ // import {input, templateCompositeFrom} from '#composite'; -import {isContributionList, isDate, isStringNonEmpty} from '#validators'; +import {isContributionList, isDate, isStringNonEmpty, isThingClass} + from '#validators'; import {exposeConstant, exposeDependencyOrContinue} from '#composite/control-flow'; import {withResolvedContribs} from '#composite/wiki-data'; @@ -26,9 +27,15 @@ export default templateCompositeFrom({ compose: false, inputs: { + class: input({ + defaultValue: null, + validate: isThingClass, + }), + date: input({ validate: isDate, acceptsNull: true, + defaultDependency: 'date', }), artistProperty: input({ @@ -42,9 +49,10 @@ export default templateCompositeFrom({ steps: () => [ withResolvedContribs({ from: input.updateValue(), + class: input('class'), + date: input('date'), thingProperty: input.thisProperty(), artistProperty: input('artistProperty'), - date: input('date'), }), exposeDependencyOrContinue({ diff --git a/src/data/composite/wiki-properties/fileExtension.js b/src/data/composite/wiki-properties/fileExtension.js index c926fa8b..fa933f56 100644 --- a/src/data/composite/wiki-properties/fileExtension.js +++ b/src/data/composite/wiki-properties/fileExtension.js @@ -1,13 +1,26 @@ // A file extension! Or the default, if provided when calling this. +import {input, templateCompositeFrom} from '#composite'; import {isFileExtension} from '#validators'; -// TODO: Not templateCompositeFrom. +export default templateCompositeFrom({ + annotation: 'name', -export default function(defaultFileExtension = null) { - return { - flags: {update: true, expose: true}, - update: {validate: isFileExtension}, - expose: {transform: (value) => value ?? defaultFileExtension}, - }; -} + compose: false, + + inputs: { + default: input({validate: isFileExtension, acceptsNull: true}), + }, + + update: { + validate: isFileExtension, + }, + + steps: () => [ + { + dependencies: [input('default')], + transform: (value, {[input('default')]: defaultValue}) => + value ?? defaultValue, + }, + ], +}); \ No newline at end of file diff --git a/src/data/composite/wiki-properties/flag.js b/src/data/composite/wiki-properties/flag.js index 076e663f..fa787f92 100644 --- a/src/data/composite/wiki-properties/flag.js +++ b/src/data/composite/wiki-properties/flag.js @@ -1,19 +1,27 @@ // Straightforward flag descriptor for a variety of property purposes. // Provide a default value, true or false! +import {input, templateCompositeFrom} from '#composite'; import {isBoolean} from '#validators'; -// TODO: Not templateCompositeFrom. +export default templateCompositeFrom({ + annotation: 'flag', -// TODO: The description is a lie. This defaults to false. Bad. + compose: false, -export default function(defaultValue = false) { - if (typeof defaultValue !== 'boolean') { - throw new TypeError(`Always set explicit defaults for flags!`); - } + inputs: { + default: input({type: 'boolean'}), + }, - return { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: defaultValue}, - }; -} + update: { + validate: isBoolean, + }, + + steps: () => [ + { + dependencies: [input('default')], + transform: (value, {[input('default')]: defaultValue}) => + value ?? defaultValue, + }, + ], +}); \ No newline at end of file diff --git a/src/data/composite/wiki-properties/hasArtwork.js b/src/data/composite/wiki-properties/hasArtwork.js new file mode 100644 index 00000000..e403a7e2 --- /dev/null +++ b/src/data/composite/wiki-properties/hasArtwork.js @@ -0,0 +1,90 @@ +import {input, templateCompositeFrom, V} from '#composite'; +import {isContributionList, isThing, strictArrayOf} from '#validators'; + +import {fillMissingListItems, withFlattenedList, withPropertyFromList} + from '#composite/data'; + +import { + exitWithoutDependency, + exposeWhetherDependencyAvailable, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: 'hasArtwork', + + inputs: { + contribs: input({ + validate: isContributionList, + defaultValue: null, + }), + + artwork: input({ + validate: isThing, + defaultValue: null, + }), + + artworks: input({ + validate: strictArrayOf(isThing), + defaultValue: null, + }), + }, + + compose: false, + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('contribs'), + mode: input.value('empty'), + }), + + { + dependencies: ['#availability'], + compute: (continuation, { + ['#availability']: availability, + }) => + (availability + ? true + : continuation()), + }, + + { + dependencies: [input('artwork'), input('artworks')], + compute: (continuation, { + [input('artwork')]: artwork, + [input('artworks')]: artworks, + }) => + continuation({ + ['#artworks']: + (artwork && artworks + ? [artwork, ...artworks] + : artwork + ? [artwork] + : artworks + ? artworks + : []), + }), + }, + + exitWithoutDependency('#artworks', { + value: input.value(false), + mode: input.value('empty'), + }), + + withPropertyFromList('#artworks', { + 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('#artworks.artistContribs', V([])), + + withFlattenedList('#artworks.artistContribs'), + + exposeWhetherDependencyAvailable({ + dependency: '#flattenedList', + mode: input.value('empty'), + }), + ], +}); \ No newline at end of file diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index d5e7657e..9ef7ccc4 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -3,15 +3,13 @@ // 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 canonicalBase} from './canonicalBase.js'; export {default as color} from './color.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'; export {default as dimensions} from './dimensions.js'; export {default as directory} from './directory.js'; @@ -19,11 +17,11 @@ export {default as duration} from './duration.js'; export {default as externalFunction} from './externalFunction.js'; export {default as fileExtension} from './fileExtension.js'; export {default as flag} from './flag.js'; +export {default as hasArtwork} from './hasArtwork.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 reverseReferenceList} from './reverseReferenceList.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'; diff --git a/src/data/composite/wiki-properties/name.js b/src/data/composite/wiki-properties/name.js index 5146488b..e4a28860 100644 --- a/src/data/composite/wiki-properties/name.js +++ b/src/data/composite/wiki-properties/name.js @@ -1,11 +1,27 @@ // A wiki data object's name! Its directory (i.e. unique identifier) will be // computed based on this value if not otherwise specified. +import {input, templateCompositeFrom} from '#composite'; import {isName} from '#validators'; -export default function(defaultName) { - return { - flags: {update: true, expose: true}, - update: {validate: isName, default: defaultName}, - }; -} +export default templateCompositeFrom({ + annotation: 'name', + + compose: false, + + inputs: { + default: input({type: 'string'}), + }, + + update: { + validate: isName, + }, + + steps: () => [ + { + dependencies: [input('default')], + transform: (value, {[input('default')]: defaultValue}) => + value ?? defaultValue, + }, + ], +}); \ No newline at end of file diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js index 4f8207b5..663349ee 100644 --- a/src/data/composite/wiki-properties/referenceList.js +++ b/src/data/composite/wiki-properties/referenceList.js @@ -11,8 +11,13 @@ import {input, templateCompositeFrom} from '#composite'; import {validateReferenceList} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputSoupyFind, inputWikiData, withResolvedReferenceList} - from '#composite/wiki-data'; + +import { + inputFindOptions, + inputSoupyFind, + inputWikiData, + withResolvedReferenceList, +} from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} from './helpers/reference-list-helpers.js'; @@ -27,6 +32,7 @@ export default templateCompositeFrom({ data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), + findOptions: inputFindOptions(), }, update: @@ -39,6 +45,7 @@ export default templateCompositeFrom({ list: input.updateValue(), data: input('data'), find: input('find'), + findOptions: input('findOptions'), }), exposeDependency({dependency: '#resolvedReferenceList'}), diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js index 9ba2e393..278f063d 100644 --- a/src/data/composite/wiki-properties/referencedArtworkList.js +++ b/src/data/composite/wiki-properties/referencedArtworkList.js @@ -1,6 +1,5 @@ import {input, templateCompositeFrom} from '#composite'; import find from '#find'; -import {isDate} from '#validators'; import annotatedReferenceList from './annotatedReferenceList.js'; @@ -23,7 +22,7 @@ export default templateCompositeFrom({ annotatedReferenceList({ referenceType: input.value(['album', 'track']), - data: 'artworkData', + data: '_artworkData', find: '#find', thing: input.value('artwork'), 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 f532ebbe..25b97907 100644 --- a/src/data/composite/wiki-properties/singleReference.js +++ b/src/data/composite/wiki-properties/singleReference.js @@ -8,11 +8,19 @@ // import {input, templateCompositeFrom} from '#composite'; -import {isThingClass, validateReference} from '#validators'; +import {validateReference} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputSoupyFind, inputWikiData, withResolvedReference} - from '#composite/wiki-data'; + +import { + inputFindOptions, + inputSoupyFind, + inputWikiData, + withResolvedReference, +} from '#composite/wiki-data'; + +import {referenceListInputDescriptions, referenceListUpdateDescription} + from './helpers/reference-list-helpers.js'; export default templateCompositeFrom({ annotation: `singleReference`, @@ -20,25 +28,24 @@ export default templateCompositeFrom({ compose: false, inputs: { - class: input.staticValue({validate: isThingClass}), + ...referenceListInputDescriptions(), + data: inputWikiData({allowMixedTypes: true}), find: inputSoupyFind(), - data: inputWikiData({allowMixedTypes: false}), + findOptions: inputFindOptions(), }, - update: ({ - [input.staticValue('class')]: thingClass, - }) => ({ - validate: - validateReference( - thingClass[Symbol.for('Thing.referenceType')]), - }), + update: + referenceListUpdateDescription({ + validateReferenceList: validateReference, + }), steps: () => [ withResolvedReference({ ref: input.updateValue(), data: input('data'), find: input('find'), + findOptions: input('findOptions'), }), exposeDependency({dependency: '#resolvedReference'}), diff --git a/src/data/files/album.js b/src/data/files/album.js new file mode 100644 index 00000000..84cda226 --- /dev/null +++ b/src/data/files/album.js @@ -0,0 +1,74 @@ +import * as path from 'node:path'; + +import {traverse} from '#node-utils'; +import {sortAlbumsTracksChronologically, sortChronologically} from '#sort'; +import {empty} from '#sugar'; + +export default ({ + documentModes: {headerAndEntries}, + thingConstructors: {Album, Track, TrackSection}, +}) => ({ + title: `Process album files`, + + files: dataPath => + traverse(path.join(dataPath, 'album'), { + filterFile: name => path.extname(name) === '.yaml', + prefixPath: 'album', + }), + + documentMode: headerAndEntries, + headerDocumentThing: Album, + entryDocumentThing: document => + ('Section' in document + ? TrackSection + : Track), + + connect({header: album, entries}) { + const trackSections = []; + + let currentTrackSection = new TrackSection(); + let currentTrackSectionTracks = []; + + Object.assign(currentTrackSection, { + name: `Default Track Section`, + isDefaultTrackSection: true, + }); + + const closeCurrentTrackSection = () => { + if ( + currentTrackSection.isDefaultTrackSection && + empty(currentTrackSectionTracks) + ) { + return; + } + + currentTrackSection.tracks = currentTrackSectionTracks; + currentTrackSection.album = album; + + trackSections.push(currentTrackSection); + }; + + for (const entry of entries) { + if (entry instanceof TrackSection) { + closeCurrentTrackSection(); + currentTrackSection = entry; + currentTrackSectionTracks = []; + continue; + } + + entry.album = album; + entry.trackSection = currentTrackSection; + + currentTrackSectionTracks.push(entry); + } + + closeCurrentTrackSection(); + + album.trackSections = trackSections; + }, + + sort({albumData, trackData}) { + sortChronologically(albumData); + sortAlbumsTracksChronologically(trackData); + }, +}); diff --git a/src/data/files/art-tag.js b/src/data/files/art-tag.js new file mode 100644 index 00000000..67a22ca7 --- /dev/null +++ b/src/data/files/art-tag.js @@ -0,0 +1,32 @@ +import {readFile} from 'node:fs/promises'; +import * as path from 'node:path'; + +import {traverse} from '#node-utils'; +import {sortAlphabetically} from '#sort'; + +export default ({ + documentModes: {allTogether}, + thingConstructors: {ArtTag}, +}) => ({ + title: `Process art tags file`, + + files: dataPath => + Promise.allSettled([ + readFile(path.join(dataPath, 'tags.yaml')) + .then(() => ['tags.yaml']), + + traverse(path.join(dataPath, 'art-tags'), { + filterFile: name => path.extname(name) === '.yaml', + prefixPath: 'art-tags', + }), + ]).then(results => results + .filter(({status}) => status === 'fulfilled') + .flatMap(({value}) => value)), + + documentMode: allTogether, + documentThing: ArtTag, + + sort({artTagData}) { + sortAlphabetically(artTagData); + }, +}); diff --git a/src/data/files/artist.js b/src/data/files/artist.js new file mode 100644 index 00000000..ef971171 --- /dev/null +++ b/src/data/files/artist.js @@ -0,0 +1,16 @@ +import {sortAlphabetically} from '#sort'; + +export default ({ + documentModes: {allInOne}, + thingConstructors: {Artist}, +}) => ({ + title: `Process artists file`, + file: 'artists.yaml', + + documentMode: allInOne, + documentThing: Artist, + + sort({artistData}) { + sortAlphabetically(artistData); + }, +}); diff --git a/src/data/files/flash.js b/src/data/files/flash.js new file mode 100644 index 00000000..3e4f750f --- /dev/null +++ b/src/data/files/flash.js @@ -0,0 +1,78 @@ +import {sortFlashesChronologically} from '#sort'; + +export default ({ + documentModes: {allInOne}, + thingConstructors: {Flash, FlashAct, FlashSide}, +}) => ({ + title: `Process flashes file`, + file: 'flashes.yaml', + + documentMode: allInOne, + documentThing: document => + ('Side' in document + ? FlashSide + : 'Act' in document + ? FlashAct + : Flash), + + connect(results) { + let thing, i; + + for (i = 0; thing = results[i]; i++) { + if (thing.isFlashSide) { + const side = thing; + const acts = []; + + for (i++; thing = results[i]; i++) { + if (thing.isFlashAct) { + const act = thing; + const flashes = []; + + for (i++; thing = results[i]; i++) { + if (thing.isFlash) { + const flash = thing; + + flash.act = act; + flashes.push(flash); + + continue; + } + + i--; + break; + } + + act.side = side; + act.flashes = flashes; + acts.push(act); + + continue; + } + + if (thing.isFlash) { + throw new Error(`Flashes must be under an act`); + } + + i--; + break; + } + + side.acts = acts; + + continue; + } + + if (thing.isFlashAct) { + throw new Error(`Acts must be under a side`); + } + + if (thing.isFlash) { + throw new Error(`Flashes must be under a side and act`); + } + } + }, + + sort({flashData}) { + sortFlashesChronologically(flashData); + }, +}); diff --git a/src/data/files/group.js b/src/data/files/group.js new file mode 100644 index 00000000..c10cbf98 --- /dev/null +++ b/src/data/files/group.js @@ -0,0 +1,45 @@ +import Thing from '#thing'; + +export default ({ + documentModes: {allInOne}, + thingConstructors: {Group, GroupCategory}, +}) => ({ + title: `Process groups file`, + file: 'groups.yaml', + + documentMode: allInOne, + documentThing: document => + ('Category' in document + ? GroupCategory + : Group), + + connect(results) { + let groupCategory; + let groupRefs = []; + + if (results[0] && !(results[0] instanceof GroupCategory)) { + throw new Error(`Expected a category at top of group data file`); + } + + for (const thing of results) { + if (thing instanceof GroupCategory) { + if (groupCategory) { + Object.assign(groupCategory, {groups: groupRefs}); + } + + groupCategory = thing; + groupRefs = []; + } else { + groupRefs.push(Thing.getReference(thing)); + } + } + + if (groupCategory) { + Object.assign(groupCategory, {groups: groupRefs}); + } + }, + + // Groups aren't sorted at all, always preserving the order in the data + // file as-is. + sort: null, +}); diff --git a/src/data/files/homepage-layout.js b/src/data/files/homepage-layout.js new file mode 100644 index 00000000..646beff6 --- /dev/null +++ b/src/data/files/homepage-layout.js @@ -0,0 +1,87 @@ +import {empty} from '#sugar'; + +export default ({ + documentModes: {allInOne}, + thingConstructors: { + HomepageLayout, + HomepageLayoutActionsRow, + HomepageLayoutAlbumCarouselRow, + HomepageLayoutAlbumGridRow, + HomepageLayoutRow, + HomepageLayoutSection, + }, +}) => ({ + title: `Process homepage layout file`, + file: 'homepage.yaml', + + 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; + }, + + connect(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; + }, +}); diff --git a/src/data/files/index.js b/src/data/files/index.js new file mode 100644 index 00000000..f3efebad --- /dev/null +++ b/src/data/files/index.js @@ -0,0 +1,10 @@ +export {default as getAlbumLoadingSpec} from './album.js'; +export {default as getArtistLoadingSpec} from './artist.js'; +export {default as getArtTagLoadingSpec} from './art-tag.js'; +export {default as getFlashLoadingSpec} from './flash.js'; +export {default as getGroupLoadingSpec} from './group.js'; +export {default as getHomepageLayoutLoadingSpec} from './homepage-layout.js'; +export {default as getNewsLoadingSpec} from './news.js'; +export {default as getSortingRuleLoadingSpec} from './sorting-rule.js'; +export {default as getStaticPageLoadingSpec} from './static-page.js'; +export {default as getWikiInfoLoadingSpec} from './wiki-info.js'; diff --git a/src/data/files/news.js b/src/data/files/news.js new file mode 100644 index 00000000..5b4a3029 --- /dev/null +++ b/src/data/files/news.js @@ -0,0 +1,16 @@ +import {sortChronologically} from '#sort'; + +export default ({ + documentModes: {allInOne}, + thingConstructors: {NewsEntry}, +}) => ({ + title: `Process news data file`, + file: 'news.yaml', + + documentMode: allInOne, + documentThing: NewsEntry, + + sort({newsData}) { + sortChronologically(newsData, {latestFirst: true}); + }, +}); diff --git a/src/data/files/sorting-rule.js b/src/data/files/sorting-rule.js new file mode 100644 index 00000000..61e1df23 --- /dev/null +++ b/src/data/files/sorting-rule.js @@ -0,0 +1,13 @@ +export default ({ + documentModes: {allInOne}, + thingConstructors: {DocumentSortingRule}, +}) => ({ + title: `Process sorting rules file`, + file: 'sorting-rules.yaml', + + documentMode: allInOne, + documentThing: document => + (document['Sort Documents'] + ? DocumentSortingRule + : null), +}); diff --git a/src/data/files/static-page.js b/src/data/files/static-page.js new file mode 100644 index 00000000..c7622bc8 --- /dev/null +++ b/src/data/files/static-page.js @@ -0,0 +1,24 @@ +import * as path from 'node:path'; + +import {traverse} from '#node-utils'; +import {sortAlphabetically} from '#sort'; + +export default ({ + documentModes: {onePerFile}, + thingConstructors: {StaticPage}, +}) => ({ + title: `Process static page files`, + + files: dataPath => + traverse(path.join(dataPath, 'static-page'), { + filterFile: name => path.extname(name) === '.yaml', + prefixPath: 'static-page', + }), + + documentMode: onePerFile, + documentThing: StaticPage, + + sort({staticPageData}) { + sortAlphabetically(staticPageData); + }, +}); diff --git a/src/data/files/wiki-info.js b/src/data/files/wiki-info.js new file mode 100644 index 00000000..a466ab0b --- /dev/null +++ b/src/data/files/wiki-info.js @@ -0,0 +1,10 @@ +export default ({ + documentModes: {oneDocumentTotal}, + thingConstructors: {WikiInfo}, +}) => ({ + title: `Process wiki info file`, + file: 'wiki-info.yaml', + + documentMode: oneDocumentTotal, + documentThing: WikiInfo, +}); diff --git a/src/data/language.js b/src/data/language.js index 3edf7e51..e97267c0 100644 --- a/src/data/language.js +++ b/src/data/language.js @@ -4,12 +4,10 @@ import path from 'node:path'; import {fileURLToPath} from 'node:url'; import chokidar from 'chokidar'; -import he from 'he'; // It stands for "HTML Entities", apparently. Cursed. import yaml from 'js-yaml'; import {annotateError, annotateErrorWithFile, showAggregate, withAggregate} from '#aggregate'; -import {externalLinkSpec} from '#external-links'; import {colors, logWarn} from '#cli'; import {empty, splitKeys, withEntries} from '#sugar'; import T from '#things'; @@ -248,19 +246,8 @@ async function processLanguageSpecFromFile(file, processLanguageSpecOpts) { } } -export function initializeLanguageObject() { - const language = new Language(); - - language.escapeHTML = string => - he.encode(string, {useNamedReferences: true}); - - language.externalLinkSpec = externalLinkSpec; - - return language; -} - export async function processLanguageFile(file) { - const language = initializeLanguageObject(); + const language = new Language() const properties = await processLanguageSpecFromFile(file); return Object.assign(language, properties); } @@ -271,7 +258,7 @@ export function watchLanguageFile(file, { const basename = path.basename(file); const events = new EventEmitter(); - const language = initializeLanguageObject(); + const language = new Language(); let emittedReady = false; let successfullyAppliedLanguage = false; diff --git a/src/data/thing.js b/src/data/thing.js index 66f73de5..0a6e3be4 100644 --- a/src/data/thing.js +++ b/src/data/thing.js @@ -10,6 +10,10 @@ export default class Thing extends CacheableObject { static referenceType = Symbol.for('Thing.referenceType'); static friendlyName = Symbol.for('Thing.friendlyName'); + static wikiData = Symbol.for('Thing.wikiData'); + static oneInstancePerWiki = Symbol.for('Thing.oneThingPerWiki'); + static constitutibleProperties = Symbol.for('Thing.constitutibleProperties'); + static getPropertyDescriptors = Symbol.for('Thing.getPropertyDescriptors'); static getSerializeDescriptors = Symbol.for('Thing.getSerializeDescriptors'); @@ -19,7 +23,6 @@ export default class Thing extends CacheableObject { 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'); @@ -60,7 +63,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`); } @@ -69,7 +72,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`); } @@ -78,13 +81,47 @@ export default class Thing extends CacheableObject { (reference ? ` (${reference})` : '')); } + static clone(source, {as = null} = {}) { + if (!(source instanceof this)) { + throw new TypeError( + `Passed thing is ${source.constructor.name}, ` + + `which is not a subclass of ${this.name}`); + } + + if (as && !(as.prototype instanceof this)) { + throw new TypeError( + `Passed constructor is ${as.name}, ` + + `which is not a subclass of ${this.name}`); + } + + let clone; + + if (as) { + clone = Reflect.construct(as, []); + } else { + clone = Reflect.construct(source.constructor, []); + } + + CacheableObject.copyUpdateValuesOnto(source, clone); + + return clone; + } + static getReference(thing) { if (!thing.constructor[Thing.referenceType]) { - throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`); + throw TypeError( + `Passed Thing is ${thing.constructor.name}, ` + + `which provides no [Thing.referenceType]`); } if (!thing.directory) { - throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); + if (thing.name) { + throw TypeError( + `Passed ${thing.constructor.name} (named ${inspect(thing.name)}) ` + + `is missing its directory`); + } else { + throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); + } } return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; diff --git a/src/data/things/AdditionalFile.js b/src/data/things/AdditionalFile.js new file mode 100644 index 00000000..e3f309a6 --- /dev/null +++ b/src/data/things/AdditionalFile.js @@ -0,0 +1,56 @@ +import {input} from '#composite'; +import Thing from '#thing'; +import {isString, validateArrayItems} from '#validators'; + +import {exposeConstant, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {contentString, simpleString, thing} from '#composite/wiki-properties'; + +export class AdditionalFile extends Thing { + static [Thing.friendlyName] = `Additional File`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + title: simpleString(), + + description: contentString(), + + filenames: [ + exposeUpdateValueOrContinue({ + validate: input.value(validateArrayItems(isString)), + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + // Expose only + + isAdditionalFile: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + 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/AdditionalName.js b/src/data/things/AdditionalName.js new file mode 100644 index 00000000..5863eeaa --- /dev/null +++ b/src/data/things/AdditionalName.js @@ -0,0 +1,33 @@ +import {input} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; +import {contentString, thing} from '#composite/wiki-properties'; + +export class AdditionalName extends Thing { + static [Thing.friendlyName] = `Additional Name`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + name: contentString(), + annotation: contentString(), + + // Expose only + + isAdditionalName: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + 'Annotation': {property: 'annotation'}, + }, + }; +} diff --git a/src/data/things/art-tag.js b/src/data/things/ArtTag.js index 57e156ee..9d35f54d 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/ArtTag.js @@ -1,18 +1,16 @@ -export const ART_TAG_DATA_FILE = 'tags.yaml'; - -import {input} from '#composite'; -import find from '#find'; -import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort'; +import {input, V} from '#composite'; import Thing from '#thing'; import {unique} from '#sugar'; import {isName} from '#validators'; import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml'; -import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} - from '#composite/control-flow'; +import { + exitWithoutDependency, + exposeConstant, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; import { - additionalNameList, annotatedReferenceList, color, contentString, @@ -23,24 +21,22 @@ import { name, soupyFind, soupyReverse, + thingList, urls, - wikiData, } 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.wikiData] = 'artTagData'; - static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({ // Update & expose - name: name('Unnamed Art Tag'), + name: name(V('Unnamed Art Tag')), directory: directory(), color: color(), - isContentWarning: flag(false), + isContentWarning: flag(V(false)), extraReadingURLs: urls(), nameShort: [ @@ -55,7 +51,7 @@ export class ArtTag extends Thing { }, ], - additionalNames: additionalNameList(), + additionalNames: thingList(V(AdditionalName)), description: contentString(), @@ -79,9 +75,11 @@ export class ArtTag extends Thing { // Expose only + isArtTag: exposeConstant(V(true)), + descriptionShort: [ - exitWithoutDependency({ - dependency: 'description', + exitWithoutDependency('description', { + value: input.value(null), mode: input.value('falsy'), }), @@ -97,29 +95,51 @@ export class ArtTag extends Thing { }), indirectlyFeaturedInArtworks: [ - withAllDescendantArtTags(), - { - dependencies: ['#allDescendantArtTags'], - compute: ({'#allDescendantArtTags': allDescendantArtTags}) => + dependencies: ['allDescendantArtTags'], + compute: ({allDescendantArtTags}) => unique( allDescendantArtTags .flatMap(artTag => artTag.directlyFeaturedInArtworks)), }, ], + // All the art tags which descend from this one - that means its own direct + // descendants, plus all the direct and indirect descendants 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). allDescendantArtTags: [ - withAllDescendantArtTags(), - exposeDependency({dependency: '#allDescendantArtTags'}), + { + dependencies: ['directDescendantArtTags'], + compute: ({directDescendantArtTags}) => + unique([ + ...directDescendantArtTags, + ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags), + ]), + }, ], directAncestorArtTags: reverseReferenceList({ reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'), }), + // 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). ancestorArtTagBaobabTree: [ - withAncestorArtTagBaobabTree(), - exposeDependency({dependency: '#ancestorArtTagBaobabTree'}), + { + dependencies: ['directAncestorArtTags'], + compute: ({directAncestorArtTags}) => + new Map( + directAncestorArtTags + .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])), + }, ], }); @@ -172,21 +192,4 @@ export class ArtTag extends Thing { }, }, }; - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allInOne}, - thingConstructors: {ArtTag}, - }) => ({ - title: `Process art tags file`, - file: ART_TAG_DATA_FILE, - - documentMode: allInOne, - documentThing: ArtTag, - - save: (results) => ({artTagData: results}), - - sort({artTagData}) { - sortAlphabetically(artTagData); - }, - }); } diff --git a/src/data/things/artist.js b/src/data/things/Artist.js index 9e329c74..f518e31e 100644 --- a/src/data/things/artist.js +++ b/src/data/things/Artist.js @@ -1,18 +1,21 @@ -export const ARTIST_DATA_FILE = 'artists.yaml'; - import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; -import {input} from '#composite'; -import {sortAlphabetically} from '#sort'; -import {stitchArrays} from '#sugar'; +import {input, V} from '#composite'; import Thing from '#thing'; -import {isName, validateArrayItems} from '#validators'; -import {getKebabCase} from '#wiki-data'; -import {parseArtwork} from '#yaml'; +import {parseArtistAliases, parseArtwork} from '#yaml'; + +import { + sortAlbumsTracksChronologically, + sortArtworksChronologically, + sortContributionsChronologically, +} from '#sort'; -import {exitWithoutDependency} from '#composite/control-flow'; +import {exitWithoutDependency, exposeConstant, exposeDependency} + from '#composite/control-flow'; +import {withFilteredList, withPropertyFromList} from '#composite/data'; +import {withContributionListSums} from '#composite/wiki-data'; import { constitutibleArtwork, @@ -22,53 +25,46 @@ import { flag, name, reverseReferenceList, - singleReference, soupyFind, soupyReverse, + thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; -import {artistTotalDuration} from '#composite/things/artist'; - export class Artist extends Thing { static [Thing.referenceType] = 'artist'; - static [Thing.wikiDataArray] = 'artistData'; + static [Thing.wikiData] = 'artistData'; + + static [Thing.constitutibleProperties] = [ + 'avatarArtwork', // from inline fields + ]; - static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({Contribution}) => ({ // Update & expose - name: name('Unnamed Artist'), + name: name(V('Unnamed Artist')), directory: directory(), urls: urls(), contextNotes: contentString(), - hasAvatar: flag(false), - avatarFileExtension: fileExtension('jpg'), + hasAvatar: flag(V(false)), + avatarFileExtension: fileExtension(V('jpg')), avatarArtwork: [ - exitWithoutDependency({ - dependency: 'hasAvatar', + exitWithoutDependency('hasAvatar', { value: input.value(null), + mode: input.value('falsy'), }), constitutibleArtwork.fromYAMLFieldSpec .call(this, 'Avatar Artwork'), ], - aliasNames: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isName)}, - expose: {transform: (names) => names ?? []}, - }, - - isAlias: flag(), - - aliasedArtist: singleReference({ - class: input.value(Artist), - find: soupyFind.input('artist'), - }), + isAlias: flag(V(false)), + artistAliases: thingList(V(Artist)), + aliasedArtist: thing(V(Artist)), // Update only @@ -77,6 +73,29 @@ export class Artist extends Thing { // Expose only + isArtist: exposeConstant(V(true)), + + mockSimpleContribution: { + flags: {expose: true}, + expose: { + dependencies: ['directory', '_find'], + compute: ({directory, _find: find}) => + Object.assign(new Contribution, { + artist: 'artist:' + directory, + + // These nulls have no effect, they're only included + // here for clarity. + date: null, + thing: null, + annotation: null, + artistProperty: null, + thingProperty: null, + + find, + }), + }, + }, + trackArtistContributions: reverseReferenceList({ reverse: soupyReverse.input('trackArtistContributionsBy'), }), @@ -97,6 +116,10 @@ export class Artist extends Thing { reverse: soupyReverse.input('albumArtistContributionsBy'), }), + albumTrackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumTrackArtistContributionsBy'), + }), + albumCoverArtistContributions: reverseReferenceList({ reverse: soupyReverse.input('albumCoverArtistContributionsBy'), }), @@ -125,7 +148,84 @@ export class Artist extends Thing { reverse: soupyReverse.input('groupsCloselyLinkedTo'), }), - totalDuration: artistTotalDuration(), + musicContributions: [ + { + dependencies: [ + 'trackArtistContributions', + 'trackContributorContributions', + ], + + compute: (continuation, { + trackArtistContributions, + trackContributorContributions, + }) => continuation({ + ['#contributions']: [ + ...trackArtistContributions, + ...trackContributorContributions, + ], + }), + }, + + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortAlbumsTracksChronologically), + }, + ], + + artworkContributions: [ + { + dependencies: [ + 'trackCoverArtistContributions', + 'albumCoverArtistContributions', + 'albumWallpaperArtistContributions', + 'albumBannerArtistContributions', + ], + + compute: (continuation, { + trackCoverArtistContributions, + albumCoverArtistContributions, + albumWallpaperArtistContributions, + albumBannerArtistContributions, + }) => continuation({ + ['#contributions']: [ + ...trackCoverArtistContributions, + ...albumCoverArtistContributions, + ...albumWallpaperArtistContributions, + ...albumBannerArtistContributions, + ], + }), + }, + + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortArtworksChronologically), + }, + ], + + musicVideoArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('musicVideoArtistContributionsBy'), + }), + + musicVideoContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('musicVideoContributorContributionsBy'), + }), + + totalDuration: [ + withPropertyFromList('musicContributions', V('thing')), + withPropertyFromList('#musicContributions.thing', V('isMainRelease')), + + withFilteredList('musicContributions', '#musicContributions.thing.isMainRelease') + .outputs({'#filteredList': '#mainReleaseContributions'}), + + withContributionListSums('#mainReleaseContributions'), + exposeDependency('#contributionListDuration'), + ], }); static [Thing.getSerializeDescriptors] = ({ @@ -139,8 +239,6 @@ export class Artist extends Thing { hasAvatar: S.id, avatarFileExtension: S.id, - aliasNames: S.id, - tracksAsCommentator: S.toRefs, albumsAsCommentator: S.toRefs, }); @@ -171,17 +269,9 @@ export class Artist extends Thing { // in the original's alias list. This is honestly a bit awkward, but it // avoids artist aliases conflicting with each other when checking for // duplicate directories. - for (const aliasName of originalArtist.aliasNames) { - // These are trouble. We should be accessing aliases' directories - // directly, but artists currently don't expose a reverse reference - // list for aliases. (This is pending a cleanup of "reverse reference" - // behavior in general.) It doesn't actually cause problems *here* - // because alias directories are computed from their names 100% of the - // time, but that *is* an assumption this code makes. - if (aliasName === artist.name) continue; - if (artist.directory === getKebabCase(aliasName)) { - return []; - } + for (const alias of originalArtist.artistAliases) { + if (alias === artist) break; + if (alias.directory === artist.directory) return []; } // And, aliases never return just a blank string. This part is pretty @@ -221,7 +311,10 @@ export class Artist extends Thing { 'Has Avatar': {property: 'hasAvatar'}, 'Avatar File Extension': {property: 'avatarFileExtension'}, - 'Aliases': {property: 'aliasNames'}, + 'Aliases': { + property: 'artistAliases', + transform: parseArtistAliases, + }, 'Dead URLs': {ignore: true}, @@ -229,53 +322,6 @@ export class Artist extends Thing { }, }; - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allInOne}, - thingConstructors: {Artist}, - }) => ({ - title: `Process artists file`, - file: ARTIST_DATA_FILE, - - documentMode: allInOne, - documentThing: Artist, - - save(results) { - const artists = results; - - const artistRefs = - artists.map(artist => Thing.getReference(artist)); - - const artistAliasNames = - artists.map(artist => artist.aliasNames); - - const artistAliases = - stitchArrays({ - originalArtistRef: artistRefs, - aliasNames: artistAliasNames, - }).flatMap(({originalArtistRef, aliasNames}) => - aliasNames.map(name => { - const alias = new Artist(); - alias.name = name; - alias.isAlias = true; - alias.aliasedArtist = originalArtistRef; - return alias; - })); - - const artistData = [...artists, ...artistAliases]; - - const artworkData = - artistData - .filter(artist => artist.hasAvatar) - .map(artist => artist.avatarArtwork); - - return {artistData, artworkData}; - }, - - sort({artistData}) { - sortAlphabetically(artistData); - }, - }); - [inspect.custom]() { const parts = []; @@ -287,7 +333,7 @@ export class Artist extends Thing { let aliasedArtist; try { aliasedArtist = this.aliasedArtist.name; - } catch (_error) { + } catch { aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); } diff --git a/src/data/things/artwork.js b/src/data/things/Artwork.js index 3cdb07d0..7beb3567 100644 --- a/src/data/things/artwork.js +++ b/src/data/things/Artwork.js @@ -1,6 +1,7 @@ import {inspect} from 'node:util'; -import {input} from '#composite'; +import {colors} from '#cli'; +import {input, V} from '#composite'; import find from '#find'; import Thing from '#thing'; @@ -24,17 +25,25 @@ import { parseDimensions, } from '#yaml'; -import {withIndexInList, withPropertyFromObject} from '#composite/data'; - import { exitWithoutDependency, exposeConstant, exposeDependency, exposeDependencyOrContinue, exposeUpdateValueOrContinue, + flipFilter, } from '#composite/control-flow'; import { + withFilteredList, + withNearbyItemFromList, + withPropertyFromList, + withPropertyFromObject, +} from '#composite/data'; + +import { + constituteFrom, + constituteOrContinue, withRecontextualizedContributionList, withResolvedAnnotatedReferenceList, withResolvedContribs, @@ -53,20 +62,20 @@ import { wikiData, } from '#composite/wiki-properties'; -import { - withAttachedArtwork, - withContainingArtworkList, - withContribsFromAttachedArtwork, - withPropertyFromAttachedArtwork, - withDate, -} from '#composite/things/artwork'; +import {withContainingArtworkList} from '#composite/things/artwork'; export class Artwork extends Thing { static [Thing.referenceType] = 'artwork'; + static [Thing.wikiData] = 'artworkData'; + + static [Thing.constitutibleProperties] = [ + // Contributions currently aren't being observed for constitution. + // 'artistContribs', // from attached artwork or thing + ]; static [Thing.getPropertyDescriptors] = ({ ArtTag, - Contribution, + ArtworkArtistContribution, }) => ({ // Update & expose @@ -79,51 +88,28 @@ export class Artwork extends Thing { label: simpleString(), source: contentString(), + originDetails: contentString(), + showFilename: simpleString(), dateFromThingProperty: simpleString(), date: [ - withDate({ - from: input.updateValue({validate: isDate}), + exposeUpdateValueOrContinue({ + validate: input.value(isDate), }), - exposeDependency({dependency: '#date'}), + constituteFrom('thing', 'dateFromThingProperty'), ], 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', + constituteFrom('thing', 'fileExtensionFromThingProperty', { + else: input.value('jpg'), }), ], @@ -134,78 +120,46 @@ export class Artwork extends Thing { validate: input.value(isDimensions), }), - exitWithoutDependency({ - dependency: 'artistContribsFromThingProperty', - 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), - }), + constituteFrom('thing', 'dimensionsFromThingProperty'), ], - attachAbove: flag(false), + attachAbove: flag(V(false)), artistContribsFromThingProperty: simpleString(), artistContribsArtistProperty: simpleString(), artistContribs: [ - withDate(), - withResolvedContribs({ from: input.updateValue({validate: isContributionList}), - date: '#date', - artistProperty: 'artistContribsArtistProperty', - }), - exposeDependencyOrContinue({ - dependency: '#resolvedContribs', - mode: input.value('empty'), + // XXX: All artwork artist contributions, as resolved from update value + // (*not* those constituted from thing), are generic artwork contribs. + // The class should be specified by whatever the artwork is placed on!! + class: input.value(ArtworkArtistContribution), + + date: 'date', + thingProperty: input.thisProperty(), + artistProperty: 'artistContribsArtistProperty', }), - withContribsFromAttachedArtwork(), + exposeDependencyOrContinue('#resolvedContribs', V('empty')), - exposeDependencyOrContinue({ - dependency: '#attachedArtwork.artistContribs', - }), + withPropertyFromObject('attachedArtwork', V('artistContribs')), - exitWithoutDependency({ - dependency: 'artistContribsFromThingProperty', - value: input.value([]), - }), + withRecontextualizedContributionList('#attachedArtwork.artistContribs'), + exposeDependencyOrContinue('#attachedArtwork.artistContribs'), - withPropertyFromObject({ - object: 'thing', - property: 'artistContribsFromThingProperty', - }).outputs({ - ['#value']: '#artistContribs', - }), + exitWithoutDependency('artistContribsFromThingProperty', V([])), - withRecontextualizedContributionList({ - list: '#artistContribs', - }), + withPropertyFromObject('thing', 'artistContribsFromThingProperty') + .outputs({'#value': '#artistContribsFromThing'}), - exposeDependency({ - dependency: '#artistContribs', - }), + withRecontextualizedContributionList('#artistContribsFromThing'), + exposeDependency('#artistContribsFromThing'), ], + style: simpleString(), + artTagsFromThingProperty: simpleString(), artTags: [ @@ -214,42 +168,14 @@ export class Artwork extends Thing { validate: validateReferenceList(ArtTag[Thing.referenceType]), }), - find: soupyFind.input('artTag'), }), - exposeDependencyOrContinue({ - dependency: '#resolvedReferenceList', - mode: input.value('empty'), - }), + exposeDependencyOrContinue('#resolvedReferenceList', V('empty')), - withPropertyFromAttachedArtwork({ - property: input.value('artTags'), - }), + constituteOrContinue('attachedArtwork', V('artTags'), V('empty')), - 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([]), - }), + constituteFrom('thing', 'artTagsFromThingProperty', V([])), ], referencedArtworksFromThingProperty: simpleString(), @@ -279,35 +205,16 @@ export class Artwork extends Thing { })), }), - data: 'artworkData', + data: '_artworkData', find: '#find', thing: input.value('artwork'), }), - exposeDependencyOrContinue({ - dependency: '#resolvedAnnotatedReferenceList', - mode: input.value('empty'), - }), - - exitWithoutDependency({ - dependency: 'referencedArtworksFromThingProperty', - value: input.value([]), - }), + exposeDependencyOrContinue('#resolvedAnnotatedReferenceList', V('empty')), - withPropertyFromObject({ - object: 'thing', - property: 'referencedArtworksFromThingProperty', - }).outputs({ - ['#value']: '#referencedArtworks', - }), - - exposeDependencyOrContinue({ - dependency: '#referencedArtworks', - }), - - exposeConstant({ - value: input.value([]), + constituteFrom('thing', 'referencedArtworksFromThingProperty', { + else: input.value([]), }), ], @@ -317,23 +224,19 @@ export class Artwork extends Thing { reverse: soupyReverse(), // used for referencedArtworks (mixedFind) - artworkData: wikiData({ - class: input.value(Artwork), - }), + artworkData: wikiData(V(Artwork)), // Expose only + isArtwork: exposeConstant(V(true)), + referencedByArtworks: reverseReferenceList({ reverse: soupyReverse.input('artworksWhichReference'), }), isMainArtwork: [ withContainingArtworkList(), - - exitWithoutDependency({ - dependency: '#containingArtworkList', - value: input.value(null), - }), + exitWithoutDependency('#containingArtworkList'), { dependencies: [input.myself(), '#containingArtworkList'], @@ -347,11 +250,7 @@ export class Artwork extends Thing { mainArtwork: [ withContainingArtworkList(), - - exitWithoutDependency({ - dependency: '#containingArtworkList', - value: input.value(null), - }), + exitWithoutDependency('#containingArtworkList'), { dependencies: ['#containingArtworkList'], @@ -361,16 +260,50 @@ export class Artwork extends Thing { ], attachedArtwork: [ - withAttachedArtwork(), + exitWithoutDependency('attachAbove', { + value: input.value(null), + mode: input.value('falsy'), + }), + + withContainingArtworkList(), + + withPropertyFromList('#containingArtworkList', V('attachAbove')), + + flipFilter('#containingArtworkList.attachAbove') + .outputs({'#containingArtworkList.attachAbove': '#filterNotAttached'}), - exposeDependency({ - dependency: '#attachedArtwork', + withNearbyItemFromList({ + list: '#containingArtworkList', + item: input.myself(), + offset: input.value(-1), + filter: '#filterNotAttached', }), + + exposeDependency('#nearbyItem'), ], attachingArtworks: reverseReferenceList({ reverse: soupyReverse.input('artworksWhichAttach'), }), + + groups: [ + withPropertyFromObject('thing', V('groups')), + exposeDependencyOrContinue('#thing.groups'), + + exposeConstant(V([])), + ], + + contentWarningArtTags: [ + withPropertyFromList('artTags', V('isContentWarning')), + withFilteredList('artTags', '#artTags.isContentWarning'), + exposeDependency('#filteredList'), + ], + + contentWarnings: [ + withPropertyFromList('contentWarningArtTags', V('name')), + exposeDependency('#contentWarningArtTags.name'), + ], + }); static [Thing.yamlDocumentSpec] = { @@ -385,6 +318,8 @@ export class Artwork extends Thing { 'Label': {property: 'label'}, 'Source': {property: 'source'}, + 'Origin Details': {property: 'originDetails'}, + 'Show Filename': {property: 'showFilename'}, 'Date': { property: 'date', @@ -398,6 +333,8 @@ export class Artwork extends Thing { transform: parseContributors, }, + 'Style': {property: 'style'}, + 'Tags': {property: 'artTags'}, 'Referenced Artworks': { @@ -456,6 +393,18 @@ export class Artwork extends Thing { 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 = []; diff --git a/src/data/things/language.js b/src/data/things/Language.js index a3f861bd..48ba0659 100644 --- a/src/data/things/language.js +++ b/src/data/things/Language.js @@ -1,24 +1,26 @@ -import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; +import {Temporal, toTemporalInstant} from '@js-temporal/polyfill'; import {withAggregate} from '#aggregate'; -import CacheableObject from '#cacheable-object'; import {logWarn} from '#cli'; +import {input, V} from '#composite'; import * as html from '#html'; -import {empty} from '#sugar'; -import {isLanguageCode} from '#validators'; +import {accumulateSum, empty, withEntries} from '#sugar'; +import {isLanguageCode, isObject} from '#validators'; import Thing from '#thing'; import { + externalLinkSpec, getExternalLinkStringOfStyleFromDescriptors, getExternalLinkStringsFromDescriptors, isExternalLinkContext, - isExternalLinkSpec, isExternalLinkStyle, } from '#external-links'; -import {externalFunction, flag, name} from '#composite/wiki-properties'; +import {exitWithoutDependency, exposeConstant} + from '#composite/control-flow'; +import {flag, name} from '#composite/wiki-properties'; -export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g; +const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g; export class Language extends Thing { static [Thing.getPropertyDescriptors] = () => ({ @@ -34,7 +36,7 @@ export class Language extends Thing { // Human-readable name. This should be the language's own native name, not // localized to any other language. - name: name(`Unnamed Language`), + name: name(V(`Unnamed Language`)), // Language code specific to JavaScript's Internationalization (Intl) API. // Usually this will be the same as the language's general code, but it @@ -56,20 +58,29 @@ export class Language extends Thing { // with languages that are currently in development and not ready for // formal release, or which are just kept hidden as "experimental zones" // for wiki development or content testing. - hidden: flag(false), + hidden: flag(V(false)), // Mapping of translation keys to values (strings). Generally, don't // access this object directly - use methods instead. - strings: { - flags: {update: true, expose: true}, - update: {validate: (t) => typeof t === 'object'}, + strings: [ + { + dependencies: [ + input.updateValue({validate: isObject}), + 'inheritedStrings', + ], + + compute: (continuation, { + [input.updateValue()]: strings, + ['inheritedStrings']: inheritedStrings, + }) => + (strings && inheritedStrings + ? continuation() + : strings ?? inheritedStrings), + }, - expose: { + { dependencies: ['inheritedStrings', 'code'], transform(strings, {inheritedStrings, code}) { - if (!strings && !inheritedStrings) return null; - if (!inheritedStrings) return strings; - const validStrings = { ...inheritedStrings, ...strings, @@ -98,6 +109,7 @@ export class Language extends Thing { logWarn`- Missing options: ${missingOptionNames.join(', ')}`; if (!empty(misplacedOptionNames)) logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`; + validStrings[key] = inheritedStrings[key]; } } @@ -105,7 +117,7 @@ export class Language extends Thing { return validStrings; }, }, - }, + ], // May be provided to specify "default" strings, generally (but not // necessarily) inherited from another Language object. @@ -114,33 +126,22 @@ export class Language extends Thing { update: {validate: (t) => typeof t === 'object'}, }, - // List of descriptors for providing to external link utilities when using - // language.formatExternalLink - refer to #external-links for info. - externalLinkSpec: { - flags: {update: true, expose: true}, - update: {validate: isExternalLinkSpec}, - }, - - // Update only - - escapeHTML: externalFunction(), - // Expose only - onlyIfOptions: { - flags: {expose: true}, - expose: { - compute: () => Symbol.for(`language.onlyIfOptions`), - }, - }, + isLanguage: exposeConstant(V(true)), + + onlyIfOptions: exposeConstant(V(Symbol.for(`language.onlyIfOptions`))), intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), + intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}), + intl_dateMonthDay: this.#intlHelper(Intl.DateTimeFormat, {month: 'numeric', day: 'numeric'}), intl_number: this.#intlHelper(Intl.NumberFormat), intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}), intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}), intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}), intl_pluralOrdinal: this.#intlHelper(Intl.PluralRules, {type: 'ordinal'}), + intl_wordSegmenter: this.#intlHelper(Intl.Segmenter, {granularity: 'word'}), validKeys: { flags: {expose: true}, @@ -158,19 +159,16 @@ export class Language extends Thing { }, // TODO: This currently isn't used. Is it still needed? - strings_htmlEscaped: { - flags: {expose: true}, - expose: { - dependencies: ['strings', 'inheritedStrings', 'escapeHTML'], - compute({strings, inheritedStrings, escapeHTML}) { - if (!(strings || inheritedStrings) || !escapeHTML) return null; - const allStrings = {...inheritedStrings, ...strings}; - return Object.fromEntries( - Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)]) - ); - }, + strings_htmlEscaped: [ + exitWithoutDependency('strings'), + + { + dependencies: ['strings'], + compute: ({strings}) => + withEntries(strings, entries => entries + .map(([key, value]) => [key, html.escape(value)])), }, - }, + ], }); static #intlHelper (constructor, opts) { @@ -191,18 +189,35 @@ export class Language extends Thing { return this.formatString(...args); } + $order(...args) { + return this.orderStringOptions(...args); + } + assertIntlAvailable(property) { if (!this[property]) { throw new Error(`Intl API ${property} unavailable`); } } + countWords(text) { + this.assertIntlAvailable('intl_wordSegmenter'); + + const string = html.resolve(text, {normalize: 'plain'}); + const segments = this.intl_wordSegmenter.segment(string); + + return accumulateSum(segments, segment => segment.isWordLike ? 1 : 0); + } + getUnitForm(value) { this.assertIntlAvailable('intl_pluralCardinal'); return this.intl_pluralCardinal.select(value); } formatString(...args) { + if (typeof args.at(-1) === 'function') { + throw new Error(`Passed function - did you mean language.encapsulate() instead?`); + } + const hasOptions = typeof args.at(-1) === 'object' && args.at(-1) !== null; @@ -210,19 +225,14 @@ export class Language extends Thing { const key = this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args); + const template = + this.#getStringTemplateFromFormedKey(key); + const options = (hasOptions ? args.at(-1) : {}); - if (!this.strings) { - throw new Error(`Strings unavailable`); - } - - if (!this.validKeys.includes(key)) { - throw new Error(`Invalid key ${key} accessed`); - } - const constantCasify = name => name .replace(/[A-Z]/g, '_$&') @@ -263,8 +273,7 @@ export class Language extends Thing { ])); const output = this.#iterateOverTemplate({ - template: this.strings[key], - + template, match: languageOptionRegex, insert: ({name: optionName}, canceledForming) => { @@ -309,7 +318,7 @@ export class Language extends Thing { return undefined; } - return optionValue; + return this.sanitize(optionValue); }, }); @@ -344,6 +353,46 @@ export class Language extends Thing { return output; } + orderStringOptions(...args) { + let slice = null, at = null, parts = null; + if (args.length >= 2 && typeof args.at(-1) === 'number') { + if (args.length >= 3 && typeof args.at(-2) === 'number') { + slice = [args.at(-2), args.at(-1)]; + parts = args.slice(0, -2); + } else { + at = args.at(-1); + parts = args.slice(0, -1); + } + } else { + parts = args; + } + + const template = this.getStringTemplate(...parts); + const matches = Array.from(template.matchAll(languageOptionRegex)); + const options = matches.map(({groups}) => groups.name); + + if (slice !== null) return options.slice(...slice); + if (at !== null) return options.at(at); + return options; + } + + getStringTemplate(...args) { + const key = this.#joinKeyParts(args); + return this.#getStringTemplateFromFormedKey(key); + } + + #getStringTemplateFromFormedKey(key) { + if (!this.strings) { + throw new Error(`Strings unavailable`); + } + + if (!this.validKeys.includes(key)) { + throw new Error(`Invalid key ${key} accessed`); + } + + return this.strings[key]; + } + #iterateOverTemplate({ template, match: regexp, @@ -374,26 +423,22 @@ export class Language extends Thing { partInProgress += template.slice(lastIndex, match.index); - // Sanitize string arguments in particular. These are taken to come from - // (raw) data and may include special characters that aren't meant to be - // rendered as HTML markup. - const sanitizedInsertion = - this.#sanitizeValueForInsertion(insertion); - - if (typeof sanitizedInsertion === 'string') { - // Join consecutive strings together. - partInProgress += sanitizedInsertion; - } else if ( - sanitizedInsertion instanceof html.Tag && - sanitizedInsertion.contentOnly - ) { - // Collapse string-only tag contents onto the current string part. - partInProgress += sanitizedInsertion.toString(); - } else { - // Push the string part in progress, then the insertion as-is. - outputParts.push(partInProgress); - outputParts.push(sanitizedInsertion); + const insertionItems = html.smush(insertion).content; + if (insertionItems.length === 1 && typeof insertionItems[0] !== 'string') { + // Push the insertion exactly as it is, rather than manipulating. + if (partInProgress) outputParts.push(partInProgress); + outputParts.push(insertion); partInProgress = ''; + } else for (const insertionItem of insertionItems) { + if (typeof insertionItem === 'string') { + // Join consecutive strings together. + partInProgress += insertionItem; + } else { + // Push the string part in progress, then the insertion as-is. + if (partInProgress) outputParts.push(partInProgress); + outputParts.push(insertionItem); + partInProgress = ''; + } } lastIndex = match.index + match[0].length; @@ -425,14 +470,9 @@ export class Language extends Thing { // html.Tag objects - gets left as-is, preserving the value exactly as it's // provided. #sanitizeValueForInsertion(value) { - const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML'); - if (!escapeHTML) { - throw new Error(`escapeHTML unavailable`); - } - switch (typeof value) { case 'string': - return escapeHTML(value); + return html.escape(value); case 'number': case 'boolean': @@ -488,22 +528,53 @@ 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); + } + + formatMonthDay(date) { + if (date === null || date === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_dateMonthDay'); + return this.intl_dateMonthDay.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, @@ -665,10 +736,6 @@ export class Language extends Thing { style = 'platform', context = 'generic', } = {}) { - if (!this.externalLinkSpec) { - throw new TypeError(`externalLinkSpec unavailable`); - } - // Null or undefined url is blank content. if (url === null || url === undefined) { return html.blank(); @@ -677,7 +744,7 @@ export class Language extends Thing { isExternalLinkContext(context); if (style === 'all') { - return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, { language: this, context, }); @@ -686,7 +753,7 @@ export class Language extends Thing { isExternalLinkStyle(style); const result = - getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { + getExternalLinkStringOfStyleFromDescriptors(url, style, externalLinkSpec, { language: this, context, }); @@ -842,6 +909,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) { @@ -900,7 +979,6 @@ Object.assign(Language.prototype, { countArtworks: countHelper('artworks'), countCommentaryEntries: countHelper('commentaryEntries', 'entries'), countContributions: countHelper('contributions'), - countCoverArts: countHelper('coverArts'), countDays: countHelper('days'), countFlashes: countHelper('flashes'), countMonths: countHelper('months'), diff --git a/src/data/things/MusicVideo.js b/src/data/things/MusicVideo.js new file mode 100644 index 00000000..a7eba04c --- /dev/null +++ b/src/data/things/MusicVideo.js @@ -0,0 +1,170 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input, V} from '#composite'; +import Thing from '#thing'; +import {is, isDate, isStringNonEmpty, isURL} from '#validators'; +import {parseContributors, parseDate} from '#yaml'; + +import {constituteFrom} from '#composite/wiki-data'; + +import { + exposeConstant, + exposeDependency, + exposeUpdateValueOrContinue, + exposeWhetherDependencyAvailable, + exitWithoutDependency, +} from '#composite/control-flow'; + +import { + contributionList, + dimensions, + directory, + fileExtension, + soupyFind, + soupyReverse, + thing, +} from '#composite/wiki-properties'; + +export class MusicVideo extends Thing { + static [Thing.referenceType] = 'music-video'; + static [Thing.friendlyName] = `Music Video`; + static [Thing.wikiData] = 'musicVideoData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + title: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + label: [ + exposeUpdateValueOrContinue({ + validate: input.value(isStringNonEmpty), + }), + + exitWithoutDependency('title', V('Music video')), + exposeConstant(V(null)), + ], + + unqualifiedDirectory: [ + { + dependencies: ['title', 'label'], + compute: (continuation, {title, label}) => + continuation({ + '#name': label ?? title, + }), + }, + + directory({name: '#name'}), + ], + + date: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + constituteFrom('thing', V('date')), + ], + + url: { + flags: {update: true, expose: true}, + update: {validate: isURL}, + }, + + coverArtFileExtension: fileExtension(V('jpg')), + coverArtDimensions: dimensions(), + + artistContribs: contributionList({ + artistProperty: input.value('musicVideoArtistContributions'), + }), + + contributorStyle: [ + exposeUpdateValueOrContinue({ + validate: input.value( + is('list', 'line')), + }), + + { + dependencies: ['contributorContribs'], + compute: ({contributorContribs}) => + (contributorContribs.length > 1 + ? 'list' + : 'line'), + }, + ], + + contributorContribs: contributionList({ + artistProperty: input.value('musicVideoContributorContributions'), + }), + + // Update only + + find: soupyFind(), + + // Expose only + + isMusicVideo: exposeConstant(V(true)), + + dateIsSpecified: exposeWhetherDependencyAvailable('_date'), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Title': {property: 'title'}, + 'Label': {property: 'label'}, + 'Directory': {property: 'unqualifiedDirectory'}, + 'Date': {property: 'date', transform: parseDate}, + 'URL': {property: 'url'}, + + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Cover Art Dimensions': {property: 'coverArtDimensions'}, + + 'Artists': {property: 'artistContribs', transform: parseContributors}, + 'Contributor Style': {property: 'contributorStyle'}, + 'Contributors': {property: 'contributorContribs', transform: parseContributors}, + }, + }; + + static [Thing.reverseSpecs] = { + musicVideoArtistContributionsBy: + soupyReverse.contributionsBy('musicVideoData', 'artistContribs'), + + musicVideoContributorContributionsBy: + soupyReverse.contributionsBy('musicVideoData', 'contributorContribs'), + }; + + get path() { + if (!this.thing) return null; + if (!this.thing.getOwnMusicVideoCoverPath) return null; + + return this.thing.getOwnMusicVideoCoverPath(this); + } + + [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/news-entry.js b/src/data/things/NewsEntry.js index 43d1638e..7cbbfc4b 100644 --- a/src/data/things/news-entry.js +++ b/src/data/things/NewsEntry.js @@ -1,20 +1,20 @@ -export const NEWS_DATA_FILE = 'news.yaml'; - -import {sortChronologically} from '#sort'; +import {V} from '#composite'; import Thing from '#thing'; import {parseDate} from '#yaml'; +import {exposeConstant} from '#composite/control-flow'; import {contentString, directory, name, simpleDate} from '#composite/wiki-properties'; export class NewsEntry extends Thing { static [Thing.referenceType] = 'news-entry'; static [Thing.friendlyName] = `News Entry`; + static [Thing.wikiData] = 'newsData'; static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: name('Unnamed News Entry'), + name: name(V('Unnamed News Entry')), directory: directory(), date: simpleDate(), @@ -22,6 +22,8 @@ export class NewsEntry extends Thing { // Expose only + isNewsEntry: exposeConstant(V(true)), + contentShort: { flags: {expose: true}, @@ -53,21 +55,4 @@ export class NewsEntry extends Thing { 'Content': {property: 'content'}, }, }; - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allInOne}, - thingConstructors: {NewsEntry}, - }) => ({ - title: `Process news data file`, - file: NEWS_DATA_FILE, - - documentMode: allInOne, - documentThing: NewsEntry, - - save: (results) => ({newsData: results}), - - sort({newsData}) { - sortChronologically(newsData, {latestFirst: true}); - }, - }); } diff --git a/src/data/things/static-page.js b/src/data/things/StaticPage.js index 52a09c31..5ddddb9d 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/StaticPage.js @@ -1,23 +1,20 @@ -export const DATA_STATIC_PAGE_DIRECTORY = 'static-page'; - -import * as path from 'node:path'; - -import {traverse} from '#node-utils'; -import {sortAlphabetically} from '#sort'; +import {V} from '#composite'; import Thing from '#thing'; import {isName} from '#validators'; +import {exposeConstant} from '#composite/control-flow'; import {contentString, directory, flag, name, simpleString} from '#composite/wiki-properties'; export class StaticPage extends Thing { static [Thing.referenceType] = 'static'; static [Thing.friendlyName] = `Static Page`; + static [Thing.wikiData] = 'staticPageData'; static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: name('Unnamed Static Page'), + name: name(V('Unnamed Static Page')), nameShort: { flags: {update: true, expose: true}, @@ -35,7 +32,11 @@ export class StaticPage extends Thing { script: simpleString(), content: contentString(), - absoluteLinks: flag(), + absoluteLinks: flag(V(false)), + + // Expose only + + isStaticPage: exposeConstant(V(true)), }); static [Thing.findSpecs] = { @@ -60,26 +61,4 @@ export class StaticPage extends Thing { 'Review Points': {ignore: true}, }, }; - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {onePerFile}, - thingConstructors: {StaticPage}, - }) => ({ - title: `Process static page files`, - - files: dataPath => - traverse(path.join(dataPath, DATA_STATIC_PAGE_DIRECTORY), { - filterFile: name => path.extname(name) === '.yaml', - prefixPath: DATA_STATIC_PAGE_DIRECTORY, - }), - - documentMode: onePerFile, - documentThing: StaticPage, - - save: (results) => ({staticPageData: results}), - - sort({staticPageData}) { - sortAlphabetically(staticPageData); - }, - }); } diff --git a/src/data/things/Track.js b/src/data/things/Track.js new file mode 100644 index 00000000..876a6542 --- /dev/null +++ b/src/data/things/Track.js @@ -0,0 +1,1352 @@ +import {inspect} from 'node:util'; + +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; +import {input, V} from '#composite'; +import find, {keyRefRegex} from '#find'; +import {onlyItem} from '#sugar'; +import {sortByDate} from '#sort'; +import Thing from '#thing'; +import {compareKebabCase} from '#wiki-data'; + +import { + isBoolean, + isColor, + isContentString, + isContributionList, + isDate, + isFileExtension, + validateReference, +} from '#validators'; + +import { + parseAdditionalFiles, + parseAdditionalNames, + parseAnnotatedReferences, + parseArtwork, + parseCommentary, + parseContributors, + parseCreditingSources, + parseReferencingSources, + parseDate, + parseDimensions, + parseDuration, + parseLyrics, + parseMusicVideos, +} from '#yaml'; + +import { + exitWithoutDependency, + exitWithoutUpdateValue, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + exposeWhetherDependencyAvailable, + withAvailabilityFilter, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + fillMissingListItems, + withFilteredList, + withFlattenedList, + withIndexInList, + withMappedList, + withPropertiesFromObject, + withPropertyFromList, + withPropertyFromObject, +} from '#composite/data'; + +import { + withRecontextualizedContributionList, + withRedatedContributionList, + withResolvedContribs, + withResolvedReference, +} from '#composite/wiki-data'; + +import { + commentatorArtists, + constitutibleArtworkList, + contentString, + dimensions, + directory, + duration, + flag, + name, + referenceList, + referencedArtworkList, + reverseReferenceList, + simpleDate, + simpleString, + soupyFind, + soupyReverse, + thing, + thingList, + urls, + wikiData, +} from '#composite/wiki-properties'; + +import { + inheritContributionListFromMainRelease, + inheritFromMainRelease, +} from '#composite/things/track'; + +export class Track extends Thing { + static [Thing.referenceType] = 'track'; + static [Thing.wikiData] = 'trackData'; + + static [Thing.constitutibleProperties] = [ + // Contributions currently aren't being observed for constitution. + // 'artistContribs', // from main release or album + // 'contributorContribs', // from main release + // 'coverArtistContribs', // from main release + + 'trackArtworks', // from inline fields + ]; + + static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, + Album, + ArtTag, + Artwork, + CommentaryEntry, + CreditingSourcesEntry, + LyricsEntry, + MusicVideo, + ReferencingSourcesEntry, + TrackArtistContribution, + TrackSection, + WikiInfo, + }) => ({ + // > Update & expose - Internal relationships + + album: thing(V(Album)), + trackSection: thing(V(TrackSection)), + + // > Update & expose - Identifying metadata + + name: name(V('Unnamed Track')), + nameText: contentString(), + + directory: directory({ + suffix: 'directorySuffix', + }), + + suffixDirectoryFromAlbum: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject('trackSection', V('suffixTrackDirectories')), + exposeDependency('#trackSection.suffixTrackDirectories'), + ], + + // Controls how find.track works - it'll never be matched by + // a reference just to the track's name, which means you don't + // have to always reference some *other* (much more commonly + // referenced) track by directory instead of more naturally by name. + alwaysReferenceByDirectory: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject('album', V('alwaysReferenceTracksByDirectory')), + + // Falsy mode means this exposes true if the album's property is true, + // but continues if the property is false (which is also the default). + exposeDependencyOrContinue({ + dependency: '#album.alwaysReferenceTracksByDirectory', + mode: input.value('falsy'), + }), + + exitWithoutDependency('_mainRelease', V(false)), + exitWithoutDependency('mainReleaseTrack', V(false)), + + withPropertyFromObject('mainReleaseTrack', V('name')), + + { + dependencies: ['name', '#mainReleaseTrack.name'], + compute: ({ + ['name']: name, + ['#mainReleaseTrack.name']: mainReleaseName, + }) => + compareKebabCase(name, mainReleaseName), + }, + ], + + // Album or track. The exposed value is really just what's provided here, + // whether or not a matching track is found on a provided album, for + // example. When presenting or processing, read `mainReleaseTrack`. + mainRelease: [ + exitWithoutUpdateValue({ + validate: input.value( + validateReference(['album', 'track'])), + }), + + { + dependencies: ['name'], + transform: (ref, continuation, {name: ownName}) => + (ref === 'same name single' + ? continuation(ref, { + ['#albumOrTrackReference']: null, + ['#sameNameSingleReference']: ownName, + }) + : continuation(ref, { + ['#albumOrTrackReference']: ref, + ['#sameNameSingleReference']: null, + })), + }, + + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('trackMainReleasesOnly'), + }).outputs({ + '#resolvedReference': '#matchingTrack', + }), + + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('album'), + }).outputs({ + '#resolvedReference': '#matchingAlbum', + }), + + withResolvedReference({ + ref: '#sameNameSingleReference', + find: soupyFind.input('albumSinglesOnly'), + findOptions: input.value({ + fuzz: { + capitalization: true, + kebab: true, + }, + }), + }).outputs({ + '#resolvedReference': '#sameNameSingle', + }), + + exposeDependencyOrContinue('#sameNameSingle'), + exposeDependencyOrContinue('#matchingAlbum'), + exposeDependency('#matchingTrack'), + ], + + bandcampTrackIdentifier: simpleString(), + bandcampArtworkIdentifier: simpleString(), + + additionalNames: thingList(V(AdditionalName)), + + dateFirstReleased: simpleDate(), + + // > Update & expose - Credits and contributors + + artistText: [ + exposeUpdateValueOrContinue({ + validate: input.value(isContentString), + }), + + withPropertyFromObject('album', V('trackArtistText')), + exposeDependency('#album.trackArtistText'), + ], + + artistTextInLists: [ + exposeUpdateValueOrContinue({ + validate: input.value(isContentString), + }), + + exposeDependencyOrContinue('_artistText'), + + withPropertyFromObject('album', V('trackArtistText')), + exposeDependency('#album.trackArtistText'), + ], + + artistContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + class: input.value(TrackArtistContribution), + date: 'date', + thingProperty: input.thisProperty(), + artistProperty: input.value('trackArtistContributions'), + }).outputs({ + '#resolvedContribs': '#artistContribs', + }), + + exposeDependencyOrContinue('#artistContribs', V('empty')), + + withPropertyFromObject('album', V('trackArtistContribs')), + + withRecontextualizedContributionList({ + list: '#album.trackArtistContribs', + artistProperty: input.value('trackArtistContributions'), + }), + + withRedatedContributionList({ + list: '#album.trackArtistContribs', + date: 'date', + }), + + exposeDependency('#album.trackArtistContribs'), + ], + + contributorContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: 'date', + thingProperty: input.thisProperty(), + artistProperty: input.value('trackArtistContributions'), + }).outputs({ + '#resolvedContribs': '#contributorContribs', + }), + + exposeDependencyOrContinue('#contributorContribs', V('empty')), + + inheritContributionListFromMainRelease(), + + exposeConstant(V([])), + ], + + // > Update & expose - General configuration + + countInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject('trackSection', V('countTracksInArtistTotals')), + exposeDependency('#trackSection.countTracksInArtistTotals'), + ], + + disableUniqueCoverArt: flag(V(false)), + disableDate: flag(V(false)), + + // > Update & expose - General metadata + + duration: duration(), + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withPropertyFromObject('trackSection', V('color')), + exposeDependencyOrContinue('#trackSection.color'), + + withPropertyFromObject('album', V('color')), + exposeDependency('#album.color'), + ], + + needsLyrics: [ + exposeUpdateValueOrContinue({ + mode: input.value('falsy'), + validate: input.value(isBoolean), + }), + + exitWithoutDependency('_lyrics', { + value: input.value(false), + mode: input.value('empty'), + }), + + withPropertyFromList('_lyrics', V('helpNeeded')), + + { + dependencies: ['#lyrics.helpNeeded'], + compute: ({ + ['#lyrics.helpNeeded']: helpNeeded, + }) => + helpNeeded.includes(true) + }, + ], + + urls: urls(), + + // > Update & expose - Artworks + + trackArtworks: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Track Artwork'), + ], + + coverArtistContribs: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: 'coverArtDate', + thingProperty: input.value('coverArtistContribs'), + artistProperty: input.value('trackCoverArtistContributions'), + }), + + exposeDependencyOrContinue('#resolvedContribs', V('empty')), + + withPropertyFromObject('album', V('trackCoverArtistContribs')), + + withRecontextualizedContributionList({ + list: '#album.trackCoverArtistContribs', + artistProperty: input.value('trackCoverArtistContributions'), + }), + + withRedatedContributionList({ + list: '#album.trackCoverArtistContribs', + date: 'coverArtDate', + }), + + exposeDependency('#album.trackCoverArtistContribs'), + ], + + coverArtDate: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withPropertyFromObject('album', V('trackArtDate')), + exposeDependencyOrContinue('#album.trackArtDate'), + + exposeDependency('date'), + ], + + coverArtFileExtension: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + withPropertyFromObject('album', V('trackCoverArtFileExtension')), + exposeDependencyOrContinue('#album.trackCoverArtFileExtension'), + + exposeConstant(V('jpg')), + ], + + coverArtDimensions: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + exposeUpdateValueOrContinue(), + + withPropertyFromObject('album', V('trackDimensions')), + exposeDependencyOrContinue('#album.trackDimensions'), + + dimensions(), + ], + + artTags: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), + }), + ], + + referencedArtworks: [ + exitWithoutDependency('hasUniqueCoverArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + referencedArtworkList(), + ], + + // > Update & expose - Referenced tracks + + referencedTracks: [ + inheritFromMainRelease(), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackReference'), + }), + ], + + sampledTracks: [ + inheritFromMainRelease(), + + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackReference'), + }), + ], + + // > Update & expose - Music videos + + musicVideos: [ + exposeUpdateValueOrContinue(), + + // TODO: Same situation as lyrics. Inherited music videos don't set + // the proper .thing property back to this track... but then, it needs + // to keep a reference to its original .thing to get its proper path, + // so maybe this is okay... + inheritFromMainRelease(), + + thingList(V(MusicVideo)), + ], + + // > Update & expose - Additional files + + additionalFiles: thingList(V(AdditionalFile)), + sheetMusicFiles: thingList(V(AdditionalFile)), + midiProjectFiles: thingList(V(AdditionalFile)), + + // > Update & expose - Content entries + + lyrics: [ + exposeUpdateValueOrContinue(), + + // 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(V(LyricsEntry)), + ], + + commentary: thingList(V(CommentaryEntry)), + creditingSources: thingList(V(CreditingSourcesEntry)), + referencingSources: thingList(V(ReferencingSourcesEntry)), + + // > Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for referencedArtworkList (mixedFind) + artworkData: wikiData(V(Artwork)), + + // used for withMatchingContributionPresets (indirectly by Contribution) + wikiInfo: thing(V(WikiInfo)), + + // > Expose only + + isTrack: exposeConstant(V(true)), + + commentatorArtists: commentatorArtists(), + + directorySuffix: [ + exitWithoutDependency('suffixDirectoryFromAlbum', { + value: input.value(null), + mode: input.value('falsy'), + }), + + withPropertyFromObject('trackSection', V('directorySuffix')), + exposeDependency('#trackSection.directorySuffix'), + ], + + date: [ + { + dependencies: ['disableDate'], + compute: (continuation, {disableDate}) => + (disableDate + ? null + : continuation()), + }, + + exposeDependencyOrContinue('dateFirstReleased'), + + withPropertyFromObject('album', V('date')), + exposeDependency('#album.date'), + ], + + trackNumber: [ + // Zero is the fallback, not one, but in most albums the first track + // (and its intended output by this composition) will be one. + + exitWithoutDependency('trackSection', V(0)), + withPropertiesFromObject('trackSection', V(['tracks', 'startCountingFrom'])), + + withIndexInList('#trackSection.tracks', input.myself()), + exitWithoutDependency('#index', V(0), V('index')), + + { + dependencies: ['#trackSection.startCountingFrom', '#index'], + compute: ({ + ['#trackSection.startCountingFrom']: startCountingFrom, + ['#index']: index, + }) => startCountingFrom + index, + }, + ], + + // Whether or not the track has "unique" cover artwork - a cover which is + // specifically associated with this track in particular, rather than with + // the track's album as a whole. This is typically used to select between + // displaying the track artwork and a fallback, such as the album artwork + // or a placeholder. (This property is named hasUniqueCoverArt instead of + // the usual hasCoverArt to emphasize that it does not inherit from the + // album.) + // + // hasUniqueCoverArt 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. + hasUniqueCoverArt: [ + { + dependencies: ['disableUniqueCoverArt'], + compute: (continuation, {disableUniqueCoverArt}) => + (disableUniqueCoverArt + ? false + : continuation()), + }, + + withResultOfAvailabilityCheck({ + from: '_coverArtistContribs', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability'], + compute: (continuation, { + ['#availability']: availability, + }) => + (availability + ? true + : continuation()), + }, + + withPropertyFromObject('album', { + property: input.value('trackCoverArtistContribs'), + internal: input.value(true), + }), + + withResultOfAvailabilityCheck({ + from: '#album.trackCoverArtistContribs', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability'], + compute: (continuation, { + ['#availability']: availability, + }) => + (availability + ? true + : continuation()), + }, + + exitWithoutDependency('_trackArtworks', { + value: input.value(false), + mode: input.value('empty'), + }), + + withPropertyFromList('_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('#trackArtworks.artistContribs', V([])), + + withFlattenedList('#trackArtworks.artistContribs'), + + exposeWhetherDependencyAvailable({ + dependency: '#flattenedList', + mode: input.value('empty'), + }), + ], + + isMainRelease: + exposeWhetherDependencyAvailable({ + dependency: 'mainReleaseTrack', + negate: input.value(true), + }), + + isSecondaryRelease: + exposeWhetherDependencyAvailable({ + dependency: 'mainReleaseTrack', + }), + + mainReleaseTrack: [ + exitWithoutDependency('mainRelease'), + + withPropertyFromObject('mainRelease', V('isTrack')), + + { + dependencies: ['mainRelease', '#mainRelease.isTrack'], + compute: (continuation, { + ['mainRelease']: mainRelease, + ['#mainRelease.isTrack']: mainReleaseIsTrack, + }) => + (mainReleaseIsTrack + ? mainRelease + : continuation()), + }, + + { + dependencies: ['name', '_directory'], + compute: (continuation, { + ['name']: ownName, + ['_directory']: ownDirectory, + }) => continuation({ + ['#mapItsNameLikeName']: + itsName => compareKebabCase(itsName, ownName), + + ['#mapItsDirectoryLikeDirectory']: + (ownDirectory + ? itsDirectory => itsDirectory === ownDirectory + : () => false), + + ['#mapItsNameLikeDirectory']: + (ownDirectory + ? itsName => compareKebabCase(itsName, ownDirectory) + : () => false), + + ['#mapItsDirectoryLikeName']: + itsDirectory => compareKebabCase(itsDirectory, ownName), + }), + }, + + withPropertyFromObject('mainRelease', V('tracks')), + + withPropertyFromList('#mainRelease.tracks', { + property: input.value('mainRelease'), + internal: input.value(true), + }), + + withAvailabilityFilter({from: '#mainRelease.tracks.mainRelease'}), + + withMappedList({ + list: '#availabilityFilter', + map: input.value(item => !item), + }).outputs({ + '#mappedList': '#availabilityFilter', + }), + + withFilteredList('#mainRelease.tracks', '#availabilityFilter') + .outputs({'#filteredList': '#mainRelease.tracks'}), + + withPropertyFromList('#mainRelease.tracks', V('name')), + + withPropertyFromList('#mainRelease.tracks', { + property: input.value('directory'), + internal: input.value(true), + }), + + withMappedList('#mainRelease.tracks.name', '#mapItsNameLikeName') + .outputs({'#mappedList': '#filterItsNameLikeName'}), + + withMappedList('#mainRelease.tracks.directory', '#mapItsDirectoryLikeDirectory') + .outputs({'#mappedList': '#filterItsDirectoryLikeDirectory'}), + + withMappedList('#mainRelease.tracks.name', '#mapItsNameLikeDirectory') + .outputs({'#mappedList': '#filterItsNameLikeDirectory'}), + + withMappedList('#mainRelease.tracks.directory', '#mapItsDirectoryLikeName') + .outputs({'#mappedList': '#filterItsDirectoryLikeName'}), + + withFilteredList('#mainRelease.tracks', '#filterItsNameLikeName') + .outputs({'#filteredList': '#matchingItsNameLikeName'}), + + withFilteredList('#mainRelease.tracks', '#filterItsDirectoryLikeDirectory') + .outputs({'#filteredList': '#matchingItsDirectoryLikeDirectory'}), + + withFilteredList('#mainRelease.tracks', '#filterItsNameLikeDirectory') + .outputs({'#filteredList': '#matchingItsNameLikeDirectory'}), + + withFilteredList('#mainRelease.tracks', '#filterItsDirectoryLikeName') + .outputs({'#filteredList': '#matchingItsDirectoryLikeName'}), + + { + dependencies: [ + '#matchingItsNameLikeName', + '#matchingItsDirectoryLikeDirectory', + '#matchingItsNameLikeDirectory', + '#matchingItsDirectoryLikeName', + ], + + compute: (continuation, { + ['#matchingItsNameLikeName']: NLN, + ['#matchingItsDirectoryLikeDirectory']: DLD, + ['#matchingItsNameLikeDirectory']: NLD, + ['#matchingItsDirectoryLikeName']: DLN, + }) => continuation({ + ['#mainReleaseTrack']: + onlyItem(DLD) ?? + onlyItem(NLN) ?? + onlyItem(DLN) ?? + onlyItem(NLD) ?? + null, + }), + }, + + { + dependencies: ['#mainReleaseTrack', input.myself()], + compute: ({ + ['#mainReleaseTrack']: mainReleaseTrack, + [input.myself()]: thisTrack, + }) => + (mainReleaseTrack === thisTrack + ? null + : mainReleaseTrack), + }, + ], + + // Only has any value for main releases, because secondary releases + // are never secondary to *another* secondary release. + secondaryReleases: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'), + }), + + allReleases: [ + { + dependencies: [ + 'mainReleaseTrack', + 'secondaryReleases', + input.myself(), + ], + + compute: (continuation, { + mainReleaseTrack, + secondaryReleases, + [input.myself()]: thisTrack, + }) => + (mainReleaseTrack + ? continuation({ + ['#mainReleaseTrack']: mainReleaseTrack, + ['#secondaryReleaseTracks']: mainReleaseTrack.secondaryReleases, + }) + : continuation({ + ['#mainReleaseTrack']: thisTrack, + ['#secondaryReleaseTracks']: secondaryReleases, + })), + }, + + { + dependencies: [ + '#mainReleaseTrack', + '#secondaryReleaseTracks', + ], + + compute: ({ + ['#mainReleaseTrack']: mainReleaseTrack, + ['#secondaryReleaseTracks']: secondaryReleaseTracks, + }) => + sortByDate([mainReleaseTrack, ...secondaryReleaseTracks]), + }, + ], + + otherReleases: [ + { + dependencies: [input.myself(), 'allReleases'], + compute: ({ + [input.myself()]: thisTrack, + ['allReleases']: allReleases, + }) => + allReleases.filter(track => track !== thisTrack), + }, + ], + + commentaryFromMainRelease: [ + exitWithoutDependency('mainReleaseTrack', V([])), + + withPropertyFromObject('mainReleaseTrack', V('commentary')), + exposeDependency('#mainReleaseTrack.commentary'), + ], + + groups: [ + withPropertyFromObject('album', V('groups')), + exposeDependency('#album.groups'), + ], + + referencedByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichReference'), + }), + + sampledByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichSample'), + }), + + featuredInFlashes: reverseReferenceList({ + reverse: soupyReverse.input('flashesWhichFeature'), + }), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + // Identifying metadata + + 'Track': {property: 'name'}, + 'Track Text': {property: 'nameText'}, + 'Directory': {property: 'directory'}, + 'Suffix Directory': {property: 'suffixDirectoryFromAlbum'}, + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + 'Main Release': {property: 'mainRelease'}, + + 'Bandcamp Track ID': { + property: 'bandcampTrackIdentifier', + transform: String, + }, + + 'Bandcamp Artwork ID': { + property: 'bandcampArtworkIdentifier', + transform: String, + }, + + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Date First Released': { + property: 'dateFirstReleased', + transform: parseDate, + }, + + // Credits and contributors + + 'Artist Text': {property: 'artistText'}, + 'Artist Text In Lists': {property: 'artistTextInLists'}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Contributors': { + property: 'contributorContribs', + transform: parseContributors, + }, + + // General configuration + + 'Count In Artist Totals': {property: 'countInArtistTotals'}, + + 'Has Cover Art': { + property: 'disableUniqueCoverArt', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + 'Has Date': { + property: 'disableDate', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + // General metadata + + 'Duration': { + property: 'duration', + transform: parseDuration, + }, + + 'Color': {property: 'color'}, + + 'Needs Lyrics': { + property: 'needsLyrics', + }, + + 'URLs': {property: 'urls'}, + + // Artworks + + 'Track Artwork': { + property: 'trackArtworks', + transform: + parseArtwork({ + thingProperty: 'trackArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'trackCoverArtistContributions', + }), + }, + + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, + + 'Cover Art Date': { + property: 'coverArtDate', + transform: parseDate, + }, + + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, + }, + + 'Art Tags': {property: 'artTags'}, + + 'Referenced Artworks': { + property: 'referencedArtworks', + transform: parseAnnotatedReferences, + }, + + // Referenced tracks + + 'Referenced Tracks': {property: 'referencedTracks'}, + 'Sampled Tracks': {property: 'sampledTracks'}, + + // Music videos + + 'Music Videos': { + property: 'musicVideos', + transform: parseMusicVideos, + }, + + // Additional files + + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, + }, + + 'Sheet Music Files': { + property: 'sheetMusicFiles', + transform: parseAdditionalFiles, + }, + + 'MIDI Project Files': { + property: 'midiProjectFiles', + transform: parseAdditionalFiles, + }, + + // Content entries + + 'Lyrics': { + property: 'lyrics', + transform: parseLyrics, + }, + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, + + 'Referencing Sources': { + property: 'referencingSources', + transform: parseReferencingSources, + }, + + // Shenanigans + + 'Franchises': {ignore: true}, + 'Inherit Franchises': {ignore: true}, + 'Review Points': {ignore: true}, + }, + + invalidFieldCombinations: [ + {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: `Secondary releases inherit samples from the main one`, fields: [ + 'Main Release', + 'Sampled Tracks', + ]}, + + { + message: ({'Has Cover Art': hasCoverArt}) => + (hasCoverArt + ? `"Has Cover Art: true" is inferred from cover artist credits` + : `Tracks without cover art must not have cover artist credits`), + + fields: [ + 'Has Cover Art', + 'Cover Artists', + ], + }, + ], + }; + + static [Thing.findSpecs] = { + track: { + referenceTypes: ['track'], + + bindTo: 'trackData', + + getMatchableNames: track => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + }, + + trackMainReleasesOnly: { + referenceTypes: ['track'], + bindTo: 'trackData', + + include: track => + !CacheableObject.getUpdateValue(track, 'mainRelease'), + + // It's still necessary to check alwaysReferenceByDirectory here, since + // it may be set manually (with `Always Reference By Directory: true`), + // and these shouldn't be matched by name (as per usual). + // See the definition for that property for more information. + getMatchableNames: track => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + }, + + trackReference: { + referenceTypes: ['track'], + bindTo: 'trackData', + + byob(fullRef, data, opts) { + const {from} = opts; + + const acontextual = () => + find.trackMainReleasesOnly(fullRef, data, opts); + + const regexMatch = fullRef.match(keyRefRegex); + if (!regexMatch || regexMatch?.keyPart) { + // It's a reference by directory or it's malformed. + // Either way, we can't handle it here! + return acontextual(); + } + + if (!from?.isTrack) { + throw new Error( + `Expected to find starting from a track, got: ` + + inspect(from, {compact: true})); + } + + const referencingTrack = from; + const referencedName = fullRef; + + for (const track of referencingTrack.album.tracks) { + // Totally ignore alwaysReferenceByDirectory here. + // void track.alwaysReferenceByDirectory; + + if (track.name === referencedName) { + if (track.isSecondaryRelease) { + return track.mainReleaseTrack; + } else { + return track; + } + } + } + + return acontextual(); + }, + }, + + trackWithArtwork: { + referenceTypes: [ + 'track', + 'track-referencing-artworks', + 'track-referenced-artworks', + ], + + bindTo: 'trackData', + + include: track => + track.hasUniqueCoverArt, + + getMatchableNames: track => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + }, + + trackPrimaryArtwork: { + [Thing.findThisThingOnly]: false, + + referenceTypes: [ + 'track', + 'track-referencing-artworks', + 'track-referenced-artworks', + ], + + bindTo: 'artworkData', + + include: (artwork) => + artwork.isArtwork && + artwork.thing.isTrack && + 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], + }, + }; + + 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, + ]; + } + + getOwnMusicVideoCoverPath(musicVideo) { + if (!this.album) return null; + if (!musicVideo.unqualifiedDirectory) return null; + + const isSingleFirstTrack = + this.album.style === 'single' && + this.album.tracks[0] === this; + + const trackPrefix = + (isSingleFirstTrack + ? '' + : this.directory + '-'); + + return [ + 'media.trackCover', + this.album.directory, + trackPrefix + musicVideo.unqualifiedDirectory, + musicVideo.coverArtFileExtension, + ]; + } + + 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, 'mainRelease')) { + parts.unshift(`${colors.yellow('[secrelease]')} `); + } + + let album; + + if (depth >= 0) { + album = this.album; + } + + if (album) { + const albumName = album.name; + const albumIndex = album.tracks.indexOf(this); + const trackNum = + (albumIndex === -1 + ? 'indeterminate position' + : `#${albumIndex + 1}`); + parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/wiki-info.js b/src/data/things/WikiInfo.js index 590598be..ffb18cd8 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/WikiInfo.js @@ -1,29 +1,39 @@ -export const WIKI_INFO_FILE = 'wiki-info.yaml'; - -import {input} from '#composite'; +import {input, V} from '#composite'; import Thing from '#thing'; -import {parseContributionPresets} from '#yaml'; +import {parseContributionPresets, parseWallpaperParts} from '#yaml'; import { isBoolean, - isColor, isContributionPresetList, isLanguageCode, isName, - isURL, + isNumber, } from '#validators'; -import {exitWithoutDependency} from '#composite/control-flow'; -import {contentString, flag, name, referenceList, soupyFind} - from '#composite/wiki-properties'; +import {exitWithoutDependency, exposeConstant} from '#composite/control-flow'; + +import { + canonicalBase, + color, + contentString, + fileExtension, + flag, + name, + referenceList, + simpleString, + soupyFind, + wallpaperParts, +} from '#composite/wiki-properties'; export class WikiInfo extends Thing { static [Thing.friendlyName] = `Wiki Info`; + static [Thing.wikiData] = 'wikiInfo'; + static [Thing.oneInstancePerWiki] = true; static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose - name: name('Unnamed Wiki'), + name: name(V('Unnamed Wiki')), // Displayed in nav bar. nameShort: { @@ -36,14 +46,7 @@ export class WikiInfo extends Thing { }, }, - color: { - flags: {update: true, expose: true}, - update: {validate: isColor}, - - expose: { - transform: color => color ?? '#0088ff', - }, - }, + color: color(V('#0088ff')), // One-line description used for <meta rel="description"> tag. description: contentString(), @@ -55,19 +58,18 @@ export class WikiInfo extends Thing { update: {validate: isLanguageCode}, }, - canonicalBase: { + canonicalBase: canonicalBase(), + canonicalMediaBase: canonicalBase(), + + wikiWallpaperBrightness: { flags: {update: true, expose: true}, - update: {validate: isURL}, - expose: { - transform: (value) => - (value === null - ? null - : value.endsWith('/') - ? value - : value + '/'), - }, + update: {validate: isNumber}, }, + wikiWallpaperFileExtension: fileExtension(V('jpg')), + wikiWallpaperStyle: simpleString(), + wikiWallpaperParts: wallpaperParts(), + divideTrackListsByGroups: referenceList({ class: input.value(Group), find: soupyFind.input('group'), @@ -79,20 +81,19 @@ export class WikiInfo extends Thing { }, // Feature toggles - enableFlashesAndGames: flag(false), - enableListings: flag(false), - enableNews: flag(false), - enableArtTagUI: flag(false), - enableGroupUI: flag(false), + enableFlashesAndGames: flag(V(false)), + enableListings: flag(V(false)), + enableNews: flag(V(false)), + enableArtTagUI: flag(V(false)), + enableGroupUI: flag(V(false)), enableSearch: [ - exitWithoutDependency({ - dependency: 'searchDataAvailable', - mode: input.value('falsy'), + exitWithoutDependency('_searchDataAvailable', { value: input.value(false), + mode: input.value('falsy'), }), - flag(true), + flag(V(true)), ], // Update only @@ -106,24 +107,46 @@ export class WikiInfo extends Thing { default: false, }, }, + + // Expose only + + isWikiInfo: exposeConstant(V(true)), }); 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'}, + 'Canonical Media Base': {property: 'canonicalMediaBase'}, + + 'Wiki Wallpaper Brightness': {property: 'wikiWallpaperBrightness'}, + '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, @@ -131,22 +154,5 @@ export class WikiInfo extends Thing { }, }; - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {oneDocumentTotal}, - thingConstructors: {WikiInfo}, - }) => ({ - title: `Process wiki info file`, - file: WIKI_INFO_FILE, - documentMode: oneDocumentTotal, - documentThing: WikiInfo, - - save(wikiInfo) { - if (!wikiInfo) { - return; - } - - return {wikiInfo}; - }, - }); } diff --git a/src/data/things/album.js b/src/data/things/album/Album.js index c71b9820..8dcc6854 100644 --- a/src/data/things/album.js +++ b/src/data/things/album/Album.js @@ -1,16 +1,8 @@ -export const DATA_ALBUM_DIRECTORY = 'album'; - -import * as path from 'node:path'; -import {inspect} from 'node:util'; - -import CacheableObject from '#cacheable-object'; -import {colors} from '#cli'; -import {input} from '#composite'; -import {traverse} from '#node-utils'; -import {sortAlbumsTracksChronologically, sortChronologically} from '#sort'; -import {accumulateSum, empty} from '#sugar'; +import {input, V} from '#composite'; +import {empty} from '#sugar'; import Thing from '#thing'; -import {isColor, isDate, isDirectory, isNumber} from '#validators'; +import {is, isContributionList, isDate, isDirectory, isNumber} + from '#validators'; import { parseAdditionalFiles, @@ -22,34 +14,37 @@ import { parseCreditingSources, parseDate, parseDimensions, + parseMusicVideos, parseWallpaperParts, } from '#yaml'; -import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} - from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - -import {exitWithoutContribs, withDirectory, withCoverArtDate} +import {withFlattenedList, withPropertyFromList} from '#composite/data'; +import {withRecontextualizedContributionList, withResolvedContribs} from '#composite/wiki-data'; import { - additionalFiles, - additionalNameList, + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { color, commentatorArtists, constitutibleArtwork, constitutibleArtworkList, contentString, - contribsPresent, contributionList, dimensions, directory, fileExtension, flag, + hasArtwork, name, referencedArtworkList, referenceList, - reverseReferenceList, simpleDate, simpleString, soupyFind, @@ -61,26 +56,39 @@ import { wikiData, } from '#composite/wiki-properties'; -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.wikiData] = 'albumData'; + + static [Thing.constitutibleProperties] = [ + 'coverArtworks', + 'wallpaperArtwork', + 'bannerArtwork', + ]; static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, + AlbumArtistContribution, + AlbumBannerArtistContribution, + AlbumWallpaperArtistContribution, ArtTag, Artwork, CommentaryEntry, CreditingSourcesEntry, Group, - Track, + MusicVideo, + TrackArtistContribution, TrackSection, WikiInfo, }) => ({ - // Update & expose + // > Update & expose - Internal relationships + + trackSections: thingList(V(TrackSection)), - name: name('Unnamed Album'), + // > Update & expose - Identifying metadata + + name: name(V('Unnamed Album')), directory: directory(), directorySuffix: [ @@ -88,152 +96,146 @@ export class Album extends Thing { validate: input.value(isDirectory), }), - withDirectory(), - - exposeDependency({ - dependency: '#directory', - }), + exposeDependency('directory'), ], - alwaysReferenceByDirectory: flag(false), - alwaysReferenceTracksByDirectory: flag(false), - suffixTrackDirectories: flag(false), + alwaysReferenceByDirectory: flag(V(false)), + alwaysReferenceTracksByDirectory: flag(V(false)), + suffixTrackDirectories: flag(V(false)), - color: color(), - urls: urls(), + style: [ + exposeUpdateValueOrContinue({ + validate: input.value(is(...[ + 'album', + 'single', + ])), + }), - additionalNames: additionalNameList(), + exposeConstant(V('album')), + ], bandcampAlbumIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), + additionalNames: thingList(V(AdditionalName)), + date: simpleDate(), - trackArtDate: simpleDate(), dateAddedToWiki: simpleDate(), - coverArtDate: [ - withCoverArtDate({ - from: input.updateValue({ - validate: isDate, - }), + // > Update & expose - Credits and contributors + + artistContribs: contributionList({ + class: input.value(AlbumArtistContribution), + artistProperty: input.value('albumArtistContributions'), + }), + + trackArtistText: contentString(), + + trackArtistContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + class: input.value(TrackArtistContribution), + thingProperty: input.thisProperty(), + artistProperty: input.value('albumTrackArtistContributions'), + }).outputs({ + '#resolvedContribs': '#trackArtistContribs', }), - exposeDependency({dependency: '#coverArtDate'}), - ], + exposeDependencyOrContinue('#trackArtistContribs', V('empty')), - coverArtFileExtension: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), - fileExtension('jpg'), + withRecontextualizedContributionList('artistContribs', { + reclass: input.value(TrackArtistContribution), + artistProperty: input.value('albumTrackArtistContributions'), + }), + + exposeDependency('#artistContribs'), ], - trackCoverArtFileExtension: fileExtension('jpg'), + // > Update & expose - General configuration - wallpaperFileExtension: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), - fileExtension('jpg'), - ], + countTracksInArtistTotals: flag(V(true)), - bannerFileExtension: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - fileExtension('jpg'), - ], + showAlbumInTracksWithoutArtists: flag(V(false)), - wallpaperStyle: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), - simpleString(), - ], + hasTrackNumbers: flag(V(true)), + isListedOnHomepage: flag(V(true)), + isListedInGalleries: flag(V(true)), - wallpaperParts: [ - exitWithoutContribs({ - contribs: 'wallpaperArtistContribs', + hideDuration: flag(V(false)), + + // > Update & expose - General metadata + + color: color(), + + urls: urls(), + + // > Update & expose - Artworks + + coverArtworks: [ + exitWithoutDependency('hasCoverArt', { value: input.value([]), + mode: input.value('falsy'), }), - wallpaperParts(), + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), ], - bannerStyle: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - simpleString(), - ], + coverArtistContribs: contributionList({ + date: 'coverArtDate', + artistProperty: input.value('albumCoverArtistContributions'), + }), - coverArtDimensions: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), - dimensions(), - ], + coverArtDate: [ + exitWithoutDependency('hasCoverArt', { + value: input.value(null), + mode: input.value('falsy'), + }), - trackDimensions: dimensions(), + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), - bannerDimensions: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - dimensions(), + exposeDependency('date'), ], - wallpaperArtwork: [ - exitWithoutDependency({ - dependency: 'wallpaperArtistContribs', - mode: input.value('empty'), + coverArtFileExtension: [ + exitWithoutDependency('hasCoverArt', { value: input.value(null), + mode: input.value('falsy'), }), - constitutibleArtwork.fromYAMLFieldSpec - .call(this, 'Wallpaper Artwork'), + fileExtension(V('jpg')), ], - bannerArtwork: [ - exitWithoutDependency({ - dependency: 'bannerArtistContribs', - mode: input.value('empty'), + coverArtDimensions: [ + exitWithoutDependency('hasCoverArt', { value: input.value(null), + mode: input.value('falsy'), }), - constitutibleArtwork.fromYAMLFieldSpec - .call(this, 'Banner Artwork'), + dimensions(), ], - coverArtworks: [ - withHasCoverArt(), - - exitWithoutDependency({ - dependency: '#hasCoverArt', - mode: input.value('falsy'), + artTags: [ + exitWithoutDependency('hasCoverArt', { value: input.value([]), + mode: input.value('falsy'), }), - constitutibleArtworkList.fromYAMLFieldSpec - .call(this, 'Cover Artwork'), + referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), + }), ], - hasTrackNumbers: flag(true), - isListedOnHomepage: flag(true), - isListedInGalleries: flag(true), - - commentary: thingList({ - class: input.value(CommentaryEntry), - }), - - creditSources: thingList({ - class: input.value(CreditingSourcesEntry), - }), - - additionalFiles: additionalFiles(), - - trackSections: thingList({ - class: input.value(TrackSection), - }), - - artistContribs: contributionList({ - date: 'date', - artistProperty: input.value('albumArtistContributions'), - }), - - coverArtistContribs: [ - withCoverArtDate(), - - contributionList({ - date: '#coverArtDate', - artistProperty: input.value('albumCoverArtistContributions'), + referencedArtworks: [ + exitWithoutDependency('hasCoverArt', { + value: input.value([]), + mode: input.value('falsy'), }), + + referencedArtworkList(), ], trackCoverArtistContribs: contributionList({ @@ -246,80 +248,161 @@ export class Album extends Thing { artistProperty: input.value('trackCoverArtistContributions'), }), - wallpaperArtistContribs: [ - withCoverArtDate(), + trackArtDate: simpleDate(), + + trackCoverArtFileExtension: fileExtension(V('jpg')), + + trackDimensions: dimensions(), + + wallpaperBrightness: { + flags: {update: true, expose: true}, + update: {validate: isNumber}, + }, + + wallpaperArtwork: [ + exitWithoutDependency('hasWallpaperArt', { + value: input.value(null), + mode: input.value('falsy'), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Wallpaper Artwork'), + ], + + wallpaperArtistContribs: contributionList({ + class: input.value(AlbumWallpaperArtistContribution), + date: 'coverArtDate', + artistProperty: input.value('albumWallpaperArtistContributions'), + }), + + wallpaperFileExtension: [ + exitWithoutDependency('hasWallpaperArt', { + value: input.value(null), + mode: input.value('falsy'), + }), - contributionList({ - date: '#coverArtDate', - artistProperty: input.value('albumWallpaperArtistContributions'), + fileExtension(V('jpg')), + ], + + wallpaperStyle: [ + exitWithoutDependency('hasWallpaperArt', { + value: input.value(null), + mode: input.value('falsy'), }), + + simpleString(), ], - bannerArtistContribs: [ - withCoverArtDate(), + wallpaperParts: [ + exitWithoutDependency('hasWallpaperArt', { + value: input.value([]), + mode: input.value('falsy'), + }), + + wallpaperParts(), + ], - contributionList({ - date: '#coverArtDate', - artistProperty: input.value('albumBannerArtistContributions'), + bannerArtwork: [ + exitWithoutDependency('hasBannerArt', { + value: input.value(null), + mode: input.value('falsy'), }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Banner Artwork'), ], - groups: referenceList({ - class: input.value(Group), - find: soupyFind.input('group'), + bannerArtistContribs: contributionList({ + class: input.value(AlbumBannerArtistContribution), + date: 'coverArtDate', + artistProperty: input.value('albumBannerArtistContributions'), }), - artTags: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), + bannerFileExtension: [ + exitWithoutDependency('hasBannerArt', { + value: input.value(null), + mode: input.value('falsy'), }), - referenceList({ - class: input.value(ArtTag), - find: soupyFind.input('artTag'), + fileExtension(V('jpg')), + ], + + bannerDimensions: [ + exitWithoutDependency('hasBannerArt', { + value: input.value(null), + mode: input.value('falsy'), }), + + dimensions(), ], - referencedArtworks: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), + bannerStyle: [ + exitWithoutDependency('hasBannerArt', { + value: input.value(null), + mode: input.value('falsy'), }), - referencedArtworkList(), + simpleString(), ], - // Update only + // > Update & expose - Groups + + groups: referenceList({ + class: input.value(Group), + find: soupyFind.input('group'), + }), + + // > Update & expose - Music videos + + musicVideos: thingList(V(MusicVideo)), + + // > Update & expose - Content entries + + commentary: thingList(V(CommentaryEntry)), + creditingSources: thingList(V(CreditingSourcesEntry)), + + // > Update & expose - Additional files + + additionalFiles: thingList(V(AdditionalFile)), + + // > Update only find: soupyFind(), reverse: soupyReverse(), // used for referencedArtworkList (mixedFind) - artworkData: wikiData({ - class: input.value(Artwork), - }), + artworkData: wikiData(V(Artwork)), // used for withMatchingContributionPresets (indirectly by Contribution) - wikiInfo: thing({ - class: input.value(WikiInfo), - }), + wikiInfo: thing(V(WikiInfo)), + + // > Expose only - // Expose only + isAlbum: exposeConstant(V(true)), commentatorArtists: commentatorArtists(), - hasCoverArt: [ - withHasCoverArt(), - exposeDependency({dependency: '#hasCoverArt'}), - ], + hasCoverArt: hasArtwork({ + contribs: '_coverArtistContribs', + artworks: '_coverArtworks', + }), - hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), - hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), + hasWallpaperArt: hasArtwork({ + contribs: '_wallpaperArtistContribs', + artwork: '_wallpaperArtwork', + }), + + hasBannerArt: hasArtwork({ + contribs: '_bannerArtistContribs', + artwork: '_bannerArtwork', + }), tracks: [ - withTracks(), - exposeDependency({dependency: '#tracks'}), + exitWithoutDependency('trackSections', V([])), + + withPropertyFromList('trackSections', V('tracks')), + withFlattenedList('#trackSections.tracks'), + exposeDependency('#flattenedList'), ], }); @@ -374,8 +457,22 @@ export class Album extends Thing { bindTo: 'albumData', getMatchableNames: album => - (album.alwaysReferenceByDirectory - ? [] + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + }, + + albumSinglesOnly: { + referencing: ['album'], + + bindTo: 'albumData', + + incldue: album => + album.style === 'single', + + getMatchableNames: album => + (album.alwaysReferenceByDirectory + ? [] : [album.name]), }, @@ -392,8 +489,8 @@ export class Album extends Thing { album.hasCoverArt, getMatchableNames: album => - (album.alwaysReferenceByDirectory - ? [] + (album.alwaysReferenceByDirectory + ? [] : [album.name]), }, @@ -408,9 +505,9 @@ export class Album extends Thing { bindTo: 'artworkData', - include: (artwork, {Artwork, Album}) => - artwork instanceof Artwork && - artwork.thing instanceof Album && + include: (artwork) => + artwork.isArtwork && + artwork.thing.isAlbum && artwork === artwork.thing.coverArtworks[0], getMatchableNames: ({thing: album}) => @@ -424,20 +521,6 @@ export class Album extends Thing { }; 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', @@ -455,6 +538,9 @@ export class Album extends Thing { albumArtistContributionsBy: soupyReverse.contributionsBy('albumData', 'artistContribs'), + albumTrackArtistContributionsBy: + soupyReverse.contributionsBy('albumData', 'trackArtistContribs'), + albumCoverArtistContributionsBy: soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'), @@ -474,21 +560,15 @@ export class Album extends Thing { static [Thing.yamlDocumentSpec] = { fields: { - 'Album': {property: 'name'}, + // Identifying metadata + 'Album': {property: 'name'}, 'Directory': {property: 'directory'}, 'Directory Suffix': {property: 'directorySuffix'}, 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, - 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, - 'Always Reference Tracks By Directory': { - property: 'alwaysReferenceTracksByDirectory', - }, - - 'Additional Names': { - property: 'additionalNames', - transform: parseAdditionalNames, - }, + 'Always Reference Tracks By Directory': {property: 'alwaysReferenceTracksByDirectory'}, + 'Style': {property: 'style'}, 'Bandcamp Album ID': { property: 'bandcampAlbumIdentifier', @@ -500,18 +580,61 @@ export class Album extends Thing { transform: String, }, + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + 'Date': { property: 'date', transform: parseDate, }, - 'Color': {property: 'color'}, - 'URLs': {property: 'urls'}, + 'Date Added': { + property: 'dateAddedToWiki', + transform: parseDate, + }, + + // Credits and contributors + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Track Artist Text': { + property: 'trackArtistText', + }, + + 'Track Artists': { + property: 'trackArtistContribs', + transform: parseContributors, + }, + + // General configuration + + 'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'}, + + 'Show Album In Tracks Without Artists': { + property: 'showAlbumInTracksWithoutArtists', + }, 'Has Track Numbers': {property: 'hasTrackNumbers'}, 'Listed on Homepage': {property: 'isListedOnHomepage'}, 'Listed in Galleries': {property: 'isListedInGalleries'}, + 'Hide Duration': {property: 'hideDuration'}, + + // General metadata + + 'Color': {property: 'color'}, + + 'URLs': {property: 'urls'}, + + // Artworks + // (Note - this YAML section is deliberately ordered differently + // than the corresponding property descriptors.) + 'Cover Artwork': { property: 'coverArtworks', transform: @@ -541,6 +664,8 @@ export class Album extends Thing { }), }, + 'Wallpaper Brightness': {property: 'wallpaperBrightness'}, + 'Wallpaper Artwork': { property: 'wallpaperArtwork', transform: @@ -555,27 +680,29 @@ export class Album extends Thing { }), }, + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, + 'Cover Art Date': { property: 'coverArtDate', transform: parseDate, }, - 'Default Track Cover Art Date': { - property: 'trackArtDate', - transform: parseDate, + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, }, - 'Date Added': { - property: 'dateAddedToWiki', - transform: parseDate, + 'Default Track Cover Artists': { + property: 'trackCoverArtistContribs', + transform: parseContributors, }, - 'Cover Art File Extension': {property: 'coverArtFileExtension'}, - 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, - - 'Cover Art Dimensions': { - property: 'coverArtDimensions', - transform: parseDimensions, + 'Default Track Cover Art Date': { + property: 'trackArtDate', + transform: parseDate, }, 'Default Track Dimensions': { @@ -589,7 +716,6 @@ export class Album extends Thing { }, 'Wallpaper Style': {property: 'wallpaperStyle'}, - 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, 'Wallpaper Parts': { property: 'wallpaperParts', @@ -601,58 +727,77 @@ export class Album extends Thing { transform: parseContributors, }, - 'Banner Style': {property: 'bannerStyle'}, - 'Banner File Extension': {property: 'bannerFileExtension'}, - 'Banner Dimensions': { property: 'bannerDimensions', transform: parseDimensions, }, - 'Commentary': { - property: 'commentary', - transform: parseCommentary, - }, + 'Banner Style': {property: 'bannerStyle'}, - 'Credit Sources': { - property: 'creditSources', - transform: parseCreditingSources, - }, + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, + 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + 'Banner File Extension': {property: 'bannerFileExtension'}, - 'Additional Files': { - property: 'additionalFiles', - transform: parseAdditionalFiles, - }, + 'Art Tags': {property: 'artTags'}, 'Referenced Artworks': { property: 'referencedArtworks', transform: parseAnnotatedReferences, }, - 'Franchises': {ignore: true}, + // Groups - 'Artists': { - property: 'artistContribs', - transform: parseContributors, + 'Groups': {property: 'groups'}, + + // Music videos + + 'Music Videos': { + property: 'musicVideos', + transform: parseMusicVideos, }, - 'Cover Artists': { - property: 'coverArtistContribs', - transform: parseContributors, + // Content entries + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, }, - 'Default Track Cover Artists': { - property: 'trackCoverArtistContribs', - transform: parseContributors, + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, }, - 'Groups': {property: 'groups'}, - 'Art Tags': {property: 'artTags'}, + // Additional files + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, + }, + + // Shenanigans + + 'Franchises': {ignore: true}, 'Review Points': {ignore: true}, }, invalidFieldCombinations: [ + {message: `Move commentary on singles to the track`, fields: [ + ['Style', 'single'], + 'Commentary', + ]}, + + {message: `Move crediting sources on singles to the track`, fields: [ + ['Style', 'single'], + 'Crediting Sources', + ]}, + + {message: `Move additional names on singles to the track`, fields: [ + ['Style', 'single'], + 'Additional Names', + ]}, + {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [ 'Wallpaper Parts', 'Wallpaper Style', @@ -665,126 +810,13 @@ export class Album extends Thing { ], }; - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {headerAndEntries}, - thingConstructors: {Album, Track}, - }) => ({ - title: `Process album files`, - - files: dataPath => - traverse(path.join(dataPath, DATA_ALBUM_DIRECTORY), { - filterFile: name => path.extname(name) === '.yaml', - prefixPath: DATA_ALBUM_DIRECTORY, - }), - - documentMode: headerAndEntries, - headerDocumentThing: Album, - entryDocumentThing: document => - ('Section' in document - ? TrackSection - : Track), - - save(results) { - const albumData = []; - const trackSectionData = []; - const trackData = []; - - const artworkData = []; - const commentaryData = []; - const creditingSourceData = []; - const lyricsData = []; - - for (const {header: album, entries} of results) { - const trackSections = []; - - let currentTrackSection = new TrackSection(); - let currentTrackSectionTracks = []; - - Object.assign(currentTrackSection, { - name: `Default Track Section`, - isDefaultTrackSection: true, - }); - - const albumRef = Thing.getReference(album); - - const closeCurrentTrackSection = () => { - if ( - currentTrackSection.isDefaultTrackSection && - empty(currentTrackSectionTracks) - ) { - return; - } - - currentTrackSection.tracks = - currentTrackSectionTracks; - - trackSections.push(currentTrackSection); - trackSectionData.push(currentTrackSection); - }; - - for (const entry of entries) { - if (entry instanceof TrackSection) { - closeCurrentTrackSection(); - currentTrackSection = entry; - currentTrackSectionTracks = []; - continue; - } - - currentTrackSectionTracks.push(entry); - trackData.push(entry); - - // 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.creditSources); - - // 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.creditSources); - - album.trackSections = trackSections; - } - - return { - albumData, - trackSectionData, - trackData, - - artworkData, - commentaryData, - creditingSourceData, - lyricsData, - }; - }, - - sort({albumData, trackData}) { - sortChronologically(albumData); - sortAlbumsTracksChronologically(trackData); - }, - }); + getOwnAdditionalFilePath(_file, filename) { + return [ + 'media.albumAdditionalFile', + this.directory, + filename, + ]; + } getOwnArtworkPath(artwork) { if (artwork === this.bannerArtwork) { @@ -823,175 +855,22 @@ export class Album extends Thing { artwork.fileExtension, ]; } -} - -export class TrackSection extends Thing { - static [Thing.friendlyName] = `Track Section`; - static [Thing.referenceType] = `track-section`; - - static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ - // Update & expose - - name: name('Unnamed Track Section'), - - unqualifiedDirectory: directory(), - - color: [ - exposeUpdateValueOrContinue({ - validate: input.value(isColor), - }), - - withAlbum(), - - withPropertyFromObject({ - object: '#album', - property: input.value('color'), - }), - - exposeDependency({dependency: '#album.color'}), - ], - - startCountingFrom: [ - withStartCountingFrom({ - from: input.updateValue({validate: isNumber}), - }), - - exposeDependency({dependency: '#startCountingFrom'}), - ], - - dateOriginallyReleased: simpleDate(), - - isDefaultTrackSection: flag(false), - - description: contentString(), - - album: [ - withAlbum(), - exposeDependency({dependency: '#album'}), - ], - tracks: thingList({ - class: input.value(Track), - }), - - // Update only - - reverse: soupyReverse(), - - // Expose only - - directory: [ - withAlbum(), - - exitWithoutDependency({ - dependency: '#album', - }), - - withPropertyFromObject({ - object: '#album', - property: input.value('directory'), - }), - - withDirectory({ - directory: 'unqualifiedDirectory', - }).outputs({ - '#directory': '#unqualifiedDirectory', - }), - - { - dependencies: ['#album.directory', '#unqualifiedDirectory'], - compute: ({ - ['#album.directory']: albumDirectory, - ['#unqualifiedDirectory']: unqualifiedDirectory, - }) => - albumDirectory + '/' + unqualifiedDirectory, - }, - ], - - continueCountingFrom: [ - withContinueCountingFrom(), - - exposeDependency({dependency: '#continueCountingFrom'}), - ], - }); - - static [Thing.findSpecs] = { - trackSection: { - referenceTypes: ['track-section'], - bindTo: 'trackSectionData', - }, - - unqualifiedTrackSection: { - referenceTypes: ['unqualified-track-section'], - - getMatchableDirectories: trackSection => - [trackSection.unqualifiedDirectory], - }, - }; - - static [Thing.reverseSpecs] = { - trackSectionsWhichInclude: { - bindTo: 'trackSectionData', + getOwnMusicVideoCoverPath(musicVideo) { + // Lala, same shenanigan as above, this is media.trackCover + // where it shouldn't be. - 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', - transform: parseDate, - }, - - 'Description': {property: 'description'}, - }, - }; - - [inspect.custom](depth) { - const parts = []; - - parts.push(Thing.prototype[inspect.custom].apply(this)); - - if (depth >= 0) { - let album = null; - try { - album = this.album; - } catch {} - - let first = null; - try { - first = this.tracks.at(0).trackNumber; - } catch {} - - let last = null; - try { - last = this.tracks.at(-1).trackNumber; - } catch {} - - if (album) { - const albumName = album.name; - const albumIndex = album.trackSections.indexOf(this); - - const num = - (albumIndex === -1 - ? 'indeterminate position' - : `#${albumIndex + 1}`); - - const range = - (albumIndex >= 0 && first !== null && last !== null - ? `: ${first}-${last}` - : ''); - - parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`); - } - } + return [ + 'media.trackCover', + this.directory, + musicVideo.unqualifiedDirectory, + musicVideo.coverArtFileExtension, + ]; + } - return parts.join(''); + // 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; } } diff --git a/src/data/things/album/TrackSection.js b/src/data/things/album/TrackSection.js new file mode 100644 index 00000000..4bc43a3c --- /dev/null +++ b/src/data/things/album/TrackSection.js @@ -0,0 +1,267 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input, V} from '#composite'; +import Thing from '#thing'; +import {isBoolean, isColor, isDirectory, isNumber} from '#validators'; +import {parseDate} from '#yaml'; + +import {withLengthOfList, withNearbyItemFromList, withPropertyFromObject} + from '#composite/data'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + contentString, + directory, + flag, + name, + simpleDate, + soupyReverse, + thing, + thingList, +} from '#composite/wiki-properties'; + +export class TrackSection extends Thing { + static [Thing.friendlyName] = `Track Section`; + static [Thing.referenceType] = `track-section`; + static [Thing.wikiData] = 'trackSectionData'; + + static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + // Update & expose + + album: thing(V(Album)), + + name: name(V('Unnamed Track Section')), + + unqualifiedDirectory: directory(), + + directorySuffix: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDirectory), + }), + + withPropertyFromObject({ + object: 'album', + property: input.value('directorySuffix'), + }), + + exposeDependency({dependency: '#album.directorySuffix'}), + ], + + suffixTrackDirectories: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject({ + object: 'album', + property: input.value('suffixTrackDirectories'), + }), + + exposeDependency({dependency: '#album.suffixTrackDirectories'}), + ], + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withPropertyFromObject({ + object: 'album', + property: input.value('color'), + }), + + exposeDependency({dependency: '#album.color'}), + ], + + hasTrackNumbers: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject('album', V('hasTrackNumbers')), + exposeDependency('#album.hasTrackNumbers'), + ], + + startCountingFrom: [ + exposeUpdateValueOrContinue({ + validate: input.value(isNumber), + }), + + withPropertyFromObject('album', V('hasTrackNumbers')), + exitWithoutDependency('#album.hasTrackNumbers', V(1), V('falsy')), + + withPropertyFromObject('album', V('trackSections')), + + withNearbyItemFromList({ + list: '#album.trackSections', + item: input.myself(), + offset: input.value(-1), + }).outputs({ + '#nearbyItem': '#previousTrackSection', + }), + + exitWithoutDependency('#previousTrackSection', V(1)), + + withPropertyFromObject('#previousTrackSection', V('continueCountingFrom')), + exposeDependency('#previousTrackSection.continueCountingFrom'), + ], + + dateOriginallyReleased: simpleDate(), + + countTracksInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject({ + object: 'album', + property: input.value('countTracksInArtistTotals'), + }), + + exposeDependency({dependency: '#album.countTracksInArtistTotals'}), + ], + + isDefaultTrackSection: flag(V(false)), + + description: contentString(), + + tracks: thingList(V(Track)), + + // Update only + + reverse: soupyReverse(), + + // Expose only + + isTrackSection: [ + exposeConstant({ + value: input.value(true), + }), + ], + + directory: [ + exitWithoutDependency({ + dependency: 'album', + }), + + withPropertyFromObject({ + object: 'album', + property: input.value('directory'), + }), + + { + dependencies: ['#album.directory', 'unqualifiedDirectory'], + compute: ({ + ['#album.directory']: albumDirectory, + ['unqualifiedDirectory']: unqualifiedDirectory, + }) => + albumDirectory + '/' + unqualifiedDirectory, + }, + ], + + continueCountingFrom: [ + withPropertyFromObject('album', V('hasTrackNumbers')), + exitWithoutDependency('#album.hasTrackNumbers', V(null), V('falsy')), + + { + dependencies: ['hasTrackNumbers', 'startCountingFrom'], + compute: (continuation, {hasTrackNumbers, startCountingFrom}) => + (hasTrackNumbers + ? continuation() + : continuation.exit(startCountingFrom)), + }, + + withLengthOfList('tracks'), + + { + dependencies: ['startCountingFrom', '#tracks.length'], + compute: ({startCountingFrom, '#tracks.length': tracks}) => + startCountingFrom + tracks, + }, + ], + }); + + static [Thing.findSpecs] = { + trackSection: { + referenceTypes: ['track-section'], + bindTo: 'trackSectionData', + }, + + unqualifiedTrackSection: { + referenceTypes: ['unqualified-track-section'], + + getMatchableDirectories: trackSection => + [trackSection.unqualifiedDirectory], + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Section': {property: 'name'}, + 'Directory Suffix': {property: 'directorySuffix'}, + 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, + + 'Color': {property: 'color'}, + 'Has Track Numbers': {property: 'hasTrackNumbers'}, + 'Start Counting From': {property: 'startCountingFrom'}, + + 'Date Originally Released': { + property: 'dateOriginallyReleased', + transform: parseDate, + }, + + 'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'}, + + 'Description': {property: 'description'}, + }, + }; + + [inspect.custom](depth) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (depth >= 0) showAlbum: { + let album = null; + try { + album = this.album; + } catch { + break showAlbum; + } + + let first = null; + try { + first = this.tracks.at(0).trackNumber; + } catch {} + + let last = null; + try { + last = this.tracks.at(-1).trackNumber; + } catch {} + + const albumName = album.name; + const albumIndex = album.trackSections.indexOf(this); + + const num = + (albumIndex === -1 + ? 'indeterminate position' + : `#${albumIndex + 1}`); + + const range = + (albumIndex >= 0 && first !== null && last !== null + ? `: ${first}-${last}` + : ''); + + parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/album/index.js b/src/data/things/album/index.js new file mode 100644 index 00000000..67bf47ab --- /dev/null +++ b/src/data/things/album/index.js @@ -0,0 +1,2 @@ +export * from './Album.js'; +export * from './TrackSection.js'; diff --git a/src/data/things/content.js b/src/data/things/content.js deleted file mode 100644 index 7f352795..00000000 --- a/src/data/things/content.js +++ /dev/null @@ -1,122 +0,0 @@ -import {input} from '#composite'; -import find from '#find'; -import Thing from '#thing'; -import {is, isDate} from '#validators'; -import {parseDate} from '#yaml'; - -import {contentString, referenceList, simpleDate, soupyFind, thing} - from '#composite/wiki-properties'; - -import { - exposeConstant, - exposeDependencyOrContinue, - exposeUpdateValueOrContinue, - withResultOfAvailabilityCheck, -} from '#composite/control-flow'; - -import {withWebArchiveDate} from '#composite/things/commentary-entry'; - -export class ContentEntry extends Thing { - static [Thing.getPropertyDescriptors] = ({Artist}) => ({ - // Update & expose - - thing: thing(), - - artists: referenceList({ - class: input.value(Artist), - find: soupyFind.input('artist'), - }), - - artistText: contentString(), - - annotation: contentString(), - - dateKind: { - flags: {update: true, expose: true}, - - update: { - validate: is(...[ - 'sometime', - 'throughout', - 'around', - ]), - }, - }, - - accessKind: [ - 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(null), - }), - ], - - 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(), - }); - - 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 {} -export class LyricsEntry extends ContentEntry {} -export class CreditingSourcesEntry extends ContentEntry {} diff --git a/src/data/things/content/CommentaryEntry.js b/src/data/things/content/CommentaryEntry.js new file mode 100644 index 00000000..33c4f92e --- /dev/null +++ b/src/data/things/content/CommentaryEntry.js @@ -0,0 +1,21 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {hasAnnotationPart} from '#composite/things/content'; + +import {ContentEntry} from './ContentEntry.js'; + +export class CommentaryEntry extends ContentEntry { + static [Thing.friendlyName] = `Commentary Entry`; + static [Thing.wikiData] = 'commentaryData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isCommentaryEntry: exposeConstant(V(true)), + + isWikiEditorCommentary: hasAnnotationPart(V('wiki editor')), + }); +} diff --git a/src/data/things/content/ContentEntry.js b/src/data/things/content/ContentEntry.js new file mode 100644 index 00000000..af721e3d --- /dev/null +++ b/src/data/things/content/ContentEntry.js @@ -0,0 +1,248 @@ +import {input, V} from '#composite'; +import {transposeArrays} from '#sugar'; +import Thing from '#thing'; +import {is, isDate, validateReferenceList} from '#validators'; +import {parseDate} from '#yaml'; + +import {withFilteredList, withMappedList, withPropertyFromList} + from '#composite/data'; +import {withResolvedReferenceList} from '#composite/wiki-data'; +import {contentString, simpleDate, soupyFind, thing} + from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + withAnnotationPartNodeLists, + withExpressedOrImplicitArtistReferences, + withWebArchiveDate, +} from '#composite/things/content'; + +export class ContentEntry extends Thing { + static [Thing.friendlyName] = `Content Entry`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + artists: [ + withExpressedOrImplicitArtistReferences({ + from: input.updateValue({ + validate: validateReferenceList('artist'), + }), + }), + + exitWithoutDependency('#artistReferences', V([])), + + withResolvedReferenceList({ + list: '#artistReferences', + find: soupyFind.input('artist'), + }), + + exposeDependency('#resolvedReferenceList'), + ], + + artistText: contentString(), + + annotation: contentString(), + + dateKind: { + flags: {update: true, expose: true}, + + update: { + validate: is(...[ + 'sometime', + 'throughout', + 'around', + ]), + }, + }, + + accessKind: [ + exitWithoutDependency('_accessDate'), + + exposeUpdateValueOrContinue({ + validate: input.value( + is(...[ + 'captured', + 'accessed', + ])), + }), + + withWebArchiveDate(), + + withResultOfAvailabilityCheck({from: '#webArchiveDate'}), + + { + dependencies: ['#availability'], + compute: (continuation, {['#availability']: availability}) => + (availability + ? continuation.exit('captured') + : continuation()), + }, + + exposeConstant(V('accessed')), + ], + + date: simpleDate(), + secondDate: simpleDate(), + + accessDate: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withWebArchiveDate(), + + exposeDependencyOrContinue({ + dependency: '#webArchiveDate', + }), + + exposeConstant(V(null)), + ], + + body: contentString(), + + // Update only + + find: soupyFind(), + + // Expose only + + isContentEntry: exposeConstant(V(true)), + + annotationParts: [ + withAnnotationPartNodeLists(), + + { + dependencies: ['#annotationPartNodeLists'], + compute: (continuation, { + ['#annotationPartNodeLists']: nodeLists, + }) => continuation({ + ['#firstNodes']: + nodeLists.map(list => list.at(0)), + + ['#lastNodes']: + nodeLists.map(list => list.at(-1)), + }), + }, + + withPropertyFromList('#firstNodes', V('i')) + .outputs({'#firstNodes.i': '#startIndices'}), + + withPropertyFromList('#lastNodes', V('iEnd')) + .outputs({'#lastNodes.iEnd': '#endIndices'}), + + { + dependencies: [ + 'annotation', + '#startIndices', + '#endIndices', + ], + + compute: ({ + ['annotation']: annotation, + ['#startIndices']: startIndices, + ['#endIndices']: endIndices, + }) => + transposeArrays([startIndices, endIndices]) + .map(([start, end]) => + annotation.slice(start, end)), + }, + ], + + sourceText: [ + withAnnotationPartNodeLists(), + + { + dependencies: ['#annotationPartNodeLists'], + compute: (continuation, { + ['#annotationPartNodeLists']: nodeLists, + }) => continuation({ + ['#firstPartWithExternalLink']: + nodeLists + .find(nodes => nodes + .some(node => node.type === 'external-link')) ?? + null, + }), + }, + + exitWithoutDependency('#firstPartWithExternalLink'), + + { + dependencies: ['annotation', '#firstPartWithExternalLink'], + compute: ({ + ['annotation']: annotation, + ['#firstPartWithExternalLink']: nodes, + }) => + annotation.slice( + nodes.at(0).i, + nodes.at(-1).iEnd), + }, + ], + + sourceURLs: [ + withAnnotationPartNodeLists(), + + { + dependencies: ['#annotationPartNodeLists'], + compute: (continuation, { + ['#annotationPartNodeLists']: nodeLists, + }) => continuation({ + ['#firstPartWithExternalLink']: + nodeLists + .find(nodes => nodes + .some(node => node.type === 'external-link')) ?? + null, + }), + }, + + exitWithoutDependency('#firstPartWithExternalLink', V([])), + + withMappedList({ + list: '#firstPartWithExternalLink', + map: input.value(node => node.type === 'external-link'), + }).outputs({ + '#mappedList': '#externalLinkFilter', + }), + + withFilteredList({ + list: '#firstPartWithExternalLink', + filter: '#externalLinkFilter', + }), + + withMappedList({ + list: '#filteredList', + map: input.value(node => node.data.href), + }), + + exposeDependency('#mappedList'), + ], + }); + + 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'}, + }, + }; +} diff --git a/src/data/things/content/CreditingSourcesEntry.js b/src/data/things/content/CreditingSourcesEntry.js new file mode 100644 index 00000000..0f7e266e --- /dev/null +++ b/src/data/things/content/CreditingSourcesEntry.js @@ -0,0 +1,17 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {ContentEntry} from './ContentEntry.js'; + +export class CreditingSourcesEntry extends ContentEntry { + static [Thing.friendlyName] = `Crediting Sources Entry`; + static [Thing.wikiData] = 'creditingSourceData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isCreditingSourcesEntry: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/content/LyricsEntry.js b/src/data/things/content/LyricsEntry.js new file mode 100644 index 00000000..8611ad9f --- /dev/null +++ b/src/data/things/content/LyricsEntry.js @@ -0,0 +1,44 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exitWithoutDependency, exposeConstant} from '#composite/control-flow'; +import {contentString} from '#composite/wiki-properties'; + +import {hasAnnotationPart} from '#composite/things/content'; + +import {ContentEntry} from './ContentEntry.js'; + +export class LyricsEntry extends ContentEntry { + static [Thing.friendlyName] = `Lyrics Entry`; + static [Thing.wikiData] = 'lyricsData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + originDetails: contentString(), + + // Expose only + + isLyricsEntry: exposeConstant(V(true)), + + isWikiLyrics: hasAnnotationPart(V('wiki lyrics')), + helpNeeded: hasAnnotationPart(V('help needed')), + + hasSquareBracketAnnotations: [ + exitWithoutDependency('isWikiLyrics', V(false), V('falsy')), + exitWithoutDependency('body', V(false)), + + { + dependencies: ['body'], + compute: ({body}) => + /\[.*\]/m.test(body), + }, + ], + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, { + fields: { + 'Origin Details': {property: 'originDetails'}, + }, + }); +} diff --git a/src/data/things/content/ReferencingSourcesEntry.js b/src/data/things/content/ReferencingSourcesEntry.js new file mode 100644 index 00000000..4b27b313 --- /dev/null +++ b/src/data/things/content/ReferencingSourcesEntry.js @@ -0,0 +1,17 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {ContentEntry} from './ContentEntry.js'; + +export class ReferencingSourcesEntry extends ContentEntry { + static [Thing.friendlyName] = `Referencing Sources Entry`; + static [Thing.wikiData] = 'referencingSourceData'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isReferencingSourceEntry: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/content/index.js b/src/data/things/content/index.js new file mode 100644 index 00000000..aaa7f304 --- /dev/null +++ b/src/data/things/content/index.js @@ -0,0 +1,9 @@ +// Yet Another Index.js File Descending From A Folder Named Content + +export * from './ContentEntry.js'; + +export * from './CommentaryEntry.js'; +export * from './LyricsEntry.js'; + +export * from './CreditingSourcesEntry.js'; +export * from './ReferencingSourcesEntry.js'; diff --git a/src/data/things/contrib/AlbumArtistContribution.js b/src/data/things/contrib/AlbumArtistContribution.js new file mode 100644 index 00000000..7b6bc9da --- /dev/null +++ b/src/data/things/contrib/AlbumArtistContribution.js @@ -0,0 +1,12 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {MusicalArtistContribution} from './MusicalArtistContribution.js'; + +export class AlbumArtistContribution extends MusicalArtistContribution { + static [Thing.getPropertyDescriptors] = () => ({ + isAlbumArtistContribution: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/contrib/AlbumAssetArtworkArtistContribution.js b/src/data/things/contrib/AlbumAssetArtworkArtistContribution.js new file mode 100644 index 00000000..fbc3f719 --- /dev/null +++ b/src/data/things/contrib/AlbumAssetArtworkArtistContribution.js @@ -0,0 +1,12 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {ArtworkArtistContribution} from './ArtworkArtistContribution.js'; + +export class AlbumAssetArtworkArtistContribution extends ArtworkArtistContribution { + static [Thing.getPropertyDescriptors] = () => ({ + isAlbumAssetArtworkArtistContribution: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/contrib/AlbumBannerArtistContribution.js b/src/data/things/contrib/AlbumBannerArtistContribution.js new file mode 100644 index 00000000..16f1c9bb --- /dev/null +++ b/src/data/things/contrib/AlbumBannerArtistContribution.js @@ -0,0 +1,12 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {AlbumAssetArtworkArtistContribution} from './AlbumAssetArtworkArtistContribution.js'; + +export class AlbumBannerArtistContribution extends AlbumAssetArtworkArtistContribution { + static [Thing.getPropertyDescriptors] = () => ({ + isAlbumBannerArtistContribution: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/contrib/AlbumWallpaperArtistContribution.js b/src/data/things/contrib/AlbumWallpaperArtistContribution.js new file mode 100644 index 00000000..acd29cf8 --- /dev/null +++ b/src/data/things/contrib/AlbumWallpaperArtistContribution.js @@ -0,0 +1,12 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {AlbumAssetArtworkArtistContribution} from './AlbumAssetArtworkArtistContribution.js'; + +export class AlbumWallpaperArtistContribution extends AlbumAssetArtworkArtistContribution { + static [Thing.getPropertyDescriptors] = () => ({ + isAlbumWallpaperArtistContribution: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/contrib/ArtworkArtistContribution.js b/src/data/things/contrib/ArtworkArtistContribution.js new file mode 100644 index 00000000..a47f2391 --- /dev/null +++ b/src/data/things/contrib/ArtworkArtistContribution.js @@ -0,0 +1,20 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {hasAnnotationFront} from '#composite/things/contribution'; + +import {Contribution} from './Contribution.js'; + +export class ArtworkArtistContribution extends Contribution { + static [Thing.getPropertyDescriptors] = () => ({ + isArtworkArtistContribution: exposeConstant(V(true)), + + recognizedAnnotationFronts: + exposeConstant(V(['edits for wiki'])), + + isEditsForWikiCredit: + hasAnnotationFront(V('edits for wiki')), + }); +} diff --git a/src/data/things/contrib/Contribution.js b/src/data/things/contrib/Contribution.js new file mode 100644 index 00000000..4352b58a --- /dev/null +++ b/src/data/things/contrib/Contribution.js @@ -0,0 +1,351 @@ +import {inspect} from 'node:util'; + +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; +import {input, V} from '#composite'; +import {empty} from '#sugar'; +import Thing from '#thing'; +import {isBoolean, isStringNonEmpty, isThing} from '#validators'; + +import {simpleDate, singleReference, simpleString, soupyFind} + from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + withFilteredList, + withNearbyItemFromList, + withPropertyFromList, + withPropertyFromObject, +} from '#composite/data'; + +import { + inheritFromContributionPresets, + withContainingReverseContributionList, + withContributionContext, +} from '#composite/things/contribution'; + +export class Contribution extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: { + flags: {update: true, expose: true}, + update: {validate: isThing}, + }, + + thingProperty: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + artistProperty: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + date: simpleDate(), + + artist: singleReference({ + find: soupyFind.input('artist'), + }), + + artistText: simpleString(), + + annotation: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + countInContributionTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + inheritFromContributionPresets(), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInContributionTotals?.(contribution) + ? true + : thing.countOwnContributionInContributionTotals + ? false + : continuation()), + }, + + exposeConstant(V(true)), + ], + + countInDurationTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + inheritFromContributionPresets(), + + withPropertyFromObject('thing', V('duration')), + exitWithoutDependency('#thing.duration', { + value: input.value(false), + mode: input.value('falsy'), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInDurationTotals?.(contribution) + ? true + : thing.countOwnContributionInDurationTotals + ? false + : continuation()), + }, + + exposeConstant(V(true)), + ], + + // Update only + + find: soupyFind(), + + // Expose only + + isContribution: exposeConstant(V(true)), + + recognizedAnnotationFronts: exposeConstant(V([])), + + annotationFront: [ + exitWithoutDependency('annotation'), + + { + dependencies: ['recognizedAnnotationFronts', 'annotation'], + compute: ({recognizedAnnotationFronts, annotation}) => + recognizedAnnotationFronts + .find(front => + annotation.startsWith(front) && ( + annotation === front || + annotation.at(front.length) === ':' || + annotation.at(front.length) === ',' + )) ?? null, + }, + ], + + annotationBack: [ + exitWithoutDependency('annotation'), + + exitWithoutDependency({ + dependency: 'annotationFront', + value: 'annotation', + }), + + { + dependencies: ['annotation', 'annotationFront'], + compute: ({annotation, annotationFront}) => + annotation.slice(annotationFront.length + 1).trim() + || null, + }, + ], + + annotationParts: [ + exitWithoutDependency('annotationBack', V([])), + + { + dependencies: ['annotationBack'], + compute: ({annotationBack}) => + annotationBack + .split(',') + .map(part => part.trim()), + }, + ], + + context: [ + withContributionContext(), + + { + dependencies: [ + '#contributionTarget', + '#contributionProperty', + ], + + compute: ({ + ['#contributionTarget']: target, + ['#contributionProperty']: property, + }) => ({ + target, + property, + }), + }, + ], + + matchingPresets: [ + withPropertyFromObject('thing', { + property: input.value('wikiInfo'), + internal: input.value(true), + }), + + exitWithoutDependency('#thing.wikiInfo', V([])), + + withPropertyFromObject('#thing.wikiInfo', V('contributionPresets')) + .outputs({'#thing.wikiInfo.contributionPresets': '#contributionPresets'}), + + exitWithoutDependency('#contributionPresets', V([]), V('empty')), + + withContributionContext(), + + // TODO: implementing this with compositional filters would be fun + { + dependencies: [ + '#contributionPresets', + '#contributionTarget', + '#contributionProperty', + 'annotation', + ], + + compute: ({ + ['#contributionPresets']: presets, + ['#contributionTarget']: target, + ['#contributionProperty']: property, + ['annotation']: annotation, + }) => + presets.filter(preset => + preset.context[0] === target && + preset.context.slice(1).includes(property) && + // For now, only match if the annotation is a complete match. + // Partial matches (e.g. because the contribution includes "two" + // annotations, separated by commas) don't count. + preset.annotation === annotation), + }, + ], + + // All the contributions from the list which includes this contribution. + // Note that this list contains not only other contributions by the same + // artist, but also this very contribution. It doesn't mix contributions + // exposed on different properties. + associatedContributions: [ + exitWithoutDependency('thing', V([])), + exitWithoutDependency('thingProperty', V([])), + + withPropertyFromObject('thing', 'thingProperty') + .outputs({'#value': '#contributions'}), + + withPropertyFromList('#contributions', V('annotation')), + + { + dependencies: ['#contributions.annotation', 'annotation'], + compute: (continuation, { + ['#contributions.annotation']: contributionAnnotations, + ['annotation']: annotation, + }) => continuation({ + ['#likeContributionsFilter']: + contributionAnnotations.map(mappingAnnotation => + (annotation?.startsWith(`edits for wiki`) + ? mappingAnnotation?.startsWith(`edits for wiki`) + : !mappingAnnotation?.startsWith(`edits for wiki`))), + }), + }, + + withFilteredList('#contributions', '#likeContributionsFilter') + .outputs({'#filteredList': '#contributions'}), + + exposeDependency('#contributions'), + ], + + previousBySameArtist: [ + withContainingReverseContributionList() + .outputs({'#containingReverseContributionList': '#list'}), + + exitWithoutDependency('#list'), + + withNearbyItemFromList('#list', input.myself(), V(-1)), + exposeDependency('#nearbyItem'), + ], + + nextBySameArtist: [ + withContainingReverseContributionList() + .outputs({'#containingReverseContributionList': '#list'}), + + exitWithoutDependency('#list'), + + withNearbyItemFromList('#list', input.myself(), V(+1)), + exposeDependency('#nearbyItem'), + ], + + groups: [ + withPropertyFromObject('thing', V('groups')), + exposeDependencyOrContinue('#thing.groups'), + + exposeConstant(V([])), + ], + }); + + [inspect.custom](depth, options, inspect) { + const parts = []; + const accentParts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (this.annotation) { + accentParts.push(colors.green(`"${this.annotation}"`)); + } + + if (this.date) { + accentParts.push(colors.yellow(this.date.toLocaleDateString())); + } + + let artistRef; + if (depth >= 0) { + let artist; + try { + artist = this.artist; + } catch { + // Computing artist might crash for any reason - don't distract from + // other errors as a result of inspecting this contribution. + } + + if (artist) { + artistRef = + colors.blue(Thing.getReference(artist)); + } + } else { + artistRef = + colors.green(CacheableObject.getUpdateValue(this, 'artist')); + } + + if (artistRef) { + accentParts.push(`by ${artistRef}`); + } + + if (this.thing) { + if (depth >= 0) { + const newOptions = { + ...options, + depth: + (options.depth === null + ? null + : options.depth - 1), + }; + + accentParts.push(`to ${inspect(this.thing, newOptions)}`); + } else { + accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`); + } + } + + if (!empty(accentParts)) { + parts.push(` (${accentParts.join(', ')})`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/contrib/MusicalArtistContribution.js b/src/data/things/contrib/MusicalArtistContribution.js new file mode 100644 index 00000000..df26850b --- /dev/null +++ b/src/data/things/contrib/MusicalArtistContribution.js @@ -0,0 +1,20 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {hasAnnotationFront} from '#composite/things/contribution'; + +import {Contribution} from './Contribution.js'; + +export class MusicalArtistContribution extends Contribution { + static [Thing.getPropertyDescriptors] = () => ({ + isMusicalArtistContribution: exposeConstant(V(true)), + + recognizedAnnotationFronts: + exposeConstant(V(['featuring'])), + + isFeaturingCredit: + hasAnnotationFront(V('featuring')), + }); +} diff --git a/src/data/things/contrib/TrackArtistContribution.js b/src/data/things/contrib/TrackArtistContribution.js new file mode 100644 index 00000000..ecbe9b34 --- /dev/null +++ b/src/data/things/contrib/TrackArtistContribution.js @@ -0,0 +1,12 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {MusicalArtistContribution} from './MusicalArtistContribution.js'; + +export class TrackArtistContribution extends MusicalArtistContribution { + static [Thing.getPropertyDescriptors] = () => ({ + isTrackArtistContribution: exposeConstant(V(true)), + }); +} diff --git a/src/data/things/contrib/index.js b/src/data/things/contrib/index.js new file mode 100644 index 00000000..187ddb2c --- /dev/null +++ b/src/data/things/contrib/index.js @@ -0,0 +1,11 @@ +export * from './Contribution.js'; + +export * from './MusicalArtistContribution.js'; +export * from './AlbumArtistContribution.js'; +export * from './TrackArtistContribution.js'; + +export * from './ArtworkArtistContribution.js'; + +export * from './AlbumAssetArtworkArtistContribution.js'; +export * from './AlbumBannerArtistContribution.js'; +export * from './AlbumWallpaperArtistContribution.js'; diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js deleted file mode 100644 index c92fafb4..00000000 --- a/src/data/things/contribution.js +++ /dev/null @@ -1,302 +0,0 @@ -import {inspect} from 'node:util'; - -import CacheableObject from '#cacheable-object'; -import {colors} from '#cli'; -import {input} from '#composite'; -import {empty} from '#sugar'; -import Thing from '#thing'; -import {isStringNonEmpty, isThing, validateReference} from '#validators'; - -import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; -import {flag, simpleDate, soupyFind} from '#composite/wiki-properties'; - -import { - withFilteredList, - withNearbyItemFromList, - withPropertyFromList, - withPropertyFromObject, -} from '#composite/data'; - -import { - inheritFromContributionPresets, - thingPropertyMatches, - thingReferenceTypeMatches, - withContainingReverseContributionList, - withContributionArtist, - withContributionContext, - withMatchingContributionPresets, -} from '#composite/things/contribution'; - -export class Contribution extends Thing { - static [Thing.getPropertyDescriptors] = () => ({ - // Update & expose - - thing: { - flags: {update: true, expose: true}, - update: {validate: isThing}, - }, - - thingProperty: { - flags: {update: true, expose: true}, - update: {validate: isStringNonEmpty}, - }, - - artistProperty: { - flags: {update: true, expose: true}, - update: {validate: isStringNonEmpty}, - }, - - date: simpleDate(), - - artist: [ - withContributionArtist({ - ref: input.updateValue({ - validate: validateReference('artist'), - }), - }), - - exposeDependency({ - dependency: '#artist', - }), - ], - - annotation: { - flags: {update: true, expose: true}, - update: {validate: isStringNonEmpty}, - }, - - countInContributionTotals: [ - inheritFromContributionPresets({ - property: input.thisProperty(), - }), - - flag(true), - ], - - countInDurationTotals: [ - inheritFromContributionPresets({ - property: input.thisProperty(), - }), - - flag(true), - ], - - // Update only - - find: soupyFind(), - - // Expose only - - context: [ - withContributionContext(), - - { - dependencies: [ - '#contributionTarget', - '#contributionProperty', - ], - - compute: ({ - ['#contributionTarget']: target, - ['#contributionProperty']: property, - }) => ({ - target, - property, - }), - }, - ], - - matchingPresets: [ - withMatchingContributionPresets(), - - exposeDependency({ - dependency: '#matchingContributionPresets', - }), - ], - - // All the contributions from the list which includes this contribution. - // Note that this list contains not only other contributions by the same - // artist, but also this very contribution. It doesn't mix contributions - // exposed on different properties. - associatedContributions: [ - exitWithoutDependency({ - dependency: 'thing', - value: input.value([]), - }), - - exitWithoutDependency({ - dependency: 'thingProperty', - value: input.value([]), - }), - - withPropertyFromObject({ - object: 'thing', - property: 'thingProperty', - }).outputs({ - '#value': '#contributions', - }), - - withPropertyFromList({ - list: '#contributions', - property: input.value('annotation'), - }), - - { - dependencies: ['#contributions.annotation', 'annotation'], - compute: (continuation, { - ['#contributions.annotation']: contributionAnnotations, - ['annotation']: annotation, - }) => continuation({ - ['#likeContributionsFilter']: - contributionAnnotations.map(mappingAnnotation => - (annotation?.startsWith(`edits for wiki`) - ? mappingAnnotation?.startsWith(`edits for wiki`) - : !mappingAnnotation?.startsWith(`edits for wiki`))), - }), - }, - - withFilteredList({ - list: '#contributions', - filter: '#likeContributionsFilter', - }).outputs({ - '#filteredList': '#contributions', - }), - - exposeDependency({ - dependency: '#contributions', - }), - ], - - isArtistContribution: thingPropertyMatches({ - value: input.value('artistContribs'), - }), - - isContributorContribution: thingPropertyMatches({ - value: input.value('contributorContribs'), - }), - - isCoverArtistContribution: thingPropertyMatches({ - value: input.value('coverArtistContribs'), - }), - - isBannerArtistContribution: thingPropertyMatches({ - value: input.value('bannerArtistContribs'), - }), - - isWallpaperArtistContribution: thingPropertyMatches({ - value: input.value('wallpaperArtistContribs'), - }), - - isForTrack: thingReferenceTypeMatches({ - value: input.value('track'), - }), - - isForAlbum: thingReferenceTypeMatches({ - value: input.value('album'), - }), - - isForFlash: thingReferenceTypeMatches({ - value: input.value('flash'), - }), - - previousBySameArtist: [ - withContainingReverseContributionList().outputs({ - '#containingReverseContributionList': '#list', - }), - - exitWithoutDependency({ - dependency: '#list', - }), - - withNearbyItemFromList({ - list: '#list', - item: input.myself(), - offset: input.value(-1), - }), - - exposeDependency({ - dependency: '#nearbyItem', - }), - ], - - nextBySameArtist: [ - withContainingReverseContributionList().outputs({ - '#containingReverseContributionList': '#list', - }), - - exitWithoutDependency({ - dependency: '#list', - }), - - withNearbyItemFromList({ - list: '#list', - item: input.myself(), - offset: input.value(+1), - }), - - exposeDependency({ - dependency: '#nearbyItem', - }), - ], - }); - - [inspect.custom](depth, options, inspect) { - const parts = []; - const accentParts = []; - - parts.push(Thing.prototype[inspect.custom].apply(this)); - - if (this.annotation) { - accentParts.push(colors.green(`"${this.annotation}"`)); - } - - if (this.date) { - accentParts.push(colors.yellow(this.date.toLocaleDateString())); - } - - let artistRef; - if (depth >= 0) { - let artist; - try { - artist = this.artist; - } catch (_error) { - // Computing artist might crash for any reason - don't distract from - // other errors as a result of inspecting this contribution. - } - - if (artist) { - artistRef = - colors.blue(Thing.getReference(artist)); - } - } else { - artistRef = - colors.green(CacheableObject.getUpdateValue(this, 'artist')); - } - - if (artistRef) { - accentParts.push(`by ${artistRef}`); - } - - if (this.thing) { - if (depth >= 0) { - const newOptions = { - ...options, - depth: - (options.depth === null - ? null - : options.depth - 1), - }; - - accentParts.push(`to ${inspect(this.thing, newOptions)}`); - } else { - accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`); - } - } - - if (!empty(accentParts)) { - parts.push(` (${accentParts.join(', ')})`); - } - - return parts.join(''); - } -} diff --git a/src/data/things/flash.js b/src/data/things/flash.js deleted file mode 100644 index a0bcb523..00000000 --- a/src/data/things/flash.js +++ /dev/null @@ -1,479 +0,0 @@ -export const FLASH_DATA_FILE = 'flashes.yaml'; - -import {input} from '#composite'; -import {empty} from '#sugar'; -import {sortFlashesChronologically} from '#sort'; -import Thing from '#thing'; -import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} - from '#validators'; - -import { - parseArtwork, - parseAdditionalNames, - parseCommentary, - parseContributors, - parseCreditingSources, - parseDate, - parseDimensions, -} from '#yaml'; - -import {withPropertyFromObject} from '#composite/data'; - -import { - exposeConstant, - exposeDependency, - exposeDependencyOrContinue, - exposeUpdateValueOrContinue, -} from '#composite/control-flow'; - -import { - additionalNameList, - color, - commentatorArtists, - constitutibleArtwork, - contentString, - contributionList, - dimensions, - directory, - fileExtension, - name, - referenceList, - simpleDate, - soupyFind, - soupyReverse, - thing, - thingList, - urls, - wikiData, -} from '#composite/wiki-properties'; - -import {withFlashAct} from '#composite/things/flash'; -import {withFlashSide} from '#composite/things/flash-act'; - -export class Flash extends Thing { - static [Thing.referenceType] = 'flash'; - - static [Thing.getPropertyDescriptors] = ({ - CommentaryEntry, - CreditingSourcesEntry, - Track, - FlashAct, - WikiInfo, - }) => ({ - // Update & expose - - name: name('Unnamed Flash'), - - directory: { - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - - // Flashes expose directory differently from other Things! Their - // default directory is dependent on the page number (or ID), not - // the name. - expose: { - dependencies: ['page'], - transform(directory, {page}) { - if (directory === null && page === null) return null; - else if (directory === null) return page; - else return directory; - }, - }, - }, - - page: { - flags: {update: true, expose: true}, - update: {validate: anyOf(isString, isNumber)}, - - expose: { - transform: (value) => (value === null ? null : value.toString()), - }, - }, - - color: [ - exposeUpdateValueOrContinue({ - validate: input.value(isColor), - }), - - withFlashAct(), - - withPropertyFromObject({ - object: '#flashAct', - property: input.value('color'), - }), - - exposeDependency({dependency: '#flashAct.color'}), - ], - - date: simpleDate(), - - coverArtFileExtension: fileExtension('jpg'), - - coverArtDimensions: dimensions(), - - coverArtwork: - constitutibleArtwork.fromYAMLFieldSpec - .call(this, 'Cover Artwork'), - - contributorContribs: contributionList({ - date: 'date', - artistProperty: input.value('flashContributorContributions'), - }), - - featuredTracks: referenceList({ - class: input.value(Track), - find: soupyFind.input('track'), - }), - - urls: urls(), - - additionalNames: additionalNameList(), - - commentary: thingList({ - class: input.value(CommentaryEntry), - }), - - creditSources: thingList({ - class: input.value(CreditingSourcesEntry), - }), - - // Update only - - find: soupyFind(), - reverse: soupyReverse(), - - // used for withMatchingContributionPresets (indirectly by Contribution) - wikiInfo: thing({ - class: input.value(WikiInfo), - }), - - // Expose only - - commentatorArtists: commentatorArtists(), - - act: [ - withFlashAct(), - exposeDependency({dependency: '#flashAct'}), - ], - - side: [ - withFlashAct(), - - withPropertyFromObject({ - object: '#flashAct', - property: input.value('side'), - }), - - exposeDependency({dependency: '#flashAct.side'}), - ], - }); - - static [Thing.getSerializeDescriptors] = ({ - serialize: S, - }) => ({ - name: S.id, - page: S.id, - directory: S.id, - date: S.id, - contributors: S.toContribRefs, - tracks: S.toRefs, - urls: S.id, - color: S.id, - }); - - static [Thing.findSpecs] = { - flash: { - referenceTypes: ['flash'], - bindTo: 'flashData', - }, - }; - - 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'}, - 'Directory': {property: 'directory'}, - 'Page': {property: 'page'}, - 'Color': {property: 'color'}, - 'URLs': {property: 'urls'}, - - 'Date': { - property: 'date', - 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': { - property: 'coverArtDimensions', - transform: parseDimensions, - }, - - 'Featured Tracks': {property: 'featuredTracks'}, - - 'Contributors': { - property: 'contributorContribs', - transform: parseContributors, - }, - - 'Commentary': { - property: 'commentary', - transform: parseCommentary, - }, - - 'Credit Sources': { - property: 'creditSources', - transform: parseCreditingSources, - }, - - 'Review Points': {ignore: true}, - }, - }; - - getOwnArtworkPath(artwork) { - return [ - 'media.flashArt', - this.directory, - artwork.fileExtension, - ]; - } -} - -export class FlashAct extends Thing { - static [Thing.referenceType] = 'flash-act'; - static [Thing.friendlyName] = `Flash Act`; - - static [Thing.getPropertyDescriptors] = () => ({ - // Update & expose - - name: name('Unnamed Flash Act'), - directory: directory(), - color: color(), - - listTerminology: [ - exposeUpdateValueOrContinue({ - validate: input.value(isContentString), - }), - - withFlashSide(), - - withPropertyFromObject({ - object: '#flashSide', - property: input.value('listTerminology'), - }), - - exposeDependencyOrContinue({ - dependency: '#flashSide.listTerminology', - }), - - exposeConstant({ - value: input.value(null), - }), - ], - - flashes: referenceList({ - class: input.value(Flash), - find: soupyFind.input('flash'), - }), - - // Update only - - find: soupyFind(), - reverse: soupyReverse(), - - // Expose only - - side: [ - withFlashSide(), - exposeDependency({dependency: '#flashSide'}), - ], - }); - - static [Thing.findSpecs] = { - flashAct: { - referenceTypes: ['flash-act'], - bindTo: 'flashActData', - }, - }; - - static [Thing.reverseSpecs] = { - flashActsWhoseFlashesInclude: { - bindTo: 'flashActData', - - referencing: flashAct => [flashAct], - referenced: flashAct => flashAct.flashes, - }, - }; - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Act': {property: 'name'}, - 'Directory': {property: 'directory'}, - - 'Color': {property: 'color'}, - 'List Terminology': {property: 'listTerminology'}, - - 'Review Points': {ignore: true}, - }, - }; -} - -export class FlashSide extends Thing { - static [Thing.referenceType] = 'flash-side'; - static [Thing.friendlyName] = `Flash Side`; - - static [Thing.getPropertyDescriptors] = () => ({ - // Update & expose - - name: name('Unnamed Flash Side'), - directory: directory(), - color: color(), - listTerminology: contentString(), - - acts: referenceList({ - class: input.value(FlashAct), - find: soupyFind.input('flashAct'), - }), - - // Update only - - find: soupyFind(), - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Side': {property: 'name'}, - 'Directory': {property: 'directory'}, - 'Color': {property: 'color'}, - 'List Terminology': {property: 'listTerminology'}, - }, - }; - - static [Thing.findSpecs] = { - flashSide: { - referenceTypes: ['flash-side'], - bindTo: 'flashSideData', - }, - }; - - static [Thing.reverseSpecs] = { - flashSidesWhoseActsInclude: { - bindTo: 'flashSideData', - - referencing: flashSide => [flashSide], - referenced: flashSide => flashSide.acts, - }, - }; - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allInOne}, - thingConstructors: {Flash, FlashAct}, - }) => ({ - title: `Process flashes file`, - file: FLASH_DATA_FILE, - - documentMode: allInOne, - documentThing: document => - ('Side' in document - ? FlashSide - : 'Act' in document - ? FlashAct - : Flash), - - save(results) { - // JavaScript likes you. - - if (!empty(results) && !(results[0] instanceof FlashSide)) { - throw new Error(`Expected a side at top of flash data file`); - } - - let index = 0; - let thing; - for (; thing = results[index]; index++) { - const flashSide = thing; - const flashActRefs = []; - - if (results[index + 1] instanceof Flash) { - throw new Error(`Expected an act to immediately follow a side`); - } - - for ( - index++; - (thing = results[index]) && thing instanceof FlashAct; - index++ - ) { - const flashAct = thing; - const flashRefs = []; - for ( - index++; - (thing = results[index]) && thing instanceof Flash; - index++ - ) { - flashRefs.push(Thing.getReference(thing)); - } - index--; - flashAct.flashes = flashRefs; - flashActRefs.push(Thing.getReference(flashAct)); - } - index--; - flashSide.acts = flashActRefs; - } - - const flashData = results.filter(x => x instanceof Flash); - const flashActData = results.filter(x => x instanceof FlashAct); - const flashSideData = results.filter(x => x instanceof FlashSide); - - const artworkData = flashData.map(flash => flash.coverArtwork); - const commentaryData = flashData.flatMap(flash => flash.commentary); - const creditingSourceData = flashData.flatMap(flash => flash.creditSources); - - return { - flashData, - flashActData, - flashSideData, - - artworkData, - commentaryData, - creditingSourceData, - }; - }, - - sort({flashData}) { - sortFlashesChronologically(flashData); - }, - }); -} diff --git a/src/data/things/flash/Flash.js b/src/data/things/flash/Flash.js new file mode 100644 index 00000000..1f290b3f --- /dev/null +++ b/src/data/things/flash/Flash.js @@ -0,0 +1,246 @@ +import {input, V} from '#composite'; +import Thing from '#thing'; +import {anyOf, isColor, isDirectory, isNumber, isString} + from '#validators'; + +import { + parseArtwork, + parseAdditionalNames, + parseCommentary, + parseContributors, + parseCreditingSources, + parseDate, + parseDimensions, +} from '#yaml'; + +import {withPropertyFromObject} from '#composite/data'; + +import { + exposeConstant, + exposeDependency, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + commentatorArtists, + constitutibleArtwork, + contributionList, + dimensions, + fileExtension, + name, + referenceList, + simpleDate, + soupyFind, + soupyReverse, + thing, + thingList, + urls, +} from '#composite/wiki-properties'; + +export class Flash extends Thing { + static [Thing.referenceType] = 'flash'; + static [Thing.wikiData] = 'flashData'; + + static [Thing.constitutibleProperties] = [ + 'coverArtwork', // from inline fields + ]; + + static [Thing.getPropertyDescriptors] = ({ + AdditionalName, + CommentaryEntry, + CreditingSourcesEntry, + FlashAct, + Track, + WikiInfo, + }) => ({ + // Update & expose + + act: thing(V(FlashAct)), + + name: name(V('Unnamed Flash')), + + directory: { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + + // Flashes expose directory differently from other Things! Their + // default directory is dependent on the page number (or ID), not + // the name. + expose: { + dependencies: ['page'], + transform(directory, {page}) { + if (directory === null && page === null) return null; + else if (directory === null) return page; + else return directory; + }, + }, + }, + + page: { + flags: {update: true, expose: true}, + update: {validate: anyOf(isString, isNumber)}, + + expose: { + transform: (value) => (value === null ? null : value.toString()), + }, + }, + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withPropertyFromObject('act', V('color')), + exposeDependency('#act.color'), + ], + + date: simpleDate(), + + coverArtFileExtension: fileExtension(V('jpg')), + + coverArtDimensions: dimensions(), + + coverArtwork: + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + + contributorContribs: contributionList({ + artistProperty: input.value('flashContributorContributions'), + }), + + featuredTracks: referenceList({ + class: input.value(Track), + find: soupyFind.input('track'), + }), + + urls: urls(), + + additionalNames: thingList(V(AdditionalName)), + + commentary: thingList(V(CommentaryEntry)), + creditingSources: thingList(V(CreditingSourcesEntry)), + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for withMatchingContributionPresets (indirectly by Contribution) + wikiInfo: thing(V(WikiInfo)), + + // Expose only + + isFlash: exposeConstant(V(true)), + + commentatorArtists: commentatorArtists(), + + side: [ + withPropertyFromObject('act', V('side')), + exposeDependency('#act.side'), + ], + }); + + static [Thing.getSerializeDescriptors] = ({ + serialize: S, + }) => ({ + name: S.id, + page: S.id, + directory: S.id, + date: S.id, + contributors: S.toContribRefs, + tracks: S.toRefs, + urls: S.id, + color: S.id, + }); + + static [Thing.findSpecs] = { + flash: { + referenceTypes: ['flash'], + bindTo: 'flashData', + }, + }; + + 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'}, + 'Directory': {property: 'directory'}, + 'Page': {property: 'page'}, + 'Color': {property: 'color'}, + 'URLs': {property: 'urls'}, + + 'Date': { + property: 'date', + 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': { + property: 'coverArtDimensions', + transform: parseDimensions, + }, + + 'Featured Tracks': {property: 'featuredTracks'}, + + 'Contributors': { + property: 'contributorContribs', + transform: parseContributors, + }, + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, + + 'Review Points': {ignore: true}, + }, + }; + + getOwnArtworkPath(artwork) { + return [ + 'media.flashArt', + this.directory, + artwork.fileExtension, + ]; + } +} diff --git a/src/data/things/flash/FlashAct.js b/src/data/things/flash/FlashAct.js new file mode 100644 index 00000000..66d4ee1b --- /dev/null +++ b/src/data/things/flash/FlashAct.js @@ -0,0 +1,74 @@ + +import {input, V} from '#composite'; +import Thing from '#thing'; +import {isContentString} from '#validators'; + +import {withPropertyFromObject} from '#composite/data'; +import {exposeConstant, exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {color, directory, name, soupyFind, soupyReverse, thing, thingList} + from '#composite/wiki-properties'; + +export class FlashAct extends Thing { + static [Thing.referenceType] = 'flash-act'; + static [Thing.friendlyName] = `Flash Act`; + static [Thing.wikiData] = 'flashActData'; + + static [Thing.getPropertyDescriptors] = ({Flash, FlashSide}) => ({ + // Update & expose + + side: thing(V(FlashSide)), + + name: name(V('Unnamed Flash Act')), + directory: directory(), + color: color(), + + listTerminology: [ + exposeUpdateValueOrContinue({ + validate: input.value(isContentString), + }), + + withPropertyFromObject('side', V('listTerminology')), + exposeDependency('#side.listTerminology'), + ], + + flashes: thingList(V(Flash)), + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // Expose only + + isFlashAct: exposeConstant(V(true)), + }); + + static [Thing.findSpecs] = { + flashAct: { + referenceTypes: ['flash-act'], + bindTo: 'flashActData', + }, + }; + + static [Thing.reverseSpecs] = { + flashActsWhoseFlashesInclude: { + bindTo: 'flashActData', + + referencing: flashAct => [flashAct], + referenced: flashAct => flashAct.flashes, + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Act': {property: 'name'}, + 'Directory': {property: 'directory'}, + + 'Color': {property: 'color'}, + 'List Terminology': {property: 'listTerminology'}, + + 'Review Points': {ignore: true}, + }, + }; +} diff --git a/src/data/things/flash/FlashSide.js b/src/data/things/flash/FlashSide.js new file mode 100644 index 00000000..5e2ea3de --- /dev/null +++ b/src/data/things/flash/FlashSide.js @@ -0,0 +1,56 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; +import {color, contentString, directory, name, soupyFind, thingList} + from '#composite/wiki-properties'; + +export class FlashSide extends Thing { + static [Thing.referenceType] = 'flash-side'; + static [Thing.friendlyName] = `Flash Side`; + static [Thing.wikiData] = 'flashSideData'; + + static [Thing.getPropertyDescriptors] = ({FlashAct}) => ({ + // Update & expose + + name: name(V('Unnamed Flash Side')), + directory: directory(), + color: color(), + listTerminology: contentString(), + + acts: thingList(V(FlashAct)), + + // Update only + + find: soupyFind(), + + // Expose only + + isFlashSide: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Side': {property: 'name'}, + 'Directory': {property: 'directory'}, + 'Color': {property: 'color'}, + 'List Terminology': {property: 'listTerminology'}, + }, + }; + + static [Thing.findSpecs] = { + flashSide: { + referenceTypes: ['flash-side'], + bindTo: 'flashSideData', + }, + }; + + static [Thing.reverseSpecs] = { + flashSidesWhoseActsInclude: { + bindTo: 'flashSideData', + + referencing: flashSide => [flashSide], + referenced: flashSide => flashSide.acts, + }, + }; +} diff --git a/src/data/things/flash/index.js b/src/data/things/flash/index.js new file mode 100644 index 00000000..19b8cc34 --- /dev/null +++ b/src/data/things/flash/index.js @@ -0,0 +1,3 @@ +export * from './Flash.js'; +export * from './FlashAct.js'; +export * from './FlashSide.js'; diff --git a/src/data/things/group.js b/src/data/things/group/Group.js index b40d15b4..6f698682 100644 --- a/src/data/things/group.js +++ b/src/data/things/group/Group.js @@ -1,31 +1,59 @@ -export const GROUP_DATA_FILE = 'groups.yaml'; - -import {input} from '#composite'; +import {input, V} from '#composite'; import Thing from '#thing'; +import {isBoolean} from '#validators'; import {parseAnnotatedReferences, parseSerieses} from '#yaml'; +import {withPropertyFromObject} from '#composite/data'; +import {withUniqueReferencingThing} from '#composite/wiki-data'; + +import { + exposeConstant, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + import { annotatedReferenceList, - color, contentString, directory, + flag, name, referenceList, - seriesList, soupyFind, + soupyReverse, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; export class Group extends Thing { static [Thing.referenceType] = 'group'; + static [Thing.wikiData] = 'groupData'; - static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({ // Update & expose - name: name('Unnamed Group'), + name: name(V('Unnamed Group')), directory: directory(), + excludeFromGalleryTabs: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withUniqueReferencingThing({ + reverse: soupyReverse.input('groupCategoriesWhichInclude'), + }).outputs({ + '#uniqueReferencingThing': '#category', + }), + + withPropertyFromObject('#category', V('excludeGroupsFromGalleryTabs')), + exposeDependencyOrContinue('#category.excludeGroupsFromGalleryTabs'), + + exposeConstant(V(false)), + ], + + divideAlbumsByStyle: flag(V(false)), + description: contentString(), urls: urls(), @@ -43,17 +71,17 @@ export class Group extends Thing { find: soupyFind.input('album'), }), - serieses: seriesList({ - group: input.myself(), - }), + serieses: thingList(V(Series)), // Update only find: soupyFind(), - reverse: soupyFind(), + reverse: soupyReverse(), // Expose only + isGroup: exposeConstant(V(true)), + descriptionShort: { flags: {expose: true}, @@ -70,8 +98,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'reverse'], - compute: ({this: group, reverse}) => + dependencies: ['this', '_reverse'], + compute: ({this: group, _reverse: reverse}) => reverse.albumsWhoseGroupsInclude(group), }, }, @@ -80,8 +108,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'reverse'], - compute: ({this: group, reverse}) => + dependencies: ['this', '_reverse'], + compute: ({this: group, _reverse: reverse}) => reverse.groupCategoriesWhichInclude(group, {unique: true}) ?.color, }, @@ -91,8 +119,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'reverse'], - compute: ({this: group, reverse}) => + dependencies: ['this', '_reverse'], + compute: ({this: group, _reverse: reverse}) => reverse.groupCategoriesWhichInclude(group, {unique: true}) ?? null, }, @@ -129,6 +157,10 @@ export class Group extends Thing { fields: { 'Group': {property: 'name'}, 'Directory': {property: 'directory'}, + + 'Exclude From Gallery Tabs': {property: 'excludeFromGalleryTabs'}, + 'Divide Albums By Style': {property: 'divideAlbumsByStyle'}, + 'Description': {property: 'description'}, 'URLs': {property: 'urls'}, @@ -151,92 +183,4 @@ export class Group extends Thing { 'Review Points': {ignore: true}, }, }; - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allInOne}, - thingConstructors: {Group, GroupCategory}, - }) => ({ - title: `Process groups file`, - file: GROUP_DATA_FILE, - - documentMode: allInOne, - documentThing: document => - ('Category' in document - ? GroupCategory - : Group), - - save(results) { - let groupCategory; - let groupRefs = []; - - if (results[0] && !(results[0] instanceof GroupCategory)) { - throw new Error(`Expected a category at top of group data file`); - } - - for (const thing of results) { - if (thing instanceof GroupCategory) { - if (groupCategory) { - Object.assign(groupCategory, {groups: groupRefs}); - } - - groupCategory = thing; - groupRefs = []; - } else { - groupRefs.push(Thing.getReference(thing)); - } - } - - if (groupCategory) { - Object.assign(groupCategory, {groups: groupRefs}); - } - - const groupData = results.filter(x => x instanceof Group); - const groupCategoryData = results.filter(x => x instanceof GroupCategory); - - return {groupData, groupCategoryData}; - }, - - // Groups aren't sorted at all, always preserving the order in the data - // file as-is. - sort: null, - }); -} - -export class GroupCategory extends Thing { - static [Thing.referenceType] = 'group-category'; - static [Thing.friendlyName] = `Group Category`; - - static [Thing.getPropertyDescriptors] = ({Group}) => ({ - // Update & expose - - name: name('Unnamed Group Category'), - directory: directory(), - - color: color(), - - groups: referenceList({ - class: input.value(Group), - find: soupyFind.input('group'), - }), - - // Update only - - find: soupyFind(), - }); - - static [Thing.reverseSpecs] = { - groupCategoriesWhichInclude: { - bindTo: 'groupCategoryData', - - referencing: groupCategory => [groupCategory], - referenced: groupCategory => groupCategory.groups, - }, - }; - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Category': {property: 'name'}, - 'Color': {property: 'color'}, - }, - }; } diff --git a/src/data/things/group/GroupCategory.js b/src/data/things/group/GroupCategory.js new file mode 100644 index 00000000..daf31868 --- /dev/null +++ b/src/data/things/group/GroupCategory.js @@ -0,0 +1,58 @@ +import {input, V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; + +import {color, directory, flag, name, referenceList, soupyFind} + from '#composite/wiki-properties'; + +export class GroupCategory extends Thing { + static [Thing.referenceType] = 'group-category'; + static [Thing.friendlyName] = `Group Category`; + static [Thing.wikiData] = 'groupCategoryData'; + + static [Thing.getPropertyDescriptors] = ({Group}) => ({ + // Update & expose + + name: name(V('Unnamed Group Category')), + directory: directory(), + + excludeGroupsFromGalleryTabs: flag(V(false)), + + color: color(), + + groups: referenceList({ + class: input.value(Group), + find: soupyFind.input('group'), + }), + + // Update only + + find: soupyFind(), + + // Expose only + + isGroupCategory: exposeConstant(V(true)), + }); + + static [Thing.reverseSpecs] = { + groupCategoriesWhichInclude: { + bindTo: 'groupCategoryData', + + referencing: groupCategory => [groupCategory], + referenced: groupCategory => groupCategory.groups, + }, + }; + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Category': {property: 'name'}, + + 'Color': {property: 'color'}, + + 'Exclude Groups From Gallery Tabs': { + property: 'excludeGroupsFromGalleryTabs', + }, + }, + }; +} diff --git a/src/data/things/group/Series.js b/src/data/things/group/Series.js new file mode 100644 index 00000000..940fe575 --- /dev/null +++ b/src/data/things/group/Series.js @@ -0,0 +1,79 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input, V} from '#composite'; +import Thing from '#thing'; +import {is} from '#validators'; + +import {contentString, name, referenceList, soupyFind, thing} + from '#composite/wiki-properties'; + +export class Series extends Thing { + static [Thing.wikiData] = 'seriesData'; + + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ + // Update & expose + + name: name(V('Unnamed Series')), + + showAlbumArtists: { + flags: {update: true, expose: true}, + update: { + validate: + is('all', 'differing', 'none'), + }, + }, + + description: contentString(), + + group: thing(V(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/group/index.js b/src/data/things/group/index.js new file mode 100644 index 00000000..1723f136 --- /dev/null +++ b/src/data/things/group/index.js @@ -0,0 +1,3 @@ +export * from './Group.js'; +export * from './GroupCategory.js'; +export * from './Series.js'; diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js deleted file mode 100644 index 82bad2d3..00000000 --- a/src/data/things/homepage-layout.js +++ /dev/null @@ -1,338 +0,0 @@ -export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml'; - -import {inspect} from 'node:util'; - -import {colors} from '#cli'; -import {input} from '#composite'; -import Thing from '#thing'; -import {empty} from '#sugar'; - -import { - anyOf, - is, - isCountingNumber, - isString, - isStringNonEmpty, - validateArrayItems, - validateReference, -} from '#validators'; - -import {exposeDependency} from '#composite/control-flow'; -import {withResolvedReference} from '#composite/wiki-data'; - -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] = ({HomepageLayoutSection}) => ({ - // Update & expose - - sidebarContent: contentString(), - - navbarLinks: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isStringNonEmpty)}, - expose: {transform: value => value ?? []}, - }, - - sections: thingList({ - class: input.value(HomepageLayoutSection), - }), - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Homepage': {ignore: true}, - - 'Sidebar Content': {property: 'sidebarContent'}, - 'Navbar Links': {property: 'navbarLinks'}, - }, - }; - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allInOne}, - thingConstructors: { - HomepageLayout, - HomepageLayoutSection, - HomepageLayoutAlbumsRow, - }, - }) => ({ - 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] = ({HomepageLayoutSection}) => ({ - // Update & expose - - section: thing({ - class: input.value(HomepageLayoutSection), - }), - - // Update only - - find: soupyFind(), - - // Expose only - - type: { - flags: {expose: true}, - - expose: { - compute() { - throw new Error(`'type' property validator must be overridden`); - }, - }, - }, - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Row': {ignore: true}, - }, - }; - - [inspect.custom](depth) { - const parts = []; - - parts.push(Thing.prototype[inspect.custom].apply(this)); - - 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)})`); - } - - 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] = Thing.extendDocumentSpec(HomepageLayoutRow, { - fields: { - 'Actions': {property: 'actionLinks'}, - }, - }); -} - -export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow { - static [Thing.friendlyName] = `Homepage Album Carousel Row`; - - static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ - ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), - - // Update & expose - - albums: referenceList({ - class: input.value(Album), - find: soupyFind.input('album'), - }), - - // Expose only - - type: { - flags: {expose: true}, - expose: {compute: () => 'album carousel'}, - }, - }); - - static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { - fields: { - 'Albums': {property: 'albums'}, - }, - }); -} - -export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow { - static [Thing.friendlyName] = `Homepage Album Grid Row`; - - static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ - ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), - - // Update & expose - - sourceGroup: [ - { - flags: {expose: true, update: true, compose: true}, - - update: { - validate: - anyOf( - is('new-releases', 'new-additions'), - validateReference(Group[Thing.referenceType])), - }, - - expose: { - transform: (value, continuation) => - (value === 'new-releases' || value === 'new-additions' - ? value - : continuation(value)), - }, - }, - - withResolvedReference({ - ref: input.updateValue(), - find: soupyFind.input('group'), - }), - - exposeDependency({dependency: '#resolvedReference'}), - ], - - sourceAlbums: referenceList({ - class: input.value(Album), - find: soupyFind.input('album'), - }), - - countAlbumsFromGroup: { - flags: {update: true, expose: true}, - update: {validate: isCountingNumber}, - }, - - // Expose only - - type: { - flags: {expose: true}, - expose: {compute: () => 'album grid'}, - }, - }); - - static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { - fields: { - 'Group': {property: 'sourceGroup'}, - 'Count': {property: 'countAlbumsFromGroup'}, - 'Albums': {property: 'sourceAlbums'}, - }, - }); -} diff --git a/src/data/things/homepage-layout/HomepageLayout.js b/src/data/things/homepage-layout/HomepageLayout.js new file mode 100644 index 00000000..1c432b53 --- /dev/null +++ b/src/data/things/homepage-layout/HomepageLayout.js @@ -0,0 +1,39 @@ +import {V} from '#composite'; +import Thing from '#thing'; +import {isStringNonEmpty, validateArrayItems} from '#validators'; + +import {exposeConstant} from '#composite/control-flow'; +import {contentString, thingList} from '#composite/wiki-properties'; + +export class HomepageLayout extends Thing { + static [Thing.friendlyName] = `Homepage Layout`; + static [Thing.wikiData] = 'homepageLayout'; + static [Thing.oneInstancePerWiki] = true; + + static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({ + // Update & expose + + sidebarContent: contentString(), + + navbarLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isStringNonEmpty)}, + expose: {transform: value => value ?? []}, + }, + + sections: thingList(V(HomepageLayoutSection)), + + // Expose only + + isHomepageLayout: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Homepage': {ignore: true}, + + 'Sidebar Content': {property: 'sidebarContent'}, + 'Navbar Links': {property: 'navbarLinks'}, + }, + }; +} diff --git a/src/data/things/homepage-layout/HomepageLayoutActionsRow.js b/src/data/things/homepage-layout/HomepageLayoutActionsRow.js new file mode 100644 index 00000000..b6d19793 --- /dev/null +++ b/src/data/things/homepage-layout/HomepageLayoutActionsRow.js @@ -0,0 +1,31 @@ +import {V} from '#composite'; +import Thing from '#thing'; +import {validateArrayItems, isString} from '#validators'; + +import {exposeConstant} from '#composite/control-flow'; + +import {HomepageLayoutRow} from './HomepageLayoutRow.js'; + +export class HomepageLayoutActionsRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Actions Row`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + actionLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isString)}, + }, + + // Expose only + + isHomepageLayoutActionsRow: exposeConstant(V(true)), + type: exposeConstant(V('actions')), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Actions': {property: 'actionLinks'}, + }, + }; +} diff --git a/src/data/things/homepage-layout/HomepageLayoutAlbumCarouselRow.js b/src/data/things/homepage-layout/HomepageLayoutAlbumCarouselRow.js new file mode 100644 index 00000000..41cfd0af --- /dev/null +++ b/src/data/things/homepage-layout/HomepageLayoutAlbumCarouselRow.js @@ -0,0 +1,31 @@ +import {input, V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; +import {referenceList, soupyFind} from '#composite/wiki-properties'; + +import {HomepageLayoutRow} from './HomepageLayoutRow.js'; + +export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Album Carousel Row`; + + static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({ + // Update & expose + + albums: referenceList({ + class: input.value(Album), + find: soupyFind.input('album'), + }), + + // Expose only + + isHomepageLayoutAlbumCarouselRow: exposeConstant(V(true)), + type: exposeConstant(V('album carousel')), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Albums': {property: 'albums'}, + }, + }; +} diff --git a/src/data/things/homepage-layout/HomepageLayoutAlbumGridRow.js b/src/data/things/homepage-layout/HomepageLayoutAlbumGridRow.js new file mode 100644 index 00000000..fafeb1ed --- /dev/null +++ b/src/data/things/homepage-layout/HomepageLayoutAlbumGridRow.js @@ -0,0 +1,68 @@ +import {input, V} from '#composite'; +import Thing from '#thing'; + +import {anyOf, is, isCountingNumber, validateReference} from '#validators'; + +import {exposeConstant, exposeDependency} from '#composite/control-flow'; +import {withResolvedReference} from '#composite/wiki-data'; +import {referenceList, soupyFind} from '#composite/wiki-properties'; + +import {HomepageLayoutRow} from './HomepageLayoutRow.js'; + +export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Album Grid Row`; + + static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ + // Update & expose + + sourceGroup: [ + { + flags: {expose: true, update: true, compose: true}, + + update: { + validate: + anyOf( + is('new-releases', 'new-additions'), + validateReference(Group[Thing.referenceType])), + }, + + expose: { + transform: (value, continuation) => + (value === 'new-releases' || value === 'new-additions' + ? value + : continuation(value)), + }, + }, + + withResolvedReference({ + ref: input.updateValue(), + find: soupyFind.input('group'), + }), + + exposeDependency('#resolvedReference'), + ], + + sourceAlbums: referenceList({ + class: input.value(Album), + find: soupyFind.input('album'), + }), + + countAlbumsFromGroup: { + flags: {update: true, expose: true}, + update: {validate: isCountingNumber}, + }, + + // Expose only + + isHomepageLayoutAlbumGridRow: exposeConstant(V(true)), + type: exposeConstant(V('album grid')), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Group': {property: 'sourceGroup'}, + 'Count': {property: 'countAlbumsFromGroup'}, + 'Albums': {property: 'sourceAlbums'}, + }, + }; +} diff --git a/src/data/things/homepage-layout/HomepageLayoutRow.js b/src/data/things/homepage-layout/HomepageLayoutRow.js new file mode 100644 index 00000000..5b0899e9 --- /dev/null +++ b/src/data/things/homepage-layout/HomepageLayoutRow.js @@ -0,0 +1,60 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; +import {soupyFind, thing} from '#composite/wiki-properties'; + +export class HomepageLayoutRow extends Thing { + static [Thing.friendlyName] = `Homepage Row`; + + static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({ + // Update & expose + + section: thing(V(HomepageLayoutSection)), + + // Update only + + find: soupyFind(), + + // Expose only + + isHomepageLayoutRow: exposeConstant(V(true)), + + type: { + flags: {expose: true}, + + expose: { + compute() { + throw new Error(`'type' property validator must be overridden`); + }, + }, + }, + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Row': {ignore: true}, + }, + }; + + [inspect.custom](depth) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + 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)})`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/homepage-layout/HomepageLayoutSection.js b/src/data/things/homepage-layout/HomepageLayoutSection.js new file mode 100644 index 00000000..1593ba6e --- /dev/null +++ b/src/data/things/homepage-layout/HomepageLayoutSection.js @@ -0,0 +1,30 @@ +import {V} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; +import {color, name, thingList} from '#composite/wiki-properties'; + +export class HomepageLayoutSection extends Thing { + static [Thing.friendlyName] = `Homepage Section`; + + static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ + // Update & expose + + name: name(V(`Unnamed Homepage Section`)), + + color: color(), + + rows: thingList(V(HomepageLayoutRow)), + + // Expose only + + isHomepageLayoutSection: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Section': {property: 'name'}, + 'Color': {property: 'color'}, + }, + }; +} diff --git a/src/data/things/homepage-layout/index.js b/src/data/things/homepage-layout/index.js new file mode 100644 index 00000000..d003e39a --- /dev/null +++ b/src/data/things/homepage-layout/index.js @@ -0,0 +1,6 @@ +export * from './HomepageLayout.js'; +export * from './HomepageLayoutSection.js'; +export * from './HomepageLayoutRow.js'; +export * from './HomepageLayoutActionsRow.js'; +export * from './HomepageLayoutAlbumCarouselRow.js'; +export * from './HomepageLayoutAlbumGridRow.js'; diff --git a/src/data/things/index.js b/src/data/things/index.js index b832ab75..3773864b 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -1,229 +1,21 @@ -import * as path from 'node:path'; -import {fileURLToPath} from 'node:url'; - -import {openAggregate, showAggregate} from '#aggregate'; -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 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 = { - '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, -}; - -let allClasses = Object.create(null); - -// src/data/things/index.js -> src/ -const __dirname = path.dirname( - path.resolve( - fileURLToPath(import.meta.url), - '../..')); - -function niceShowAggregate(error, ...opts) { - showAggregate(error, { - pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), - ...opts, - }); -} - -function errorDuplicateClassNames() { - const locationDict = Object.create(null); - - for (const [location, classes] of Object.entries(allClassLists)) { - for (const className of Object.keys(classes)) { - if (className in locationDict) { - locationDict[className].push(location); - } else { - locationDict[className] = [location]; - } - } - } - - let success = true; - - for (const [className, locations] of Object.entries(locationDict)) { - if (locations.length === 1) { - continue; - } - - logError`Thing class name ${`"${className}"`} is defined more than once: ${locations.join(', ')}`; - success = false; - } - - return success; -} - -function flattenClassLists() { - 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; - 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({ - showFailedClasses, - message, - op, -}) { - const failureSymbol = Symbol(); - const aggregate = openAggregate({ - message, - returnOnFail: failureSymbol, - }); - - const failedClasses = []; - - for (const [name, constructor] of Object.entries(allClasses)) { - const result = aggregate.call(op, constructor); - - if (result === failureSymbol) { - failedClasses.push(name); - } - } - - try { - aggregate.close(); - return true; - } catch (error) { - niceShowAggregate(error); - showFailedClasses(failedClasses); - return false; - } -} - -function evaluatePropertyDescriptors() { - const opts = {...allClasses}; - - return descriptorAggregateHelper({ - message: `Errors evaluating Thing class property descriptors`, - - op(constructor) { - if (!constructor[Thing.getPropertyDescriptors]) { - throw new Error(`Missing [Thing.getPropertyDescriptors] function`); - } - - const results = constructor[Thing.getPropertyDescriptors](opts); - - for (const [key, value] of Object.entries(results)) { - if (Array.isArray(value)) { - results[key] = compositeFrom({ - annotation: `${constructor.name}.${key}`, - compose: false, - steps: value, - }); - } else if (value.toResolvedComposition) { - results[key] = compositeFrom(value.toResolvedComposition()); - } - } - - constructor[CacheableObject.propertyDescriptors] = { - ...constructor[CacheableObject.propertyDescriptors] ?? {}, - ...results, - }; - }, - - showFailedClasses(failedClasses) { - logError`Failed to evaluate property descriptors for classes: ${failedClasses.join(', ')}`; - }, - }); -} - -function evaluateSerializeDescriptors() { - const opts = {...allClasses, serialize}; - - return descriptorAggregateHelper({ - message: `Errors evaluating Thing class serialize descriptors`, - - op(constructor) { - if (!constructor[Thing.getSerializeDescriptors]) { - return; - } - - constructor[serialize.serializeDescriptors] = - constructor[Thing.getSerializeDescriptors](opts); - }, - - showFailedClasses(failedClasses) { - logError`Failed to evaluate serialize descriptors for classes: ${failedClasses.join(', ')}`; - }, - }); -} - -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); - -flattenClassLists(); - -if (!evaluatePropertyDescriptors()) - process.exit(1); - -if (!evaluateSerializeDescriptors()) - process.exit(1); - -if (!finalizeCacheableObjectPrototypes()) - process.exit(1); - -Object.assign(allClasses, {Thing}); - -export default allClasses; +// Not actually the entry point for #things - that's init.js in this folder. + +export * from './album/index.js'; +export * from './content/index.js'; +export * from './contrib/index.js'; +export * from './flash/index.js'; +export * from './group/index.js'; +export * from './homepage-layout/index.js'; +export * from './sorting-rule/index.js'; + +export * from './AdditionalFile.js'; +export * from './AdditionalName.js'; +export * from './ArtTag.js'; +export * from './Artist.js'; +export * from './Artwork.js'; +export * from './Language.js'; +export * from './MusicVideo.js'; +export * from './NewsEntry.js'; +export * from './StaticPage.js'; +export * from './Track.js'; +export * from './WikiInfo.js'; diff --git a/src/data/things/init.js b/src/data/things/init.js new file mode 100644 index 00000000..e705f626 --- /dev/null +++ b/src/data/things/init.js @@ -0,0 +1,208 @@ +// This is the actual entry point for #things. + +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import {openAggregate, showAggregate} from '#aggregate'; +import CacheableObject from '#cacheable-object'; +import {logError} from '#cli'; +import {compositeFrom} from '#composite'; +import * as serialize from '#serialize'; +import {empty} from '#sugar'; +import Thing from '#thing'; + +import * as indexExports from './index.js'; + +const thingConstructors = Object.create(null); + +// src/data/things/index.js -> src/ +const __dirname = path.dirname( + path.resolve( + fileURLToPath(import.meta.url), + '../..')); + +function niceShowAggregate(error, ...opts) { + showAggregate(error, { + pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), + showClasses: false, + ...opts, + }); +} + +function sortThingConstructors() { + let remaining = []; + for (const constructor of Object.values(indexExports)) { + if (typeof constructor !== 'function') continue; + if (!(constructor.prototype instanceof Thing)) continue; + remaining.push(constructor); + } + + let sorted = []; + while (true) { + if (sorted[0]) { + const superclass = Object.getPrototypeOf(sorted[0]); + if (superclass !== Thing) { + if (sorted.includes(superclass)) { + sorted.unshift(...sorted.splice(sorted.indexOf(superclass), 1)); + } else { + sorted.unshift(superclass); + } + continue; + } + } + + if (!empty(remaining)) { + sorted.unshift(remaining.shift()); + } else { + break; + } + } + + for (const constructor of sorted) { + thingConstructors[constructor.name] = constructor; + } +} + +function descriptorAggregateHelper({ + showFailedClasses, + message, + op, +}) { + const failureSymbol = Symbol(); + const aggregate = openAggregate({ + message, + returnOnFail: failureSymbol, + }); + + const failedClasses = []; + + for (const [name, constructor] of Object.entries(thingConstructors)) { + const result = aggregate.call(op, constructor); + + if (result === failureSymbol) { + failedClasses.push(name); + } + } + + try { + aggregate.close(); + return true; + } catch (error) { + niceShowAggregate(error); + showFailedClasses(failedClasses); + + /* + if (error.errors) { + for (const sub of error.errors) { + console.error(sub); + } + } + */ + + return false; + } +} + +function evaluatePropertyDescriptors() { + const opts = {...thingConstructors}; + + return descriptorAggregateHelper({ + message: `Errors evaluating Thing class property descriptors`, + + op(constructor) { + if (!constructor[Thing.getPropertyDescriptors]) { + throw new Error(`Missing [Thing.getPropertyDescriptors] function`); + } + + const results = constructor[Thing.getPropertyDescriptors](opts); + + for (const [key, value] of Object.entries(results)) { + if (Array.isArray(value)) { + results[key] = compositeFrom({ + annotation: `${constructor.name}.${key}`, + compose: false, + steps: value, + }); + } else if (value.toResolvedComposition) { + results[key] = compositeFrom(value.toResolvedComposition()); + } + } + + constructor[CacheableObject.propertyDescriptors] = + Object.create(constructor[CacheableObject.propertyDescriptors] ?? null); + + Object.assign(constructor[CacheableObject.propertyDescriptors], results); + }, + + showFailedClasses(failedClasses) { + logError`Failed to evaluate property descriptors for classes: ${failedClasses.join(', ')}`; + }, + }); +} + +function evaluateSerializeDescriptors() { + const opts = {...thingConstructors, serialize}; + + return descriptorAggregateHelper({ + message: `Errors evaluating Thing class serialize descriptors`, + + op(constructor) { + if (!constructor[Thing.getSerializeDescriptors]) { + return; + } + + constructor[serialize.serializeDescriptors] = + constructor[Thing.getSerializeDescriptors](opts); + }, + + showFailedClasses(failedClasses) { + logError`Failed to evaluate serialize descriptors for classes: ${failedClasses.join(', ')}`; + }, + }); +} + +function finalizeYamlDocumentSpecs() { + return descriptorAggregateHelper({ + message: `Errors finalizing Thing YAML document specs`, + + op(constructor) { + const superclass = Object.getPrototypeOf(constructor); + if ( + constructor[Thing.yamlDocumentSpec] && + superclass[Thing.yamlDocumentSpec] + ) { + constructor[Thing.yamlDocumentSpec] = + Thing.extendDocumentSpec(superclass, constructor[Thing.yamlDocumentSpec]); + } + }, + + showFailedClasses(failedClasses) { + logError`Failed to finalize YAML document specs for classes: ${failedClasses.join(', ')}`; + }, + }); +} + +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(', ')}`; + }, + }); +} + +sortThingConstructors(); + +if (!evaluatePropertyDescriptors()) process.exit(1); +if (!evaluateSerializeDescriptors()) process.exit(1); +if (!finalizeYamlDocumentSpecs()) process.exit(1); +if (!finalizeCacheableObjectPrototypes()) process.exit(1); + +Object.assign(thingConstructors, {Thing}); + +export default thingConstructors; diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule/DocumentSortingRule.js index b169a541..0f67d8f5 100644 --- a/src/data/things/sorting-rule.js +++ b/src/data/things/sorting-rule/DocumentSortingRule.js @@ -1,28 +1,19 @@ -export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml'; - import {readFile, writeFile} from 'node:fs/promises'; import * as path from 'node:path'; -import {input} from '#composite'; -import {chunkByProperties, compareArrays, unique} from '#sugar'; +import {V} from '#composite'; +import {chunkByProperties, compareArrays} 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'; +import {exposeConstant} from '#composite/control-flow'; function isSelectFollowingEntry(value) { isObject(value); @@ -35,144 +26,7 @@ function isSelectFollowingEntry(value) { 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; - } -} +import {ThingSortingRule} from './ThingSortingRule.js'; export class DocumentSortingRule extends ThingSortingRule { static [Thing.getPropertyDescriptors] = () => ({ @@ -218,9 +72,13 @@ export class DocumentSortingRule extends ThingSortingRule { flags: {update: true, expose: true}, update: {validate: isStringNonEmpty}, }, + + // Expose only + + isDocumentSortingRule: exposeConstant(V(true)), }); - static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, { + static [Thing.yamlDocumentSpec] = { fields: { 'Sort Documents': {property: 'filename'}, 'Select Documents Following': {property: 'selectDocumentsFollowing'}, @@ -233,7 +91,7 @@ export class DocumentSortingRule extends ThingSortingRule { 'Select Documents Under', ]}, ], - }); + }; static async apply(rule, {wikiData, dataPath, dry}) { const oldLayout = getThingLayoutForFilename(rule.filename, wikiData); @@ -261,10 +119,8 @@ export class DocumentSortingRule extends ThingSortingRule { } static async* applyAll(rules, {wikiData, dataPath, dry}) { - rules = - rules - .slice() - .sort((a, b) => a.filename.localeCompare(b.filename, 'en')); + rules = rules + .toSorted((a, b) => a.filename.localeCompare(b.filename, 'en')); for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) { const initialLayout = getThingLayoutForFilename(filename, wikiData); diff --git a/src/data/things/sorting-rule/SortingRule.js b/src/data/things/sorting-rule/SortingRule.js new file mode 100644 index 00000000..5d4bba99 --- /dev/null +++ b/src/data/things/sorting-rule/SortingRule.js @@ -0,0 +1,70 @@ +import {V} from '#composite'; +import {unique} from '#sugar'; +import Thing from '#thing'; +import {isStringNonEmpty} from '#validators'; + +import {exposeConstant} from '#composite/control-flow'; +import {flag} from '#composite/wiki-properties'; + +export class SortingRule extends Thing { + static [Thing.friendlyName] = `Sorting Rule`; + static [Thing.wikiData] = 'sortingRules'; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + active: flag(V(true)), + + message: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + // Expose only + + isSortingRule: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Message': {property: 'message'}, + 'Active': {property: 'active'}, + }, + }; + + 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}); + } + } +} diff --git a/src/data/things/sorting-rule/ThingSortingRule.js b/src/data/things/sorting-rule/ThingSortingRule.js new file mode 100644 index 00000000..b5cc76dc --- /dev/null +++ b/src/data/things/sorting-rule/ThingSortingRule.js @@ -0,0 +1,83 @@ +import {V} from '#composite'; +import Thing from '#thing'; +import {isStringNonEmpty, strictArrayOf} from '#validators'; + +import { + compareCaseLessSensitive, + sortByDate, + sortByDirectory, + sortByName, +} from '#sort'; + +import {exposeConstant} from '#composite/control-flow'; + +import {SortingRule} from './SortingRule.js'; + +export class ThingSortingRule extends SortingRule { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + properties: { + flags: {update: true, expose: true}, + update: { + validate: strictArrayOf(isStringNonEmpty), + }, + }, + + // Expose only + + isThingSortingRule: exposeConstant(V(true)), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'By Properties': {property: 'properties'}, + }, + }; + + sort(sortable) { + if (this.properties) { + for (const property of this.properties.toReversed()) { + 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; + } +} diff --git a/src/data/things/sorting-rule/index.js b/src/data/things/sorting-rule/index.js new file mode 100644 index 00000000..7b83bd44 --- /dev/null +++ b/src/data/things/sorting-rule/index.js @@ -0,0 +1,3 @@ +export * from './SortingRule.js'; +export * from './ThingSortingRule.js'; +export * from './DocumentSortingRule.js'; diff --git a/src/data/things/track.js b/src/data/things/track.js deleted file mode 100644 index 57aaa90d..00000000 --- a/src/data/things/track.js +++ /dev/null @@ -1,781 +0,0 @@ -import {inspect} from 'node:util'; - -import CacheableObject from '#cacheable-object'; -import {colors} from '#cli'; -import {input} from '#composite'; -import Thing from '#thing'; -import {isBoolean, isColor, isContributionList, isDate, isFileExtension} - from '#validators'; - -import { - parseAdditionalFiles, - parseAdditionalNames, - parseAnnotatedReferences, - parseArtwork, - parseCommentary, - parseContributors, - parseCreditingSources, - parseDate, - parseDimensions, - parseDuration, - parseLyrics, -} from '#yaml'; - -import {withPropertyFromObject} from '#composite/data'; - -import { - exposeConstant, - exposeDependency, - exposeDependencyOrContinue, - exposeUpdateValueOrContinue, - exposeWhetherDependencyAvailable, -} from '#composite/control-flow'; - -import { - withRecontextualizedContributionList, - withRedatedContributionList, - withResolvedContribs, -} from '#composite/wiki-data'; - -import { - additionalFiles, - additionalNameList, - commentatorArtists, - constitutibleArtworkList, - contentString, - contributionList, - dimensions, - directory, - duration, - flag, - name, - referenceList, - referencedArtworkList, - reverseReferenceList, - simpleDate, - simpleString, - singleReference, - soupyFind, - soupyReverse, - thing, - thingList, - urls, - wikiData, -} from '#composite/wiki-properties'; - -import { - exitWithoutUniqueCoverArt, - inheritContributionListFromMainRelease, - inheritFromMainRelease, - withAllReleases, - withAlwaysReferenceByDirectory, - withContainingTrackSection, - withCoverArtistContribs, - withDate, - withDirectorySuffix, - withHasUniqueCoverArt, - withMainRelease, - withOtherReleases, - withPropertyFromAlbum, - withSuffixDirectoryFromAlbum, - withTrackArtDate, - withTrackNumber, -} from '#composite/things/track'; - -export class Track extends Thing { - static [Thing.referenceType] = 'track'; - - static [Thing.getPropertyDescriptors] = ({ - Album, - ArtTag, - Artwork, - CommentaryEntry, - CreditingSourcesEntry, - Flash, - LyricsEntry, - TrackSection, - WikiInfo, - }) => ({ - // Update & expose - - name: name('Unnamed Track'), - - directory: [ - withDirectorySuffix(), - - directory({ - suffix: '#directorySuffix', - }), - ], - - suffixDirectoryFromAlbum: [ - { - dependencies: [ - input.updateValue({validate: isBoolean}), - ], - - compute: (continuation, { - [input.updateValue()]: value, - }) => continuation({ - ['#flagValue']: value ?? false, - }), - }, - - withSuffixDirectoryFromAlbum({ - flagValue: '#flagValue', - }), - - exposeDependency({ - dependency: '#suffixDirectoryFromAlbum', - }) - ], - - album: thing({ - class: input.value(Album), - }), - - additionalNames: additionalNameList(), - - bandcampTrackIdentifier: simpleString(), - bandcampArtworkIdentifier: simpleString(), - - duration: duration(), - urls: urls(), - dateFirstReleased: simpleDate(), - - color: [ - exposeUpdateValueOrContinue({ - validate: input.value(isColor), - }), - - withContainingTrackSection(), - - withPropertyFromObject({ - object: '#trackSection', - property: input.value('color'), - }), - - exposeDependencyOrContinue({dependency: '#trackSection.color'}), - - withPropertyFromAlbum({ - property: input.value('color'), - }), - - exposeDependency({dependency: '#album.color'}), - ], - - alwaysReferenceByDirectory: [ - withAlwaysReferenceByDirectory(), - exposeDependency({dependency: '#alwaysReferenceByDirectory'}), - ], - - // 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 - // as the track's own coverArtists. - disableUniqueCoverArt: flag(), - - // File extension for track's corresponding media file. This represents the - // track's unique cover artwork, if any, and does not inherit the extension - // of the album's main artwork. It does inherit trackCoverArtFileExtension, - // if present on the album. - coverArtFileExtension: [ - exitWithoutUniqueCoverArt(), - - exposeUpdateValueOrContinue({ - validate: input.value(isFileExtension), - }), - - withPropertyFromAlbum({ - property: input.value('trackCoverArtFileExtension'), - }), - - exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), - - exposeConstant({ - value: input.value('jpg'), - }), - ], - - coverArtDate: [ - withTrackArtDate({ - from: input.updateValue({ - validate: isDate, - }), - }), - - exposeDependency({dependency: '#trackArtDate'}), - ], - - coverArtDimensions: [ - exitWithoutUniqueCoverArt(), - - exposeUpdateValueOrContinue(), - - withPropertyFromAlbum({ - property: input.value('trackDimensions'), - }), - - exposeDependencyOrContinue({dependency: '#album.trackDimensions'}), - - dimensions(), - ], - - commentary: thingList({ - class: input.value(CommentaryEntry), - }), - - creditSources: thingList({ - class: input.value(CreditingSourcesEntry), - }), - - lyrics: [ - // 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(), - - mainReleaseTrack: singleReference({ - class: input.value(Track), - find: soupyFind.input('track'), - }), - - artistContribs: [ - inheritContributionListFromMainRelease(), - - withDate(), - - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - thingProperty: input.thisProperty(), - artistProperty: input.value('trackArtistContributions'), - date: '#date', - }).outputs({ - '#resolvedContribs': '#artistContribs', - }), - - exposeDependencyOrContinue({ - dependency: '#artistContribs', - mode: input.value('empty'), - }), - - withPropertyFromAlbum({ - property: input.value('artistContribs'), - }), - - withRecontextualizedContributionList({ - list: '#album.artistContribs', - artistProperty: input.value('trackArtistContributions'), - }), - - withRedatedContributionList({ - list: '#album.artistContribs', - date: '#date', - }), - - exposeDependency({dependency: '#album.artistContribs'}), - ], - - contributorContribs: [ - inheritContributionListFromMainRelease(), - - withDate(), - - contributionList({ - date: '#date', - artistProperty: input.value('trackContributorContributions'), - }), - ], - - coverArtistContribs: [ - withCoverArtistContribs({ - from: input.updateValue({ - validate: isContributionList, - }), - }), - - exposeDependency({dependency: '#coverArtistContribs'}), - ], - - referencedTracks: [ - inheritFromMainRelease({ - notFoundValue: input.value([]), - }), - - referenceList({ - class: input.value(Track), - find: soupyFind.input('track'), - }), - ], - - sampledTracks: [ - inheritFromMainRelease({ - notFoundValue: input.value([]), - }), - - referenceList({ - class: input.value(Track), - find: soupyFind.input('track'), - }), - ], - - trackArtworks: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), - }), - - constitutibleArtworkList.fromYAMLFieldSpec - .call(this, 'Track Artwork'), - ], - - artTags: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), - }), - - referenceList({ - class: input.value(ArtTag), - find: soupyFind.input('artTag'), - }), - ], - - referencedArtworks: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), - }), - - referencedArtworkList(), - ], - - // Update only - - find: soupyFind(), - reverse: soupyReverse(), - - // used for referencedArtworkList (mixedFind) - artworkData: wikiData({ - class: input.value(Artwork), - }), - - // used for withAlwaysReferenceByDirectory (for some reason) - trackData: wikiData({ - class: input.value(Track), - }), - - // used for withMatchingContributionPresets (indirectly by Contribution) - wikiInfo: thing({ - class: input.value(WikiInfo), - }), - - // Expose only - - commentatorArtists: commentatorArtists(), - - date: [ - withDate(), - exposeDependency({dependency: '#date'}), - ], - - trackNumber: [ - withTrackNumber(), - exposeDependency({dependency: '#trackNumber'}), - ], - - hasUniqueCoverArt: [ - withHasUniqueCoverArt(), - exposeDependency({dependency: '#hasUniqueCoverArt'}), - ], - - isMainRelease: [ - withMainRelease(), - - exposeWhetherDependencyAvailable({ - dependency: '#mainRelease', - negate: input.value(true), - }), - ], - - isSecondaryRelease: [ - withMainRelease(), - - exposeWhetherDependencyAvailable({ - 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: reverseReferenceList({ - reverse: soupyReverse.input('tracksWhichReference'), - }), - - sampledByTracks: reverseReferenceList({ - reverse: soupyReverse.input('tracksWhichSample'), - }), - - featuredInFlashes: reverseReferenceList({ - reverse: soupyReverse.input('flashesWhichFeature'), - }), - }); - - static [Thing.yamlDocumentSpec] = { - fields: { - 'Track': {property: 'name'}, - 'Directory': {property: 'directory'}, - 'Suffix Directory': {property: 'suffixDirectoryFromAlbum'}, - - 'Additional Names': { - property: 'additionalNames', - transform: parseAdditionalNames, - }, - - 'Bandcamp Track ID': { - property: 'bandcampTrackIdentifier', - transform: String, - }, - - 'Bandcamp Artwork ID': { - property: 'bandcampArtworkIdentifier', - transform: String, - }, - - 'Duration': { - property: 'duration', - transform: parseDuration, - }, - - 'Color': {property: 'color'}, - 'URLs': {property: 'urls'}, - - 'Date First Released': { - property: 'dateFirstReleased', - transform: parseDate, - }, - - 'Cover Art Date': { - property: 'coverArtDate', - transform: parseDate, - }, - - 'Cover Art File Extension': {property: 'coverArtFileExtension'}, - - 'Cover Art Dimensions': { - property: 'coverArtDimensions', - transform: parseDimensions, - }, - - 'Has Cover Art': { - property: 'disableUniqueCoverArt', - transform: value => - (typeof value === 'boolean' - ? !value - : value), - }, - - 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, - - 'Lyrics': { - property: 'lyrics', - transform: parseLyrics, - }, - - 'Commentary': { - property: 'commentary', - transform: parseCommentary, - }, - - 'Credit Sources': { - property: 'creditSources', - transform: parseCreditingSources, - }, - - 'Additional Files': { - property: 'additionalFiles', - transform: parseAdditionalFiles, - }, - - 'Sheet Music Files': { - property: 'sheetMusicFiles', - transform: parseAdditionalFiles, - }, - - 'MIDI Project Files': { - property: 'midiProjectFiles', - transform: parseAdditionalFiles, - }, - - 'Main Release': {property: 'mainReleaseTrack'}, - 'Referenced Tracks': {property: 'referencedTracks'}, - 'Sampled Tracks': {property: 'sampledTracks'}, - - 'Referenced Artworks': { - property: 'referencedArtworks', - transform: parseAnnotatedReferences, - }, - - 'Franchises': {ignore: true}, - 'Inherit Franchises': {ignore: true}, - - 'Artists': { - property: 'artistContribs', - transform: parseContributors, - }, - - 'Contributors': { - property: 'contributorContribs', - transform: parseContributors, - }, - - 'Cover Artists': { - property: 'coverArtistContribs', - 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: `Secondary releases inherit references from the main one`, fields: [ - 'Main Release', - 'Referenced Tracks', - ]}, - - {message: `Secondary releases inherit samples from the main one`, fields: [ - 'Main Release', - 'Sampled Tracks', - ]}, - - {message: `Secondary releases inherit artists from the main one`, fields: [ - 'Main Release', - 'Artists', - ]}, - - {message: `Secondary releases inherit contributors from the main one`, fields: [ - 'Main Release', - 'Contributors', - ]}, - - {message: `Secondary releases inherit lyrics from the main one`, fields: [ - 'Main Release', - 'Lyrics', - ]}, - - { - message: ({'Has Cover Art': hasCoverArt}) => - (hasCoverArt - ? `"Has Cover Art: true" is inferred from cover artist credits` - : `Tracks without cover art must not have cover artist credits`), - - fields: [ - 'Has Cover Art', - 'Cover Artists', - ], - }, - ], - }; - - static [Thing.findSpecs] = { - track: { - referenceTypes: ['track'], - - bindTo: 'trackData', - - getMatchableNames: track => - (track.alwaysReferenceByDirectory - ? [] - : [track.name]), - }, - - trackMainReleasesOnly: { - referenceTypes: ['track'], - bindTo: 'trackData', - - include: track => - !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'), - - // It's still necessary to check alwaysReferenceByDirectory here, since - // it may be set manually (with `Always Reference By Directory: true`), - // and these shouldn't be matched by name (as per usual). - // See the definition for that property for more information. - getMatchableNames: track => - (track.alwaysReferenceByDirectory - ? [] - : [track.name]), - }, - - trackWithArtwork: { - referenceTypes: [ - 'track', - 'track-referencing-artworks', - 'track-referenced-artworks', - ], - - bindTo: 'trackData', - - include: track => - track.hasUniqueCoverArt, - - getMatchableNames: track => - (track.alwaysReferenceByDirectory - ? [] - : [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; - - getOwnArtworkPath(artwork) { - if (!this.album) return null; - - return [ - 'media.trackCover', - this.album.directory, - - (artwork.unqualifiedDirectory - ? this.directory + '-' + artwork.unqualifiedDirectory - : this.directory), - - artwork.fileExtension, - ]; - } - - [inspect.custom](depth) { - const parts = []; - - parts.push(Thing.prototype[inspect.custom].apply(this)); - - if (CacheableObject.getUpdateValue(this, 'mainReleaseTrack')) { - parts.unshift(`${colors.yellow('[secrelease]')} `); - } - - let album; - - if (depth >= 0) { - album = this.album; - } - - if (album) { - const albumName = album.name; - const albumIndex = album.tracks.indexOf(this); - const trackNum = - (albumIndex === -1 - ? 'indeterminate position' - : `#${albumIndex + 1}`); - parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`); - } - - return parts.join(''); - } -} diff --git a/src/data/yaml.js b/src/data/yaml.js index 036fe8a7..7ba6d559 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -7,11 +7,13 @@ import {inspect as nodeInspect} from 'node:util'; import yaml from 'js-yaml'; +import * as fileLoadingSpecs from '#files'; 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 {matchContentEntries} from '#wiki-data'; import { aggregateThrows, @@ -43,6 +45,42 @@ function inspect(value, opts = {}) { return nodeInspect(value, {colors: ENABLE_COLOR, ...opts}); } +function makeEmptyWikiData() { + const wikiData = {}; + + for (const thingConstructor of Object.values(thingConstructors)) { + if (thingConstructor[Thing.wikiData]) { + if (thingConstructor[Thing.oneInstancePerWiki]) { + wikiData[thingConstructor[Thing.wikiData]] = null; + } else { + wikiData[thingConstructor[Thing.wikiData]] = []; + } + } + } + + return wikiData; +} + +function pushWikiData(a, b) { + for (const key of Object.keys(b)) { + if (!Object.hasOwn(a, key)) { + throw new Error(`${key} not present`); + } + + if (Array.isArray(a[key])) { + if (Array.isArray(b[key])) { + a[key].push(...b[key]); + } else { + throw new Error(`${key} is an array, expected array of items to push`); + } + } else if (a[key] === null) { + a[key] = b[key]; + } else if (b[key] !== null) { + throw new Error(`${key} already has a value: ${inspect(a[key])}`); + } + } +} + // General function for inputting a single document (usually loaded from YAML) // and outputting an instance of a provided Thing subclass. // @@ -86,7 +124,7 @@ function makeProcessDocument(thingConstructor, { // ] // // ...means A can't coexist with B or C, B can't coexist with A or C, and - // C can't coexist iwth A, B, or D - but it's okay for D to coexist with + // C can't coexist with A, B, or D - but it's okay for D to coexist with // A or B. // invalidFieldCombinations = [], @@ -160,6 +198,16 @@ function makeProcessDocument(thingConstructor, { const thing = Reflect.construct(thingConstructor, []); + const wikiData = makeEmptyWikiData(); + const flat = [thing]; + if (thingConstructor[Thing.wikiData]) { + if (thingConstructor[Thing.oneInstancePerWiki]) { + wikiData[thingConstructor[Thing.wikiData]] = thing; + } else { + wikiData[thingConstructor[Thing.wikiData]] = [thing]; + } + } + const documentEntries = Object.entries(document) .filter(([field]) => !ignoredFields.includes(field)); @@ -181,9 +229,22 @@ function makeProcessDocument(thingConstructor, { const fieldCombinationErrors = []; - for (const {message, fields} of invalidFieldCombinations) { + for (const {message, fields: fieldsSpec} of invalidFieldCombinations) { const fieldsPresent = - presentFields.filter(field => fields.includes(field)); + fieldsSpec.flatMap(fieldSpec => { + if (Array.isArray(fieldSpec)) { + const [field, match] = fieldSpec; + if (!presentFields.includes(field)) return []; + if (typeof match === 'function') { + return match(document[field]) ? [field] : []; + } else { + return document[field] === match ? [field] : []; + } + } + + const field = fieldSpec; + return presentFields.includes(field) ? [field] : []; + }); if (fieldsPresent.length >= 2) { const filteredDocument = @@ -193,7 +254,10 @@ function makeProcessDocument(thingConstructor, { {preserveOriginalOrder: true}); fieldCombinationErrors.push( - new FieldCombinationError(filteredDocument, message)); + new FieldCombinationError( + filteredDocument, + fieldsSpec, + message)); for (const field of Object.keys(filteredDocument)) { skippedFields.add(field); @@ -249,7 +313,9 @@ function makeProcessDocument(thingConstructor, { // This variable would like to certify itself as "not into capitalism". let propertyValue = - (fieldSpecs[field].transform + (documentValue === null + ? null + : fieldSpecs[field].transform ? fieldSpecs[field].transform(documentValue, transformUtilities) : documentValue); @@ -293,26 +359,29 @@ function makeProcessDocument(thingConstructor, { const followSubdocSetup = setup => { let error = null; - let subthing; + let result; try { - const result = bouncer(setup.data, setup.documentType); - subthing = result.thing; - result.aggregate.close(); + let aggregate; + ({result, aggregate} = bouncer(setup.data, setup.documentType)); + aggregate.close(); } catch (caughtError) { error = caughtError; } - if (subthing) { + if (result.thing) { if (setup.bindInto) { - subthing[setup.bindInto] = thing; + result.thing[setup.bindInto] = thing; } if (setup.provide) { - Object.assign(subthing, setup.provide); + Object.assign(result.thing, setup.provide); } } - return {error, subthing}; + pushWikiData(wikiData, result.wikiData); + flat.push(...result.flat); + + return {error, subthing: result.thing}; }; for (const [field, layout] of Object.entries(subdocLayouts)) { @@ -395,7 +464,14 @@ function makeProcessDocument(thingConstructor, { {preserveOriginalOrder: true}))); } - return {thing, aggregate}; + return { + aggregate, + result: { + thing, + flat, + wikiData, + }, + }; }); } @@ -415,19 +491,36 @@ export class FieldCombinationAggregateError extends AggregateError { } export class FieldCombinationError extends Error { - constructor(fields, message) { - const fieldNames = Object.keys(fields); + constructor(filteredDocument, fieldsSpec, message) { + const fieldNames = Object.keys(filteredDocument); const fieldNamesText = fieldNames - .map(field => colors.red(field)) + .map(field => { + if (fieldsSpec.includes(field)) { + return colors.red(field); + } + + const match = + fieldsSpec + .find(fieldSpec => + Array.isArray(fieldSpec) && + fieldSpec[0] === field) + .at(1); + + if (typeof match === 'function') { + return colors.red(`${field}: ${filteredDocument[field]}`); + } else { + return colors.red(`${field}: ${match}`); + } + }) .join(', '); const mainMessage = `Don't combine ${fieldNamesText}`; const causeMessage = (typeof message === 'function' - ? message(fields) + ? message(filteredDocument) : typeof message === 'string' ? message : null); @@ -439,7 +532,7 @@ export class FieldCombinationError extends Error { : null), }); - this.fields = fields; + this.fields = fieldNames; } } @@ -553,6 +646,9 @@ export const extractAccentRegex = export const extractPrefixAccentRegex = /^(?:\((?<accent>.*)\) )?(?<main>.*?)$/; +export const asNameRegex = + /^as (?<name>\S.+?)(?:(?<=\S)[,:] | +- |$)(?: *(?<annotation>.*))?$/; + // TODO: Should this fit better within actual YAML loading infrastructure?? export function parseArrayEntries(entries, mapFn) { // If this isn't something we can parse, just return it as-is. @@ -586,12 +682,14 @@ export function parseContributors(entries) { if (typeof item === 'object' && item['Who']) return { artist: item['Who'], + artistText: item['As'] ?? null, annotation: item['What'] ?? null, }; if (typeof item === 'object' && item['Artist']) return { artist: item['Artist'], + artistText: item['Artist Text'] ?? null, annotation: item['Annotation'] ?? null, countInContributionTotals: item['Count In Contribution Totals'] ?? null, @@ -600,59 +698,72 @@ export function parseContributors(entries) { if (typeof item !== 'string') return item; - const match = item.match(extractAccentRegex); + let match; + + match = item.match(extractAccentRegex); if (!match) return item; - return { - artist: match.groups.main, - annotation: match.groups.accent ?? null, - }; + const {accent} = match.groups; + + let artist = match.groups.main; + let artistText = null; + let annotation = null; + + if (accent) { + match = accent.match(asNameRegex); + if (match) { + artistText = match.groups.name; + annotation = match.groups.annotation ?? null; + } else { + annotation = accent; + } + } + + return {artist, artistText, annotation}; }); } -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 parseMusicVideos(entries, {subdoc, MusicVideo}) { return parseArrayEntries(entries, item => { if (typeof item !== 'object') return item; - return { - name: item['Name'], - description: item['Description'] ?? null, - albums: item['Albums'] ?? null, + return subdoc(MusicVideo, item, {bindInto: 'thing'}); + }); +} - showAlbumArtists: item['Show Album Artists'] ?? null, - }; +export function parseSerieses(entries, {subdoc, Series}) { + return parseArrayEntries(entries, item => { + if (typeof item !== 'object') return item; + + return subdoc(Series, item, {bindInto: 'group'}); }); } @@ -827,40 +938,87 @@ export function parseArtwork({ return transform; } -export function parseContentEntries(thingClass, sourceText, {subdoc}) { - const map = matchEntry => ({ - 'Artists': - matchEntry.artistReferences - .split(',') - .map(ref => ref.trim()), +export function parseContentEntriesFromSourceText(thingClass, sourceText, {subdoc}) { + function map(matchEntry) { + let artistText = null, artistReferences = null; + + if (matchEntry.artists) { + const artistTextNodes = + Array.from( + splitContentNodesAround( + parseContentNodes(matchEntry.artists), + /\|/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.artists; + } else { + artistText = matchEntry.artists; + } + } else { + const firstSeparatorIndex = + separatorIndices.at(0); + + const secondSeparatorIndex = + separatorIndices.at(1) ?? + artistTextNodes.length; + + artistReferences = + matchEntry.artists.slice( + artistTextNodes.at(0).i, + artistTextNodes.at(firstSeparatorIndex - 1).iEnd); + + artistText = + matchEntry.artists.slice( + artistTextNodes.at(firstSeparatorIndex).iEnd, + artistTextNodes.at(secondSeparatorIndex - 1).iEnd); + } - 'Artist Text': - matchEntry.artistDisplayText, + if (artistReferences) { + artistReferences = + artistReferences + .split(',') + .map(ref => ref.trim()); + } + } - 'Annotation': - matchEntry.annotation, + return { + 'Artists': + artistReferences, - 'Date': - matchEntry.date, + 'Artist Text': + artistText, - 'Second Date': - matchEntry.secondDate, + 'Annotation': + matchEntry.annotation, - 'Date Kind': - matchEntry.dateKind, + 'Date': + matchEntry.date, - 'Access Date': - matchEntry.accessDate, + 'Second Date': + matchEntry.secondDate, - 'Access Kind': - matchEntry.accessKind, + 'Date Kind': + matchEntry.dateKind, - 'Body': - matchEntry.body, - }); + 'Access Date': + matchEntry.accessDate, + + 'Access Kind': + matchEntry.accessKind, + + 'Body': + matchEntry.body, + }; + } const documents = - matchContentEntries(sourceText) + Array.from(matchContentEntries(sourceText)) .map(matchEntry => withEntries( map(matchEntry), @@ -876,22 +1034,60 @@ export function parseContentEntries(thingClass, sourceText, {subdoc}) { return subdocs; } -export function parseCommentary(sourceText, {subdoc, CommentaryEntry}) { - return parseContentEntries(CommentaryEntry, sourceText, {subdoc}); +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(sourceText, {subdoc, CreditingSourcesEntry}) { - return parseContentEntries(CreditingSourcesEntry, sourceText, {subdoc}); +export function parseCreditingSources(value, {subdoc, CreditingSourcesEntry}) { + return parseContentEntries(CreditingSourcesEntry, value, {subdoc}); } -export function parseLyrics(sourceText, {subdoc, LyricsEntry}) { - if (!multipleLyricsDetectionRegex.test(sourceText)) { - const document = {'Body': sourceText}; +export function parseReferencingSources(value, {subdoc, ReferencingSourcesEntry}) { + return parseContentEntries(ReferencingSourcesEntry, value, {subdoc}); +} + +export function parseLyrics(value, {subdoc, LyricsEntry}) { + if (typeof value === 'string' && !/^(@@|<i>.*:<\/i>)/m.test(value)) { + const document = {'Body': value}; return [subdoc(LyricsEntry, document, {bindInto: 'thing'})]; } - return parseContentEntries(LyricsEntry, sourceText, {subdoc}); + return parseContentEntries(LyricsEntry, value, {subdoc}); +} + +export function parseArtistAliases(value, {subdoc, Artist}) { + return parseArrayEntries(value, item => { + const config = { + bindInto: 'aliasedArtist', + provide: {isAlias: true}, + }; + + if (typeof item === 'string') { + return subdoc(Artist, {'Artist': item}, config); + } else if (typeof item === 'object' && !Array.isArray(item)) { + if (item['Name']) { + const clone = {...item}; + clone['Artist'] = item['Name']; + delete clone['Name']; + return subdoc(Artist, clone, config); + } else { + return subdoc(Artist, item, config); + } + } else { + return item; + } + }); } // documentModes: Symbols indicating sets of behavior for loading and processing @@ -923,6 +1119,12 @@ export const documentModes = { // array of processed documents (wiki objects). allInOne: Symbol('Document mode: allInOne'), + // allTogether: One or more documens, spread across any number of files. + // Expects files array (or function) and processDocument function. + // Calls save with an array of processed documents (wiki objects) - this is + // a flat array, *not* an array of the documents processed from *each* file. + allTogether: Symbol('Document mode: allTogether'), + // oneDocumentTotal: Just a single document, represented in one file. // Expects file string (or function) and processDocument function. Calls // save with the single processed wiki document (data object). @@ -969,23 +1171,13 @@ 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); - + for (const getSpecFn of Object.values(fileLoadingSpecs)) { steps.push(getSpecFn({ documentModes, thingConstructors, @@ -1033,6 +1225,7 @@ export async function getFilesFromDataStep(dataStep, {dataPath}) { } } + case documentModes.allTogether: case documentModes.headerAndEntries: case documentModes.onePerFile: { if (!dataStep.files) { @@ -1188,27 +1381,37 @@ export function processThingsFromDataStep(documents, dataStep) { const {documentMode} = dataStep; switch (documentMode) { - case documentModes.allInOne: { - const result = []; + case documentModes.allInOne: + case documentModes.allTogether: { + const things = []; + const flat = []; + const wikiData = makeEmptyWikiData(); const aggregate = openAggregate({message: `Errors processing documents`}); documents.forEach( decorateErrorWithIndex((document, index) => { - const {thing, aggregate: subAggregate} = + const {result, aggregate: subAggregate} = processDocument(document, dataStep.documentThing); - thing[Thing.yamlSourceDocument] = document; - thing[Thing.yamlSourceDocumentPlacement] = + result.thing[Thing.yamlSourceDocument] = document; + result.thing[Thing.yamlSourceDocumentPlacement] = [documentModes.allInOne, index]; - result.push(thing); + things.push(result.thing); + flat.push(...result.flat); + pushWikiData(wikiData, result.wikiData); + aggregate.call(subAggregate.close); })); return { aggregate, - result, - things: result, + result: { + network: things, + flat: things, + file: things, + wikiData, + }, }; } @@ -1216,17 +1419,21 @@ export function processThingsFromDataStep(documents, dataStep) { if (documents.length > 1) throw new Error(`Only expected one document to be present, got ${documents.length}`); - const {thing, aggregate} = + const {result, aggregate} = processDocument(documents[0], dataStep.documentThing); - thing[Thing.yamlSourceDocument] = documents[0]; - thing[Thing.yamlSourceDocumentPlacement] = + result.thing[Thing.yamlSourceDocument] = documents[0]; + result.thing[Thing.yamlSourceDocumentPlacement] = [documentModes.oneDocumentTotal]; return { aggregate, - result: thing, - things: [thing], + result: { + network: result.thing, + flat: result.flat, + file: [result.thing], + wikiData: result.wikiData, + }, }; } @@ -1238,14 +1445,17 @@ export function processThingsFromDataStep(documents, dataStep) { throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`); const aggregate = openAggregate({message: `Errors processing documents`}); + const wikiData = makeEmptyWikiData(); - const {thing: headerThing, aggregate: headerAggregate} = + const {result: headerResult, aggregate: headerAggregate} = processDocument(headerDocument, dataStep.headerDocumentThing); - headerThing[Thing.yamlSourceDocument] = headerDocument; - headerThing[Thing.yamlSourceDocumentPlacement] = + headerResult.thing[Thing.yamlSourceDocument] = headerDocument; + headerResult.thing[Thing.yamlSourceDocumentPlacement] = [documentModes.headerAndEntries, 'header']; + pushWikiData(wikiData, headerResult.wikiData); + try { headerAggregate.close(); } catch (caughtError) { @@ -1253,17 +1463,18 @@ export function processThingsFromDataStep(documents, dataStep) { aggregate.push(caughtError); } - const entryThings = []; + const entryResults = []; for (const [index, entryDocument] of entryDocuments.entries()) { - const {thing: entryThing, aggregate: entryAggregate} = + const {result: entryResult, aggregate: entryAggregate} = processDocument(entryDocument, dataStep.entryDocumentThing); - entryThing[Thing.yamlSourceDocument] = entryDocument; - entryThing[Thing.yamlSourceDocumentPlacement] = + entryResult.thing[Thing.yamlSourceDocument] = entryDocument; + entryResult.thing[Thing.yamlSourceDocumentPlacement] = [documentModes.headerAndEntries, 'entry', index]; - entryThings.push(entryThing); + entryResults.push(entryResult); + pushWikiData(wikiData, entryResult.wikiData); try { entryAggregate.close(); @@ -1276,10 +1487,16 @@ export function processThingsFromDataStep(documents, dataStep) { return { aggregate, result: { - header: headerThing, - entries: entryThings, + network: { + header: headerResult.thing, + entries: entryResults.map(result => result.thing), + }, + + flat: headerResult.flat.concat(entryResults.flatMap(result => result.flat)), + file: [headerResult.thing, ...entryResults.map(result => result.thing)], + + wikiData, }, - things: [headerThing, ...entryThings], }; } @@ -1290,17 +1507,21 @@ export function processThingsFromDataStep(documents, dataStep) { if (empty(documents) || !documents[0]) throw new Error(`Expected a document, this file is empty`); - const {thing, aggregate} = + const {result, aggregate} = processDocument(documents[0], dataStep.documentThing); - thing[Thing.yamlSourceDocument] = documents[0]; - thing[Thing.yamlSourceDocumentPlacement] = + result.thing[Thing.yamlSourceDocument] = documents[0]; + result.thing[Thing.yamlSourceDocumentPlacement] = [documentModes.onePerFile]; return { aggregate, - result: thing, - things: [thing], + result: { + network: result.thing, + flat: result.flat, + file: [result.thing], + wikiData: result.wikiData, + }, }; } @@ -1401,10 +1622,10 @@ export async function processThingsFromDataSteps(documentLists, fileLists, dataS file: files, documents: documentLists, }).map(({file, documents}) => { - const {result, aggregate, things} = + const {result, aggregate} = processThingsFromDataStep(documents, dataStep); - for (const thing of things) { + for (const thing of result.file) { thing[Thing.yamlSourceFilename] = path.relative(dataPath, file) .split(path.sep) @@ -1431,41 +1652,35 @@ export async function processThingsFromDataSteps(documentLists, fileLists, dataS translucent: true, }).contain(await fileListPromise)); - const thingLists = + const results = aggregate .receive(await Promise.all(dataStepPromises)); - return {aggregate, result: thingLists}; + return {aggregate, result: results}; } -// Flattens a list of *lists* of things for a given data step (each list -// corresponding to one YAML file) into results to be saved on the final -// wikiData object, routing thing lists into the step's save() function. -export function saveThingsFromDataStep(thingLists, dataStep) { +// Runs a data step's connect() function, if present, with representations +// of the results from the YAML files, called "networks" - one network and +// one call to .connect() per YAML file - in order to form data connections +// (direct links) between related objects within a file. +export function connectThingsFromDataStep(results, dataStep) { const {documentMode} = dataStep; switch (documentMode) { - case documentModes.allInOne: { - const things = - (empty(thingLists) - ? [] - : thingLists[0]); - - return dataStep.save(things); + case documentModes.oneDocumentTotal: + case documentModes.onePerFile: { + // These results are never connected. + return; } - case documentModes.oneDocumentTotal: { - const thing = - (empty(thingLists) - ? {} - : thingLists[0]); - - return dataStep.save(thing); - } + case documentModes.allInOne: + case documentModes.allTogether: + case documentModes.headerAndEntries: { + for (const result of results) { + dataStep.connect?.(result.network); + } - case documentModes.headerAndEntries: - case documentModes.onePerFile: { - return dataStep.save(thingLists); + break; } default: @@ -1473,60 +1688,70 @@ export function saveThingsFromDataStep(thingLists, dataStep) { } } -// Flattens a list of *lists* of things for each data step (each list -// corresponding to one YAML file) into the final wikiData object, -// routing thing lists into each step's save() function. -export function saveThingsFromDataSteps(thingLists, dataSteps) { +export function connectThingsFromDataSteps(processThingResultLists, dataSteps) { const aggregate = openAggregate({ - message: `Errors finalizing things from data files`, + message: `Errors connecting things from data files`, translucent: true, }); - const wikiData = {}; - stitchArrays({ dataStep: dataSteps, - thingLists: thingLists, - }).map(({dataStep, thingLists}) => { + processThingResults: processThingResultLists, + }).forEach(({dataStep, processThingResults}) => { try { - return saveThingsFromDataStep(thingLists, dataStep); + connectThingsFromDataStep(processThingResults, dataStep); } catch (caughtError) { const error = new Error( - `Error finalizing things for data step: ${colors.bright(dataStep.title)}`, + `Error connecting things for data step: ${colors.bright(dataStep.title)}`, {cause: caughtError}); error[Symbol.for('hsmusic.aggregate.translucent')] = true; aggregate.push(error); - - return null; } - }) - .filter(Boolean) - .forEach(saveResult => { - for (const [saveKey, saveValue] of Object.entries(saveResult)) { - if (Object.hasOwn(wikiData, saveKey)) { - if (Array.isArray(wikiData[saveKey])) { - if (Array.isArray(saveValue)) { - wikiData[saveKey].push(...saveValue); - } else { - throw new Error(`${saveKey} already present, expected array of items to push`); - } - } else { - if (Array.isArray(saveValue)) { - throw new Error(`${saveKey} already present and not an array, refusing to overwrite`); - } else { - throw new Error(`${saveKey} already present, refusing to overwrite`); - } - } - } else { - wikiData[saveKey] = saveValue; + }); + + return {result: null, aggregate}; +} + +export function makeWikiDataFromDataSteps(processThingResultLists, _dataSteps) { + const wikiData = makeEmptyWikiData(); + + for (const result of processThingResultLists.flat(2)) { + pushWikiData(wikiData, result.wikiData); + } + + const scanForConstituted = + processThingResultLists.flat(2).flatMap(result => result.flat); + + const exists = new Set(scanForConstituted); + + while (scanForConstituted.length) { + const scanningThing = scanForConstituted.pop(); + + for (const key of scanningThing.constructor[Thing.constitutibleProperties] ?? []) { + const maybeConstitutedThings = + (Array.isArray(scanningThing[key]) + ? scanningThing[key] + : scanningThing[key] + ? [scanningThing[key]] + : []); + + for (const thing of maybeConstitutedThings) { + if (exists.has(thing)) continue; + exists.add(thing); + + if (thing.constructor[Thing.wikiData]) { + pushWikiData(wikiData, {[thing.constructor[Thing.wikiData]]: [thing]}); } + + scanForConstituted.push(thing); } - }); + } + } - return {aggregate, result: wikiData}; + return wikiData; } export async function loadAndProcessDataDocuments(dataSteps, {dataPath}) { @@ -1539,13 +1764,15 @@ export async function loadAndProcessDataDocuments(dataSteps, {dataPath}) { aggregate.receive( await loadYAMLDocumentsFromDataSteps(dataSteps, {dataPath})); - const thingLists = + const processThingResultLists = aggregate.receive( await processThingsFromDataSteps(documentLists, fileLists, dataSteps, {dataPath})); + aggregate.receive( + connectThingsFromDataSteps(processThingResultLists, dataSteps)); + const wikiData = - aggregate.receive( - saveThingsFromDataSteps(thingLists, dataSteps)); + makeWikiDataFromDataSteps(processThingResultLists, dataSteps); return {aggregate, result: wikiData}; } @@ -1589,9 +1816,14 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) { ['lyricsData', [/* find */]], + ['musicVideoData', [/* find */]], + + ['referencingSourceData', [/* find */]], + + ['seriesData', [/* find */]], + ['trackData', [ 'artworkData', - 'trackData', 'wikiInfo', ]], @@ -1615,6 +1847,7 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) { for (const thing of things) { if (thing === undefined) continue; + if (thing === null) continue; let hasFind; if (constructorHasFindMap.has(thing.constructor)) { |