diff options
Diffstat (limited to 'src')
332 files changed, 18157 insertions, 7258 deletions
diff --git a/src/util/aggregate.js b/src/aggregate.js index e8f45f3b..d5ea2d73 100644 --- a/src/util/aggregate.js +++ b/src/aggregate.js @@ -1,5 +1,5 @@ -import {colors} from './cli.js'; -import {empty, typeAppearance} from './sugar.js'; +import {colors} from '#cli'; +import {empty, typeAppearance} from '#sugar'; // Utility function for providing useful interfaces to the JS AggregateError // class. @@ -110,7 +110,6 @@ export function openAggregate({ return results.map(({aggregate, result}) => { if (!aggregate) { - console.log('nope:', results); throw new Error(`Expected an array of {aggregate, result} objects`); } @@ -441,7 +440,15 @@ export function showAggregate(topError, { } } - return determineCauseHelper(cause.cause); + if (cause.cause) { + return determineCauseHelper(cause.cause); + } + + if (cause.errors) { + return determineErrorsHelper(cause); + } + + return cause; }; const determineCause = error => @@ -479,7 +486,7 @@ export function showAggregate(topError, { : error.errors?.flatMap(determineErrorsHelper) ?? null); const flattenErrorStructure = (error, level = 0) => { - const cause = determineCause(error); + const cause = determineCause(error); // may be an array! const errors = determineErrors(error); return { @@ -494,7 +501,9 @@ export function showAggregate(topError, { : error.stack), cause: - (cause + (Array.isArray(cause) + ? cause.map(cause => flattenErrorStructure(cause, level + 1)) + : cause ? flattenErrorStructure(cause, level + 1) : null), @@ -529,15 +538,29 @@ export function showAggregate(topError, { unhelpfulTraceLines: ownUnhelpfulTraceLines, }, }, index, apparentSiblings) => { + const causeSingle = Array.isArray(cause) ? null : cause; + const causeArray = Array.isArray(cause) ? cause : null; + const subApparentSiblings = - (cause && errors - ? [cause, ...errors] - : cause - ? [cause] + (causeSingle && errors + ? [causeSingle, ...errors] + : causeSingle + ? [causeSingle] + : causeArray && errors + ? [...causeArray, ...errors] + : causeArray + ? causeArray : errors ? errors : []); + const presentedAsErrors = + (causeArray && errors + ? [...causeArray, ...errors] + : causeArray + ? causeArray + : errors); + const anythingHasErrorsThisLayer = apparentSiblings.some(({errors}) => !empty(errors)); @@ -581,12 +604,12 @@ export function showAggregate(topError, { headerPart += ` ${colors.dim(tracePart)}`; } - const head1 = level % 2 === 0 ? '\u21aa' : colors.dim('\u21aa'); + const head1 = '\u21aa'; const bar1 = ' '; const causePart = - (cause - ? recursive(cause, 0, subApparentSiblings) + (causeSingle + ? recursive(causeSingle, 0, subApparentSiblings) .split('\n') .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`) .join('\n') @@ -596,8 +619,8 @@ export function showAggregate(topError, { const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e'); const errorsPart = - (errors - ? errors + (presentedAsErrors + ? presentedAsErrors .map((error, index) => recursive(error, index + 1, subApparentSiblings)) .flatMap(str => str.split('\n')) .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`) diff --git a/src/util/cli.js b/src/cli.js index 72979d3f..ec72a625 100644 --- a/src/util/cli.js +++ b/src/cli.js @@ -1,12 +1,8 @@ // Utility functions for CLI- and de8ugging-rel8ted stuff. -// -// A 8unch of these depend on process.stdout 8eing availa8le, so they won't -// work within the 8rowser. -const {process} = globalThis; +import {sortByName} from '#sort'; export const ENABLE_COLOR = - process && ((process.env.CLICOLOR_FORCE && process.env.CLICOLOR_FORCE === '1') ?? (process.env.CLICOLOR && process.env.CLICOLOR === '1' && @@ -95,8 +91,12 @@ export async function parseOptions(options, optionDescriptorMap) { // } // // ['--directory', 'apple'] -> {'directory': 'apple'} + // ['--directory=banana'] -> {'directory': 'banana'} // ['--directory', 'artichoke'] -> (error) + // // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']} + // ['--files=a,b,c'] -> {'files': ['a', 'b', 'c']} + // ['--files', 'a,b,c'] -> {'files': ['a', 'b', 'c']} const handleDashless = optionDescriptorMap[parseOptions.handleDashless]; const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown]; @@ -149,9 +149,27 @@ export async function parseOptions(options, optionDescriptorMap) { } case 'series': { + if (option.includes('=')) { + result[name] = option.split('=')[1].split(','); + break; + } + + // without a semicolon to conclude the series, + // assume the next option expresses the whole series if (!options.slice(i).includes(';')) { - console.error(`Expected a series of values concluding with ; (\\;) for --${name}`); - process.exit(1); + let value = options[++i]; + + if (!value || value.startsWith('-')) { + value = null; + } + + if (!value) { + console.error(`Expected values for --${name}`); + process.exit(1); + } + + result[name] = value.split('=')[1].split(','); + break; } const endIndex = i + options.slice(i).indexOf(';'); @@ -358,77 +376,79 @@ decorateTime.displayTime = function () { } }; -export function progressPromiseAll(msgOrMsgFn, array) { +const progressUpdateInterval = 1000 / 60; + +function progressShow(message, total) { + let start = Date.now(), last = 0, done = 0; + + const progress = () => { + const messagePart = + (typeof message === 'function' + ? message() + : message); + + const percent = + Math.round((done / total) * 1000) / 10 + '%'; + + const percentPart = + percent.padEnd('99.9%'.length, ' '); + + return `${messagePart} [${percentPart}]`; + }; + + process.stdout.write(`\r` + progress()); + + return () => { + done++; + + if (done === total) { + process.stdout.write( + `\r\x1b[2m` + progress() + + `\x1b[0;32m Done! ` + + `\x1b[0;2m(${formatDuration(Date.now() - start)}) ` + + `\x1b[0m\n` + ); + } else if (Date.now() - last >= progressUpdateInterval) { + process.stdout.write('\r' + progress()); + last = Date.now(); + } + }; +} + +export function progressPromiseAll(message, array) { if (!array.length) { return Promise.resolve([]); } - const msgFn = - typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn; - - let done = 0, - total = array.length; - process.stdout.write(`\r${msgFn()} [0/${total}]`); - const start = Date.now(); - return Promise.all( - array.map((promise) => - Promise.resolve(promise).then((val) => { - done++; - // const pc = `${done}/${total}`; - const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd( - '99.9%'.length, - ' ' - ); - if (done === total) { - const time = Date.now() - start; - process.stdout.write( - `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n` - ); - } else { - process.stdout.write(`\r${msgFn()} [${pc}] `); - } - return val; - }) - ) - ); + const show = progressShow(message, array.length); + + const next = value => { + show(); + + return value; + }; + + const promises = + array.map(promise => Promise.resolve(promise).then(next)); + + return Promise.all(promises); } -export function progressCallAll(msgOrMsgFn, array) { +export function progressCallAll(message, array) { if (!array.length) { return []; } - const msgFn = - typeof msgOrMsgFn === 'function' ? msgOrMsgFn : () => msgOrMsgFn; - - const updateInterval = 1000 / 60; + const show = progressShow(message, array.length); - let done = 0, - total = array.length; - process.stdout.write(`\r${msgFn()} [0/${total}]`); - const start = Date.now(); - const vals = []; - let lastTime = 0; + const values = []; for (const fn of array) { - const val = fn(); - done++; - - if (done === total) { - const pc = '100%'.padEnd('99.9%'.length, ' '); - const time = Date.now() - start; - process.stdout.write( - `\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n` - ); - } else if (Date.now() - lastTime >= updateInterval) { - const pc = (Math.round((done / total) * 1000) / 10 + '%').padEnd('99.9%'.length, ' '); - process.stdout.write(`\r${msgFn()} [${pc}] `); - lastTime = Date.now(); - } - vals.push(val); + values.push(fn()); + show(); } - return vals; + return values; } export function fileIssue({ @@ -441,6 +461,24 @@ export function fileIssue({ console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`)); } +// Quick'n dirty function to present a duration nicely for command-line use. +export function formatDuration(timeDelta) { + const seconds = timeDelta / 1000; + + if (seconds > 90) { + const modSeconds = Math.floor(seconds % 60); + const minutes = Math.floor(seconds - seconds % 60) / 60; + return `${minutes}m${modSeconds}s`; + } + + if (seconds < 0.1) { + return 'instant'; + } + + const precision = (seconds > 1 ? 3 : 2); + return `${seconds.toPrecision(precision)}s`; +} + export async function logicalCWD() { if (process.env.PWD) { return process.env.PWD; @@ -451,7 +489,7 @@ export async function logicalCWD() { try { await stat('/bin/sh'); - } catch (error) { + } catch { // Not logical, so sad. return process.cwd(); } @@ -471,3 +509,27 @@ export async function logicalPathTo(target) { const cwd = await logicalCWD(); return relative(cwd, target); } + +export function stringifyCache(cache) { + cache ??= {}; + + if (Object.keys(cache).length === 0) { + return `{}`; + } + + const entries = Object.entries(cache); + sortByName(entries, {getName: entry => entry[0]}); + + return [ + `{`, + entries + .map(([key, value]) => [JSON.stringify(key), JSON.stringify(value)]) + .map(([key, value]) => `${key}: ${value}`) + .map((line, index, array) => + (index < array.length - 1 + ? `${line},` + : line)) + .map(line => ` ${line}`), + `}`, + ].flat().join('\n'); +} diff --git a/src/util/colors.js b/src/common-util/colors.js index 7298c46a..7298c46a 100644 --- a/src/util/colors.js +++ b/src/common-util/colors.js diff --git a/src/util/search-spec.js b/src/common-util/search-spec.js index bc24e1a1..af5ec201 100644 --- a/src/util/search-spec.js +++ b/src/common-util/search-spec.js @@ -85,107 +85,116 @@ function prepareArtwork(thing, { return serializeSrc; } -export const searchSpec = { - generic: { - query: ({ - albumData, - artTagData, - artistData, - flashData, - groupData, - trackData, - }) => [ - albumData, - - artTagData, - - artistData - .filter(artist => !artist.isAlias), - - flashData, - - groupData, - - trackData - // Exclude rereleases - there's no reasonable way to differentiate - // them from the main release as part of this query. - .filter(track => !track.originalReleaseTrack), - ].flat(), - - process(thing, opts) { - const fields = {}; - - fields.primaryName = - thing.name; - - const kind = - thing.constructor[Symbol.for('Thing.referenceType')]; - - fields.parentName = - (kind === 'track' - ? thing.album.name - : kind === 'group' - ? thing.category.name - : kind === 'flash' - ? thing.act.name - : null); - - fields.color = - thing.color; - - fields.artTags = - (Object.hasOwn(thing, 'artTags') - ? thing.artTags.map(artTag => artTag.nameShort) - : []); - - fields.additionalNames = - (Object.hasOwn(thing, 'additionalNames') - ? thing.additionalNames.map(entry => entry.name) - : Object.hasOwn(thing, 'aliasNames') - ? thing.aliasNames - : []); - - const contribKeys = [ - 'artistContribs', - 'bannerArtistContribs', - 'contributorContribs', - 'coverArtistContribs', - 'wallpaperArtistContribs', - ]; +function baselineProcess(thing, opts) { + const fields = {}; + + fields.primaryName = + thing.name; - const contributions = - contribKeys - .filter(key => Object.hasOwn(thing, key)) - .flatMap(key => thing[key]); + fields.artwork = + prepareArtwork(thing, opts); + + fields.color = + thing.color; + + return fields; +} - fields.contributors = - contributions - .flatMap(({artist}) => [ - artist.name, - ...artist.aliasNames, - ]); +const baselineStore = [ + 'primaryName', + 'artwork', + 'color', +]; - const groups = - (Object.hasOwn(thing, 'groups') - ? thing.groups - : Object.hasOwn(thing, 'album') - ? thing.album.groups - : []); +function genericQuery(wikiData) { + return [ + wikiData.albumData, - const mainContributorNames = - contributions - .map(({artist}) => artist.name); + wikiData.artTagData, - fields.groups = - groups - .filter(group => !mainContributorNames.includes(group.name)) - .map(group => group.name); + wikiData.artistData + .filter(artist => !artist.isAlias), + + wikiData.flashData, + + wikiData.groupData, + + wikiData.trackData + // Exclude rereleases - there's no reasonable way to differentiate + // them from the main release as part of this query. + .filter(track => !track.mainReleaseTrack), + ].flat(); +} + +function genericProcess(thing, opts) { + const fields = baselineProcess(thing, opts); + + const kind = + thing.constructor[Symbol.for('Thing.referenceType')]; + + fields.parentName = + (kind === 'track' + ? thing.album.name + : kind === 'group' + ? thing.category.name + : kind === 'flash' + ? thing.act.name + : null); + + fields.artTags = + (thing.constructor.hasPropertyDescriptor('artTags') + ? thing.artTags.map(artTag => artTag.nameShort) + : []); + + fields.additionalNames = + (thing.constructor.hasPropertyDescriptor('additionalNames') + ? thing.additionalNames.map(entry => entry.name) + : thing.constructor.hasPropertyDescriptor('aliasNames') + ? thing.aliasNames + : []); + + const contribKeys = [ + 'artistContribs', + 'contributorContribs', + ]; + + const contributions = + contribKeys + .filter(key => Object.hasOwn(thing, key)) + .flatMap(key => thing[key]); + + fields.contributors = + contributions + .flatMap(({artist}) => [ + artist.name, + ...artist.aliasNames, + ]); + + const groups = + (Object.hasOwn(thing, 'groups') + ? thing.groups + : Object.hasOwn(thing, 'album') + ? thing.album.groups + : []); + + const mainContributorNames = + contributions + .map(({artist}) => artist.name); + + fields.groups = + groups + .filter(group => !mainContributorNames.includes(group.name)) + .map(group => group.name); + + return fields; +} - fields.artwork = - prepareArtwork(thing, opts); +const genericStore = baselineStore; - return fields; - }, +export const searchSpec = { + generic: { + query: genericQuery, + process: genericProcess, index: [ 'primaryName', @@ -194,13 +203,25 @@ export const searchSpec = { 'additionalNames', 'contributors', 'groups', - ], + ].map(field => ({field, tokenize: 'forward'})), - store: [ + store: genericStore, + }, + + verbatim: { + query: genericQuery, + process: genericProcess, + + index: [ 'primaryName', - 'artwork', - 'color', + 'parentName', + 'artTags', + 'additionalNames', + 'contributors', + 'groups', ], + + store: genericStore, }, }; diff --git a/src/util/serialize.js b/src/common-util/serialize.js index eb18a759..eb18a759 100644 --- a/src/util/serialize.js +++ b/src/common-util/serialize.js diff --git a/src/util/sort.js b/src/common-util/sort.js index ea1e024a..d93d94c1 100644 --- a/src/util/sort.js +++ b/src/common-util/sort.js @@ -3,6 +3,12 @@ // initial sort matters! (Spoilers: If what you're doing involves any kind of // parallelization, it definitely matters.) +// TODO: This is obviously limiting. It does describe the behavior +// we've been *assuming* for the entire time the wiki is around, +// but it would be nice to support sorting in different locales +// somehow. +export const SORTING_LOCALE = 'en'; + import {empty, sortMultipleArrays, unique} from './sugar.js'; @@ -17,8 +23,8 @@ export function compareCaseLessSensitive(a, b) { const bl = b.toLowerCase(); return al === bl - ? a.localeCompare(b, undefined, {numeric: true}) - : al.localeCompare(bl, undefined, {numeric: true}); + ? a.localeCompare(b, SORTING_LOCALE, {numeric: true}) + : al.localeCompare(bl, SORTING_LOCALE, {numeric: true}); } // Subtract common prefixes and other characters which some people don't like @@ -383,6 +389,22 @@ export function sortAlbumsTracksChronologically(data, { return data; } +export function sortArtworksChronologically(data, { + latestFirst = false, +} = {}) { + // Artworks conveniently describe their things as artwork.thing, so they + // work in sortEntryThingPairs. (Yes, this is just assuming the artworks + // are only for albums and tracks... sorry... TODO...) + sortEntryThingPairs(data, things => + sortAlbumsTracksChronologically(things, {latestFirst})); + + // Artworks' own dates always matter before however the thing places itself, + // and accommodate per-thing properties like coverArtDate anyway. + sortByDate(data, {latestFirst}); + + return data; +} + export function sortFlashesChronologically(data, { latestFirst = false, getDate, @@ -407,6 +429,7 @@ export function sortFlashesChronologically(data, { export function sortContributionsChronologically(data, sortThings, { latestFirst = false, + getThing = contrib => contrib.thing, } = {}) { // Contributions only have one date property (which is provided when // the contribution is created). They're sorted by this most primarily, @@ -415,7 +438,7 @@ export function sortContributionsChronologically(data, sortThings, { const entries = data.map(contrib => ({ entry: contrib, - thing: contrib.thing, + thing: getThing(contrib), })); sortEntryThingPairs( diff --git a/src/util/sugar.js b/src/common-util/sugar.js index 7dd173a0..e931ad59 100644 --- a/src/util/sugar.js +++ b/src/common-util/sugar.js @@ -6,8 +6,6 @@ // It will likely only do exactly what I want it to, and only in the cases I // decided were relevant enough to 8other handling. -import {colors} from './cli.js'; - // Apparently JavaScript doesn't come with a function to split an array into // chunks! Weird. Anyway, this is an awesome place to use a generator, even // though we don't really make use of the 8enefits of generators any time we @@ -118,10 +116,14 @@ export function findIndexOrEnd(array, fn) { // returns null (or values in the array are nullish), they'll just be skipped in // the sum. export function accumulateSum(array, fn = x => x) { + if (!Array.isArray(array)) { + return accumulateSum(Array.from(array, fn)); + } + return array.reduce( (accumulator, value, index, array) => accumulator + - fn(value, index, array) ?? 0, + (fn(value, index, array) ?? 0), 0); } @@ -223,6 +225,9 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => ? arr1.every((x, i) => arr2[i] === x) : arr1.every((x) => arr2.includes(x))); +export const exhaust = (generatorFunction) => + Array.from(generatorFunction()); + export function compareObjects(obj1, obj2, { checkOrder = false, checkSymbols = true, @@ -253,11 +258,20 @@ export function compareObjects(obj1, obj2, { // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice. export const withEntries = (obj, fn) => { - const result = fn(Object.entries(obj)); - if (result instanceof Promise) { - return result.then(entries => Object.fromEntries(entries)); + if (obj instanceof Map) { + const result = fn(Array.from(obj.entries())); + if (result instanceof Promise) { + return result.then(entries => new map(entries)); + } else { + return new Map(result); + } } else { - return Object.fromEntries(result); + const result = fn(Object.entries(obj)); + if (result instanceof Promise) { + return result.then(entries => Object.fromEntries(entries)); + } else { + return Object.fromEntries(result); + } } } @@ -301,34 +315,74 @@ export function filterProperties(object, properties, { return filteredObject; } -export function queue(array, max = 50) { - if (max === 0) { - return array.map((fn) => fn()); +export function queue(functionList, queueSize = 50) { + if (queueSize === 0) { + return functionList.map(fn => fn()); } - const begin = []; - let current = 0; - const ret = array.map( - (fn) => - new Promise((resolve, reject) => { - begin.push(() => { - current++; - Promise.resolve(fn()).then((value) => { - current--; - if (current < max && begin.length) { - begin.shift()(); - } - resolve(value); - }, reject); - }); - }) - ); + const promiseList = []; + const resolveList = []; + const rejectList = []; - for (let i = 0; i < max && begin.length; i++) { - begin.shift()(); + for (let i = 0; i < functionList.length; i++) { + const promiseWithResolvers = Promise.withResolvers(); + promiseList.push(promiseWithResolvers.promise); + resolveList.push(promiseWithResolvers.resolve); + rejectList.push(promiseWithResolvers.reject); } - return ret; + let cursor = 0; + let running = 0; + + const next = async () => { + if (running >= queueSize) { + return; + } + + if (cursor === functionList.length) { + return; + } + + const thisFunction = functionList[cursor]; + const thisResolve = resolveList[cursor]; + const thisReject = rejectList[cursor]; + + delete functionList[cursor]; + delete resolveList[cursor]; + delete rejectList[cursor]; + + cursor++; + running++; + + try { + thisResolve(await thisFunction()); + } catch (error) { + thisReject(error); + } finally { + running--; + + // If the cursor is at 1, this is the first promise that resolved, + // so we're now done the "kick start", and can start the remaining + // promises (up to queueSize). + if (cursor === 1) { + // Since only one promise is used for the "kick start", and that one + // has just resolved, we know there's none running at all right now, + // and can start as many as specified in the queueSize right away. + for (let i = 0; i < queueSize; i++) { + next(); + } + } else { + next(); + } + } + }; + + // Only start a single promise, as a "kick start", so that it resolves as + // early as possible (it will resolve before we use CPU to start the rest + // of the promises, up to queueSize). + next(); + + return promiseList; } export function delay(ms) { @@ -358,11 +412,19 @@ export function splitKeys(key) { } // Follows a key path like 'foo.bar.baz' to get an item nested deeply inside -// an object. +// an object. If a value partway through the chain is an array, the values +// down the rest of the chain are gotten for each item in the array. +// +// obj: {x: [{y: ['a']}, {y: ['b', 'c']}]} +// key: 'x.y' +// -> [['a'], ['b', 'c']] +// export function getNestedProp(obj, key) { const recursive = (o, k) => (k.length === 1 ? o[k[0]] + : Array.isArray(o[k[0]]) + ? o[k[0]].map(v => recursive(v, k.slice(1))) : recursive(o[k[0]], k.slice(1))); return recursive(obj, splitKeys(key)); @@ -461,14 +523,12 @@ export function* iterateMultiline(content, iterator, { const columnNumber = index - startOfLine; - let where = null; - if (formatWhere) { - where = - colors.yellow( - (isMultiline - ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}` - : `pos: ${index + 1}`)); - } + const where = + (formatWhere && isMultiline + ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}` + : formatWhere + ? `pos: ${index + 1}` + : null); countLineBreaks(index, length); @@ -777,6 +837,38 @@ export function chunkMultipleArrays(...args) { return results; } +// This (or its helper function) should probably be a generator, but generators +// are scary... Note that the root node is never considered a leaf, even if it +// doesn't have any branches. It does NOT pay attention to the *values* of the +// leaf nodes - it's suited to handle this kind of form: +// +// { +// foo: { +// bar: {}, +// baz: {}, +// qux: { +// woz: {}, +// }, +// }, +// } +// +// for which it outputs ['bar', 'baz', 'woz']. +// +export function collectTreeLeaves(tree) { + const recursive = ([key, value]) => + (value instanceof Map + ? (value.size === 0 + ? [key] + : Array.from(value.entries()).flatMap(recursive)) + : (empty(Object.keys(value)) + ? [key] + : Object.entries(value).flatMap(recursive))); + + const root = Symbol(); + const leaves = recursive([root, tree]); + return (leaves[0] === root ? [] : leaves); +} + // Delicious function annotations, such as: // // (*bound) soWeAreBackInTheMine diff --git a/src/util/wiki-data.js b/src/common-util/wiki-data.js index f97ecd63..546f1ad9 100644 --- a/src/util/wiki-data.js +++ b/src/common-util/wiki-data.js @@ -1,6 +1,6 @@ // Utility functions for interacting with wiki data. -import {accumulateSum, empty, unique} from './sugar.js'; +import {accumulateSum, chunkByConditions, empty, unique} from './sugar.js'; import {sortByDate} from './sort.js'; // This is a duplicate binding of filterMultipleArrays that's included purely @@ -57,10 +57,9 @@ export function getKebabCase(name) { // Specific data utilities -// Matches heading details from commentary data in roughly the formats: +// Matches heading details from commentary data in roughly the format: // -// <i>artistReference:</i> (annotation, date) -// <i>artistReference|artistDisplayText:</i> (annotation, date) +// <i>artistText:</i> (annotation, date) // // where capturing group "annotation" can be any text at all, except that the // last entry (past a comma or the only content within parentheses), if parsed @@ -83,8 +82,9 @@ export function getKebabCase(name) { // parentheses can be part of the actual annotation content. // // Capturing group "artistReference" is all the characters between <i> and </i> -// (apart from the pipe and "artistDisplayText" text, if present), and is either -// the name of an artist or an "artist:directory"-style reference. +// (apart from the pipe and the "artistText" group, if present), and is either +// the name of one or more artist or "artist:directory"-style references, +// joined by commas, if multiple. // // This regular expression *doesn't* match bodies, which will need to be parsed // out of the original string based on the indices matched using this. @@ -94,7 +94,7 @@ const dateRegex = groupName => String.raw`(?<${groupName}>[a-zA-Z]+ [0-9]{1,2}, [0-9]{4,4}|[0-9]{1,2} [^,]*[0-9]{4,4}|[0-9]{1,4}[-/][0-9]{1,4}[-/][0-9]{1,4})`; const commentaryRegexRaw = - String.raw`^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`; + String.raw`^<i>(?<artistText>.+?):<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?<dateKind>sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?<accessKind>captured|accessed) ${dateRegex('accessDate')})?)?\))?`; export const commentaryRegexCaseInsensitive = new RegExp(commentaryRegexRaw, 'gmi'); export const commentaryRegexCaseSensitive = @@ -102,6 +102,36 @@ export const commentaryRegexCaseSensitive = export const commentaryRegexCaseSensitiveOneShot = new RegExp(commentaryRegexRaw); +// The #validators function isOldStyleLyrics() describes +// what this regular expression detects against. +export const multipleLyricsDetectionRegex = + /^<i>.*:<\/i>/m; + +export function matchContentEntries(sourceText) { + const matchEntries = []; + + let previousMatchEntry = null; + let previousEndIndex = null; + + for (const {0: matchText, index: startIndex, groups: matchEntry} + of sourceText.matchAll(commentaryRegexCaseSensitive)) { + if (previousMatchEntry) { + previousMatchEntry.body = sourceText.slice(previousEndIndex, startIndex); + } + + matchEntries.push(matchEntry); + + previousMatchEntry = matchEntry; + previousEndIndex = startIndex + matchText.length; + } + + if (previousMatchEntry) { + previousMatchEntry.body = sourceText.slice(previousEndIndex); + } + + return matchEntries; +} + export function filterAlbumsByCommentary(albums) { return albums .filter((album) => [album, ...album.tracks].some((x) => x.commentary)); @@ -167,10 +197,10 @@ export function getFlashLink(flash) { } export function getTotalDuration(tracks, { - originalReleasesOnly = false, + mainReleasesOnly = false, } = {}) { - if (originalReleasesOnly) { - tracks = tracks.filter(t => !t.originalReleaseTrack); + if (mainReleasesOnly) { + tracks = tracks.filter(t => !t.mainReleaseTrack); } return accumulateSum(tracks, track => track.duration); @@ -192,6 +222,25 @@ export function getArtistAvatar(artist, {to}) { return to('media.artistAvatar', artist.directory, artist.avatarFileExtension); } +// Used in multiple content functions for the artist info page, +// because shared logic is torture oooooooooooooooo. +export function chunkArtistTrackContributions(contributions) { + return ( + // First chunk by (contribution) date and album. + chunkByConditions(contributions, [ + ({date: date1}, {date: date2}) => + +date1 !== +date2, + ({thing: track1}, {thing: track2}) => + track1.album !== track2.album, + ]).map(contribs => + // Then, *within* the boundaries of the existing chunks, + // chunk contributions to the same thing together. + chunkByConditions(contribs, [ + ({thing: thing1}, {thing: thing2}) => + thing1 !== thing2, + ]))); +} + // Big-ass homepage row functions export function getNewAdditions(numAlbums, {albumData}) { @@ -342,7 +391,7 @@ export function filterItemsForCarousel(items) { return items .filter(item => item.hasCoverArt) - .filter(item => item.artTags.every(tag => !tag.isContentWarning)) + .filter(item => item.artTags.every(artTag => !artTag.isContentWarning)) .slice(0, maxCarouselLayoutItems + 1); } @@ -473,3 +522,30 @@ export function combineWikiDataArrays(arrays) { return combined; } } + +// Markdown stuff + +export function* matchMarkdownLinks(markdownSource, {marked}) { + const plausibleLinkRegexp = /\[.*?\)/g; + + let plausibleMatch = null; + while (plausibleMatch = plausibleLinkRegexp.exec(markdownSource)) { + // Pedantic rules use more particular parentheses detection in link + // destinations - they allow one level of balanced parentheses, and + // otherwise, parentheses must be escaped. This allows for entire links + // to be wrapped in parentheses, e.g below: + // + // This is so cool. ([You know??](https://example.com)) + // + const definiteMatch = + marked.Lexer.rules.inline.pedantic.link + .exec(markdownSource.slice(plausibleMatch.index)); + + if (definiteMatch) { + const [{length}, label, href] = definiteMatch; + const index = plausibleMatch.index + definiteMatch.index; + + yield {label, href, index, length}; + } + } +} diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js index 68120b23..7e05b5b5 100644 --- a/src/content/dependencies/generateAdditionalFilesList.js +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -1,26 +1,22 @@ -import {stitchArrays} from '#sugar'; - export default { + contentDependencies: ['generateAdditionalFilesListChunk'], extraDependencies: ['html'], - slots: { - chunks: { - validate: v => v.strictArrayOf(v.isHTML), - }, + relations: (relation, additionalFiles) => ({ + chunks: + additionalFiles + .map(file => relation('generateAdditionalFilesListChunk', file)), + }), - chunkItems: { - validate: v => v.strictArrayOf(v.isHTML), - }, + slots: { + showFileSizes: {type: 'boolean', default: true}, }, - generate: (slots, {html}) => + generate: (relations, slots, {html}) => html.tag('ul', {class: 'additional-files-list'}, {[html.onlyIfContent]: true}, - stitchArrays({ - chunk: slots.chunks, - items: slots.chunkItems, - }).map(({chunk, items}) => - chunk.clone() - .slot('items', items))), + relations.chunks.map(chunk => chunk.slots({ + showFileSizes: slots.showFileSizes, + }))), }; diff --git a/src/content/dependencies/generateAdditionalFilesListChunk.js b/src/content/dependencies/generateAdditionalFilesListChunk.js index 507b2329..3cac851b 100644 --- a/src/content/dependencies/generateAdditionalFilesListChunk.js +++ b/src/content/dependencies/generateAdditionalFilesListChunk.js @@ -1,46 +1,81 @@ +import {stitchArrays} from '#sugar'; + export default { - extraDependencies: ['html', 'language'], + contentDependencies: ['linkAdditionalFile', 'transformContent'], + extraDependencies: ['getSizeOfMediaFile', 'html', 'language', 'urls'], - slots: { - title: { - type: 'html', - mutable: false, - }, + relations: (relation, file) => ({ + description: + relation('transformContent', file.description), - description: { - type: 'html', - mutable: false, - }, + links: + file.filenames + .map(filename => relation('linkAdditionalFile', file, filename)), + }), + + data: (file) => ({ + title: + file.title, + + paths: + file.paths, + }), - items: { - validate: v => v.looseArrayOf(v.isHTML), + slots: { + showFileSizes: { + type: 'boolean', }, }, - generate: (slots, {html, language}) => - language.encapsulate('releaseInfo.additionalFiles.entry', capsule => + generate: (data, relations, slots, {getSizeOfMediaFile, html, language, urls}) => + language.encapsulate('releaseInfo.additionalFiles', capsule => html.tag('li', html.tag('details', - html.isBlank(slots.items) && + html.isBlank(relations.links) && {open: true}, [ html.tag('summary', html.tag('span', - language.$(capsule, { + language.$(capsule, 'entry', { title: - html.tag('b', slots.title), + html.tag('b', data.title), }))), html.tag('ul', [ html.tag('li', {class: 'entry-description'}, {[html.onlyIfContent]: true}, - slots.description), - (html.isBlank(slots.items) + relations.description.slot('mode', 'inline')), + + (html.isBlank(relations.links) ? html.tag('li', - language.$(capsule, 'noFilesAvailable')) - : slots.items), + language.$(capsule, 'entry.noFilesAvailable')) + + : stitchArrays({ + link: relations.links, + path: data.paths, + }).map(({link, path}) => + html.tag('li', + language.encapsulate(capsule, 'file', workingCapsule => { + const workingOptions = {file: link}; + + if (slots.showFileSizes) { + const fileSize = + getSizeOfMediaFile( + urls + .from('media.root') + .to(...path)); + + if (fileSize) { + workingCapsule += '.withSize'; + workingOptions.size = + language.formatFileSize(fileSize); + } + } + + return language.$(workingCapsule, workingOptions); + })))), ]), ]))), }; diff --git a/src/content/dependencies/generateAdditionalFilesListChunkItem.js b/src/content/dependencies/generateAdditionalFilesListChunkItem.js deleted file mode 100644 index c37d6bb2..00000000 --- a/src/content/dependencies/generateAdditionalFilesListChunkItem.js +++ /dev/null @@ -1,30 +0,0 @@ -export default { - extraDependencies: ['html', 'language'], - - slots: { - fileLink: { - type: 'html', - mutable: false, - }, - - fileSize: { - validate: v => v.isWholeNumber, - }, - }, - - generate(slots, {html, language}) { - const itemParts = ['releaseInfo.additionalFiles.file']; - const itemOptions = {file: slots.fileLink}; - - if (slots.fileSize) { - itemParts.push('withSize'); - itemOptions.size = language.formatFileSize(slots.fileSize); - } - - const li = - html.tag('li', - language.$(...itemParts, itemOptions)); - - return li; - }, -}; diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js index 4f92580d..b7392dfd 100644 --- a/src/content/dependencies/generateAdditionalNamesBox.js +++ b/src/content/dependencies/generateAdditionalNamesBox.js @@ -10,6 +10,7 @@ export default { generate: (relations, {html, language}) => html.tag('div', {id: 'additional-names-box'}, + {class: 'drop'}, {[html.onlyIfContent]: true}, [ diff --git a/src/content/dependencies/generateAlbumAdditionalFilesList.js b/src/content/dependencies/generateAlbumAdditionalFilesList.js deleted file mode 100644 index 9818a43c..00000000 --- a/src/content/dependencies/generateAlbumAdditionalFilesList.js +++ /dev/null @@ -1,96 +0,0 @@ -import {stitchArrays} from '#sugar'; - -export default { - contentDependencies: [ - 'generateAdditionalFilesList', - 'generateAdditionalFilesListChunk', - 'generateAdditionalFilesListChunkItem', - 'linkAlbumAdditionalFile', - 'transformContent', - ], - - extraDependencies: ['getSizeOfAdditionalFile', 'html', 'urls'], - - relations: (relation, album, additionalFiles) => ({ - list: - relation('generateAdditionalFilesList', additionalFiles), - - chunks: - additionalFiles - .map(() => relation('generateAdditionalFilesListChunk')), - - chunkDescriptions: - additionalFiles - .map(({description}) => - (description - ? relation('transformContent', description) - : null)), - - chunkItems: - additionalFiles - .map(({files}) => - (files ?? []) - .map(() => relation('generateAdditionalFilesListChunkItem'))), - - chunkItemFileLinks: - additionalFiles - .map(({files}) => - (files ?? []) - .map(file => relation('linkAlbumAdditionalFile', album, file))), - }), - - data: (album, additionalFiles) => ({ - albumDirectory: album.directory, - - chunkTitles: - additionalFiles - .map(({title}) => title), - - chunkItemLocations: - additionalFiles - .map(({files}) => files ?? []), - }), - - slots: { - showFileSizes: {type: 'boolean', default: true}, - }, - - generate: (data, relations, slots, {getSizeOfAdditionalFile, urls}) => - relations.list.slots({ - chunks: - stitchArrays({ - chunk: relations.chunks, - description: relations.chunkDescriptions, - title: data.chunkTitles, - }).map(({chunk, title, description}) => - chunk.slots({ - title, - description: - (description - ? description.slot('mode', 'inline') - : null), - })), - - chunkItems: - stitchArrays({ - items: relations.chunkItems, - fileLinks: relations.chunkItemFileLinks, - locations: data.chunkItemLocations, - }).map(({items, fileLinks, locations}) => - stitchArrays({ - item: items, - fileLink: fileLinks, - location: locations, - }).map(({item, fileLink, location}) => - item.slots({ - fileLink: fileLink, - fileSize: - (slots.showFileSizes - ? getSizeOfAdditionalFile( - urls - .from('media.root') - .to('media.albumAdditionalFile', data.albumDirectory, location)) - : 0), - }))), - }), -}; diff --git a/src/content/dependencies/generateAlbumArtInfoBox.js b/src/content/dependencies/generateAlbumArtInfoBox.js new file mode 100644 index 00000000..8c44c930 --- /dev/null +++ b/src/content/dependencies/generateAlbumArtInfoBox.js @@ -0,0 +1,39 @@ +export default { + contentDependencies: ['generateReleaseInfoContributionsLine'], + extraDependencies: ['html', 'language'], + + relations: (relation, album) => ({ + wallpaperArtistContributionsLine: + (album.wallpaperArtwork + ? relation('generateReleaseInfoContributionsLine', + album.wallpaperArtwork.artistContribs) + : null), + + bannerArtistContributionsLine: + (album.bannerArtwork + ? relation('generateReleaseInfoContributionsLine', + album.bannerArtwork.artistContribs) + : null), + }), + + generate: (relations, {html, language}) => + language.encapsulate('releaseInfo', capsule => + html.tag('div', {class: 'album-art-info'}, + {[html.onlyIfContent]: true}, + + html.tag('p', + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + [ + relations.wallpaperArtistContributionsLine?.slots({ + stringKey: capsule + '.wallpaperArtBy', + chronologyKind: 'wallpaperArt', + }), + + relations.bannerArtistContributionsLine?.slots({ + stringKey: capsule + '.bannerArtBy', + chronologyKind: 'bannerArt', + }), + ]))), +}; diff --git a/src/content/dependencies/generateAlbumArtworkColumn.js b/src/content/dependencies/generateAlbumArtworkColumn.js new file mode 100644 index 00000000..e6762463 --- /dev/null +++ b/src/content/dependencies/generateAlbumArtworkColumn.js @@ -0,0 +1,38 @@ +export default { + contentDependencies: ['generateAlbumArtInfoBox', 'generateCoverArtwork'], + extraDependencies: ['html'], + + relations: (relation, album) => ({ + firstCover: + (album.hasCoverArt + ? relation('generateCoverArtwork', album.coverArtworks[0]) + : null), + + restCovers: + (album.hasCoverArt + ? album.coverArtworks.slice(1).map(artwork => + relation('generateCoverArtwork', artwork)) + : []), + + albumArtInfoBox: + relation('generateAlbumArtInfoBox', album), + }), + + generate: (relations, {html}) => + html.tags([ + relations.firstCover?.slots({ + showOriginDetails: true, + showArtTagDetails: true, + showReferenceDetails: true, + }), + + relations.albumArtInfoBox, + + relations.restCovers.map(cover => + cover.slots({ + showOriginDetails: true, + showArtTagDetails: true, + showReferenceDetails: true, + })), + ]), +}; diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 91ffeb04..3529c4dc 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -1,15 +1,14 @@ -import {stitchArrays} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; export default { contentDependencies: [ 'generateAlbumCommentarySidebar', - 'generateAlbumCoverArtwork', 'generateAlbumNavAccent', 'generateAlbumSecondaryNav', - 'generateAlbumStyleRules', + 'generateAlbumStyleTags', 'generateCommentaryEntry', 'generateContentHeading', - 'generateTrackCoverArtwork', + 'generateCoverArtwork', 'generatePageLayout', 'linkAlbum', 'linkExternal', @@ -18,7 +17,22 @@ export default { extraDependencies: ['html', 'language'], - relations(relation, album) { + query(album) { + const query = {}; + + query.tracksWithCommentary = + album.tracks + .filter(({commentary}) => !empty(commentary)); + + query.thingsWithCommentary = + (empty(album.commentary) + ? query.tracksWithCommentary + : [album, ...query.tracksWithCommentary]); + + return query; + }, + + relations(relation, query, album) { const relations = {}; relations.layout = @@ -30,8 +44,8 @@ export default { relations.sidebar = relation('generateAlbumCommentarySidebar', album); - relations.albumStyleRules = - relation('generateAlbumStyleRules', album, null); + relations.albumStyleTags = + relation('generateAlbumStyleTags', album, null); relations.albumLink = relation('linkAlbum', album); @@ -39,7 +53,7 @@ export default { relations.albumNavAccent = relation('generateAlbumNavAccent', album, null); - if (album.commentary) { + if (!empty(album.commentary)) { relations.albumCommentaryHeading = relation('generateContentHeading'); @@ -51,7 +65,7 @@ export default { if (album.hasCoverArt) { relations.albumCommentaryCover = - relation('generateAlbumCoverArtwork', album); + relation('generateCoverArtwork', album.coverArtworks[0]); } relations.albumCommentaryEntries = @@ -59,32 +73,28 @@ export default { .map(entry => relation('generateCommentaryEntry', entry)); } - const tracksWithCommentary = - album.tracks - .filter(({commentary}) => commentary); - relations.trackCommentaryHeadings = - tracksWithCommentary + query.tracksWithCommentary .map(() => relation('generateContentHeading')); relations.trackCommentaryLinks = - tracksWithCommentary + query.tracksWithCommentary .map(track => relation('linkTrack', track)); relations.trackCommentaryListeningLinks = - tracksWithCommentary + query.tracksWithCommentary .map(track => track.urls.map(url => relation('linkExternal', url))); relations.trackCommentaryCovers = - tracksWithCommentary + query.tracksWithCommentary .map(track => (track.hasUniqueCoverArt - ? relation('generateTrackCoverArtwork', track) + ? relation('generateCoverArtwork', track.trackArtworks[0]) : null)); relations.trackCommentaryEntries = - tracksWithCommentary + query.tracksWithCommentary .map(track => track.commentary .map(entry => relation('generateCommentaryEntry', entry))); @@ -92,29 +102,20 @@ export default { return relations; }, - data(album) { + data(query, album) { const data = {}; data.name = album.name; data.color = album.color; data.date = album.date; - const tracksWithCommentary = - album.tracks - .filter(({commentary}) => commentary); - - const thingsWithCommentary = - (album.commentary - ? [album, ...tracksWithCommentary] - : tracksWithCommentary); - data.entryCount = - thingsWithCommentary + query.thingsWithCommentary .flatMap(({commentary}) => commentary) .length; data.wordCount = - thingsWithCommentary + query.thingsWithCommentary .flatMap(({commentary}) => commentary) .map(({body}) => body) .join(' ') @@ -122,15 +123,15 @@ export default { .length; data.trackCommentaryTrackDates = - tracksWithCommentary + query.tracksWithCommentary .map(track => track.dateFirstReleased); data.trackCommentaryDirectories = - tracksWithCommentary + query.tracksWithCommentary .map(track => track.directory); data.trackCommentaryColors = - tracksWithCommentary + query.tracksWithCommentary .map(track => (track.color === album.color ? null @@ -150,7 +151,7 @@ export default { headingMode: 'sticky', color: data.color, - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, mainClasses: ['long-content'], mainContent: [ @@ -265,7 +266,10 @@ export default { }), })), - cover?.slots({mode: 'commentary'}), + cover?.slots({ + mode: 'commentary', + color: true, + }), trackDate && trackDate !== data.date && diff --git a/src/content/dependencies/generateAlbumCommentarySidebar.js b/src/content/dependencies/generateAlbumCommentarySidebar.js index 8313e6de..9ecec66d 100644 --- a/src/content/dependencies/generateAlbumCommentarySidebar.js +++ b/src/content/dependencies/generateAlbumCommentarySidebar.js @@ -1,3 +1,5 @@ +import {empty} from '#sugar'; + export default { contentDependencies: [ 'generateAlbumSidebarTrackSection', @@ -28,10 +30,10 @@ export default { data: (album) => ({ albumHasCommentary: - !!album.commentary, + !empty(album.commentary), anyTrackHasCommentary: - album.tracks.some(track => track.commentary), + album.tracks.some(track => !empty(track.commentary)), }), generate: (data, relations, {html, language}) => diff --git a/src/content/dependencies/generateAlbumCoverArtwork.js b/src/content/dependencies/generateAlbumCoverArtwork.js deleted file mode 100644 index ff7d2b85..00000000 --- a/src/content/dependencies/generateAlbumCoverArtwork.js +++ /dev/null @@ -1,100 +0,0 @@ -export default { - contentDependencies: [ - 'generateCoverArtwork', - 'generateCoverArtworkArtTagDetails', - 'generateCoverArtworkArtistDetails', - 'generateCoverArtworkReferenceDetails', - 'image', - 'linkAlbumReferencedArtworks', - 'linkAlbumReferencingArtworks', - ], - - extraDependencies: ['html', 'language'], - - relations: (relation, album) => ({ - coverArtwork: - relation('generateCoverArtwork'), - - image: - relation('image'), - - artTagDetails: - relation('generateCoverArtworkArtTagDetails', album.artTags), - - artistDetails: - relation('generateCoverArtworkArtistDetails', album.coverArtistContribs), - - referenceDetails: - relation('generateCoverArtworkReferenceDetails', - album.referencedArtworks, - album.referencedByArtworks), - - referencedArtworksLink: - relation('linkAlbumReferencedArtworks', album), - - referencingArtworksLink: - relation('linkAlbumReferencingArtworks', album), - }), - - data: (album) => ({ - path: - ['media.albumCover', album.directory, album.coverArtFileExtension], - - color: - album.color, - - dimensions: - album.coverArtDimensions, - - warnings: - album.artTags - .filter(tag => tag.isContentWarning) - .map(tag => tag.name), - }), - - slots: { - mode: {type: 'string'}, - - details: { - validate: v => v.is('tags', 'artists'), - default: 'tags', - }, - - showReferenceLinks: { - type: 'boolean', - default: false, - }, - }, - - generate: (data, relations, slots, {language}) => - relations.coverArtwork.slots({ - mode: slots.mode, - - image: - relations.image.slots({ - path: data.path, - color: data.color, - alt: language.$('misc.alt.albumCover'), - }), - - dimensions: data.dimensions, - warnings: data.warnings, - - details: [ - slots.details === 'tags' && - relations.artTagDetails, - - slots.details === 'artists' && - relations.artistDetails, - - slots.showReferenceLinks && - relations.referenceDetails.slots({ - referencedLink: - relations.referencedArtworksLink, - - referencingLink: - relations.referencingArtworksLink, - }), - ], - }), -}; diff --git a/src/content/dependencies/generateAlbumGalleryAlbumGrid.js b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js new file mode 100644 index 00000000..7f152871 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js @@ -0,0 +1,90 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateCoverGrid', + 'image', + 'linkAlbum', + ], + + extraDependencies: ['html', 'language'], + + query: (album) => ({ + artworks: + (album.hasCoverArt + ? album.coverArtworks + : []), + }), + + relations: (relation, query, album) => ({ + coverGrid: + relation('generateCoverGrid'), + + albumLinks: + query.artworks.map(_artwork => + relation('linkAlbum', album)), + + images: + query.artworks + .map(artwork => relation('image', artwork)), + }), + + data: (query, album) => ({ + albumName: + album.name, + + artworkLabels: + query.artworks + .map(artwork => artwork.label), + + artworkArtists: + query.artworks + .map(artwork => artwork.artistContribs + .map(contrib => contrib.artist.name)), + }), + + slots: { + attributes: {type: 'attributes', mutable: false}, + }, + + generate: (data, relations, slots, {html, language}) => + html.tag('div', + {[html.onlyIfContent]: true}, + + slots.attributes, + + [ + relations.coverArtistsLine, + + relations.coverGrid.slots({ + links: + relations.albumLinks, + + names: + data.artworkLabels + .map(label => label ?? data.albumName), + + images: + stitchArrays({ + image: relations.images, + label: data.artworkLabels, + }).map(({image, label}) => + image.slots({ + missingSourceContent: + language.$('misc.albumGalleryGrid.noCoverArt', { + name: + label ?? data.albumName, + }), + })), + + info: + data.artworkArtists.map(artists => + language.$('misc.coverGrid.details.coverArtists', { + [language.onlyIfOptions]: ['artists'], + + artists: + language.formatUnitList(artists), + })), + }), + ]), +}; diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js index b48d92af..516a7ca8 100644 --- a/src/content/dependencies/generateAlbumGalleryPage.js +++ b/src/content/dependencies/generateAlbumGalleryPage.js @@ -1,18 +1,18 @@ -import {compareArrays, stitchArrays} from '#sugar'; +import {stitchArrays, unique} from '#sugar'; +import {getKebabCase} from '#wiki-data'; export default { contentDependencies: [ - 'generateAlbumGalleryCoverArtistsLine', + 'generateAlbumGalleryAlbumGrid', 'generateAlbumGalleryNoTrackArtworksLine', 'generateAlbumGalleryStatsLine', + 'generateAlbumGalleryTrackGrid', 'generateAlbumNavAccent', 'generateAlbumSecondaryNav', - 'generateAlbumStyleRules', - 'generateCoverGrid', + 'generateAlbumStyleTags', + 'generateIntrapageDotSwitcher', 'generatePageLayout', - 'image', 'linkAlbum', - 'linkTrack', ], extraDependencies: ['html', 'language'], @@ -20,147 +20,82 @@ export default { query(album) { const query = {}; - const tracksWithUniqueCoverArt = + const trackArtworkLabels = album.tracks - .filter(track => track.hasUniqueCoverArt); - - // Don't display "all artwork by..." for albums where there's - // only one unique artwork in the first place. - if (tracksWithUniqueCoverArt.length > 1) { - const allCoverArtistArrays = - tracksWithUniqueCoverArt - .map(track => track.coverArtistContribs) - .map(contribs => contribs.map(contrib => contrib.artist)); - - const allSameCoverArtists = - allCoverArtistArrays - .slice(1) - .every(artists => compareArrays(artists, allCoverArtistArrays[0])); - - if (allSameCoverArtists) { - query.coverArtistsForAllTracks = - allCoverArtistArrays[0]; - } - } + .map(track => track.trackArtworks + .map(artwork => artwork.label)); + + const recurranceThreshold = 2; + + // This list may include null, if some artworks are not labelled! + // That's expected. + query.recurringTrackArtworkLabels = + unique(trackArtworkLabels.flat()) + .filter(label => + trackArtworkLabels + .filter(labels => labels.includes(label)) + .length >= + (label === null + ? 1 + : recurranceThreshold)); return query; }, - relations(relation, query, album) { - const relations = {}; + relations: (relation, query, album) => ({ + layout: + relation('generatePageLayout'), - relations.layout = - relation('generatePageLayout'); + albumStyleTags: + relation('generateAlbumStyleTags', album, null), - relations.albumStyleRules = - relation('generateAlbumStyleRules', album, null); - - relations.albumLink = - relation('linkAlbum', album); - - relations.albumNavAccent = - relation('generateAlbumNavAccent', album, null); - - relations.secondaryNav = - relation('generateAlbumSecondaryNav', album); - - relations.statsLine = - relation('generateAlbumGalleryStatsLine', album); - - if (album.tracks.every(track => !track.hasUniqueCoverArt)) { - relations.noTrackArtworksLine = - relation('generateAlbumGalleryNoTrackArtworksLine'); - } - - if (query.coverArtistsForAllTracks) { - relations.coverArtistsLine = - relation('generateAlbumGalleryCoverArtistsLine', query.coverArtistsForAllTracks); - } - - relations.coverGrid = - relation('generateCoverGrid'); - - relations.links = [ + albumLink: relation('linkAlbum', album), - ... - album.tracks - .map(track => relation('linkTrack', track)), - ]; - - relations.images = [ - (album.hasCoverArt - ? relation('image', album.artTags) - : relation('image')), + albumNavAccent: + relation('generateAlbumNavAccent', album, null), - ... - album.tracks.map(track => - (track.hasUniqueCoverArt - ? relation('image', track.artTags) - : relation('image'))), - ]; - - return relations; - }, + secondaryNav: + relation('generateAlbumSecondaryNav', album), - data(query, album) { - const data = {}; + statsLine: + relation('generateAlbumGalleryStatsLine', album), - data.name = album.name; - data.color = album.color; - - data.names = [ - album.name, - ...album.tracks.map(track => track.name), - ]; - - data.coverArtists = [ - (album.hasCoverArt - ? album.coverArtistContribs.map(({artist}) => artist.name) + noTrackArtworksLine: + (album.tracks.every(track => !track.hasUniqueCoverArt) + ? relation('generateAlbumGalleryNoTrackArtworksLine') : null), - ... - album.tracks.map(track => { - if (query.coverArtistsForAllTracks) { - return null; - } + setSwitcher: + relation('generateIntrapageDotSwitcher'), - if (track.hasUniqueCoverArt) { - return track.coverArtistContribs.map(({artist}) => artist.name); - } + albumGrid: + relation('generateAlbumGalleryAlbumGrid', album), - return null; - }), - ]; - - data.paths = [ - (album.hasCoverArt - ? ['media.albumCover', album.directory, album.coverArtFileExtension] - : null), + trackGrids: + query.recurringTrackArtworkLabels.map(label => + relation('generateAlbumGalleryTrackGrid', album, label)), + }), - ... - album.tracks.map(track => - (track.hasUniqueCoverArt - ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] - : null)), - ]; + data: (query, album) => ({ + trackGridLabels: + query.recurringTrackArtworkLabels, - data.dimensions = [ - (album.hasCoverArt - ? album.coverArtDimensions - : null), + trackGridIDs: + query.recurringTrackArtworkLabels.map(label => + 'track-grid-' + + (label + ? getKebabCase(label) + : 'no-label')), - ... - album.tracks.map(track => - (track.hasUniqueCoverArt - ? track.coverArtDimensions - : null)), - ]; + name: + album.name, - return data; - }, + color: + album.color, + }), - generate: (data, relations, {language}) => + generate: (data, relations, {html, language}) => language.encapsulate('albumGalleryPage', pageCapsule => relations.layout.slots({ title: @@ -171,39 +106,44 @@ export default { headingMode: 'static', color: data.color, - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, mainClasses: ['top-index'], mainContent: [ relations.statsLine, - relations.coverArtistsLine, + + relations.albumGrid, + relations.noTrackArtworksLine, - relations.coverGrid - .slots({ - links: relations.links, - names: data.names, - images: - stitchArrays({ - image: relations.images, - path: data.paths, - dimensions: data.dimensions, - name: data.names, - }).map(({image, path, dimensions, name}) => - image.slots({ - path, - dimensions, - missingSourceContent: - language.$('misc.albumGalleryGrid.noCoverArt', {name}), - })), - info: - data.coverArtists.map(names => - (names === null - ? null - : language.$('misc.coverGrid.details.coverArtists', { - artists: language.formatUnitList(names), - }))), - }), + data.trackGridLabels.some(value => value !== null) && + html.tag('p', {class: 'gallery-set-switcher'}, + language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule => + language.$(switcherCapsule, { + sets: + relations.setSwitcher.slots({ + initialOptionIndex: 0, + + titles: + data.trackGridLabels.map(label => + label ?? + language.$(switcherCapsule, 'unlabeledSet')), + + targetIDs: + data.trackGridIDs, + }), + }))), + + stitchArrays({ + grid: relations.trackGrids, + id: data.trackGridIDs, + }).map(({grid, id}, index) => + grid.slots({ + attributes: [ + {id}, + index >= 1 && {style: 'display: none'}, + ], + })), ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js new file mode 100644 index 00000000..fb5ed7ea --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js @@ -0,0 +1,122 @@ +import {compareArrays, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAlbumGalleryCoverArtistsLine', + 'generateCoverGrid', + 'image', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query(album, label) { + const query = {}; + + query.artworks = + album.tracks.map(track => + track.trackArtworks.find(artwork => artwork.label === label) ?? + null); + + const presentArtworks = + query.artworks.filter(Boolean); + + if (presentArtworks.length > 1) { + const allArtistArrays = + presentArtworks + .map(artwork => artwork.artistContribs + .map(contrib => contrib.artist)); + + const allSameArtists = + allArtistArrays + .slice(1) + .every(artists => compareArrays(artists, allArtistArrays[0])); + + if (allSameArtists) { + query.artistsForAllTrackArtworks = + allArtistArrays[0]; + } + } + + return query; + }, + + relations: (relation, query, album, _label) => ({ + coverArtistsLine: + (query.artistsForAllTrackArtworks + ? relation('generateAlbumGalleryCoverArtistsLine', + query.artistsForAllTrackArtworks) + : null), + + coverGrid: + relation('generateCoverGrid'), + + albumLink: + relation('linkAlbum', album), + + trackLinks: + album.tracks + .map(track => relation('linkTrack', track)), + + images: + query.artworks + .map(artwork => relation('image', artwork)), + }), + + data: (query, album, _label) => ({ + trackNames: + album.tracks + .map(track => track.name), + + artworkArtists: + query.artworks.map(artwork => + (query.artistsForAllTrackArtworks + ? null + : artwork + ? artwork.artistContribs + .map(contrib => contrib.artist.name) + : null)), + }), + + slots: { + attributes: {type: 'attributes', mutable: false}, + }, + + generate: (data, relations, slots, {html, language}) => + html.tag('div', + {[html.onlyIfContent]: true}, + + slots.attributes, + + [ + relations.coverArtistsLine, + + relations.coverGrid.slots({ + links: + relations.trackLinks, + + names: + data.trackNames, + + images: + stitchArrays({ + image: relations.images, + name: data.trackNames, + }).map(({image, name}) => + image.slots({ + missingSourceContent: + language.$('misc.albumGalleryGrid.noCoverArt', {name}), + })), + + info: + data.artworkArtists.map(artists => + language.$('misc.coverGrid.details.coverArtists', { + [language.onlyIfOptions]: ['artists'], + + artists: + language.formatUnitList(artists), + })), + }), + ]), +}; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index 1f741a60..1664c788 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -1,17 +1,20 @@ +import {empty} from '#sugar'; + export default { contentDependencies: [ + 'generateAdditionalFilesList', 'generateAdditionalNamesBox', - 'generateAlbumAdditionalFilesList', + 'generateAlbumArtworkColumn', 'generateAlbumBanner', - 'generateAlbumCoverArtwork', 'generateAlbumNavAccent', 'generateAlbumReleaseInfo', 'generateAlbumSecondaryNav', 'generateAlbumSidebar', 'generateAlbumSocialEmbed', - 'generateAlbumStyleRules', + 'generateAlbumStyleTags', 'generateAlbumTrackList', - 'generateCommentarySection', + 'generateCommentaryEntry', + 'generateContentContentHeading', 'generateContentHeading', 'generatePageLayout', 'linkAlbumCommentary', @@ -24,8 +27,8 @@ export default { layout: relation('generatePageLayout'), - albumStyleRules: - relation('generateAlbumStyleRules', album, null), + albumStyleTags: + relation('generateAlbumStyleTags', album, null), socialEmbed: relation('generateAlbumSocialEmbed', album), @@ -42,10 +45,8 @@ export default { additionalNamesBox: relation('generateAdditionalNamesBox', album.additionalNames), - cover: - (album.hasCoverArt - ? relation('generateAlbumCoverArtwork', album) - : null), + artworkColumn: + relation('generateAlbumArtworkColumn', album), banner: (album.hasBannerArt @@ -55,6 +56,9 @@ export default { contentHeading: relation('generateContentHeading'), + contentContentHeading: + relation('generateContentContentHeading', album), + releaseInfo: relation('generateAlbumReleaseInfo', album), @@ -64,7 +68,7 @@ export default { : null), commentaryLink: - (album.commentary || album.tracks.some(t => t.commentary) + ([album, ...album.tracks].some(({commentary}) => !empty(commentary)) ? relation('linkAlbumCommentary', album) : null), @@ -72,15 +76,15 @@ export default { relation('generateAlbumTrackList', album), additionalFilesList: - relation('generateAlbumAdditionalFilesList', - album, - album.additionalFiles), + relation('generateAdditionalFilesList', album.additionalFiles), - artistCommentarySection: - relation('generateCommentarySection', album.commentary), + artistCommentaryEntries: + album.commentary + .map(entry => relation('generateCommentaryEntry', entry)), - creditSourcesSection: - relation('generateCommentarySection', album.creditSources), + creditSourceEntries: + album.creditingSources + .map(entry => relation('generateCommentaryEntry', entry)), }), data: (album) => ({ @@ -104,16 +108,12 @@ export default { color: data.color, headingMode: 'sticky', - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, additionalNames: relations.additionalNamesBox, - cover: - (relations.cover - ? relations.cover.slots({ - showReferenceLinks: true, - }) - : null), + artworkColumnContent: + relations.artworkColumn, mainContent: [ relations.releaseInfo, @@ -160,12 +160,12 @@ export default { : html.blank()), - !html.isBlank(relations.creditSourcesSection) && - language.encapsulate(capsule, 'readCreditSources', capsule => + !html.isBlank(relations.creditSourceEntries) && + language.encapsulate(capsule, 'readCreditingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#credit-sources'}, + {href: '#crediting-sources'}, language.$(capsule, 'link')), })), ])), @@ -183,6 +183,11 @@ export default { }), ])), + (!html.isBlank(relations.artistCommentaryEntries) || + !html.isBlank(relations.creditSourceEntries)) + && + html.tag('hr', {class: 'main-separator'}), + language.encapsulate('releaseInfo.additionalFiles', capsule => html.tags([ relations.contentHeading.clone() @@ -194,12 +199,25 @@ export default { relations.additionalFilesList, ])), - relations.artistCommentarySection, + html.tags([ + relations.contentContentHeading.clone() + .slots({ + attributes: {id: 'artist-commentary'}, + string: 'misc.artistCommentary', + }), - relations.creditSourcesSection.slots({ - id: 'credit-sources', - title: language.$('misc.creditSources'), - }), + relations.artistCommentaryEntries, + ]), + + html.tags([ + relations.contentContentHeading.clone() + .slots({ + attributes: {id: 'crediting-sources'}, + string: 'misc.creditingSources', + }), + + relations.creditSourceEntries, + ]), ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js index 3adb01be..432c5f3d 100644 --- a/src/content/dependencies/generateAlbumNavAccent.js +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -1,4 +1,4 @@ -import {atOffset} from '#sugar'; +import {atOffset, empty} from '#sugar'; export default { contentDependencies: [ @@ -65,8 +65,8 @@ export default { album.tracks.length > 1, commentaryPageIsStub: - !album.commentary && - album.tracks.every(t => !t.commentary), + [album, ...album.tracks] + .every(({commentary}) => empty(commentary)), galleryIsStub: album.tracks.every(t => !t.hasUniqueCoverArt), diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js index 3f3d77b3..52c78dc2 100644 --- a/src/content/dependencies/generateAlbumReferencedArtworksPage.js +++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js @@ -1,7 +1,6 @@ export default { contentDependencies: [ - 'generateAlbumCoverArtwork', - 'generateAlbumStyleRules', + 'generateAlbumStyleTags', 'generateBackToAlbumLink', 'generateReferencedArtworksPage', 'linkAlbum', @@ -11,27 +10,21 @@ export default { relations: (relation, album) => ({ page: - relation('generateReferencedArtworksPage', album.referencedArtworks), + relation('generateReferencedArtworksPage', album.coverArtworks[0]), - albumStyleRules: - relation('generateAlbumStyleRules', album, null), + albumStyleTags: + relation('generateAlbumStyleTags', album, null), albumLink: relation('linkAlbum', album), backToAlbumLink: relation('generateBackToAlbumLink', album), - - cover: - relation('generateAlbumCoverArtwork', album), }), data: (album) => ({ name: album.name, - - color: - album.color, }), generate: (data, relations, {html, language}) => @@ -42,10 +35,7 @@ export default { data.name, }), - color: data.color, - styleRules: [relations.albumStyleRules], - - cover: relations.cover, + styleTags: relations.albumStyleTags, navLinks: [ {auto: 'home'}, diff --git a/src/content/dependencies/generateAlbumReferencingArtworksPage.js b/src/content/dependencies/generateAlbumReferencingArtworksPage.js index 8f2349f9..bc36ae06 100644 --- a/src/content/dependencies/generateAlbumReferencingArtworksPage.js +++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js @@ -1,7 +1,6 @@ export default { contentDependencies: [ - 'generateAlbumCoverArtwork', - 'generateAlbumStyleRules', + 'generateAlbumStyleTags', 'generateBackToAlbumLink', 'generateReferencingArtworksPage', 'linkAlbum', @@ -11,27 +10,21 @@ export default { relations: (relation, album) => ({ page: - relation('generateReferencingArtworksPage', album.referencedByArtworks), + relation('generateReferencingArtworksPage', album.coverArtworks[0]), - albumStyleRules: - relation('generateAlbumStyleRules', album, null), + albumStyleTags: + relation('generateAlbumStyleTags', album, null), albumLink: relation('linkAlbum', album), backToAlbumLink: relation('generateBackToAlbumLink', album), - - cover: - relation('generateAlbumCoverArtwork', album), }), data: (album) => ({ name: album.name, - - color: - album.color, }), generate: (data, relations, {html, language}) => @@ -42,10 +35,7 @@ export default { data.name, }), - color: data.color, - styleRules: [relations.albumStyleRules], - - cover: relations.cover, + styleTags: relations.albumStyleTags, navLinks: [ {auto: 'home'}, diff --git a/src/content/dependencies/generateAlbumReleaseInfo.js b/src/content/dependencies/generateAlbumReleaseInfo.js index 217282c0..2a958244 100644 --- a/src/content/dependencies/generateAlbumReleaseInfo.js +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -3,7 +3,7 @@ import {accumulateSum, empty} from '#sugar'; export default { contentDependencies: [ 'generateReleaseInfoContributionsLine', - 'linkExternal', + 'generateReleaseInfoListenLine', ], extraDependencies: ['html', 'language'], @@ -14,18 +14,8 @@ export default { relations.artistContributionsLine = relation('generateReleaseInfoContributionsLine', album.artistContribs); - relations.coverArtistContributionsLine = - relation('generateReleaseInfoContributionsLine', album.coverArtistContribs); - - relations.wallpaperArtistContributionsLine = - relation('generateReleaseInfoContributionsLine', album.wallpaperArtistContribs); - - relations.bannerArtistContributionsLine = - relation('generateReleaseInfoContributionsLine', album.bannerArtistContribs); - - relations.externalLinks = - album.urls.map(url => - relation('linkExternal', url)); + relations.listenLine = + relation('generateReleaseInfoListenLine', album); return relations; }, @@ -73,31 +63,11 @@ export default { chronologyKind: 'album', }), - relations.coverArtistContributionsLine.slots({ - stringKey: capsule + '.coverArtBy', - chronologyKind: 'coverArt', - }), - - relations.wallpaperArtistContributionsLine.slots({ - stringKey: capsule + '.wallpaperArtBy', - chronologyKind: 'wallpaperArt', - }), - - relations.bannerArtistContributionsLine.slots({ - stringKey: capsule + '.bannerArtBy', - chronologyKind: 'bannerArt', - }), - language.$(capsule, 'released', { [language.onlyIfOptions]: ['date'], date: language.formatDate(data.date), }), - language.$(capsule, 'artReleased', { - [language.onlyIfOptions]: ['date'], - date: language.formatDate(data.coverArtDate), - }), - language.$(capsule, 'duration', { [language.onlyIfOptions]: ['duration'], duration: @@ -110,21 +80,16 @@ export default { html.tag('p', {[html.onlyIfContent]: true}, - language.$(capsule, 'listenOn', { - [language.onlyIfOptions]: ['links'], - - links: - language.formatDisjunctionList( - relations.externalLinks - .map(link => - link.slot('context', [ - 'album', - (data.numTracks === 0 - ? 'albumNoTracks' - : data.numTracks === 1 - ? 'albumOneTrack' - : 'albumMultipleTracks'), - ]))), + relations.listenLine.slots({ + context: [ + 'album', + + (data.numTracks === 0 + ? 'albumNoTracks' + : data.numTracks === 1 + ? 'albumOneTrack' + : 'albumMultipleTracks'), + ], })), ])), }; diff --git a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js index 9f9aaf23..22dfa51c 100644 --- a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js +++ b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js @@ -67,6 +67,8 @@ export default { generate: (relations, slots) => relations.parentSiblingsPart.slots({ + attributes: {class: 'group-nav-links'}, + showPreviousNext: slots.mode === 'album', colorStyle: relations.colorStyle, diff --git a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js index f579cdc9..16f205e3 100644 --- a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js +++ b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js @@ -62,7 +62,7 @@ export default { generate: (data, relations, slots, {language}) => relations.parentSiblingsPart.slots({ - attributes: {class: 'series-nav-link'}, + attributes: {class: 'series-nav-links'}, showPreviousNext: slots.mode === 'album', diff --git a/src/content/dependencies/generateAlbumSidebar.js b/src/content/dependencies/generateAlbumSidebar.js index bd53ef71..7cf689cc 100644 --- a/src/content/dependencies/generateAlbumSidebar.js +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -1,4 +1,5 @@ -import {stitchArrays} from '#sugar'; +import {sortAlbumsTracksChronologically} from '#sort'; +import {stitchArrays, transposeArrays} from '#sugar'; export default { contentDependencies: [ @@ -7,6 +8,7 @@ export default { 'generateAlbumSidebarTrackListBox', 'generatePageSidebar', 'generatePageSidebarConjoinedBox', + 'generateTrackReleaseBox', ], extraDependencies: ['html', 'wikiData'], @@ -17,7 +19,7 @@ export default { groupData.flatMap(group => group.serieses), }), - query(sprawl, album) { + query(sprawl, album, track) { const query = {}; query.groups = @@ -35,6 +37,33 @@ export default { series.albums.includes(album) && !query.groups.includes(series.group)); + if (track) { + const albumTrackMap = + new Map(transposeArrays([ + track.allReleases.map(t => t.album), + track.allReleases, + ])); + + const allReleaseAlbums = + sortAlbumsTracksChronologically( + Array.from(albumTrackMap.keys())); + + const currentReleaseIndex = + allReleaseAlbums.indexOf(track.album); + + const earlierReleaseAlbums = + allReleaseAlbums.slice(0, currentReleaseIndex); + + const laterReleaseAlbums = + allReleaseAlbums.slice(currentReleaseIndex + 1); + + query.earlierReleaseTracks = + earlierReleaseAlbums.map(album => albumTrackMap.get(album)); + + query.laterReleaseTracks = + laterReleaseAlbums.map(album => albumTrackMap.get(album)); + } + return query; }, @@ -63,10 +92,25 @@ export default { query.disconnectedSerieses .map(series => relation('generateAlbumSidebarSeriesBox', album, series)), + + earlierTrackReleaseBoxes: + (track + ? query.earlierReleaseTracks + .map(track => + relation('generateTrackReleaseBox', track)) + : null), + + laterTrackReleaseBoxes: + (track + ? query.laterReleaseTracks + .map(track => + relation('generateTrackReleaseBox', track)) + : null), }), data: (_query, _sprawl, _album, track) => ({ isAlbumPage: !track, + isTrackPage: !!track, }), generate(data, relations, {html}) { @@ -98,9 +142,15 @@ export default { ]), ], + data.isTrackPage && + relations.earlierTrackReleaseBoxes, + relations.trackListBox, - !data.isAlbumPage && + data.isTrackPage && + relations.laterTrackReleaseBoxes, + + data.isTrackPage && relations.conjoinedBox.slots({ attributes: {class: 'conjoined-group-sidebar-box'}, boxes: diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js index bb788d65..dae5fa03 100644 --- a/src/content/dependencies/generateAlbumSidebarTrackSection.js +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -1,3 +1,5 @@ +import {empty, stitchArrays} from '#sugar'; + export default { contentDependencies: ['linkTrack'], extraDependencies: ['getColors', 'html', 'language'], @@ -15,23 +17,25 @@ export default { data(album, track, trackSection) { const data = {}; - data.hasTrackNumbers = album.hasTrackNumbers; + data.hasTrackNumbers = + album.hasTrackNumbers && + !empty(trackSection.tracks); + data.isTrackPage = !!track; data.name = trackSection.name; data.color = trackSection.color; data.isDefaultTrackSection = trackSection.isDefaultTrackSection; - data.firstTrackNumber = trackSection.startIndex + 1; - data.lastTrackNumber = trackSection.startIndex + trackSection.tracks.length; + data.firstTrackNumber = + (data.hasTrackNumbers + ? trackSection.tracks.at(0).trackNumber + : null); - if (track) { - const index = trackSection.tracks.indexOf(track); - if (index !== -1) { - data.includesCurrentTrack = true; - data.currentTrackIndex = index; - } - } + data.lastTrackNumber = + (data.hasTrackNumbers + ? trackSection.tracks.at(-1).trackNumber + : null); data.trackDirectories = trackSection.tracks @@ -39,7 +43,14 @@ export default { data.tracksAreMissingCommentary = trackSection.tracks - .map(track => !track.commentary); + .map(track => empty(track.commentary)); + + data.tracksAreCurrentTrack = + trackSection.tracks + .map(traaaaaaaack => traaaaaaaack === track); + + data.includesCurrentTrack = + data.tracksAreCurrentTrack.includes(true); return data; }, @@ -70,29 +81,39 @@ export default { } const trackListItems = - relations.trackLinks.map((trackLink, index) => - html.tag('li', - data.includesCurrentTrack && - index === data.currentTrackIndex && - {class: 'current'}, - - slots.mode === 'commentary' && - data.tracksAreMissingCommentary[index] && - {class: 'no-commentary'}, - - language.$(capsule, 'item', { - track: - (slots.mode === 'commentary' && data.tracksAreMissingCommentary[index] - ? trackLink.slots({ - linkless: true, - }) - : slots.anchor - ? trackLink.slots({ - anchor: true, - hash: data.trackDirectories[index], - }) - : trackLink), - }))); + stitchArrays({ + trackLink: relations.trackLinks, + directory: data.trackDirectories, + isCurrentTrack: data.tracksAreCurrentTrack, + missingCommentary: data.tracksAreMissingCommentary, + }).map(({ + trackLink, + directory, + isCurrentTrack, + missingCommentary, + }) => + html.tag('li', + data.includesCurrentTrack && + isCurrentTrack && + {class: 'current'}, + + slots.mode === 'commentary' && + missingCommentary && + {class: 'no-commentary'}, + + language.$(capsule, 'item', { + track: + (slots.mode === 'commentary' && missingCommentary + ? trackLink.slots({ + linkless: true, + }) + : slots.anchor + ? trackLink.slots({ + anchor: true, + hash: directory, + }) + : trackLink), + }))); return html.tag('details', data.includesCurrentTrack && @@ -119,17 +140,22 @@ export default { colorStyle, html.tag('span', - language.encapsulate(capsule, 'group', workingCapsule => { - const workingOptions = {group: sectionName}; - - if (data.hasTrackNumbers) { - workingCapsule += '.withRange'; - workingOptions.range = - `${data.firstTrackNumber}–${data.lastTrackNumber}`; - } - - return language.$(workingCapsule, workingOptions); - }))), + language.encapsulate(capsule, 'group', groupCapsule => + language.encapsulate(groupCapsule, workingCapsule => { + const workingOptions = {group: sectionName}; + + if (data.hasTrackNumbers) { + workingCapsule += '.withRange'; + workingOptions.rangePart = + html.tag('span', {class: 'track-section-range'}, + language.$(groupCapsule, 'withRange.rangePart', { + range: + `${data.firstTrackNumber}–${data.lastTrackNumber}`, + })); + } + + return language.$(workingCapsule, workingOptions); + })))), (data.hasTrackNumbers ? html.tag('ol', diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js index 54574e45..e28a3fd0 100644 --- a/src/content/dependencies/generateAlbumSocialEmbed.js +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -6,7 +6,7 @@ export default { 'generateAlbumSocialEmbedDescription', ], - extraDependencies: ['absoluteTo', 'language', 'urls'], + extraDependencies: ['absoluteTo', 'language'], relations(relation, album) { return { @@ -32,8 +32,7 @@ export default { data.hasImage = album.hasCoverArt; if (data.hasImage) { - data.coverArtDirectory = album.directory; - data.coverArtFileExtension = album.coverArtFileExtension; + data.imagePath = album.coverArtworks[0].path; } data.albumName = album.name; @@ -41,7 +40,7 @@ export default { return data; }, - generate: (data, relations, {absoluteTo, language, urls}) => + generate: (data, relations, {absoluteTo, language}) => language.encapsulate('albumPage.socialEmbed', embedCapsule => relations.socialEmbed.slots({ title: @@ -65,10 +64,7 @@ export default { imagePath: (data.hasImage - ? '/' + - urls - .from('shared.root') - .to('media.albumCover', data.coverArtDirectory, data.coverArtFileExtension) + ? data.imagePath : null), })), }; diff --git a/src/content/dependencies/generateAlbumStyleRules.js b/src/content/dependencies/generateAlbumStyleRules.js deleted file mode 100644 index 6bfcc62e..00000000 --- a/src/content/dependencies/generateAlbumStyleRules.js +++ /dev/null @@ -1,107 +0,0 @@ -import {empty, stitchArrays} from '#sugar'; - -export default { - extraDependencies: ['to'], - - data(album, track) { - const data = {}; - - data.hasWallpaper = !empty(album.wallpaperArtistContribs); - data.hasBanner = !empty(album.bannerArtistContribs); - - if (data.hasWallpaper) { - if (!empty(album.wallpaperParts)) { - data.wallpaperMode = 'parts'; - - data.wallpaperPaths = - album.wallpaperParts.map(part => - (part.asset - ? ['media.albumWallpaperPart', album.directory, part.asset] - : null)); - - data.wallpaperStyles = - album.wallpaperParts.map(part => part.style); - } else { - data.wallpaperMode = 'one'; - data.wallpaperPath = ['media.albumWallpaper', album.directory, album.wallpaperFileExtension]; - data.wallpaperStyle = album.wallpaperStyle; - } - } - - if (data.hasBanner) { - data.hasBannerStyle = !!album.bannerStyle; - data.bannerStyle = album.bannerStyle; - } - - data.albumDirectory = album.directory; - - if (track) { - data.trackDirectory = track.directory; - } - - return data; - }, - - generate(data, {to}) { - const indent = parts => - (parts ?? []) - .filter(Boolean) - .join('\n') - .split('\n') - .map(line => ' '.repeat(4) + line) - .join('\n'); - - const rule = (selector, parts) => - (!empty(parts.filter(Boolean)) - ? [`${selector} {`, indent(parts), `}`] - : []); - - const oneWallpaperRule = - data.wallpaperMode === 'one' && - rule(`body::before`, [ - `background-image: url("${to(...data.wallpaperPath)}");`, - data.wallpaperStyle, - ]); - - const wallpaperPartRules = - data.wallpaperMode === 'parts' && - stitchArrays({ - path: data.wallpaperPaths, - style: data.wallpaperStyles, - }).map(({path, style}, index) => - rule(`.wallpaper-part:nth-child(${index + 1})`, [ - path && `background-image: url("${to(...path)}");`, - style, - ])); - - const nukeBasicWallpaperRule = - data.wallpaperMode === 'parts' && - rule(`body::before`, ['display: none']); - - const wallpaperRules = [ - oneWallpaperRule, - ...wallpaperPartRules || [], - nukeBasicWallpaperRule, - ]; - - const bannerRule = - data.hasBanner && - rule(`#banner img`, [ - data.bannerStyle, - ]); - - const dataRule = - rule(`:root`, [ - data.albumDirectory && - `--album-directory: ${data.albumDirectory};`, - data.trackDirectory && - `--track-directory: ${data.trackDirectory};`, - ]); - - return ( - [...wallpaperRules, bannerRule, dataRule] - .filter(Boolean) - .flat() - .join('\n')); - }, -}; diff --git a/src/content/dependencies/generateAlbumStyleTags.js b/src/content/dependencies/generateAlbumStyleTags.js new file mode 100644 index 00000000..4cdc6581 --- /dev/null +++ b/src/content/dependencies/generateAlbumStyleTags.js @@ -0,0 +1,65 @@ +import {empty} from '#sugar'; + +export default { + contentDependencies: ['generateAlbumWallpaperStyleTag', 'generateStyleTag'], + extraDependencies: ['html'], + + relations: (relation, album, _track) => ({ + styleTag: + relation('generateStyleTag'), + + wallpaperStyleTag: + relation('generateAlbumWallpaperStyleTag', album), + }), + + data(album, track) { + const data = {}; + + data.hasBanner = !empty(album.bannerArtistContribs); + + if (data.hasBanner) { + data.hasBannerStyle = !!album.bannerStyle; + data.bannerStyle = album.bannerStyle; + } + + data.albumDirectory = album.directory; + + if (track) { + data.trackDirectory = track.directory; + } + + return data; + }, + + generate: (data, relations, {html}) => + html.tags([ + relations.wallpaperStyleTag, + + relations.styleTag.clone().slots({ + attributes: {class: 'album-banner-style'}, + + rules: [ + data.hasBanner && { + select: '#banner img', + declare: [data.bannerStyle], + }, + ], + }), + + relations.styleTag.clone().slots({ + attributes: {class: 'album-directory-style'}, + + rules: [ + { + select: ':root', + declare: [ + data.albumDirectory && + `--album-directory: ${data.albumDirectory};`, + data.trackDirectory && + `--track-directory: ${data.trackDirectory};`, + ], + }, + ] + }), + ], {[html.joinChildren]: ''}), +}; diff --git a/src/content/dependencies/generateAlbumTrackList.js b/src/content/dependencies/generateAlbumTrackList.js index 9743c750..0a949ded 100644 --- a/src/content/dependencies/generateAlbumTrackList.js +++ b/src/content/dependencies/generateAlbumTrackList.js @@ -102,11 +102,11 @@ export default { .map(section => section.tracks.length > 1); if (album.hasTrackNumbers) { - data.trackSectionStartIndices = + data.trackSectionsStartCountingFrom = album.trackSections - .map(section => section.startIndex); + .map(section => section.startCountingFrom); } else { - data.trackSectionStartIndices = + data.trackSectionsStartCountingFrom = album.trackSections .map(() => null); } @@ -147,7 +147,7 @@ export default { name: data.trackSectionNames, duration: data.trackSectionDurations, durationApproximate: data.trackSectionDurationsApproximate, - startIndex: data.trackSectionStartIndices, + startCountingFrom: data.trackSectionsStartCountingFrom, }).map(({ heading, description, @@ -156,7 +156,7 @@ export default { name, duration, durationApproximate, - startIndex, + startCountingFrom, }) => [ language.encapsulate('trackList.section', capsule => heading.slots({ @@ -190,7 +190,7 @@ export default { html.tag(listTag, data.hasTrackNumbers && - {start: startIndex + 1}, + {start: startCountingFrom}, slotItems(items)), ]), diff --git a/src/content/dependencies/generateAlbumWallpaperStyleTag.js b/src/content/dependencies/generateAlbumWallpaperStyleTag.js new file mode 100644 index 00000000..47864a1d --- /dev/null +++ b/src/content/dependencies/generateAlbumWallpaperStyleTag.js @@ -0,0 +1,38 @@ +export default { + contentDependencies: ['generateWallpaperStyleTag'], + extraDependencies: ['html'], + + relations: (relation, album) => ({ + wallpaperStyleTag: + (album.hasWallpaperArt + ? relation('generateWallpaperStyleTag') + : null), + }), + + data: (album) => ({ + singleWallpaperPath: + ['media.albumWallpaper', album.directory, album.wallpaperFileExtension], + + singleWallpaperStyle: + album.wallpaperStyle, + + wallpaperPartPaths: + album.wallpaperParts.map(part => + (part.asset + ? ['media.albumWallpaperPart', album.directory, part.asset] + : null)), + + wallpaperPartStyles: + album.wallpaperParts.map(part => part.style), + }), + + generate: (data, relations, {html}) => + (relations.wallpaperStyleTag + ? relations.wallpaperStyleTag.slots({ + singleWallpaperPath: data.singleWallpaperPath, + singleWallpaperStyle: data.singleWallpaperStyle, + wallpaperPartPaths: data.wallpaperPartPaths, + wallpaperPartStyles: data.wallpaperPartStyles, + }) + : html.blank()), +}; diff --git a/src/content/dependencies/generateArtTagAncestorDescendantMapList.js b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js new file mode 100644 index 00000000..80d19b5a --- /dev/null +++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js @@ -0,0 +1,153 @@ +import { + filterMultipleArrays, + sortMultipleArrays, + stitchArrays, + unique, +} from '#sugar'; + +export default { + contentDependencies: ['linkArtTagDynamically'], + extraDependencies: ['html', 'language'], + + // Recursion ain't too pretty! + + query(ancestorArtTag, targetArtTag) { + const recursive = artTag => { + const artTags = + artTag.directDescendantArtTags.slice(); + + const displayBriefly = + !artTags.includes(targetArtTag) && + artTags.length > 3; + + const artTagsIncludeTargetArtTag = + artTags.map(artTag => artTag.allDescendantArtTags.includes(targetArtTag)); + + const numExemptArtTags = + (displayBriefly + ? artTagsIncludeTargetArtTag + .filter(includesTargetArtTag => !includesTargetArtTag) + .length + : null); + + const artTagsTimesFeaturedTotal = + artTags.map(artTag => + unique([ + ...artTag.directlyFeaturedInArtworks, + ...artTag.indirectlyFeaturedInArtworks, + ]).length); + + const sublists = + stitchArrays({ + artTag: artTags, + includesTargetArtTag: artTagsIncludeTargetArtTag, + }).map(({artTag, includesTargetArtTag}) => + (includesTargetArtTag + ? recursive(artTag) + : null)); + + if (displayBriefly) { + filterMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal, + (artTag, sublist) => + artTag === targetArtTag || + sublist !== null); + } else { + sortMultipleArrays(artTags, sublists, artTagsTimesFeaturedTotal, + (artTagA, artTagB, sublistA, sublistB) => + (sublistA && sublistB + ? 0 + : !sublistA && !sublistB + ? 0 + : sublistA + ? 1 + : -1)); + } + + return { + displayBriefly, + numExemptArtTags, + artTags, + artTagsTimesFeaturedTotal, + sublists, + }; + }; + + return {root: recursive(ancestorArtTag)}; + }, + + relations(relation, query, _ancestorArtTag, _targetArtTag) { + const recursive = ({artTags, sublists}) => ({ + artTagLinks: + artTags + .map(artTag => relation('linkArtTagDynamically', artTag)), + + sublists: + sublists + .map(sublist => (sublist ? recursive(sublist) : null)), + }); + + return {root: recursive(query.root)}; + }, + + data(query, _ancestorArtTag, targetArtTag) { + const recursive = ({ + displayBriefly, + numExemptArtTags, + artTags, + artTagsTimesFeaturedTotal, + sublists, + }) => ({ + displayBriefly, + numExemptArtTags, + artTagsTimesFeaturedTotal, + + artTagsAreTargetTag: + artTags + .map(artTag => artTag === targetArtTag), + + sublists: + sublists + .map(sublist => (sublist ? recursive(sublist) : null)), + }); + + return {root: recursive(query.root)}; + }, + + generate(data, relations, {html, language}) { + const recursive = (dataNode, relationsNode) => + html.tag('dl', {class: dataNode === data.root && 'tree-list'}, [ + dataNode.displayBriefly && + html.tag('dt', + language.$('artTagPage.sidebar.otherTagsExempt', { + tags: + language.countArtTags(dataNode.numExemptArtTags, {unit: true}), + })), + + stitchArrays({ + isTargetTag: dataNode.artTagsAreTargetTag, + timesFeaturedTotal: dataNode.artTagsTimesFeaturedTotal, + dataSublist: dataNode.sublists, + + artTagLink: relationsNode.artTagLinks, + relationsSublist: relationsNode.sublists, + }).map(({ + isTargetTag, timesFeaturedTotal, dataSublist, + artTagLink, relationsSublist, + }) => [ + html.tag('dt', + {class: (dataSublist || isTargetTag) && 'current'}, + [ + artTagLink, + html.tag('span', {class: 'times-used'}, + language.countTimesFeatured(timesFeaturedTotal)), + ]), + + dataSublist && + html.tag('dd', + recursive(dataSublist, relationsSublist)), + ]), + ]); + + return recursive(data.root, relations.root); + }, +}; diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js index d55a628b..cfd6d03e 100644 --- a/src/content/dependencies/generateArtTagGalleryPage.js +++ b/src/content/dependencies/generateArtTagGalleryPage.js @@ -1,14 +1,19 @@ -import {sortAlbumsTracksChronologically} from '#sort'; -import {stitchArrays} from '#sugar'; +import {sortArtworksChronologically} from '#sort'; +import {empty, stitchArrays, unique} from '#sugar'; export default { contentDependencies: [ + 'generateAdditionalNamesBox', + 'generateArtTagGalleryPageFeaturedLine', + 'generateArtTagGalleryPageShowingLine', + 'generateArtTagNavLinks', 'generateCoverGrid', 'generatePageLayout', + 'generateQuickDescription', 'image', - 'linkAlbum', - 'linkArtTag', - 'linkTrack', + 'linkAnythingMan', + 'linkArtTagGallery', + 'linkExternal', ], extraDependencies: ['html', 'language', 'wikiData'], @@ -19,74 +24,107 @@ export default { }; }, - query(sprawl, tag) { - const things = tag.taggedInThings.slice(); + query(sprawl, artTag) { + const directArtworks = artTag.directlyFeaturedInArtworks; + const indirectArtworks = artTag.indirectlyFeaturedInArtworks; + const allArtworks = unique([...directArtworks, ...indirectArtworks]); - sortAlbumsTracksChronologically(things, { - getDate: thing => thing.coverArtDate ?? thing.date, - latestFirst: true, - }); + sortArtworksChronologically(allArtworks, {latestFirst: true}); - return {things}; + return {directArtworks, indirectArtworks, allArtworks}; }, - relations(relation, query, sprawl, tag) { + relations(relation, query, sprawl, artTag) { const relations = {}; relations.layout = relation('generatePageLayout'); - relations.artTagMainLink = - relation('linkArtTag', tag); + relations.navLinks = + relation('generateArtTagNavLinks', artTag); + + relations.additionalNamesBox = + relation('generateAdditionalNamesBox', artTag.additionalNames); + + relations.quickDescription = + relation('generateQuickDescription', artTag); + + relations.featuredLine = + relation('generateArtTagGalleryPageFeaturedLine'); + + relations.showingLine = + relation('generateArtTagGalleryPageShowingLine'); + + if (!empty(artTag.extraReadingURLs)) { + relations.extraReadingLinks = + artTag.extraReadingURLs + .map(url => relation('linkExternal', url)); + } + + if (!empty(artTag.directAncestorArtTags)) { + relations.ancestorLinks = + artTag.directAncestorArtTags + .map(artTag => relation('linkArtTagGallery', artTag)); + } + + if (!empty(artTag.directDescendantArtTags)) { + relations.descendantLinks = + artTag.directDescendantArtTags + .map(artTag => relation('linkArtTagGallery', artTag)); + } relations.coverGrid = relation('generateCoverGrid'); relations.links = - query.things.map(thing => - (thing.album - ? relation('linkTrack', thing) - : relation('linkAlbum', thing))); + query.allArtworks + .map(artwork => relation('linkAnythingMan', artwork.thing)); relations.images = - query.things.map(thing => - relation('image', thing.artTags)); + query.allArtworks + .map(artwork => relation('image', artwork)); return relations; }, - data(query, sprawl, tag) { + data(query, sprawl, artTag) { const data = {}; data.enableListings = sprawl.enableListings; - data.name = tag.name; - data.color = tag.color; + data.name = artTag.name; + data.color = artTag.color; - data.numArtworks = query.things.length; + data.numArtworksIndirectly = query.indirectArtworks.length; + data.numArtworksDirectly = query.directArtworks.length; + data.numArtworksTotal = query.allArtworks.length; data.names = - query.things.map(thing => thing.name); + query.allArtworks + .map(artwork => artwork.thing.name); - data.paths = - query.things.map(thing => - (thing.album - ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension] - : ['media.albumCover', thing.directory, thing.coverArtFileExtension])); + data.artworkArtists = + query.allArtworks + .map(artwork => artwork.artistContribs + .map(contrib => contrib.artist.name)); - data.dimensions = - query.things.map(thing => thing.coverArtDimensions); + data.artworkLabels = + query.allArtworks + .map(artwork => artwork.label) - data.coverArtists = - query.things.map(thing => - thing.coverArtistContribs - .map(({artist}) => artist.name)); + data.onlyFeaturedIndirectly = + query.allArtworks.map(artwork => + !query.directArtworks.includes(artwork)); + + data.hasMixedDirectIndirect = + data.onlyFeaturedIndirectly.includes(true) && + data.onlyFeaturedIndirectly.includes(false); return data; }, generate: (data, relations, {html, language}) => - language.encapsulate('tagPage', pageCapsule => + language.encapsulate('artTagGalleryPage', pageCapsule => relations.layout.slots({ title: language.$(pageCapsule, 'title', { @@ -94,59 +132,107 @@ export default { }), headingMode: 'static', - color: data.color, + additionalNames: relations.additionalNamesBox, + mainClasses: ['top-index'], mainContent: [ - html.tag('p', {class: 'quick-info'}, - language.$(pageCapsule, 'infoLine', { - coverArts: language.countCoverArts(data.numArtworks, { - unit: true, + relations.quickDescription.slots({ + extraReadingLinks: relations.extraReadingLinks ?? null, + }), + + data.numArtworksTotal === 0 && + html.tag('p', {class: 'quick-info'}, + language.encapsulate(pageCapsule, 'featuredLine.notFeatured', capsule => [ + language.$(capsule), + html.tag('br'), + language.$(capsule, 'callToAction'), + ])), + + data.numArtworksTotal >= 1 && + relations.featuredLine.clone() + .slots({ + showing: 'all', + count: data.numArtworksTotal, + }), + + data.hasMixedDirectIndirect && [ + relations.featuredLine.clone() + .slots({ + showing: 'direct', + count: data.numArtworksDirectly, }), - })), + + relations.featuredLine.clone() + .slots({ + showing: 'indirect', + count: data.numArtworksIndirectly, + }), + ], + + relations.ancestorLinks && + html.tag('p', {id: 'descends-from-line'}, + {class: 'quick-info'}, + language.$(pageCapsule, 'descendsFrom', { + tags: language.formatUnitList(relations.ancestorLinks), + })), + + relations.descendantLinks && + html.tag('p', {id: 'descendants-line'}, + {class: 'quick-info'}, + language.$(pageCapsule, 'descendants', { + tags: language.formatUnitList(relations.descendantLinks), + })), + + data.hasMixedDirectIndirect && [ + relations.showingLine.clone() + .slot('showing', 'all'), + + relations.showingLine.clone() + .slot('showing', 'direct'), + + relations.showingLine.clone() + .slot('showing', 'indirect'), + ], relations.coverGrid .slots({ links: relations.links, + images: relations.images, names: data.names, - images: - stitchArrays({ - image: relations.images, - path: data.paths, - dimensions: data.dimensions, - }).map(({image, path, dimensions}) => - image.slots({ - path, - dimensions, - })), + lazy: 12, + + classes: + data.onlyFeaturedIndirectly.map(onlyFeaturedIndirectly => + (onlyFeaturedIndirectly ? 'featured-indirectly' : '')), info: - data.coverArtists.map(names => - (names === null - ? null - : language.$('misc.coverGrid.details.coverArtists', { - artists: language.formatUnitList(names), - }))), + stitchArrays({ + artists: data.artworkArtists, + label: data.artworkLabels, + }).map(({artists, label}) => + language.encapsulate('misc.coverGrid.details.coverArtists', workingCapsule => { + const workingOptions = {}; + + workingOptions[language.onlyIfOptions] = ['artists']; + workingOptions.artists = + language.formatUnitList(artists); + + if (label) { + workingCapsule += '.customLabel'; + workingOptions.label = label; + } + + return language.$(workingCapsule, workingOptions); + })), }), ], navLinkStyle: 'hierarchical', - navLinks: [ - {auto: 'home'}, - - data.enableListings && - { - path: ['localized.listingIndex'], - title: language.$('listingIndex.title'), - }, - - { - html: - language.$(pageCapsule, 'nav.tag', { - tag: relations.artTagMainLink, - }), - }, - ], + navLinks: + html.resolve( + relations.navLinks + .slot('currentExtra', 'gallery')), })), }; diff --git a/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js new file mode 100644 index 00000000..b4620fa4 --- /dev/null +++ b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js @@ -0,0 +1,23 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + showing: { + validate: v => v.is('all', 'direct', 'indirect'), + }, + + count: {type: 'number'}, + }, + + generate: (slots, {html, language}) => + language.encapsulate('artTagGalleryPage', pageCapsule => + html.tag('p', {class: 'quick-info'}, + {id: `featured-${slots.showing}-line`}, + + language.$(pageCapsule, 'featuredLine', slots.showing, { + coverArts: + language.countArtworks(slots.count, { + unit: true, + }), + }))), +}; diff --git a/src/content/dependencies/generateArtTagGalleryPageShowingLine.js b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js new file mode 100644 index 00000000..6df4d0e5 --- /dev/null +++ b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js @@ -0,0 +1,22 @@ +export default { + extraDependencies: ['html', 'language'], + + slots: { + showing: { + validate: v => v.is('all', 'direct', 'indirect'), + }, + + count: {type: 'number'}, + }, + + generate: (slots, {html, language}) => + language.encapsulate('artTagGalleryPage', pageCapsule => + html.tag('p', {class: 'quick-info'}, + {id: `showing-${slots.showing}-line`}, + + language.$(pageCapsule, 'showingLine', { + showing: + html.tag('a', {href: '#'}, + language.$(pageCapsule, 'showingLine', slots.showing)), + }))), +}; diff --git a/src/content/dependencies/generateArtTagInfoPage.js b/src/content/dependencies/generateArtTagInfoPage.js new file mode 100644 index 00000000..9df51b77 --- /dev/null +++ b/src/content/dependencies/generateArtTagInfoPage.js @@ -0,0 +1,281 @@ +import {empty, stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: [ + 'generateAdditionalNamesBox', + 'generateArtTagNavLinks', + 'generateArtTagSidebar', + 'generateContentHeading', + 'generatePageLayout', + 'linkArtTagGallery', + 'linkArtTagInfo', + 'linkExternal', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => ({ + enableListings: wikiInfo.enableListings, + }), + + query(sprawl, artTag) { + const query = {}; + + query.directThings = + artTag.directlyFeaturedInArtworks; + + query.indirectThings = + artTag.indirectlyFeaturedInArtworks; + + query.allThings = + unique([...query.directThings, ...query.indirectThings]); + + query.allDescendantsHaveMoreDescendants = + artTag.directDescendantArtTags + .every(descendant => !empty(descendant.directDescendantArtTags)); + + return query; + }, + + relations: (relation, query, sprawl, artTag) => ({ + layout: + relation('generatePageLayout'), + + navLinks: + relation('generateArtTagNavLinks', artTag), + + sidebar: + relation('generateArtTagSidebar', artTag), + + additionalNamesBox: + relation('generateAdditionalNamesBox', artTag.additionalNames), + + contentHeading: + relation('generateContentHeading'), + + description: + relation('transformContent', artTag.description), + + galleryLink: + (empty(query.allThings) + ? null + : relation('linkArtTagGallery', artTag)), + + extraReadingLinks: + artTag.extraReadingURLs + .map(url => relation('linkExternal', url)), + + relatedArtTagLinks: + artTag.relatedArtTags + .map(({artTag}) => relation('linkArtTagInfo', artTag)), + + directAncestorLinks: + artTag.directAncestorArtTags + .map(artTag => relation('linkArtTagInfo', artTag)), + + directDescendantInfoLinks: + artTag.directDescendantArtTags + .map(artTag => relation('linkArtTagInfo', artTag)), + + directDescendantGalleryLinks: + artTag.directDescendantArtTags.map(artTag => + (query.allDescendantsHaveMoreDescendants + ? null + : relation('linkArtTagGallery', artTag))), + }), + + data: (query, sprawl, artTag) => ({ + enableListings: + sprawl.enableListings, + + name: + artTag.name, + + color: + artTag.color, + + numArtworksIndirectly: + query.indirectThings.length, + + numArtworksDirectly: + query.directThings.length, + + numArtworksTotal: + query.allThings.length, + + relatedArtTagAnnotations: + artTag.relatedArtTags + .map(({annotation}) => annotation), + + directDescendantTimesFeaturedTotal: + artTag.directDescendantArtTags.map(artTag => + unique([ + ...artTag.directlyFeaturedInArtworks, + ...artTag.indirectlyFeaturedInArtworks, + ]).length), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('artTagInfoPage', pageCapsule => + relations.layout.slots({ + title: + language.$(pageCapsule, 'title', { + tag: language.sanitize(data.name), + }), + + headingMode: 'sticky', + color: data.color, + + additionalNames: relations.additionalNamesBox, + + mainContent: [ + html.tag('p', + language.encapsulate(pageCapsule, 'featuredIn', capsule => + (data.numArtworksTotal === 0 + ? language.$(capsule, 'notFeatured') + + : data.numArtworksDirectly === 0 + ? language.$(capsule, 'indirectlyOnly', { + artworks: + language.countArtworks(data.numArtworksIndirectly, {unit: true}), + }) + + : data.numArtworksIndirectly === 0 + ? language.$(capsule, 'directlyOnly', { + artworks: + language.countArtworks(data.numArtworksDirectly, {unit: true}), + }) + + : language.$(capsule, 'directlyAndIndirectly', { + artworksDirectly: + language.countArtworks(data.numArtworksDirectly, {unit: true}), + + artworksIndirectly: + language.countArtworks(data.numArtworksIndirectly, {unit: false}), + + artworksTotal: + language.countArtworks(data.numArtworksTotal, {unit: false}), + })))), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$(pageCapsule, 'viewArtGallery', { + [language.onlyIfOptions]: ['link'], + + link: + relations.galleryLink + ?.slot('content', language.$(pageCapsule, 'viewArtGallery.link')), + })), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.encapsulate(pageCapsule, 'seeAlso', capsule => + language.$(capsule, { + [language.onlyIfOptions]: ['tags'], + + tags: + language.formatUnitList( + stitchArrays({ + artTagLink: relations.relatedArtTagLinks, + annotation: data.relatedArtTagAnnotations, + }).map(({artTagLink, annotation}) => + (html.isBlank(annotation) + ? artTagLink + : language.$(capsule, 'tagWithAnnotation', { + tag: artTagLink, + annotation, + })))), + }))), + + html.tag('blockquote', + {[html.onlyIfContent]: true}, + + relations.description + .slot('mode', 'multiline')), + + html.tag('p', + {[html.onlyIfContent]: true}, + + language.$(pageCapsule, 'readMoreOn', { + [language.onlyIfOptions]: ['links'], + + tag: language.sanitize(data.name), + links: language.formatDisjunctionList(relations.extraReadingLinks), + })), + + language.encapsulate(pageCapsule, 'descendsFromTags', listCapsule => + html.tags([ + relations.contentHeading.clone() + .slots({ + title: + language.$(listCapsule, { + tag: language.sanitize(data.name), + }), + }), + + html.tag('ul', + {[html.onlyIfContent]: true}, + + relations.directAncestorLinks + .map(link => + html.tag('li', + language.$(listCapsule, 'item', { + tag: link, + })))), + ])), + + language.encapsulate(pageCapsule, 'descendantTags', listCapsule => + html.tags([ + relations.contentHeading.clone() + .slots({ + title: + language.$(listCapsule, { + tag: language.sanitize(data.name), + }), + }), + + html.tag('ul', + {[html.onlyIfContent]: true}, + + stitchArrays({ + infoLink: relations.directDescendantInfoLinks, + galleryLink: relations.directDescendantGalleryLinks, + timesFeaturedTotal: data.directDescendantTimesFeaturedTotal, + }).map(({infoLink, galleryLink, timesFeaturedTotal}) => + html.tag('li', + language.encapsulate(listCapsule, 'item', itemCapsule => + language.encapsulate(itemCapsule, workingCapsule => { + const workingOptions = {}; + + workingOptions.tag = infoLink; + + if (!html.isBlank(galleryLink ?? html.blank())) { + workingCapsule += '.withGallery'; + workingOptions.gallery = + galleryLink.slot('content', + language.$(itemCapsule, 'withGallery.gallery')); + } + + if (timesFeaturedTotal >= 1) { + workingCapsule += `.withTimesUsed`; + workingOptions.timesUsed = + language.countTimesFeatured(timesFeaturedTotal, { + unit: true, + }); + } + + return language.$(workingCapsule, workingOptions); + }))))), + ])), + ], + + navLinkStyle: 'hierarchical', + navLinks: relations.navLinks.content, + + leftSidebar: + relations.sidebar, + })), +}; diff --git a/src/content/dependencies/generateArtTagNavLinks.js b/src/content/dependencies/generateArtTagNavLinks.js new file mode 100644 index 00000000..9061a09f --- /dev/null +++ b/src/content/dependencies/generateArtTagNavLinks.js @@ -0,0 +1,81 @@ +export default { + contentDependencies: [ + 'generateInterpageDotSwitcher', + 'linkArtTagInfo', + 'linkArtTagGallery', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => + ({enableListings: wikiInfo.enableListings}), + + relations: (relation, sprawl, tag) => ({ + switcher: + relation('generateInterpageDotSwitcher'), + + mainLink: + relation('linkArtTagInfo', tag), + + infoLink: + relation('linkArtTagInfo', tag), + + galleryLink: + relation('linkArtTagGallery', tag), + }), + + data: (sprawl) => + ({enableListings: sprawl.enableListings}), + + slots: { + currentExtra: { + validate: v => v.is('gallery'), + }, + }, + + generate(data, relations, slots, {language}) { + if (!data.enableListings) { + return [ + {auto: 'home'}, + {auto: 'current'}, + ]; + } + + const infoLink = + relations.infoLink.slots({ + attributes: {class: slots.currentExtra === null && 'current'}, + content: language.$('misc.nav.info'), + }); + + const galleryLink = + relations.galleryLink.slots({ + attributes: {class: slots.currentExtra === 'gallery' && 'current'}, + content: language.$('misc.nav.gallery'), + }); + + return [ + {auto: 'home'}, + + data.enableListings && + { + path: ['localized.listingIndex'], + title: language.$('listingIndex.title'), + }, + + { + html: + language.$('artTagPage.nav.tag', { + tag: relations.mainLink, + }), + + accent: + relations.switcher.slots({ + links: [ + infoLink, + galleryLink, + ], + }), + }, + ].filter(Boolean); + }, +}; diff --git a/src/content/dependencies/generateArtTagSidebar.js b/src/content/dependencies/generateArtTagSidebar.js new file mode 100644 index 00000000..9e2f813c --- /dev/null +++ b/src/content/dependencies/generateArtTagSidebar.js @@ -0,0 +1,124 @@ +import {collectTreeLeaves, empty, stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: [ + 'generatePageSidebar', + 'generatePageSidebarBox', + 'generateArtTagAncestorDescendantMapList', + 'linkArtTagDynamically', + ], + + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({artTagData}) => + ({artTagData}), + + query(sprawl, artTag) { + const baobab = artTag.ancestorArtTagBaobabTree; + const uniqueLeaves = new Set(collectTreeLeaves(baobab)); + + // Just match the order in tag data. + const furthestAncestorArtTags = + sprawl.artTagData + .filter(artTag => uniqueLeaves.has(artTag)); + + return {furthestAncestorArtTags}; + }, + + relations: (relation, query, sprawl, artTag) => ({ + sidebar: + relation('generatePageSidebar'), + + sidebarBox: + relation('generatePageSidebarBox'), + + artTagLink: + relation('linkArtTagDynamically', artTag), + + directDescendantArtTagLinks: + artTag.directDescendantArtTags + .map(descendantArtTag => + relation('linkArtTagDynamically', descendantArtTag)), + + furthestAncestorArtTagMapLists: + query.furthestAncestorArtTags + .map(ancestorArtTag => + relation('generateArtTagAncestorDescendantMapList', + ancestorArtTag, + artTag)), + }), + + data: (query, sprawl, artTag) => ({ + name: artTag.name, + + directDescendantTimesFeaturedTotal: + artTag.directDescendantArtTags.map(artTag => + unique([ + ...artTag.directlyFeaturedInArtworks, + ...artTag.indirectlyFeaturedInArtworks, + ]).length), + + furthestAncestorArtTagNames: + query.furthestAncestorArtTags + .map(ancestorArtTag => ancestorArtTag.name), + }), + + generate(data, relations, {html, language}) { + if ( + empty(relations.directDescendantArtTagLinks) && + empty(relations.furthestAncestorArtTagMapLists) + ) { + return relations.sidebar; + } + + return relations.sidebar.slots({ + boxes: [ + relations.sidebarBox.slots({ + content: [ + html.tag('h1', + relations.artTagLink), + + !empty(relations.directDescendantArtTagLinks) && + html.tag('details', {class: 'current', open: true}, [ + html.tag('summary', + html.tag('span', + html.tag('b', + language.sanitize(data.name)))), + + html.tag('ul', + stitchArrays({ + link: relations.directDescendantArtTagLinks, + timesFeaturedTotal: data.directDescendantTimesFeaturedTotal, + }).map(({link, timesFeaturedTotal}) => + html.tag('li', [ + link, + html.tag('span', {class: 'times-used'}, + language.countTimesFeatured(timesFeaturedTotal)), + ]))), + ]), + + stitchArrays({ + name: data.furthestAncestorArtTagNames, + list: relations.furthestAncestorArtTagMapLists, + }).map(({name, list}) => + html.tag('details', + { + class: 'has-tree-list', + open: + empty(relations.directDescendantArtTagLinks) && + relations.furthestAncestorArtTagMapLists.length === 1, + }, + [ + html.tag('summary', + html.tag('span', + html.tag('b', + language.sanitize(name)))), + + list, + ])), + ], + }), + ], + }); + }, +}; diff --git a/src/content/dependencies/generateArtistArtworkColumn.js b/src/content/dependencies/generateArtistArtworkColumn.js new file mode 100644 index 00000000..a4135489 --- /dev/null +++ b/src/content/dependencies/generateArtistArtworkColumn.js @@ -0,0 +1,13 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations: (relation, artist) => ({ + coverArtwork: + (artist.hasAvatar + ? relation('generateCoverArtwork', artist.avatarArtwork) + : null), + }), + + generate: (relations) => + relations.coverArtwork, +}; diff --git a/src/content/dependencies/generateArtistCredit.js b/src/content/dependencies/generateArtistCredit.js index 72d55854..bab32f7d 100644 --- a/src/content/dependencies/generateArtistCredit.js +++ b/src/content/dependencies/generateArtistCredit.js @@ -36,11 +36,18 @@ export default { // Note that the normal contributions will implicitly *always* // "differ from context" if no context contributions are given, // as in release info lines. - query.normalContributionsDifferFromContext = + + query.normalContributionArtistsDifferFromContext = !compareArrays( query.normalContributions.map(({artist}) => artist), contextNormalContributions.map(({artist}) => artist), - {checkOrder: false}); + {checkOrder: true}); + + query.normalContributionAnnotationsDifferFromContext = + !compareArrays( + query.normalContributions.map(({annotation}) => annotation), + contextNormalContributions.map(({annotation}) => annotation), + {checkOrder: true}); return query; }, @@ -60,8 +67,11 @@ export default { }), data: (query, _creditContributions, _contextContributions) => ({ - normalContributionsDifferFromContext: - query.normalContributionsDifferFromContext, + normalContributionArtistsDifferFromContext: + query.normalContributionArtistsDifferFromContext, + + normalContributionAnnotationsDifferFromContext: + query.normalContributionAnnotationsDifferFromContext, hasWikiEdits: !empty(query.wikiEditContributions), @@ -80,6 +90,8 @@ export default { // It won't be used if contextContributions isn't provided. featuringStringKey: {type: 'string'}, + additionalStringOptions: {validate: v => v.isObject}, + showAnnotation: {type: 'boolean', default: false}, showExternalLinks: {type: 'boolean', default: false}, showChronology: {type: 'boolean', default: false}, @@ -146,23 +158,37 @@ export default { ...relations.featuringContributionLinks, ]); + const effectivelyDiffers = + (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) || + (data.normalContributionArtistsDifferFromContext); + if (empty(relations.featuringContributionLinks)) { - if (data.normalContributionsDifferFromContext) { - return language.$(slots.normalStringKey, {artists: artistsList}); + if (effectivelyDiffers) { + return language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: artistsList, + }); } else { return html.blank(); } } - if (data.normalContributionsDifferFromContext && slots.normalFeaturingStringKey) { + if (effectivelyDiffers && slots.normalFeaturingStringKey) { return language.$(slots.normalFeaturingStringKey, { + ...slots.additionalStringOptions, artists: artistsList, featuring: featuringList, }); } else if (slots.featuringStringKey) { - return language.$(slots.featuringStringKey, {artists: featuringList}); + return language.$(slots.featuringStringKey, { + ...slots.additionalStringOptions, + artists: featuringList, + }); } else { - return language.$(slots.normalStringKey, {artists: everyoneList}); + return language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: everyoneList, + }); } }, }; diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js index 7a76188a..6a24275e 100644 --- a/src/content/dependencies/generateArtistGalleryPage.js +++ b/src/content/dependencies/generateArtistGalleryPage.js @@ -1,5 +1,4 @@ -import {sortAlbumsTracksChronologically} from '#sort'; -import {stitchArrays} from '#sugar'; +import {sortArtworksChronologically} from '#sort'; export default { contentDependencies: [ @@ -7,83 +6,59 @@ export default { 'generateCoverGrid', 'generatePageLayout', 'image', - 'linkAlbum', - 'linkTrack', + 'linkAnythingMan', ], extraDependencies: ['html', 'language'], - query(artist) { - const things = - ([ - artist.albumCoverArtistContributions, - artist.trackCoverArtistContributions, - ]).flat() - .filter(({annotation}) => !annotation?.startsWith(`edits for wiki`)) - .map(({thing}) => thing); - - sortAlbumsTracksChronologically(things, { - latestFirst: true, - getDate: thing => thing.coverArtDate ?? thing.date, - }); - - return {things}; - }, - - relations(relation, query, artist) { - const relations = {}; - - relations.layout = - relation('generatePageLayout'); - - relations.artistNavLinks = - relation('generateArtistNavLinks', artist); - - relations.coverGrid = - relation('generateCoverGrid'); - - relations.links = - query.things.map(thing => - (thing.album - ? relation('linkTrack', thing) - : relation('linkAlbum', thing))); - - relations.images = - query.things.map(thing => - relation('image', thing.artTags)); - - return relations; - }, - - data(query, artist) { - const data = {}; - - data.name = artist.name; - - data.numArtworks = query.things.length; - - data.names = - query.things.map(thing => thing.name); - - data.paths = - query.things.map(thing => - (thing.album - ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension] - : ['media.albumCover', thing.directory, thing.coverArtFileExtension])); - - data.dimensions = - query.things.map(thing => thing.coverArtDimensions); - - data.otherCoverArtists = - query.things.map(thing => - (thing.coverArtistContribs.length > 1 - ? thing.coverArtistContribs - .filter(({artist: otherArtist}) => otherArtist !== artist) - .map(({artist: otherArtist}) => otherArtist.name) - : null)); - - return data; - }, + query: (artist) => ({ + artworks: + sortArtworksChronologically( + ([ + artist.albumCoverArtistContributions, + artist.trackCoverArtistContributions, + ]).flat() + .filter(contrib => !contrib.annotation?.startsWith(`edits for wiki`)) + .map(contrib => contrib.thing), + {latestFirst: true}), + }), + + relations: (relation, query, artist) => ({ + layout: + relation('generatePageLayout'), + + artistNavLinks: + relation('generateArtistNavLinks', artist), + + coverGrid: + relation('generateCoverGrid'), + + links: + query.artworks + .map(artwork => relation('linkAnythingMan', artwork.thing)), + + images: + query.artworks + .map(artwork => relation('image', artwork)), + }), + + data: (query, artist) => ({ + name: + artist.name, + + numArtworks: + query.artworks.length, + + names: + query.artworks + .map(artwork => artwork.thing.name), + + otherCoverArtists: + query.artworks + .map(artwork => artwork.artistContribs + .filter(contrib => contrib.artist !== artist) + .map(contrib => contrib.artist.name)), + }), generate: (data, relations, {html, language}) => language.encapsulate('artistGalleryPage', pageCapsule => @@ -100,7 +75,7 @@ export default { html.tag('p', {class: 'quick-info'}, language.$(pageCapsule, 'infoLine', { coverArts: - language.countCoverArts(data.numArtworks, { + language.countArtworks(data.numArtworks, { unit: true, }), })), @@ -108,27 +83,16 @@ export default { relations.coverGrid .slots({ links: relations.links, + images: relations.images, names: data.names, - images: - stitchArrays({ - image: relations.images, - path: data.paths, - dimensions: data.dimensions, - }).map(({image, path, dimensions}) => - image.slots({ - path, - dimensions, - })), - - // TODO: Can this be [language.onlyIfOptions]? info: data.otherCoverArtists.map(names => - (names === null - ? null - : language.$('misc.coverGrid.details.otherCoverArtists', { - artists: language.formatUnitList(names), - }))), + language.$('misc.coverGrid.details.otherCoverArtists', { + [language.onlyIfOptions]: ['artists'], + + artists: language.formatUnitList(names), + })), }), ], diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js index f84d00de..e1fa7a0b 100644 --- a/src/content/dependencies/generateArtistGroupContributionsInfo.js +++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js @@ -1,83 +1,90 @@ -import {empty, filterProperties, stitchArrays, unique} from '#sugar'; +import {accumulateSum, empty, stitchArrays, withEntries} from '#sugar'; export default { contentDependencies: ['linkGroup'], extraDependencies: ['html', 'language', 'wikiData'], - sprawl({groupCategoryData}) { - return { - groupOrder: groupCategoryData.flatMap(category => category.groups), - } - }, + sprawl: ({groupCategoryData}) => ({ + groupOrder: + groupCategoryData.flatMap(category => category.groups), + }), - query(sprawl, tracksAndAlbums) { - const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album); - const filteredTracks = tracksAndAlbums.filter(thing => thing.album); + query(sprawl, contributions) { + const allGroupsUnordered = + new Set(contributions.flatMap(contrib => contrib.groups)); - const allAlbums = unique([ - ...filteredAlbums, - ...filteredTracks.map(track => track.album), - ]); + const allGroupsOrdered = + sprawl.groupOrder.filter(group => allGroupsUnordered.has(group)); - const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups)); - const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group)); + const groupToThingsCountedForContributions = + new Map(allGroupsOrdered.map(group => [group, new Set])); - const mapTemplate = allGroupsOrdered.map(group => [group, 0]); - const groupToCountMap = new Map(mapTemplate); - const groupToDurationMap = new Map(mapTemplate); - const groupToDurationCountMap = new Map(mapTemplate); + const groupToThingsCountedForDuration = + new Map(allGroupsOrdered.map(group => [group, new Set])); - for (const album of filteredAlbums) { - for (const group of album.groups) { - groupToCountMap.set(group, groupToCountMap.get(group) + 1); - } - } + for (const contrib of contributions) { + for (const group of contrib.groups) { + if (contrib.countInContributionTotals) { + groupToThingsCountedForContributions.get(group).add(contrib.thing); + } - for (const track of filteredTracks) { - for (const group of track.album.groups) { - groupToCountMap.set(group, groupToCountMap.get(group) + 1); - if (track.duration && track.originalReleaseTrack === null) { - groupToDurationMap.set(group, groupToDurationMap.get(group) + track.duration); - groupToDurationCountMap.set(group, groupToDurationCountMap.get(group) + 1); + if (contrib.countInDurationTotals) { + groupToThingsCountedForDuration.get(group).add(contrib.thing); } } } + const groupToTotalContributions = + withEntries( + groupToThingsCountedForContributions, + entries => entries.map( + ([group, things]) => + ([group, things.size]))); + + const groupToTotalDuration = + withEntries( + groupToThingsCountedForDuration, + entries => entries.map( + ([group, things]) => + ([group, accumulateSum(things, thing => thing.duration)]))) + const groupsSortedByCount = allGroupsOrdered - .slice() - .sort((a, b) => groupToCountMap.get(b) - groupToCountMap.get(a)); + .filter(group => groupToTotalContributions.get(group) > 0) + .sort((a, b) => + (groupToTotalContributions.get(b) + - groupToTotalContributions.get(a))); - // The filter here ensures all displayed groups have at least some duration - // when sorting by duration. const groupsSortedByDuration = allGroupsOrdered - .filter(group => groupToDurationMap.get(group) > 0) - .sort((a, b) => groupToDurationMap.get(b) - groupToDurationMap.get(a)); + .filter(group => groupToTotalDuration.get(group) > 0) + .sort((a, b) => + (groupToTotalDuration.get(b) + - groupToTotalDuration.get(a))); const groupCountsSortedByCount = groupsSortedByCount - .map(group => groupToCountMap.get(group)); + .map(group => groupToTotalContributions.get(group)); const groupDurationsSortedByCount = groupsSortedByCount - .map(group => groupToDurationMap.get(group)); + .map(group => groupToTotalDuration.get(group)); const groupDurationsApproximateSortedByCount = groupsSortedByCount - .map(group => groupToDurationCountMap.get(group) > 1); + .map(group => groupToThingsCountedForDuration.get(group).size > 1); const groupCountsSortedByDuration = groupsSortedByDuration - .map(group => groupToCountMap.get(group)); + .map(group => groupToTotalContributions.get(group)); const groupDurationsSortedByDuration = groupsSortedByDuration - .map(group => groupToDurationMap.get(group)); + .map(group => groupToTotalDuration.get(group)); const groupDurationsApproximateSortedByDuration = groupsSortedByDuration - .map(group => groupToDurationCountMap.get(group) > 1); + .map(group => groupToThingsCountedForDuration.get(group).size > 1); return { groupsSortedByCount, @@ -93,29 +100,35 @@ export default { }; }, - relations(relation, query) { - return { - groupLinksSortedByCount: - query.groupsSortedByCount - .map(group => relation('linkGroup', group)), + relations: (relation, query) => ({ + groupLinksSortedByCount: + query.groupsSortedByCount + .map(group => relation('linkGroup', group)), - groupLinksSortedByDuration: - query.groupsSortedByDuration - .map(group => relation('linkGroup', group)), - }; - }, + groupLinksSortedByDuration: + query.groupsSortedByDuration + .map(group => relation('linkGroup', group)), + }), - data(query) { - return filterProperties(query, [ - 'groupCountsSortedByCount', - 'groupDurationsSortedByCount', - 'groupDurationsApproximateSortedByCount', + data: (query) => ({ + groupCountsSortedByCount: + query.groupCountsSortedByCount, - 'groupCountsSortedByDuration', - 'groupDurationsSortedByDuration', - 'groupDurationsApproximateSortedByDuration', - ]); - }, + groupDurationsSortedByCount: + query.groupDurationsSortedByCount, + + groupDurationsApproximateSortedByCount: + query.groupDurationsApproximateSortedByCount, + + groupCountsSortedByDuration: + query.groupCountsSortedByDuration, + + groupDurationsSortedByDuration: + query.groupDurationsSortedByDuration, + + groupDurationsApproximateSortedByDuration: + query.groupDurationsApproximateSortedByDuration, + }), slots: { title: { diff --git a/src/content/dependencies/generateArtistInfoPage.js b/src/content/dependencies/generateArtistInfoPage.js index 81fede4c..1f738de4 100644 --- a/src/content/dependencies/generateArtistInfoPage.js +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -2,6 +2,7 @@ import {empty, stitchArrays, unique} from '#sugar'; export default { contentDependencies: [ + 'generateArtistArtworkColumn', 'generateArtistGroupContributionsInfo', 'generateArtistInfoPageArtworksChunkedList', 'generateArtistInfoPageCommentaryChunkedList', @@ -9,7 +10,6 @@ export default { 'generateArtistInfoPageTracksChunkedList', 'generateArtistNavLinks', 'generateContentHeading', - 'generateCoverArtwork', 'generatePageLayout', 'linkArtistGallery', 'linkExternal', @@ -20,29 +20,17 @@ export default { extraDependencies: ['html', 'language'], query: (artist) => ({ - // Even if an artist has served as both "artist" (compositional) and - // "contributor" (instruments, production, etc) on the same track, that - // track only counts as one unique contribution in the list. - allTracks: - unique( - ([ - artist.trackArtistContributions, - artist.trackContributorContributions, - ]).flat() - .map(({thing}) => thing)), - - // Artworks are different, though. We intentionally duplicate album data - // objects when the artist has contributed some combination of cover art, - // wallpaper, and banner - these each count as a unique contribution. - allArtworks: - ([ - artist.albumCoverArtistContributions, - artist.albumWallpaperArtistContributions, - artist.albumBannerArtistContributions, - artist.trackCoverArtistContributions, - ]).flat() - .filter(({annotation}) => !annotation?.startsWith('edits for wiki')) - .map(({thing}) => thing), + trackContributions: [ + ...artist.trackArtistContributions, + ...artist.trackContributorContributions, + ], + + artworkContributions: [ + ...artist.albumCoverArtistContributions, + ...artist.albumWallpaperArtistContributions, + ...artist.albumBannerArtistContributions, + ...artist.trackCoverArtistContributions, + ], // Banners and wallpapers don't show up in the artist gallery page, only // cover art. @@ -68,10 +56,8 @@ export default { artistNavLinks: relation('generateArtistNavLinks', artist), - cover: - (artist.hasAvatar - ? relation('generateCoverArtwork', [], []) - : null), + artworkColumn: + relation('generateArtistArtworkColumn', artist), contentHeading: relation('generateContentHeading'), @@ -95,7 +81,7 @@ export default { relation('generateArtistInfoPageTracksChunkedList', artist), tracksGroupInfo: - relation('generateArtistGroupContributionsInfo', query.allTracks), + relation('generateArtistGroupContributionsInfo', query.trackContributions), artworksChunkedList: relation('generateArtistInfoPageArtworksChunkedList', artist, false), @@ -104,7 +90,7 @@ export default { relation('generateArtistInfoPageArtworksChunkedList', artist, true), artworksGroupInfo: - relation('generateArtistGroupContributionsInfo', query.allArtworks), + relation('generateArtistGroupContributionsInfo', query.artworkContributions), artistGalleryLink: (query.hasGallery @@ -115,27 +101,26 @@ export default { relation('generateArtistInfoPageFlashesChunkedList', artist), commentaryChunkedList: - relation('generateArtistInfoPageCommentaryChunkedList', artist), + relation('generateArtistInfoPageCommentaryChunkedList', artist, false), + + wikiEditorCommentaryChunkedList: + relation('generateArtistInfoPageCommentaryChunkedList', artist, true), }), data: (query, artist) => ({ name: artist.name, - directory: - artist.directory, - - avatarFileExtension: - (artist.hasAvatar - ? artist.avatarFileExtension - : null), - closeGroupAnnotations: query.generalLinkedGroups .map(({annotation}) => annotation), totalTrackCount: - query.allTracks.length, + unique( + query.trackContributions + .filter(contrib => contrib.countInContributionTotals) + .map(contrib => contrib.thing)) + .length, totalDuration: artist.totalDuration, @@ -147,16 +132,8 @@ export default { title: data.name, headingMode: 'sticky', - cover: - (relations.cover - ? relations.cover.slots({ - path: [ - 'media.artistAvatar', - data.directory, - data.avatarFileExtension, - ], - }) - : null), + artworkColumnContent: + relations.artworkColumn, mainContent: [ html.tags([ @@ -262,7 +239,8 @@ export default { {href: '#flashes'}, language.$(pageCapsule, 'flashList.title')), - !html.isBlank(relations.commentaryChunkedList) && + (!html.isBlank(relations.commentaryChunkedList) || + !html.isBlank(relations.wikiEditorCommentaryChunkedList)) && html.tag('a', {href: '#commentary'}, language.$(pageCapsule, 'commentaryList.title')), @@ -349,12 +327,17 @@ export default { }), html.tags([ - html.tag('p', - {[html.onlyIfSiblings]: true}, + language.encapsulate(pageCapsule, 'wikiEditArtworks', capsule => + relations.contentHeading.clone() + .slots({ + tag: 'p', - language.$(pageCapsule, 'wikiEditArtworks', { - artist: data.name, - })), + title: + language.$(capsule, {artist: data.name}), + + stickyTitle: + language.$(capsule, 'sticky'), + })), relations.editsForWikiArtworksChunkedList, ]), @@ -380,6 +363,22 @@ export default { }), relations.commentaryChunkedList, + + html.tags([ + language.encapsulate(pageCapsule, 'wikiEditorCommentary', capsule => + relations.contentHeading.clone() + .slots({ + tag: 'p', + + title: + language.$(capsule, {artist: data.name}), + + stickyTitle: + language.$(capsule, 'sticky'), + })), + + relations.wikiEditorCommentaryChunkedList, + ]), ]), ], diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js index 089cfb8d..cb436b0f 100644 --- a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js @@ -1,8 +1,11 @@ +import {empty} from '#sugar'; + export default { contentDependencies: [ 'generateArtistInfoPageChunkItem', 'generateArtistInfoPageOtherArtistLinks', 'linkTrack', + 'transformContent', ], extraDependencies: ['html', 'language'], @@ -24,11 +27,14 @@ export default { trackLink: (query.kind === 'track-cover' - ? relation('linkTrack', contrib.thing) + ? relation('linkTrack', contrib.thing.thing) : null), otherArtistLinks: relation('generateArtistInfoPageOtherArtistLinks', [contrib]), + + originDetails: + relation('transformContent', contrib.thing.originDetails), }), data: (query, contrib) => ({ @@ -37,6 +43,9 @@ export default { annotation: contrib.annotation, + + label: + contrib.thing.label, }), slots: { @@ -51,9 +60,33 @@ export default { otherArtistLinks: relations.otherArtistLinks, annotation: - (slots.filterEditsForWiki - ? data.annotation?.replace(/^edits for wiki(: )?/, '') - : data.annotation), + language.encapsulate('artistPage.creditList.entry.artwork.accent', workingCapsule => { + const workingOptions = {}; + + const artworkLabel = data.label; + + if (artworkLabel) { + workingCapsule += '.withLabel'; + workingOptions.label = + language.typicallyLowerCase(artworkLabel); + } + + const contribAnnotation = + (slots.filterEditsForWiki + ? data.annotation?.replace(/^edits for wiki(: )?/, '') + : data.annotation); + + if (contribAnnotation) { + workingCapsule += '.withAnnotation'; + workingOptions.annotation = contribAnnotation; + } + + if (empty(Object.keys(workingOptions))) { + return html.blank(); + } + + return language.$(workingCapsule, workingOptions); + }), content: language.encapsulate('artistPage.creditList.entry', capsule => @@ -68,5 +101,11 @@ export default { : data.kind === 'banner' ? language.$(capsule, 'bannerArt') : language.$(capsule, 'coverArt')))))), + + originDetails: + relations.originDetails.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + }), }), }; diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js index 8b024147..75a4aa5a 100644 --- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js @@ -27,20 +27,21 @@ export default { sortContributionsChronologically( filteredContributions, - sortAlbumsTracksChronologically); + sortAlbumsTracksChronologically, + {getThing: contrib => contrib.thing.thing}); query.contribs = chunkByConditions(filteredContributions, [ ({date: date1}, {date: date2}) => +date1 !== +date2, - ({thing: thing1}, {thing: thing2}) => + ({thing: {thing: thing1}}, {thing: {thing: thing2}}) => (thing1.album ?? thing1) !== (thing2.album ?? thing2), ]); query.albums = query.contribs - .map(contribs => contribs[0].thing) + .map(contribs => contribs[0].thing.thing) .map(thing => thing.album ?? thing); return query; diff --git a/src/content/dependencies/generateArtistInfoPageChunk.js b/src/content/dependencies/generateArtistInfoPageChunk.js index c16d50f3..fce68a7d 100644 --- a/src/content/dependencies/generateArtistInfoPageChunk.js +++ b/src/content/dependencies/generateArtistInfoPageChunk.js @@ -8,6 +8,8 @@ export default { validate: v => v.is('flash', 'album'), }, + id: {type: 'string'}, + albumLink: { type: 'html', mutable: false, @@ -99,9 +101,13 @@ export default { } return html.tags([ - html.tag('dt', accentedLink), + html.tag('dt', + slots.id && {id: slots.id}, + accentedLink), + html.tag('dd', html.tag('ul', + {class: 'offset-tooltips'}, slots.items)), ]); }, diff --git a/src/content/dependencies/generateArtistInfoPageChunkItem.js b/src/content/dependencies/generateArtistInfoPageChunkItem.js index 9d406c67..c80aeab7 100644 --- a/src/content/dependencies/generateArtistInfoPageChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js @@ -1,8 +1,14 @@ import {empty} from '#sugar'; export default { + contentDependencies: ['generateTextWithTooltip'], extraDependencies: ['html', 'language'], + relations: (relation) => ({ + textWithTooltip: + relation('generateTextWithTooltip'), + }), + slots: { content: { type: 'html', @@ -18,41 +24,80 @@ export default { validate: v => v.strictArrayOf(v.isHTML), }, - rerelease: {type: 'boolean'}, + rereleaseTooltip: { + type: 'html', + mutable: false, + }, + + firstReleaseTooltip: { + type: 'html', + mutable: false, + }, + + originDetails: { + type: 'html', + mutable: false, + }, }, - generate: (slots, {html, language}) => + generate: (relations, slots, {html, language}) => language.encapsulate('artistPage.creditList.entry', entryCapsule => html.tag('li', slots.rerelease && {class: 'rerelease'}, - language.encapsulate(entryCapsule, workingCapsule => { - const workingOptions = {entry: slots.content}; - - if (slots.rerelease) { - workingCapsule += '.rerelease'; - return language.$(workingCapsule, workingOptions); - } - - let anyAccent = false; - - if (!empty(slots.otherArtistLinks)) { - anyAccent = true; - workingCapsule += '.withArtists'; - workingOptions.artists = - language.formatConjunctionList(slots.otherArtistLinks); - } - - if (!html.isBlank(slots.annotation)) { - anyAccent = true; - workingCapsule += '.withAnnotation'; - workingOptions.annotation = slots.annotation; - } - - if (anyAccent) { - return language.$(workingCapsule, workingOptions); - } else { - return slots.content; - } - }))), + html.tags([ + language.encapsulate(entryCapsule, workingCapsule => { + const workingOptions = {entry: slots.content}; + + if (!html.isBlank(slots.rereleaseTooltip)) { + workingCapsule += '.rerelease'; + workingOptions.rerelease = + relations.textWithTooltip.slots({ + attributes: {class: 'rerelease'}, + text: language.$(entryCapsule, 'rerelease.term'), + tooltip: slots.rereleaseTooltip, + }); + + return language.$(workingCapsule, workingOptions); + } + + if (!html.isBlank(slots.firstReleaseTooltip)) { + workingCapsule += '.firstRelease'; + workingOptions.firstRelease = + relations.textWithTooltip.slots({ + attributes: {class: 'first-release'}, + text: language.$(entryCapsule, 'firstRelease.term'), + tooltip: slots.firstReleaseTooltip, + }); + + return language.$(workingCapsule, workingOptions); + } + + let anyAccent = false; + + if (!empty(slots.otherArtistLinks)) { + anyAccent = true; + workingCapsule += '.withArtists'; + workingOptions.artists = + language.formatConjunctionList(slots.otherArtistLinks); + } + + if (!html.isBlank(slots.annotation)) { + anyAccent = true; + workingCapsule += '.withAnnotation'; + workingOptions.annotation = slots.annotation; + } + + if (anyAccent) { + return language.$(workingCapsule, workingOptions); + } else { + return slots.content; + } + }), + + html.tag('span', {class: 'origin-details'}, + {[html.onlyIfContent]: true}, + + slots.originDetails), + ]))), }; diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js index 0f238d13..88c5ed54 100644 --- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js @@ -19,7 +19,7 @@ export default { extraDependencies: ['html', 'language'], - query(artist) { + query(artist, filterWikiEditorCommentary) { const processEntry = ({ thing, entry, @@ -43,6 +43,7 @@ export default { flash, annotation: entry.annotation, + annotationParts: entry.annotationParts, }, }); @@ -87,6 +88,12 @@ export default { .flatMap(thing => thing.commentary .filter(entry => entry.artists.includes(artist)) + + .filter(entry => + (filterWikiEditorCommentary + ? entry.isWikiEditorCommentary + : !entry.isWikiEditorCommentary)) + .map(entry => processEntry({thing, entry}))); const processAlbumEntries = ({albums}) => @@ -146,7 +153,7 @@ export default { return {chunks}; }, - relations: (relation, query) => ({ + relations: (relation, query, _artist, filterWikiEditorCommentary) => ({ chunks: query.chunks .map(() => relation('generateArtistInfoPageChunk')), @@ -178,13 +185,16 @@ export default { itemAnnotations: query.chunks .map(({chunk}) => chunk - .map(({annotation}) => - (annotation - ? relation('transformContent', annotation) - : null))), + .map(entry => + relation('transformContent', + (filterWikiEditorCommentary + ? entry.annotationParts + .filter(part => part !== 'wiki editor') + .join(', ') + : entry.annotation)))), }), - data: (query) => ({ + data: (query, _artist, _filterWikiEditorCommentary) => ({ chunkTypes: query.chunks .map(({chunkType}) => chunkType), @@ -232,12 +242,10 @@ export default { }).map(({item, link, annotation, type}) => item.slots({ annotation: - (annotation - ? annotation.slots({ - mode: 'inline', - absorbPunctuationFollowingExternalLinks: false, - }) - : null), + annotation.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + }), content: (type === 'album' @@ -259,7 +267,10 @@ export default { item.slots({ annotation: (annotation - ? annotation.slot('mode', 'inline') + ? annotation.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + }) : null), content: diff --git a/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js new file mode 100644 index 00000000..f86dead7 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js @@ -0,0 +1,75 @@ +import {sortChronologically} from '#sort'; +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateTooltip', + 'linkOtherReleaseOnArtistInfoPage', + ], + + extraDependencies: ['html', 'language'], + + query: (track) => ({ + rereleases: + sortChronologically(track.allReleases).slice(1), + }), + + relations: (relation, query, track, artist) => ({ + tooltip: + relation('generateTooltip'), + + firstReleaseColorStyle: + relation('generateColorStyleAttribute', track.color), + + rereleaseLinks: + query.rereleases + .map(rerelease => + relation('linkOtherReleaseOnArtistInfoPage', rerelease, artist)), + }), + + data: (query, track) => ({ + firstReleaseDate: + track.dateFirstReleased ?? + track.album.date, + + rereleaseDates: + query.rereleases + .map(rerelease => + rerelease.dateFirstReleased ?? + rerelease.album.date), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('artistPage.creditList.entry.firstRelease', capsule => + relations.tooltip.slots({ + attributes: [ + {class: 'first-release-tooltip'}, + relations.firstReleaseColorStyle, + ], + + contentAttributes: [ + {[html.joinChildren]: html.tag('hr', {class: 'cute'})}, + ], + + content: + stitchArrays({ + rereleaseLink: relations.rereleaseLinks, + rereleaseDate: data.rereleaseDates, + }).map(({rereleaseLink, rereleaseDate}) => + html.tags([ + language.$(capsule, 'rerelease', { + album: + html.metatag('blockwrap', rereleaseLink), + }), + + html.tag('br'), + + language.formatRelativeDate(rereleaseDate, data.firstReleaseDate, { + considerRoundingDays: true, + approximate: true, + absolute: true, + }), + ])), + })), +}; diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js new file mode 100644 index 00000000..1d849919 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js @@ -0,0 +1,61 @@ +import {sortChronologically} from '#sort'; + +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateTooltip', + 'linkOtherReleaseOnArtistInfoPage' + ], + + extraDependencies: ['html', 'language'], + + query: (track) => ({ + firstRelease: + sortChronologically(track.allReleases)[0], + }), + + relations: (relation, query, track, artist) => ({ + tooltip: + relation('generateTooltip'), + + rereleaseColorStyle: + relation('generateColorStyleAttribute', track.color), + + firstReleaseLink: + relation('linkOtherReleaseOnArtistInfoPage', query.firstRelease, artist), + }), + + data: (query, track) => ({ + rereleaseDate: + track.dateFirstReleased ?? + track.album.date, + + firstReleaseDate: + query.firstRelease.dateFirstReleased ?? + query.firstRelease.album.date, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('artistPage.creditList.entry.rerelease', capsule => + relations.tooltip.slots({ + attributes: [ + {class: 'rerelease-tooltip'}, + relations.rereleaseColorStyle, + ], + + content: [ + language.$(capsule, 'firstRelease', { + album: + html.metatag('blockwrap', relations.firstReleaseLink), + }), + + html.tag('br'), + + language.formatRelativeDate(data.firstReleaseDate, data.rereleaseDate, { + considerRoundingDays: true, + approximate: true, + absolute: true, + }), + ], + })), +}; diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunk.js b/src/content/dependencies/generateArtistInfoPageTracksChunk.js index b42e4165..f6d70901 100644 --- a/src/content/dependencies/generateArtistInfoPageTracksChunk.js +++ b/src/content/dependencies/generateArtistInfoPageTracksChunk.js @@ -40,7 +40,7 @@ export default { contribs .filter(contrib => contrib.countInDurationTotals) .map(contrib => contrib.thing) - .filter(track => track.isOriginalRelease) + .filter(track => track.isMainRelease) .filter(track => track.duration > 0)); data.duration = diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js index 96976826..a42d6fee 100644 --- a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js @@ -1,9 +1,12 @@ +import {sortChronologically} from '#sort'; import {empty} from '#sugar'; export default { contentDependencies: [ 'generateArtistInfoPageChunkItem', + 'generateArtistInfoPageFirstReleaseTooltip', 'generateArtistInfoPageOtherArtistLinks', + 'generateArtistInfoPageRereleaseTooltip', 'linkTrack', ], @@ -61,6 +64,26 @@ export default { ]; } + // It's kinda awkward to perform this chronological sort here, + // per track, rather than just reusing the one that's done to + // sort all the items on the page altogether... but then, the + // sort for the page is actually *a different* sort, on purpsoe. + // That sort is according to the dates of the contributions; + // this is according to the dates of the tracks. Those can be + // different - and it's the latter that determines whether the + // track is a rerelease! + const allReleasesChronologically = + sortChronologically(query.track.allReleases); + + query.isFirstRelease = + allReleasesChronologically[0] === query.track; + + query.isRerelease = + allReleasesChronologically[0] !== query.track; + + query.hasOtherReleases = + !empty(query.track.otherReleases); + return query; }, @@ -73,15 +96,22 @@ export default { otherArtistLinks: relation('generateArtistInfoPageOtherArtistLinks', contribs), + + rereleaseTooltip: + (query.isRerelease + ? relation('generateArtistInfoPageRereleaseTooltip', query.track, artist) + : null), + + firstReleaseTooltip: + (query.isFirstRelease && query.hasOtherReleases + ? relation('generateArtistInfoPageFirstReleaseTooltip', query.track, artist) + : null), }), data: (query) => ({ duration: query.track.duration, - rerelease: - query.track.isRerelease, - contribAnnotations: (query.displayedContributions ? query.displayedContributions @@ -92,7 +122,8 @@ export default { generate: (data, relations, {html, language}) => relations.template.slots({ otherArtistLinks: relations.otherArtistLinks, - rerelease: data.rerelease, + rereleaseTooltip: relations.rereleaseTooltip, + firstReleaseTooltip: relations.firstReleaseTooltip, annotation: (data.contribAnnotations diff --git a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js index 7c01accb..84eb29ac 100644 --- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js @@ -1,6 +1,7 @@ import {sortAlbumsTracksChronologically, sortContributionsChronologically} from '#sort'; -import {chunkByConditions, stitchArrays} from '#sugar'; +import {stitchArrays} from '#sugar'; +import {chunkArtistTrackContributions} from '#wiki-data'; export default { contentDependencies: [ @@ -21,19 +22,7 @@ export default { sortAlbumsTracksChronologically); query.contribs = - // First chunk by (contribution) date and album. - chunkByConditions(allContributions, [ - ({date: date1}, {date: date2}) => - +date1 !== +date2, - ({thing: track1}, {thing: track2}) => - track1.album !== track2.album, - ]).map(contribs => - // Then, *within* the boundaries of the existing chunks, - // chunk contributions to the same thing together. - chunkByConditions(contribs, [ - ({thing: thing1}, {thing: thing2}) => - thing1 !== thing2, - ])); + chunkArtistTrackContributions(allContributions); query.albums = query.contribs @@ -58,8 +47,35 @@ export default { contribs)), }), - generate: (relations) => + data: (query, _artist) => ({ + albumDirectories: + query.albums + .map(album => album.directory), + + albumChunkIndices: + query.albums + .reduce(([indices, map], album) => { + if (map.has(album)) { + const n = map.get(album); + indices.push(n); + map.set(album, n + 1); + } else { + indices.push(0); + map.set(album, 1); + } + return [indices, map]; + }, [[], new Map()]) + [0], + }), + + generate: (data, relations) => relations.chunkedList.slots({ - chunks: relations.chunks, + chunks: + stitchArrays({ + chunk: relations.chunks, + albumDirectory: data.albumDirectories, + albumChunkIndex: data.albumChunkIndices, + }).map(({chunk, albumDirectory, albumChunkIndex}) => + chunk.slot('id', `tracks-${albumDirectory}-${albumChunkIndex}`)), }), }; diff --git a/src/content/dependencies/generateColorStyleRules.js b/src/content/dependencies/generateColorStyleRules.js deleted file mode 100644 index c412b8f2..00000000 --- a/src/content/dependencies/generateColorStyleRules.js +++ /dev/null @@ -1,42 +0,0 @@ -export default { - contentDependencies: ['generateColorStyleVariables'], - extraDependencies: ['html'], - - relations: (relation) => ({ - variables: - relation('generateColorStyleVariables'), - }), - - data: (color) => ({ - color: - color ?? null, - }), - - slots: { - color: { - validate: v => v.isColor, - }, - }, - - generate(data, relations, slots) { - const color = data.color ?? slots.color; - - if (!color) { - return ''; - } - - return [ - `:root {`, - ...( - relations.variables - .slots({ - color, - context: 'page-root', - mode: 'property-list', - }) - .content - .map(line => line + ';')), - `}`, - ].join('\n'); - }, -}; diff --git a/src/content/dependencies/generateColorStyleTag.js b/src/content/dependencies/generateColorStyleTag.js new file mode 100644 index 00000000..2b1a21dd --- /dev/null +++ b/src/content/dependencies/generateColorStyleTag.js @@ -0,0 +1,51 @@ +export default { + contentDependencies: ['generateColorStyleVariables', 'generateStyleTag'], + extraDependencies: ['html'], + + relations: (relation) => ({ + styleTag: + relation('generateStyleTag'), + + variables: + relation('generateColorStyleVariables'), + }), + + data: (color) => ({ + color: + color ?? null, + }), + + slots: { + color: { + validate: v => v.isColor, + }, + }, + + generate(data, relations, slots, {html}) { + const color = + data.color ?? slots.color; + + if (!color) { + return html.blank(); + } + + return relations.styleTag.slots({ + attributes: [ + {class: 'color-style'}, + {'data-color': color}, + ], + + rules: [ + { + select: ':root', + declare: + relations.variables.slots({ + color, + context: 'page-root', + mode: 'declarations', + }).content, + }, + ], + }); + }, +}; diff --git a/src/content/dependencies/generateColorStyleVariables.js b/src/content/dependencies/generateColorStyleVariables.js index 5270dbe4..c872d0b6 100644 --- a/src/content/dependencies/generateColorStyleVariables.js +++ b/src/content/dependencies/generateColorStyleVariables.js @@ -18,7 +18,7 @@ export default { }, mode: { - validate: v => v.is('style', 'property-list'), + validate: v => v.is('style', 'declarations'), default: 'style', }, }, @@ -50,15 +50,15 @@ export default { `--shadow-color: ${shadow}`, ]; - let selectedProperties; + let selectedDeclarations; switch (slots.context) { case 'any-content': - selectedProperties = anyContent; + selectedDeclarations = anyContent; break; case 'image-box': - selectedProperties = [ + selectedDeclarations = [ `--primary-color: ${primary}`, `--dim-color: ${dim}`, `--deep-color: ${deep}`, @@ -67,14 +67,14 @@ export default { break; case 'page-root': - selectedProperties = [ + selectedDeclarations = [ ...anyContent, `--page-primary-color: ${primary}`, ]; break; case 'primary-only': - selectedProperties = [ + selectedDeclarations = [ `--primary-color: ${primary}`, ]; break; @@ -82,10 +82,10 @@ export default { switch (slots.mode) { case 'style': - return selectedProperties.join('; '); + return selectedDeclarations.join('; '); - case 'property-list': - return selectedProperties; + case 'declarations': + return selectedDeclarations.map(declaration => declaration + ';'); } }, }; diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js index 9243a89c..367de506 100644 --- a/src/content/dependencies/generateCommentaryEntry.js +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -12,14 +12,14 @@ export default { relations: (relation, entry) => ({ artistLinks: - (!empty(entry.artists) && !entry.artistDisplayText + (!empty(entry.artists) && !entry.artistText ? entry.artists .map(artist => relation('linkArtist', artist)) : null), artistsContent: - (entry.artistDisplayText - ? relation('transformContent', entry.artistDisplayText) + (entry.artistText + ? relation('transformContent', entry.artistText) : null), annotationContent: @@ -51,6 +51,9 @@ export default { relations.colorStyle.clone() .slot('color', slots.color), + !html.isBlank(relations.date) && + {class: 'dated'}, + language.encapsulate(entryCapsule, 'title', titleCapsule => [ html.tag('span', {class: 'commentary-entry-heading-text'}, language.encapsulate(titleCapsule, workingCapsule => { diff --git a/src/content/dependencies/generateCommentarySection.js b/src/content/dependencies/generateCommentarySection.js deleted file mode 100644 index ed871b47..00000000 --- a/src/content/dependencies/generateCommentarySection.js +++ /dev/null @@ -1,53 +0,0 @@ -import {empty} from '#sugar'; - -export default { - contentDependencies: [ - 'transformContent', - 'generateCommentaryEntry', - 'generateContentHeading', - ], - - extraDependencies: ['html', 'language'], - - relations: (relation, entries) => ({ - heading: - relation('generateContentHeading'), - - entries: - (entries - ? entries.map(entry => - relation('generateCommentaryEntry', entry)) - : []), - }), - - data: (entries) => ({ - firstEntryIsDated: - (empty(entries) - ? null - : !!entries[0].date), - }), - - slots: { - title: {type: 'html', mutable: false}, - id: {type: 'string', default: 'artist-commentary'}, - }, - - generate: (data, relations, slots, {html, language}) => - html.tags([ - relations.heading - .slots({ - title: - (html.isBlank(slots.title) - ? language.$('misc.artistCommentary') - : slots.title), - - attributes: [ - {id: slots.id}, - data.firstEntryIsDated && - {class: 'first-entry-is-dated'}, - ], - }), - - relations.entries, - ]), -}; diff --git a/src/content/dependencies/generateContentContentHeading.js b/src/content/dependencies/generateContentContentHeading.js new file mode 100644 index 00000000..314ef197 --- /dev/null +++ b/src/content/dependencies/generateContentContentHeading.js @@ -0,0 +1,39 @@ +export default { + contentDependencies: ['generateContentHeading'], + extraDependencies: ['html', 'language'], + + relations: (relation, _thing) => ({ + contentHeading: + relation('generateContentHeading'), + }), + + data: (thing) => ({ + name: + thing.name, + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + string: { + type: 'string', + }, + }, + + generate: (data, relations, slots, {html, language}) => + relations.contentHeading.slots({ + attributes: slots.attributes, + + title: + language.$(slots.string, { + thing: + html.tag('i', data.name), + }), + + stickyTitle: + language.$(slots.string, 'sticky'), + }), +}; diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js index 78c9051c..378c0e1c 100644 --- a/src/content/dependencies/generateContributionTooltipChronologySection.js +++ b/src/content/dependencies/generateContributionTooltipChronologySection.js @@ -1,3 +1,19 @@ +import Thing from '#thing'; + +function getName(thing) { + if (!thing) { + return null; + } + + const referenceType = thing.constructor[Thing.referenceType]; + + if (referenceType === 'artwork') { + return thing.thing.name; + } + + return thing.name; +} + export default { contentDependencies: ['linkAnythingMan'], extraDependencies: ['html', 'language'], @@ -30,14 +46,10 @@ export default { data: (query, _contribution) => ({ previousName: - (query.previous - ? query.previous.thing.name - : null), + getName(query.previous?.thing), nextName: - (query.next - ? query.next.thing.name - : null), + getName(query.next?.thing), }), slots: { diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js index 50ca89ae..78a6103b 100644 --- a/src/content/dependencies/generateCoverArtwork.js +++ b/src/content/dependencies/generateCoverArtwork.js @@ -1,11 +1,57 @@ export default { - contentDependencies: ['image', 'linkArtistGallery'], + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateCoverArtworkArtTagDetails', + 'generateCoverArtworkArtistDetails', + 'generateCoverArtworkOriginDetails', + 'generateCoverArtworkReferenceDetails', + 'image', + ], + extraDependencies: ['html'], + relations: (relation, artwork) => ({ + colorStyleAttribute: + relation('generateColorStyleAttribute'), + + image: + relation('image', artwork), + + originDetails: + relation('generateCoverArtworkOriginDetails', artwork), + + artTagDetails: + relation('generateCoverArtworkArtTagDetails', artwork), + + artistDetails: + relation('generateCoverArtworkArtistDetails', artwork), + + referenceDetails: + relation('generateCoverArtworkReferenceDetails', artwork), + }), + + data: (artwork) => ({ + attachAbove: + artwork.attachAbove, + + attachedArtworkIsMainArtwork: + (artwork.attachAbove + ? artwork.attachedArtwork.isMainArtwork + : null), + + color: + artwork.thing.color ?? null, + + dimensions: + artwork.dimensions, + }), + slots: { - image: { - type: 'html', - mutable: true, + alt: {type: 'string'}, + + color: { + validate: v => v.anyOf(v.isBoolean, v.isColor), + default: false, }, mode: { @@ -13,13 +59,10 @@ export default { default: 'primary', }, - dimensions: { - validate: v => v.isDimensions, - }, - - warnings: { - validate: v => v.looseArrayOf(v.isString), - }, + showOriginDetails: {type: 'boolean', default: false}, + showArtTagDetails: {type: 'boolean', default: false}, + showArtistDetails: {type: 'boolean', default: false}, + showReferenceDetails: {type: 'boolean', default: false}, details: { type: 'html', @@ -27,60 +70,88 @@ export default { }, }, - generate(slots, {html}) { + generate(data, relations, slots, {html}) { + const {image} = relations; + + image.setSlot('alt', slots.alt); + const square = - (slots.dimensions - ? slots.dimensions[0] === slots.dimensions[1] + (data.dimensions + ? data.dimensions[0] === data.dimensions[1] : true); - const sizeSlots = - (square - ? {square: true} - : {dimensions: slots.dimensions}); - - switch (slots.mode) { - case 'primary': - return html.tags([ - slots.image.slots({ - thumb: 'medium', - reveal: true, - link: true, - - warnings: slots.warnings, - ...sizeSlots, - }), - - slots.details, - ]); - - case 'thumbnail': - return ( - slots.image.slots({ - thumb: 'small', - reveal: false, - link: false, - - warnings: slots.warnings, - ...sizeSlots, - })); - - case 'commentary': - return ( - slots.image.slots({ - thumb: 'medium', - reveal: true, - link: true, - lazy: true, - - warnings: slots.warnings, - ...sizeSlots, - - attributes: - {class: 'commentary-art'}, - })); - - default: - return html.blank(); + if (square) { + image.setSlot('square', true); + } else { + image.setSlot('dimensions', data.dimensions); } + + const attributes = html.attributes(); + + let color = null; + if (typeof slots.color === 'boolean') { + if (slots.color) { + color = data.color; + } + } else if (slots.color) { + color = slots.color; + } + + if (color) { + relations.colorStyleAttribute.setSlot('color', color); + attributes.add(relations.colorStyleAttribute); + } + + return html.tags([ + data.attachAbove && + html.tag('div', {class: 'cover-artwork-joiner'}), + + html.tag('div', {class: 'cover-artwork'}, + slots.mode === 'commentary' && + {class: 'commentary-art'}, + + data.attachAbove && + data.attachedArtworkIsMainArtwork && + {class: 'attached-artwork-is-main-artwork'}, + + attributes, + + (slots.mode === 'primary' + ? [ + relations.image.slots({ + thumb: 'medium', + reveal: true, + link: true, + }), + + slots.showOriginDetails && + relations.originDetails, + + slots.showArtTagDetails && + relations.artTagDetails, + + slots.showArtistDetails && + relations.artistDetails, + + slots.showReferenceDetails && + relations.referenceDetails, + + slots.details, + ] + : slots.mode === 'thumbnail' + ? relations.image.slots({ + thumb: 'small', + reveal: false, + link: false, + }) + : slots.mode === 'commentary' + ? relations.image.slots({ + thumb: 'medium', + reveal: true, + link: true, + lazy: true, + }) + : html.blank())), + ]); }, }; diff --git a/src/content/dependencies/generateCoverArtworkArtTagDetails.js b/src/content/dependencies/generateCoverArtworkArtTagDetails.js index 81ead8a9..4d908665 100644 --- a/src/content/dependencies/generateCoverArtworkArtTagDetails.js +++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js @@ -1,22 +1,42 @@ -import {stitchArrays} from '#sugar'; +import {compareArrays, empty, stitchArrays} from '#sugar'; + +function linkable(tag) { + return !tag.isContentWarning; +} export default { - contentDependencies: ['linkArtTag'], - extraDependencies: ['html'], + contentDependencies: ['linkArtTagGallery'], + extraDependencies: ['html', 'language'], - query: (artTags) => ({ + query: (artwork) => ({ linkableArtTags: - artTags - .filter(tag => !tag.isContentWarning), + artwork.artTags.filter(linkable), + + mainArtworkLinkableArtTags: + (artwork.mainArtwork + ? artwork.mainArtwork.artTags.filter(linkable) + : null), }), - relations: (relation, query, _artTags) => ({ - tagLinks: + relations: (relation, query, _artwork) => ({ + artTagLinks: query.linkableArtTags - .map(tag => relation('linkArtTag', tag)), + .map(tag => relation('linkArtTagGallery', tag)), }), - data: (query, _artTags) => { + data: (query, artwork) => { + const data = {}; + + data.attachAbove = artwork.attachAbove; + + data.sameAsMainArtwork = + !artwork.isMainArtwork && + query.mainArtworkLinkableArtTags && + !empty(query.mainArtworkLinkableArtTags) && + compareArrays( + query.mainArtworkLinkableArtTags, + query.linkableArtTags); + const seenShortNames = new Set(); const duplicateShortNames = new Set(); @@ -28,23 +48,28 @@ export default { } } - const preferShortName = + data.preferShortName = query.linkableArtTags .map(artTag => !duplicateShortNames.has(artTag.nameShort)); - return {preferShortName}; + return data; }, - generate: (data, relations, {html}) => - html.tag('ul', {class: 'image-details'}, - {[html.onlyIfContent]: true}, + generate: (data, relations, {html, language}) => + language.encapsulate('misc.coverArtwork', capsule => + html.tag('ul', {class: 'image-details'}, + {[html.onlyIfContent]: true}, - {class: 'art-tag-details'}, + {class: 'art-tag-details'}, - stitchArrays({ - tagLink: relations.tagLinks, - preferShortName: data.preferShortName, - }).map(({tagLink, preferShortName}) => - html.tag('li', - tagLink.slot('preferShortName', preferShortName)))), + (data.sameAsMainArtwork && data.attachAbove + ? html.blank() + : data.sameAsMainArtwork && relations.artTagLinks.length >= 3 + ? language.$(capsule, 'sameTagsAsMainArtwork') + : stitchArrays({ + artTagLink: relations.artTagLinks, + preferShortName: data.preferShortName, + }).map(({artTagLink, preferShortName}) => + html.tag('li', + artTagLink.slot('preferShortName', preferShortName)))))), }; diff --git a/src/content/dependencies/generateCoverArtworkArtistDetails.js b/src/content/dependencies/generateCoverArtworkArtistDetails.js index 5b235353..3ead80ab 100644 --- a/src/content/dependencies/generateCoverArtworkArtistDetails.js +++ b/src/content/dependencies/generateCoverArtworkArtistDetails.js @@ -2,9 +2,9 @@ export default { contentDependencies: ['linkArtistGallery'], extraDependencies: ['html', 'language'], - relations: (relation, contributions) => ({ + relations: (relation, artwork) => ({ artistLinks: - contributions + artwork.artistContribs .map(contrib => contrib.artist) .map(artist => relation('linkArtistGallery', artist)), @@ -17,6 +17,8 @@ export default { {class: 'illustrator-details'}, language.$('misc.coverGrid.details.coverArtists', { + [language.onlyIfOptions]: ['artists'], + artists: language.formatConjunctionList(relations.artistLinks), })), diff --git a/src/content/dependencies/generateCoverArtworkOriginDetails.js b/src/content/dependencies/generateCoverArtworkOriginDetails.js new file mode 100644 index 00000000..8628179e --- /dev/null +++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js @@ -0,0 +1,170 @@ +import Thing from '#thing'; + +export default { + contentDependencies: [ + 'generateArtistCredit', + 'generateAbsoluteDatetimestamp', + 'linkAlbum', + 'transformContent', + ], + + extraDependencies: ['html', 'language', 'pagePath'], + + query: (artwork) => ({ + artworkThingType: + artwork.thing.constructor[Thing.referenceType], + + attachedArtistContribs: + (artwork.attachedArtwork + ? artwork.attachedArtwork.artistContribs + : null) + }), + + relations: (relation, query, artwork) => ({ + credit: + relation('generateArtistCredit', + artwork.artistContribs, + query.attachedArtistContribs ?? []), + + source: + relation('transformContent', artwork.source), + + originDetails: + relation('transformContent', artwork.originDetails), + + albumLink: + (query.artworkThingType === 'album' + ? relation('linkAlbum', artwork.thing) + : null), + + datetimestamp: + (artwork.date && artwork.date !== artwork.thing.date + ? relation('generateAbsoluteDatetimestamp', artwork.date) + : null), + }), + + + data: (query, artwork) => ({ + label: + artwork.label, + + artworkThingType: + query.artworkThingType, + }), + + generate: (data, relations, {html, language, pagePath}) => + language.encapsulate('misc.coverArtwork', capsule => + html.tag('p', {class: 'image-details'}, + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + {class: 'origin-details'}, + + (() => { + relations.datetimestamp?.setSlots({ + style: 'year', + tooltip: true, + }); + + const artworkBy = + language.encapsulate(capsule, 'artworkBy', workingCapsule => { + const workingOptions = {}; + + if (data.label) { + workingCapsule += '.customLabel'; + workingOptions.label = data.label; + } + + if (relations.datetimestamp) { + workingCapsule += '.withYear'; + workingOptions.year = relations.datetimestamp; + } + + return relations.credit.slots({ + showAnnotation: true, + showExternalLinks: true, + showChronology: true, + showWikiEdits: true, + + trimAnnotation: false, + + chronologyKind: 'coverArt', + + normalStringKey: workingCapsule, + additionalStringOptions: workingOptions, + }); + }); + + const trackArtFromAlbum = + pagePath[0] === 'track' && + data.artworkThingType === 'album' && + language.$(capsule, 'trackArtFromAlbum', { + album: + relations.albumLink.slot('color', false), + }); + + const source = + language.encapsulate(capsule, 'source', workingCapsule => { + const workingOptions = { + [language.onlyIfOptions]: ['source'], + source: relations.source.slot('mode', 'inline'), + }; + + if (html.isBlank(artworkBy) && data.label) { + workingCapsule += '.customLabel'; + workingOptions.label = data.label; + } + + if (html.isBlank(artworkBy) && relations.datetimestamp) { + workingCapsule += '.withYear'; + workingOptions.year = relations.datetimestamp; + } + + return language.$(workingCapsule, workingOptions); + }); + + const label = + html.isBlank(artworkBy) && + html.isBlank(source) && + language.encapsulate(capsule, 'customLabel', workingCapsule => { + const workingOptions = { + [language.onlyIfOptions]: ['label'], + label: data.label, + }; + + if (relations.datetimestamp) { + workingCapsule += '.withYear'; + workingOptions.year = relations.datetimestamp; + } + + return language.$(workingCapsule, workingOptions); + }); + + const year = + html.isBlank(artworkBy) && + html.isBlank(source) && + html.isBlank(label) && + language.$(capsule, 'year', { + [language.onlyIfOptions]: ['year'], + year: relations.datetimestamp, + }); + + const originDetails = + html.tag('span', {class: 'origin-details'}, + {[html.onlyIfContent]: true}, + + relations.originDetails.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + })); + + return [ + artworkBy, + trackArtFromAlbum, + source, + label, + year, + originDetails, + ]; + })())), +}; diff --git a/src/content/dependencies/generateCoverArtworkReferenceDetails.js b/src/content/dependencies/generateCoverArtworkReferenceDetails.js index 006b2b4b..035ab586 100644 --- a/src/content/dependencies/generateCoverArtworkReferenceDetails.js +++ b/src/content/dependencies/generateCoverArtworkReferenceDetails.js @@ -1,20 +1,24 @@ export default { + contentDependencies: ['linkReferencedArtworks', 'linkReferencingArtworks'], extraDependencies: ['html', 'language'], - data: (referenced, referencedBy) => ({ + relations: (relation, artwork) => ({ + referencedArtworksLink: + relation('linkReferencedArtworks', artwork), + + referencingArtworksLink: + relation('linkReferencingArtworks', artwork), + }), + + data: (artwork) => ({ referenced: - referenced.length, + artwork.referencedArtworks.length, referencedBy: - referencedBy.length, + artwork.referencedByArtworks.length, }), - slots: { - referencedLink: {type: 'html', mutable: true}, - referencingLink: {type: 'html', mutable: true}, - }, - - generate: (data, slots, {html, language}) => + generate: (data, relations, {html, language}) => language.encapsulate('releaseInfo', capsule => { const referencedText = language.$(capsule, 'referencesArtworks', { @@ -47,10 +51,10 @@ export default { [ !html.isBlank(referencedText) && - slots.referencedLink.slot('content', referencedText), + relations.referencedArtworksLink.slot('content', referencedText), !html.isBlank(referencingText) && - slots.referencingLink.slot('content', referencingText), + relations.referencingArtworksLink.slot('content', referencingText), ])); }), } diff --git a/src/content/dependencies/generateCoverCarousel.js b/src/content/dependencies/generateCoverCarousel.js index 69220da6..0705d93e 100644 --- a/src/content/dependencies/generateCoverCarousel.js +++ b/src/content/dependencies/generateCoverCarousel.js @@ -2,24 +2,16 @@ import {empty, repeat, stitchArrays} from '#sugar'; import {getCarouselLayoutForNumberOfItems} from '#wiki-data'; export default { - contentDependencies: ['generateGridActionLinks'], extraDependencies: ['html'], - relations(relation) { - return { - actionLinks: relation('generateGridActionLinks'), - }; - }, - slots: { images: {validate: v => v.strictArrayOf(v.isHTML)}, links: {validate: v => v.strictArrayOf(v.isHTML)}, lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)}, - actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, }, - generate(relations, slots, {html}) { + generate(slots, {html}) { const stitched = stitchArrays({ image: slots.images, @@ -27,7 +19,7 @@ export default { }); if (empty(stitched)) { - return; + return html.blank(); } const layout = getCarouselLayoutForNumberOfItems(stitched.length); @@ -58,9 +50,6 @@ export default { }), })))), ])), - - relations.actionLinks - .slot('actionLinks', slots.actionLinks), ]); }, }; diff --git a/src/content/dependencies/generateCoverGrid.js b/src/content/dependencies/generateCoverGrid.js index fa9b3dda..e4dfd905 100644 --- a/src/content/dependencies/generateCoverGrid.js +++ b/src/content/dependencies/generateCoverGrid.js @@ -15,23 +15,59 @@ export default { links: {validate: v => v.strictArrayOf(v.isHTML)}, names: {validate: v => v.strictArrayOf(v.isHTML)}, info: {validate: v => v.strictArrayOf(v.isHTML)}, + notFromThisGroup: {validate: v => v.strictArrayOf(v.isBoolean)}, + + // Differentiating from sparseArrayOf here - this list of classes should + // have the same length as the items above, i.e. nulls aren't going to be + // filtered out of it, but it is okay to *include* null (standing in for + // no classes for this grid item). + classes: { + validate: v => + v.strictArrayOf( + v.optional( + v.anyOf( + v.isArray, + v.isString))), + }, lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)}, actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, }, - generate(relations, slots, {html, language}) { - return ( - html.tag('div', {class: 'grid-listing'}, [ + generate: (relations, slots, {html, language}) => + html.tag('div', {class: 'grid-listing'}, + {[html.onlyIfContent]: true}, + + [ stitchArrays({ + classes: slots.classes, image: slots.images, link: slots.links, name: slots.names, info: slots.info, - }).map(({image, link, name, info}, index) => + + notFromThisGroup: + slots.notFromThisGroup ?? + Array.from(slots.links).fill(null) + }).map(({ + classes, + image, + link, + name, + info, + notFromThisGroup, + }, index) => link.slots({ - attributes: {class: ['grid-item', 'box']}, + attributes: [ + {class: ['grid-item', 'box']}, + + (classes + ? {class: classes} + : null), + ], + colorContext: 'image-box', + content: [ image.slots({ thumb: 'medium', @@ -47,7 +83,15 @@ export default { html.tag('span', {[html.onlyIfContent]: true}, - language.sanitize(name)), + (notFromThisGroup + ? language.encapsulate('misc.coverGrid.details.notFromThisGroup', capsule => + language.$(capsule, { + name, + marker: + html.tag('span', {class: 'grid-name-marker'}, + language.$(capsule, 'marker')), + })) + : language.sanitize(name))), html.tag('span', {[html.onlyIfContent]: true}, @@ -62,6 +106,5 @@ export default { relations.actionLinks .slot('actionLinks', slots.actionLinks), - ])); - }, + ]), }; diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js index d9ed036a..a92d15fc 100644 --- a/src/content/dependencies/generateDatetimestampTemplate.js +++ b/src/content/dependencies/generateDatetimestampTemplate.js @@ -31,8 +31,10 @@ export default { slots.mainContent), tooltip: - slots.tooltip?.slots({ - attributes: [{class: 'datetimestamp-tooltip'}], - }), + (html.isBlank(slots.tooltip) + ? null + : slots.tooltip.slots({ + attributes: [{class: 'datetimestamp-tooltip'}], + })), }), }; diff --git a/src/content/dependencies/generateExpandableGallerySection.js b/src/content/dependencies/generateExpandableGallerySection.js new file mode 100644 index 00000000..122ca4b1 --- /dev/null +++ b/src/content/dependencies/generateExpandableGallerySection.js @@ -0,0 +1,92 @@ +export default { + contentDependencies: ['generateContentHeading'], + extraDependencies: ['html', 'language'], + + relations: (relation) => ({ + contentHeading: + relation('generateContentHeading'), + }), + + slots: { + title: { + type: 'html', + mutable: false, + }, + + contentAboveCut: { + type: 'html', + mutable: false, + }, + + contentBelowCut: { + type: 'html', + mutable: false, + }, + + caption: { + type: 'html', + mutable: false, + }, + + expandCue: { + type: 'html', + mutable: false, + }, + + collapseCue: { + type: 'html', + mutable: false, + }, + }, + + generate: (relations, slots, {html, language}) => + html.tag('section', {class: 'expandable-gallery-section'}, [ + relations.contentHeading.slots({ + tag: 'h2', + title: slots.title, + }), + + html.tag('div', {class: 'section-content-above-cut'}, + {[html.onlyIfContent]: true}, + + slots.contentAboveCut), + + html.tag('div', {class: 'section-content-below-cut'}, + {[html.onlyIfContent]: true}, + + !html.isBlank(slots.contentBelowCut) && + {style: 'display: none'}, + + slots.contentBelowCut), + + html.tag('div', {class: 'section-expando'}, + {[html.onlyIfSiblings]: true}, + + html.tag('div', {class: 'section-expando-content'}, + {[html.joinChildren]: html.tag('br')}, + + [ + html.tag('span', {class: 'section-caption'}, + slots.caption), + + !html.isBlank(slots.contentBelowCut) && + language.$('misc.coverGrid.expandCollapseCue', { + cue: + html.tag('a', {class: 'section-expando-toggle'}, + {href: '#'}, + + {[html.joinChildren]: ''}, + {[html.noEdgeWhitespace]: true}, + + [ + html.tag('span', {class: 'section-expand-cue'}, + slots.expandCue), + + html.tag('span', {class: 'section-collapse-cue'}, + {style: 'display: none'}, + slots.collapseCue), + ]), + }), + ])), + ]), +}; diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js index 8f174b21..84ab549d 100644 --- a/src/content/dependencies/generateFlashActGalleryPage.js +++ b/src/content/dependencies/generateFlashActGalleryPage.js @@ -1,5 +1,3 @@ -import {stitchArrays} from '#sugar'; - import striptags from 'striptags'; export default { @@ -37,7 +35,7 @@ export default { coverGridImages: act.flashes - .map(_flash => relation('image')), + .map(flash => relation('image', flash.coverArtwork)), flashLinks: act.flashes @@ -50,10 +48,6 @@ export default { flashNames: act.flashes.map(flash => flash.name), - - flashCoverPaths: - act.flashes.map(flash => - ['media.flashArt', flash.directory, flash.coverArtFileExtension]) }), generate: (data, relations, {language}) => @@ -71,15 +65,9 @@ export default { mainContent: [ relations.coverGrid.slots({ links: relations.flashLinks, + images: relations.coverGridImages, names: data.flashNames, lazy: 6, - - images: - stitchArrays({ - image: relations.coverGridImages, - path: data.flashCoverPaths, - }).map(({image, path}) => - image.slot('path', path)), }), ], diff --git a/src/content/dependencies/generateFlashArtworkColumn.js b/src/content/dependencies/generateFlashArtworkColumn.js new file mode 100644 index 00000000..5987df9e --- /dev/null +++ b/src/content/dependencies/generateFlashArtworkColumn.js @@ -0,0 +1,11 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + + relations: (relation, flash) => ({ + coverArtwork: + relation('generateCoverArtwork', flash.coverArtwork), + }), + + generate: (relations) => + relations.coverArtwork, +}; diff --git a/src/content/dependencies/generateFlashCoverArtwork.js b/src/content/dependencies/generateFlashCoverArtwork.js deleted file mode 100644 index 4b0e5242..00000000 --- a/src/content/dependencies/generateFlashCoverArtwork.js +++ /dev/null @@ -1,41 +0,0 @@ -export default { - contentDependencies: ['generateCoverArtwork', 'image'], - extraDependencies: ['html', 'language'], - - relations: (relation) => ({ - coverArtwork: - relation('generateCoverArtwork'), - - image: - relation('image'), - }), - - data: (flash) => ({ - path: - ['media.flashArt', flash.directory, flash.coverArtFileExtension], - - color: - flash.color, - - dimensions: - flash.coverArtDimensions, - }), - - slots: { - mode: {type: 'string'}, - }, - - generate: (data, relations, slots, {language}) => - relations.coverArtwork.slots({ - mode: slots.mode, - - image: - relations.image.slots({ - path: data.path, - color: data.color, - alt: language.$('misc.alt.flashArt'), - }), - - dimensions: data.dimensions, - }), -}; diff --git a/src/content/dependencies/generateFlashIndexPage.js b/src/content/dependencies/generateFlashIndexPage.js index a21bb49e..2788406c 100644 --- a/src/content/dependencies/generateFlashIndexPage.js +++ b/src/content/dependencies/generateFlashIndexPage.js @@ -53,7 +53,7 @@ export default { actCoverGridImages: query.flashActs .map(act => act.flashes - .map(() => relation('image'))), + .map(flash => relation('image', flash.coverArtwork))), }), data: (query) => ({ @@ -73,11 +73,6 @@ export default { query.flashActs .map(act => act.flashes .map(flash => flash.name)), - - actCoverGridPaths: - query.flashActs - .map(act => act.flashes - .map(flash => ['media.flashArt', flash.directory, flash.coverArtFileExtension])), }), generate: (data, relations, {html, language}) => @@ -116,7 +111,6 @@ export default { coverGridImages: relations.actCoverGridImages, coverGridLinks: relations.actCoverGridLinks, coverGridNames: data.actCoverGridNames, - coverGridPaths: data.actCoverGridPaths, }).map(({ colorStyle, actLink, @@ -126,7 +120,6 @@ export default { coverGridImages, coverGridLinks, coverGridNames, - coverGridPaths, }, index) => [ html.tag('h2', {id: anchor}, @@ -135,15 +128,9 @@ export default { coverGrid.slots({ links: coverGridLinks, + images: coverGridImages, names: coverGridNames, lazy: index === 0 ? 4 : true, - - images: - stitchArrays({ - image: coverGridImages, - path: coverGridPaths, - }).map(({image, path}) => - image.slot('path', path)), }), ]), ], diff --git a/src/content/dependencies/generateFlashInfoPage.js b/src/content/dependencies/generateFlashInfoPage.js index 990951f4..ee043bfa 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -2,11 +2,13 @@ import {empty} from '#sugar'; export default { contentDependencies: [ - 'generateCommentarySection', + 'generateAdditionalNamesBox', + 'generateCommentaryEntry', + 'generateContentContentHeading', 'generateContentHeading', 'generateContributionList', 'generateFlashActSidebar', - 'generateFlashCoverArtwork', + 'generateFlashArtworkColumn', 'generateFlashNavAccent', 'generatePageLayout', 'generateTrackList', @@ -39,16 +41,22 @@ export default { sidebar: relation('generateFlashActSidebar', flash.act, flash), + additionalNamesBox: + relation('generateAdditionalNamesBox', flash.additionalNames), + externalLinks: query.urls .map(url => relation('linkExternal', url)), - cover: - relation('generateFlashCoverArtwork', flash), + artworkColumn: + relation('generateFlashArtworkColumn', flash), contentHeading: relation('generateContentHeading'), + contentContentHeading: + relation('generateContentContentHeading', flash), + flashActLink: relation('linkFlashAct', flash.act), @@ -61,11 +69,13 @@ export default { contributorContributionList: relation('generateContributionList', flash.contributorContribs), - artistCommentarySection: - relation('generateCommentarySection', flash.commentary), + artistCommentaryEntries: + flash.commentary + .map(entry => relation('generateCommentaryEntry', entry)), - creditSourcesSection: - relation('generateCommentarySection', flash.creditSources), + creditSourceEntries: + flash.creditingSources + .map(entry => relation('generateCommentaryEntry', entry)), }), data: (_query, flash) => ({ @@ -90,7 +100,9 @@ export default { color: data.color, headingMode: 'sticky', - cover: relations.cover, + additionalNames: relations.additionalNamesBox, + + artworkColumnContent: relations.artworkColumn, mainContent: [ html.tag('p', @@ -115,7 +127,7 @@ export default { {[html.joinChildren]: html.tag('br')}, language.encapsulate('releaseInfo', capsule => [ - !html.isBlank(relations.artistCommentarySection) && + !html.isBlank(relations.artistCommentaryEntries) && language.encapsulate(capsule, 'readCommentary', capsule => language.$(capsule, { link: @@ -124,12 +136,12 @@ export default { language.$(capsule, 'link')), })), - !html.isBlank(relations.creditSourcesSection) && - language.encapsulate(capsule, 'readCreditSources', capsule => + !html.isBlank(relations.creditSourceEntries) && + language.encapsulate(capsule, 'readCreditingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#credit-sources'}, + {href: '#crediting-sources'}, language.$(capsule, 'link')), })), ])), @@ -159,12 +171,25 @@ export default { }), ]), - relations.artistCommentarySection, + html.tags([ + relations.contentContentHeading.clone() + .slots({ + attributes: {id: 'artist-commentary'}, + string: 'misc.artistCommentary', + }), - relations.creditSourcesSection.slots({ - id: 'credit-sources', - title: language.$('misc.creditSources'), - }), + relations.artistCommentaryEntries, + ]), + + html.tags([ + relations.contentContentHeading.clone() + .slots({ + attributes: {id: 'crediting-sources'}, + string: 'misc.creditingSources', + }), + + relations.creditSourceEntries, + ]), ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateGridActionLinks.js b/src/content/dependencies/generateGridActionLinks.js index f5b1aaa6..585a02b9 100644 --- a/src/content/dependencies/generateGridActionLinks.js +++ b/src/content/dependencies/generateGridActionLinks.js @@ -1,5 +1,3 @@ -import {empty} from '#sugar'; - export default { extraDependencies: ['html'], @@ -7,16 +5,12 @@ export default { actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, }, - generate(slots, {html}) { - if (empty(slots.actionLinks)) { - return html.blank(); - } + generate: (slots, {html}) => + html.tag('div', {class: 'grid-actions'}, + {[html.onlyIfContent]: true}, - return ( - html.tag('div', {class: 'grid-actions'}, - slots.actionLinks - .filter(Boolean) - .map(link => link - .slot('attributes', {class: ['grid-item', 'box']})))); - }, + (slots.actionLinks ?? []) + .filter(link => link && !html.isBlank(link)) + .map(link => link + .slot('attributes', {class: ['grid-item', 'box']}))), }; diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js index 79746cd0..dfdad0e8 100644 --- a/src/content/dependencies/generateGroupGalleryPage.js +++ b/src/content/dependencies/generateGroupGalleryPage.js @@ -1,14 +1,14 @@ import {sortChronologically} from '#sort'; -import {empty, stitchArrays} from '#sugar'; import {filterItemsForCarousel, getTotalDuration} from '#wiki-data'; export default { contentDependencies: [ 'generateCoverCarousel', - 'generateCoverGrid', + 'generateGroupGalleryPageAlbumsByDateView', + 'generateGroupGalleryPageAlbumsBySeriesView', 'generateGroupNavLinks', 'generateGroupSecondaryNav', - 'generateGroupSidebar', + 'generateIntrapageDotSwitcher', 'generatePageLayout', 'generateQuickDescription', 'image', @@ -21,95 +21,73 @@ export default { sprawl: ({wikiInfo}) => ({enableGroupUI: wikiInfo.enableGroupUI}), - relations(relation, sprawl, group) { - const relations = {}; + query(_sprawl, group) { + const query = {}; - const albums = + query.allAlbums = sortChronologically(group.albums.slice(), {latestFirst: true}); - relations.layout = - relation('generatePageLayout'); + query.allTracks = + query.allAlbums.flatMap((album) => album.tracks); - relations.navLinks = - relation('generateGroupNavLinks', group); + query.carouselAlbums = + filterItemsForCarousel(group.featuredAlbums); - if (sprawl.enableGroupUI) { - relations.secondaryNav = - relation('generateGroupSecondaryNav', group); - - relations.sidebar = - relation('generateGroupSidebar', group); - } - - const carouselAlbums = filterItemsForCarousel(group.featuredAlbums); - - if (!empty(carouselAlbums)) { - relations.coverCarousel = - relation('generateCoverCarousel'); - - relations.carouselLinks = - carouselAlbums - .map(album => relation('linkAlbum', album)); + return query; + }, - relations.carouselImages = - carouselAlbums - .map(album => relation('image', album.artTags)); - } + relations: (relation, query, sprawl, group) => ({ + layout: + relation('generatePageLayout'), - relations.quickDescription = - relation('generateQuickDescription', group); + navLinks: + relation('generateGroupNavLinks', group), - relations.coverGrid = - relation('generateCoverGrid'); + secondaryNav: + (sprawl.enableGroupUI + ? relation('generateGroupSecondaryNav', group) + : null), - relations.gridLinks = - albums - .map(album => relation('linkAlbum', album)); + coverCarousel: + relation('generateCoverCarousel'), - relations.gridImages = - albums.map(album => - (album.hasCoverArt - ? relation('image', album.artTags) - : relation('image'))); + carouselLinks: + query.carouselAlbums + .map(album => relation('linkAlbum', album)), - return relations; - }, + carouselImages: + query.carouselAlbums + .map(album => relation('image', album.coverArtworks[0])), - data(sprawl, group) { - const data = {}; + quickDescription: + relation('generateQuickDescription', group), - data.name = group.name; - data.color = group.color; + albumViewSwitcher: + relation('generateIntrapageDotSwitcher'), - const albums = sortChronologically(group.albums.slice(), {latestFirst: true}); - const tracks = albums.flatMap((album) => album.tracks); + albumsBySeriesView: + relation('generateGroupGalleryPageAlbumsBySeriesView', group), - data.numAlbums = albums.length; - data.numTracks = tracks.length; - data.totalDuration = getTotalDuration(tracks, {originalReleasesOnly: true}); + albumsByDateView: + relation('generateGroupGalleryPageAlbumsByDateView', group), + }), - data.gridNames = albums.map(album => album.name); - data.gridDurations = albums.map(album => getTotalDuration(album.tracks)); - data.gridNumTracks = albums.map(album => album.tracks.length); + data: (query, _sprawl, group) => ({ + name: + group.name, - data.gridPaths = - albums.map(album => - (album.hasCoverArt - ? ['media.albumCover', album.directory, album.coverArtFileExtension] - : null)); + color: + group.color, - const carouselAlbums = filterItemsForCarousel(group.featuredAlbums); + numAlbums: + query.allAlbums.length, - if (!empty(group.featuredAlbums)) { - data.carouselPaths = - carouselAlbums.map(album => - (album.hasCoverArt - ? ['media.albumCover', album.directory, album.coverArtFileExtension] - : null)); - } + numTracks: + query.allTracks.length, - return data; - }, + totalDuration: + getTotalDuration(query.allTracks, {mainReleasesOnly: true}), + }), generate: (data, relations, {html, language}) => language.encapsulate('groupGalleryPage', pageCapsule => @@ -121,16 +99,10 @@ export default { mainClasses: ['top-index'], mainContent: [ - relations.coverCarousel - ?.slots({ - links: relations.carouselLinks, - images: - stitchArrays({ - image: relations.carouselImages, - path: data.carouselPaths, - }).map(({image, path}) => - image.slot('path', path)), - }), + relations.coverCarousel.slots({ + links: relations.carouselLinks, + images: relations.carouselImages, + }), relations.quickDescription, @@ -155,49 +127,78 @@ export default { })), })), - relations.coverGrid - .slots({ - links: relations.gridLinks, - names: data.gridNames, - images: - stitchArrays({ - image: relations.gridImages, - path: data.gridPaths, - name: data.gridNames, - }).map(({image, path, name}) => - image.slots({ - path, - missingSourceContent: - language.$('misc.coverGrid.noCoverArt', { - album: name, - }), - })), - info: - stitchArrays({ - numTracks: data.gridNumTracks, - duration: data.gridDurations, - }).map(({numTracks, duration}) => - language.$('misc.coverGrid.details.albumLength', { - tracks: language.countTracks(numTracks, {unit: true}), - time: language.formatDuration(duration), - })), - }), + ([ + !html.isBlank(relations.albumsBySeriesView), + !html.isBlank(relations.albumsByDateView) + ]).filter(Boolean).length > 1 && + + language.encapsulate(pageCapsule, 'albumViewSwitcher', capsule => + html.tag('p', {class: 'gallery-view-switcher'}, + {class: ['drop', 'shiny']}, + + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + [ + language.$(capsule), + + relations.albumViewSwitcher.slots({ + initialOptionIndex: 0, + + titles: [ + !html.isBlank(relations.albumsByDateView) && + language.$(capsule, 'byDate'), + + !html.isBlank(relations.albumsBySeriesView) && + language.$(capsule, 'bySeries'), + ].filter(Boolean), + + targetIDs: [ + !html.isBlank(relations.albumsByDateView) && + 'group-album-gallery-by-date', + + !html.isBlank(relations.albumsBySeriesView) && + 'group-album-gallery-by-series', + ].filter(Boolean), + }), + ])), + + /* + data.trackGridLabels.some(value => value !== null) && + html.tag('p', {class: 'gallery-set-switcher'}, + language.encapsulate(pageCapsule, 'setSwitcher', switcherCapsule => + language.$(switcherCapsule, { + sets: + relations.setSwitcher.slots({ + initialOptionIndex: 0, + + titles: + data.trackGridLabels.map(label => + label ?? + language.$(switcherCapsule, 'unlabeledSet')), + + targetIDs: + data.trackGridIDs, + }), + }))), + */ + + relations.albumsByDateView, + + relations.albumsBySeriesView.slots({ + attributes: [ + !html.isBlank(relations.albumsBySeriesView) && + {style: 'display: none'}, + ], + }), ], - leftSidebar: - (relations.sidebar - ? relations.sidebar - .slot('currentExtra', 'gallery') - .content /* TODO: Kludge. */ - : null), - navLinkStyle: 'hierarchical', navLinks: relations.navLinks .slot('currentExtra', 'gallery') .content, - secondaryNav: - relations.secondaryNav ?? null, + secondaryNav: relations.secondaryNav, })), }; diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js new file mode 100644 index 00000000..7d9aa2d2 --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js @@ -0,0 +1,66 @@ +import {stitchArrays} from '#sugar'; +import {getTotalDuration} from '#wiki-data'; + +export default { + contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'], + extraDependencies: ['language'], + + relations: (relation, albums, _group) => ({ + coverGrid: + relation('generateCoverGrid'), + + links: + albums.map(album => + relation('linkAlbum', album)), + + images: + albums.map(album => + (album.hasCoverArt + ? relation('image', album.coverArtworks[0]) + : relation('image'))) + }), + + data: (albums, group) => ({ + names: + albums.map(album => album.name), + + durations: + albums.map(album => getTotalDuration(album.tracks)), + + tracks: + albums.map(album => album.tracks.length), + + notFromThisGroup: + albums.map(album => !album.groups.includes(group)), + }), + + generate: (data, relations, {language}) => + language.encapsulate('misc.coverGrid', capsule => + relations.coverGrid.slots({ + links: relations.links, + names: data.names, + notFromThisGroup: data.notFromThisGroup, + + images: + stitchArrays({ + image: relations.images, + name: data.names, + }).map(({image, name}) => + image.slots({ + missingSourceContent: + language.$(capsule, 'noCoverArt', { + album: name, + }), + })), + + info: + stitchArrays({ + tracks: data.tracks, + duration: data.durations, + }).map(({tracks, duration}) => + language.$(capsule, 'details.albumLength', { + tracks: language.countTracks(tracks, {unit: true}), + time: language.formatDuration(duration), + })), + })), +}; diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js new file mode 100644 index 00000000..b7d01eb5 --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js @@ -0,0 +1,39 @@ +import {sortChronologically} from '#sort'; + +export default { + contentDependencies: ['generateGroupGalleryPageAlbumGrid'], + extraDependencies: ['html', 'language'], + + query: (group) => ({ + albums: + sortChronologically(group.albums, {latestFirst: true}), + }), + + relations: (relation, query, group) => ({ + albumGrid: + relation('generateGroupGalleryPageAlbumGrid', + query.albums, + group), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + }, + + generate: (relations, slots, {html, language}) => + language.encapsulate('groupGalleryPage.albumsByDate', capsule => + html.tag('div', {id: 'group-album-gallery-by-date'}, + slots.attributes, + + {[html.onlyIfContent]: true}, + + html.tag('section', [ + html.tag('h2', + language.$(capsule, 'title')), + + relations.albumGrid, + ]))), +}; diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js new file mode 100644 index 00000000..0337275f --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js @@ -0,0 +1,26 @@ +export default { + contentDependencies: ['generateGroupGalleryPageSeriesSection'], + extraDependencies: ['html'], + + relations: (relation, group) => ({ + seriesSections: + group.serieses + .map(series => + relation('generateGroupGalleryPageSeriesSection', series)), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + }, + + generate: (relations, slots, {html}) => + html.tag('div', {id: 'group-album-gallery-by-series'}, + slots.attributes, + + {[html.onlyIfContent]: true}, + + relations.seriesSections), +}; diff --git a/src/content/dependencies/generateGroupGalleryPageSeriesSection.js b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js new file mode 100644 index 00000000..2ccead5d --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js @@ -0,0 +1,156 @@ +import {sortChronologically} from '#sort'; + +export default { + contentDependencies: [ + 'generateExpandableGallerySection', + 'generateGroupGalleryPageAlbumGrid', + ], + + extraDependencies: ['html', 'language'], + + query(series) { + const query = {}; + + // Includes undated albums. + const albumsLatestFirst = + sortChronologically(series.albums, {latestFirst: true}); + + query.albumsAboveCut = albumsLatestFirst.slice(0, 4); + query.albumsBelowCut = albumsLatestFirst.slice(4); + + query.allAlbumsDated = + series.albums.every(album => album.date); + + query.anyAlbumNotFromThisGroup = + series.albums.some(album => !album.groups.includes(series.group)); + + query.latestAlbum = + albumsLatestFirst + .filter(album => album.date) + .at(0) ?? + null; + + query.earliestAlbum = + albumsLatestFirst + .filter(album => album.date) + .at(-1) ?? + null; + + return query; + }, + + relations: (relation, query, series) => ({ + gallerySection: + relation('generateExpandableGallerySection'), + + gridAboveCut: + relation('generateGroupGalleryPageAlbumGrid', + query.albumsAboveCut, + series.group), + + gridBelowCut: + relation('generateGroupGalleryPageAlbumGrid', + query.albumsBelowCut, + series.group), + }), + + data: (query, series) => ({ + name: + series.name, + + groupName: + series.group.name, + + albums: + series.albums.length, + + tracks: + series.albums + .flatMap(album => album.tracks) + .length, + + allAlbumsDated: + query.allAlbumsDated, + + anyAlbumNotFromThisGroup: + query.anyAlbumNotFromThisGroup, + + earliestAlbumDate: + (query.earliestAlbum + ? query.earliestAlbum.date + : null), + + latestAlbumDate: + (query.latestAlbum + ? query.latestAlbum.date + : null), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('groupGalleryPage.albumSection', capsule => + relations.gallerySection.slots({ + title: data.name, + + contentAboveCut: relations.gridAboveCut, + contentBelowCut: relations.gridBelowCut, + + caption: + language.encapsulate(capsule, 'caption', captionCapsule => + html.tags([ + data.anyAlbumNotFromThisGroup && + language.$(captionCapsule, 'seriesAlbumsNotFromGroup', { + marker: + language.$('misc.coverGrid.details.notFromThisGroup.marker'), + + series: + html.tag('i', data.name), + + group: data.groupName, + }), + + language.encapsulate(captionCapsule, workingCapsule => { + const workingOptions = {}; + + workingOptions.tracks = + html.tag('b', + language.countTracks(data.tracks, {unit: true})); + + workingOptions.albums = + html.tag('b', + language.countAlbums(data.albums, {unit: true})); + + if (data.allAlbumsDated) { + const earliestDate = data.earliestAlbumDate; + const latestDate = data.latestAlbumDate; + + const earliestYear = earliestDate.getFullYear(); + const latestYear = latestDate.getFullYear(); + + if (earliestYear === latestYear) { + if (data.albums === 1) { + workingCapsule += '.withDate'; + workingOptions.date = + language.formatDate(earliestDate); + } else { + workingCapsule += '.withYear'; + workingOptions.year = + language.formatYear(earliestDate); + } + } else { + workingCapsule += '.withYearRange'; + workingOptions.yearRange = + language.formatYearRange(earliestDate, latestDate); + } + } + + return language.$(workingCapsule, workingOptions); + }), + ], {[html.joinChildren]: html.tag('br')})), + + expandCue: + language.$(capsule, 'expand'), + + collapseCue: + language.$(capsule, 'collapse'), + })), +}; diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js index 99e7e8ff..4680cb46 100644 --- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js @@ -127,7 +127,8 @@ export default { workingCapsule += '.withArtists'; workingOptions.by = html.tag('span', {class: 'by'}, - html.metatag('chunkwrap', {split: ','}, + // TODO: This is obviously evil. + html.metatag('chunkwrap', {split: /,| (?=and)/}, html.resolve(artistCredit))); } diff --git a/src/content/dependencies/generateImageOverlay.js b/src/content/dependencies/generateImageOverlay.js new file mode 100644 index 00000000..cfb78a1b --- /dev/null +++ b/src/content/dependencies/generateImageOverlay.js @@ -0,0 +1,50 @@ +export default { + extraDependencies: ['html', 'language'], + + generate: ({html, language}) => + html.tag('div', {id: 'image-overlay-container'}, + html.tag('div', {id: 'image-overlay-content-container'}, [ + html.tag('span', {id: 'image-overlay-image-area'}, + html.tag('span', {id: 'image-overlay-image-layout'}, [ + html.tag('img', {id: 'image-overlay-image'}), + html.tag('img', {id: 'image-overlay-image-thumb'}), + ])), + + html.tag('div', {id: 'image-overlay-action-container'}, + language.encapsulate('releaseInfo.viewOriginalFile', capsule => [ + html.tag('div', {id: 'image-overlay-action-content-without-size'}, + language.$(capsule, { + link: html.tag('a', {class: 'image-overlay-view-original'}, + language.$(capsule, 'link')), + })), + + html.tag('div', {id: 'image-overlay-action-content-with-size'}, [ + language.$(capsule, 'withSize', { + link: + html.tag('a', {class: 'image-overlay-view-original'}, + language.$(capsule, 'link')), + + size: + html.tag('span', + {[html.joinChildren]: ''}, + [ + html.tag('span', {id: 'image-overlay-file-size-kilobytes'}, + language.$('count.fileSize.kilobytes', { + kilobytes: + html.tag('span', {class: 'image-overlay-file-size-count'}), + })), + + html.tag('span', {id: 'image-overlay-file-size-megabytes'}, + language.$('count.fileSize.megabytes', { + megabytes: + html.tag('span', {class: 'image-overlay-file-size-count'}), + })), + ]), + }), + + html.tag('span', {id: 'image-overlay-file-size-warning'}, + language.$(capsule, 'sizeWarning')), + ]), + ])), + ])), +}; diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js index 3f300676..1d58367d 100644 --- a/src/content/dependencies/generateIntrapageDotSwitcher.js +++ b/src/content/dependencies/generateIntrapageDotSwitcher.js @@ -42,6 +42,8 @@ export default { }).map(({title, targetID}) => html.tag('a', {href: '#'}, {'data-target-id': targetID}, + {[html.onlyIfContent]: true}, + language.sanitize(title))), }), }; diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js new file mode 100644 index 00000000..0a929429 --- /dev/null +++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js @@ -0,0 +1,22 @@ +export default { + contentDependencies: ['generateListAllAdditionalFilesChunk'], + extraDependencies: ['html', 'language'], + + relations: (relation, _album, additionalFiles) => ({ + chunk: + relation('generateListAllAdditionalFilesChunk', additionalFiles), + }), + + slots: { + stringsKey: {type: 'string'}, + }, + + generate: (relations, slots, {language}) => + language.encapsulate('listingPage', slots.stringsKey, pageCapsule => + relations.chunk.slots({ + title: + language.$(pageCapsule, 'albumFiles'), + + stringsKey: slots.stringsKey, + })), +}; diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js new file mode 100644 index 00000000..a0af1375 --- /dev/null +++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js @@ -0,0 +1,51 @@ +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateListAllAdditionalFilesAlbumChunk', + 'generateListAllAdditionalFilesTrackChunk', + 'linkAlbum', + ], + + extraDependencies: ['html'], + + relations: (relation, album, property) => ({ + heading: + relation('generateContentHeading'), + + albumLink: + relation('linkAlbum', album), + + albumChunk: + relation('generateListAllAdditionalFilesAlbumChunk', + album, + album[property] ?? []), + + trackChunks: + album.tracks.map(track => + relation('generateListAllAdditionalFilesTrackChunk', + track, + track[property] ?? [])), + }), + + slots: { + stringsKey: {type: 'string'}, + }, + + generate: (relations, slots, {html}) => + html.tags([ + relations.heading.slots({ + tag: 'h3', + title: relations.albumLink, + }), + + html.tag('dl', + {[html.onlyIfContent]: true}, + + [ + relations.albumChunk.slot('stringsKey', slots.stringsKey), + + relations.trackChunks.map(trackChunk => + trackChunk.slot('stringsKey', slots.stringsKey)), + ]), + ]), +}; diff --git a/src/content/dependencies/generateListAllAdditionalFilesChunk.js b/src/content/dependencies/generateListAllAdditionalFilesChunk.js index deb8c4ea..df652efd 100644 --- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js +++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js @@ -1,90 +1,99 @@ -import {empty, stitchArrays} from '#sugar'; +import {stitchArrays} from '#sugar'; export default { + contentDependencies: ['linkAdditionalFile'], extraDependencies: ['html', 'language'], + relations: (relation, additionalFiles) => ({ + links: + additionalFiles + .map(file => file.filenames + .map(filename => relation('linkAdditionalFile', file, filename))), + }), + + data: (additionalFiles) => ({ + titles: + additionalFiles + .map(file => file.title), + + filenames: + additionalFiles + .map(file => file.filenames), + }), + slots: { title: { type: 'html', mutable: false, }, - additionalFileTitles: { - validate: v => v.strictArrayOf(v.isHTML), - }, - - additionalFileLinks: { - validate: v => v.strictArrayOf(v.strictArrayOf(v.isHTML)), - }, - - additionalFileFiles: { - validate: v => v.strictArrayOf(v.strictArrayOf(v.isString)), - }, - stringsKey: {type: 'string'}, }, - generate(slots, {html, language}) { - if (empty(slots.additionalFileLinks)) { - return html.blank(); - } + generate: (data, relations, slots, {html, language}) => + language.encapsulate('listingPage', slots.stringsKey, pageCapsule => + html.tags([ + html.tag('dt', + {[html.onlyIfSiblings]: true}, + slots.title), - return html.tags([ - html.tag('dt', slots.title), - html.tag('dd', - html.tag('ul', - stitchArrays({ - additionalFileTitle: slots.additionalFileTitles, - additionalFileLinks: slots.additionalFileLinks, - additionalFileFiles: slots.additionalFileFiles, - }).map(({ - additionalFileTitle, - additionalFileLinks, - additionalFileFiles, - }) => - language.encapsulate('listingPage', slots.stringsKey, 'file', capsule => - (additionalFileLinks.length === 1 - ? html.tag('li', - additionalFileLinks[0].slots({ - content: - language.$(capsule, { - title: additionalFileTitle, - }), - })) + html.tag('dd', + {[html.onlyIfContent]: true}, - : additionalFileLinks.length === 0 - ? html.tag('li', - language.$(capsule, 'withNoFiles', { - title: additionalFileTitle, - })) + html.tag('ul', + {[html.onlyIfContent]: true}, - : html.tag('li', {class: 'has-details'}, - html.tag('details', [ - html.tag('summary', - html.tag('span', - language.$(capsule, 'withMultipleFiles', { - title: - html.tag('b', additionalFileTitle), + stitchArrays({ + title: data.titles, + links: relations.links, + filenames: data.filenames, + }).map(({ + title, + links, + filenames, + }) => + language.encapsulate(pageCapsule, 'file', capsule => + (links.length === 1 + ? html.tag('li', + links[0].slots({ + content: + language.$(capsule, { + title: title, + }), + })) - files: - language.countAdditionalFiles( - additionalFileLinks.length, - {unit: true}), - }))), + : links.length === 0 + ? html.tag('li', + language.$(capsule, 'withNoFiles', { + title: title, + })) - html.tag('ul', - stitchArrays({ - additionalFileLink: additionalFileLinks, - additionalFileFile: additionalFileFiles, - }).map(({additionalFileLink, additionalFileFile}) => - html.tag('li', - additionalFileLink.slots({ - content: - language.$(capsule, { - title: additionalFileFile, - }), - })))), - ]))))))), - ]); - }, + : html.tag('li', {class: 'has-details'}, + html.tag('details', [ + html.tag('summary', + html.tag('span', + language.$(capsule, 'withMultipleFiles', { + title: + html.tag('b', title), + + files: + language.countAdditionalFiles( + links.length, + {unit: true}), + }))), + + html.tag('ul', + stitchArrays({ + link: links, + filename: filenames, + }).map(({link, filename}) => + html.tag('li', + link.slots({ + content: + language.$(capsule, { + title: filename, + }), + })))), + ]))))))), + ])), }; diff --git a/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js new file mode 100644 index 00000000..b2e5addf --- /dev/null +++ b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js @@ -0,0 +1,23 @@ +export default { + contentDependencies: ['generateListAllAdditionalFilesChunk', 'linkTrack'], + extraDependencies: ['html'], + + relations: (relation, track, additionalFiles) => ({ + trackLink: + relation('linkTrack', track), + + chunk: + relation('generateListAllAdditionalFilesChunk', additionalFiles), + }), + + slots: { + stringsKey: {type: 'string'}, + }, + + generate: (relations, slots) => + relations.chunk.slots({ + title: relations.trackLink, + stringsKey: slots.stringsKey, + }), +}; + diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js new file mode 100644 index 00000000..0c91ce0c --- /dev/null +++ b/src/content/dependencies/generateLyricsEntry.js @@ -0,0 +1,91 @@ +export default { + contentDependencies: ['linkArtist', 'linkExternal', 'transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, entry) => ({ + content: + relation('transformContent', entry.body), + + artistText: + relation('transformContent', entry.artistText), + + artistLinks: + entry.artists + .filter(artist => artist.name !== 'HSMusic Wiki') // smh + .map(artist => relation('linkArtist', artist)), + + sourceLinks: + entry.sourceURLs + .map(url => relation('linkExternal', url)), + + originDetails: + relation('transformContent', entry.originDetails), + }), + + data: (entry) => ({ + isWikiLyrics: + entry.isWikiLyrics, + + hasSquareBracketAnnotations: + entry.hasSquareBracketAnnotations, + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('misc.lyrics', capsule => + html.tag('div', {class: 'lyrics-entry'}, + slots.attributes, + + [ + html.tag('p', {class: 'lyrics-details'}, + {[html.onlyIfContent]: true}, + {[html.joinChildren]: html.tag('br')}, + + [ + language.$(capsule, 'source', { + [language.onlyIfOptions]: ['source'], + + source: + language.formatUnitList( + relations.sourceLinks.map(link => + link.slots({ + indicateExternal: true, + tab: 'separate', + }))), + }), + + data.isWikiLyrics && + language.$(capsule, 'contributors', { + [language.onlyIfOptions]: ['contributors'], + + contributors: + (html.isBlank(relations.artistText) + ? language.formatUnitList(relations.artistLinks) + : relations.artistText.slot('mode', 'inline')), + }), + + // This check is doubled up only for clarity: entries are coded + // in data so that `hasSquareBracketAnnotations` is only true + // if `isWikiLyrics` is also true. + data.isWikiLyrics && + data.hasSquareBracketAnnotations && + language.$(capsule, 'squareBracketAnnotations'), + ]), + + html.tag('p', {class: 'origin-details'}, + {[html.onlyIfContent]: true}, + + relations.originDetails.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + })), + + relations.content.slot('mode', 'lyrics'), + ])), +}; diff --git a/src/content/dependencies/generateLyricsSection.js b/src/content/dependencies/generateLyricsSection.js new file mode 100644 index 00000000..f6b719a9 --- /dev/null +++ b/src/content/dependencies/generateLyricsSection.js @@ -0,0 +1,81 @@ +import {stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateContentHeading', + 'generateIntrapageDotSwitcher', + 'generateLyricsEntry', + 'transformContent', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, entries) => ({ + heading: + relation('generateContentHeading'), + + switcher: + relation('generateIntrapageDotSwitcher'), + + entries: + entries + .map(entry => relation('generateLyricsEntry', entry)), + + annotations: + entries + .map(entry => entry.annotation) + .map(annotation => relation('transformContent', annotation)), + }), + + data: (entries) => ({ + ids: + Array.from( + {length: entries.length}, + (_, index) => 'lyrics-entry-' + index), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('releaseInfo.lyrics', capsule => + html.tags([ + relations.heading + .slots({ + attributes: {id: 'lyrics'}, + title: language.$(capsule), + }), + + html.tag('p', {class: 'lyrics-switcher'}, + {[html.onlyIfContent]: true}, + + language.$(capsule, 'switcher', { + [language.onlyIfOptions]: ['entries'], + + entries: + relations.switcher.slots({ + initialOptionIndex: 0, + + titles: + relations.annotations.map(annotation => + annotation.slots({ + mode: 'inline', + textOnly: true, + })), + + targetIDs: + data.ids, + }), + })), + + stitchArrays({ + entry: relations.entries, + id: data.ids, + }).map(({entry, id}, index) => + entry.slots({ + attributes: [ + {id}, + + index >= 1 && + {style: 'display: none'}, + ], + })), + ])), +}; diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index fa2cdc18..0326f415 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -1,13 +1,16 @@ import {openAggregate} from '#aggregate'; -import {empty, repeat} from '#sugar'; +import {atOffset, empty, repeat} from '#sugar'; export default { contentDependencies: [ - 'generateColorStyleRules', + 'generateColorStyleTag', 'generateFooterLocalizationLinks', + 'generateImageOverlay', 'generatePageSidebar', 'generateSearchSidebarBox', + 'generateStaticURLStyleTag', 'generateStickyHeadingContainer', + 'generateWikiWallpaperStyleTag', 'transformContent', ], @@ -57,8 +60,17 @@ export default { relation('transformContent', sprawl.footerContent); } - relations.colorStyleRules = - relation('generateColorStyleRules'); + relations.colorStyleTag = + relation('generateColorStyleTag'); + + relations.staticURLStyleTag = + relation('generateStaticURLStyleTag'); + + relations.wikiWallpaperStyleTag = + relation('generateWikiWallpaperStyleTag'); + + relations.imageOverlay = + relation('generateImageOverlay'); return relations; }, @@ -89,7 +101,7 @@ export default { mutable: false, }, - cover: { + artworkColumnContent: { type: 'html', mutable: false, }, @@ -103,9 +115,9 @@ export default { color: {validate: v => v.isColor}, - styleRules: { - validate: v => v.sparseArrayOf(v.isHTML), - default: [], + styleTags: { + type: 'html', + mutable: false, }, mainClasses: { @@ -253,6 +265,34 @@ export default { 'oembed.json' : null); + const canonicalHref = + (data.canonicalBase + ? data.canonicalBase + pagePathStringFromRoot + : null); + + const primaryCover = (() => { + const apparentFirst = tag => html.smooth(tag).content[0]; + + const maybeTemplate = + apparentFirst(slots.artworkColumnContent); + + if (!maybeTemplate) return null; + + const maybeTemplateContent = + html.resolve(maybeTemplate, {normalize: 'tag'}); + + const maybeCoverArtwork = + apparentFirst(maybeTemplateContent); + + if (!maybeCoverArtwork) return null; + + if (maybeCoverArtwork.attributes.has('class', 'cover-artwork')) { + return maybeTemplate; + } else { + return null; + } + })(); + const titleContentsHTML = (html.isBlank(slots.title) ? null @@ -267,10 +307,16 @@ export default { (html.isBlank(slots.title) ? null : slots.headingMode === 'sticky' - ? relations.stickyHeadingContainer.slots({ - title: titleContentsHTML, - cover: slots.cover, - }) + ? [ + relations.stickyHeadingContainer.slots({ + title: titleContentsHTML, + cover: primaryCover, + }), + + relations.stickyHeadingContainer.clone().slots({ + rootAttributes: {inert: true}, + }), + ] : html.tag('h1', titleContentsHTML)); // TODO: There could be neat interactions with the sticky heading here, @@ -301,9 +347,11 @@ export default { [ titleHTML, - html.tag('div', {id: 'cover-art-container'}, + html.tag('div', {id: 'artwork-column'}, {[html.onlyIfContent]: true}, - slots.cover), + {class: 'isolate-tooltip-z-indexing'}, + + slots.artworkColumnContent), subtitleHTML, @@ -346,7 +394,7 @@ export default { slots.navLinks ?.filter(Boolean) - ?.map((cur, i) => { + ?.map((cur, i, entries) => { let content; if (cur.html) { @@ -380,20 +428,13 @@ export default { (slots.navLinkStyle === 'hierarchical' && i === slots.navLinks.length - 1); - return ( + const navLink = html.tag('span', {class: 'nav-link'}, showAsCurrent && {class: 'current'}, [ html.tag('span', {class: 'nav-link-content'}, - // Use inline-block styling on the content span, - // rather than wrapping the whole nav-link in a proper - // blockwrap, so that if the content spans multiple - // lines, it'll kick the accent down beneath it. - i > 0 && - {class: 'blockwrap'}, - content), html.tag('span', {class: 'nav-link-accent'}, @@ -404,7 +445,25 @@ export default { [language.onlyIfOptions]: ['links'], links: cur.accent, })), - ])); + ]); + + if (slots.navLinkStyle === 'index') { + return navLink; + } + + const prev = + atOffset(entries, i, -1); + + if ( + prev && + prev.releaseRestToWrapTogether !== true && + (prev.releaseRestToWrapTogether === false || + prev.auto === 'home') + ) { + return navLink; + } else { + return html.metatag('blockwrap', navLink); + } })), html.tag('div', {class: 'nav-bottom-row'}, @@ -445,14 +504,15 @@ export default { let showingSidebarLeft; let showingSidebarRight; + let sidebarsInContentColumn = false; const leftSidebar = getSidebar('leftSidebar', 'sidebar-left', willShowSearch); const rightSidebar = getSidebar('rightSidebar', 'sidebar-right', false); if (willShowSearch) { if (html.isBlank(leftSidebar)) { - leftSidebar.setSlot('initiallyHidden', true); - showingSidebarLeft = false; + sidebarsInContentColumn = true; + showingSidebarLeft = true; } leftSidebar.setSlot( @@ -529,59 +589,33 @@ export default { {id: 'additional-files', string: 'additionalFiles'}, {id: 'commentary', string: 'commentary'}, {id: 'artist-commentary', string: 'artistCommentary'}, - {id: 'credit-sources', string: 'creditSources'}, + {id: 'crediting-sources', string: 'creditingSources'}, + {id: 'referencing-sources', string: 'referencingSources'}, ])), ]); - const imageOverlayHTML = html.tag('div', {id: 'image-overlay-container'}, - html.tag('div', {id: 'image-overlay-content-container'}, [ - html.tag('a', {id: 'image-overlay-image-container'}, [ - html.tag('img', {id: 'image-overlay-image'}), - html.tag('img', {id: 'image-overlay-image-thumb'}), - ]), - - html.tag('div', {id: 'image-overlay-action-container'}, - language.encapsulate('releaseInfo.viewOriginalFile', capsule => [ - html.tag('div', {id: 'image-overlay-action-content-without-size'}, - language.$(capsule, { - link: html.tag('a', {class: 'image-overlay-view-original'}, - language.$(capsule, 'link')), - })), + const slottedStyleTags = + html.smush(slots.styleTags); - html.tag('div', {id: 'image-overlay-action-content-with-size'}, [ - language.$(capsule, 'withSize', { - link: - html.tag('a', {class: 'image-overlay-view-original'}, - language.$(capsule, 'link')), + const slottedWallpaperStyleTag = + slottedStyleTags.content + .find(tag => tag.attributes.has('class', 'wallpaper-style')); - size: - html.tag('span', - {[html.joinChildren]: ''}, - [ - html.tag('span', {id: 'image-overlay-file-size-kilobytes'}, - language.$('count.fileSize.kilobytes', { - kilobytes: - html.tag('span', {class: 'image-overlay-file-size-count'}), - })), + const fallbackWallpaperStyleTag = + (slottedWallpaperStyleTag + ? html.blank() + : relations.wikiWallpaperStyleTag); - html.tag('span', {id: 'image-overlay-file-size-megabytes'}, - language.$('count.fileSize.megabytes', { - megabytes: - html.tag('span', {class: 'image-overlay-file-size-count'}), - })), - ]), - }), - - html.tag('span', {id: 'image-overlay-file-size-warning'}, - language.$(capsule, 'sizeWarning')), - ]), - ])), - ])); + const usingWallpaperStyleTag = + (slottedWallpaperStyleTag + ? slottedWallpaperStyleTag + : html.resolve(fallbackWallpaperStyleTag, {normalize: 'tag'})); const numWallpaperParts = - html.resolve(slots.styleRules, {normalize: 'string'}) - .match(/\.wallpaper-part:nth-child/g) - ?.length ?? 0; + (usingWallpaperStyleTag && + usingWallpaperStyleTag.attributes.has('data-wallpaper-mode', 'parts') + ? parseInt(usingWallpaperStyleTag.attributes.get('data-num-wallpaper-parts')) + : 0); const wallpaperPartsHTML = html.tag('div', {class: 'wallpaper-parts'}, @@ -689,13 +723,15 @@ export default { Object.entries(meta) .filter(([key, value]) => value) .map(([key, value]) => html.tag('meta', {[key]: value}))), + */ - canonical && + canonicalHref && html.tag('link', { rel: 'canonical', - href: canonical, + href: canonicalHref, }), + /* ...( localizedCanonical .map(({lang, href}) => html.tag('link', { @@ -703,7 +739,6 @@ export default { hreflang: lang, href, }))), - */ hasSocialEmbed && @@ -722,11 +757,14 @@ export default { href: to('staticCSS.path', 'site.css'), }), - html.tag('style', [ - relations.colorStyleRules - .slot('color', slots.color ?? data.wikiColor), - slots.styleRules, - ]), + relations.colorStyleTag + .slot('color', slots.color ?? data.wikiColor), + + relations.staticURLStyleTag, + + fallbackWallpaperStyleTag, + + slottedStyleTags, html.tag('script', { src: to('staticLib.path', 'chroma-js/chroma.min.js'), @@ -755,13 +793,16 @@ export default { showingSidebarRight && {class: 'showing-sidebar-right'}, + sidebarsInContentColumn && + {class: 'sidebars-in-content-column'}, + [ skippersHTML, layoutHTML, ]), // infoCardHTML, - imageOverlayHTML, + relations.imageOverlay, ]), ]) ]).toString(); diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js index 05b1d469..7974c707 100644 --- a/src/content/dependencies/generatePageSidebarConjoinedBox.js +++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js @@ -32,11 +32,7 @@ export default { .map((content, index, {length}) => [ content, index < length - 1 && - html.tag('hr', { - style: - `border-color: var(--primary-color); ` + - `border-style: none none dotted none`, - }), + html.tag('hr', {class: 'cute'}), ]), }), }; diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js index 3d21b15d..83451eca 100644 --- a/src/content/dependencies/generateReferencedArtworksPage.js +++ b/src/content/dependencies/generateReferencedArtworksPage.js @@ -1,67 +1,55 @@ -import {stitchArrays} from '#sugar'; - export default { contentDependencies: [ + 'generateCoverArtwork', 'generateCoverGrid', 'generatePageLayout', 'image', - 'linkAlbum', - 'linkTrack', + 'linkAnythingMan', ], extraDependencies: ['html', 'language'], - relations: (relation, referencedArtworks) => ({ + relations: (relation, artwork) => ({ layout: relation('generatePageLayout'), + cover: + relation('generateCoverArtwork', artwork), + coverGrid: relation('generateCoverGrid'), links: - referencedArtworks.map(({thing}) => - (thing.album - ? relation('linkTrack', thing) - : relation('linkAlbum', thing))), + artwork.referencedArtworks.map(({artwork}) => + relation('linkAnythingMan', artwork.thing)), images: - referencedArtworks.map(({thing}) => - relation('image', thing.artTags)), + artwork.referencedArtworks.map(({artwork}) => + relation('image', artwork)), }), - data: (referencedArtworks) => ({ + data: (artwork) => ({ + color: + artwork.thing.color, + count: - referencedArtworks.length, + artwork.referencedArtworks.length, names: - referencedArtworks - .map(({thing}) => thing.name), - - paths: - referencedArtworks - .map(({thing}) => - (thing.album - ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension] - : ['media.albumCover', thing.directory, thing.coverArtFileExtension])), - - dimensions: - referencedArtworks - .map(({thing}) => thing.coverArtDimensions), + artwork.referencedArtworks + .map(({artwork}) => artwork.thing.name), coverArtistNames: - referencedArtworks - .map(({thing}) => - thing.coverArtistContribs + artwork.referencedArtworks + .map(({artwork}) => + artwork.artistContribs .map(contrib => contrib.artist.name)), }), slots: { - color: {validate: v => v.isColor}, - - styleRules: {type: 'html', mutable: false}, + styleTags: {type: 'html', mutable: false}, title: {type: 'html', mutable: false}, - cover: {type: 'html', mutable: true}, navLinks: {validate: v => v.isArray}, navBottomRowContent: {type: 'html', mutable: false}, @@ -73,11 +61,13 @@ export default { title: slots.title, subtitle: language.$(pageCapsule, 'subtitle'), - color: slots.color, - styleRules: slots.styleRules, + color: data.color, + styleTags: slots.styleTags, - cover: - slots.cover.slot('details', 'artists'), + artworkColumnContent: + relations.cover.slots({ + showArtistDetails: true, + }), mainClasses: ['top-index'], mainContent: [ @@ -91,19 +81,9 @@ export default { relations.coverGrid.slots({ links: relations.links, + images: relations.images, names: data.names, - images: - stitchArrays({ - image: relations.images, - path: data.paths, - dimensions: data.dimensions, - }).map(({image, path, dimensions}) => - image.slots({ - path, - dimensions, - })), - info: data.coverArtistNames.map(names => language.$('misc.coverGrid.details.coverArtists', { diff --git a/src/content/dependencies/generateReferencingArtworksPage.js b/src/content/dependencies/generateReferencingArtworksPage.js index 2fe2e93d..e97b01f8 100644 --- a/src/content/dependencies/generateReferencingArtworksPage.js +++ b/src/content/dependencies/generateReferencingArtworksPage.js @@ -1,67 +1,55 @@ -import {stitchArrays} from '#sugar'; - export default { contentDependencies: [ + 'generateCoverArtwork', 'generateCoverGrid', 'generatePageLayout', 'image', - 'linkAlbum', - 'linkTrack', + 'linkAnythingMan', ], extraDependencies: ['html', 'language'], - relations: (relation, referencingArtworks) => ({ + relations: (relation, artwork) => ({ layout: relation('generatePageLayout'), + cover: + relation('generateCoverArtwork', artwork), + coverGrid: relation('generateCoverGrid'), links: - referencingArtworks.map(({thing}) => - (thing.album - ? relation('linkTrack', thing) - : relation('linkAlbum', thing))), + artwork.referencedByArtworks.map(({artwork}) => + relation('linkAnythingMan', artwork.thing)), images: - referencingArtworks.map(({thing}) => - relation('image', thing.artTags)), + artwork.referencedByArtworks.map(({artwork}) => + relation('image', artwork)), }), - data: (referencingArtworks) => ({ + data: (artwork) => ({ + color: + artwork.thing.color, + count: - referencingArtworks.length, + artwork.referencedByArtworks.length, names: - referencingArtworks - .map(({thing}) => thing.name), - - paths: - referencingArtworks - .map(({thing}) => - (thing.album - ? ['media.trackCover', thing.album.directory, thing.directory, thing.coverArtFileExtension] - : ['media.albumCover', thing.directory, thing.coverArtFileExtension])), - - dimensions: - referencingArtworks - .map(({thing}) => thing.coverArtDimensions), + artwork.referencedByArtworks + .map(({artwork}) => artwork.thing.name), coverArtistNames: - referencingArtworks - .map(({thing}) => - thing.coverArtistContribs + artwork.referencedByArtworks + .map(({artwork}) => + artwork.artistContribs .map(contrib => contrib.artist.name)), }), slots: { - color: {validate: v => v.isColor}, - - styleRules: {type: 'html', mutable: false}, + styleTags: {type: 'html', mutable: false}, title: {type: 'html', mutable: false}, - cover: {type: 'html', mutable: true}, navLinks: {validate: v => v.isArray}, navBottomRowContent: {type: 'html', mutable: false}, @@ -73,11 +61,13 @@ export default { title: slots.title, subtitle: language.$(pageCapsule, 'subtitle'), - color: slots.color, - styleRules: slots.styleRules, + color: data.color, + styleTags: slots.styleTags, - cover: - slots.cover.slot('details', 'artists'), + artworkColumnContent: + relations.cover.slots({ + showArtistDetails: true, + }), mainClasses: ['top-index'], mainContent: [ @@ -91,19 +81,9 @@ export default { relations.coverGrid.slots({ links: relations.links, + images: relations.images, names: data.names, - images: - stitchArrays({ - image: relations.images, - path: data.paths, - dimensions: data.dimensions, - }).map(({image, path, dimensions}) => - image.slots({ - path, - dimensions, - })), - info: data.coverArtistNames.map(names => language.$('misc.coverGrid.details.coverArtists', { diff --git a/src/content/dependencies/generateReleaseInfoListenLine.js b/src/content/dependencies/generateReleaseInfoListenLine.js new file mode 100644 index 00000000..f2a6dd29 --- /dev/null +++ b/src/content/dependencies/generateReleaseInfoListenLine.js @@ -0,0 +1,150 @@ +import {isExternalLinkContext} from '#external-links'; +import {empty, stitchArrays, unique} from '#sugar'; + +function getReleaseContext(urlString, { + _artistURLs, + albumArtistURLs, +}) { + const composerBandcampDomains = + albumArtistURLs + .filter(url => url.hostname.endsWith('.bandcamp.com')) + .map(url => url.hostname); + + const url = new URL(urlString); + + if (url.hostname === 'homestuck.bandcamp.com') { + return 'officialRelease'; + } + + if (composerBandcampDomains.includes(url.hostname)) { + return 'composerRelease'; + } + + return null; +} + +export default { + contentDependencies: ['linkExternal'], + extraDependencies: ['html', 'language'], + + query(thing) { + const query = {}; + + query.album = + (thing.album + ? thing.album + : thing); + + query.artists = + thing.artistContribs + .map(contrib => contrib.artist); + + query.artistGroups = + query.artists + .flatMap(artist => artist.closelyLinkedGroups) + .map(({group}) => group); + + query.albumArtists = + query.album.artistContribs + .map(contrib => contrib.artist); + + query.albumArtistGroups = + query.albumArtists + .flatMap(artist => artist.closelyLinkedGroups) + .map(({group}) => group); + + return query; + }, + + relations: (relation, _query, thing) => ({ + links: + thing.urls.map(url => relation('linkExternal', url)), + }), + + data(query, thing) { + const data = {}; + + data.name = thing.name; + + const artistURLs = + unique([ + ...query.artists.flatMap(artist => artist.urls), + ...query.artistGroups.flatMap(group => group.urls), + ]).map(url => new URL(url)); + + const albumArtistURLs = + unique([ + ...query.albumArtists.flatMap(artist => artist.urls), + ...query.albumArtistGroups.flatMap(group => group.urls), + ]).map(url => new URL(url)); + + const boundGetReleaseContext = urlString => + getReleaseContext(urlString, { + artistURLs, + albumArtistURLs, + }); + + let releaseContexts = + thing.urls.map(boundGetReleaseContext); + + const albumReleaseContexts = + query.album.urls.map(boundGetReleaseContext); + + const presentReleaseContexts = + unique(releaseContexts.filter(Boolean)); + + const presentAlbumReleaseContexts = + unique(albumReleaseContexts.filter(Boolean)); + + if ( + presentReleaseContexts.length <= 1 && + presentAlbumReleaseContexts.length <= 1 + ) { + releaseContexts = + thing.urls.map(() => null); + } + + data.releaseContexts = releaseContexts; + + return data; + }, + + slots: { + visibleWithoutLinks: { + type: 'boolean', + default: false, + }, + + context: { + validate: () => isExternalLinkContext, + default: 'generic', + }, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('releaseInfo.listenOn', capsule => + (empty(relations.links) && slots.visibleWithoutLinks + ? language.$(capsule, 'noLinks', { + name: + html.tag('i', data.name), + }) + + : language.$('releaseInfo.listenOn', { + [language.onlyIfOptions]: ['links'], + + links: + language.formatDisjunctionList( + stitchArrays({ + link: relations.links, + releaseContext: data.releaseContexts, + }).map(({link, releaseContext}) => + link.slot('context', [ + ... + (Array.isArray(slots.context) + ? slots.context + : [slots.context]), + + releaseContext, + ]))), + }))), +}; diff --git a/src/content/dependencies/generateSearchSidebarBox.js b/src/content/dependencies/generateSearchSidebarBox.js index 188a678f..308a1105 100644 --- a/src/content/dependencies/generateSearchSidebarBox.js +++ b/src/content/dependencies/generateSearchSidebarBox.js @@ -57,6 +57,26 @@ export default { html.tag('template', {class: 'wiki-search-tag-result-kind-string'}, language.$(capsule, 'artTag')), ]), + + language.encapsulate(capsule, 'resultFilter', capsule => [ + html.tag('template', {class: 'wiki-search-album-result-filter-string'}, + language.$(capsule, 'album')), + + html.tag('template', {class: 'wiki-search-artist-result-filter-string'}, + language.$(capsule, 'artist')), + + html.tag('template', {class: 'wiki-search-flash-result-filter-string'}, + language.$(capsule, 'flash')), + + html.tag('template', {class: 'wiki-search-group-result-filter-string'}, + language.$(capsule, 'group')), + + html.tag('template', {class: 'wiki-search-track-result-filter-string'}, + language.$(capsule, 'track')), + + html.tag('template', {class: 'wiki-search-tag-result-filter-string'}, + language.$(capsule, 'artTag')), + ]), ], })), }; diff --git a/src/content/dependencies/generateSocialEmbed.js b/src/content/dependencies/generateSocialEmbed.js index 85a0f4d3..513ea518 100644 --- a/src/content/dependencies/generateSocialEmbed.js +++ b/src/content/dependencies/generateSocialEmbed.js @@ -1,5 +1,5 @@ export default { - extraDependencies: ['html', 'language', 'wikiData'], + extraDependencies: ['absoluteTo', 'html', 'language', 'wikiData'], sprawl({wikiInfo}) { return { @@ -23,10 +23,10 @@ export default { headingContent: {type: 'string'}, headingLink: {type: 'string'}, - imagePath: {type: 'string'}, + imagePath: {validate: v => v.strictArrayOf(v.isString)}, }, - generate(data, slots, {html, language}) { + generate(data, slots, {absoluteTo, html, language}) { switch (slots.mode) { case 'html': return html.tags([ @@ -40,7 +40,10 @@ export default { }), slots.imagePath && - html.tag('meta', {property: 'og:image', content: slots.imagePath}), + html.tag('meta', { + property: 'og:image', + content: absoluteTo(...slots.imagePath), + }), ]); case 'json': diff --git a/src/content/dependencies/generateStaticPage.js b/src/content/dependencies/generateStaticPage.js index 226152c7..931352b4 100644 --- a/src/content/dependencies/generateStaticPage.js +++ b/src/content/dependencies/generateStaticPage.js @@ -23,17 +23,19 @@ export default { title: data.name, headingMode: 'sticky', - styleRules: - (data.stylesheet - ? [data.stylesheet] - : []), + styleTags: [ + html.tag('style', {class: 'static-page-style'}, + {[html.onlyIfContent]: true}, + data.stylesheet), + ], mainClasses: ['long-content'], mainContent: [ relations.content, - data.script && - html.tag('script', data.script), + html.tag('script', + {[html.onlyIfContent]: true}, + data.script), ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateStaticURLStyleTag.js b/src/content/dependencies/generateStaticURLStyleTag.js new file mode 100644 index 00000000..b927e5d6 --- /dev/null +++ b/src/content/dependencies/generateStaticURLStyleTag.js @@ -0,0 +1,23 @@ +export default { + contentDependencies: ['generateStyleTag'], + extraDependencies: ['to'], + + relations: (relation) => ({ + styleTag: + relation('generateStyleTag'), + }), + + generate: (relations, {to}) => + relations.styleTag.slots({ + attributes: {class: 'static-url-style'}, + + rules: [ + { + select: '.image-media-link::after', + declare: [ + `mask-image: url("${to('staticMisc.path', 'image.svg')}");` + ], + }, + ], + }), +}; diff --git a/src/content/dependencies/generateStickyHeadingContainer.js b/src/content/dependencies/generateStickyHeadingContainer.js index 7cfbcf50..ec3062a3 100644 --- a/src/content/dependencies/generateStickyHeadingContainer.js +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -2,6 +2,11 @@ export default { extraDependencies: ['html'], slots: { + rootAttributes: { + type: 'attributes', + mutable: false, + }, + title: { type: 'html', mutable: false, @@ -13,27 +18,42 @@ export default { }, }, - generate: (slots, {html}) => - html.tag('div', {class: 'content-sticky-heading-container'}, + generate: (slots, {html}) => html.tags([ + html.tag('div', {class: 'content-sticky-heading-root'}, + slots.rootAttributes, + !html.isBlank(slots.cover) && {class: 'has-cover'}, - [ - html.tag('div', {class: 'content-sticky-heading-row'}, [ - html.tag('h1', slots.title), + html.tag('div', {class: 'content-sticky-heading-anchor'}, + html.tag('div', {class: 'content-sticky-heading-container'}, + !html.isBlank(slots.cover) && + {class: 'has-cover'}, + + [ + html.tag('div', {class: 'content-sticky-heading-row'}, [ + html.tag('h1', [ + html.tag('span', {class: 'reference-collapsed-heading'}, + {inert: true}, + + slots.title.clone()), + + slots.title, + ]), - html.tag('div', {class: 'content-sticky-heading-cover-container'}, - {[html.onlyIfContent]: true}, + html.tag('div', {class: 'content-sticky-heading-cover-container'}, + {[html.onlyIfContent]: true}, - html.tag('div', {class: 'content-sticky-heading-cover'}, - {[html.onlyIfContent]: true}, + html.tag('div', {class: 'content-sticky-heading-cover'}, + {[html.onlyIfContent]: true}, - (html.isBlank(slots.cover) - ? html.blank() - : slots.cover.slot('mode', 'thumbnail')))), - ]), + (html.isBlank(slots.cover) + ? html.blank() + : slots.cover.slot('mode', 'thumbnail')))), + ]), - html.tag('div', {class: 'content-sticky-subheading-row'}, - html.tag('h2', {class: 'content-sticky-subheading'})), - ]), + html.tag('div', {class: 'content-sticky-subheading-row'}, + html.tag('h2', {class: 'content-sticky-subheading'})), + ]))), + ]), }; diff --git a/src/content/dependencies/generateStyleTag.js b/src/content/dependencies/generateStyleTag.js new file mode 100644 index 00000000..5ed09ae5 --- /dev/null +++ b/src/content/dependencies/generateStyleTag.js @@ -0,0 +1,48 @@ +import {empty} from '#sugar'; + +const indent = text => + text + .split('\n') + .map(line => ' '.repeat(4) + line) + .join('\n'); + +export default { + extraDependencies: ['html'], + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + rules: { + validate: v => + v.looseArrayOf( + v.validateProperties({ + select: v.isString, + declare: v.looseArrayOf(v.isString), + })), + }, + }, + + generate: (slots, {html}) => + html.tag('style', slots.attributes, + {[html.onlyIfContent]: true}, + + slots.rules + .filter(Boolean) + + .map(rule => ({ + select: rule.select, + declare: rule.declare.filter(Boolean), + })) + + .filter(rule => !empty(rule.declare)) + + .map(rule => + `${rule.select} {\n` + + indent(rule.declare.join('\n')) + '\n' + + `}`) + + .join('\n\n')), +}; diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js new file mode 100644 index 00000000..c7e7f0f8 --- /dev/null +++ b/src/content/dependencies/generateTrackArtistCommentarySection.js @@ -0,0 +1,147 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: [ + 'generateContentContentHeading', + 'generateCommentaryEntry', + 'linkAlbum', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + query: (track) => ({ + otherSecondaryReleasesWithCommentary: + track.otherReleases + .filter(track => !track.isMainRelease) + .filter(track => !empty(track.commentary)), + }), + + relations: (relation, query, track) => ({ + contentContentHeading: + relation('generateContentContentHeading', track), + + mainReleaseTrackLink: + (track.isSecondaryRelease + ? relation('linkTrack', track.mainReleaseTrack) + : null), + + mainReleaseArtistCommentaryEntries: + (track.isSecondaryRelease + ? track.mainReleaseTrack.commentary + .map(entry => relation('generateCommentaryEntry', entry)) + : null), + + thisReleaseAlbumLink: + relation('linkAlbum', track.album), + + artistCommentaryEntries: + track.commentary + .map(entry => relation('generateCommentaryEntry', entry)), + + otherReleaseTrackLinks: + query.otherSecondaryReleasesWithCommentary + .map(track => relation('linkTrack', track)), + }), + + data: (query, track) => ({ + name: + track.name, + + isSecondaryRelease: + track.isSecondaryRelease, + + mainReleaseName: + (track.isSecondaryRelease + ? track.mainReleaseTrack.name + : null), + + mainReleaseAlbumName: + (track.isSecondaryRelease + ? track.mainReleaseTrack.album.name + : null), + + mainReleaseAlbumColor: + (track.isSecondaryRelease + ? track.mainReleaseTrack.album.color + : null), + + otherReleaseAlbumNames: + query.otherSecondaryReleasesWithCommentary + .map(track => track.album.name), + + otherReleaseAlbumColors: + query.otherSecondaryReleasesWithCommentary + .map(track => track.album.color), + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('misc.artistCommentary', capsule => + html.tags([ + relations.contentContentHeading.slots({ + attributes: {id: 'artist-commentary'}, + string: 'misc.artistCommentary', + }), + + relations.artistCommentaryEntries, + + data.isSecondaryRelease && + html.tag('div', {class: 'inherited-commentary-section'}, + {[html.onlyIfContent]: true}, + + [ + html.tag('p', {class: ['drop', 'commentary-drop']}, + {[html.onlyIfSiblings]: true}, + + language.encapsulate(capsule, 'info.fromMainRelease', workingCapsule => { + const workingOptions = {}; + + workingOptions.album = + relations.mainReleaseTrackLink.slots({ + content: + data.mainReleaseAlbumName, + + color: + data.mainReleaseAlbumColor, + }); + + if (data.name !== data.mainReleaseName) { + workingCapsule += '.namedDifferently'; + workingOptions.name = + html.tag('i', data.mainReleaseName); + } + + return language.$(workingCapsule, workingOptions); + })), + + relations.mainReleaseArtistCommentaryEntries, + ]), + + html.tag('p', {class: ['drop', 'commentary-drop']}, + {[html.onlyIfContent]: true}, + + language.encapsulate(capsule, 'info.seeSpecificReleases', workingCapsule => { + const workingOptions = {}; + + workingOptions[language.onlyIfOptions] = ['albums']; + + workingOptions.albums = + language.formatUnitList( + stitchArrays({ + trackLink: relations.otherReleaseTrackLinks, + albumName: data.otherReleaseAlbumNames, + albumColor: data.otherReleaseAlbumColors, + }).map(({trackLink, albumName, albumColor}) => + trackLink.slots({ + content: language.sanitize(albumName), + color: albumColor, + }))); + + if (!html.isBlank(relations.artistCommentaryEntries)) { + workingCapsule += '.withMainCommentary'; + } + + return language.$(workingCapsule, workingOptions); + })), + ])), +}; diff --git a/src/content/dependencies/generateTrackArtworkColumn.js b/src/content/dependencies/generateTrackArtworkColumn.js new file mode 100644 index 00000000..f06d735b --- /dev/null +++ b/src/content/dependencies/generateTrackArtworkColumn.js @@ -0,0 +1,33 @@ +export default { + contentDependencies: ['generateCoverArtwork'], + extraDependencies: ['html'], + + relations: (relation, track) => ({ + albumCover: + (!track.hasUniqueCoverArt && track.album.hasCoverArt + ? relation('generateCoverArtwork', track.album.coverArtworks[0]) + : null), + + trackCovers: + (track.hasUniqueCoverArt + ? track.trackArtworks.map(artwork => + relation('generateCoverArtwork', artwork)) + : []), + }), + + generate: (relations, {html}) => + html.tags([ + relations.albumCover?.slots({ + showOriginDetails: true, + showArtTagDetails: true, + showReferenceDetails: true, + }), + + relations.trackCovers.map(cover => + cover.slots({ + showOriginDetails: true, + showArtTagDetails: true, + showReferenceDetails: true, + })), + ]), +}; diff --git a/src/content/dependencies/generateTrackCoverArtwork.js b/src/content/dependencies/generateTrackCoverArtwork.js deleted file mode 100644 index 9153e2fc..00000000 --- a/src/content/dependencies/generateTrackCoverArtwork.js +++ /dev/null @@ -1,143 +0,0 @@ -export default { - contentDependencies: [ - 'generateCoverArtwork', - 'generateCoverArtworkArtTagDetails', - 'generateCoverArtworkArtistDetails', - 'generateCoverArtworkReferenceDetails', - 'image', - 'linkAlbum', - 'linkTrackReferencedArtworks', - 'linkTrackReferencingArtworks', - ], - - extraDependencies: ['html', 'language'], - - query: (track) => ({ - artTags: - (track.hasUniqueCoverArt - ? track.artTags - : track.album.artTags), - - coverArtistContribs: - (track.hasUniqueCoverArt - ? track.coverArtistContribs - : track.album.coverArtistContribs), - }), - - relations: (relation, query, track) => ({ - coverArtwork: - relation('generateCoverArtwork'), - - image: - relation('image'), - - artTagDetails: - relation('generateCoverArtworkArtTagDetails', - query.artTags), - - artistDetails: - relation('generateCoverArtworkArtistDetails', - query.coverArtistContribs), - - referenceDetails: - relation('generateCoverArtworkReferenceDetails', - track.referencedArtworks, - track.referencedByArtworks), - - referencedArtworksLink: - relation('linkTrackReferencedArtworks', track), - - referencingArtworksLink: - relation('linkTrackReferencingArtworks', track), - - albumLink: - relation('linkAlbum', track.album), - }), - - data: (query, track) => ({ - path: - (track.hasUniqueCoverArt - ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] - : ['media.albumCover', track.album.directory, track.album.coverArtFileExtension]), - - color: - track.color, - - dimensions: - (track.hasUniqueCoverArt - ? track.coverArtDimensions - : track.album.coverArtDimensions), - - nonUnique: - !track.hasUniqueCoverArt, - - warnings: - query.artTags - .filter(tag => tag.isContentWarning) - .map(tag => tag.name), - }), - - slots: { - mode: {type: 'string'}, - - details: { - validate: v => v.is('tags', 'artists'), - default: 'tags', - }, - - showReferenceLinks: { - type: 'boolean', - default: false, - }, - - showNonUniqueLine: { - type: 'boolean', - default: false, - }, - }, - - generate: (data, relations, slots, {html, language}) => - relations.coverArtwork.slots({ - mode: slots.mode, - - image: - relations.image.slots({ - path: data.path, - color: data.color, - alt: language.$('misc.alt.trackCover'), - }), - - dimensions: data.dimensions, - warnings: data.warnings, - - details: [ - slots.details === 'tags' && - relations.artTagDetails, - - slots.details === 'artists'&& - relations.artistDetails, - - slots.showReferenceLinks && - relations.referenceDetails.slots({ - referencedLink: - relations.referencedArtworksLink, - - referencingLink: - relations.referencingArtworksLink, - }), - - slots.showNonUniqueLine && - data.nonUnique && - html.tag('p', {class: 'image-details'}, - {class: 'non-unique-details'}, - - language.$('misc.trackArtFromAlbum', { - album: - relations.albumLink.slots({ - color: false, - }), - })), - ], - }), -}; - diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 4540b79c..6c16ce27 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -1,16 +1,19 @@ export default { contentDependencies: [ + 'generateAdditionalFilesList', 'generateAdditionalNamesBox', - 'generateAlbumAdditionalFilesList', 'generateAlbumNavAccent', 'generateAlbumSecondaryNav', 'generateAlbumSidebar', - 'generateAlbumStyleRules', - 'generateCommentarySection', + 'generateAlbumStyleTags', + 'generateCommentaryEntry', + 'generateContentContentHeading', 'generateContentHeading', 'generateContributionList', + 'generateLyricsSection', 'generatePageLayout', - 'generateTrackCoverArtwork', + 'generateTrackArtistCommentarySection', + 'generateTrackArtworkColumn', 'generateTrackInfoPageFeaturedByFlashesList', 'generateTrackInfoPageOtherReleasesList', 'generateTrackList', @@ -23,19 +26,21 @@ export default { 'transformContent', ], - extraDependencies: ['html', 'language', 'wikiData'], + extraDependencies: ['html', 'language'], - sprawl: ({wikiInfo}) => ({ - divideTrackListsByGroups: - wikiInfo.divideTrackListsByGroups, + query: (track) => ({ + mainReleaseTrack: + (track.isMainRelease + ? track + : track.mainReleaseTrack), }), - relations: (relation, sprawl, track) => ({ + relations: (relation, query, track) => ({ layout: relation('generatePageLayout'), - albumStyleRules: - relation('generateAlbumStyleRules', track.album, track), + albumStyleTags: + relation('generateAlbumStyleTags', track.album, track), socialEmbed: relation('generateTrackSocialEmbed', track), @@ -55,19 +60,20 @@ export default { additionalNamesBox: relation('generateAdditionalNamesBox', track.additionalNames), - cover: - (track.hasUniqueCoverArt || track.album.hasCoverArt - ? relation('generateTrackCoverArtwork', track) - : null), + artworkColumn: + relation('generateTrackArtworkColumn', track), contentHeading: relation('generateContentHeading'), + contentContentHeading: + relation('generateContentContentHeading', track), + releaseInfo: relation('generateTrackReleaseInfo', track), otherReleasesList: - relation('generateTrackInfoPageOtherReleasesList', track), + relation('generateTrackInfoPageOtherReleasesList', track), contributorContributionList: relation('generateContributionList', track.contributorContribs), @@ -80,43 +86,40 @@ export default { referencedByTracksList: relation('generateTrackListDividedByGroups', - track.referencedByTracks, - sprawl.divideTrackListsByGroups), + query.mainReleaseTrack.referencedByTracks), sampledByTracksList: relation('generateTrackListDividedByGroups', - track.sampledByTracks, - sprawl.divideTrackListsByGroups), + query.mainReleaseTrack.sampledByTracks), flashesThatFeatureList: relation('generateTrackInfoPageFeaturedByFlashesList', track), - lyrics: - relation('transformContent', track.lyrics), + lyricsSection: + relation('generateLyricsSection', track.lyrics), sheetMusicFilesList: - relation('generateAlbumAdditionalFilesList', - track.album, - track.sheetMusicFiles), + relation('generateAdditionalFilesList', track.sheetMusicFiles), midiProjectFilesList: - relation('generateAlbumAdditionalFilesList', - track.album, - track.midiProjectFiles), + relation('generateAdditionalFilesList', track.midiProjectFiles), additionalFilesList: - relation('generateAlbumAdditionalFilesList', - track.album, - track.additionalFiles), + relation('generateAdditionalFilesList', track.additionalFiles), artistCommentarySection: - relation('generateCommentarySection', track.commentary), + relation('generateTrackArtistCommentarySection', track), + + creditingSourceEntries: + track.creditingSources + .map(entry => relation('generateCommentaryEntry', entry)), - creditSourcesSection: - relation('generateCommentarySection', track.creditSources), + referencingSourceEntries: + track.referencingSources + .map(entry => relation('generateCommentaryEntry', entry)), }), - data: (sprawl, track) => ({ + data: (_query, track) => ({ name: track.name, @@ -137,15 +140,10 @@ export default { additionalNames: relations.additionalNamesBox, color: data.color, - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, - cover: - (relations.cover - ? relations.cover.slots({ - showReferenceLinks: true, - showNonUniqueLine: true, - }) - : null), + artworkColumnContent: + relations.artworkColumn, mainContent: [ relations.releaseInfo, @@ -191,25 +189,26 @@ export default { language.$(capsule, 'link')), })), - !html.isBlank(relations.creditSourcesSection) && - language.encapsulate(capsule, 'readCreditSources', capsule => + !html.isBlank(relations.creditingSourceEntries) && + language.encapsulate(capsule, 'readCreditingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#credit-sources'}, + {href: '#crediting-sources'}, language.$(capsule, 'link')), })), - ])), - html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'also-released-as'}, - title: language.$('releaseInfo.alsoReleasedAs'), - }), + !html.isBlank(relations.referencingSourceEntries) && + language.encapsulate(capsule, 'readReferencingSources', capsule => + language.$(capsule, { + link: + html.tag('a', + {href: '#referencing-sources'}, + language.$(capsule, 'link')), + })), + ])), - relations.otherReleasesList, - ]), + relations.otherReleasesList, html.tags([ relations.contentHeading.clone() @@ -321,17 +320,7 @@ export default { relations.flashesThatFeatureList, ]), - html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'lyrics'}, - title: language.$('releaseInfo.lyrics'), - }), - - html.tag('blockquote', - {[html.onlyIfContent]: true}, - relations.lyrics.slot('mode', 'lyrics')), - ]), + relations.lyricsSection, html.tags([ relations.contentHeading.clone() @@ -365,10 +354,25 @@ export default { relations.artistCommentarySection, - relations.creditSourcesSection.slots({ - id: 'credit-sources', - title: language.$('misc.creditSources'), - }), + html.tags([ + relations.contentContentHeading.clone() + .slots({ + attributes: {id: 'crediting-sources'}, + string: 'misc.creditingSources', + }), + + relations.creditingSourceEntries, + ]), + + html.tags([ + relations.contentContentHeading.clone() + .slots({ + attributes: {id: 'referencing-sources'}, + string: 'misc.referencingSources', + }), + + relations.referencingSourceEntries, + ]), ], navLinkStyle: 'hierarchical', diff --git a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js index 5958be9a..61654512 100644 --- a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js +++ b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js @@ -14,7 +14,7 @@ export default { sortedFeatures: (sprawl.enableFlashesAndGames ? sortFlashesChronologically( - [track, ...track.otherReleases].flatMap(track => + track.allReleases.flatMap(track => track.featuredInFlashes.map(flash => ({ flash, track, @@ -36,6 +36,8 @@ export default { .map(({track: directlyFeaturedTrack}) => (directlyFeaturedTrack === track ? null + : directlyFeaturedTrack.name === track.name + ? null : relation('linkTrack', directlyFeaturedTrack))), }), @@ -52,7 +54,6 @@ export default { const options = {flash: flashLink}; if (trackLink) { - attributes.add('class', 'rerelease'); parts.push('asDifferentRelease'); options.track = trackLink; } diff --git a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js index 004bba6d..ebd76577 100644 --- a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js +++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js @@ -1,80 +1,42 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateAbsoluteDatetimestamp', - 'generateColorStyleAttribute', - 'generateRelativeDatetimestamp', - 'linkAlbum', - 'linkTrack', - ], - + contentDependencies: ['linkTrack'], extraDependencies: ['html', 'language'], relations: (relation, track) => ({ - colorStyles: - track.otherReleases - .map(track => relation('generateColorStyleAttribute', track.color)), - trackLinks: track.otherReleases .map(track => relation('linkTrack', track)), + }), - albumLinks: + data: (track) => ({ + albumNames: track.otherReleases - .map(track => relation('linkAlbum', track.album)), - - datetimestamps: - track.otherReleases.map(track2 => - (track2.date - ? (track.date - ? relation('generateRelativeDatetimestamp', - track2.date, - track.date) - : relation('generateAbsoluteDatetimestamp', - track2.date)) - : null)), + .map(track => track.album.name), - items: - track.otherReleases.map(track => ({ - trackLink: relation('linkTrack', track), - albumLink: relation('linkAlbum', track.album), - })), + albumColors: + track.otherReleases + .map(track => track.album.color), }), - generate: (relations, {html, language}) => - html.tag('ul', + generate: (data, relations, {html, language}) => + html.tag('p', {[html.onlyIfContent]: true}, - stitchArrays({ - trackLink: relations.trackLinks, - albumLink: relations.albumLinks, - datetimestamp: relations.datetimestamps, - colorStyle: relations.colorStyles, - }).map(({ - trackLink, - albumLink, - datetimestamp, - colorStyle, - }) => { - const parts = ['releaseInfo.alsoReleasedAs.item']; - const options = {}; - - options.track = trackLink.slot('color', false); - options.album = albumLink; - - if (datetimestamp) { - parts.push('withYear'); - options.year = - datetimestamp.slots({ - style: 'year', - tooltip: true, - }); - } - - return ( - html.tag('li', - colorStyle, - language.$(...parts, options))); - })), + language.$('releaseInfo.alsoReleasedOn', { + [language.onlyIfOptions]: ['albums'], + + albums: + language.formatConjunctionList( + stitchArrays({ + trackLink: relations.trackLinks, + albumName: data.albumNames, + albumColor: data.albumColors, + }).map(({trackLink, albumName, albumColor}) => + trackLink.slots({ + content: language.sanitize(albumName), + color: albumColor, + }))), + })), }; diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js index 3cba479e..230868d6 100644 --- a/src/content/dependencies/generateTrackListDividedByGroups.js +++ b/src/content/dependencies/generateTrackListDividedByGroups.js @@ -7,9 +7,16 @@ export default { 'linkGroup', ], - extraDependencies: ['html', 'language'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl: ({wikiInfo}) => ({ + divideTrackListsByGroups: + wikiInfo.divideTrackListsByGroups, + }), + + query(sprawl, tracks) { + const dividingGroups = sprawl.divideTrackListsByGroups; - query(tracks, dividingGroups) { const groupings = new Map(); const ungroupedTracks = []; @@ -43,9 +50,9 @@ export default { return {groups, groupedTracks, ungroupedTracks}; }, - relations: (relation, query, tracks, groups) => ({ + relations: (relation, query, sprawl, tracks) => ({ flatList: - (empty(groups) + (empty(sprawl.divideTrackListsByGroups) ? relation('generateTrackList', tracks) : null), @@ -66,7 +73,7 @@ export default { : relation('generateTrackList', query.ungroupedTracks)), }), - data: (query) => ({ + data: (query, _sprawl, _tracks) => ({ groupNames: query.groups .map(group => group.name), diff --git a/src/content/dependencies/generateTrackListItem.js b/src/content/dependencies/generateTrackListItem.js index 887b6f03..3c850a18 100644 --- a/src/content/dependencies/generateTrackListItem.js +++ b/src/content/dependencies/generateTrackListItem.js @@ -97,7 +97,8 @@ export default { workingCapsule += '.withArtists'; workingOptions.by = html.tag('span', {class: 'by'}, - html.metatag('chunkwrap', {split: ','}, + // TODO: This is obviously evil. + html.metatag('chunkwrap', {split: /,| (?=and)/}, html.resolve(relations.credit))); } diff --git a/src/content/dependencies/generateTrackNavLinks.js b/src/content/dependencies/generateTrackNavLinks.js index e01653f0..6a8b7c64 100644 --- a/src/content/dependencies/generateTrackNavLinks.js +++ b/src/content/dependencies/generateTrackNavLinks.js @@ -15,7 +15,7 @@ export default { track.album.hasTrackNumbers, trackNumber: - track.album.tracks.indexOf(track) + 1, + track.trackNumber, }), slots: { diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js index ac81e525..7073409e 100644 --- a/src/content/dependencies/generateTrackReferencedArtworksPage.js +++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js @@ -1,9 +1,8 @@ export default { contentDependencies: [ - 'generateAlbumStyleRules', + 'generateAlbumStyleTags', 'generateBackToTrackLink', 'generateReferencedArtworksPage', - 'generateTrackCoverArtwork', 'generateTrackNavLinks', ], @@ -11,27 +10,21 @@ export default { relations: (relation, track) => ({ page: - relation('generateReferencedArtworksPage', track.referencedArtworks), + relation('generateReferencedArtworksPage', track.trackArtworks[0]), - albumStyleRules: - relation('generateAlbumStyleRules', track.album, track), + albumStyleTags: + relation('generateAlbumStyleTags', track.album, track), navLinks: relation('generateTrackNavLinks', track), backToTrackLink: relation('generateBackToTrackLink', track), - - cover: - relation('generateTrackCoverArtwork', track), }), data: (track) => ({ name: track.name, - - color: - track.color, }), generate: (data, relations, {html, language}) => @@ -42,10 +35,7 @@ export default { data.name, }), - color: data.color, - styleRules: [relations.albumStyleRules], - - cover: relations.cover, + styleTags: relations.albumStyleTags, navLinks: html.resolve( diff --git a/src/content/dependencies/generateTrackReferencingArtworksPage.js b/src/content/dependencies/generateTrackReferencingArtworksPage.js index 097ee929..a45144c8 100644 --- a/src/content/dependencies/generateTrackReferencingArtworksPage.js +++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js @@ -1,9 +1,8 @@ export default { contentDependencies: [ - 'generateAlbumStyleRules', + 'generateAlbumStyleTags', 'generateBackToTrackLink', 'generateReferencingArtworksPage', - 'generateTrackCoverArtwork', 'generateTrackNavLinks', ], @@ -11,27 +10,21 @@ export default { relations: (relation, track) => ({ page: - relation('generateReferencingArtworksPage', track.referencedByArtworks), + relation('generateReferencingArtworksPage', track.trackArtworks[0]), - albumStyleRules: - relation('generateAlbumStyleRules', track.album, track), + albumStyleTags: + relation('generateAlbumStyleTags', track.album, track), navLinks: relation('generateTrackNavLinks', track), backToTrackLink: relation('generateBackToTrackLink', track), - - cover: - relation('generateTrackCoverArtwork', track), }), data: (track) => ({ name: track.name, - - color: - track.color, }), generate: (data, relations, {html, language}) => @@ -42,10 +35,7 @@ export default { data.name, }), - color: data.color, - styleRules: [relations.albumStyleRules], - - cover: relations.cover, + styleTags: relations.albumStyleTags, navLinks: html.resolve( diff --git a/src/content/dependencies/generateTrackReleaseBox.js b/src/content/dependencies/generateTrackReleaseBox.js new file mode 100644 index 00000000..ef02e2b9 --- /dev/null +++ b/src/content/dependencies/generateTrackReleaseBox.js @@ -0,0 +1,46 @@ +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generatePageSidebarBox', + 'linkTrack', + ], + + extraDependencies: ['html', 'language'], + + relations: (relation, track) => ({ + box: + relation('generatePageSidebarBox'), + + colorStyle: + relation('generateColorStyleAttribute', track.album.color), + + trackLink: + relation('linkTrack', track), + }), + + data: (track) => ({ + albumName: + track.album.name, + }), + + generate: (data, relations, {html, language}) => + language.encapsulate('albumSidebar.releaseBox', boxCapsule => + relations.box.slots({ + attributes: [ + {class: 'track-release-sidebar-box'}, + relations.colorStyle, + ], + + content: [ + html.tag('h1', + language.$(boxCapsule, 'title', { + album: + relations.trackLink.slots({ + color: false, + content: + language.sanitize(data.albumName), + }), + })), + ], + })), +}; diff --git a/src/content/dependencies/generateTrackReleaseInfo.js b/src/content/dependencies/generateTrackReleaseInfo.js index 38b8383f..3298dcc4 100644 --- a/src/content/dependencies/generateTrackReleaseInfo.js +++ b/src/content/dependencies/generateTrackReleaseInfo.js @@ -1,9 +1,7 @@ -import {empty} from '#sugar'; - export default { contentDependencies: [ 'generateReleaseInfoContributionsLine', - 'linkExternal', + 'generateReleaseInfoListenLine', ], extraDependencies: ['html', 'language'], @@ -11,19 +9,11 @@ export default { relations(relation, track) { const relations = {}; - relations.artistContributionLinks = + relations.artistContributionsLine = relation('generateReleaseInfoContributionsLine', track.artistContribs); - if (track.hasUniqueCoverArt) { - relations.coverArtistContributionsLine = - relation('generateReleaseInfoContributionsLine', track.coverArtistContribs); - } - - if (!empty(track.urls)) { - relations.externalLinks = - track.urls.map(url => - relation('linkExternal', url)); - } + relations.listenLine = + relation('generateReleaseInfoListenLine', track); return relations; }, @@ -37,7 +27,6 @@ export default { if ( track.hasUniqueCoverArt && - track.coverArtDate && +track.coverArtDate !== +track.date ) { data.coverArtDate = track.coverArtDate; @@ -54,27 +43,17 @@ export default { {[html.joinChildren]: html.tag('br')}, [ - relations.artistContributionLinks.slots({ + relations.artistContributionsLine.slots({ stringKey: capsule + '.by', featuringStringKey: capsule + '.by.featuring', chronologyKind: 'track', }), - relations.coverArtistContributionsLine?.slots({ - stringKey: capsule + '.coverArtBy', - chronologyKind: 'trackArt', - }), - language.$(capsule, 'released', { [language.onlyIfOptions]: ['date'], date: language.formatDate(data.date), }), - language.$(capsule, 'artReleased', { - [language.onlyIfOptions]: ['date'], - date: language.formatDate(data.coverArtDate), - }), - language.$(capsule, 'duration', { [language.onlyIfOptions]: ['duration'], duration: language.formatDuration(data.duration), @@ -82,17 +61,9 @@ export default { ]), html.tag('p', - language.encapsulate(capsule, 'listenOn', capsule => - (relations.externalLinks - ? language.$(capsule, { - links: - language.formatDisjunctionList( - relations.externalLinks - .map(link => link.slot('context', 'track'))), - }) - : language.$(capsule, 'noLinks', { - name: - html.tag('i', data.name), - })))), + relations.listenLine.slots({ + visibleWithoutLinks: true, + context: ['track'], + })), ])), }; diff --git a/src/content/dependencies/generateTrackSocialEmbed.js b/src/content/dependencies/generateTrackSocialEmbed.js index d8e21e38..310816f3 100644 --- a/src/content/dependencies/generateTrackSocialEmbed.js +++ b/src/content/dependencies/generateTrackSocialEmbed.js @@ -4,7 +4,7 @@ export default { 'generateTrackSocialEmbedDescription', ], - extraDependencies: ['absoluteTo', 'language', 'urls'], + extraDependencies: ['absoluteTo', 'language'], relations(relation, track) { return { @@ -26,20 +26,18 @@ export default { data.trackDirectory = track.directory; data.albumDirectory = album.directory; + data.hasImage = track.hasUniqueCoverArt || album.hasCoverArt; + if (track.hasUniqueCoverArt) { - data.imageSource = 'track'; - data.coverArtFileExtension = track.coverArtFileExtension; + data.imagePath = track.trackArtworks[0].path; } else if (album.hasCoverArt) { - data.imageSource = 'album'; - data.coverArtFileExtension = album.coverArtFileExtension; - } else { - data.imageSource = 'none'; + data.imagePath = album.coverArtworks[0].path; } return data; }, - generate: (data, relations, {absoluteTo, language, urls}) => + generate: (data, relations, {absoluteTo, language}) => language.encapsulate('trackPage.socialEmbed', embedCapsule => relations.socialEmbed.slots({ title: @@ -59,16 +57,8 @@ export default { absoluteTo('localized.album', data.albumDirectory), imagePath: - (data.imageSource === 'album' - ? '/' + - urls - .from('shared.root') - .to('media.albumCover', data.albumDirectory, data.coverArtFileExtension) - : data.imageSource === 'track' - ? '/' + - urls - .from('shared.root') - .to('media.trackCover', data.albumDirectory, data.trackDirectory, data.coverArtFileExtension) + (data.hasImage + ? data.imagePath : null), })), }; diff --git a/src/content/dependencies/generateWallpaperStyleTag.js b/src/content/dependencies/generateWallpaperStyleTag.js new file mode 100644 index 00000000..bf094300 --- /dev/null +++ b/src/content/dependencies/generateWallpaperStyleTag.js @@ -0,0 +1,80 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + contentDependencies: ['generateStyleTag'], + extraDependencies: ['html', 'to'], + + relations: (relation) => ({ + styleTag: + relation('generateStyleTag'), + }), + + slots: { + singleWallpaperPath: { + validate: v => v.strictArrayOf(v.isString), + }, + + singleWallpaperStyle: { + validate: v => v.isString, + }, + + wallpaperPartPaths: { + validate: v => + v.strictArrayOf(v.optional(v.strictArrayOf(v.isString))), + }, + + wallpaperPartStyles: { + validate: v => + v.strictArrayOf(v.optional(v.isString)), + }, + }, + + generate(relations, slots, {html, to}) { + const attributes = html.attributes(); + const rules = []; + + attributes.add('class', 'wallpaper-style'); + + if (empty(slots.wallpaperPartPaths)) { + attributes.set('data-wallpaper-mode', 'one'); + + rules.push({ + select: 'body::before', + declare: [ + `background-image: url("${to(...slots.singleWallpaperPath)}");`, + slots.singleWallpaperStyle, + ], + }); + } else { + attributes.set('data-wallpaper-mode', 'parts'); + attributes.set('data-num-wallpaper-parts', slots.wallpaperPartPaths.length); + + stitchArrays({ + path: slots.wallpaperPartPaths, + style: slots.wallpaperPartStyles, + }).forEach(({path, style}, index) => { + rules.push({ + select: `.wallpaper-part:nth-child(${index + 1})`, + declare: [ + path && `background-image: url("${to(...path)}");`, + style, + ], + }); + }); + + rules.push({ + select: 'body::before', + declare: [ + 'display: none;', + ], + }); + } + + relations.styleTag.setSlots({ + attributes, + rules, + }); + + return relations.styleTag; + }, +}; diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js deleted file mode 100644 index 84ed5545..00000000 --- a/src/content/dependencies/generateWikiHomeAlbumsRow.js +++ /dev/null @@ -1,150 +0,0 @@ -import {empty, stitchArrays} from '#sugar'; -import {getNewAdditions, getNewReleases} from '#wiki-data'; - -export default { - contentDependencies: [ - 'generateWikiHomeContentRow', - 'generateCoverCarousel', - 'generateCoverGrid', - 'image', - 'linkAlbum', - 'transformContent', - ], - - extraDependencies: ['language', 'wikiData'], - - sprawl({albumData}, row) { - const sprawl = {}; - - switch (row.sourceGroup) { - case 'new-releases': - sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData}); - break; - - case 'new-additions': - sprawl.albums = getNewAdditions(row.countAlbumsFromGroup, {albumData}); - break; - - default: - sprawl.albums = - (row.sourceGroup - ? row.sourceGroup.albums - .slice() - .reverse() - .filter(album => album.isListedOnHomepage) - .slice(0, row.countAlbumsFromGroup) - : []); - } - - if (!empty(row.sourceAlbums)) { - sprawl.albums.push(...row.sourceAlbums); - } - - return sprawl; - }, - - relations(relation, sprawl, row) { - const relations = {}; - - relations.contentRow = - relation('generateWikiHomeContentRow', row); - - if (row.displayStyle === 'grid') { - relations.coverGrid = - relation('generateCoverGrid'); - } - - if (row.displayStyle === 'carousel') { - relations.coverCarousel = - relation('generateCoverCarousel'); - } - - relations.links = - sprawl.albums - .map(album => relation('linkAlbum', album)); - - relations.images = - sprawl.albums - .map(album => relation('image', album.artTags)); - - if (row.actionLinks) { - relations.actionLinks = - row.actionLinks - .map(content => relation('transformContent', content)); - } - - return relations; - }, - - data(sprawl, row) { - const data = {}; - - data.displayStyle = row.displayStyle; - - if (row.displayStyle === 'grid') { - data.names = - sprawl.albums - .map(album => album.name); - } - - data.paths = - sprawl.albums - .map(album => - (album.hasCoverArt - ? ['media.albumCover', album.directory, album.coverArtFileExtension] - : null)); - - return data; - }, - - generate(data, relations, {language}) { - // Grids and carousels share some slots! Very convenient. - const commonSlots = {}; - - commonSlots.links = - relations.links; - - commonSlots.images = - stitchArrays({ - image: relations.images, - path: data.paths, - name: data.names ?? data.paths.slice().fill(null), - }).map(({image, path, name}) => - image.slots({ - path, - missingSourceContent: - language.$('misc.coverGrid.noCoverArt', { - [language.onlyIfOptions]: ['album'], - album: name, - }), - })); - - commonSlots.actionLinks = - (relations.actionLinks - ? relations.actionLinks - .map(contents => - contents - .slot('mode', 'single-link') - .content) - : null); - - let content; - - switch (data.displayStyle) { - case 'grid': - content = - relations.coverGrid.slots({ - ...commonSlots, - names: data.names, - }); - break; - - case 'carousel': - content = - relations.coverCarousel.slots(commonSlots); - break; - } - - return relations.contentRow.slots({content}); - }, -}; diff --git a/src/content/dependencies/generateWikiHomeContentRow.js b/src/content/dependencies/generateWikiHomeContentRow.js deleted file mode 100644 index 27b12e55..00000000 --- a/src/content/dependencies/generateWikiHomeContentRow.js +++ /dev/null @@ -1,28 +0,0 @@ -export default { - contentDependencies: ['generateColorStyleAttribute'], - extraDependencies: ['html'], - - relations: (relation, row) => ({ - colorStyle: - relation('generateColorStyleAttribute', row.color), - }), - - data: (row) => - ({name: row.name}), - - slots: { - content: { - type: 'html', - mutable: false, - }, - }, - - generate: (data, relations, slots, {html}) => - html.tag('section', {class: 'row'}, - relations.colorStyle, - - [ - html.tag('h2', data.name), - slots.content, - ]), -}; diff --git a/src/content/dependencies/generateWikiHomePage.js b/src/content/dependencies/generateWikiHomePage.js deleted file mode 100644 index ee14a587..00000000 --- a/src/content/dependencies/generateWikiHomePage.js +++ /dev/null @@ -1,116 +0,0 @@ -export default { - contentDependencies: [ - 'generatePageLayout', - 'generatePageSidebar', - 'generatePageSidebarBox', - 'generateWikiHomeAlbumsRow', - 'generateWikiHomeNewsBox', - 'transformContent', - ], - - extraDependencies: ['wikiData'], - - sprawl({wikiInfo}) { - return { - wikiName: wikiInfo.name, - - enableNews: wikiInfo.enableNews, - }; - }, - - relations(relation, sprawl, homepageLayout) { - const relations = {}; - - relations.layout = - relation('generatePageLayout'); - - relations.sidebar = - relation('generatePageSidebar'); - - if (homepageLayout.sidebarContent) { - relations.customSidebarBox = - relation('generatePageSidebarBox'); - - relations.customSidebarContent = - relation('transformContent', homepageLayout.sidebarContent); - } - - if (sprawl.enableNews) { - relations.newsSidebarBox = - relation('generateWikiHomeNewsBox'); - } - - if (homepageLayout.navbarLinks) { - relations.customNavLinkContents = - homepageLayout.navbarLinks - .map(content => relation('transformContent', content)); - } - - relations.contentRows = - homepageLayout.rows.map(row => { - switch (row.type) { - case 'albums': - return relation('generateWikiHomeAlbumsRow', row); - default: - return null; - } - }); - - return relations; - }, - - data(sprawl) { - return { - wikiName: sprawl.wikiName, - }; - }, - - generate(data, relations) { - return relations.layout.slots({ - title: data.wikiName, - showWikiNameInTitle: false, - - mainClasses: ['top-index'], - headingMode: 'static', - - mainContent: [ - relations.contentRows, - ], - - leftSidebar: - relations.sidebar.slots({ - wide: true, - - boxes: [ - relations.customSidebarContent && - relations.customSidebarBox.slots({ - attributes: {class: 'custom-content-sidebar-box'}, - collapsible: false, - - content: - relations.customSidebarContent - .slot('mode', 'multiline'), - }), - - relations.newsSidebarBox, - ], - }), - - navLinkStyle: 'index', - navLinks: [ - {auto: 'home', current: true}, - - ...( - relations.customNavLinkContents - ?.map(content => ({ - html: - content.slots({ - mode: 'single-link', - preferShortLinkNames: true, - }), - })) - ?? []), - ], - }); - }, -}; diff --git a/src/content/dependencies/generateWikiHomepageActionsRow.js b/src/content/dependencies/generateWikiHomepageActionsRow.js new file mode 100644 index 00000000..9f501099 --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageActionsRow.js @@ -0,0 +1,22 @@ +export default { + contentDependencies: ['generateGridActionLinks', 'transformContent'], + + relations: (relation, row) => ({ + template: + relation('generateGridActionLinks'), + + links: + row.actionLinks + .map(content => relation('transformContent', content)), + }), + + generate: (relations) => + relations.template.slots({ + actionLinks: + relations.links + .map(contents => + contents + .slot('mode', 'single-link') + .content), + }), +}; diff --git a/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js new file mode 100644 index 00000000..b45bfc19 --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js @@ -0,0 +1,22 @@ +export default { + contentDependencies: ['generateCoverCarousel', 'image', 'linkAlbum'], + + relations: (relation, row) => ({ + coverCarousel: + relation('generateCoverCarousel'), + + links: + row.albums + .map(album => relation('linkAlbum', album)), + + images: + row.albums + .map(album => relation('image', album.coverArtworks[0])), + }), + + generate: (relations) => + relations.coverCarousel.slots({ + links: relations.links, + images: relations.images, + }), +}; diff --git a/src/content/dependencies/generateWikiHomepageAlbumGridRow.js b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js new file mode 100644 index 00000000..a00136ba --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js @@ -0,0 +1,78 @@ +import {empty, stitchArrays} from '#sugar'; +import {getNewAdditions, getNewReleases} from '#wiki-data'; + +export default { + contentDependencies: ['generateCoverGrid', 'image', 'linkAlbum'], + extraDependencies: ['language', 'wikiData'], + + sprawl({albumData}, row) { + const sprawl = {}; + + switch (row.sourceGroup) { + case 'new-releases': + sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData}); + break; + + case 'new-additions': + sprawl.albums = getNewAdditions(row.countAlbumsFromGroup, {albumData}); + break; + + default: + sprawl.albums = + (row.sourceGroup + ? row.sourceGroup.albums + .slice() + .reverse() + .filter(album => album.isListedOnHomepage) + .slice(0, row.countAlbumsFromGroup) + : []); + } + + if (!empty(row.sourceAlbums)) { + sprawl.albums.push(...row.sourceAlbums); + } + + return sprawl; + }, + + relations: (relation, sprawl, _row) => ({ + coverGrid: + relation('generateCoverGrid'), + + links: + sprawl.albums + .map(album => relation('linkAlbum', album)), + + images: + sprawl.albums + .map(album => + relation('image', + (album.hasCoverArt + ? album.coverArtworks[0] + : null))), + }), + + data: (sprawl, _row) => ({ + names: + sprawl.albums + .map(album => album.name), + }), + + generate: (data, relations, {language}) => + relations.coverGrid.slots({ + links: relations.links, + names: data.names, + + images: + stitchArrays({ + image: relations.images, + name: data.names, + }).map(({image, name}) => + image.slots({ + missingSourceContent: + language.$('misc.coverGrid.noCoverArt', { + album: name, + }), + })), + }), +}; diff --git a/src/content/dependencies/generateWikiHomeNewsBox.js b/src/content/dependencies/generateWikiHomepageNewsBox.js index 83a27695..83a27695 100644 --- a/src/content/dependencies/generateWikiHomeNewsBox.js +++ b/src/content/dependencies/generateWikiHomepageNewsBox.js diff --git a/src/content/dependencies/generateWikiHomepagePage.js b/src/content/dependencies/generateWikiHomepagePage.js new file mode 100644 index 00000000..8c09a007 --- /dev/null +++ b/src/content/dependencies/generateWikiHomepagePage.js @@ -0,0 +1,97 @@ +export default { + contentDependencies: [ + 'generatePageLayout', + 'generatePageSidebar', + 'generatePageSidebarBox', + 'generateWikiHomepageNewsBox', + 'generateWikiHomepageSection', + 'transformContent', + ], + + extraDependencies: ['wikiData'], + + sprawl: ({wikiInfo}) => ({ + wikiName: + wikiInfo.name, + + enableNews: + wikiInfo.enableNews, + }), + + relations: (relation, sprawl, homepageLayout) => ({ + layout: + relation('generatePageLayout'), + + sidebar: + relation('generatePageSidebar'), + + customSidebarBox: + relation('generatePageSidebarBox'), + + customSidebarContent: + relation('transformContent', homepageLayout.sidebarContent), + + newsSidebarBox: + (sprawl.enableNews + ? relation('generateWikiHomepageNewsBox') + : null), + + customNavLinkContents: + homepageLayout.navbarLinks + .map(content => relation('transformContent', content)), + + sections: + homepageLayout.sections + .map(section => relation('generateWikiHomepageSection', section)), + }), + + data: (sprawl) => ({ + wikiName: + sprawl.wikiName, + }), + + generate: (data, relations) => + relations.layout.slots({ + title: data.wikiName, + showWikiNameInTitle: false, + + mainClasses: ['top-index'], + headingMode: 'static', + + mainContent: [ + relations.sections, + ], + + leftSidebar: + relations.sidebar.slots({ + wide: true, + + boxes: [ + relations.customSidebarBox.slots({ + attributes: {class: 'custom-content-sidebar-box'}, + collapsible: false, + + content: + relations.customSidebarContent + .slot('mode', 'multiline'), + }), + + relations.newsSidebarBox, + ], + }), + + navLinkStyle: 'index', + navLinks: [ + {auto: 'home', current: true}, + + ... + relations.customNavLinkContents.map(content => ({ + html: + content.slots({ + mode: 'single-link', + preferShortLinkNames: true, + }), + })), + ], + }), +}; diff --git a/src/content/dependencies/generateWikiHomepageSection.js b/src/content/dependencies/generateWikiHomepageSection.js new file mode 100644 index 00000000..49a474da --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageSection.js @@ -0,0 +1,39 @@ +export default { + contentDependencies: [ + 'generateColorStyleAttribute', + 'generateWikiHomepageActionsRow', + 'generateWikiHomepageAlbumCarouselRow', + 'generateWikiHomepageAlbumGridRow', + ], + + extraDependencies: ['html'], + + relations: (relation, homepageSection) => ({ + colorStyle: + relation('generateColorStyleAttribute', homepageSection.color), + + rows: + homepageSection.rows.map(row => + (row.type === 'actions' + ? relation('generateWikiHomepageActionsRow', row) + : row.type === 'album carousel' + ? relation('generateWikiHomepageAlbumCarouselRow', row) + : row.type === 'album grid' + ? relation('generateWikiHomepageAlbumGridRow', row) + : null)), + }), + + data: (homepageSection) => ({ + name: + homepageSection.name, + }), + + generate: (data, relations, {html}) => + html.tag('section', + relations.colorStyle, + + [ + html.tag('h2', data.name), + relations.rows, + ]), +}; diff --git a/src/content/dependencies/generateWikiWallpaperStyleTag.js b/src/content/dependencies/generateWikiWallpaperStyleTag.js new file mode 100644 index 00000000..12d27304 --- /dev/null +++ b/src/content/dependencies/generateWikiWallpaperStyleTag.js @@ -0,0 +1,38 @@ +export default { + contentDependencies: ['generateWallpaperStyleTag'], + extraDependencies: ['wikiData'], + + sprawl: ({wikiInfo}) => ({wikiInfo}), + + relations: (relation) => ({ + wallpaperStyleTag: + relation('generateWallpaperStyleTag'), + }), + + data: ({wikiInfo}) => ({ + singleWallpaperPath: [ + 'media.path', + 'bg.' + wikiInfo.wikiWallpaperFileExtension, + ], + + singleWallpaperStyle: + wikiInfo.wikiWallpaperStyle, + + wallpaperPartPaths: + wikiInfo.wikiWallpaperParts.map(part => + (part.asset + ? ['media.path', part.asset] + : null)), + + wallpaperPartStyles: + wikiInfo.wikiWallpaperParts.map(part => part.style), + }), + + generate: (data, relations) => + relations.wallpaperStyleTag.slots({ + singleWallpaperPath: data.singleWallpaperPath, + singleWallpaperStyle: data.singleWallpaperStyle, + wallpaperPartPaths: data.wallpaperPartPaths, + wallpaperPartStyles: data.wallpaperPartStyles, + }), +}; diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js index b1f02819..bf47b14f 100644 --- a/src/content/dependencies/image.js +++ b/src/content/dependencies/image.js @@ -5,7 +5,7 @@ export default { extraDependencies: [ 'checkIfImagePathHasCachedThumbnails', 'getDimensionsOfImagePath', - 'getSizeOfImagePath', + 'getSizeOfMediaFile', 'getThumbnailEqualOrSmaller', 'getThumbnailsAvailableForDimensions', 'html', @@ -16,74 +16,83 @@ export default { contentDependencies: ['generateColorStyleAttribute'], - relations: (relation) => ({ + relations: (relation, _artwork) => ({ colorStyle: relation('generateColorStyleAttribute'), }), - data(artTags) { - const data = {}; - - if (artTags) { - data.contentWarnings = - artTags - .filter(tag => tag.isContentWarning) - .map(tag => tag.name); - } else { - data.contentWarnings = null; - } - - return data; - }, + data: (artwork) => ({ + path: + (artwork + ? artwork.path + : null), + + warnings: + (artwork + ? artwork.artTags + .filter(artTag => artTag.isContentWarning) + .map(artTag => artTag.name) + : null), + + dimensions: + (artwork + ? artwork.dimensions + : null), + }), slots: { - src: {type: 'string'}, - - path: { - validate: v => v.validateArrayItems(v.isString), - }, - thumb: {type: 'string'}, + reveal: {type: 'boolean', default: true}, + lazy: {type: 'boolean', default: false}, + square: {type: 'boolean', default: false}, + link: { validate: v => v.anyOf(v.isBoolean, v.isString), default: false, }, - color: { - validate: v => v.isColor, - }, + color: {validate: v => v.isColor}, - warnings: { - validate: v => v.looseArrayOf(v.isString), + // Added to the .image-container. + attributes: { + type: 'attributes', + mutable: false, }, - reveal: {type: 'boolean', default: true}, - lazy: {type: 'boolean', default: false}, - - square: {type: 'boolean', default: false}, + // Added to the <img> itself. + alt: {type: 'string'}, - dimensions: { - validate: v => v.isDimensions, - }, + // Specify 'src' or 'path', or the path will be used from the artwork. + // If none of the above is present, the message in missingSourceContent + // will be displayed instead. - alt: {type: 'string'}, + src: {type: 'string'}, - attributes: { - type: 'attributes', - mutable: false, + path: { + validate: v => v.validateArrayItems(v.isString), }, missingSourceContent: { type: 'html', mutable: false, }, + + // These will also be used from the artwork if not specified as slots. + + warnings: { + validate: v => v.looseArrayOf(v.isString), + }, + + dimensions: { + validate: v => v.isDimensions, + }, }, generate(data, relations, slots, { checkIfImagePathHasCachedThumbnails, getDimensionsOfImagePath, - getSizeOfImagePath, + getSizeOfMediaFile, getThumbnailEqualOrSmaller, getThumbnailsAvailableForDimensions, html, @@ -91,15 +100,14 @@ export default { missingImagePaths, to, }) { - let originalSrc; - - if (slots.src) { - originalSrc = slots.src; - } else if (!empty(slots.path)) { - originalSrc = to(...slots.path); - } else { - originalSrc = ''; - } + const originalSrc = + (slots.src + ? slots.src + : slots.path + ? to(...slots.path) + : data.path + ? to(...data.path) + : ''); // TODO: This feels janky. It's necessary to deal with static content that // includes strings like <img src="media/misc/foo.png">, but processing the @@ -121,29 +129,27 @@ export default { !isMissingImageFile && (typeof slots.link === 'string' || slots.link); - const contentWarnings = - slots.warnings ?? - data.contentWarnings; + const warnings = slots.warnings ?? data.warnings; + const dimensions = slots.dimensions ?? data.dimensions; const willReveal = slots.reveal && originalSrc && !isMissingImageFile && - !empty(contentWarnings); - - const willSquare = - slots.square; + !empty(warnings); const imgAttributes = html.attributes([ {class: 'image'}, slots.alt && {alt: slots.alt}, - slots.dimensions?.[0] && - {width: slots.dimensions[0]}, + dimensions && + dimensions[0] && + {width: dimensions[0]}, - slots.dimensions?.[1] && - {height: slots.dimensions[1]}, + dimensions && + dimensions[1] && + {height: dimensions[1]}, ]); const isPlaceholder = @@ -169,7 +175,7 @@ export default { html.tag('span', {class: 'reveal-warnings'}, language.$('misc.contentWarnings.warnings', { - warnings: language.formatUnitList(contentWarnings), + warnings: language.formatUnitList(warnings), })), html.tag('br'), @@ -224,19 +230,17 @@ export default { const originalDimensions = getDimensionsOfImagePath(mediaSrc); const availableThumbs = getThumbnailsAvailableForDimensions(originalDimensions); - const originalLength = Math.max(originalDimensions[0], originalDimensions[1]); const fileSize = (willLink && mediaSrc - ? getSizeOfImagePath(mediaSrc) + ? getSizeOfMediaFile(mediaSrc) : null); imgAttributes.add([ fileSize && {'data-original-size': fileSize}, - originalLength && - {'data-original-length': originalLength}, + {'data-dimensions': originalDimensions.join('x')}, !empty(availableThumbs) && {'data-thumbs': @@ -325,14 +329,14 @@ export default { wrapped = html.tag('div', {class: 'image-outer-area'}, - willSquare && + slots.square && {class: 'square-content'}, wrapped); wrapped = html.tag('div', {class: 'image-container'}, - willSquare && + slots.square && {class: 'square'}, typeof slots.link === 'string' && diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js index a5009804..25d7324f 100644 --- a/src/content/dependencies/index.js +++ b/src/content/dependencies/index.js @@ -11,6 +11,11 @@ import {colors, logWarn} from '#cli'; import contentFunction, {ContentFunctionSpecError} from '#content-function'; import {annotateFunction} from '#sugar'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const codeSrcPath = path.resolve(__dirname, '..'); +const codeRootPath = path.resolve(codeSrcPath, '..'); + function cachebust(filePath) { if (filePath in cachebust.cache) { cachebust.cache[filePath] += 1; @@ -42,7 +47,9 @@ export function watchContentDependencies({ close, }); - const eslint = new ESLint(); + const eslint = new ESLint({ + cwd: codeRootPath, + }); const metaPath = fileURLToPath(import.meta.url); const metaDirname = path.dirname(metaPath); diff --git a/src/content/dependencies/linkAdditionalFile.js b/src/content/dependencies/linkAdditionalFile.js new file mode 100644 index 00000000..a8a940b1 --- /dev/null +++ b/src/content/dependencies/linkAdditionalFile.js @@ -0,0 +1,29 @@ +export default { + contentDependencies: ['linkTemplate'], + + query: (file, filename) => ({ + index: + file.filenames.indexOf(filename), + }), + + relations: (relation, _query, _file, _filename) => ({ + linkTemplate: + relation('linkTemplate'), + }), + + data: (query, file, filename) => ({ + filename, + + // Kinda jank, but eh. + path: + (query.index >= 0 + ? file.paths.at(query.index) + : null), + }), + + generate: (data, relations) => + relations.linkTemplate.slots({ + path: data.path, + content: data.filename, + }), +}; diff --git a/src/content/dependencies/linkAlbumAdditionalFile.js b/src/content/dependencies/linkAlbumAdditionalFile.js deleted file mode 100644 index 39e7111e..00000000 --- a/src/content/dependencies/linkAlbumAdditionalFile.js +++ /dev/null @@ -1,24 +0,0 @@ -export default { - contentDependencies: ['linkTemplate'], - - relations(relation) { - return { - linkTemplate: relation('linkTemplate'), - }; - }, - - data(album, file) { - return { - albumDirectory: album.directory, - file, - }; - }, - - generate(data, relations) { - return relations.linkTemplate - .slots({ - path: ['media.albumAdditionalFile', data.albumDirectory, data.file], - content: data.file, - }); - }, -}; diff --git a/src/content/dependencies/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js index 7173b417..45f8c2a9 100644 --- a/src/content/dependencies/linkAlbumDynamically.js +++ b/src/content/dependencies/linkAlbumDynamically.js @@ -1,3 +1,5 @@ +import {empty} from '#sugar'; + export default { contentDependencies: [ 'linkAlbumCommentary', @@ -23,7 +25,7 @@ export default { album.directory, albumHasCommentary: - !!album.commentary, + !empty(album.commentary), }), slots: { diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js index d4697403..e408c1b2 100644 --- a/src/content/dependencies/linkAnythingMan.js +++ b/src/content/dependencies/linkAnythingMan.js @@ -1,6 +1,7 @@ export default { contentDependencies: [ 'linkAlbum', + 'linkArtwork', 'linkFlash', 'linkTrack', ], @@ -13,6 +14,8 @@ export default { link: (query.referenceType === 'album' ? relation('linkAlbum', thing) + : query.referenceType === 'artwork' + ? relation('linkArtwork', thing) : query.referenceType === 'flash' ? relation('linkFlash', thing) : query.referenceType === 'track' diff --git a/src/content/dependencies/linkArtTagDynamically.js b/src/content/dependencies/linkArtTagDynamically.js new file mode 100644 index 00000000..964258e1 --- /dev/null +++ b/src/content/dependencies/linkArtTagDynamically.js @@ -0,0 +1,14 @@ +export default { + contentDependencies: ['linkArtTagGallery', 'linkArtTagInfo'], + extraDependencies: ['pagePath'], + + relations: (relation, artTag) => ({ + galleryLink: relation('linkArtTagGallery', artTag), + infoLink: relation('linkArtTagInfo', artTag), + }), + + generate: (relations, {pagePath}) => + (pagePath[0] === 'artTagInfo' + ? relations.infoLink + : relations.galleryLink), +}; diff --git a/src/content/dependencies/linkArtTagGallery.js b/src/content/dependencies/linkArtTagGallery.js new file mode 100644 index 00000000..a92b69c1 --- /dev/null +++ b/src/content/dependencies/linkArtTagGallery.js @@ -0,0 +1,8 @@ +export default { + contentDependencies: ['linkThing'], + + relations: (relation, artTag) => + ({link: relation('linkThing', 'localized.artTagGallery', artTag)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTagInfo.js index 7ddb7786..409cb3c0 100644 --- a/src/content/dependencies/linkArtTag.js +++ b/src/content/dependencies/linkArtTagInfo.js @@ -2,7 +2,7 @@ export default { contentDependencies: ['linkThing'], relations: (relation, artTag) => - ({link: relation('linkThing', 'localized.tag', artTag)}), + ({link: relation('linkThing', 'localized.artTagInfo', artTag)}), generate: (relations) => relations.link, }; diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js new file mode 100644 index 00000000..8cd6f359 --- /dev/null +++ b/src/content/dependencies/linkArtwork.js @@ -0,0 +1,20 @@ +export default { + contentDependencies: ['linkAlbum', 'linkTrack'], + + query: (artwork) => ({ + referenceType: + artwork.thing.constructor[Symbol.for('Thing.referenceType')], + }), + + relations: (relation, query, artwork) => ({ + link: + (query.referenceType === 'album' + ? relation('linkAlbum', artwork.thing) + : query.referenceType === 'track' + ? relation('linkTrack', artwork.thing) + : null), + }), + + generate: (relations) => + relations.link, +}; diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 073c821e..45c08a08 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -39,6 +39,11 @@ export default { default: false, }, + disableBrowserTooltip: { + type: 'boolean', + default: false, + }, + tab: { validate: v => v.is('default', 'separate'), default: 'default', @@ -50,7 +55,7 @@ export default { try { new URL(data.url); urlIsValid = true; - } catch (error) { + } catch { urlIsValid = false; } @@ -111,7 +116,9 @@ export default { linkAttributes.add('class', 'indicate-external'); let titleText; - if (slots.tab === 'separate') { + if (slots.disableBrowserTooltip) { + titleText = null; + } else if (slots.tab === 'separate') { if (html.isBlank(slots.content)) { titleText = language.$('misc.external.opensInNewTab.annotation'); diff --git a/src/content/dependencies/linkFlashSide.js b/src/content/dependencies/linkFlashSide.js new file mode 100644 index 00000000..b77ca65a --- /dev/null +++ b/src/content/dependencies/linkFlashSide.js @@ -0,0 +1,22 @@ +export default { + contentDependencies: ['linkFlashAct'], + + relations: (relation, flashSide) => ({ + link: + relation('linkFlashAct', flashSide.acts[0]), + }), + + data: (flashSide) => ({ + name: + flashSide.name, + + color: + flashSide.color, + }), + + generate: (data, relations) => + relations.link.slots({ + content: data.name, + color: data.color, + }), +}; diff --git a/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js new file mode 100644 index 00000000..ec856631 --- /dev/null +++ b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js @@ -0,0 +1,62 @@ +import {sortAlbumsTracksChronologically, sortContributionsChronologically} + from '#sort'; +import {chunkArtistTrackContributions} from '#wiki-data'; + +export default { + contentDependencies: ['generateColorStyleAttribute'], + extraDependencies: ['html', 'language'], + + query(track, artist) { + const relevantInfoPageChunkingContributions = + track.allReleases + .flatMap(release => [ + ...release.artistContribs, + ...release.contributorContribs, + ]) + .filter(c => c.artist === artist); + + sortContributionsChronologically( + relevantInfoPageChunkingContributions, + sortAlbumsTracksChronologically); + + const contributionChunks = + chunkArtistTrackContributions(relevantInfoPageChunkingContributions); + + const trackChunks = + contributionChunks + .map(chunksInAlbum => chunksInAlbum + .map(chunksInTrack => chunksInTrack[0].thing)); + + const trackChunksForThisAlbum = + trackChunks + .filter(tracks => tracks[0].album === track.album); + + const containingChunkIndex = + trackChunksForThisAlbum + .findIndex(tracks => tracks.includes(track)); + + return {containingChunkIndex}; + }, + + relations: (relation, _query, track, _artist) => ({ + colorStyle: + relation('generateColorStyleAttribute', track.album.color), + }), + + data: (query, track, _artist) => ({ + albumName: + track.album.name, + + albumDirectory: + track.album.directory, + + containingChunkIndex: + query.containingChunkIndex, + }), + + generate: (data, relations, {html, language}) => + html.tag('a', + {href: `#tracks-${data.albumDirectory}-${data.containingChunkIndex}`}, + relations.colorStyle.slot('context', 'primary-only'), + language.sanitize(data.albumName)), +}; diff --git a/src/content/dependencies/linkPathFromMedia.js b/src/content/dependencies/linkPathFromMedia.js index 34a2b857..d71c69f8 100644 --- a/src/content/dependencies/linkPathFromMedia.js +++ b/src/content/dependencies/linkPathFromMedia.js @@ -1,13 +1,64 @@ +import {empty} from '#sugar'; + export default { contentDependencies: ['linkTemplate'], + extraDependencies: [ + 'checkIfImagePathHasCachedThumbnails', + 'getDimensionsOfImagePath', + 'getSizeOfMediaFile', + 'getThumbnailsAvailableForDimensions', + 'html', + 'to', + ], + relations: (relation) => ({link: relation('linkTemplate')}), data: (path) => ({path}), - generate: (data, relations) => - relations.link - .slot('path', ['media.path', data.path]), + generate(data, relations, { + checkIfImagePathHasCachedThumbnails, + getDimensionsOfImagePath, + getSizeOfMediaFile, + getThumbnailsAvailableForDimensions, + html, + to, + }) { + const attributes = html.attributes(); + + if (checkIfImagePathHasCachedThumbnails(data.path)) { + const dimensions = getDimensionsOfImagePath(data.path); + const availableThumbs = getThumbnailsAvailableForDimensions(dimensions); + const fileSize = getSizeOfMediaFile(data.path); + + const embedSrc = + to('thumb.path', data.path.replace(/\.(png|jpg)$/, '.tack.jpg')); + + attributes.add([ + {class: 'image-media-link'}, + + {'data-embed-src': embedSrc}, + + fileSize && + {'data-original-size': fileSize}, + + {'data-dimensions': dimensions.join('x')}, + + !empty(availableThumbs) && + {'data-thumbs': + availableThumbs + .map(([name, size]) => `${name}:${size}`) + .join(' ')}, + ]); + } + + relations.link.setSlots({ + attributes, + path: ['media.path', data.path], + }); + + return relations.link; + }, }; diff --git a/src/content/dependencies/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js new file mode 100644 index 00000000..c456b808 --- /dev/null +++ b/src/content/dependencies/linkReferencedArtworks.js @@ -0,0 +1,24 @@ +import Thing from '#thing'; + +export default { + contentDependencies: [ + 'linkAlbumReferencedArtworks', + 'linkTrackReferencedArtworks', + ], + + query: (artwork) => ({ + referenceType: + artwork.thing.constructor[Thing.referenceType], + }), + + relations: (relation, query, artwork) => ({ + link: + (query.referenceType === 'album' + ? relation('linkAlbumReferencedArtworks', artwork.thing) + : query.referenceType === 'track' + ? relation('linkTrackReferencedArtworks', artwork.thing) + : null), + }), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkReferencingArtworks.js b/src/content/dependencies/linkReferencingArtworks.js new file mode 100644 index 00000000..0cfca4db --- /dev/null +++ b/src/content/dependencies/linkReferencingArtworks.js @@ -0,0 +1,24 @@ +import Thing from '#thing'; + +export default { + contentDependencies: [ + 'linkAlbumReferencingArtworks', + 'linkTrackReferencingArtworks', + ], + + query: (artwork) => ({ + referenceType: + artwork.thing.constructor[Thing.referenceType], + }), + + relations: (relation, query, artwork) => ({ + link: + (query.referenceType === 'album' + ? relation('linkAlbumReferencingArtworks', artwork.thing) + : query.referenceType === 'track' + ? relation('linkTrackReferencingArtworks', artwork.thing) + : null), + }), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js index 63cc82e8..4f853dc4 100644 --- a/src/content/dependencies/linkTemplate.js +++ b/src/content/dependencies/linkTemplate.js @@ -26,6 +26,11 @@ export default { type: 'html', mutable: false, }, + + suffixNormalContent: { + type: 'html', + mutable: false, + }, }, generate(slots, { @@ -61,13 +66,22 @@ export default { attributes.set('title', slots.tooltip); } - const content = + const mainContent = (html.isBlank(slots.content) ? language.$('misc.missingLinkContent') - : striptags(html.resolve(slots.content, {normalize: 'string'}), { - disallowedTags: new Set(['a']), - })); + : striptags( + html.resolve(slots.content, {normalize: 'string'}), + {disallowedTags: new Set(['a'])})); + + const allContent = + (html.isBlank(slots.suffixNormalContent) + ? mainContent + : html.tags([ + mainContent, + html.tag('span', {class: 'normal-content'}, + slots.suffixNormalContent), + ], {[html.joinChildren]: ''})); - return html.tag('a', attributes, content); + return html.tag('a', attributes, allContent); }, } diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js index 242cd4cb..bbcf1c34 100644 --- a/src/content/dependencies/linkTrackDynamically.js +++ b/src/content/dependencies/linkTrackDynamically.js @@ -1,3 +1,5 @@ +import {empty} from '#sugar'; + export default { contentDependencies: ['linkTrack'], extraDependencies: ['pagePath'], @@ -14,7 +16,7 @@ export default { track.album.directory, trackHasCommentary: - !!track.commentary, + !empty(track.commentary), }), generate(data, relations, {pagePath}) { diff --git a/src/content/dependencies/linkWikiHome.js b/src/content/dependencies/linkWikiHomepage.js index d8d3d0a0..d8d3d0a0 100644 --- a/src/content/dependencies/linkWikiHome.js +++ b/src/content/dependencies/linkWikiHomepage.js diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js index e33ad7b5..8ec69f1d 100644 --- a/src/content/dependencies/listAllAdditionalFilesTemplate.js +++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js @@ -1,209 +1,44 @@ import {sortChronologically} from '#sort'; -import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; export default { contentDependencies: [ 'generateListingPage', - 'generateListAllAdditionalFilesChunk', - 'linkAlbum', - 'linkTrack', - 'linkAlbumAdditionalFile', + 'generateListAllAdditionalFilesAlbumSection', ], - extraDependencies: ['html', 'language', 'wikiData'], + extraDependencies: ['html', 'wikiData'], sprawl: ({albumData}) => ({albumData}), - query(sprawl, spec, property) { - const albums = - sortChronologically(sprawl.albumData.slice()); + query: (sprawl, spec, property) => ({ + spec, + property, - const tracks = - albums - .map(album => album.tracks.slice()); - - // Get additional file objects from albums and their tracks. - // There's a possibility that albums and tracks don't both implement - // the same additional file fields - in this case, just treat them - // as though they do implement those fields, but don't have any - // additional files of that type. - - const albumAdditionalFileObjects = - albums - .map(album => album[property] ?? []); - - const trackAdditionalFileObjects = - tracks - .map(byAlbum => byAlbum - .map(track => track[property] ?? [])); - - // Filter out tracks that don't have any additional files. - - stitchArrays({tracks, trackAdditionalFileObjects}) - .forEach(({tracks, trackAdditionalFileObjects}) => { - filterMultipleArrays(tracks, trackAdditionalFileObjects, - (track, trackAdditionalFileObjects) => !empty(trackAdditionalFileObjects)); - }); - - // Filter out albums that don't have any tracks, - // nor any additional files of their own. - - filterMultipleArrays(albums, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects, - (album, albumAdditionalFileObjects, tracks, trackAdditionalFileObjects) => - !empty(albumAdditionalFileObjects) || - !empty(trackAdditionalFileObjects)); - - // Map additional file objects into titles and lists of file names. - - const albumAdditionalFileTitles = - albumAdditionalFileObjects - .map(byAlbum => byAlbum - .map(({title}) => title)); - - const albumAdditionalFileFiles = - albumAdditionalFileObjects - .map(byAlbum => byAlbum - .map(({files}) => files ?? [])); - - const trackAdditionalFileTitles = - trackAdditionalFileObjects - .map(byAlbum => byAlbum - .map(byTrack => byTrack - .map(({title}) => title))); - - const trackAdditionalFileFiles = - trackAdditionalFileObjects - .map(byAlbum => byAlbum - .map(byTrack => byTrack - .map(({files}) => files ?? []))); - - return { - spec, - albums, - tracks, - albumAdditionalFileTitles, - albumAdditionalFileFiles, - trackAdditionalFileTitles, - trackAdditionalFileFiles, - }; - }, + albums: + sortChronologically(sprawl.albumData.slice()), + }), relations: (relation, query) => ({ page: relation('generateListingPage', query.spec), - albumLinks: - query.albums - .map(album => relation('linkAlbum', album)), - - trackLinks: - query.tracks - .map(byAlbum => byAlbum - .map(track => relation('linkTrack', track))), - - albumChunks: - query.albums - .map(() => relation('generateListAllAdditionalFilesChunk')), - - trackChunks: - query.tracks - .map(byAlbum => byAlbum - .map(() => relation('generateListAllAdditionalFilesChunk'))), - - albumAdditionalFileLinks: - stitchArrays({ - album: query.albums, - files: query.albumAdditionalFileFiles, - }).map(({album, files: byAlbum}) => - byAlbum.map(files => files - .map(file => - relation('linkAlbumAdditionalFile', album, file)))), - - trackAdditionalFileLinks: - stitchArrays({ - album: query.albums, - files: query.trackAdditionalFileFiles, - }).map(({album, files: byAlbum}) => - byAlbum - .map(byTrack => byTrack - .map(files => files - .map(file => relation('linkAlbumAdditionalFile', album, file))))), - }), - - data: (query) => ({ - albumAdditionalFileTitles: query.albumAdditionalFileTitles, - trackAdditionalFileTitles: query.trackAdditionalFileTitles, - albumAdditionalFileFiles: query.albumAdditionalFileFiles, - trackAdditionalFileFiles: query.trackAdditionalFileFiles, + albumSections: + query.albums.map(album => + relation('generateListAllAdditionalFilesAlbumSection', + album, + query.property)), }), slots: { stringsKey: {type: 'string'}, }, - generate: (data, relations, slots, {html, language}) => + generate: (relations, slots) => relations.page.slots({ type: 'custom', content: - stitchArrays({ - albumLink: relations.albumLinks, - trackLinks: relations.trackLinks, - albumChunk: relations.albumChunks, - trackChunks: relations.trackChunks, - albumAdditionalFileTitles: data.albumAdditionalFileTitles, - trackAdditionalFileTitles: data.trackAdditionalFileTitles, - albumAdditionalFileLinks: relations.albumAdditionalFileLinks, - trackAdditionalFileLinks: relations.trackAdditionalFileLinks, - albumAdditionalFileFiles: data.albumAdditionalFileFiles, - trackAdditionalFileFiles: data.trackAdditionalFileFiles, - }).map(({ - albumLink, - trackLinks, - albumChunk, - trackChunks, - albumAdditionalFileTitles, - trackAdditionalFileTitles, - albumAdditionalFileLinks, - trackAdditionalFileLinks, - albumAdditionalFileFiles, - trackAdditionalFileFiles, - }) => [ - html.tag('h3', {class: 'content-heading'}, albumLink), - - html.tag('dl', [ - albumChunk.slots({ - title: - language.$('listingPage', slots.stringsKey, 'albumFiles'), - - additionalFileTitles: albumAdditionalFileTitles, - additionalFileLinks: albumAdditionalFileLinks, - additionalFileFiles: albumAdditionalFileFiles, - - stringsKey: slots.stringsKey, - }), - - stitchArrays({ - trackLink: trackLinks, - trackChunk: trackChunks, - trackAdditionalFileTitles, - trackAdditionalFileLinks, - trackAdditionalFileFiles, - }).map(({ - trackLink, - trackChunk, - trackAdditionalFileTitles, - trackAdditionalFileLinks, - trackAdditionalFileFiles, - }) => - trackChunk.slots({ - title: trackLink, - additionalFileTitles: trackAdditionalFileTitles, - additionalFileLinks: trackAdditionalFileLinks, - additionalFileFiles: trackAdditionalFileFiles, - stringsKey: slots.stringsKey, - })), - ]), - ]), + relations.albumSections.map(section => + section.slot('stringsKey', slots.stringsKey)), }), }; diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js index b3a54747..93dd4ce8 100644 --- a/src/content/dependencies/listArtTagNetwork.js +++ b/src/content/dependencies/listArtTagNetwork.js @@ -1 +1,366 @@ -export default {generate() {}}; +import {sortAlphabetically} from '#sort'; +import {empty, stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtTagInfo'], + extraDependencies: ['html', 'language', 'wikiData'], + + sprawl({artTagData}) { + return {artTagData}; + }, + + query(sprawl, spec) { + const artTags = + sprawl.artTagData.filter(artTag => !artTag.isContentWarning); + + const rootArtTags = + artTags + .filter(artTag => !empty(artTag.directDescendantArtTags)) + .filter(artTag => + empty(artTag.directAncestorArtTags) || + artTag.directAncestorArtTags.length >= 2); + + sortAlphabetically(rootArtTags); + + rootArtTags.sort( + ({directAncestorArtTags: ancestorsA}, + {directAncestorArtTags: ancestorsB}) => + ancestorsA.length - ancestorsB.length); + + const getStats = (artTag) => ({ + directUses: + artTag.directlyFeaturedInArtworks.length, + + // Not currently displayed + directAndIndirectUses: + unique([ + ...artTag.indirectlyFeaturedInArtworks, + ...artTag.directlyFeaturedInArtworks, + ]).length, + + totalUses: + [ + ...artTag.directlyFeaturedInArtworks, + ... + artTag.allDescendantArtTags + .flatMap(artTag => artTag.directlyFeaturedInArtworks), + ].length, + + descendants: + artTag.allDescendantArtTags.length, + + leaves: + (empty(artTag.directDescendantArtTags) + ? null + : artTag.allDescendantArtTags + .filter(artTag => empty(artTag.directDescendantArtTags)) + .length), + }); + + const recursive = (artTag, depth) => { + const descendantNodes = + (empty(artTag.directDescendantArtTags) + ? null + : depth > 0 && artTag.directAncestorArtTags.length >= 2 + ? null + : artTag.directDescendantArtTags + .map(artTag => recursive(artTag, depth + 1))); + + descendantNodes?.sort( + ({descendantNodes: descendantNodesA}, + {descendantNodes: descendantNodesB}) => + (descendantNodesA ? 1 : 0) + - (descendantNodesB ? 1 : 0)); + + const recursiveGetRootAncestor = ancestorArtTag => + (ancestorArtTag.directAncestorArtTags.length === 1 + ? recursiveGetRootAncestor(ancestorArtTag.directAncestorArtTags[0]) + : ancestorArtTag); + + const ancestorRootArtTags = + (depth === 0 && !empty(artTag.directAncestorArtTags) + ? unique(artTag.directAncestorArtTags.map(recursiveGetRootAncestor)) + : null); + + const stats = getStats(artTag); + + return { + artTag, + stats, + descendantNodes, + ancestorRootArtTags, + }; + }; + + const uppermostRootTags = + artTags + .filter(artTag => !empty(artTag.directDescendantArtTags)) + .filter(artTag => empty(artTag.directAncestorArtTags)); + + const orphanArtTags = + artTags + .filter(artTag => empty(artTag.directDescendantArtTags)) + .filter(artTag => empty(artTag.directAncestorArtTags)); + + return { + spec, + + rootNodes: + rootArtTags + .map(artTag => recursive(artTag, 0)), + + uppermostRootTags, + orphanArtTags, + }; + }, + + relations(relation, query) { + const recursive = queryNode => ({ + artTagLink: + relation('linkArtTagInfo', queryNode.artTag), + + ancestorTagLinks: + queryNode.ancestorRootArtTags + ?.map(artTag => relation('linkArtTagInfo', artTag)) + ?? null, + + descendantNodes: + queryNode.descendantNodes + ?.map(recursive) + ?? null, + }); + + return { + page: + relation('generateListingPage', query.spec), + + rootNodes: + query.rootNodes.map(recursive), + + uppermostRootTagLinks: + query.uppermostRootTags + .map(artTag => relation('linkArtTagInfo', artTag)), + + orphanArtTagLinks: + query.orphanArtTags + .map(artTag => relation('linkArtTagInfo', artTag)), + }; + }, + + data(query) { + const rootArtTags = query.rootNodes.map(({artTag}) => artTag); + + const recursive = queryNode => ({ + directory: + queryNode.artTag.directory, + + directUses: + queryNode.stats.directUses, + + totalUses: + queryNode.stats.totalUses, + + descendants: + queryNode.stats.descendants, + + leaves: + queryNode.stats.leaves, + + representsRoot: + rootArtTags.includes(queryNode.artTag), + + ancestorTagDirectories: + queryNode.ancestorRootArtTags + ?.map(artTag => artTag.directory) + ?? null, + + descendantNodes: + queryNode.descendantNodes + ?.map(recursive) + ?? null, + }); + + return { + rootNodes: + query.rootNodes.map(recursive), + + uppermostRootTagDirectories: + query.uppermostRootTags + .map(artTag => artTag.directory), + }; + }, + + generate(data, relations, {html, language}) { + const prefix = `listingPage.listArtTags.network`; + + const wrapTagWithJumpTo = (dataNode, relationsNode, depth) => + (depth === 0 + ? relationsNode.artTagLink + : dataNode.representsRoot + ? language.$(prefix, 'tag.jumpToRoot', { + tag: + relationsNode.artTagLink.slots({ + anchor: true, + hash: dataNode.directory, + }), + }) + : relationsNode.artTagLink); + + const wrapTagWithStats = (dataNode, relationsNode, depth) => [ + html.tag('span', {class: 'network-tag'}, + language.$(prefix, 'tag', { + tag: + wrapTagWithJumpTo(dataNode, relationsNode, depth), + })), + + html.tag('span', {class: 'network-tag'}, + {class: 'with-stat'}, + {style: 'display: none'}, + + language.$(prefix, 'tag.withStat', { + tag: + wrapTagWithJumpTo(dataNode, relationsNode, depth), + + stat: + html.tag('span', {class: 'network-tag-stat'}, + language.$(prefix, 'tag.withStat.stat', { + stat: [ + html.tag('span', {class: 'network-tag-direct-uses-stat'}, + dataNode.directUses.toString()), + + html.tag('span', {class: 'network-tag-total-uses-stat'}, + dataNode.totalUses.toString()), + + html.tag('span', {class: 'network-tag-descendants-stat'}, + dataNode.descendants.toString()), + + html.tag('span', {class: 'network-tag-leaves-stat'}, + (dataNode.leaves === null + ? language.$(prefix, 'tag.withStat.notApplicable') + : dataNode.leaves.toString())), + ], + })), + })) + ]; + + const recursive = (dataNode, relationsNode, depth) => [ + html.tag('dt', + { + id: depth === 0 && dataNode.directory, + class: depth % 2 === 0 ? 'even' : 'odd', + }, + + (depth === 0 + ? (relationsNode.ancestorTagLinks + ? language.$(prefix, 'root.withAncestors', { + tag: + wrapTagWithStats(dataNode, relationsNode, depth), + + ancestors: + language.formatUnitList( + stitchArrays({ + link: relationsNode.ancestorTagLinks, + directory: dataNode.ancestorTagDirectories, + }).map(({link, directory}) => + link.slots({ + anchor: true, + hash: directory, + }))), + }) + : language.$(prefix, 'root.jumpToTop', { + tag: + wrapTagWithStats(dataNode, relationsNode, depth), + + link: + html.tag('a', {href: '#top'}, + language.$(prefix, 'root.jumpToTop.link')), + })) + : wrapTagWithStats(dataNode, relationsNode, depth))), + + dataNode.descendantNodes && + relationsNode.descendantNodes && + html.tag('dd', + {class: depth % 2 === 0 ? 'even' : 'odd'}, + html.tag('dl', + stitchArrays({ + dataNode: dataNode.descendantNodes, + relationsNode: relationsNode.descendantNodes, + }).map(({dataNode, relationsNode}) => + recursive(dataNode, relationsNode, depth + 1)))), + ]; + + return relations.page.slots({ + type: 'custom', + + content: [ + html.tag('p', {id: 'network-stat-line'}, + language.$(prefix, 'statLine', { + stat: [ + html.tag('a', {id: 'network-stat-none'}, + {href: '#'}, + language.$(prefix, 'statLine.none')), + + html.tag('a', {id: 'network-stat-total-uses'}, + {href: '#'}, + {style: 'display: none'}, + language.$(prefix, 'statLine.totalUses')), + + html.tag('a', {id: 'network-stat-direct-uses'}, + {href: '#'}, + {style: 'display: none'}, + language.$(prefix, 'statLine.directUses')), + + html.tag('a', {id: 'network-stat-descendants'}, + {href: '#'}, + {style: 'display: none'}, + language.$(prefix, 'statLine.descendants')), + + html.tag('a', {id: 'network-stat-leaves'}, + {href: '#'}, + {style: 'display: none'}, + language.$(prefix, 'statLine.leaves')), + ], + })), + + html.tag('dl', {id: 'network-top-dl'}, [ + html.tag('dt', {id: 'top'}, + language.$(prefix, 'jumpToRoot.title')), + + html.tag('dd', + html.tag('ul', + stitchArrays({ + link: relations.uppermostRootTagLinks, + directory: data.uppermostRootTagDirectories, + }).map(({link, directory}) => + html.tag('li', + language.$(prefix, 'jumpToRoot.item', { + tag: + link.slots({ + anchor: true, + hash: directory, + }), + }))))), + + stitchArrays({ + dataNode: data.rootNodes, + relationsNode: relations.rootNodes, + }).map(({dataNode, relationsNode}) => + recursive(dataNode, relationsNode, 0)), + + !empty(relations.orphanArtTagLinks) && [ + html.tag('dt', + language.$(prefix, 'orphanArtTags.title')), + + html.tag('dd', + html.tag('ul', + relations.orphanArtTagLinks.map(orphanArtTagLink => + html.tag('li', + language.$(prefix, 'orphanArtTags.item', { + tag: orphanArtTagLink, + }))))), + ], + ]), + ], + }); + }, +}; diff --git a/src/content/dependencies/listTagsByName.js b/src/content/dependencies/listArtTagsByName.js index d7022a55..1df9dfff 100644 --- a/src/content/dependencies/listTagsByName.js +++ b/src/content/dependencies/listArtTagsByName.js @@ -1,8 +1,8 @@ import {sortAlphabetically} from '#sort'; -import {stitchArrays} from '#sugar'; +import {stitchArrays, unique} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtTag'], + contentDependencies: ['generateListingPage', 'linkArtTagGallery'], extraDependencies: ['language', 'wikiData'], sprawl({artTagData}) { @@ -16,7 +16,7 @@ export default { artTags: sortAlphabetically( artTagData - .filter(tag => !tag.isContentWarning)), + .filter(artTag => !artTag.isContentWarning)), }; }, @@ -26,15 +26,18 @@ export default { artTagLinks: query.artTags - .map(tag => relation('linkArtTag', tag)), + .map(artTag => relation('linkArtTagGallery', artTag)), }; }, data(query) { return { counts: - query.artTags - .map(tag => tag.taggedInThings.length), + query.artTags.map(artTag => + unique([ + ...artTag.indirectlyFeaturedInArtworks, + ...artTag.directlyFeaturedInArtworks, + ]).length), }; }, diff --git a/src/content/dependencies/listArtTagsByUses.js b/src/content/dependencies/listArtTagsByUses.js new file mode 100644 index 00000000..eca7f1c6 --- /dev/null +++ b/src/content/dependencies/listArtTagsByUses.js @@ -0,0 +1,54 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays, unique} from '#sugar'; + +export default { + contentDependencies: ['generateListingPage', 'linkArtTagGallery'], + extraDependencies: ['language', 'wikiData'], + + sprawl: ({artTagData}) => + ({artTagData}), + + query({artTagData}, spec) { + const artTags = + sortAlphabetically( + artTagData + .filter(artTag => !artTag.isContentWarning)); + + const counts = + artTags.map(artTag => + unique([ + ...artTag.directlyFeaturedInArtworks, + ...artTag.indirectlyFeaturedInArtworks, + ]).length); + + filterByCount(artTags, counts); + sortByCount(artTags, counts, {greatestFirst: true}); + + return {spec, artTags, counts}; + }, + + relations: (relation, query) => ({ + page: + relation('generateListingPage', query.spec), + + artTagLinks: + query.artTags + .map(artTag => relation('linkArtTagGallery', artTag)), + }), + + data: (query) => + ({counts: query.counts}), + + generate: (data, relations, {language}) => + relations.page.slots({ + type: 'rows', + rows: + stitchArrays({ + link: relations.artTagLinks, + count: data.counts, + }).map(({link, count}) => ({ + tag: link, + timesUsed: language.countTimesUsed(count, {unit: true}), + })), + }), +}; diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js index 41944959..99f19764 100644 --- a/src/content/dependencies/listArtistsByContributions.js +++ b/src/content/dependencies/listArtistsByContributions.js @@ -1,13 +1,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; - -import { - accumulateSum, - empty, - filterByCount, - filterMultipleArrays, - stitchArrays, - unique, -} from '#sugar'; +import {empty, filterByCount, filterMultipleArrays, stitchArrays} + from '#sugar'; export default { contentDependencies: ['generateListingPage', 'linkArtist'], @@ -41,37 +34,46 @@ export default { query[countsKey] = counts; }; + const countContributions = (artist, keys) => { + const contribs = + keys + .flatMap(key => artist[key]) + .filter(contrib => contrib.countInContributionTotals); + + const things = + new Set(contribs.map(contrib => contrib.thing)); + + return things.size; + }; + queryContributionInfo( 'artistsByTrackContributions', 'countsByTrackContributions', artist => - (unique( - ([ - artist.trackArtistContributions, - artist.trackContributorContributions, - ]).flat() - .map(({thing}) => thing) - )).length); + countContributions(artist, [ + 'trackArtistContributions', + 'trackContributorContributions', + ])); queryContributionInfo( 'artistsByArtworkContributions', 'countsByArtworkContributions', artist => - accumulateSum( - [ - artist.albumCoverArtistContributions, - artist.albumWallpaperArtistContributions, - artist.albumBannerArtistContributions, - artist.trackCoverArtistContributions, - ], - contribs => contribs.length)); + countContributions(artist, [ + 'albumCoverArtistContributions', + 'albumWallpaperArtistContributions', + 'albumBannerArtistContributions', + 'trackCoverArtistContributions', + ])); if (sprawl.enableFlashesAndGames) { queryContributionInfo( 'artistsByFlashContributions', 'countsByFlashContributions', artist => - artist.flashContributorContributions.length); + countContributions(artist, [ + 'flashContributorContributions', + ])); } return query; diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js index 0bf9dd2d..17096cfc 100644 --- a/src/content/dependencies/listArtistsByGroup.js +++ b/src/content/dependencies/listArtistsByGroup.js @@ -37,20 +37,25 @@ export default { ([ (unique( ([ - artist.albumArtistContributions, - artist.albumCoverArtistContributions, - artist.albumWallpaperArtistContributions, - artist.albumBannerArtistContributions, + artist.albumArtistContributions + .map(contrib => contrib.thing), + artist.albumCoverArtistContributions + .map(contrib => contrib.thing.thing), + artist.albumWallpaperArtistContributions + .map(contrib => contrib.thing.thing), + artist.albumBannerArtistContributions + .map(contrib => contrib.thing.thing), ]).flat() - .map(({thing}) => thing) )).map(album => album.groups), (unique( ([ - artist.trackArtistContributions, - artist.trackContributorContributions, - artist.trackCoverArtistContributions, + artist.trackArtistContributions + .map(contrib => contrib.thing), + artist.trackContributorContributions + .map(contrib => contrib.thing), + artist.trackCoverArtistContributions + .map(contrib => contrib.thing.thing), ]).flat() - .map(({thing}) => thing) )).map(track => track.album.groups), ]).flat() .map(groups => groups diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js index 27a2faa3..2a8d1b4c 100644 --- a/src/content/dependencies/listArtistsByLatestContribution.js +++ b/src/content/dependencies/listArtistsByLatestContribution.js @@ -98,13 +98,16 @@ export default { ])) { // Might combine later with 'track' of the same album and date. considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork'); + // '?? album.date' is kept here because wallpaper and banner may + // technically be present for an album w/o cover art, therefore + // also no cover art date. } } for (const track of tracksLatestFirst) { for (const artist of getArtists(track, 'coverArtistContribs')) { // No special effect if artist already has 'artwork' for the same album and date. - considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork'); + considerDate(artist, track.coverArtDate, track.album, 'artwork'); } for (const artist of new Set([ diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js index da2f26db..c79e1bc4 100644 --- a/src/content/dependencies/listGroupsByDuration.js +++ b/src/content/dependencies/listGroupsByDuration.js @@ -16,7 +16,7 @@ export default { groups.map(group => getTotalDuration( group.albums.flatMap(album => album.tracks), - {originalReleasesOnly: true})); + {mainReleasesOnly: true})); filterByCount(groups, durations); sortByCount(groups, durations, {greatestFirst: true}); diff --git a/src/content/dependencies/listTagsByUses.js b/src/content/dependencies/listTagsByUses.js deleted file mode 100644 index 00c700a5..00000000 --- a/src/content/dependencies/listTagsByUses.js +++ /dev/null @@ -1,59 +0,0 @@ -import {sortAlphabetically, sortByCount} from '#sort'; -import {filterByCount, stitchArrays} from '#sugar'; - -export default { - contentDependencies: ['generateListingPage', 'linkArtTag'], - extraDependencies: ['language', 'wikiData'], - - sprawl({artTagData}) { - return {artTagData}; - }, - - query({artTagData}, spec) { - const artTags = - sortAlphabetically( - artTagData - .filter(tag => !tag.isContentWarning)); - - const counts = - artTags - .map(tag => tag.taggedInThings.length); - - filterByCount(artTags, counts); - sortByCount(artTags, counts, {greatestFirst: true}); - - return {spec, artTags, counts}; - }, - - relations(relation, query) { - return { - page: relation('generateListingPage', query.spec), - - artTagLinks: - query.artTags - .map(tag => relation('linkArtTag', tag)), - }; - }, - - data(query) { - return { - counts: - query.artTags - .map(tag => tag.taggedInThings.length), - }; - }, - - generate(data, relations, {language}) { - return relations.page.slots({ - type: 'rows', - rows: - stitchArrays({ - link: relations.artTagLinks, - count: data.counts, - }).map(({link, count}) => ({ - tag: link, - timesUsed: language.countTimesUsed(count, {unit: true}), - })), - }); - }, -}; diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js index 0a2bfd6c..dcfaeaf0 100644 --- a/src/content/dependencies/listTracksByDate.js +++ b/src/content/dependencies/listTracksByDate.js @@ -5,49 +5,54 @@ export default { contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], extraDependencies: ['language', 'wikiData'], - sprawl({trackData}) { - return {trackData}; - }, + sprawl: ({trackData}) => ({trackData}), query({trackData}, spec) { - return { - spec, + const query = {spec}; + + query.tracks = + sortAlbumsTracksChronologically( + trackData.filter(track => track.date)); + + query.chunks = + chunkByProperties(query.tracks, ['album', 'date']); - chunks: - chunkByProperties( - sortAlbumsTracksChronologically( - trackData.filter(track => track.date)), - ['album', 'date']), - }; + return query; }, - relations(relation, query) { - return { - page: relation('generateListingPage', query.spec), + relations: (relation, query) => ({ + page: + relation('generateListingPage', query.spec), - albumLinks: - query.chunks - .map(({album}) => relation('linkAlbum', album)), + albumLinks: + query.chunks + .map(({album}) => relation('linkAlbum', album)), - trackLinks: - query.chunks - .map(({chunk}) => chunk - .map(track => relation('linkTrack', track))), - }; - }, + trackLinks: + query.chunks + .map(({chunk}) => chunk + .map(track => relation('linkTrack', track))), + }), - data(query) { - return { - dates: - query.chunks - .map(({date}) => date), + data: (query) => ({ + dates: + query.chunks + .map(({date}) => date), - rereleases: - query.chunks.map(({chunk}) => - chunk.map(track => - track.originalReleaseTrack !== null)), - }; - }, + rereleases: + query.chunks + .map(({chunk}) => chunk + .map(track => + // Check if the index of this track... + query.tracks.indexOf(track) > + // ...is greater than the *smallest* index + // of any of this track's *other* releases. + // (It won't be greater than its own index, + // so we can use otherReleases here, rather + // than allReleases.) + Math.min(... + track.otherReleases.map(t => query.tracks.indexOf(t))))), + }), generate(data, relations, {language}) { return relations.page.slots({ @@ -79,7 +84,7 @@ export default { data.rereleases.map(rereleases => rereleases.map(rerelease => (rerelease - ? {class: 'rerelease'} + ? {class: 'rerelease-line'} : null))), }); }, diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js index a13a76f0..e6ab9d7d 100644 --- a/src/content/dependencies/listTracksWithLyrics.js +++ b/src/content/dependencies/listTracksWithLyrics.js @@ -2,7 +2,7 @@ export default { contentDependencies: ['listTracksWithExtra'], relations: (relation, spec) => - ({page: relation('listTracksWithExtra', spec, 'lyrics', 'truthy')}), + ({page: relation('listTracksWithExtra', spec, 'lyrics', 'array')}), generate: (relations) => relations.page, diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index 48e20f94..e9a75744 100644 --- a/src/content/dependencies/transformContent.js +++ b/src/content/dependencies/transformContent.js @@ -1,7 +1,11 @@ +import {basename} from 'node:path'; + +import {logWarn} from '#cli'; import {bindFind} from '#find'; -import {replacerSpec, parseInput} from '#replacer'; +import {replacerSpec, parseContentNodes} from '#replacer'; import {Marked} from 'marked'; +import striptags from 'striptags'; const commonMarkedOptions = { headerIds: false, @@ -45,24 +49,44 @@ function getPlaceholder(node, content) { return {type: 'text', data: content.slice(node.i, node.iEnd)}; } +function getArg(node, argKey) { + return ( + node.data.args + ?.find(({key}) => key.data === argKey) + ?.value ?? + null); +} + export default { contentDependencies: [ ...( Object.values(replacerSpec) .map(description => description.link) .filter(Boolean)), + 'image', + 'generateTextWithTooltip', + 'generateTooltip', 'linkExternal', ], - extraDependencies: ['html', 'language', 'to', 'wikiData'], + extraDependencies: [ + 'html', + 'language', + 'niceShowAggregate', + 'to', + 'wikiData', + ], sprawl(wikiData, content) { - const find = bindFind(wikiData); + const find = bindFind(wikiData, {mode: 'quiet'}); - const parsedNodes = parseInput(content ?? ''); + const {result: parsedNodes, error} = + parseContentNodes(content ?? '', {errorMode: 'return'}); return { + error, + nodes: parsedNodes .map(node => { if (node.type !== 'tag') { @@ -133,6 +157,30 @@ export default { return {i: node.i, iEnd: node.iEnd, type: 'internal-link', data}; } + if (replacerKey === 'tooltip') { + // TODO: Again, no recursive nodes. Sorry! + // const enteredLabel = node.data.label && transformNode(node.data.label, opts); + const enteredLabel = node.data.label?.data; + + return { + i: node.i, + iEnd: node.iEnd, + type: 'tooltip', + data: { + tooltip: + replacerValue ?? '(empty tooltip...)', + + label: + enteredLabel ?? '(tooltip without label)', + + link: + (getArg(node, 'link') + ? getArg(node, 'link')[0].data + : null), + }, + }; + } + // This will be another {type: 'tag'} node which gets processed in // generate. Extract replacerKey and replacerValue now, since it'd // be a pain to deal with later. @@ -152,6 +200,9 @@ export default { return { content, + error: + sprawl.error, + nodes: sprawl.nodes .map(node => { @@ -184,10 +235,18 @@ export default { link: relation(name, arg), label: node.data.label, hash: node.data.hash, + name: arg?.name, + shortName: arg?.shortName ?? arg?.nameShort, } : getPlaceholder(node, content)); return { + textWithTooltip: + relation('generateTextWithTooltip'), + + tooltip: + relation('generateTooltip'), + internalLinks: nodes .filter(({type}) => type === 'internal-link') @@ -206,11 +265,15 @@ export default { externalLinks: nodes .filter(({type}) => type === 'external-link') - .map(node => { - const {href} = node.data; + .map(({data: {href}}) => + relation('linkExternal', href)), - return relation('linkExternal', href); - }), + externalLinksForTooltipNodes: + nodes + .filter(({type}) => type === 'tooltip') + .filter(({data}) => data.link) + .map(({data: {link: href}}) => + relation('linkExternal', href)), images: nodes @@ -241,16 +304,27 @@ export default { default: true, }, + textOnly: { + type: 'boolean', + default: false, + }, + thumb: { validate: v => v.is('small', 'medium', 'large'), default: 'large', }, }, - generate(data, relations, slots, {html, language, to}) { + generate(data, relations, slots, {html, language, niceShowAggregate, to}) { + if (data.error) { + logWarn`Error in content text.`; + niceShowAggregate(data.error); + } + let imageIndex = 0; let internalLinkIndex = 0; let externalLinkIndex = 0; + let externalLinkForTooltipNodeIndex = 0; let offsetTextNode = 0; @@ -258,6 +332,20 @@ export default { data.nodes.map((node, index) => { const nextNode = data.nodes[index + 1]; + const absorbFollowingPunctuation = template => { + if (nextNode?.type !== 'text') { + return; + } + + const text = nextNode.data; + const match = text.match(/^[.,;:?!…]+(?=[^\n]*[a-z])/i); + const suffix = match?.[0]; + if (suffix) { + template.setSlot('suffixNormalContent', suffix); + offsetTextNode = suffix.length; + } + }; + switch (node.type) { case 'text': { const text = node.data.slice(offsetTextNode); @@ -291,9 +379,8 @@ export default { height && {height}, style && {style}, - align === 'center' && - !link && - {class: 'align-center'}, + align && !link && + {class: 'align-' + align}, pixelate && {class: 'pixelate'}); @@ -304,8 +391,8 @@ export default { {href: link}, {target: '_blank'}, - align === 'center' && - {class: 'align-center'}, + align && + {class: 'align-' + align}, {title: language.encapsulate('misc.external.opensInNewTab', capsule => @@ -355,8 +442,8 @@ export default { inline: false, data: html.tag('div', {class: 'content-image-container'}, - align === 'center' && - {class: 'align-center'}, + align && + {class: 'align-' + align}, image), }; @@ -368,32 +455,74 @@ export default { ? to('media.path', node.src.slice('media/'.length)) : node.src); - const { - width, - height, - align, - pixelate, - } = node; + const {width, height, align, inline, pixelate} = node; - const content = - html.tag('div', {class: 'content-video-container'}, - html.tag('video', - src && {src}, - width && {width}, - height && {height}, + const video = + html.tag('video', + src && {src}, + width && {width}, + height && {height}, - {controls: true}, + {controls: true}, - align === 'center' && - {class: 'align-center'}, + align && inline && + {class: 'align-' + align}, + + pixelate && + {class: 'pixelate'}); + + const content = + (inline + ? video + : html.tag('div', {class: 'content-video-container'}, + align && + {class: 'align-' + align}, + + video)); - pixelate && - {class: 'pixelate'})); return { type: 'processed-video', - data: - content, + data: content, + }; + } + + case 'audio': { + const src = + (node.src.startsWith('media/') + ? to('media.path', node.src.slice('media/'.length)) + : node.src); + + const {align, inline, nameless} = node; + + const audio = + html.tag('audio', + src && {src}, + + align && inline && + {class: 'align-' + align}, + + {controls: true}); + + const content = + (inline + ? audio + : html.tag('div', {class: 'content-audio-container'}, + align && + {class: 'align-' + align}, + + [ + !nameless && + html.tag('a', {class: 'filename'}, + src && {href: src}, + language.sanitize(basename(node.src))), + + audio, + ])); + + return { + type: 'processed-audio', + data: content, }; } @@ -411,7 +540,17 @@ export default { nodeFromRelations.link, {slots: ['content', 'hash']}); - const {label, hash} = nodeFromRelations; + const {label, hash, shortName, name} = nodeFromRelations; + + if (slots.textOnly) { + if (label) { + return {type: 'text', data: label}; + } else if (slots.preferShortLinkNames) { + return {type: 'text', data: shortName ?? name}; + } else { + return {type: 'text', data: name}; + } + } // These are removed from the typical combined slots({})-style // because we don't want to override slots that were already set @@ -425,7 +564,7 @@ export default { try { link.getSlotDescription('preferShortName'); hasPreferShortNameSlot = true; - } catch (error) { + } catch { hasPreferShortNameSlot = false; } @@ -438,7 +577,7 @@ export default { try { link.getSlotDescription('tooltipStyle'); hasTooltipStyleSlot = true; - } catch (error) { + } catch { hasTooltipStyleSlot = false; } @@ -446,6 +585,18 @@ export default { link.setSlot('tooltipStyle', 'none'); } + let doTheAbsorbyThing = false; + + // TODO: This is just silly. + try { + const tag = html.resolve(link, {normalize: 'tag'}); + doTheAbsorbyThing ||= tag.attributes.has('class', 'image-media-link'); + } catch {} + + if (doTheAbsorbyThing) { + absorbFollowingPunctuation(link); + } + return {type: 'processed-internal-link', data: link}; } @@ -453,19 +604,17 @@ export default { const {label} = node.data; const externalLink = relations.externalLinks[externalLinkIndex++]; + if (slots.textOnly) { + return {type: 'text', data: label}; + } + externalLink.setSlots({ content: label, fromContent: true, }); - if (slots.absorbPunctuationFollowingExternalLinks && nextNode?.type === 'text') { - const text = nextNode.data; - const match = text.match(/^[.,;:?!…]+/); - const suffix = match?.[0]; - if (suffix) { - externalLink.setSlot('suffixNormalContent', suffix); - offsetTextNode = suffix.length; - } + if (slots.absorbPunctuationFollowingExternalLinks) { + absorbFollowingPunctuation(externalLink); } if (slots.indicateExternalLinks) { @@ -479,6 +628,52 @@ export default { return {type: 'processed-external-link', data: externalLink}; } + case 'tooltip': { + const {label, link, tooltip: tooltipContent} = node.data; + + const externalLink = + (link + ? relations.externalLinksForTooltipNodes + .at(externalLinkForTooltipNodeIndex++) + : null); + + if (externalLink) { + externalLink.setSlots({ + content: label, + fromContent: true, + }); + + if (slots.indicateExternalLinks) { + externalLink.setSlots({ + indicateExternal: true, + disableBrowserTooltip: true, + tab: 'separate', + style: 'platform', + }); + } + } + + const textWithTooltip = relations.textWithTooltip.clone(); + const tooltip = relations.tooltip.clone(); + + tooltip.setSlots({ + attributes: {class: 'content-tooltip'}, + content: tooltipContent, // Not sanitized! + }); + + textWithTooltip.setSlots({ + attributes: [ + {class: 'content-tooltip-guy'}, + externalLink && {class: 'has-link'}, + ], + + text: externalLink ?? label, + tooltip, + }); + + return {type: 'processed-tooltip', data: textWithTooltip}; + } + case 'tag': { const {replacerKey, replacerValue} = node.data; @@ -495,12 +690,19 @@ export default { ? valueFn(replacerValue) : replacerValue); - const contents = + const content = (htmlFn ? htmlFn(value, {html, language}) : value); - return {type: 'text', data: contents.toString()}; + const contentText = + html.resolve(content, {normalize: 'string'}); + + if (slots.textOnly) { + return {type: 'text', data: striptags(contentText)}; + } else { + return {type: 'text', data: contentText}; + } } default: @@ -584,7 +786,8 @@ export default { if ( (attributes.get('data-type') === 'processed-image' && !attributes.get('data-inline')) || - attributes.get('data-type') === 'processed-video' + attributes.get('data-type') === 'processed-video' || + attributes.get('data-type') === 'processed-audio' ) { tags[tags.length - 1] = tags[tags.length - 1].replace(/<p>$/, ''); deleteParagraph = true; @@ -661,25 +864,12 @@ export default { const markedInput = extractNonTextNodes({ - getTextNodeContents(node, index) { - // First, replace line breaks that follow text content with - // <br> tags. - let content = node.data.replace(/(?!^)\n/gm, '<br>\n'); - - // Scrap line breaks that are at the end of a verse. - content = content.replace(/<br>$(?=\n\n)/gm, ''); - - // If the node started with a line break, and it's not the - // very first node, then whatever came before it was inline. - // (This is an assumption based on text links being basically - // the only tag that shows up in lyrics.) Since this text is - // following content that was already inline, restore that - // initial line break. - if (node.data[0] === '\n' && index !== 0) { - content = '<br>' + content; - } - - return content; + getTextNodeContents(node) { + // Just insert <br> before every line break. The resulting + // text will appear all in one paragraph - this is expected + // for lyrics, and allows for multiple lines of proportional + // space between stanzas. + return node.data.replace(/\n/g, '<br>\n'); }, }); diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js index 010d967a..651a61cf 100644 --- a/src/data/cacheable-object.js +++ b/src/data/cacheable-object.js @@ -1,79 +1,3 @@ -// Generally extendable class for caching properties and handling dependencies, -// with a few key properties: -// -// 1) The behavior of every property is defined by its descriptor, which is a -// static value stored on the subclass (all instances share the same property -// descriptors). -// -// 1a) Additional properties may not be added past the time of object -// construction, and attempts to do so (including externally setting a -// property name which has no corresponding descriptor) will throw a -// TypeError. (This is done via an Object.seal(this) call after a newly -// created instance defines its own properties according to the descriptor -// on its constructor class.) -// -// 2) Properties may have two flags set: update and expose. Properties which -// update are provided values from the external. Properties which expose -// provide values to the external, generally dependent on other update -// properties (within the same object). -// -// 2a) Properties may be flagged as both updating and exposing. This is so -// that the same name may be used for both "output" and "input". -// -// 3) Exposed properties have values which are computations dependent on other -// properties, as described by a `compute` function on the descriptor. -// Depended-upon properties are explicitly listed on the descriptor next to -// this function, and are only provided as arguments to the function once -// listed. -// -// 3a) An exposed property may depend only upon updating properties, not other -// exposed properties (within the same object). This is to force the -// general complexity of a single object to be fairly simple: inputs -// directly determine outputs, with the only in-between step being the -// `compute` function, no multiple-layer dependencies. Note that this is -// only true within a given object - externally, values provided to one -// object's `update` may be (and regularly are) the exposed values of -// another object. -// -// 3b) If a property both updates and exposes, it is automatically regarded as -// a dependancy. (That is, its exposed value will depend on the value it is -// updated with.) Rather than a required `compute` function, these have an -// optional `transform` function, which takes the update value as its first -// argument and then the usual key-value dependencies as its second. If no -// `transform` function is provided, the expose value is the same as the -// update value. -// -// 4) Exposed properties are cached; that is, if no depended-upon properties are -// updated, the value of an exposed property is not recomputed. -// -// 4a) The cache for an exposed property is invalidated as soon as any of its -// dependencies are updated, but the cache itself is lazy: the exposed -// value will not be recomputed until it is again accessed. (Likewise, an -// exposed value won't be computed for the first time until it is first -// accessed.) -// -// 5) Updating a property may optionally apply validation checks before passing, -// declared by a `validate` function on the `update` block. This function -// should either throw an error (e.g. TypeError) or return false if the value -// is invalid. -// -// 6) Objects do not expect all updating properties to be provided at once. -// Incomplete objects are deliberately supported and enabled. -// -// 6a) The default value for every updating property is null; undefined is not -// accepted as a property value under any circumstances (it always errors). -// However, this default may be overridden by specifying a `default` value -// on a property's `update` block. (This value will be checked against -// the property's validate function.) Note that a property may always be -// updated to null, even if the default is non-null. (Null always bypasses -// the validate check.) -// -// 6b) It's required by the external consumer of an object to determine whether -// or not the object is ready for use (within the larger program). This is -// convenienced by the static CacheableObject.listAccessibleProperties() -// function, which provides a mapping of exposed property names to whether -// or not their dependencies are yet met. - import {inspect as nodeInspect} from 'node:util'; import {colors, ENABLE_COLOR} from '#cli'; @@ -84,53 +8,21 @@ function inspect(value) { export default class CacheableObject { static propertyDescriptors = Symbol.for('CacheableObject.propertyDescriptors'); + static constructorFinalized = Symbol.for('CacheableObject.constructorFinalized'); + static propertyDependants = Symbol.for('CacheableObject.propertyDependants'); - #propertyUpdateValues = Object.create(null); - #propertyUpdateCacheInvalidators = Object.create(null); - - // Note the constructor doesn't take an initial data source. Due to a quirk - // of JavaScript, private members can't be accessed before the superclass's - // constructor is finished processing - so if we call the overridden - // update() function from inside this constructor, it will error when - // writing to private members. Pretty bad! - // - // That means initial data must be provided by following up with update() - // after constructing the new instance of the Thing (sub)class. - - constructor() { - this.#defineProperties(); - this.#initializeUpdatingPropertyValues(); - - if (CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return new Proxy(this, { - get: (obj, key) => { - if (!Object.hasOwn(obj, key)) { - if (key !== 'constructor') { - CacheableObject._invalidAccesses.add(`(${obj.constructor.name}).${key}`); - } - } - return obj[key]; - }, - }); - } - } + static cacheValid = Symbol.for('CacheableObject.cacheValid'); + static updateValue = Symbol.for('CacheableObject.updateValues'); - #withEachPropertyDescriptor(callback) { - const {[CacheableObject.propertyDescriptors]: propertyDescriptors} = - this.constructor; + constructor({seal = true} = {}) { + this[CacheableObject.updateValue] = Object.create(null); + this[CacheableObject.cachedValue] = Object.create(null); + this[CacheableObject.cacheValid] = Object.create(null); + const propertyDescriptors = this.constructor[CacheableObject.propertyDescriptors]; for (const property of Reflect.ownKeys(propertyDescriptors)) { - callback(property, propertyDescriptors[property]); - } - } - - #initializeUpdatingPropertyValues() { - this.#withEachPropertyDescriptor((property, descriptor) => { - const {flags, update} = descriptor; - - if (!flags.update) { - return; - } + const {flags, update} = propertyDescriptors[property]; + if (!flags.update) continue; if ( typeof update === 'object' && @@ -141,188 +33,161 @@ export default class CacheableObject { } else { this[property] = null; } - }); + } + + if (seal) { + Object.seal(this); + } } - #defineProperties() { - if (!this.constructor[CacheableObject.propertyDescriptors]) { - throw new Error(`Expected constructor ${this.constructor.name} to provide CacheableObject.propertyDescriptors`); + static finalizeCacheableObjectPrototype() { + if (Object.hasOwn(this, CacheableObject.constructorFinalized)) { + throw new Error(`Constructor ${this.name} already finalized`); + } + + if (!this[CacheableObject.propertyDescriptors]) { + throw new Error(`Expected constructor ${this.name} to provide CacheableObject.propertyDescriptors`); } - this.#withEachPropertyDescriptor((property, descriptor) => { - const {flags} = descriptor; + this[CacheableObject.propertyDependants] = Object.create(null); + + const propertyDescriptors = this[CacheableObject.propertyDescriptors]; + for (const property of Reflect.ownKeys(propertyDescriptors)) { + const {flags, update, expose} = propertyDescriptors[property]; const definition = { configurable: false, enumerable: flags.expose, }; - if (flags.update) { - definition.set = this.#getUpdateObjectDefinitionSetterFunction(property); - } - - if (flags.expose) { - definition.get = this.#getExposeObjectDefinitionGetterFunction(property); - } - - Object.defineProperty(this, property, definition); - }); - - Object.seal(this); - } + if (flags.update) setSetter: { + definition.set = function(newValue) { + if (newValue === undefined) { + throw new TypeError(`Properties cannot be set to undefined`); + } - #getUpdateObjectDefinitionSetterFunction(property) { - const {update} = this.#getPropertyDescriptor(property); - const validate = update?.validate; + const oldValue = this[CacheableObject.updateValue][property]; - return (newValue) => { - const oldValue = this.#propertyUpdateValues[property]; + if (newValue === oldValue) { + return; + } - if (newValue === undefined) { - throw new TypeError(`Properties cannot be set to undefined`); - } + if (newValue !== null && update?.validate) { + try { + const result = update.validate(newValue); + if (result === undefined) { + throw new TypeError(`Validate function returned undefined`); + } else if (result !== true) { + throw new TypeError(`Validation failed for value ${newValue}`); + } + } catch (caughtError) { + throw new CacheableObjectPropertyValueError( + property, oldValue, newValue, {cause: caughtError}); + } + } - if (newValue === oldValue) { - return; - } + this[CacheableObject.updateValue][property] = newValue; - if (newValue !== null && validate) { - try { - const result = validate(newValue); - if (result === undefined) { - throw new TypeError(`Validate function returned undefined`); - } else if (result !== true) { - throw new TypeError(`Validation failed for value ${newValue}`); + const dependants = this.constructor[CacheableObject.propertyDependants][property]; + if (dependants) { + for (const dependant of dependants) { + this[CacheableObject.cacheValid][dependant] = false; + } } - } catch (caughtError) { - throw new CacheableObjectPropertyValueError( - property, oldValue, newValue, {cause: caughtError}); - } + }; } - this.#propertyUpdateValues[property] = newValue; - this.#invalidateCachesDependentUpon(property); - }; - } - - #getPropertyDescriptor(property) { - return this.constructor[CacheableObject.propertyDescriptors][property]; - } + if (flags.expose) setGetter: { + if (flags.update && !expose?.transform) { + definition.get = function() { + return this[CacheableObject.updateValue][property]; + }; - #invalidateCachesDependentUpon(property) { - const invalidators = this.#propertyUpdateCacheInvalidators[property]; - if (!invalidators) { - return; - } + break setGetter; + } - for (const invalidate of invalidators) { - invalidate(); - } - } + if (flags.update && expose?.compute) { + throw new Error(`Updating property ${property} has compute function, should be formatted as transform`); + } - #getExposeObjectDefinitionGetterFunction(property) { - const {flags} = this.#getPropertyDescriptor(property); - const compute = this.#getExposeComputeFunction(property); - - if (compute) { - let cachedValue; - const checkCacheValid = this.#getExposeCheckCacheValidFunction(property); - return () => { - if (checkCacheValid()) { - return cachedValue; - } else { - return (cachedValue = compute()); + if (!flags.update && !expose?.compute) { + throw new Error(`Exposed property ${property} does not update and is missing compute function`); } - }; - } else if (!flags.update && !compute) { - throw new Error(`Exposed property ${property} does not update and is missing compute function`); - } else { - return () => this.#propertyUpdateValues[property]; - } - } - #getExposeComputeFunction(property) { - const {flags, expose} = this.#getPropertyDescriptor(property); + definition.get = function() { + if (this[CacheableObject.cacheValid][property]) { + return this[CacheableObject.cachedValue][property]; + } - const compute = expose?.compute; - const transform = expose?.transform; + const dependencies = Object.create(null); + for (const key of expose.dependencies ?? []) { + switch (key) { + case 'this': + dependencies.this = this; + break; - if (flags.update && !transform) { - return null; - } else if (flags.update && compute) { - throw new Error(`Updating property ${property} has compute function, should be formatted as transform`); - } else if (!flags.update && !compute) { - throw new Error(`Exposed property ${property} does not update and is missing compute function`); - } + case 'thisProperty': + dependencies.thisProperty = property; + break; - let getAllDependencies; + default: + dependencies[key] = this[CacheableObject.updateValue][key]; + break; + } + } - if (expose.dependencies?.length > 0) { - const dependencyKeys = expose.dependencies.slice(); - const shouldReflectObject = dependencyKeys.includes('this'); - const shouldReflectProperty = dependencyKeys.includes('thisProperty'); + const value = + (flags.update + ? expose.transform(this[CacheableObject.updateValue][property], dependencies) + : expose.compute(dependencies)); - getAllDependencies = () => { - const dependencies = Object.create(null); + this[CacheableObject.cachedValue][property] = value; + this[CacheableObject.cacheValid][property] = true; - for (const key of dependencyKeys) { - dependencies[key] = this.#propertyUpdateValues[key]; - } + return value; + }; + } + + if (flags.expose) recordAsDependant: { + const dependantsMap = this[CacheableObject.propertyDependants]; - if (shouldReflectObject) { - dependencies.this = this; + if (flags.update && expose?.transform) { + if (dependantsMap[property]) { + dependantsMap[property].push(property); + } else { + dependantsMap[property] = [property]; + } } - if (shouldReflectProperty) { - dependencies.thisProperty = property; + for (const dependency of expose?.dependencies ?? []) { + switch (dependency) { + case 'this': + case 'thisProperty': + continue; + + default: { + if (dependantsMap[dependency]) { + dependantsMap[dependency].push(property); + } else { + dependantsMap[dependency] = [property]; + } + } + } } + } - return dependencies; - }; - } else { - const dependencies = Object.create(null); - Object.freeze(dependencies); - getAllDependencies = () => dependencies; + Object.defineProperty(this.prototype, property, definition); } - if (flags.update) { - return () => transform(this.#propertyUpdateValues[property], getAllDependencies()); - } else { - return () => compute(getAllDependencies()); - } + this[CacheableObject.constructorFinalized] = true; } - #getExposeCheckCacheValidFunction(property) { - const {flags, expose} = this.#getPropertyDescriptor(property); - - let valid = false; - - const invalidate = () => { - valid = false; - }; - - const dependencyKeys = new Set(expose?.dependencies); - - if (flags.update) { - dependencyKeys.add(property); - } - - for (const key of dependencyKeys) { - if (this.#propertyUpdateCacheInvalidators[key]) { - this.#propertyUpdateCacheInvalidators[key].push(invalidate); - } else { - this.#propertyUpdateCacheInvalidators[key] = [invalidate]; - } - } + static getPropertyDescriptor(property) { + return this[CacheableObject.propertyDescriptors][property]; + } - return () => { - if (!valid) { - valid = true; - return false; - } else { - return true; - } - }; + static hasPropertyDescriptor(property) { + return Object.hasOwn(this[CacheableObject.propertyDescriptors], property); } static cacheAllExposedProperties(obj) { @@ -349,30 +214,12 @@ export default class CacheableObject { } } - static DEBUG_SLOW_TRACK_INVALID_PROPERTIES = false; - static _invalidAccesses = new Set(); - - static showInvalidAccesses() { - if (!this.DEBUG_SLOW_TRACK_INVALID_PROPERTIES) { - return; - } - - if (!this._invalidAccesses.size) { - return; - } - - console.log(`${this._invalidAccesses.size} unique invalid accesses:`); - for (const line of this._invalidAccesses) { - console.log(` - ${line}`); - } - } - static getUpdateValue(object, key) { - if (!Object.hasOwn(object, key)) { + if (!object.constructor.hasPropertyDescriptor(key)) { return undefined; } - return object.#propertyUpdateValues[key] ?? null; + return object[CacheableObject.updateValue][key] ?? null; } static clone(object) { @@ -384,7 +231,7 @@ export default class CacheableObject { } static copyUpdateValuesOnto(source, target) { - Object.assign(target, source.#propertyUpdateValues); + Object.assign(target, source[CacheableObject.updateValue]); } } @@ -392,8 +239,22 @@ export class CacheableObjectPropertyValueError extends Error { [Symbol.for('hsmusic.aggregate.translucent')] = true; constructor(property, oldValue, newValue, options) { + let inspectOldValue, inspectNewValue; + + try { + inspectOldValue = inspect(oldValue); + } catch { + inspectOldValue = colors.red(`(couldn't inspect)`); + } + + try { + inspectNewValue = inspect(newValue); + } catch { + inspectNewValue = colors.red(`(couldn't inspect)`); + } + super( - `Error setting ${colors.green(property)} (${inspect(oldValue)} -> ${inspect(newValue)})`, + `Error setting ${colors.green(property)} (${inspectOldValue} -> ${inspectNewValue})`, options); this.property = property; diff --git a/src/data/checks.js b/src/data/checks.js index 8f9f0305..3fcb6d3b 100644 --- a/src/data/checks.js +++ b/src/data/checks.js @@ -4,12 +4,11 @@ import {inspect as nodeInspect} from 'node:util'; import {colors, ENABLE_COLOR} from '#cli'; import CacheableObject from '#cacheable-object'; -import {replacerSpec, parseInput} from '#replacer'; +import {replacerSpec, parseContentNodes} from '#replacer'; import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline} from '#sugar'; import Thing from '#thing'; import thingConstructors from '#things'; -import {combineWikiDataArrays, commentaryRegexCaseSensitive} from '#wiki-data'; import { annotateErrorWithIndex, @@ -50,7 +49,7 @@ export function reportDirectoryErrors(wikiData, { if (!thingData) continue; for (const thing of thingData) { - if (findSpec.include && !findSpec.include(thing)) { + if (findSpec.include && !findSpec.include(thing, thingConstructors)) { continue; } @@ -185,18 +184,34 @@ export function filterReferenceErrors(wikiData, { groups: 'group', artTags: '_artTag', referencedArtworks: '_artwork', - commentary: '_commentary', + commentary: '_content', + creditingSources: '_content', + }], + + ['artTagData', { + directDescendantArtTags: 'artTag', + }], + + ['artworkData', { + referencedArtworks: '_artwork', }], ['flashData', { - commentary: '_commentary', + commentary: '_content', + creditingSources: '_content', }], ['groupCategoryData', { groups: 'group', }], - ['homepageLayout.rows', { + ['homepageLayout.sections.rows', { + _include: row => row.type === 'album carousel', + albums: 'album', + }], + + ['homepageLayout.sections.rows', { + _include: row => row.type === 'album grid', sourceGroup: '_homepageSourceGroup', sourceAlbums: 'album', }], @@ -210,20 +225,23 @@ export function filterReferenceErrors(wikiData, { flashes: 'flash', }], - ['groupData', { - serieses: '_serieses', + ['seriesData', { + albums: 'album', }], ['trackData', { artistContribs: '_contrib', contributorContribs: '_contrib', coverArtistContribs: '_contrib', - referencedTracks: '_trackNotRerelease', - sampledTracks: '_trackNotRerelease', + referencedTracks: '_trackMainReleasesOnly', + sampledTracks: '_trackMainReleasesOnly', artTags: '_artTag', referencedArtworks: '_artwork', - originalReleaseTrack: '_trackNotRerelease', - commentary: '_commentary', + mainReleaseTrack: '_trackMainReleasesOnly', + commentary: '_content', + creditingSources: '_content', + referencingSources: '_content', + lyrics: '_content', }], ['wikiInfo', { @@ -237,21 +255,33 @@ export function filterReferenceErrors(wikiData, { const aggregate = openAggregate({message: `Errors validating between-thing references in data`}); for (const [thingDataProp, propSpec] of referenceSpec) { const thingData = getNestedProp(wikiData, thingDataProp); - const things = Array.isArray(thingData) ? thingData : [thingData]; + const things = + (Array.isArray(thingData) + ? thingData.flat(Infinity) + : [thingData]); + aggregate.nest({message: `Reference errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => { for (const thing of things) { + if (propSpec._include && !propSpec._include(thing)) { + continue; + } + nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => { for (const [property, findFnKey] of Object.entries(propSpec)) { + if (property === '_include') { + continue; + } + let value = CacheableObject.getUpdateValue(thing, property); let writeProperty = true; switch (findFnKey) { - case '_commentary': + case '_content': if (value) { value = - Array.from(value.matchAll(commentaryRegexCaseSensitive)) - .map(({groups}) => groups.artistReferences) - .map(text => text.split(',').map(text => text.trim())); + value.map(entry => + CacheableObject.getUpdateValue(entry, 'artists') ?? + []); } writeProperty = false; @@ -265,15 +295,6 @@ export function filterReferenceErrors(wikiData, { // need writing, humm...) writeProperty = false; break; - - case '_serieses': - if (value) { - // Doesn't report on which series has the error, but... - value = value.flatMap(series => series.albums); - } - - writeProperty = false; - break; } if (value === undefined) { @@ -291,15 +312,12 @@ export function filterReferenceErrors(wikiData, { case '_artwork': { const mixed = find.mixed({ - album: find.albumWithArtwork, - track: find.trackWithArtwork, + album: find.albumPrimaryArtwork, + track: find.trackPrimaryArtwork, }); const data = - combineWikiDataArrays([ - wikiData.albumData, - wikiData.trackData, - ]); + wikiData.artworkData; findFn = ref => mixed(ref.reference, data, {mode: 'error'}); @@ -310,7 +328,7 @@ export function filterReferenceErrors(wikiData, { findFn = boundFind.artTag; break; - case '_commentary': + case '_content': findFn = findArtistOrAlias; break; @@ -328,37 +346,32 @@ export function filterReferenceErrors(wikiData, { }; break; - case '_serieses': - findFn = boundFind.album; - break; - case '_trackArtwork': findFn = ref => boundFind.track(ref.reference); break; - case '_trackNotRerelease': + case '_trackMainReleasesOnly': findFn = trackRef => { const track = boundFind.track(trackRef); - const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack'); + const mainRef = track && CacheableObject.getUpdateValue(track, 'mainReleaseTrack'); - if (originalRef) { - // It's possible for the original to not actually exist, in this case. - // It should still be reported since the 'Originally Released As' field - // was present. - const original = boundFind.track(originalRef, {mode: 'quiet'}); + if (mainRef) { + // It's possible for the main release to not actually exist, in this case. + // It should still be reported since the 'Main Release' field was present. + const main = boundFind.track(mainRef, {mode: 'quiet'}); // Prefer references by name, but only if it's unambiguous. - const originalByName = - (original - ? boundFind.track(original.name, {mode: 'quiet'}) + const mainByName = + (main + ? boundFind.track(main.name, {mode: 'quiet'}) : null); const shouldBeMessage = - (originalByName - ? colors.green(original.name) - : original - ? colors.green('track:' + original.directory) - : colors.green(originalRef)); + (mainByName + ? colors.green(main.name) + : main + ? colors.green('track:' + main.directory) + : colors.green(mainRef)); throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`); } @@ -372,7 +385,11 @@ export function filterReferenceErrors(wikiData, { break; } - const suppress = fn => conditionallySuppressError(error => { + const suppress = fn => conditionallySuppressError(_error => { + // We're not suppressing any errors at the moment. + // An old suppression is kept below for reference. + + /* if (property === 'sampledTracks') { // Suppress "didn't match anything" errors in particular, just for samples. // In hsmusic-data we have a lot of "stub" sample data which don't have @@ -385,6 +402,7 @@ export function filterReferenceErrors(wikiData, { return true; } } + */ return false; }, fn); @@ -438,7 +456,7 @@ export function filterReferenceErrors(wikiData, { } } - if (findFnKey === '_commentary') { + if (findFnKey === '_content') { filter( value, {message: errorMessage}, decorateErrorWithIndex(refs => @@ -539,16 +557,33 @@ export function reportContentTextErrors(wikiData, { description: 'description', }; + const artworkShape = { + source: 'artwork source', + originDetails: 'artwork origin details', + }; + const commentaryShape = { body: 'commentary body', - artistDisplayText: 'commentary artist display text', + artistText: 'commentary artist text', annotation: 'commentary annotation', }; + const lyricsShape = { + body: 'lyrics body', + artistText: 'lyrics artist text', + annotation: 'lyrics annotation', + }; + const contentTextSpec = [ ['albumData', { additionalFiles: additionalFileShape, commentary: commentaryShape, + creditingSources: commentaryShape, + coverArtworks: artworkShape, + }], + + ['artTagData', { + description: '_content', }], ['artistData', { @@ -557,6 +592,8 @@ export function reportContentTextErrors(wikiData, { ['flashData', { commentary: commentaryShape, + creditingSources: commentaryShape, + coverArtwork: artworkShape, }], ['flashActData', { @@ -586,10 +623,12 @@ export function reportContentTextErrors(wikiData, { ['trackData', { additionalFiles: additionalFileShape, commentary: commentaryShape, - creditSources: commentaryShape, - lyrics: '_content', + creditingSources: commentaryShape, + referencingSources: commentaryShape, + lyrics: lyricsShape, midiProjectFiles: additionalFileShape, sheetMusicFiles: additionalFileShape, + trackArtworks: artworkShape, }], ['wikiInfo', { @@ -602,7 +641,7 @@ export function reportContentTextErrors(wikiData, { const findArtistOrAlias = bindFindArtistOrAlias(boundFind); function* processContent(input) { - const nodes = parseInput(input); + const nodes = parseContentNodes(input); for (const node of nodes) { const index = node.i; @@ -659,7 +698,7 @@ export function reportContentTextErrors(wikiData, { } else if (node.type === 'external-link') { try { new URL(node.data.href); - } catch (error) { + } catch { yield { index, length, message: @@ -710,8 +749,8 @@ export function reportContentTextErrors(wikiData, { for (const thing of things) { nest({message: `Content text errors in ${inspect(thing)}`}, ({nest, push}) => { - for (const [property, shape] of Object.entries(propSpec)) { - const value = thing[property]; + for (let [property, shape] of Object.entries(propSpec)) { + let value = thing[property]; if (value === undefined) { push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`)); @@ -730,6 +769,31 @@ export function reportContentTextErrors(wikiData, { const topMessage = `Content text errors` + fieldPropertyMessage; + const checkShapeEntries = (entry, callProcessContentOpts) => { + for (const [key, annotation] of Object.entries(shape)) { + const value = entry[key]; + + // TODO: This should be an undefined/null check, like above, + // but it's not, because sometimes the stuff we're checking + // here isn't actually coded as a Thing - so the properties + // might really be undefined instead of null. Terrifying and + // awful. And most of all, citation needed. + if (!value) continue; + + callProcessContent({ + ...callProcessContentOpts, + + // TODO: `nest` isn't provided by `callProcessContentOpts` + //`but `push` is - this is to match the old code, but + // what's the deal here? + nest, + + value, + message: `Error in ${colors.green(annotation)}`, + }); + } + }; + if (shape === '_content') { callProcessContent({ nest, @@ -737,26 +801,18 @@ export function reportContentTextErrors(wikiData, { value, message: topMessage, }); - } else { + } else if (Array.isArray(value)) { nest({message: topMessage}, ({push}) => { for (const [index, entry] of value.entries()) { - for (const [key, annotation] of Object.entries(shape)) { - const value = entry[key]; - - // TODO: Should this check undefined/null similar to above? - if (!value) continue; - - callProcessContent({ - nest, - push, - value, - message: `Error in ${colors.green(annotation)}`, - annotateError: error => - annotateErrorWithIndex(error, index), - }); - } + checkShapeEntries(entry, { + push, + annotateError: error => + annotateErrorWithIndex(error, index), + }); } }); + } else { + checkShapeEntries(value, {push}); } } }); @@ -765,3 +821,49 @@ export function reportContentTextErrors(wikiData, { } }); } + +export function reportOrphanedArtworks(wikiData) { + const aggregate = + openAggregate({message: `Artwork objects are orphaned`}); + + const assess = ({ + message, + filterThing, + filterContribs, + link, + }) => { + aggregate.nest({message: `Orphaned ${message}`}, ({push}) => { + const ostensibleArtworks = + wikiData.artworkData + .filter(artwork => + artwork.thing instanceof filterThing && + artwork.artistContribsFromThingProperty === filterContribs); + + const orphanedArtworks = + ostensibleArtworks + .filter(artwork => !artwork.thing[link].includes(artwork)); + + for (const artwork of orphanedArtworks) { + push(new Error(`Orphaned: ${inspect(artwork)}`)); + } + }); + }; + + const {Album, Track} = thingConstructors; + + assess({ + message: `album cover artworks`, + filterThing: Album, + filterContribs: 'coverArtistContribs', + link: 'coverArtworks', + }); + + assess({ + message: `track artworks`, + filterThing: Track, + filterContribs: 'coverArtistContribs', + link: 'trackArtworks', + }); + + aggregate.close(); +} diff --git a/src/data/composite/control-flow/flipFilter.js b/src/data/composite/control-flow/flipFilter.js new file mode 100644 index 00000000..995bacad --- /dev/null +++ b/src/data/composite/control-flow/flipFilter.js @@ -0,0 +1,36 @@ +// Flips a filter, so that each true item becomes false, and vice versa. +// Overwrites the provided dependency. +// +// See also: +// - withAvailabilityFilter + +import {input, templateCompositeFrom} from '#composite'; + +export default templateCompositeFrom({ + annotation: `flipFilter`, + + inputs: { + filter: input({type: 'array'}), + }, + + outputs: ({ + [input.staticDependency('filter')]: filterDependency, + }) => [filterDependency ?? '#flippedFilter'], + + steps: () => [ + { + dependencies: [ + input('filter'), + input.staticDependency('filter'), + ], + + compute: (continuation, { + [input('filter')]: filter, + [input.staticDependency('filter')]: filterDependency, + }) => continuation({ + [filterDependency ?? '#flippedFilter']: + filter.map(item => !item), + }), + }, + ], +}); diff --git a/src/data/composite/control-flow/index.js b/src/data/composite/control-flow/index.js index 7e137a14..778dc66b 100644 --- a/src/data/composite/control-flow/index.js +++ b/src/data/composite/control-flow/index.js @@ -10,6 +10,7 @@ export {default as exposeDependency} from './exposeDependency.js'; export {default as exposeDependencyOrContinue} from './exposeDependencyOrContinue.js'; export {default as exposeUpdateValueOrContinue} from './exposeUpdateValueOrContinue.js'; export {default as exposeWhetherDependencyAvailable} from './exposeWhetherDependencyAvailable.js'; +export {default as flipFilter} from './flipFilter.js'; export {default as raiseOutputWithoutDependency} from './raiseOutputWithoutDependency.js'; export {default as raiseOutputWithoutUpdateValue} from './raiseOutputWithoutUpdateValue.js'; export {default as withAvailabilityFilter} from './withAvailabilityFilter.js'; diff --git a/src/data/composite/control-flow/withAvailabilityFilter.js b/src/data/composite/control-flow/withAvailabilityFilter.js index cfea998e..fd93af71 100644 --- a/src/data/composite/control-flow/withAvailabilityFilter.js +++ b/src/data/composite/control-flow/withAvailabilityFilter.js @@ -4,6 +4,7 @@ // Accepts the same mode options as withResultOfAvailabilityCheck. // // See also: +// - flipFilter // - withFilteredList // - withResultOfAvailabilityCheck // diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js index 46a3dc81..05b59445 100644 --- a/src/data/composite/data/index.js +++ b/src/data/composite/data/index.js @@ -20,6 +20,7 @@ export {default as withMappedList} from './withMappedList.js'; export {default as withSortedList} from './withSortedList.js'; export {default as withStretchedList} from './withStretchedList.js'; +export {default as withLengthOfList} from './withLengthOfList.js'; export {default as withPropertyFromList} from './withPropertyFromList.js'; export {default as withPropertiesFromList} from './withPropertiesFromList.js'; diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js index 44c1661d..15ee3373 100644 --- a/src/data/composite/data/withFilteredList.js +++ b/src/data/composite/data/withFilteredList.js @@ -2,9 +2,6 @@ // corresponding items in a list. Items which correspond to a truthy value // are kept, and the rest are excluded from the output list. // -// If the flip option is set, only items corresponding with a *falsy* value in -// the filter are kept. -// // TODO: There should be two outputs - one for the items included according to // the filter, and one for the items excluded. // @@ -22,28 +19,19 @@ export default templateCompositeFrom({ inputs: { list: input({type: 'array'}), filter: input({type: 'array'}), - - flip: input({ - type: 'boolean', - defaultValue: false, - }), }, outputs: ['#filteredList'], steps: () => [ { - dependencies: [input('list'), input('filter'), input('flip')], + dependencies: [input('list'), input('filter')], compute: (continuation, { [input('list')]: list, [input('filter')]: filter, - [input('flip')]: flip, }) => continuation({ '#filteredList': - list.filter((_item, index) => - (flip - ? !filter[index] - : filter[index])), + list.filter((_item, index) => filter[index]), }), }, ], diff --git a/src/data/composite/data/withLengthOfList.js b/src/data/composite/data/withLengthOfList.js new file mode 100644 index 00000000..e67aa887 --- /dev/null +++ b/src/data/composite/data/withLengthOfList.js @@ -0,0 +1,54 @@ +import {input, templateCompositeFrom} from '#composite'; + +function getOutputName({ + [input.staticDependency('list')]: list, +}) { + if (list && list.startsWith('#')) { + return `${list}.length`; + } else if (list) { + return `#${list}.length`; + } else { + return '#length'; + } +} + +export default templateCompositeFrom({ + annotation: `withMappedList`, + + inputs: { + list: input({type: 'array'}), + }, + + outputs: inputs => [getOutputName(inputs)], + + steps: () => [ + { + dependencies: [input.staticDependency('list')], + compute: (continuation, inputs) => + continuation({'#output': getOutputName(inputs)}), + }, + + { + dependencies: [input('list')], + compute: (continuation, { + [input('list')]: list, + }) => continuation({ + ['#value']: + (list === null + ? null + : list.length), + }), + }, + + { + dependencies: ['#output', '#value'], + + compute: (continuation, { + ['#output']: output, + ['#value']: value, + }) => continuation({ + [output]: value, + }), + }, + ], +}); diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js index 0bc63a92..cd32058e 100644 --- a/src/data/composite/data/withMappedList.js +++ b/src/data/composite/data/withMappedList.js @@ -1,12 +1,16 @@ // Applies a map function to each item in a list, just like a normal JavaScript // map. // +// Pass a filter (e.g. from withAvailabilityFilter) to process only items +// kept by the filter. Other items will be left as-is. +// // See also: // - withFilteredList // - withSortedList // import {input, templateCompositeFrom} from '#composite'; +import {stitchArrays} from '#sugar'; export default templateCompositeFrom({ annotation: `withMappedList`, @@ -14,19 +18,31 @@ export default templateCompositeFrom({ inputs: { list: input({type: 'array'}), map: input({type: 'function'}), + + filter: input({ + type: 'array', + defaultValue: null, + }), }, outputs: ['#mappedList'], steps: () => [ { - dependencies: [input('list'), input('map')], + dependencies: [input('list'), input('map'), input('filter')], compute: (continuation, { [input('list')]: list, [input('map')]: mapFn, + [input('filter')]: filter, }) => continuation({ ['#mappedList']: - list.map(mapFn), + stitchArrays({ + item: list, + keep: filter ?? Array.from(list, () => true), + }).map(({item, keep}, index) => + (keep + ? mapFn(item, index, list) + : item)), }), }, ], diff --git a/src/data/composite/data/withNearbyItemFromList.js b/src/data/composite/data/withNearbyItemFromList.js index 83a8cc21..5e165219 100644 --- a/src/data/composite/data/withNearbyItemFromList.js +++ b/src/data/composite/data/withNearbyItemFromList.js @@ -9,6 +9,10 @@ // - If the 'valuePastEdge' input is provided, that value will be output // instead of null. // +// - If the 'filter' input is provided, corresponding items will be skipped, +// and only (repeating `offset`) the next included in the filter will be +// returned. +// // Both the list and item must be provided. // // See also: @@ -16,7 +20,6 @@ // import {input, templateCompositeFrom} from '#composite'; -import {atOffset} from '#sugar'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; @@ -28,9 +31,12 @@ export default templateCompositeFrom({ inputs: { list: input({acceptsNull: false, type: 'array'}), item: input({acceptsNull: false}), - offset: input({type: 'number'}), + wrap: input({type: 'boolean', defaultValue: false}), + valuePastEdge: input({defaultValue: null}), + + filter: input({defaultValue: null, type: 'array'}), }, outputs: ['#nearbyItem'], @@ -45,29 +51,55 @@ export default templateCompositeFrom({ dependency: '#index', mode: input.value('index'), - output: input.value({ - ['#nearbyItem']: - null, - }), + output: input.value({'#nearbyItem': null}), }), { dependencies: [ input('list'), input('offset'), + input('wrap'), + input('valuePastEdge'), + + input('filter'), + '#index', ], compute: (continuation, { [input('list')]: list, [input('offset')]: offset, + [input('wrap')]: wrap, + [input('valuePastEdge')]: valuePastEdge, + + [input('filter')]: filter, + ['#index']: index, - }) => continuation({ - ['#nearbyItem']: - atOffset(list, index, offset, {wrap}), - }), + }) => { + const startIndex = index; + + do { + index += offset; + + if (wrap) { + index = index % list.length; + } else if (index < 0) { + return continuation({'#nearbyItem': valuePastEdge}); + } else if (index >= list.length) { + return continuation({'#nearbyItem': valuePastEdge}); + } + + if (filter && !filter[index]) { + continue; + } + + return continuation({'#nearbyItem': list[index]}); + } while (index !== startIndex); + + return continuation({'#nearbyItem': null}); + }, }, ], }); diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js index 65ebf77b..760095c2 100644 --- a/src/data/composite/data/withPropertyFromList.js +++ b/src/data/composite/data/withPropertyFromList.js @@ -5,11 +5,15 @@ // original list are kept null here. Objects which don't have the specified // property are retained in-place as null. // +// If the `internal` input is true, this reads the CacheableObject update value +// of each object rather than its exposed value. +// // See also: // - withPropertiesFromList // - withPropertyFromObject // +import CacheableObject from '#cacheable-object'; import {input, templateCompositeFrom} from '#composite'; function getOutputName({list, property, prefix}) { @@ -26,6 +30,7 @@ export default templateCompositeFrom({ list: input({type: 'array'}), property: input({type: 'string'}), prefix: input.staticValue({type: 'string', defaultValue: null}), + internal: input({type: 'boolean', defaultValue: false}), }, outputs: ({ @@ -37,13 +42,26 @@ export default templateCompositeFrom({ steps: () => [ { - dependencies: [input('list'), input('property')], + dependencies: [ + input('list'), + input('property'), + input('internal'), + ], + compute: (continuation, { [input('list')]: list, [input('property')]: property, + [input('internal')]: internal, }) => continuation({ ['#values']: - list.map(item => item[property] ?? null), + list.map(item => + (item === null + ? null + : internal + ? CacheableObject.getUpdateValue(item, property) + ?? null + : item[property] + ?? null)), }), }, diff --git a/src/data/composite/data/withPropertyFromObject.js b/src/data/composite/data/withPropertyFromObject.js index 4f240506..7b452b99 100644 --- a/src/data/composite/data/withPropertyFromObject.js +++ b/src/data/composite/data/withPropertyFromObject.js @@ -13,6 +13,21 @@ import CacheableObject from '#cacheable-object'; import {input, templateCompositeFrom} from '#composite'; +function getOutputName({ + [input.staticDependency('object')]: object, + [input.staticValue('property')]: property, +}) { + if (object && property) { + if (object.startsWith('#')) { + return `${object}.${property}`; + } else { + return `#${object}.${property}`; + } + } else { + return '#value'; + } +} + export default templateCompositeFrom({ annotation: `withPropertyFromObject`, @@ -22,15 +37,7 @@ export default templateCompositeFrom({ internal: input({type: 'boolean', defaultValue: false}), }, - outputs: ({ - [input.staticDependency('object')]: object, - [input.staticValue('property')]: property, - }) => - (object && property - ? (object.startsWith('#') - ? [`${object}.${property}`] - : [`#${object}.${property}`]) - : ['#value']), + outputs: inputs => [getOutputName(inputs)], steps: () => [ { @@ -39,17 +46,8 @@ export default templateCompositeFrom({ input.staticValue('property'), ], - compute: (continuation, { - [input.staticDependency('object')]: object, - [input.staticValue('property')]: property, - }) => continuation({ - '#output': - (object && property - ? (object.startsWith('#') - ? `${object}.${property}` - : `#${object}.${property}`) - : '#value'), - }), + compute: (continuation, inputs) => + continuation({'#output': getOutputName(inputs)}), }, { diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js index 8b5098f0..dfc6864f 100644 --- a/src/data/composite/things/album/index.js +++ b/src/data/composite/things/album/index.js @@ -1 +1,2 @@ +export {default as withHasCoverArt} from './withHasCoverArt.js'; export {default as withTracks} from './withTracks.js'; diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/things/album/withHasCoverArt.js new file mode 100644 index 00000000..fd3f2894 --- /dev/null +++ b/src/data/composite/things/album/withHasCoverArt.js @@ -0,0 +1,64 @@ +// TODO: This shouldn't be coded as an Album-specific thing, +// or even really to do with cover artworks in particular, either. + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {fillMissingListItems, withFlattenedList, withPropertyFromList} + from '#composite/data'; + +export default templateCompositeFrom({ + annotation: 'withHasCoverArt', + + outputs: ['#hasCoverArt'], + + steps: () => [ + withResultOfAvailabilityCheck({ + from: 'coverArtistContribs', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability'], + compute: (continuation, { + ['#availability']: availability, + }) => + (availability + ? continuation.raiseOutput({ + ['#hasCoverArt']: true, + }) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'coverArtworks', + mode: input.value('empty'), + output: input.value({'#hasCoverArt': false}), + }), + + withPropertyFromList({ + list: 'coverArtworks', + property: input.value('artistContribs'), + internal: input.value(true), + }), + + // Since we're getting the update value for each artwork's artistContribs, + // it may not be set at all, and in that case won't be exposing as []. + fillMissingListItems({ + list: '#coverArtworks.artistContribs', + fill: input.value([]), + }), + + withFlattenedList({ + list: '#coverArtworks.artistContribs', + }), + + withResultOfAvailabilityCheck({ + from: '#flattenedList', + mode: input.value('empty'), + }).outputs({ + '#availability': '#hasCoverArt', + }), + ], +}); diff --git a/src/data/composite/things/album/withTracks.js b/src/data/composite/things/album/withTracks.js index 348220e7..835ee570 100644 --- a/src/data/composite/things/album/withTracks.js +++ b/src/data/composite/things/album/withTracks.js @@ -1,7 +1,6 @@ import {input, templateCompositeFrom} from '#composite'; import {withFlattenedList, withPropertyFromList} from '#composite/data'; -import {withResolvedReferenceList} from '#composite/wiki-data'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; diff --git a/src/data/composite/things/art-tag/index.js b/src/data/composite/things/art-tag/index.js new file mode 100644 index 00000000..bbd38293 --- /dev/null +++ b/src/data/composite/things/art-tag/index.js @@ -0,0 +1,2 @@ +export {default as withAllDescendantArtTags} from './withAllDescendantArtTags.js'; +export {default as withAncestorArtTagBaobabTree} from './withAncestorArtTagBaobabTree.js'; diff --git a/src/data/composite/things/art-tag/withAllDescendantArtTags.js b/src/data/composite/things/art-tag/withAllDescendantArtTags.js new file mode 100644 index 00000000..795f96cd --- /dev/null +++ b/src/data/composite/things/art-tag/withAllDescendantArtTags.js @@ -0,0 +1,44 @@ +// Gets all the art tags which descend from this one - that means its own direct +// descendants, but also all the direct and indirect desceands of each of those! +// The results aren't specially sorted, but they won't contain any duplicates +// (for example if two descendant tags both route deeper to end up including +// some of the same tags). + +import {input, templateCompositeFrom} from '#composite'; +import {unique} from '#sugar'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withResolvedReferenceList} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; + +export default templateCompositeFrom({ + annotation: `withAllDescendantArtTags`, + + outputs: ['#allDescendantArtTags'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'directDescendantArtTags', + mode: input.value('empty'), + output: input.value({'#allDescendantArtTags': []}) + }), + + withResolvedReferenceList({ + list: 'directDescendantArtTags', + find: soupyFind.input('artTag'), + }), + + { + dependencies: ['#resolvedReferenceList'], + compute: (continuation, { + ['#resolvedReferenceList']: directDescendantArtTags, + }) => continuation({ + ['#allDescendantArtTags']: + unique([ + ...directDescendantArtTags, + ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags), + ]), + }), + }, + ], +}) diff --git a/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js new file mode 100644 index 00000000..e084a42b --- /dev/null +++ b/src/data/composite/things/art-tag/withAncestorArtTagBaobabTree.js @@ -0,0 +1,46 @@ +// Gets all the art tags which are ancestors of this one as a "baobab tree" - +// what you'd typically think of as roots are all up in the air! Since this +// really is backwards from the way that the art tag tree is written in data, +// chances are pretty good that there will be many of the exact same "leaf" +// nodes - art tags which don't themselves have any ancestors. In the actual +// data structure, each node is a Map, with keys for each ancestor and values +// for each ancestor's own baobab (thus a branching structure, just like normal +// trees in this regard). + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withReverseReferenceList} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; + +export default templateCompositeFrom({ + annotation: `withAncestorArtTagBaobabTree`, + + outputs: ['#ancestorArtTagBaobabTree'], + + steps: () => [ + withReverseReferenceList({ + reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'), + }).outputs({ + ['#reverseReferenceList']: '#directAncestorArtTags', + }), + + raiseOutputWithoutDependency({ + dependency: '#directAncestorArtTags', + mode: input.value('empty'), + output: input.value({'#ancestorArtTagBaobabTree': new Map()}), + }), + + { + dependencies: ['#directAncestorArtTags'], + compute: (continuation, { + ['#directAncestorArtTags']: directAncestorArtTags, + }) => continuation({ + ['#ancestorArtTagBaobabTree']: + new Map( + directAncestorArtTags + .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])), + }), + }, + ], +}); diff --git a/src/data/composite/things/artist/artistTotalDuration.js b/src/data/composite/things/artist/artistTotalDuration.js index ff709f28..b8a205fe 100644 --- a/src/data/composite/things/artist/artistTotalDuration.js +++ b/src/data/composite/things/artist/artistTotalDuration.js @@ -2,8 +2,9 @@ import {input, templateCompositeFrom} from '#composite'; import {exposeDependency} from '#composite/control-flow'; import {withFilteredList, withPropertyFromList} from '#composite/data'; -import {withContributionListSums, withReverseContributionList} +import {withContributionListSums, withReverseReferenceList} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `artistTotalDuration`, @@ -11,18 +12,16 @@ export default templateCompositeFrom({ compose: false, steps: () => [ - withReverseContributionList({ - data: 'trackData', - list: input.value('artistContribs'), + withReverseReferenceList({ + reverse: soupyReverse.input('trackArtistContributionsBy'), }).outputs({ - '#reverseContributionList': '#contributionsAsArtist', + '#reverseReferenceList': '#contributionsAsArtist', }), - withReverseContributionList({ - data: 'trackData', - list: input.value('contributorContribs'), + withReverseReferenceList({ + reverse: soupyReverse.input('trackContributorContributionsBy'), }).outputs({ - '#reverseContributionList': '#contributionsAsContributor', + '#reverseReferenceList': '#contributionsAsContributor', }), { @@ -49,18 +48,18 @@ export default templateCompositeFrom({ withPropertyFromList({ list: '#allContributions.thing', - property: input.value('isOriginalRelease'), + property: input.value('isMainRelease'), }), withFilteredList({ list: '#allContributions', - filter: '#allContributions.thing.isOriginalRelease', + filter: '#allContributions.thing.isMainRelease', }).outputs({ - '#filteredList': '#originalContributions', + '#filteredList': '#mainReleaseContributions', }), withContributionListSums({ - list: '#originalContributions', + list: '#mainReleaseContributions', }), exposeDependency({ diff --git a/src/data/composite/things/artwork/index.js b/src/data/composite/things/artwork/index.js new file mode 100644 index 00000000..3693c10f --- /dev/null +++ b/src/data/composite/things/artwork/index.js @@ -0,0 +1,5 @@ +export {default as withAttachedArtwork} from './withAttachedArtwork.js'; +export {default as withContainingArtworkList} from './withContainingArtworkList.js'; +export {default as withContribsFromAttachedArtwork} from './withContribsFromAttachedArtwork.js'; +export {default as withDate} from './withDate.js'; +export {default as withPropertyFromAttachedArtwork} from './withPropertyFromAttachedArtwork.js'; diff --git a/src/data/composite/things/artwork/withAttachedArtwork.js b/src/data/composite/things/artwork/withAttachedArtwork.js new file mode 100644 index 00000000..d7c0d87b --- /dev/null +++ b/src/data/composite/things/artwork/withAttachedArtwork.js @@ -0,0 +1,43 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {flipFilter, raiseOutputWithoutDependency} + from '#composite/control-flow'; +import {withNearbyItemFromList, withPropertyFromList} from '#composite/data'; + +import withContainingArtworkList from './withContainingArtworkList.js'; + +export default templateCompositeFrom({ + annotaion: `withContribsFromMainArtwork`, + + outputs: ['#attachedArtwork'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'attachAbove', + mode: input.value('falsy'), + output: input.value({'#attachedArtwork': null}), + }), + + withContainingArtworkList(), + + withPropertyFromList({ + list: '#containingArtworkList', + property: input.value('attachAbove'), + }), + + flipFilter({ + filter: '#containingArtworkList.attachAbove', + }).outputs({ + '#containingArtworkList.attachAbove': '#filterNotAttached', + }), + + withNearbyItemFromList({ + list: '#containingArtworkList', + item: input.myself(), + offset: input.value(-1), + filter: '#filterNotAttached', + }).outputs({ + '#nearbyItem': '#attachedArtwork', + }), + ], +}); diff --git a/src/data/composite/things/artwork/withContainingArtworkList.js b/src/data/composite/things/artwork/withContainingArtworkList.js new file mode 100644 index 00000000..9c928ffd --- /dev/null +++ b/src/data/composite/things/artwork/withContainingArtworkList.js @@ -0,0 +1,46 @@ +// Gets the list of artworks which contains this one, which is functionally +// equivalent to `this.thing[this.thingProperty]`. If the exposed value is not +// a list at all (i.e. the property holds a single artwork), this composition +// outputs null. + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withContainingArtworkList`, + + outputs: ['#containingArtworkList'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'thing', + output: input.value({'#containingArtworkList': null}), + }), + + raiseOutputWithoutDependency({ + dependency: 'thingProperty', + output: input.value({'#containingArtworkList': null}), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'thingProperty', + }).outputs({ + '#value': '#containingValue', + }), + + { + dependencies: ['#containingValue'], + compute: (continuation, { + ['#containingValue']: containingValue, + }) => continuation({ + ['#containingArtworkList']: + (Array.isArray(containingValue) + ? containingValue + : null), + }), + }, + ], +}); diff --git a/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js new file mode 100644 index 00000000..e9425c95 --- /dev/null +++ b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js @@ -0,0 +1,27 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withRecontextualizedContributionList} from '#composite/wiki-data'; + +import withPropertyFromAttachedArtwork from './withPropertyFromAttachedArtwork.js'; + +export default templateCompositeFrom({ + annotaion: `withContribsFromAttachedArtwork`, + + outputs: ['#attachedArtwork.artistContribs'], + + steps: () => [ + withPropertyFromAttachedArtwork({ + property: input.value('artistContribs'), + }), + + raiseOutputWithoutDependency({ + dependency: '#attachedArtwork.artistContribs', + output: input.value({'#attachedArtwork.artistContribs': null}), + }), + + withRecontextualizedContributionList({ + list: '#attachedArtwork.artistContribs', + }), + ], +}); diff --git a/src/data/composite/things/artwork/withDate.js b/src/data/composite/things/artwork/withDate.js new file mode 100644 index 00000000..5e05b814 --- /dev/null +++ b/src/data/composite/things/artwork/withDate.js @@ -0,0 +1,41 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `withDate`, + + inputs: { + from: input({ + defaultDependency: 'date', + acceptsNull: true, + }), + }, + + outputs: ['#date'], + + steps: () => [ + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: date, + }) => + (date + ? continuation.raiseOutput({'#date': date}) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'dateFromThingProperty', + output: input.value({'#date': null}), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'dateFromThingProperty', + }).outputs({ + ['#value']: '#date', + }), + ], +}) diff --git a/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js new file mode 100644 index 00000000..a2f954b9 --- /dev/null +++ b/src/data/composite/things/artwork/withPropertyFromAttachedArtwork.js @@ -0,0 +1,65 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {withResultOfAvailabilityCheck} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; + +import withAttachedArtwork from './withAttachedArtwork.js'; + +function getOutputName({ + [input.staticValue('property')]: property, +}) { + if (property) { + return `#attachedArtwork.${property}`; + } else { + return '#value'; + } +} + +export default templateCompositeFrom({ + annotation: `withPropertyFromAttachedArtwork`, + + inputs: { + property: input({type: 'string'}), + }, + + outputs: inputs => [getOutputName(inputs)], + + steps: () => [ + { + dependencies: [input.staticValue('property')], + compute: (continuation, inputs) => + continuation({'#output': getOutputName(inputs)}), + }, + + withAttachedArtwork(), + + withResultOfAvailabilityCheck({ + from: '#attachedArtwork', + }), + + { + dependencies: ['#availability', '#output'], + compute: (continuation, { + ['#availability']: availability, + ['#output']: output, + }) => + (availability + ? continuation() + : continuation.raiseOutput({[output]: null})), + }, + + withPropertyFromObject({ + object: '#attachedArtwork', + property: input('property'), + }), + + { + dependencies: ['#value', '#output'], + compute: (continuation, { + ['#value']: value, + ['#output']: output, + }) => + continuation.raiseOutput({[output]: value}), + }, + ], +}); diff --git a/src/data/composite/things/content/contentArtists.js b/src/data/composite/things/content/contentArtists.js new file mode 100644 index 00000000..8d5db5a5 --- /dev/null +++ b/src/data/composite/things/content/contentArtists.js @@ -0,0 +1,40 @@ +import {input, templateCompositeFrom} from '#composite'; +import {validateReferenceList} from '#validators'; + +import {exitWithoutDependency, exposeDependency} + from '#composite/control-flow'; +import {withResolvedReferenceList} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; + +import withExpressedOrImplicitArtistReferences + from './helpers/withExpressedOrImplicitArtistReferences.js'; + +export default templateCompositeFrom({ + annotation: `contentArtists`, + + compose: false, + + update: { + validate: validateReferenceList('artist'), + }, + + steps: () => [ + withExpressedOrImplicitArtistReferences({ + from: input.updateValue(), + }), + + exitWithoutDependency({ + dependency: '#artistReferences', + value: input.value([]), + }), + + withResolvedReferenceList({ + list: '#artistReferences', + find: soupyFind.input('artist'), + }), + + exposeDependency({ + dependency: '#resolvedReferenceList', + }), + ], +}); diff --git a/src/data/composite/things/content/hasAnnotationPart.js b/src/data/composite/things/content/hasAnnotationPart.js new file mode 100644 index 00000000..83d175e3 --- /dev/null +++ b/src/data/composite/things/content/hasAnnotationPart.js @@ -0,0 +1,25 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {exposeDependency} from '#composite/control-flow'; + +import withHasAnnotationPart from './withHasAnnotationPart.js'; + +export default templateCompositeFrom({ + annotation: `hasAnnotationPart`, + + compose: false, + + inputs: { + part: input({type: 'string'}), + }, + + steps: () => [ + withHasAnnotationPart({ + part: input('part'), + }), + + exposeDependency({ + dependency: '#hasAnnotationPart', + }), + ], +}); diff --git a/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js new file mode 100644 index 00000000..62799d43 --- /dev/null +++ b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js @@ -0,0 +1,60 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withFilteredList, withMappedList} from '#composite/data'; +import {withContentNodes} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withExpressedOrImplicitArtistReferences`, + + inputs: { + from: input({type: 'array', acceptsNull: true}), + }, + + outputs: ['#artistReferences'], + + steps: () => [ + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: expressedArtistReferences, + }) => + (expressedArtistReferences + ? continuation.raiseOutput({'#artistReferences': expressedArtistReferences}) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'artistText', + output: input.value({'#artistReferences': null}), + }), + + withContentNodes({ + from: 'artistText', + }), + + withMappedList({ + list: '#contentNodes', + map: input.value(node => + node.type === 'tag' && + node.data.replacerKey?.data === 'artist'), + }).outputs({ + '#mappedList': '#artistTagFilter', + }), + + withFilteredList({ + list: '#contentNodes', + filter: '#artistTagFilter', + }).outputs({ + '#filteredList': '#artistTags', + }), + + withMappedList({ + list: '#artistTags', + map: input.value(node => + node.data.replacerValue[0].data), + }).outputs({ + '#mappedList': '#artistReferences', + }), + ], +}); diff --git a/src/data/composite/things/content/index.js b/src/data/composite/things/content/index.js new file mode 100644 index 00000000..4176337d --- /dev/null +++ b/src/data/composite/things/content/index.js @@ -0,0 +1,7 @@ +export {default as contentArtists} from './contentArtists.js'; +export {default as hasAnnotationPart} from './hasAnnotationPart.js'; +export {default as withAnnotationParts} from './withAnnotationParts.js'; +export {default as withHasAnnotationPart} from './withHasAnnotationPart.js'; +export {default as withSourceText} from './withSourceText.js'; +export {default as withSourceURLs} from './withSourceURLs.js'; +export {default as withWebArchiveDate} from './withWebArchiveDate.js'; diff --git a/src/data/composite/things/content/withAnnotationParts.js b/src/data/composite/things/content/withAnnotationParts.js new file mode 100644 index 00000000..6311b57a --- /dev/null +++ b/src/data/composite/things/content/withAnnotationParts.js @@ -0,0 +1,93 @@ +import {input, templateCompositeFrom} from '#composite'; +import {transposeArrays} from '#sugar'; +import {is} from '#validators'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromList} from '#composite/data'; +import {splitContentNodesAround, withContentNodes} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withAnnotationParts`, + + inputs: { + mode: input({ + validate: is('strings', 'nodes'), + }), + }, + + outputs: ['#annotationParts'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: 'annotation', + output: input.value({'#annotationParts': []}), + }), + + withContentNodes({ + from: 'annotation', + }), + + splitContentNodesAround({ + nodes: '#contentNodes', + around: input.value(/, */g), + }), + + { + dependencies: ['#contentNodeLists', input('mode')], + compute: (continuation, { + ['#contentNodeLists']: nodeLists, + [input('mode')]: mode, + }) => + (mode === 'nodes' + ? continuation.raiseOutput({'#annotationParts': nodeLists}) + : continuation()), + }, + + { + dependencies: ['#contentNodeLists'], + + compute: (continuation, { + ['#contentNodeLists']: nodeLists, + }) => continuation({ + ['#firstNodes']: + nodeLists.map(list => list.at(0)), + + ['#lastNodes']: + nodeLists.map(list => list.at(-1)), + }), + }, + + withPropertyFromList({ + list: '#firstNodes', + property: input.value('i'), + }).outputs({ + '#firstNodes.i': '#startIndices', + }), + + withPropertyFromList({ + list: '#lastNodes', + property: input.value('iEnd'), + }).outputs({ + '#lastNodes.iEnd': '#endIndices', + }), + + { + dependencies: [ + 'annotation', + '#startIndices', + '#endIndices', + ], + + compute: (continuation, { + ['annotation']: annotation, + ['#startIndices']: startIndices, + ['#endIndices']: endIndices, + }) => continuation({ + ['#annotationParts']: + transposeArrays([startIndices, endIndices]) + .map(([start, end]) => + annotation.slice(start, end)), + }), + }, + ], +}); diff --git a/src/data/composite/things/content/withHasAnnotationPart.js b/src/data/composite/things/content/withHasAnnotationPart.js new file mode 100644 index 00000000..4af554f3 --- /dev/null +++ b/src/data/composite/things/content/withHasAnnotationPart.js @@ -0,0 +1,43 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +import withAnnotationParts from './withAnnotationParts.js'; + +export default templateCompositeFrom({ + annotation: `withHasAnnotationPart`, + + inputs: { + part: input({type: 'string'}), + }, + + outputs: ['#hasAnnotationPart'], + + steps: () => [ + withAnnotationParts({ + mode: input.value('strings'), + }), + + raiseOutputWithoutDependency({ + dependency: '#annotationParts', + output: input.value({'#hasAnnotationPart': false}), + }), + + { + dependencies: [ + input('part'), + '#annotationParts', + ], + + compute: (continuation, { + [input('part')]: search, + ['#annotationParts']: parts, + }) => continuation({ + ['#hasAnnotationPart']: + parts.some(part => + part.toLowerCase() === + search.toLowerCase()), + }), + }, + ], +}); diff --git a/src/data/composite/things/content/withSourceText.js b/src/data/composite/things/content/withSourceText.js new file mode 100644 index 00000000..292306b7 --- /dev/null +++ b/src/data/composite/things/content/withSourceText.js @@ -0,0 +1,53 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +import withAnnotationParts from './withAnnotationParts.js'; + +export default templateCompositeFrom({ + annotation: `withSourceText`, + + outputs: ['#sourceText'], + + steps: () => [ + withAnnotationParts({ + mode: input.value('nodes'), + }), + + raiseOutputWithoutDependency({ + dependency: '#annotationParts', + output: input.value({'#sourceText': null}), + }), + + { + dependencies: ['#annotationParts'], + compute: (continuation, { + ['#annotationParts']: annotationParts, + }) => continuation({ + ['#firstPartWithExternalLink']: + annotationParts + .find(nodes => nodes + .some(node => node.type === 'external-link')) ?? + null, + }), + }, + + raiseOutputWithoutDependency({ + dependency: '#firstPartWithExternalLink', + output: input.value({'#sourceText': null}), + }), + + { + dependencies: ['annotation', '#firstPartWithExternalLink'], + compute: (continuation, { + ['annotation']: annotation, + ['#firstPartWithExternalLink']: nodes, + }) => continuation({ + ['#sourceText']: + annotation.slice( + nodes.at(0).i, + nodes.at(-1).iEnd), + }), + }, + ], +}); diff --git a/src/data/composite/things/content/withSourceURLs.js b/src/data/composite/things/content/withSourceURLs.js new file mode 100644 index 00000000..f85ff9ea --- /dev/null +++ b/src/data/composite/things/content/withSourceURLs.js @@ -0,0 +1,62 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withFilteredList, withMappedList} from '#composite/data'; + +import withAnnotationParts from './withAnnotationParts.js'; + +export default templateCompositeFrom({ + annotation: `withSourceURLs`, + + outputs: ['#sourceURLs'], + + steps: () => [ + withAnnotationParts({ + mode: input.value('nodes'), + }), + + raiseOutputWithoutDependency({ + dependency: '#annotationParts', + output: input.value({'#sourceURLs': []}), + }), + + { + dependencies: ['#annotationParts'], + compute: (continuation, { + ['#annotationParts']: annotationParts, + }) => continuation({ + ['#firstPartWithExternalLink']: + annotationParts + .find(nodes => nodes + .some(node => node.type === 'external-link')) ?? + null, + }), + }, + + raiseOutputWithoutDependency({ + dependency: '#firstPartWithExternalLink', + output: input.value({'#sourceURLs': []}), + }), + + withMappedList({ + list: '#firstPartWithExternalLink', + map: input.value(node => node.type === 'external-link'), + }).outputs({ + '#mappedList': '#externalLinkFilter', + }), + + withFilteredList({ + list: '#firstPartWithExternalLink', + filter: '#externalLinkFilter', + }).outputs({ + '#filteredList': '#externalLinks', + }), + + withMappedList({ + list: '#externalLinks', + map: input.value(node => node.data.href), + }).outputs({ + '#mappedList': '#sourceURLs', + }), + ], +}); diff --git a/src/data/composite/things/content/withWebArchiveDate.js b/src/data/composite/things/content/withWebArchiveDate.js new file mode 100644 index 00000000..3aaa4f64 --- /dev/null +++ b/src/data/composite/things/content/withWebArchiveDate.js @@ -0,0 +1,41 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; + +export default templateCompositeFrom({ + annotation: `withWebArchiveDate`, + + outputs: ['#webArchiveDate'], + + steps: () => [ + { + dependencies: ['annotation'], + + compute: (continuation, {annotation}) => + continuation({ + ['#dateText']: + annotation + ?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//) + ?.[1] ?? + null, + }), + }, + + raiseOutputWithoutDependency({ + dependency: '#dateText', + output: input.value({['#webArchiveDate']: null}), + }), + + { + dependencies: ['#dateText'], + compute: (continuation, {['#dateText']: dateText}) => + continuation({ + ['#webArchiveDate']: + new Date( + dateText.slice(0, 4) + '/' + + dateText.slice(4, 6) + '/' + + dateText.slice(6, 8)), + }), + }, + ], +}); diff --git a/src/data/composite/things/contribution/inheritFromContributionPresets.js b/src/data/composite/things/contribution/inheritFromContributionPresets.js index 82425b9c..a74e6db3 100644 --- a/src/data/composite/things/contribution/inheritFromContributionPresets.js +++ b/src/data/composite/things/contribution/inheritFromContributionPresets.js @@ -1,7 +1,7 @@ import {input, templateCompositeFrom} from '#composite'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromList, withPropertyFromObject} from '#composite/data'; +import {withPropertyFromList} from '#composite/data'; import withMatchingContributionPresets from './withMatchingContributionPresets.js'; diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js index 4a37f2cf..a678c3f5 100644 --- a/src/data/composite/things/contribution/thingPropertyMatches.js +++ b/src/data/composite/things/contribution/thingPropertyMatches.js @@ -12,19 +12,31 @@ export default templateCompositeFrom({ }, steps: () => [ + { + dependencies: ['thing', 'thingProperty'], + + compute: (continuation, {thing, thingProperty}) => + continuation({ + ['#thingProperty']: + (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork' + ? thing.artistContribsFromThingProperty + : thingProperty), + }), + }, + exitWithoutDependency({ - dependency: 'thingProperty', + dependency: '#thingProperty', value: input.value(false), }), { dependencies: [ - 'thingProperty', + '#thingProperty', input('value'), ], compute: ({ - ['thingProperty']: thingProperty, + ['#thingProperty']: thingProperty, [input('value')]: value, }) => thingProperty === value, diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js index 2ee811af..4042e78f 100644 --- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js +++ b/src/data/composite/things/contribution/thingReferenceTypeMatches.js @@ -29,10 +29,37 @@ export default templateCompositeFrom({ input('value'), ], - compute: ({ + compute: (continuation, { ['#thing.constructor']: constructor, [input('value')]: value, }) => + (constructor[Symbol.for('Thing.referenceType')] === value + ? continuation.exit(true) + : constructor[Symbol.for('Thing.referenceType')] === 'artwork' + ? continuation() + : continuation.exit(false)), + }, + + withPropertyFromObject({ + object: 'thing', + property: input.value('thing'), + }), + + withPropertyFromObject({ + object: '#thing.thing', + property: input.value('constructor'), + }), + + { + dependencies: [ + '#thing.thing.constructor', + input('value'), + ], + + compute: ({ + ['#thing.thing.constructor']: constructor, + [input('value')]: value, + }) => constructor[Symbol.for('Thing.referenceType')] === value, }, ], diff --git a/src/data/composite/things/contribution/withContainingReverseContributionList.js b/src/data/composite/things/contribution/withContainingReverseContributionList.js index 56704c8b..175d6cbb 100644 --- a/src/data/composite/things/contribution/withContainingReverseContributionList.js +++ b/src/data/composite/things/contribution/withContainingReverseContributionList.js @@ -1,8 +1,12 @@ -// Get the artist's contribution list containing this property. +// Get the artist's contribution list containing this property. Although that +// list literally includes both dated and dateless contributions, here, if the +// current contribution is dateless, the list is filtered to only include +// dateless contributions from the same immediately nearby context. import {input, templateCompositeFrom} from '#composite'; -import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; import withContributionArtist from './withContributionArtist.js'; @@ -34,7 +38,43 @@ export default templateCompositeFrom({ object: '#artist', property: input('artistProperty'), }).outputs({ - ['#value']: '#containingReverseContributionList', + ['#value']: '#list', }), + + withResultOfAvailabilityCheck({ + from: 'date', + }).outputs({ + ['#availability']: '#hasDate', + }), + + { + dependencies: ['#hasDate', '#list'], + compute: (continuation, { + ['#hasDate']: hasDate, + ['#list']: list, + }) => + (hasDate + ? continuation.raiseOutput({ + ['#containingReverseContributionList']: + list.filter(contrib => contrib.date), + }) + : continuation({ + ['#list']: + list.filter(contrib => !contrib.date), + })), + }, + + { + dependencies: ['#list', 'thing'], + compute: (continuation, { + ['#list']: list, + ['thing']: thing, + }) => continuation({ + ['#containingReverseContributionList']: + (thing.album + ? list.filter(contrib => contrib.thing.album === thing.album) + : list), + }), + }, ], }); diff --git a/src/data/composite/things/contribution/withContributionArtist.js b/src/data/composite/things/contribution/withContributionArtist.js index 5a611c1a..5f81c716 100644 --- a/src/data/composite/things/contribution/withContributionArtist.js +++ b/src/data/composite/things/contribution/withContributionArtist.js @@ -1,8 +1,7 @@ import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {withPropertyFromObject} from '#composite/data'; import {withResolvedReference} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withContributionArtist`, @@ -17,16 +16,9 @@ export default templateCompositeFrom({ outputs: ['#artist'], steps: () => [ - withPropertyFromObject({ - object: 'thing', - property: input.value('artistData'), - internal: input.value(true), - }), - withResolvedReference({ ref: input('ref'), - data: '#thing.artistData', - find: input.value(find.artist), + find: soupyFind.input('artist'), }).outputs({ '#resolvedReference': '#artist', }), diff --git a/src/data/composite/things/flash-act/withFlashSide.js b/src/data/composite/things/flash-act/withFlashSide.js index 64daa1fb..e09f06e6 100644 --- a/src/data/composite/things/flash-act/withFlashSide.js +++ b/src/data/composite/things/flash-act/withFlashSide.js @@ -2,9 +2,10 @@ // If there's no side whose list of flash acts includes this act, the output // dependency will be null. -import {input, templateCompositeFrom} from '#composite'; +import {templateCompositeFrom} from '#composite'; import {withUniqueReferencingThing} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withFlashSide`, @@ -13,8 +14,7 @@ export default templateCompositeFrom({ steps: () => [ withUniqueReferencingThing({ - data: 'flashSideData', - list: input.value('acts'), + reverse: soupyReverse.input('flashSidesWhoseActsInclude'), }).outputs({ ['#uniqueReferencingThing']: '#flashSide', }), diff --git a/src/data/composite/things/flash/withFlashAct.js b/src/data/composite/things/flash/withFlashAct.js index 652b8bfb..87922aff 100644 --- a/src/data/composite/things/flash/withFlashAct.js +++ b/src/data/composite/things/flash/withFlashAct.js @@ -2,9 +2,10 @@ // If there's no flash whose list of flashes includes this flash, the output // dependency will be null. -import {input, templateCompositeFrom} from '#composite'; +import {templateCompositeFrom} from '#composite'; import {withUniqueReferencingThing} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withFlashAct`, @@ -13,8 +14,7 @@ export default templateCompositeFrom({ steps: () => [ withUniqueReferencingThing({ - data: 'flashActData', - list: input.value('flashes'), + reverse: soupyReverse.input('flashActsWhoseFlashesInclude'), }).outputs({ ['#uniqueReferencingThing']: '#flashAct', }), diff --git a/src/data/composite/things/track-section/index.js b/src/data/composite/things/track-section/index.js index 3202ed49..f11a2ab5 100644 --- a/src/data/composite/things/track-section/index.js +++ b/src/data/composite/things/track-section/index.js @@ -1 +1,3 @@ export {default as withAlbum} from './withAlbum.js'; +export {default as withContinueCountingFrom} from './withContinueCountingFrom.js'; +export {default as withStartCountingFrom} from './withStartCountingFrom.js'; diff --git a/src/data/composite/things/track-section/withAlbum.js b/src/data/composite/things/track-section/withAlbum.js index a4dfff0d..e257062e 100644 --- a/src/data/composite/things/track-section/withAlbum.js +++ b/src/data/composite/things/track-section/withAlbum.js @@ -1,8 +1,9 @@ // Gets the track section's album. -import {input, templateCompositeFrom} from '#composite'; +import {templateCompositeFrom} from '#composite'; import {withUniqueReferencingThing} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withAlbum`, @@ -11,8 +12,7 @@ export default templateCompositeFrom({ steps: () => [ withUniqueReferencingThing({ - data: 'albumData', - list: input.value('trackSections'), + reverse: soupyReverse.input('albumsWhoseTrackSectionsInclude'), }).outputs({ ['#uniqueReferencingThing']: '#album', }), diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js new file mode 100644 index 00000000..0ca52b6c --- /dev/null +++ b/src/data/composite/things/track-section/withContinueCountingFrom.js @@ -0,0 +1,25 @@ +import {templateCompositeFrom} from '#composite'; + +import withStartCountingFrom from './withStartCountingFrom.js'; + +export default templateCompositeFrom({ + annotation: `withContinueCountingFrom`, + + outputs: ['#continueCountingFrom'], + + steps: () => [ + withStartCountingFrom(), + + { + dependencies: ['#startCountingFrom', 'tracks'], + compute: (continuation, { + ['#startCountingFrom']: startCountingFrom, + ['tracks']: tracks, + }) => continuation({ + ['#continueCountingFrom']: + startCountingFrom + + tracks.length, + }), + }, + ], +}); diff --git a/src/data/composite/things/track-section/withStartCountingFrom.js b/src/data/composite/things/track-section/withStartCountingFrom.js new file mode 100644 index 00000000..ef345327 --- /dev/null +++ b/src/data/composite/things/track-section/withStartCountingFrom.js @@ -0,0 +1,64 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data'; + +import withAlbum from './withAlbum.js'; + +export default templateCompositeFrom({ + annotation: `withStartCountingFrom`, + + inputs: { + from: input({ + type: 'number', + defaultDependency: 'startCountingFrom', + acceptsNull: true, + }), + }, + + outputs: ['#startCountingFrom'], + + steps: () => [ + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: from, + }) => + (from === null + ? continuation() + : continuation.raiseOutput({'#startCountingFrom': from})), + }, + + withAlbum(), + + raiseOutputWithoutDependency({ + dependency: '#album', + output: input.value({'#startCountingFrom': 1}), + }), + + withPropertyFromObject({ + object: '#album', + property: input.value('trackSections'), + }), + + withNearbyItemFromList({ + list: '#album.trackSections', + item: input.myself(), + offset: input.value(-1), + }).outputs({ + '#nearbyItem': '#previousTrackSection', + }), + + raiseOutputWithoutDependency({ + dependency: '#previousTrackSection', + output: input.value({'#startCountingFrom': 1}), + }), + + withPropertyFromObject({ + object: '#previousTrackSection', + property: input.value('continueCountingFrom'), + }).outputs({ + '#previousTrackSection.continueCountingFrom': '#startCountingFrom', + }), + ], +}); diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js index 05ccaaba..e789e736 100644 --- a/src/data/composite/things/track/index.js +++ b/src/data/composite/things/track/index.js @@ -1,16 +1,17 @@ export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js'; -export {default as inheritContributionListFromOriginalRelease} from './inheritContributionListFromOriginalRelease.js'; -export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js'; -export {default as trackReverseReferenceList} from './trackReverseReferenceList.js'; -export {default as withAlbum} from './withAlbum.js'; +export {default as inheritContributionListFromMainRelease} from './inheritContributionListFromMainRelease.js'; +export {default as inheritFromMainRelease} from './inheritFromMainRelease.js'; +export {default as withAllReleases} from './withAllReleases.js'; export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js'; export {default as withContainingTrackSection} from './withContainingTrackSection.js'; +export {default as withCoverArtistContribs} from './withCoverArtistContribs.js'; export {default as withDate} from './withDate.js'; export {default as withDirectorySuffix} from './withDirectorySuffix.js'; export {default as withHasUniqueCoverArt} from './withHasUniqueCoverArt.js'; -export {default as withOriginalRelease} from './withOriginalRelease.js'; +export {default as withMainRelease} from './withMainRelease.js'; export {default as withOtherReleases} from './withOtherReleases.js'; export {default as withPropertyFromAlbum} from './withPropertyFromAlbum.js'; -export {default as withPropertyFromOriginalRelease} from './withPropertyFromOriginalRelease.js'; +export {default as withPropertyFromMainRelease} from './withPropertyFromMainRelease.js'; export {default as withSuffixDirectoryFromAlbum} from './withSuffixDirectoryFromAlbum.js'; export {default as withTrackArtDate} from './withTrackArtDate.js'; +export {default as withTrackNumber} from './withTrackNumber.js'; diff --git a/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js b/src/data/composite/things/track/inheritContributionListFromMainRelease.js index f4ae3ddb..89252feb 100644 --- a/src/data/composite/things/track/inheritContributionListFromOriginalRelease.js +++ b/src/data/composite/things/track/inheritContributionListFromMainRelease.js @@ -1,5 +1,5 @@ -// Like inheritFromOriginalRelease, but tuned for contributions. -// Recontextualized contributions for this track. +// Like inheritFromMainRelease, but tuned for contributions. +// Recontextualizes contributions for this track. import {input, templateCompositeFrom} from '#composite'; @@ -9,36 +9,36 @@ import {withRecontextualizedContributionList, withRedatedContributionList} from '#composite/wiki-data'; import withDate from './withDate.js'; -import withPropertyFromOriginalRelease - from './withPropertyFromOriginalRelease.js'; +import withPropertyFromMainRelease + from './withPropertyFromMainRelease.js'; export default templateCompositeFrom({ - annotation: `inheritContributionListFromOriginalRelease`, + annotation: `inheritContributionListFromMainRelease`, steps: () => [ - withPropertyFromOriginalRelease({ + withPropertyFromMainRelease({ property: input.thisProperty(), notFoundValue: input.value([]), }), raiseOutputWithoutDependency({ - dependency: '#isRerelease', + dependency: '#isSecondaryRelease', mode: input.value('falsy'), }), withRecontextualizedContributionList({ - list: '#originalValue', + list: '#mainReleaseValue', }), withDate(), withRedatedContributionList({ - list: '#originalValue', + list: '#mainReleaseValue', date: '#date', }), exposeDependency({ - dependency: '#originalValue', + dependency: '#mainReleaseValue', }), ], }); diff --git a/src/data/composite/things/track/inheritFromOriginalRelease.js b/src/data/composite/things/track/inheritFromMainRelease.js index 38ab06be..b1cbb65e 100644 --- a/src/data/composite/things/track/inheritFromOriginalRelease.js +++ b/src/data/composite/things/track/inheritFromMainRelease.js @@ -1,9 +1,9 @@ // Early exits with the value for the same property as specified on the -// original release, if this track is a rerelease, and otherwise continues +// main release, if this track is a secondary release, and otherwise continues // without providing any further dependencies. // -// Like withOriginalRelease, this will early exit (with notFoundValue) if the -// original release is specified by reference and that reference doesn't +// Like withMainRelease, this will early exit (with notFoundValue) if the +// main release is specified by reference and that reference doesn't // resolve to anything. import {input, templateCompositeFrom} from '#composite'; @@ -11,11 +11,11 @@ import {input, templateCompositeFrom} from '#composite'; import {exposeDependency, raiseOutputWithoutDependency} from '#composite/control-flow'; -import withPropertyFromOriginalRelease - from './withPropertyFromOriginalRelease.js'; +import withPropertyFromMainRelease + from './withPropertyFromMainRelease.js'; export default templateCompositeFrom({ - annotation: `inheritFromOriginalRelease`, + annotation: `inheritFromMainRelease`, inputs: { notFoundValue: input({ @@ -24,18 +24,18 @@ export default templateCompositeFrom({ }, steps: () => [ - withPropertyFromOriginalRelease({ + withPropertyFromMainRelease({ property: input.thisProperty(), notFoundValue: input('notFoundValue'), }), raiseOutputWithoutDependency({ - dependency: '#isRerelease', + dependency: '#isSecondaryRelease', mode: input.value('falsy'), }), exposeDependency({ - dependency: '#originalValue', + dependency: '#mainReleaseValue', }), ], }); diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js deleted file mode 100644 index 65a2263d..00000000 --- a/src/data/composite/things/track/trackAdditionalNameList.js +++ /dev/null @@ -1,38 +0,0 @@ -// Compiles additional names from various sources. - -import {input, templateCompositeFrom} from '#composite'; -import {isAdditionalNameList} from '#validators'; - -import withInferredAdditionalNames from './withInferredAdditionalNames.js'; -import withSharedAdditionalNames from './withSharedAdditionalNames.js'; - -export default templateCompositeFrom({ - annotation: `trackAdditionalNameList`, - - compose: false, - - update: {validate: isAdditionalNameList}, - - steps: () => [ - withInferredAdditionalNames(), - withSharedAdditionalNames(), - - { - dependencies: [ - '#inferredAdditionalNames', - '#sharedAdditionalNames', - input.updateValue(), - ], - - compute: ({ - ['#inferredAdditionalNames']: inferredAdditionalNames, - ['#sharedAdditionalNames']: sharedAdditionalNames, - [input.updateValue()]: providedAdditionalNames, - }) => [ - ...providedAdditionalNames ?? [], - ...sharedAdditionalNames, - ...inferredAdditionalNames, - ], - }, - ], -}); diff --git a/src/data/composite/things/track/trackReverseReferenceList.js b/src/data/composite/things/track/trackReverseReferenceList.js deleted file mode 100644 index 44940ae7..00000000 --- a/src/data/composite/things/track/trackReverseReferenceList.js +++ /dev/null @@ -1,38 +0,0 @@ -// Like a normal reverse reference list ("objects which reference this object -// under a specified property"), only excluding rereleases from the possible -// outputs. While it's useful to travel from a rerelease to the tracks it -// references, rereleases aren't generally relevant from the perspective of -// the tracks *being* referenced. Apart from hiding rereleases from lists on -// the site, it also excludes keeps them from relational data processing, such -// as on the "Tracks - by Times Referenced" listing page. - -import {input, templateCompositeFrom} from '#composite'; -import {withReverseReferenceList} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `trackReverseReferenceList`, - - compose: false, - - inputs: { - list: input({type: 'string'}), - }, - - steps: () => [ - withReverseReferenceList({ - data: 'trackData', - list: input('list'), - }), - - { - flags: {expose: true}, - expose: { - dependencies: ['#reverseReferenceList'], - compute: ({ - ['#reverseReferenceList']: reverseReferenceList, - }) => - reverseReferenceList.filter(track => !track.originalReleaseTrack), - }, - }, - ], -}); diff --git a/src/data/composite/things/track/withAlbum.js b/src/data/composite/things/track/withAlbum.js deleted file mode 100644 index 03b840d4..00000000 --- a/src/data/composite/things/track/withAlbum.js +++ /dev/null @@ -1,22 +0,0 @@ -// Gets the track's album. This will early exit if albumData is missing. -// If there's no album whose list of tracks includes this track, the output -// dependency will be null. - -import {input, templateCompositeFrom} from '#composite'; - -import {withUniqueReferencingThing} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `withAlbum`, - - outputs: ['#album'], - - steps: () => [ - withUniqueReferencingThing({ - data: 'albumData', - list: input.value('tracks'), - }).outputs({ - ['#uniqueReferencingThing']: '#album', - }), - ], -}); diff --git a/src/data/composite/things/track/withAllReleases.js b/src/data/composite/things/track/withAllReleases.js new file mode 100644 index 00000000..891db102 --- /dev/null +++ b/src/data/composite/things/track/withAllReleases.js @@ -0,0 +1,46 @@ +// Gets all releases of the current track. All items of the outputs are +// distinct Track objects; one track is the main release; all else are +// secondary releases of that main release; and one item, which may be +// the main release or one of the secondary releases, is the current +// track. The results are sorted by date, and it is possible that the +// main release is not actually the earliest/first. + +import {input, templateCompositeFrom} from '#composite'; +import {sortByDate} from '#sort'; + +import {withPropertyFromObject} from '#composite/data'; + +import withMainRelease from './withMainRelease.js'; + +export default templateCompositeFrom({ + annotation: `withAllReleases`, + + outputs: ['#allReleases'], + + steps: () => [ + withMainRelease({ + selfIfMain: input.value(true), + notFoundValue: input.value([]), + }), + + // We don't talk about bruno no no + // Yes, this can perform a normal access equivalent to + // `this.secondaryReleases` from within a data composition. + // Oooooooooooooooooooooooooooooooooooooooooooooooo + withPropertyFromObject({ + object: '#mainRelease', + property: input.value('secondaryReleases'), + }), + + { + dependencies: ['#mainRelease', '#mainRelease.secondaryReleases'], + compute: (continuation, { + ['#mainRelease']: mainRelease, + ['#mainRelease.secondaryReleases']: secondaryReleases, + }) => continuation({ + ['#allReleases']: + sortByDate([mainRelease, ...secondaryReleases]), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js index e01720b4..87edf21e 100644 --- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js +++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js @@ -16,6 +16,8 @@ import { exposeUpdateValueOrContinue, } from '#composite/control-flow'; +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; + export default templateCompositeFrom({ annotation: `withAlwaysReferenceByDirectory`, @@ -26,19 +28,7 @@ export default templateCompositeFrom({ validate: input.value(isBoolean), }), - // withAlwaysReferenceByDirectory is sort of a fragile area - we can't - // find the track's album the normal way because albums' track lists - // recurse back into alwaysReferenceByDirectory! - withResolvedReference({ - ref: 'dataSourceAlbum', - data: 'albumData', - find: input.value(find.album), - }).outputs({ - '#resolvedReference': '#album', - }), - - withPropertyFromObject({ - object: '#album', + withPropertyFromAlbum({ property: input.value('alwaysReferenceTracksByDirectory'), }), @@ -51,7 +41,7 @@ export default templateCompositeFrom({ // Remaining code is for defaulting to true if this track is a rerelease of // another with the same name, so everything further depends on access to - // trackData as well as originalReleaseTrack. + // trackData as well as mainReleaseTrack. exitWithoutDependency({ dependency: 'trackData', @@ -60,45 +50,46 @@ export default templateCompositeFrom({ }), exitWithoutDependency({ - dependency: 'originalReleaseTrack', + dependency: 'mainReleaseTrack', value: input.value(false), }), - // It's necessary to use the custom trackOriginalReleasesOnly find function + // It's necessary to use the custom trackMainReleasesOnly find function // here, so as to avoid recursion issues - the find.track() function depends // on accessing each track's alwaysReferenceByDirectory, which means it'll // hit *this track* - and thus this step - and end up recursing infinitely. - // By definition, find.trackOriginalReleasesOnly excludes tracks which have - // an originalReleaseTrack update value set, which means even though it does + // By definition, find.trackMainReleasesOnly excludes tracks which have + // an mainReleaseTrack update value set, which means even though it does // still access each of tracks' `alwaysReferenceByDirectory` property, it // won't access that of *this* track - it will never proceed past the // `exitWithoutDependency` step directly above, so there's no opportunity // for recursion. withResolvedReference({ - ref: 'originalReleaseTrack', + ref: 'mainReleaseTrack', data: 'trackData', - find: input.value(find.trackOriginalReleasesOnly), + find: input.value(find.trackMainReleasesOnly), }).outputs({ - '#resolvedReference': '#originalRelease', + '#resolvedReference': '#mainRelease', }), exitWithoutDependency({ - dependency: '#originalRelease', + dependency: '#mainRelease', value: input.value(false), }), withPropertyFromObject({ - object: '#originalRelease', + object: '#mainRelease', property: input.value('name'), }), { - dependencies: ['name', '#originalRelease.name'], + dependencies: ['name', '#mainRelease.name'], compute: (continuation, { name, - ['#originalRelease.name']: originalName, + ['#mainRelease.name']: mainReleaseName, }) => continuation({ - ['#alwaysReferenceByDirectory']: name === originalName, + ['#alwaysReferenceByDirectory']: + name === mainReleaseName, }), }, ], diff --git a/src/data/composite/things/track/withContainingTrackSection.js b/src/data/composite/things/track/withContainingTrackSection.js index 9bbd9bd5..3d4d081e 100644 --- a/src/data/composite/things/track/withContainingTrackSection.js +++ b/src/data/composite/things/track/withContainingTrackSection.js @@ -1,8 +1,9 @@ // Gets the track section containing this track from its album's track list. -import {input, templateCompositeFrom} from '#composite'; +import {templateCompositeFrom} from '#composite'; import {withUniqueReferencingThing} from '#composite/wiki-data'; +import {soupyReverse} from '#composite/wiki-properties'; export default templateCompositeFrom({ annotation: `withContainingTrackSection`, @@ -11,8 +12,7 @@ export default templateCompositeFrom({ steps: () => [ withUniqueReferencingThing({ - data: 'trackSectionData', - list: input.value('tracks'), + reverse: soupyReverse.input('trackSectionsWhichInclude'), }).outputs({ ['#uniqueReferencingThing']: '#trackSection', }), diff --git a/src/data/composite/things/track/withCoverArtistContribs.js b/src/data/composite/things/track/withCoverArtistContribs.js new file mode 100644 index 00000000..9057cfeb --- /dev/null +++ b/src/data/composite/things/track/withCoverArtistContribs.js @@ -0,0 +1,73 @@ +import {input, templateCompositeFrom} from '#composite'; +import {isContributionList} from '#validators'; + +import {exposeDependencyOrContinue} from '#composite/control-flow'; + +import { + withRecontextualizedContributionList, + withRedatedContributionList, + withResolvedContribs, +} from '#composite/wiki-data'; + +import exitWithoutUniqueCoverArt from './exitWithoutUniqueCoverArt.js'; +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; +import withTrackArtDate from './withTrackArtDate.js'; + +export default templateCompositeFrom({ + annotation: `withCoverArtistContribs`, + + inputs: { + from: input({ + defaultDependency: 'coverArtistContribs', + validate: isContributionList, + acceptsNull: true, + }), + }, + + outputs: ['#coverArtistContribs'], + + steps: () => [ + exitWithoutUniqueCoverArt({ + value: input.value([]), + }), + + withTrackArtDate(), + + withResolvedContribs({ + from: input('from'), + thingProperty: input.value('coverArtistContribs'), + artistProperty: input.value('trackCoverArtistContributions'), + date: '#trackArtDate', + }).outputs({ + '#resolvedContribs': '#coverArtistContribs', + }), + + exposeDependencyOrContinue({ + dependency: '#coverArtistContribs', + mode: input.value('empty'), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtistContribs'), + }), + + withRecontextualizedContributionList({ + list: '#album.trackCoverArtistContribs', + artistProperty: input.value('trackCoverArtistContributions'), + }), + + withRedatedContributionList({ + list: '#album.trackCoverArtistContribs', + date: '#trackArtDate', + }), + + { + dependencies: ['#album.trackCoverArtistContribs'], + compute: (continuation, { + ['#album.trackCoverArtistContribs']: coverArtistContribs, + }) => continuation({ + ['#coverArtistContribs']: coverArtistContribs, + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js index f7e65f25..85d3b92a 100644 --- a/src/data/composite/things/track/withHasUniqueCoverArt.js +++ b/src/data/composite/things/track/withHasUniqueCoverArt.js @@ -5,11 +5,18 @@ // or a placeholder. (This property is named hasUniqueCoverArt instead of // the usual hasCoverArt to emphasize that it does not inherit from the // album.) +// +// withHasUniqueCoverArt is based only around the presence of *specified* +// cover artist contributions, not whether the references to artists on those +// contributions actually resolve to anything. It completely evades interacting +// with find/replace. import {input, templateCompositeFrom} from '#composite'; -import {empty} from '#sugar'; -import {withResolvedContribs} from '#composite/wiki-data'; +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {fillMissingListItems, withFlattenedList, withPropertyFromList} + from '#composite/data'; import withPropertyFromAlbum from './withPropertyFromAlbum.js'; @@ -29,36 +36,73 @@ export default templateCompositeFrom({ : continuation()), }, - withResolvedContribs({ + withResultOfAvailabilityCheck({ from: 'coverArtistContribs', - date: input.value(null), + mode: input.value('empty'), }), { - dependencies: ['#resolvedContribs'], + dependencies: ['#availability'], compute: (continuation, { - ['#resolvedContribs']: contribsFromTrack, + ['#availability']: availability, }) => - (empty(contribsFromTrack) - ? continuation() - : continuation.raiseOutput({ + (availability + ? continuation.raiseOutput({ ['#hasUniqueCoverArt']: true, - })), + }) + : continuation()), }, withPropertyFromAlbum({ property: input.value('trackCoverArtistContribs'), + internal: input.value(true), + }), + + withResultOfAvailabilityCheck({ + from: '#album.trackCoverArtistContribs', + mode: input.value('empty'), }), { - dependencies: ['#album.trackCoverArtistContribs'], + dependencies: ['#availability'], compute: (continuation, { - ['#album.trackCoverArtistContribs']: contribsFromAlbum, + ['#availability']: availability, }) => - continuation.raiseOutput({ - ['#hasUniqueCoverArt']: - !empty(contribsFromAlbum), - }), + (availability + ? continuation.raiseOutput({ + ['#hasUniqueCoverArt']: true, + }) + : continuation()), }, + + raiseOutputWithoutDependency({ + dependency: 'trackArtworks', + mode: input.value('empty'), + output: input.value({'#hasUniqueCoverArt': false}), + }), + + withPropertyFromList({ + list: 'trackArtworks', + property: input.value('artistContribs'), + internal: input.value(true), + }), + + // Since we're getting the update value for each artwork's artistContribs, + // it may not be set at all, and in that case won't be exposing as []. + fillMissingListItems({ + list: '#trackArtworks.artistContribs', + fill: input.value([]), + }), + + withFlattenedList({ + list: '#trackArtworks.artistContribs', + }), + + withResultOfAvailabilityCheck({ + from: '#flattenedList', + mode: input.value('empty'), + }).outputs({ + '#availability': '#hasUniqueCoverArt', + }), ], }); diff --git a/src/data/composite/things/track/withOriginalRelease.js b/src/data/composite/things/track/withMainRelease.js index c7f49657..3a91edae 100644 --- a/src/data/composite/things/track/withOriginalRelease.js +++ b/src/data/composite/things/track/withMainRelease.js @@ -1,62 +1,54 @@ -// Just includes the original release of this track as a dependency. -// If this track isn't a rerelease, then it'll provide null, unless the -// {selfIfOriginal} option is set, in which case it'll provide this track -// itself. This will early exit (with notFoundValue) if the original release +// Just includes the main release of this track as a dependency. +// If this track isn't a secondary release, then it'll provide null, unless +// the {selfIfMain} option is set, in which case it'll provide this track +// itself. This will early exit (with notFoundValue) if the main release // is specified by reference and that reference doesn't resolve to anything. import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {validateWikiData} from '#validators'; import {exitWithoutDependency, withResultOfAvailabilityCheck} from '#composite/control-flow'; import {withResolvedReference} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; export default templateCompositeFrom({ - annotation: `withOriginalRelease`, + annotation: `withMainRelease`, inputs: { - selfIfOriginal: input({type: 'boolean', defaultValue: false}), - - data: input({ - validate: validateWikiData({referenceType: 'track'}), - defaultDependency: 'trackData', - }), - + selfIfMain: input({type: 'boolean', defaultValue: false}), notFoundValue: input({defaultValue: null}), }, - outputs: ['#originalRelease'], + outputs: ['#mainRelease'], steps: () => [ withResultOfAvailabilityCheck({ - from: 'originalReleaseTrack', + from: 'mainReleaseTrack', }), { dependencies: [ input.myself(), - input('selfIfOriginal'), + input('selfIfMain'), '#availability', ], compute: (continuation, { [input.myself()]: track, - [input('selfIfOriginal')]: selfIfOriginal, + [input('selfIfMain')]: selfIfMain, '#availability': availability, }) => (availability ? continuation() : continuation.raiseOutput({ - ['#originalRelease']: - (selfIfOriginal ? track : null), + ['#mainRelease']: + (selfIfMain ? track : null), })), }, withResolvedReference({ - ref: 'originalReleaseTrack', - data: input('data'), - find: input.value(find.track), + ref: 'mainReleaseTrack', + find: soupyFind.input('track'), }), exitWithoutDependency({ @@ -71,7 +63,7 @@ export default templateCompositeFrom({ ['#resolvedReference']: resolvedReference, }) => continuation({ - ['#originalRelease']: resolvedReference, + ['#mainRelease']: resolvedReference, }), }, ], diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js index f8c1c3f0..bb3e8983 100644 --- a/src/data/composite/things/track/withOtherReleases.js +++ b/src/data/composite/things/track/withOtherReleases.js @@ -1,8 +1,9 @@ -import {input, templateCompositeFrom} from '#composite'; +// Gets all releases of the current track *except* this track itself; +// in other words, all other releases of the current track. -import {exitWithoutDependency} from '#composite/control-flow'; +import {input, templateCompositeFrom} from '#composite'; -import withOriginalRelease from './withOriginalRelease.js'; +import withAllReleases from './withAllReleases.js'; export default templateCompositeFrom({ annotation: `withOtherReleases`, @@ -10,31 +11,16 @@ export default templateCompositeFrom({ outputs: ['#otherReleases'], steps: () => [ - exitWithoutDependency({ - dependency: 'trackData', - mode: input.value('empty'), - }), - - withOriginalRelease({ - selfIfOriginal: input.value(true), - notFoundValue: input.value([]), - }), + withAllReleases(), { - dependencies: [input.myself(), '#originalRelease', 'trackData'], + dependencies: [input.myself(), '#allReleases'], compute: (continuation, { [input.myself()]: thisTrack, - ['#originalRelease']: originalRelease, - trackData, + ['#allReleases']: allReleases, }) => continuation({ ['#otherReleases']: - (originalRelease === thisTrack - ? [] - : [originalRelease]) - .concat(trackData.filter(track => - track !== originalRelease && - track !== thisTrack && - track.originalReleaseTrack === originalRelease)), + allReleases.filter(track => track !== thisTrack), }), }, ], diff --git a/src/data/composite/things/track/withPropertyFromAlbum.js b/src/data/composite/things/track/withPropertyFromAlbum.js index d41390fa..a203c2e7 100644 --- a/src/data/composite/things/track/withPropertyFromAlbum.js +++ b/src/data/composite/things/track/withPropertyFromAlbum.js @@ -2,17 +2,15 @@ // property name prefixed with '#album.' (by default). import {input, templateCompositeFrom} from '#composite'; -import {is} from '#validators'; import {withPropertyFromObject} from '#composite/data'; -import withAlbum from './withAlbum.js'; - export default templateCompositeFrom({ annotation: `withPropertyFromAlbum`, inputs: { property: input.staticValue({type: 'string'}), + internal: input({type: 'boolean', defaultValue: false}), }, outputs: ({ @@ -20,11 +18,21 @@ export default templateCompositeFrom({ }) => ['#album.' + property], steps: () => [ - withAlbum(), + // XXX: This is a ridiculous hack considering `defaultValue` above. + // If we were certain what was up, we'd just get around to fixing it LOL + { + dependencies: [input('internal')], + compute: (continuation, { + [input('internal')]: internal, + }) => continuation({ + ['#internal']: internal ?? false, + }), + }, withPropertyFromObject({ - object: '#album', + object: 'album', property: input('property'), + internal: '#internal', }), { diff --git a/src/data/composite/things/track/withPropertyFromOriginalRelease.js b/src/data/composite/things/track/withPropertyFromMainRelease.js index fd37f6de..393a4c63 100644 --- a/src/data/composite/things/track/withPropertyFromOriginalRelease.js +++ b/src/data/composite/things/track/withPropertyFromMainRelease.js @@ -1,8 +1,8 @@ -// Provides a value inherited from the original release, if applicable, and a -// flag indicating if this track is a rerelase or not. +// Provides a value inherited from the main release, if applicable, and a +// flag indicating if this track is a secondary release or not. // -// Like withOriginalRelease, this will early exit (with notFoundValue) if the -// original release is specified by reference and that reference doesn't +// Like withMainRelease, this will early exit (with notFoundValue) if the +// main release is specified by reference and that reference doesn't // resolve to anything. import {input, templateCompositeFrom} from '#composite'; @@ -10,10 +10,10 @@ import {input, templateCompositeFrom} from '#composite'; import {withResultOfAvailabilityCheck} from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; -import withOriginalRelease from './withOriginalRelease.js'; +import withMainRelease from './withMainRelease.js'; export default templateCompositeFrom({ - annotation: `inheritFromOriginalRelease`, + annotation: `inheritFromMainRelease`, inputs: { property: input({type: 'string'}), @@ -26,18 +26,18 @@ export default templateCompositeFrom({ outputs: ({ [input.staticValue('property')]: property, }) => - ['#isRerelease'].concat( + ['#isSecondaryRelease'].concat( (property - ? ['#original.' + property] - : ['#originalValue'])), + ? ['#mainRelease.' + property] + : ['#mainReleaseValue'])), steps: () => [ - withOriginalRelease({ + withMainRelease({ notFoundValue: input('notFoundValue'), }), withResultOfAvailabilityCheck({ - from: '#originalRelease', + from: '#mainRelease', }), { @@ -54,14 +54,14 @@ export default templateCompositeFrom({ ? continuation() : continuation.raiseOutput( Object.assign( - {'#isRerelease': false}, + {'#isSecondaryRelease': false}, (property - ? {['#original.' + property]: null} - : {'#originalValue': null})))), + ? {['#mainRelease.' + property]: null} + : {'#mainReleaseValue': null})))), }, withPropertyFromObject({ - object: '#originalRelease', + object: '#mainRelease', property: input('property'), }), @@ -77,10 +77,10 @@ export default templateCompositeFrom({ }) => continuation.raiseOutput( Object.assign( - {'#isRerelease': true}, + {'#isSecondaryRelease': true}, (property - ? {['#original.' + property]: value} - : {'#originalValue': value}))), + ? {['#mainRelease.' + property]: value} + : {'#mainReleaseValue': value}))), }, ], }); diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js index e2c4d8bc..9b7b61c7 100644 --- a/src/data/composite/things/track/withTrackArtDate.js +++ b/src/data/composite/things/track/withTrackArtDate.js @@ -1,11 +1,3 @@ -// Gets the date of cover art release. This represents only the track's own -// unique cover artwork, if any. -// -// If the 'fallback' option is false (the default), this will only output -// the track's own coverArtDate or its album's trackArtDate. If 'fallback' -// is set, and neither of these is available, it'll output the track's own -// date instead. - import {input, templateCompositeFrom} from '#composite'; import {isDate} from '#validators'; @@ -24,11 +16,6 @@ export default templateCompositeFrom({ defaultDependency: 'coverArtDate', acceptsNull: true, }), - - fallback: input({ - type: 'boolean', - defaultValue: false, - }), }, outputs: ['#trackArtDate'], @@ -57,20 +44,13 @@ export default templateCompositeFrom({ }), { - dependencies: [ - '#album.trackArtDate', - input('fallback'), - ], - + dependencies: ['#album.trackArtDate'], compute: (continuation, { ['#album.trackArtDate']: albumTrackArtDate, - [input('fallback')]: fallback, }) => (albumTrackArtDate ? continuation.raiseOutput({'#trackArtDate': albumTrackArtDate}) - : fallback - ? continuation() - : continuation.raiseOutput({'#trackArtDate': null})), + : continuation()), }, withDate().outputs({ diff --git a/src/data/composite/things/track/withTrackNumber.js b/src/data/composite/things/track/withTrackNumber.js new file mode 100644 index 00000000..61428e8c --- /dev/null +++ b/src/data/composite/things/track/withTrackNumber.js @@ -0,0 +1,50 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withIndexInList, withPropertiesFromObject} from '#composite/data'; + +import withContainingTrackSection from './withContainingTrackSection.js'; + +export default templateCompositeFrom({ + annotation: `withTrackNumber`, + + outputs: ['#trackNumber'], + + steps: () => [ + withContainingTrackSection(), + + // Zero is the fallback, not one, but in most albums the first track + // (and its intended output by this composition) will be one. + raiseOutputWithoutDependency({ + dependency: '#trackSection', + output: input.value({'#trackNumber': 0}), + }), + + withPropertiesFromObject({ + object: '#trackSection', + properties: input.value(['tracks', 'startCountingFrom']), + }), + + withIndexInList({ + list: '#trackSection.tracks', + item: input.myself(), + }), + + raiseOutputWithoutDependency({ + dependency: '#index', + output: input.value({'#trackNumber': 0}), + }), + + { + dependencies: ['#trackSection.startCountingFrom', '#index'], + compute: (continuation, { + ['#trackSection.startCountingFrom']: startCountingFrom, + ['#index']: index, + }) => continuation({ + ['#trackNumber']: + startCountingFrom + + index, + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/gobbleSoupyFind.js b/src/data/composite/wiki-data/gobbleSoupyFind.js new file mode 100644 index 00000000..aec3f5b1 --- /dev/null +++ b/src/data/composite/wiki-data/gobbleSoupyFind.js @@ -0,0 +1,39 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {withPropertyFromObject} from '#composite/data'; + +import inputSoupyFind, {getSoupyFindInputKey} from './inputSoupyFind.js'; + +export default templateCompositeFrom({ + annotation: `gobbleSoupyFind`, + + inputs: { + find: inputSoupyFind(), + }, + + outputs: ['#find'], + + steps: () => [ + { + dependencies: [input('find')], + compute: (continuation, { + [input('find')]: find, + }) => + (typeof find === 'function' + ? continuation.raiseOutput({ + ['#find']: find, + }) + : continuation({ + ['#key']: + getSoupyFindInputKey(find), + })), + }, + + withPropertyFromObject({ + object: 'find', + property: '#key', + }).outputs({ + '#value': '#find', + }), + ], +}); diff --git a/src/data/composite/wiki-data/gobbleSoupyReverse.js b/src/data/composite/wiki-data/gobbleSoupyReverse.js new file mode 100644 index 00000000..86a1061c --- /dev/null +++ b/src/data/composite/wiki-data/gobbleSoupyReverse.js @@ -0,0 +1,39 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {withPropertyFromObject} from '#composite/data'; + +import inputSoupyReverse, {getSoupyReverseInputKey} from './inputSoupyReverse.js'; + +export default templateCompositeFrom({ + annotation: `gobbleSoupyReverse`, + + inputs: { + reverse: inputSoupyReverse(), + }, + + outputs: ['#reverse'], + + steps: () => [ + { + dependencies: [input('reverse')], + compute: (continuation, { + [input('reverse')]: reverse, + }) => + (typeof reverse === 'function' + ? continuation.raiseOutput({ + ['#reverse']: reverse, + }) + : continuation({ + ['#key']: + getSoupyReverseInputKey(reverse), + })), + }, + + withPropertyFromObject({ + object: 'reverse', + property: '#key', + }).outputs({ + '#value': '#reverse', + }), + ], +}); diff --git a/src/data/composite/wiki-data/helpers/withResolvedReverse.js b/src/data/composite/wiki-data/helpers/withResolvedReverse.js new file mode 100644 index 00000000..818f60b7 --- /dev/null +++ b/src/data/composite/wiki-data/helpers/withResolvedReverse.js @@ -0,0 +1,40 @@ +// Actually execute a reverse function. + +import {input, templateCompositeFrom} from '#composite'; + +import inputWikiData from '../inputWikiData.js'; + +export default templateCompositeFrom({ + annotation: `withReverseReferenceList`, + + inputs: { + data: inputWikiData({allowMixedTypes: true}), + reverse: input({type: 'function'}), + options: input({type: 'object', defaultValue: null}), + }, + + outputs: ['#resolvedReverse'], + + steps: () => [ + { + dependencies: [ + input.myself(), + input('data'), + input('reverse'), + input('options'), + ], + + compute: (continuation, { + [input.myself()]: myself, + [input('data')]: data, + [input('reverse')]: reverseFunction, + [input('options')]: opts, + }) => continuation({ + ['#resolvedReverse']: + (data + ? reverseFunction(myself, data, opts) + : reverseFunction(myself, opts)), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/helpers/withReverseList-template.js b/src/data/composite/wiki-data/helpers/withReverseList-template.js deleted file mode 100644 index 6ffd5d70..00000000 --- a/src/data/composite/wiki-data/helpers/withReverseList-template.js +++ /dev/null @@ -1,193 +0,0 @@ -// Baseline implementation shared by or underlying reverse lists. -// -// This is a very rudimentary "these compositions have basically the same -// shape but slightly different guts midway through" kind of solution, -// and should use compositional subroutines instead, once those are ready. -// -// But, until then, this has the same effect of avoiding code duplication -// and clearly identifying differences. -// -// --- -// -// This implementation uses a global cache (via WeakMap) to attempt to speed -// up subsequent similar accesses. -// -// This has absolutely not been rigorously tested with altering properties of -// data objects in a wiki data array which is reused. If a new wiki data array -// is used, a fresh cache will always be created. -// - -import {input, templateCompositeFrom} from '#composite'; -import {sortByDate} from '#sort'; -import {stitchArrays} from '#sugar'; - -import {exitWithoutDependency, raiseOutputWithoutDependency} - from '#composite/control-flow'; -import {withFlattenedList, withMappedList} from '#composite/data'; - -import inputWikiData from '../inputWikiData.js'; - -export default function withReverseList_template({ - annotation, - - propertyInputName, - outputName, - - additionalInputs = {}, - - customCompositionSteps, -}) { - // Mapping of reference list property to WeakMap. - // Each WeakMap maps a wiki data array to another weak map, - // which in turn maps each referenced thing to an array of - // things referencing it. - const caches = new Map(); - - return templateCompositeFrom({ - annotation, - - inputs: { - data: inputWikiData({ - allowMixedTypes: true, - }), - - [propertyInputName]: input({ - type: 'string', - }), - - ...additionalInputs, - }, - - outputs: [outputName], - - steps: () => [ - // Early exit with an empty array if the data list isn't available. - exitWithoutDependency({ - dependency: input('data'), - value: input.value([]), - }), - - // Raise an empty array (don't early exit) if the data list is empty. - raiseOutputWithoutDependency({ - dependency: input('data'), - mode: input.value('empty'), - output: input.value({[outputName]: []}), - }), - - // Check for an existing cache record which corresponds to this - // property input and input('data'). If it exists, query it for the - // current thing, and raise that; if it doesn't, create it, put it - // where it needs to be, and provide it so the next steps can fill - // it in. - { - dependencies: [input(propertyInputName), input('data'), input.myself()], - - compute: (continuation, { - [input(propertyInputName)]: property, - [input('data')]: data, - [input.myself()]: myself, - }) => { - if (!caches.has(property)) { - const cache = new WeakMap(); - caches.set(property, cache); - - const cacheRecord = new WeakMap(); - cache.set(data, cacheRecord); - - return continuation({ - ['#cacheRecord']: cacheRecord, - }); - } - - const cache = caches.get(property); - - if (!cache.has(data)) { - const cacheRecord = new WeakMap(); - cache.set(data, cacheRecord); - - return continuation({ - ['#cacheRecord']: cacheRecord, - }); - } - - return continuation.raiseOutput({ - [outputName]: - cache.get(data).get(myself) ?? [], - }); - }, - }, - - ...customCompositionSteps(), - - // Actually fill in the cache record. Since we're building up a *reverse* - // reference list, track connections in terms of the referenced thing. - // Although we gather all referenced things into a set and provide that - // for sorting purposes in the next step, we *don't* reprovide the cache - // record, because we're mutating that in-place - we'll just reuse its - // existing '#cacheRecord' dependency. - { - dependencies: ['#cacheRecord', '#referencingThings', '#referencedThings'], - compute: (continuation, { - ['#cacheRecord']: cacheRecord, - ['#referencingThings']: referencingThings, - ['#referencedThings']: referencedThings, - }) => { - const allReferencedThings = new Set(); - - stitchArrays({ - referencingThing: referencingThings, - referencedThings: referencedThings, - }).forEach(({referencingThing, referencedThings}) => { - for (const referencedThing of referencedThings) { - if (cacheRecord.has(referencedThing)) { - cacheRecord.get(referencedThing).push(referencingThing); - } else { - cacheRecord.set(referencedThing, [referencingThing]); - allReferencedThings.add(referencedThing); - } - } - }); - - return continuation({ - ['#allReferencedThings']: - allReferencedThings, - }); - }, - }, - - // Sort the entries in the cache records, too, just by date - the rest of - // sorting should be handled outside of this composition, either preceding - // (changing the 'data' input) or following (sorting the output). - // Again we're mutating in place, so no need to reprovide '#cacheRecord' - // here. - { - dependencies: ['#cacheRecord', '#allReferencedThings'], - compute: (continuation, { - ['#cacheRecord']: cacheRecord, - ['#allReferencedThings']: allReferencedThings, - }) => { - for (const referencedThing of allReferencedThings) { - if (cacheRecord.has(referencedThing)) { - const referencingThings = cacheRecord.get(referencedThing); - sortByDate(referencingThings); - } - } - - return continuation(); - }, - }, - - // Then just pluck out the current object from the now-filled cache record! - { - dependencies: ['#cacheRecord', input.myself()], - compute: (continuation, { - ['#cacheRecord']: cacheRecord, - [input.myself()]: myself, - }) => continuation({ - [outputName]: - cacheRecord.get(myself) ?? [], - }), - }, - ], - }); -} diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js index 51d07384..38afc2ac 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -5,23 +5,25 @@ // export {default as exitWithoutContribs} from './exitWithoutContribs.js'; +export {default as gobbleSoupyFind} from './gobbleSoupyFind.js'; +export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js'; export {default as inputNotFoundMode} from './inputNotFoundMode.js'; +export {default as inputSoupyFind} from './inputSoupyFind.js'; +export {default as inputSoupyReverse} from './inputSoupyReverse.js'; export {default as inputWikiData} from './inputWikiData.js'; +export {default as splitContentNodesAround} from './splitContentNodesAround.js'; export {default as withClonedThings} from './withClonedThings.js'; +export {default as withConstitutedArtwork} from './withConstitutedArtwork.js'; +export {default as withContentNodes} from './withContentNodes.js'; export {default as withContributionListSums} from './withContributionListSums.js'; export {default as withCoverArtDate} from './withCoverArtDate.js'; export {default as withDirectory} from './withDirectory.js'; -export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js'; export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js'; export {default as withRedatedContributionList} from './withRedatedContributionList.js'; export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js'; export {default as withResolvedContribs} from './withResolvedContribs.js'; export {default as withResolvedReference} from './withResolvedReference.js'; export {default as withResolvedReferenceList} from './withResolvedReferenceList.js'; -export {default as withResolvedSeriesList} from './withResolvedSeriesList.js'; -export {default as withReverseAnnotatedReferenceList} from './withReverseAnnotatedReferenceList.js'; -export {default as withReverseContributionList} from './withReverseContributionList.js'; export {default as withReverseReferenceList} from './withReverseReferenceList.js'; -export {default as withReverseSingleReferenceList} from './withReverseSingleReferenceList.js'; export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js'; export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js'; diff --git a/src/data/composite/wiki-data/inputSoupyFind.js b/src/data/composite/wiki-data/inputSoupyFind.js new file mode 100644 index 00000000..020f4990 --- /dev/null +++ b/src/data/composite/wiki-data/inputSoupyFind.js @@ -0,0 +1,28 @@ +import {input} from '#composite'; +import {anyOf, isFunction, isString} from '#validators'; + +function inputSoupyFind() { + return input({ + validate: + anyOf( + isFunction, + val => { + isString(val); + + if (!val.startsWith('_soupyFind:')) { + throw new Error(`Expected soupyFind.input() token`); + } + + return true; + }), + }); +} + +inputSoupyFind.input = key => + input.value('_soupyFind:' + key); + +export default inputSoupyFind; + +export function getSoupyFindInputKey(value) { + return value.slice('_soupyFind:'.length); +} diff --git a/src/data/composite/wiki-data/inputSoupyReverse.js b/src/data/composite/wiki-data/inputSoupyReverse.js new file mode 100644 index 00000000..0b0a23fe --- /dev/null +++ b/src/data/composite/wiki-data/inputSoupyReverse.js @@ -0,0 +1,32 @@ +import {input} from '#composite'; +import {anyOf, isFunction, isString} from '#validators'; + +function inputSoupyReverse() { + return input({ + validate: + anyOf( + isFunction, + val => { + isString(val); + + if (!val.startsWith('_soupyReverse:')) { + throw new Error(`Expected soupyReverse.input() token`); + } + + return true; + }), + }); +} + +inputSoupyReverse.input = key => + input.value('_soupyReverse:' + key); + +export default inputSoupyReverse; + +export function getSoupyReverseInputKey(value) { + return value.slice('_soupyReverse:'.length).replace(/\.unique$/, ''); +} + +export function doesSoupyReverseInputWantUnique(value) { + return value.endsWith('.unique'); +} diff --git a/src/data/composite/wiki-data/inputWikiData.js b/src/data/composite/wiki-data/inputWikiData.js index cf7a7c2c..b9021986 100644 --- a/src/data/composite/wiki-data/inputWikiData.js +++ b/src/data/composite/wiki-data/inputWikiData.js @@ -12,6 +12,6 @@ export default function inputWikiData({ } = {}) { return input({ validate: validateWikiData({referenceType, allowMixedTypes}), - acceptsNull: true, + defaultValue: null, }); } diff --git a/src/data/composite/wiki-data/splitContentNodesAround.js b/src/data/composite/wiki-data/splitContentNodesAround.js new file mode 100644 index 00000000..6648d8e1 --- /dev/null +++ b/src/data/composite/wiki-data/splitContentNodesAround.js @@ -0,0 +1,87 @@ +import {input, templateCompositeFrom} from '#composite'; +import {splitContentNodesAround} from '#replacer'; +import {anyOf, isFunction, validateInstanceOf} from '#validators'; + +import {withFilteredList, withMappedList, withUnflattenedList} + from '#composite/data'; + +export default templateCompositeFrom({ + annotation: `splitContentNodesAround`, + + inputs: { + nodes: input({type: 'array'}), + + around: input({ + validate: + anyOf(isFunction, validateInstanceOf(RegExp)), + }), + }, + + outputs: ['#contentNodeLists'], + + steps: () => [ + { + dependencies: [input('nodes'), input('around')], + + compute: (continuation, { + [input('nodes')]: nodes, + [input('around')]: splitter, + }) => continuation({ + ['#nodes']: + Array.from(splitContentNodesAround(nodes, splitter)), + }), + }, + + withMappedList({ + list: '#nodes', + map: input.value(node => node.type === 'separator'), + }).outputs({ + '#mappedList': '#separatorFilter', + }), + + withMappedList({ + list: '#separatorFilter', + filter: '#separatorFilter', + map: input.value((_node, index) => index), + }), + + withFilteredList({ + list: '#mappedList', + filter: '#separatorFilter', + }).outputs({ + '#filteredList': '#separatorIndices', + }), + + { + dependencies: ['#nodes', '#separatorFilter'], + + compute: (continuation, { + ['#nodes']: nodes, + ['#separatorFilter']: separatorFilter, + }) => continuation({ + ['#nodes']: + nodes.map((node, index) => + (separatorFilter[index] + ? null + : node)), + }), + }, + + { + dependencies: ['#separatorIndices'], + compute: (continuation, { + ['#separatorIndices']: separatorIndices, + }) => continuation({ + ['#unflattenIndices']: + [0, ...separatorIndices], + }), + }, + + withUnflattenedList({ + list: '#nodes', + indices: '#unflattenIndices', + }).outputs({ + '#unflattenedList': '#contentNodeLists', + }), + ], +}); diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js new file mode 100644 index 00000000..28d719e2 --- /dev/null +++ b/src/data/composite/wiki-data/withConstitutedArtwork.js @@ -0,0 +1,60 @@ +import {input, templateCompositeFrom} from '#composite'; +import thingConstructors from '#things'; + +export default templateCompositeFrom({ + annotation: `withConstitutedArtwork`, + + inputs: { + thingProperty: input({type: 'string', acceptsNull: true}), + dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}), + fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}), + dateFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsArtistProperty: input({type: 'string', acceptsNull: true}), + artTagsFromThingProperty: input({type: 'string', acceptsNull: true}), + referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}), + }, + + outputs: ['#constitutedArtwork'], + + steps: () => [ + { + dependencies: [ + input.myself(), + input('thingProperty'), + input('dimensionsFromThingProperty'), + input('fileExtensionFromThingProperty'), + input('dateFromThingProperty'), + input('artistContribsFromThingProperty'), + input('artistContribsArtistProperty'), + input('artTagsFromThingProperty'), + input('referencedArtworksFromThingProperty'), + ], + + compute: (continuation, { + [input.myself()]: myself, + [input('thingProperty')]: thingProperty, + [input('dimensionsFromThingProperty')]: dimensionsFromThingProperty, + [input('fileExtensionFromThingProperty')]: fileExtensionFromThingProperty, + [input('dateFromThingProperty')]: dateFromThingProperty, + [input('artistContribsFromThingProperty')]: artistContribsFromThingProperty, + [input('artistContribsArtistProperty')]: artistContribsArtistProperty, + [input('artTagsFromThingProperty')]: artTagsFromThingProperty, + [input('referencedArtworksFromThingProperty')]: referencedArtworksFromThingProperty, + }) => continuation({ + ['#constitutedArtwork']: + Object.assign(new thingConstructors.Artwork, { + thing: myself, + thingProperty, + dimensionsFromThingProperty, + fileExtensionFromThingProperty, + artistContribsFromThingProperty, + artistContribsArtistProperty, + artTagsFromThingProperty, + dateFromThingProperty, + referencedArtworksFromThingProperty, + }), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/withContentNodes.js b/src/data/composite/wiki-data/withContentNodes.js new file mode 100644 index 00000000..d014d43b --- /dev/null +++ b/src/data/composite/wiki-data/withContentNodes.js @@ -0,0 +1,25 @@ +import {input, templateCompositeFrom} from '#composite'; +import {parseContentNodes} from '#replacer'; + +export default templateCompositeFrom({ + annotation: `withContentNodes`, + + inputs: { + from: input({type: 'string', acceptsNull: false}), + }, + + outputs: ['#contentNodes'], + + steps: () => [ + { + dependencies: [input('from')], + + compute: (continuation, { + [input('from')]: string, + }) => continuation({ + ['#contentNodes']: + parseContentNodes(string), + }), + }, + ], +}); diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/wiki-data/withCoverArtDate.js index 0c644c77..a114d5ff 100644 --- a/src/data/composite/wiki-data/withCoverArtDate.js +++ b/src/data/composite/wiki-data/withCoverArtDate.js @@ -1,7 +1,3 @@ -// Gets the current thing's coverArtDate, or, if the 'fallback' option is set, -// the thing's date. This is always null if the thing doesn't actually have -// any coverArtistContribs. - import {input, templateCompositeFrom} from '#composite'; import {isDate} from '#validators'; @@ -18,11 +14,6 @@ export default templateCompositeFrom({ defaultDependency: 'coverArtDate', acceptsNull: true, }), - - fallback: input({ - type: 'boolean', - defaultValue: false, - }), }, outputs: ['#coverArtDate'], @@ -50,21 +41,11 @@ export default templateCompositeFrom({ }, { - dependencies: [input('fallback')], - compute: (continuation, { - [input('fallback')]: fallback, - }) => - (fallback - ? continuation() - : continuation.raiseOutput({'#coverArtDate': null})), - }, - - { dependencies: ['date'], compute: (continuation, {date}) => (date - ? continuation.raiseOutput({'#coverArtDate': date}) - : continuation.raiseOutput({'#coverArtDate': null})), + ? continuation({'#coverArtDate': date}) + : continuation({'#coverArtDate': null})), }, ], }); diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js deleted file mode 100644 index 144781a8..00000000 --- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js +++ /dev/null @@ -1,261 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {stitchArrays} from '#sugar'; -import {isCommentary} from '#validators'; -import {commentaryRegexCaseSensitive} from '#wiki-data'; - -import { - fillMissingListItems, - withFlattenedList, - withPropertiesFromList, - withUnflattenedList, -} from '#composite/data'; - -import withResolvedReferenceList from './withResolvedReferenceList.js'; - -export default templateCompositeFrom({ - annotation: `withParsedCommentaryEntries`, - - inputs: { - from: input({validate: isCommentary}), - }, - - outputs: ['#parsedCommentaryEntries'], - - steps: () => [ - { - dependencies: [input('from')], - - compute: (continuation, { - [input('from')]: commentaryText, - }) => continuation({ - ['#rawMatches']: - Array.from(commentaryText.matchAll(commentaryRegexCaseSensitive)), - }), - }, - - withPropertiesFromList({ - list: '#rawMatches', - properties: input.value([ - '0', // The entire match as a string. - 'groups', - 'index', - ]), - }).outputs({ - '#rawMatches.0': '#rawMatches.text', - '#rawMatches.groups': '#rawMatches.groups', - '#rawMatches.index': '#rawMatches.startIndex', - }), - - { - dependencies: [ - '#rawMatches.text', - '#rawMatches.startIndex', - ], - - compute: (continuation, { - ['#rawMatches.text']: text, - ['#rawMatches.startIndex']: startIndex, - }) => continuation({ - ['#rawMatches.endIndex']: - stitchArrays({text, startIndex}) - .map(({text, startIndex}) => startIndex + text.length), - }), - }, - - { - dependencies: [ - input('from'), - '#rawMatches.startIndex', - '#rawMatches.endIndex', - ], - - compute: (continuation, { - [input('from')]: commentaryText, - ['#rawMatches.startIndex']: startIndex, - ['#rawMatches.endIndex']: endIndex, - }) => continuation({ - ['#entries.body']: - stitchArrays({startIndex, endIndex}) - .map(({endIndex}, index, stitched) => - (index === stitched.length - 1 - ? commentaryText.slice(endIndex) - : commentaryText.slice( - endIndex, - stitched[index + 1].startIndex))) - .map(body => body.trim()), - }), - }, - - withPropertiesFromList({ - list: '#rawMatches.groups', - prefix: input.value('#entries'), - properties: input.value([ - 'artistReferences', - 'artistDisplayText', - 'annotation', - 'date', - 'secondDate', - 'dateKind', - 'accessDate', - 'accessKind', - ]), - }), - - // The artistReferences group will always have a value, since it's required - // for the line to match in the first place. - - { - dependencies: ['#entries.artistReferences'], - compute: (continuation, { - ['#entries.artistReferences']: artistReferenceTexts, - }) => continuation({ - ['#entries.artistReferences']: - artistReferenceTexts - .map(text => text.split(',').map(ref => ref.trim())), - }), - }, - - withFlattenedList({ - list: '#entries.artistReferences', - }), - - withResolvedReferenceList({ - list: '#flattenedList', - data: 'artistData', - find: input.value(find.artist), - notFoundMode: input.value('null'), - }), - - withUnflattenedList({ - list: '#resolvedReferenceList', - }).outputs({ - '#unflattenedList': '#entries.artists', - }), - - fillMissingListItems({ - list: '#entries.artistDisplayText', - fill: input.value(null), - }), - - fillMissingListItems({ - list: '#entries.annotation', - fill: input.value(null), - }), - - { - dependencies: ['#entries.annotation'], - compute: (continuation, { - ['#entries.annotation']: annotation, - }) => continuation({ - ['#entries.webArchiveDate']: - annotation - .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//)) - .map(match => match?.[1]) - .map(dateText => - (dateText - ? dateText.slice(0, 4) + '/' + - dateText.slice(4, 6) + '/' + - dateText.slice(6, 8) - : null)), - }), - }, - - { - dependencies: ['#entries.date'], - compute: (continuation, { - ['#entries.date']: date, - }) => continuation({ - ['#entries.date']: - date - .map(date => date ? new Date(date) : null), - }), - }, - - { - dependencies: ['#entries.secondDate'], - compute: (continuation, { - ['#entries.secondDate']: secondDate, - }) => continuation({ - ['#entries.secondDate']: - secondDate - .map(date => date ? new Date(date) : null), - }), - }, - - fillMissingListItems({ - list: '#entries.dateKind', - fill: input.value(null), - }), - - { - dependencies: ['#entries.accessDate', '#entries.webArchiveDate'], - compute: (continuation, { - ['#entries.accessDate']: accessDate, - ['#entries.webArchiveDate']: webArchiveDate, - }) => continuation({ - ['#entries.accessDate']: - stitchArrays({accessDate, webArchiveDate}) - .map(({accessDate, webArchiveDate}) => - accessDate ?? - webArchiveDate ?? - null) - .map(date => date ? new Date(date) : date), - }), - }, - - { - dependencies: ['#entries.accessKind', '#entries.webArchiveDate'], - compute: (continuation, { - ['#entries.accessKind']: accessKind, - ['#entries.webArchiveDate']: webArchiveDate, - }) => continuation({ - ['#entries.accessKind']: - stitchArrays({accessKind, webArchiveDate}) - .map(({accessKind, webArchiveDate}) => - accessKind ?? - (webArchiveDate && 'captured') ?? - null), - }), - }, - - { - dependencies: [ - '#entries.artists', - '#entries.artistDisplayText', - '#entries.annotation', - '#entries.date', - '#entries.secondDate', - '#entries.dateKind', - '#entries.accessDate', - '#entries.accessKind', - '#entries.body', - ], - - compute: (continuation, { - ['#entries.artists']: artists, - ['#entries.artistDisplayText']: artistDisplayText, - ['#entries.annotation']: annotation, - ['#entries.date']: date, - ['#entries.secondDate']: secondDate, - ['#entries.dateKind']: dateKind, - ['#entries.accessDate']: accessDate, - ['#entries.accessKind']: accessKind, - ['#entries.body']: body, - }) => continuation({ - ['#parsedCommentaryEntries']: - stitchArrays({ - artists, - artistDisplayText, - annotation, - date, - secondDate, - dateKind, - accessDate, - accessKind, - body, - }), - }), - }, - ], -}); diff --git a/src/data/composite/wiki-data/withRecontextualizedContributionList.js b/src/data/composite/wiki-data/withRecontextualizedContributionList.js index d2401eac..bcc6e486 100644 --- a/src/data/composite/wiki-data/withRecontextualizedContributionList.js +++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js @@ -10,7 +10,6 @@ import {input, templateCompositeFrom} from '#composite'; import {isStringNonEmpty} from '#validators'; -import {raiseOutputWithoutDependency} from '#composite/control-flow'; import {withClonedThings} from '#composite/wiki-data'; export default templateCompositeFrom({ diff --git a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js index 789a8844..9cc52f29 100644 --- a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js @@ -1,15 +1,13 @@ import {input, templateCompositeFrom} from '#composite'; import {stitchArrays} from '#sugar'; -import {isDate, isObject, validateArrayItems} from '#validators'; +import {isObject, validateArrayItems} from '#validators'; import {withPropertyFromList} from '#composite/data'; -import { - exitWithoutDependency, - raiseOutputWithoutDependency, - withAvailabilityFilter, -} from '#composite/control-flow'; +import {raiseOutputWithoutDependency, withAvailabilityFilter} + from '#composite/control-flow'; +import inputSoupyFind from './inputSoupyFind.js'; import inputNotFoundMode from './inputNotFoundMode.js'; import inputWikiData from './inputWikiData.js'; import raiseResolvedReferenceList from './raiseResolvedReferenceList.js'; @@ -24,17 +22,12 @@ export default templateCompositeFrom({ acceptsNull: true, }), - date: input({ - validate: isDate, - acceptsNull: true, - }), - reference: input({type: 'string', defaultValue: 'reference'}), annotation: input({type: 'string', defaultValue: 'annotation'}), thing: input({type: 'string', defaultValue: 'thing'}), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), + find: inputSoupyFind(), notFoundMode: inputNotFoundMode(), }, @@ -42,11 +35,6 @@ export default templateCompositeFrom({ outputs: ['#resolvedAnnotatedReferenceList'], steps: () => [ - exitWithoutDependency({ - dependency: input('data'), - value: input.value([]), - }), - raiseOutputWithoutDependency({ dependency: input('list'), mode: input.value('empty'), @@ -98,17 +86,6 @@ export default templateCompositeFrom({ }), }, - { - dependencies: ['#matches', input('date')], - compute: (continuation, { - ['#matches']: matches, - [input('date')]: date, - }) => continuation({ - ['#matches']: - matches.map(match => ({...match, date})), - }), - }, - withAvailabilityFilter({ from: '#resolvedReferenceList', }), diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js index fd3d8a0d..838c991f 100644 --- a/src/data/composite/wiki-data/withResolvedContribs.js +++ b/src/data/composite/wiki-data/withResolvedContribs.js @@ -110,6 +110,7 @@ export default templateCompositeFrom({ '#thingProperty', input('artistProperty'), input.myself(), + 'find', ], compute: (continuation, { @@ -117,6 +118,7 @@ export default templateCompositeFrom({ ['#thingProperty']: thingProperty, [input('artistProperty')]: artistProperty, [input.myself()]: myself, + ['find']: find, }) => continuation({ ['#contributions']: details.map(details => { @@ -127,6 +129,7 @@ export default templateCompositeFrom({ thing: myself, thingProperty: thingProperty, artistProperty: artistProperty, + find: find, }); return contrib; diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js index ea71707e..6f422194 100644 --- a/src/data/composite/wiki-data/withResolvedReference.js +++ b/src/data/composite/wiki-data/withResolvedReference.js @@ -1,16 +1,14 @@ // Resolves a reference by using the provided find function to match it -// within the provided thingData dependency. This will early exit if the -// data dependency is null. Otherwise, the data object is provided on the -// output dependency, or null, if the reference doesn't match anything or +// within the provided thingData dependency. The data object is provided on +// the output dependency, or null, if the reference doesn't match anything or // itself was null to begin with. import {input, templateCompositeFrom} from '#composite'; -import { - exitWithoutDependency, - raiseOutputWithoutDependency, -} from '#composite/control-flow'; +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import gobbleSoupyFind from './gobbleSoupyFind.js'; +import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; export default templateCompositeFrom({ @@ -20,7 +18,7 @@ export default templateCompositeFrom({ ref: input({type: 'string', acceptsNull: true}), data: inputWikiData({allowMixedTypes: false}), - find: input({type: 'function'}), + find: inputSoupyFind(), }, outputs: ['#resolvedReference'], @@ -33,24 +31,26 @@ export default templateCompositeFrom({ }), }), - exitWithoutDependency({ - dependency: input('data'), + gobbleSoupyFind({ + find: input('find'), }), { dependencies: [ input('ref'), input('data'), - input('find'), + '#find', ], compute: (continuation, { [input('ref')]: ref, [input('data')]: data, - [input('find')]: findFunction, + ['#find']: findFunction, }) => continuation({ ['#resolvedReference']: - findFunction(ref, data, {mode: 'quiet'}) ?? null, + (data + ? findFunction(ref, data, {mode: 'quiet'}) ?? null + : findFunction(ref, {mode: 'quiet'}) ?? null), }), }, ], diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js index 790a962f..9dc960dd 100644 --- a/src/data/composite/wiki-data/withResolvedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedReferenceList.js @@ -1,19 +1,18 @@ // Resolves a list of references, with each reference matched with provided -// data in the same way as withResolvedReference. This will early exit if the -// data dependency is null (even if the reference list is empty). By default -// it will filter out references which don't match, but this can be changed -// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null'). +// data in the same way as withResolvedReference. By default it will filter +// out references which don't match, but this can be changed to early exit +// ({notFoundMode: 'exit'}) or leave null in place ('null'). import {input, templateCompositeFrom} from '#composite'; import {isString, validateArrayItems} from '#validators'; -import { - exitWithoutDependency, - raiseOutputWithoutDependency, - withAvailabilityFilter, -} from '#composite/control-flow'; +import {raiseOutputWithoutDependency, withAvailabilityFilter} + from '#composite/control-flow'; +import {withMappedList} from '#composite/data'; +import gobbleSoupyFind from './gobbleSoupyFind.js'; import inputNotFoundMode from './inputNotFoundMode.js'; +import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; import raiseResolvedReferenceList from './raiseResolvedReferenceList.js'; @@ -27,7 +26,7 @@ export default templateCompositeFrom({ }), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), + find: inputSoupyFind(), notFoundMode: inputNotFoundMode(), }, @@ -35,11 +34,6 @@ export default templateCompositeFrom({ outputs: ['#resolvedReferenceList'], steps: () => [ - exitWithoutDependency({ - dependency: input('data'), - value: input.value([]), - }), - raiseOutputWithoutDependency({ dependency: input('list'), mode: input.value('empty'), @@ -48,18 +42,30 @@ export default templateCompositeFrom({ }), }), + gobbleSoupyFind({ + find: input('find'), + }), + { - dependencies: [input('list'), input('data'), input('find')], + dependencies: [input('data'), '#find'], compute: (continuation, { - [input('list')]: list, [input('data')]: data, - [input('find')]: findFunction, - }) => - continuation({ - '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})), - }), + ['#find']: findFunction, + }) => continuation({ + ['#map']: + (data + ? ref => findFunction(ref, data, {mode: 'quiet'}) + : ref => findFunction(ref, {mode: 'quiet'})), + }), }, + withMappedList({ + list: input('list'), + map: '#map', + }).outputs({ + '#mappedList': '#matches', + }), + withAvailabilityFilter({ from: '#matches', }), diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js deleted file mode 100644 index 4ac74cc3..00000000 --- a/src/data/composite/wiki-data/withResolvedSeriesList.js +++ /dev/null @@ -1,131 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {stitchArrays} from '#sugar'; -import {isSeriesList, validateThing} from '#validators'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; - -import { - fillMissingListItems, - withFlattenedList, - withUnflattenedList, - withPropertiesFromList, -} from '#composite/data'; - -import withResolvedReferenceList from './withResolvedReferenceList.js'; - -export default templateCompositeFrom({ - annotation: `withResolvedSeriesList`, - - inputs: { - group: input({ - validate: validateThing({referenceType: 'group'}), - }), - - list: input({ - validate: isSeriesList, - acceptsNull: true, - }), - }, - - outputs: ['#resolvedSeriesList'], - - steps: () => [ - raiseOutputWithoutDependency({ - dependency: input('list'), - mode: input.value('empty'), - output: input.value({ - ['#resolvedSeriesList']: [], - }), - }), - - withPropertiesFromList({ - list: input('list'), - prefix: input.value('#serieses'), - properties: input.value([ - 'name', - 'description', - 'albums', - - 'showAlbumArtists', - ]), - }), - - fillMissingListItems({ - list: '#serieses.albums', - fill: input.value([]), - }), - - withFlattenedList({ - list: '#serieses.albums', - }), - - withResolvedReferenceList({ - list: '#flattenedList', - data: 'albumData', - find: input.value(find.album), - notFoundMode: input.value('null'), - }), - - withUnflattenedList({ - list: '#resolvedReferenceList', - }).outputs({ - '#unflattenedList': '#serieses.albums', - }), - - fillMissingListItems({ - list: '#serieses.description', - fill: input.value(null), - }), - - fillMissingListItems({ - list: '#serieses.showAlbumArtists', - fill: input.value(null), - }), - - { - dependencies: [ - '#serieses.name', - '#serieses.description', - '#serieses.albums', - - '#serieses.showAlbumArtists', - ], - - compute: (continuation, { - ['#serieses.name']: name, - ['#serieses.description']: description, - ['#serieses.albums']: albums, - - ['#serieses.showAlbumArtists']: showAlbumArtists, - }) => continuation({ - ['#seriesProperties']: - stitchArrays({ - name, - description, - albums, - - showAlbumArtists, - }).map(properties => ({ - ...properties, - group: input - })) - }), - }, - - { - dependencies: ['#seriesProperties', input('group')], - compute: (continuation, { - ['#seriesProperties']: seriesProperties, - [input('group')]: group, - }) => continuation({ - ['#resolvedSeriesList']: - seriesProperties - .map(properties => ({ - ...properties, - group, - })), - }), - }, - ], -}); diff --git a/src/data/composite/wiki-data/withReverseAnnotatedReferenceList.js b/src/data/composite/wiki-data/withReverseAnnotatedReferenceList.js deleted file mode 100644 index feae9ccb..00000000 --- a/src/data/composite/wiki-data/withReverseAnnotatedReferenceList.js +++ /dev/null @@ -1,116 +0,0 @@ -// Analogous implementation for withReverseReferenceList, for annotated -// references. -// -// Unlike withReverseContributionList, this composition is responsible for -// "flipping" the directionality of references: in a forward reference list, -// `thing` points to the thing being referenced, while here, it points to the -// referencing thing. -// -// This behavior can be customized to respect reference lists which are shaped -// differently than the default and/or to customize the reversed property and -// provide a less generic label than just "thing". - -import withReverseList_template from './helpers/withReverseList-template.js'; - -import {input} from '#composite'; -import {stitchArrays} from '#sugar'; - -import { - withFlattenedList, - withMappedList, - withPropertyFromList, - withStretchedList, -} from '#composite/data'; - -export default withReverseList_template({ - annotation: `withReverseAnnotatedReferenceList`, - - propertyInputName: 'list', - outputName: '#reverseAnnotatedReferenceList', - - additionalInputs: { - forward: input({type: 'string', defaultValue: 'thing'}), - backward: input({type: 'string', defaultValue: 'thing'}), - annotation: input({type: 'string', defaultValue: 'annotation'}), - }, - - customCompositionSteps: () => [ - withPropertyFromList({ - list: input('data'), - property: input('list'), - }).outputs({ - '#values': '#referenceLists', - }), - - withPropertyFromList({ - list: '#referenceLists', - property: input.value('length'), - }), - - withFlattenedList({ - list: '#referenceLists', - }).outputs({ - '#flattenedList': '#references', - }), - - withStretchedList({ - list: input('data'), - lengths: '#referenceLists.length', - }).outputs({ - '#stretchedList': '#things', - }), - - withPropertyFromList({ - list: '#references', - property: input('annotation'), - }).outputs({ - '#values': '#annotations', - }), - - withPropertyFromList({ - list: '#references', - property: input.value('date'), - }).outputs({ - '#references.date': '#dates', - }), - - { - dependencies: [ - input('backward'), - input('annotation'), - '#things', - '#annotations', - '#dates', - ], - - compute: (continuation, { - [input('backward')]: thingProperty, - [input('annotation')]: annotationProperty, - ['#things']: things, - ['#annotations']: annotations, - ['#dates']: dates, - }) => continuation({ - '#referencingThings': - stitchArrays({ - [thingProperty]: things, - [annotationProperty]: annotations, - date: dates, - }), - }), - }, - - withPropertyFromList({ - list: '#references', - property: input('forward'), - }).outputs({ - '#values': '#individualReferencedThings', - }), - - withMappedList({ - list: '#individualReferencedThings', - map: input.value(thing => [thing]), - }).outputs({ - '#mappedList': '#referencedThings', - }), - ], -}); diff --git a/src/data/composite/wiki-data/withReverseContributionList.js b/src/data/composite/wiki-data/withReverseContributionList.js deleted file mode 100644 index 2396c3b4..00000000 --- a/src/data/composite/wiki-data/withReverseContributionList.js +++ /dev/null @@ -1,37 +0,0 @@ -// Analogous implementation for withReverseReferenceList, for contributions. - -import withReverseList_template from './helpers/withReverseList-template.js'; - -import {input} from '#composite'; - -import {withFlattenedList, withMappedList, withPropertyFromList} - from '#composite/data'; - -export default withReverseList_template({ - annotation: `withReverseContributionList`, - - propertyInputName: 'list', - outputName: '#reverseContributionList', - - customCompositionSteps: () => [ - withPropertyFromList({ - list: input('data'), - property: input('list'), - }).outputs({ - '#values': '#contributionLists', - }), - - withFlattenedList({ - list: '#contributionLists', - }).outputs({ - '#flattenedList': '#referencingThings', - }), - - withMappedList({ - list: '#referencingThings', - map: input.value(contrib => [contrib.artist]), - }).outputs({ - '#mappedList': '#referencedThings', - }), - ], -}); diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js index 70d9a58d..906f5bc5 100644 --- a/src/data/composite/wiki-data/withReverseReferenceList.js +++ b/src/data/composite/wiki-data/withReverseReferenceList.js @@ -1,44 +1,36 @@ // Check out the info on reverseReferenceList! // This is its composable form. -import withReverseList_template from './helpers/withReverseList-template.js'; +import {input, templateCompositeFrom} from '#composite'; -import {input} from '#composite'; +import gobbleSoupyReverse from './gobbleSoupyReverse.js'; +import inputSoupyReverse from './inputSoupyReverse.js'; +import inputWikiData from './inputWikiData.js'; -import {withMappedList} from '#composite/data'; +import withResolvedReverse from './helpers/withResolvedReverse.js'; -export default withReverseList_template({ +export default templateCompositeFrom({ annotation: `withReverseReferenceList`, - propertyInputName: 'list', - outputName: '#reverseReferenceList', - - customCompositionSteps: () => [ - { - dependencies: [input('list')], - compute: (continuation, { - [input('list')]: list, - }) => continuation({ - ['#referenceMap']: - thing => thing[list], - }), - }, - - withMappedList({ - list: input('data'), - map: '#referenceMap', - }).outputs({ - '#mappedList': '#referencedThings', + inputs: { + data: inputWikiData({allowMixedTypes: true}), + reverse: inputSoupyReverse(), + }, + + outputs: ['#reverseReferenceList'], + + steps: () => [ + gobbleSoupyReverse({ + reverse: input('reverse'), }), - { - dependencies: [input('data')], - compute: (continuation, { - [input('data')]: data, - }) => continuation({ - ['#referencingThings']: - data, - }), - }, + // TODO: Check that the reverse spec returns a list. + + withResolvedReverse({ + data: input('data'), + reverse: '#reverse', + }).outputs({ + '#resolvedReverse': '#reverseReferenceList', + }), ], }); diff --git a/src/data/composite/wiki-data/withReverseSingleReferenceList.js b/src/data/composite/wiki-data/withReverseSingleReferenceList.js deleted file mode 100644 index dd97dc66..00000000 --- a/src/data/composite/wiki-data/withReverseSingleReferenceList.js +++ /dev/null @@ -1,50 +0,0 @@ -// Like withReverseReferenceList, but for finding all things which reference -// the current thing by a property that contains a single reference, rather -// than within a reference list. - -import withReverseList_template from './helpers/withReverseList-template.js'; - -import {input} from '#composite'; - -import {withMappedList} from '#composite/data'; - -export default withReverseList_template({ - annotation: `withReverseSingleReferenceList`, - - propertyInputName: 'ref', - outputName: '#reverseSingleReferenceList', - - customCompositionSteps: () => [ - { - dependencies: [input('data')], - compute: (continuation, { - [input('data')]: data, - }) => continuation({ - ['#referencingThings']: - data, - }), - }, - - // This map wraps each referenced thing in a single-item array. - // Each referencing thing references exactly one thing, if any. - { - dependencies: [input('ref')], - compute: (continuation, { - [input('ref')]: ref, - }) => continuation({ - ['#singleReferenceMap']: - thing => - (thing[ref] - ? [thing[ref]] - : []), - }), - }, - - withMappedList({ - list: '#referencingThings', - map: '#singleReferenceMap', - }).outputs({ - '#mappedList': '#referencedThings', - }), - ], -}); diff --git a/src/data/composite/wiki-data/withUniqueReferencingThing.js b/src/data/composite/wiki-data/withUniqueReferencingThing.js index 61c10618..7c267038 100644 --- a/src/data/composite/wiki-data/withUniqueReferencingThing.js +++ b/src/data/composite/wiki-data/withUniqueReferencingThing.js @@ -4,48 +4,33 @@ import {input, templateCompositeFrom} from '#composite'; -import {exitWithoutDependency, raiseOutputWithoutDependency} - from '#composite/control-flow'; - +import gobbleSoupyReverse from './gobbleSoupyReverse.js'; +import inputSoupyReverse from './inputSoupyReverse.js'; import inputWikiData from './inputWikiData.js'; -import withReverseReferenceList from './withReverseReferenceList.js'; + +import withResolvedReverse from './helpers/withResolvedReverse.js'; export default templateCompositeFrom({ annotation: `withUniqueReferencingThing`, inputs: { - data: inputWikiData({allowMixedTypes: false}), - list: input({type: 'string'}), + data: inputWikiData({allowMixedTypes: true}), + reverse: inputSoupyReverse(), }, outputs: ['#uniqueReferencingThing'], steps: () => [ - // Early exit with null (not an empty array) if the data list - // isn't available. - exitWithoutDependency({ - dependency: input('data'), + gobbleSoupyReverse({ + reverse: input('reverse'), }), - withReverseReferenceList({ + withResolvedReverse({ data: input('data'), - list: input('list'), + reverse: '#reverse', + options: input.value({unique: true}), + }).outputs({ + '#resolvedReverse': '#uniqueReferencingThing', }), - - raiseOutputWithoutDependency({ - dependency: '#reverseReferenceList', - mode: input.value('empty'), - output: input.value({'#uniqueReferencingThing': null}), - }), - - { - dependencies: ['#reverseReferenceList'], - compute: (continuation, { - ['#reverseReferenceList']: reverseReferenceList, - }) => continuation({ - ['#uniqueReferencingThing']: - reverseReferenceList[0], - }), - }, ], }); diff --git a/src/data/composite/wiki-properties/additionalFiles.js b/src/data/composite/wiki-properties/additionalFiles.js deleted file mode 100644 index 6760527a..00000000 --- a/src/data/composite/wiki-properties/additionalFiles.js +++ /dev/null @@ -1,30 +0,0 @@ -// This is a somewhat more involved data structure - it's for additional -// or "bonus" files associated with albums or tracks (or anything else). -// It's got this form: -// -// [ -// {title: 'Booklet', files: ['Booklet.pdf']}, -// { -// title: 'Wallpaper', -// description: 'Cool Wallpaper!', -// files: ['1440x900.png', '1920x1080.png'] -// }, -// {title: 'Alternate Covers', description: null, files: [...]}, -// ... -// ] -// - -import {isAdditionalFileList} from '#validators'; - -// TODO: Not templateCompositeFrom. - -export default function() { - return { - flags: {update: true, expose: true}, - update: {validate: isAdditionalFileList}, - expose: { - transform: (additionalFiles) => - additionalFiles ?? [], - }, - }; -} diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js deleted file mode 100644 index c5971d4a..00000000 --- a/src/data/composite/wiki-properties/additionalNameList.js +++ /dev/null @@ -1,14 +0,0 @@ -// A list of additional names! These can be used for a variety of purposes, -// e.g. providing extra searchable titles, localizations, romanizations or -// original titles, and so on. Each item has a name and, optionally, a -// descriptive annotation. - -import {isAdditionalNameList} from '#validators'; - -export default function() { - return { - flags: {update: true, expose: true}, - update: {validate: isAdditionalNameList}, - expose: {transform: value => value ?? []}, - }; -} diff --git a/src/data/composite/wiki-properties/annotatedReferenceList.js b/src/data/composite/wiki-properties/annotatedReferenceList.js index d6364475..8e6c96a1 100644 --- a/src/data/composite/wiki-properties/annotatedReferenceList.js +++ b/src/data/composite/wiki-properties/annotatedReferenceList.js @@ -1,10 +1,7 @@ import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {combineWikiDataArrays} from '#wiki-data'; import { isContentString, - isDate, optional, validateArrayItems, validateProperties, @@ -12,7 +9,7 @@ import { } from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withResolvedAnnotatedReferenceList} +import {inputSoupyFind, inputWikiData, withResolvedAnnotatedReferenceList} from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} @@ -27,12 +24,7 @@ export default templateCompositeFrom({ ...referenceListInputDescriptions(), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), - - date: input({ - validate: isDate, - acceptsNull: true, - }), + find: inputSoupyFind(), reference: input.staticValue({type: 'string', defaultValue: 'reference'}), annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}), @@ -59,8 +51,6 @@ export default templateCompositeFrom({ withResolvedAnnotatedReferenceList({ list: input.updateValue(), - date: input('date'), - reference: input('reference'), annotation: input('annotation'), thing: input('thing'), diff --git a/src/data/composite/wiki-properties/commentary.js b/src/data/composite/wiki-properties/commentary.js deleted file mode 100644 index cd6b7ac4..00000000 --- a/src/data/composite/wiki-properties/commentary.js +++ /dev/null @@ -1,30 +0,0 @@ -// Artist commentary! Generally present on tracks and albums. - -import {input, templateCompositeFrom} from '#composite'; -import {isCommentary} from '#validators'; - -import {exitWithoutDependency, exposeDependency} - from '#composite/control-flow'; -import {withParsedCommentaryEntries} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `commentary`, - - compose: false, - - steps: () => [ - exitWithoutDependency({ - dependency: input.updateValue({validate: isCommentary}), - mode: input.value('falsy'), - value: input.value(null), - }), - - withParsedCommentaryEntries({ - from: input.updateValue(), - }), - - exposeDependency({ - dependency: '#parsedCommentaryEntries', - }), - ], -}); diff --git a/src/data/composite/wiki-properties/commentatorArtists.js b/src/data/composite/wiki-properties/commentatorArtists.js index c5c14769..54d3e1a5 100644 --- a/src/data/composite/wiki-properties/commentatorArtists.js +++ b/src/data/composite/wiki-properties/commentatorArtists.js @@ -7,7 +7,6 @@ import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; import {withFlattenedList, withPropertyFromList, withUniqueItemsOnly} from '#composite/data'; -import {withParsedCommentaryEntries} from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `commentatorArtists`, @@ -21,19 +20,13 @@ export default templateCompositeFrom({ value: input.value([]), }), - withParsedCommentaryEntries({ - from: 'commentary', - }), - withPropertyFromList({ - list: '#parsedCommentaryEntries', + list: 'commentary', property: input.value('artists'), - }).outputs({ - '#parsedCommentaryEntries.artists': '#artistLists', }), withFlattenedList({ - list: '#artistLists', + list: '#commentary.artists', }).outputs({ '#flattenedList': '#artists', }), diff --git a/src/data/composite/wiki-properties/constitutibleArtwork.js b/src/data/composite/wiki-properties/constitutibleArtwork.js new file mode 100644 index 00000000..48f4211a --- /dev/null +++ b/src/data/composite/wiki-properties/constitutibleArtwork.js @@ -0,0 +1,70 @@ +// This composition does not actually inspect the values of any properties +// specified, so it's not responsible for determining whether a constituted +// artwork should exist at all. + +import {input, templateCompositeFrom} from '#composite'; +import {withEntries} from '#sugar'; +import Thing from '#thing'; +import {validateThing} from '#validators'; + +import {exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {withConstitutedArtwork} from '#composite/wiki-data'; + +const template = templateCompositeFrom({ + annotation: `constitutibleArtwork`, + + compose: false, + + inputs: { + thingProperty: input({type: 'string', acceptsNull: true}), + dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}), + fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}), + dateFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsArtistProperty: input({type: 'string', acceptsNull: true}), + artTagsFromThingProperty: input({type: 'string', acceptsNull: true}), + referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}), + }, + + steps: () => [ + exposeUpdateValueOrContinue({ + validate: input.value( + validateThing({ + referenceType: 'artwork', + })), + }), + + withConstitutedArtwork({ + thingProperty: input('thingProperty'), + dimensionsFromThingProperty: input('dimensionsFromThingProperty'), + fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'), + dateFromThingProperty: input('dateFromThingProperty'), + artistContribsFromThingProperty: input('artistContribsFromThingProperty'), + artistContribsArtistProperty: input('artistContribsArtistProperty'), + artTagsFromThingProperty: input('artTagsFromThingProperty'), + referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'), + }), + + exposeDependency({ + dependency: '#constitutedArtwork', + }), + ], +}); + +template.fromYAMLFieldSpec = function(field) { + const {[Thing.yamlDocumentSpec]: documentSpec} = this; + + const {provide} = documentSpec.fields[field].transform; + + const inputs = + withEntries(provide, entries => + entries.map(([property, value]) => [ + property, + input.value(value), + ])); + + return template(inputs); +}; + +export default template; diff --git a/src/data/composite/wiki-properties/constitutibleArtworkList.js b/src/data/composite/wiki-properties/constitutibleArtworkList.js new file mode 100644 index 00000000..dad3a957 --- /dev/null +++ b/src/data/composite/wiki-properties/constitutibleArtworkList.js @@ -0,0 +1,72 @@ +// This composition does not actually inspect the values of any properties +// specified, so it's not responsible for determining whether a constituted +// artwork should exist at all. + +import {input, templateCompositeFrom} from '#composite'; +import {withEntries} from '#sugar'; +import Thing from '#thing'; +import {validateWikiData} from '#validators'; + +import {exposeUpdateValueOrContinue} from '#composite/control-flow'; +import {withConstitutedArtwork} from '#composite/wiki-data'; + +const template = templateCompositeFrom({ + annotation: `constitutibleArtworkList`, + + compose: false, + + inputs: { + thingProperty: input({type: 'string', acceptsNull: true}), + dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}), + fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}), + dateFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}), + artistContribsArtistProperty: input({type: 'string', acceptsNull: true}), + artTagsFromThingProperty: input({type: 'string', acceptsNull: true}), + referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}), + }, + + steps: () => [ + exposeUpdateValueOrContinue({ + validate: input.value( + validateWikiData({ + referenceType: 'artwork', + })), + }), + + withConstitutedArtwork({ + thingProperty: input('thingProperty'), + dimensionsFromThingProperty: input('dimensionsFromThingProperty'), + fileExtensionFromThingProperty: input('fileExtensionFromThingProperty'), + dateFromThingProperty: input('dateFromThingProperty'), + artistContribsFromThingProperty: input('artistContribsFromThingProperty'), + artistContribsArtistProperty: input('artistContribsArtistProperty'), + artTagsFromThingProperty: input('artTagsFromThingProperty'), + referencedArtworksFromThingProperty: input('referencedArtworksFromThingProperty'), + }), + + { + dependencies: ['#constitutedArtwork'], + compute: ({ + ['#constitutedArtwork']: constitutedArtwork, + }) => [constitutedArtwork], + }, + ], +}); + +template.fromYAMLFieldSpec = function(field) { + const {[Thing.yamlDocumentSpec]: documentSpec} = this; + + const {provide} = documentSpec.fields[field].transform; + + const inputs = + withEntries(provide, entries => + entries.map(([property, value]) => [ + property, + input.value(value), + ])); + + return template(inputs); +}; + +export default template; diff --git a/src/data/composite/wiki-properties/directory.js b/src/data/composite/wiki-properties/directory.js index 9ca2a204..1756a8e5 100644 --- a/src/data/composite/wiki-properties/directory.js +++ b/src/data/composite/wiki-properties/directory.js @@ -18,6 +18,7 @@ export default templateCompositeFrom({ name: input({ validate: isName, defaultDependency: 'name', + acceptsNull: true, }), suffix: input({ diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js index b55616c0..e8f109d3 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -3,12 +3,11 @@ // Entries here may depend on entries in #composite/control-flow, // #composite/data, and #composite/wiki-data. -export {default as additionalFiles} from './additionalFiles.js'; -export {default as additionalNameList} from './additionalNameList.js'; export {default as annotatedReferenceList} from './annotatedReferenceList.js'; export {default as color} from './color.js'; -export {default as commentary} from './commentary.js'; export {default as commentatorArtists} from './commentatorArtists.js'; +export {default as constitutibleArtwork} from './constitutibleArtwork.js'; +export {default as constitutibleArtworkList} from './constitutibleArtworkList.js'; export {default as contentString} from './contentString.js'; export {default as contribsPresent} from './contribsPresent.js'; export {default as contributionList} from './contributionList.js'; @@ -21,15 +20,12 @@ export {default as flag} from './flag.js'; export {default as name} from './name.js'; export {default as referenceList} from './referenceList.js'; export {default as referencedArtworkList} from './referencedArtworkList.js'; -export {default as reverseAnnotatedReferenceList} from './reverseAnnotatedReferenceList.js'; -export {default as reverseContributionList} from './reverseContributionList.js'; export {default as reverseReferenceList} from './reverseReferenceList.js'; -export {default as reverseReferencedArtworkList} from './reverseReferencedArtworkList.js'; -export {default as reverseSingleReferenceList} from './reverseSingleReferenceList.js'; -export {default as seriesList} from './seriesList.js'; export {default as simpleDate} from './simpleDate.js'; export {default as simpleString} from './simpleString.js'; export {default as singleReference} from './singleReference.js'; +export {default as soupyFind} from './soupyFind.js'; +export {default as soupyReverse} from './soupyReverse.js'; export {default as thing} from './thing.js'; export {default as thingList} from './thingList.js'; export {default as urls} from './urls.js'; diff --git a/src/data/composite/wiki-properties/referenceList.js b/src/data/composite/wiki-properties/referenceList.js index 4d4cb106..4f8207b5 100644 --- a/src/data/composite/wiki-properties/referenceList.js +++ b/src/data/composite/wiki-properties/referenceList.js @@ -11,7 +11,8 @@ import {input, templateCompositeFrom} from '#composite'; import {validateReferenceList} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withResolvedReferenceList} from '#composite/wiki-data'; +import {inputSoupyFind, inputWikiData, withResolvedReferenceList} + from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} from './helpers/reference-list-helpers.js'; @@ -25,7 +26,7 @@ export default templateCompositeFrom({ ...referenceListInputDescriptions(), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), + find: inputSoupyFind(), }, update: diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js index 819b2f43..4f243493 100644 --- a/src/data/composite/wiki-properties/referencedArtworkList.js +++ b/src/data/composite/wiki-properties/referencedArtworkList.js @@ -1,7 +1,5 @@ import {input, templateCompositeFrom} from '#composite'; import find from '#find'; -import {isDate} from '#validators'; -import {combineWikiDataArrays} from '#wiki-data'; import annotatedReferenceList from './annotatedReferenceList.js'; @@ -10,47 +8,24 @@ export default templateCompositeFrom({ compose: false, - inputs: { - date: input({ - validate: isDate, - acceptsNull: true, - }), - }, - steps: () => [ { - dependencies: [ - 'albumData', - 'trackData', - ], - - compute: (continuation, { - albumData, - trackData, - }) => continuation({ - ['#data']: - combineWikiDataArrays([ - albumData, - trackData, - ]), - }), - }, - - { compute: (continuation) => continuation({ ['#find']: find.mixed({ - track: find.trackWithArtwork, - album: find.albumWithArtwork, + track: find.trackPrimaryArtwork, + album: find.albumPrimaryArtwork, }), }), }, annotatedReferenceList({ referenceType: input.value(['album', 'track']), - data: '#data', + + data: 'artworkData', find: '#find', - date: input('date'), + + thing: input.value('artwork'), }), ], }); diff --git a/src/data/composite/wiki-properties/reverseAnnotatedReferenceList.js b/src/data/composite/wiki-properties/reverseAnnotatedReferenceList.js deleted file mode 100644 index ba7166b9..00000000 --- a/src/data/composite/wiki-properties/reverseAnnotatedReferenceList.js +++ /dev/null @@ -1,33 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseAnnotatedReferenceList} - from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `reverseAnnotatedReferenceList`, - - compose: false, - - inputs: { - data: inputWikiData({allowMixedTypes: false}), - list: input({type: 'string'}), - - forward: input({type: 'string', defaultValue: 'thing'}), - backward: input({type: 'string', defaultValue: 'thing'}), - annotation: input({type: 'string', defaultValue: 'annotation'}), - }, - - steps: () => [ - withReverseAnnotatedReferenceList({ - data: input('data'), - list: input('list'), - - forward: input('forward'), - backward: input('backward'), - annotation: input('annotation'), - }), - - exposeDependency({dependency: '#reverseAnnotatedReferenceList'}), - ], -}); diff --git a/src/data/composite/wiki-properties/reverseContributionList.js b/src/data/composite/wiki-properties/reverseContributionList.js deleted file mode 100644 index 7f3f9c81..00000000 --- a/src/data/composite/wiki-properties/reverseContributionList.js +++ /dev/null @@ -1,24 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseContributionList} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `reverseContributionList`, - - compose: false, - - inputs: { - data: inputWikiData({allowMixedTypes: false}), - list: input({type: 'string'}), - }, - - steps: () => [ - withReverseContributionList({ - data: input('data'), - list: input('list'), - }), - - exposeDependency({dependency: '#reverseContributionList'}), - ], -}); diff --git a/src/data/composite/wiki-properties/reverseReferenceList.js b/src/data/composite/wiki-properties/reverseReferenceList.js index 84ba67df..6d590a67 100644 --- a/src/data/composite/wiki-properties/reverseReferenceList.js +++ b/src/data/composite/wiki-properties/reverseReferenceList.js @@ -1,13 +1,13 @@ // Neat little shortcut for "reversing" the reference lists stored on other // things - for example, tracks specify a "referenced tracks" property, and // you would use this to compute a corresponding "referenced *by* tracks" -// property. Naturally, the passed ref list property is of the things in the -// wiki data provided, not the requesting Thing itself. +// property. import {input, templateCompositeFrom} from '#composite'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseReferenceList} from '#composite/wiki-data'; +import {inputSoupyReverse, inputWikiData, withReverseReferenceList} + from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `reverseReferenceList`, @@ -15,14 +15,14 @@ export default templateCompositeFrom({ compose: false, inputs: { - data: inputWikiData({allowMixedTypes: false}), - list: input({type: 'string'}), + data: inputWikiData({allowMixedTypes: true}), + reverse: inputSoupyReverse(), }, steps: () => [ withReverseReferenceList({ data: input('data'), - list: input('list'), + reverse: input('reverse'), }), exposeDependency({dependency: '#reverseReferenceList'}), diff --git a/src/data/composite/wiki-properties/reverseReferencedArtworkList.js b/src/data/composite/wiki-properties/reverseReferencedArtworkList.js deleted file mode 100644 index 2950bdb9..00000000 --- a/src/data/composite/wiki-properties/reverseReferencedArtworkList.js +++ /dev/null @@ -1,39 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import {combineWikiDataArrays} from '#wiki-data'; - -import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseAnnotatedReferenceList} - from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `reverseReferencedArtworkList`, - - compose: false, - - steps: () => [ - { - dependencies: [ - 'albumData', - 'trackData', - ], - - compute: (continuation, { - albumData, - trackData, - }) => continuation({ - ['#data']: - combineWikiDataArrays([ - albumData, - trackData, - ]), - }), - }, - - withReverseAnnotatedReferenceList({ - data: '#data', - list: input.value('referencedArtworks'), - }), - - exposeDependency({dependency: '#reverseAnnotatedReferenceList'}), - ], -}); diff --git a/src/data/composite/wiki-properties/reverseSingleReferenceList.js b/src/data/composite/wiki-properties/reverseSingleReferenceList.js deleted file mode 100644 index d180b12d..00000000 --- a/src/data/composite/wiki-properties/reverseSingleReferenceList.js +++ /dev/null @@ -1,24 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withReverseSingleReferenceList} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `reverseSingleReferenceList`, - - compose: false, - - inputs: { - data: inputWikiData({allowMixedTypes: false}), - ref: input({type: 'string'}), - }, - - steps: () => [ - withReverseSingleReferenceList({ - data: input('data'), - ref: input('ref'), - }), - - exposeDependency({dependency: '#reverseSingleReferenceList'}), - ], -}); diff --git a/src/data/composite/wiki-properties/seriesList.js b/src/data/composite/wiki-properties/seriesList.js deleted file mode 100644 index 2a101b45..00000000 --- a/src/data/composite/wiki-properties/seriesList.js +++ /dev/null @@ -1,31 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; -import {isSeriesList, validateThing} from '#validators'; - -import {exposeDependency} from '#composite/control-flow'; -import {withResolvedSeriesList} from '#composite/wiki-data'; - -export default templateCompositeFrom({ - annotation: `seriesList`, - - compose: false, - - inputs: { - group: input({ - validate: validateThing({referenceType: 'group'}), - }), - }, - - steps: () => [ - withResolvedSeriesList({ - group: input('group'), - - list: input.updateValue({ - validate: isSeriesList, - }), - }), - - exposeDependency({ - dependency: '#resolvedSeriesList', - }), - ], -}); diff --git a/src/data/composite/wiki-properties/singleReference.js b/src/data/composite/wiki-properties/singleReference.js index db4fc9f9..f532ebbe 100644 --- a/src/data/composite/wiki-properties/singleReference.js +++ b/src/data/composite/wiki-properties/singleReference.js @@ -11,7 +11,8 @@ import {input, templateCompositeFrom} from '#composite'; import {isThingClass, validateReference} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withResolvedReference} from '#composite/wiki-data'; +import {inputSoupyFind, inputWikiData, withResolvedReference} + from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `singleReference`, @@ -21,8 +22,7 @@ export default templateCompositeFrom({ inputs: { class: input.staticValue({validate: isThingClass}), - find: input({type: 'function'}), - + find: inputSoupyFind(), data: inputWikiData({allowMixedTypes: false}), }, diff --git a/src/data/composite/wiki-properties/soupyFind.js b/src/data/composite/wiki-properties/soupyFind.js new file mode 100644 index 00000000..0f9a17e3 --- /dev/null +++ b/src/data/composite/wiki-properties/soupyFind.js @@ -0,0 +1,14 @@ +import {isObject} from '#validators'; + +import {inputSoupyFind} from '#composite/wiki-data'; + +function soupyFind() { + return { + flags: {update: true}, + update: {validate: isObject}, + }; +} + +soupyFind.input = inputSoupyFind.input; + +export default soupyFind; diff --git a/src/data/composite/wiki-properties/soupyReverse.js b/src/data/composite/wiki-properties/soupyReverse.js new file mode 100644 index 00000000..784a66b4 --- /dev/null +++ b/src/data/composite/wiki-properties/soupyReverse.js @@ -0,0 +1,37 @@ +import {isObject} from '#validators'; + +import {inputSoupyReverse} from '#composite/wiki-data'; + +function soupyReverse() { + return { + flags: {update: true}, + update: {validate: isObject}, + }; +} + +soupyReverse.input = inputSoupyReverse.input; + +soupyReverse.contributionsBy = + (bindTo, contributionsProperty) => ({ + bindTo, + + referencing: thing => thing[contributionsProperty], + referenced: contrib => [contrib.artist], + }); + +soupyReverse.artworkContributionsBy = + (bindTo, artworkProperty, {single = false} = {}) => ({ + bindTo, + + referencing: thing => + (single + ? (thing[artworkProperty] + ? thing[artworkProperty].artistContribs + : []) + : thing[artworkProperty] + .flatMap(artwork => artwork.artistContribs)), + + referenced: contrib => [contrib.artist], + }); + +export default soupyReverse; diff --git a/src/data/thing.js b/src/data/thing.js index 78ad3642..f719224d 100644 --- a/src/data/thing.js +++ b/src/data/thing.js @@ -16,9 +16,19 @@ export default class Thing extends CacheableObject { static findSpecs = Symbol.for('Thing.findSpecs'); static findThisThingOnly = Symbol.for('Thing.findThisThingOnly'); + static reverseSpecs = Symbol.for('Thing.reverseSpecs'); + static yamlDocumentSpec = Symbol.for('Thing.yamlDocumentSpec'); static getYamlLoadingSpec = Symbol.for('Thing.getYamlLoadingSpec'); + static yamlSourceFilename = Symbol.for('Thing.yamlSourceFilename'); + static yamlSourceDocument = Symbol.for('Thing.yamlSourceDocument'); + static yamlSourceDocumentPlacement = Symbol.for('Thing.yamlSourceDocumentPlacement'); + + [Symbol.for('Thing.yamlSourceFilename')] = null; + [Symbol.for('Thing.yamlSourceDocument')] = null; + [Symbol.for('Thing.yamlSourceDocumentPlacement')] = null; + static isThingConstructor = Symbol.for('Thing.isThingConstructor'); static isThing = Symbol.for('Thing.isThing'); @@ -26,14 +36,15 @@ export default class Thing extends CacheableObject { // Symbol.for('Thing.isThingConstructor') in constructor static [Symbol.for('Thing.isThingConstructor')] = NaN; - static [CacheableObject.propertyDescriptors] = { + constructor() { + super({seal: false}); + // To detect: // Object.hasOwn(object, Symbol.for('Thing.isThing')) - [Symbol.for('Thing.isThing')]: { - flags: {expose: true}, - expose: {compute: () => NaN}, - }, - }; + this[Symbol.for('Thing.isThing')] = NaN; + + Object.seal(this); + } static [Symbol.for('Thing.selectAll')] = _wikiData => []; @@ -49,7 +60,7 @@ export default class Thing extends CacheableObject { if (this.name) { name = colors.green(`"${this.name}"`); } - } catch (error) { + } catch { name = colors.yellow(`couldn't get name`); } @@ -58,7 +69,7 @@ export default class Thing extends CacheableObject { if (this.directory) { reference = colors.blue(Thing.getReference(this)); } - } catch (error) { + } catch { reference = colors.yellow(`couldn't get reference`); } diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js new file mode 100644 index 00000000..398d0af5 --- /dev/null +++ b/src/data/things/additional-file.js @@ -0,0 +1,47 @@ +import {input} from '#composite'; +import Thing from '#thing'; +import {isString, validateArrayItems} from '#validators'; + +import {contentString, simpleString, thing} from '#composite/wiki-properties'; + +import {exposeConstant, exposeUpdateValueOrContinue} + from '#composite/control-flow'; + +export class AdditionalFile extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + title: simpleString(), + + description: contentString(), + + filenames: [ + exposeUpdateValueOrContinue({ + validate: input.value(validateArrayItems(isString)), + }), + + exposeConstant({ + value: input.value([]), + }), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Title': {property: 'title'}, + 'Description': {property: 'description'}, + 'Files': {property: 'filenames'}, + }, + }; + + get paths() { + if (!this.thing) return null; + if (!this.thing.getOwnAdditionalFilePath) return null; + + return ( + this.filenames.map(filename => + this.thing.getOwnAdditionalFilePath(this, filename))); + } +} diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js new file mode 100644 index 00000000..4c23f291 --- /dev/null +++ b/src/data/things/additional-name.js @@ -0,0 +1,21 @@ +import Thing from '#thing'; + +import {contentString, thing} from '#composite/wiki-properties'; + +export class AdditionalName extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + name: contentString(), + annotation: contentString(), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + 'Annotation': {property: 'annotation'}, + }, + }; +} diff --git a/src/data/things/album.js b/src/data/things/album.js index bd54a356..5132b962 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -6,18 +6,20 @@ import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import {traverse} from '#node-utils'; import {sortAlbumsTracksChronologically, sortChronologically} from '#sort'; -import {accumulateSum, empty} from '#sugar'; +import {empty} from '#sugar'; import Thing from '#thing'; -import {isColor, isDate, isDirectory, validateWikiData} from '#validators'; +import {isColor, isDate, isDirectory, isNumber} from '#validators'; import { parseAdditionalFiles, parseAdditionalNames, parseAnnotatedReferences, + parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, parseDate, parseDimensions, parseWallpaperParts, @@ -27,19 +29,14 @@ import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; -import { - exitWithoutContribs, - withDirectory, - withResolvedReference, - withCoverArtDate, -} from '#composite/wiki-data'; +import {exitWithoutContribs, withDirectory, withCoverArtDate} + from '#composite/wiki-data'; import { - additionalFiles, - additionalNameList, - commentary, color, commentatorArtists, + constitutibleArtwork, + constitutibleArtworkList, contentString, contribsPresent, contributionList, @@ -50,10 +47,10 @@ import { name, referencedArtworkList, referenceList, - reverseReferencedArtworkList, simpleDate, simpleString, - singleReference, + soupyFind, + soupyReverse, thing, thingList, urls, @@ -61,17 +58,21 @@ import { wikiData, } from '#composite/wiki-properties'; -import {withTracks} from '#composite/things/album'; -import {withAlbum} from '#composite/things/track-section'; +import {withHasCoverArt, withTracks} from '#composite/things/album'; +import {withAlbum, withContinueCountingFrom, withStartCountingFrom} + from '#composite/things/track-section'; export class Album extends Thing { static [Thing.referenceType] = 'album'; static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, ArtTag, - Artist, + Artwork, + CommentaryEntry, + CreditingSourcesEntry, Group, - Track, TrackSection, WikiInfo, }) => ({ @@ -92,13 +93,18 @@ export class Album extends Thing { }), ], + alwaysReferenceByDirectory: flag(false), alwaysReferenceTracksByDirectory: flag(false), suffixTrackDirectories: flag(false), + countTracksInArtistTotals: flag(true), + color: color(), urls: urls(), - additionalNames: additionalNameList(), + additionalNames: thingList({ + class: input.value(AdditionalName), + }), bandcampAlbumIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), @@ -108,16 +114,10 @@ export class Album extends Thing { dateAddedToWiki: simpleDate(), coverArtDate: [ - // ~~TODO: Why does this fall back, but Track.coverArtDate doesn't?~~ - // TODO: OK so it's because tracks don't *store* dates just like that. - // Really instead of fallback being a flag, it should be a date value, - // if this option is worth existing at all. withCoverArtDate({ from: input.updateValue({ validate: isDate, }), - - fallback: input.value(true), }), exposeDependency({dependency: '#coverArtDate'}), @@ -146,7 +146,11 @@ export class Album extends Thing { ], wallpaperParts: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + exitWithoutContribs({ + contribs: 'wallpaperArtistContribs', + value: input.value([]), + }), + wallpaperParts(), ], @@ -167,13 +171,56 @@ export class Album extends Thing { dimensions(), ], + wallpaperArtwork: [ + exitWithoutDependency({ + dependency: 'wallpaperArtistContribs', + mode: input.value('empty'), + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Wallpaper Artwork'), + ], + + bannerArtwork: [ + exitWithoutDependency({ + dependency: 'bannerArtistContribs', + mode: input.value('empty'), + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Banner Artwork'), + ], + + coverArtworks: [ + withHasCoverArt(), + + exitWithoutDependency({ + dependency: '#hasCoverArt', + mode: input.value('falsy'), + value: input.value([]), + }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + ], + hasTrackNumbers: flag(true), isListedOnHomepage: flag(true), isListedInGalleries: flag(true), - commentary: commentary(), - creditSources: commentary(), - additionalFiles: additionalFiles(), + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), + + additionalFiles: thingList({ + class: input.value(AdditionalFile), + }), trackSections: thingList({ class: input.value(TrackSection), @@ -185,9 +232,7 @@ export class Album extends Thing { }), coverArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), - }), + withCoverArtDate(), contributionList({ date: '#coverArtDate', @@ -206,9 +251,7 @@ export class Album extends Thing { }), wallpaperArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), - }), + withCoverArtDate(), contributionList({ date: '#coverArtDate', @@ -217,9 +260,7 @@ export class Album extends Thing { ], bannerArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), - }), + withCoverArtDate(), contributionList({ date: '#coverArtDate', @@ -229,8 +270,7 @@ export class Album extends Thing { groups: referenceList({ class: input.value(Group), - find: input.value(find.group), - data: 'groupData', + find: soupyFind.input('group'), }), artTags: [ @@ -241,8 +281,7 @@ export class Album extends Thing { referenceList({ class: input.value(ArtTag), - find: input.value(find.artTag), - data: 'artTagData', + find: soupyFind.input('artTag'), }), ], @@ -252,44 +291,20 @@ export class Album extends Thing { value: input.value([]), }), - { - dependencies: ['coverArtDate', 'date'], - compute: (continuation, { - coverArtDate, - date, - }) => continuation({ - ['#date']: - coverArtDate ?? date, - }), - }, - - referencedArtworkList({ - date: '#date', - }), + referencedArtworkList(), ], // Update only - albumData: wikiData({ - class: input.value(Album), - }), - - artistData: wikiData({ - class: input.value(Artist), - }), - - artTagData: wikiData({ - class: input.value(ArtTag), - }), - - groupData: wikiData({ - class: input.value(Group), - }), + find: soupyFind(), + reverse: soupyReverse(), - trackData: wikiData({ - class: input.value(Track), + // used for referencedArtworkList (mixedFind) + artworkData: wikiData({ + class: input.value(Artwork), }), + // used for withMatchingContributionPresets (indirectly by Contribution) wikiInfo: thing({ class: input.value(WikiInfo), }), @@ -298,7 +313,11 @@ export class Album extends Thing { commentatorArtists: commentatorArtists(), - hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasCoverArt: [ + withHasCoverArt(), + exposeDependency({dependency: '#hasCoverArt'}), + ], + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), @@ -306,15 +325,6 @@ export class Album extends Thing { withTracks(), exposeDependency({dependency: '#tracks'}), ], - - referencedByArtworks: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), - }), - - reverseReferencedArtworkList(), - ], }); static [Thing.getSerializeDescriptors] = ({ @@ -359,16 +369,110 @@ export class Album extends Thing { static [Thing.findSpecs] = { album: { - referenceTypes: ['album', 'album-commentary', 'album-gallery'], + referenceTypes: [ + 'album', + 'album-commentary', + 'album-gallery', + ], + bindTo: 'albumData', + + getMatchableNames: album => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), }, albumWithArtwork: { - referenceTypes: ['album'], + referenceTypes: [ + 'album', + 'album-referencing-artworks', + 'album-referenced-artworks', + ], + bindTo: 'albumData', include: album => album.hasCoverArt, + + getMatchableNames: album => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + }, + + albumPrimaryArtwork: { + [Thing.findThisThingOnly]: false, + + referenceTypes: [ + 'album', + 'album-referencing-artworks', + 'album-referenced-artworks', + ], + + bindTo: 'artworkData', + + include: (artwork, {Artwork, Album}) => + artwork instanceof Artwork && + artwork.thing instanceof Album && + artwork === artwork.thing.coverArtworks[0], + + getMatchableNames: ({thing: album}) => + (album.alwaysReferenceByDirectory + ? [] + : [album.name]), + + getMatchableDirectories: ({thing: album}) => + [album.directory], + }, + }; + + static [Thing.reverseSpecs] = { + albumsWhoseTracksInclude: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.tracks, + }, + + albumsWhoseTrackSectionsInclude: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.trackSections, + }, + + albumsWhoseArtworksFeature: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.artTags, + }, + + albumsWhoseGroupsInclude: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.groups, + }, + + albumArtistContributionsBy: + soupyReverse.contributionsBy('albumData', 'artistContribs'), + + albumCoverArtistContributionsBy: + soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'), + + albumWallpaperArtistContributionsBy: + soupyReverse.artworkContributionsBy('albumData', 'wallpaperArtwork', {single: true}), + + albumBannerArtistContributionsBy: + soupyReverse.artworkContributionsBy('albumData', 'bannerArtwork', {single: true}), + + albumsWithCommentaryBy: { + bindTo: 'albumData', + + referencing: album => [album], + referenced: album => album.commentatorArtists, }, }; @@ -380,6 +484,7 @@ export class Album extends Thing { 'Directory Suffix': {property: 'directorySuffix'}, 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, 'Always Reference Tracks By Directory': { property: 'alwaysReferenceTracksByDirectory', }, @@ -399,6 +504,8 @@ export class Album extends Thing { transform: String, }, + 'Count Tracks In Artist Totals': {property: 'countInArtistTotals'}, + 'Date': { property: 'date', transform: parseDate, @@ -411,6 +518,49 @@ export class Album extends Thing { 'Listed on Homepage': {property: 'isListedOnHomepage'}, 'Listed in Galleries': {property: 'isListedInGalleries'}, + 'Cover Artwork': { + property: 'coverArtworks', + transform: + parseArtwork({ + thingProperty: 'coverArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'albumCoverArtistContributions', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + }), + }, + + 'Banner Artwork': { + property: 'bannerArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'bannerArtwork', + dimensionsFromThingProperty: 'bannerDimensions', + fileExtensionFromThingProperty: 'bannerFileExtension', + dateFromThingProperty: 'date', + artistContribsFromThingProperty: 'bannerArtistContribs', + artistContribsArtistProperty: 'albumBannerArtistContributions', + }), + }, + + 'Wallpaper Artwork': { + property: 'wallpaperArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'wallpaperArtwork', + dimensionsFromThingProperty: null, + fileExtensionFromThingProperty: 'wallpaperFileExtension', + dateFromThingProperty: 'date', + artistContribsFromThingProperty: 'wallpaperArtistContribs', + artistContribsArtistProperty: 'albumWallpaperArtistContributions', + }), + }, + 'Cover Art Date': { property: 'coverArtDate', transform: parseDate, @@ -444,9 +594,10 @@ export class Album extends Thing { transform: parseContributors, }, - 'Wallpaper Style': {property: 'wallpaperStyle'}, 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + 'Wallpaper Style': {property: 'wallpaperStyle'}, + 'Wallpaper Parts': { property: 'wallpaperParts', transform: parseWallpaperParts, @@ -465,8 +616,15 @@ export class Album extends Thing { transform: parseDimensions, }, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, 'Additional Files': { property: 'additionalFiles', @@ -516,7 +674,7 @@ export class Album extends Thing { static [Thing.getYamlLoadingSpec] = ({ documentModes: {headerAndEntries}, - thingConstructors: {Album, Track, TrackSectionHelper}, + thingConstructors: {Album, Track}, }) => ({ title: `Process album files`, @@ -538,6 +696,12 @@ export class Album extends Thing { const trackSectionData = []; const trackData = []; + const artworkData = []; + const commentaryData = []; + const creditingSourceData = []; + const referencingSourceData = []; + const lyricsData = []; + for (const {header: album, entries} of results) { const trackSections = []; @@ -549,8 +713,6 @@ export class Album extends Thing { isDefaultTrackSection: true, }); - const albumRef = Thing.getReference(album); - const closeCurrentTrackSection = () => { if ( currentTrackSection.isDefaultTrackSection && @@ -577,17 +739,53 @@ export class Album extends Thing { currentTrackSectionTracks.push(entry); trackData.push(entry); - entry.dataSourceAlbum = albumRef; + // Set the track's album before accessing its list of artworks. + // The existence of its artwork objects may depend on access to + // its album's 'Default Track Cover Artists'. + entry.album = album; + + artworkData.push(...entry.trackArtworks); + commentaryData.push(...entry.commentary); + creditingSourceData.push(...entry.creditingSources); + referencingSourceData.push(...entry.referencingSources); + + // TODO: As exposed, Track.lyrics tries to inherit from the main + // release, which is impossible before the data's been linked. + // We just use the update value here. But it's icky! + lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []); } closeCurrentTrackSection(); albumData.push(album); + artworkData.push(...album.coverArtworks); + + if (album.bannerArtwork) { + artworkData.push(album.bannerArtwork); + } + + if (album.wallpaperArtwork) { + artworkData.push(album.wallpaperArtwork); + } + + commentaryData.push(...album.commentary); + creditingSourceData.push(...album.creditingSources); + album.trackSections = trackSections; } - return {albumData, trackSectionData, trackData}; + return { + albumData, + trackSectionData, + trackData, + + artworkData, + commentaryData, + creditingSourceData, + referencingSourceData, + lyricsData, + }; }, sort({albumData, trackData}) { @@ -595,13 +793,65 @@ export class Album extends Thing { sortAlbumsTracksChronologically(trackData); }, }); + + getOwnAdditionalFilePath(_file, filename) { + return [ + 'media.albumAdditionalFile', + this.directory, + filename, + ]; + } + + getOwnArtworkPath(artwork) { + if (artwork === this.bannerArtwork) { + return [ + 'media.albumBanner', + this.directory, + artwork.fileExtension, + ]; + } + + if (artwork === this.wallpaperArtwork) { + if (!empty(this.wallpaperParts)) { + return null; + } + + return [ + 'media.albumWallpaper', + this.directory, + artwork.fileExtension, + ]; + } + + // TODO: using trackCover here is obviously, badly wrong + // but we ought to refactor banners and wallpapers similarly + // (i.e. depend on those intrinsic artwork paths rather than + // accessing media.{albumBanner,albumWallpaper} from content + // or other code directly) + return [ + 'media.trackCover', + this.directory, + + (artwork.unqualifiedDirectory + ? 'cover-' + artwork.unqualifiedDirectory + : 'cover'), + + artwork.fileExtension, + ]; + } + + // As of writing, albums don't even have a `duration` property... + // so this function will never be called... but the message stands... + countOwnContributionInDurationTotals(_contrib) { + return false; + } } export class TrackSection extends Thing { static [Thing.friendlyName] = `Track Section`; static [Thing.referenceType] = `track-section`; - static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({Track}) => ({ // Update & expose name: name('Unnamed Track Section'), @@ -623,6 +873,14 @@ export class TrackSection extends Thing { exposeDependency({dependency: '#album.color'}), ], + startCountingFrom: [ + withStartCountingFrom({ + from: input.updateValue({validate: isNumber}), + }), + + exposeDependency({dependency: '#startCountingFrom'}), + ], + dateOriginallyReleased: simpleDate(), isDefaultTrackSection: flag(false), @@ -640,9 +898,7 @@ export class TrackSection extends Thing { // Update only - albumData: wikiData({ - class: input.value(Album), - }), + reverse: soupyReverse(), // Expose only @@ -674,42 +930,10 @@ export class TrackSection extends Thing { }, ], - startIndex: [ - withAlbum(), - - withPropertyFromObject({ - object: '#album', - property: input.value('trackSections'), - }), - - { - dependencies: ['#album.trackSections', input.myself()], - compute: (continuation, { - ['#album.trackSections']: trackSections, - [input.myself()]: myself, - }) => continuation({ - ['#index']: - trackSections.indexOf(myself), - }), - }, - - exitWithoutDependency({ - dependency: '#index', - mode: input.value('index'), - value: input.value(0), - }), + continueCountingFrom: [ + withContinueCountingFrom(), - { - dependencies: ['#album.trackSections', '#index'], - compute: ({ - ['#album.trackSections']: trackSections, - ['#index']: index, - }) => - accumulateSum( - trackSections - .slice(0, index) - .map(section => section.tracks.length)), - }, + exposeDependency({dependency: '#continueCountingFrom'}), ], }); @@ -727,10 +951,20 @@ export class TrackSection extends Thing { }, }; + static [Thing.reverseSpecs] = { + trackSectionsWhichInclude: { + bindTo: 'trackSectionData', + + referencing: trackSection => [trackSection], + referenced: trackSection => trackSection.tracks, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Section': {property: 'name'}, 'Color': {property: 'color'}, + 'Start Counting From': {property: 'startCountingFrom'}, 'Date Originally Released': { property: 'dateOriginallyReleased', @@ -746,38 +980,38 @@ export class TrackSection extends Thing { parts.push(Thing.prototype[inspect.custom].apply(this)); - if (depth >= 0) { + if (depth >= 0) showAlbum: { let album = null; try { album = this.album; - } catch {} + } catch { + break showAlbum; + } let first = null; try { - first = this.startIndex; + first = this.tracks.at(0).trackNumber; } catch {} - let length = null; + let last = null; try { - length = this.tracks.length; + last = this.tracks.at(-1).trackNumber; } catch {} - if (album) { - const albumName = album.name; - const albumIndex = album.trackSections.indexOf(this); + const albumName = album.name; + const albumIndex = album.trackSections.indexOf(this); - const num = - (albumIndex === -1 - ? 'indeterminate position' - : `#${albumIndex + 1}`); + const num = + (albumIndex === -1 + ? 'indeterminate position' + : `#${albumIndex + 1}`); - const range = - (albumIndex >= 0 && first !== null && length !== null - ? `: ${first + 1}-${first + length + 1}` - : ''); + const range = + (albumIndex >= 0 && first !== null && last !== null + ? `: ${first}-${last}` + : ''); - parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`); - } + parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`); } return parts.join(''); diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index 3149b310..518f616b 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,31 +1,45 @@ export const ART_TAG_DATA_FILE = 'tags.yaml'; import {input} from '#composite'; -import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort'; +import {sortAlphabetically} from '#sort'; import Thing from '#thing'; +import {unique} from '#sugar'; import {isName} from '#validators'; +import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml'; -import {exposeUpdateValueOrContinue} from '#composite/control-flow'; +import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} + from '#composite/control-flow'; import { + annotatedReferenceList, color, + contentString, directory, flag, + referenceList, + reverseReferenceList, name, - wikiData, + soupyFind, + soupyReverse, + thingList, + urls, } from '#composite/wiki-properties'; +import {withAllDescendantArtTags, withAncestorArtTagBaobabTree} + from '#composite/things/art-tag'; + export class ArtTag extends Thing { static [Thing.referenceType] = 'tag'; static [Thing.friendlyName] = `Art Tag`; - static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({ // Update & expose name: name('Unnamed Art Tag'), directory: directory(), color: color(), isContentWarning: flag(false), + extraReadingURLs: urls(), nameShort: [ exposeUpdateValueOrContinue({ @@ -39,30 +53,74 @@ export class ArtTag extends Thing { }, ], - // Update only + additionalNames: thingList({ + class: input.value(AdditionalName), + }), - albumData: wikiData({ - class: input.value(Album), + description: contentString(), + + directDescendantArtTags: referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), }), - trackData: wikiData({ - class: input.value(Track), + relatedArtTags: annotatedReferenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), + + reference: input.value('artTag'), + thing: input.value('artTag'), }), + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + // Expose only - taggedInThings: { - flags: {expose: true}, + descriptionShort: [ + exitWithoutDependency({ + dependency: 'description', + mode: input.value('falsy'), + }), + + { + dependencies: ['description'], + compute: ({description}) => + description.split('<hr class="split">')[0], + }, + ], - expose: { - dependencies: ['this', 'albumData', 'trackData'], - compute: ({this: artTag, albumData, trackData}) => - sortAlbumsTracksChronologically( - [...albumData, ...trackData] - .filter(({artTags}) => artTags.includes(artTag)), - {getDate: thing => thing.coverArtDate ?? thing.date}), + directlyFeaturedInArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichFeature'), + }), + + indirectlyFeaturedInArtworks: [ + withAllDescendantArtTags(), + + { + dependencies: ['#allDescendantArtTags'], + compute: ({'#allDescendantArtTags': allDescendantArtTags}) => + unique( + allDescendantArtTags + .flatMap(artTag => artTag.directlyFeaturedInArtworks)), }, - }, + ], + + allDescendantArtTags: [ + withAllDescendantArtTags(), + exposeDependency({dependency: '#allDescendantArtTags'}), + ], + + directAncestorArtTags: reverseReferenceList({ + reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'), + }), + + ancestorArtTagBaobabTree: [ + withAncestorArtTagBaobabTree(), + exposeDependency({dependency: '#ancestorArtTagBaobabTree'}), + ], }); static [Thing.findSpecs] = { @@ -70,10 +128,19 @@ export class ArtTag extends Thing { referenceTypes: ['tag'], bindTo: 'artTagData', - getMatchableNames: tag => - (tag.isContentWarning - ? [`cw: ${tag.name}`] - : [tag.name]), + getMatchableNames: artTag => + (artTag.isContentWarning + ? [`cw: ${artTag.name}`] + : [artTag.name]), + }, + }; + + static [Thing.reverseSpecs] = { + artTagsWhichDirectlyAncestor: { + bindTo: 'artTagData', + + referencing: artTag => [artTag], + referenced: artTag => artTag.directDescendantArtTags, }, }; @@ -82,9 +149,27 @@ export class ArtTag extends Thing { 'Tag': {property: 'name'}, 'Short Name': {property: 'nameShort'}, 'Directory': {property: 'directory'}, + 'Description': {property: 'description'}, + 'Extra Reading URLs': {property: 'extraReadingURLs'}, + + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, 'Color': {property: 'color'}, 'Is CW': {property: 'isContentWarning'}, + + 'Direct Descendant Tags': {property: 'directDescendantArtTags'}, + + 'Related Tags': { + property: 'relatedArtTags', + transform: entries => + parseAnnotatedReferences(entries, { + referenceField: 'Tag', + referenceProperty: 'artTag', + }), + }, }, }; diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 8fdb8a12..5b67051c 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -5,28 +5,27 @@ import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import {sortAlphabetically} from '#sort'; -import {stitchArrays, unique} from '#sugar'; +import {stitchArrays} from '#sugar'; import Thing from '#thing'; import {isName, validateArrayItems} from '#validators'; import {getKebabCase} from '#wiki-data'; +import {parseArtwork} from '#yaml'; -import {exposeDependency} from '#composite/control-flow'; -import {withReverseContributionList} from '#composite/wiki-data'; +import {exitWithoutDependency} from '#composite/control-flow'; import { + constitutibleArtwork, contentString, directory, fileExtension, flag, name, - reverseAnnotatedReferenceList, - reverseContributionList, reverseReferenceList, singleReference, + soupyFind, + soupyReverse, urls, - wikiData, } from '#composite/wiki-properties'; import {artistTotalDuration} from '#composite/things/artist'; @@ -35,7 +34,7 @@ export class Artist extends Thing { static [Thing.referenceType] = 'artist'; static [Thing.wikiDataArray] = 'artistData'; - static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose name: name('Unnamed Artist'), @@ -47,6 +46,16 @@ export class Artist extends Thing { hasAvatar: flag(false), avatarFileExtension: fileExtension('jpg'), + avatarArtwork: [ + exitWithoutDependency({ + dependency: 'hasAvatar', + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Avatar Artwork'), + ], + aliasNames: { flags: {update: true, expose: true}, update: {validate: validateArrayItems(isName)}, @@ -57,95 +66,62 @@ export class Artist extends Thing { aliasedArtist: singleReference({ class: input.value(Artist), - find: input.value(find.artist), - data: 'artistData', + find: soupyFind.input('artist'), }), // Update only - albumData: wikiData({ - class: input.value(Album), - }), - - artistData: wikiData({ - class: input.value(Artist), - }), - - flashData: wikiData({ - class: input.value(Flash), - }), - - groupData: wikiData({ - class: input.value(Group), - }), - - trackData: wikiData({ - class: input.value(Track), - }), + find: soupyFind(), + reverse: soupyReverse(), // Expose only - trackArtistContributions: reverseContributionList({ - data: 'trackData', - list: input.value('artistContribs'), + trackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackArtistContributionsBy'), }), - trackContributorContributions: reverseContributionList({ - data: 'trackData', - list: input.value('contributorContribs'), + trackContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackContributorContributionsBy'), }), - trackCoverArtistContributions: reverseContributionList({ - data: 'trackData', - list: input.value('coverArtistContribs'), + trackCoverArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackCoverArtistContributionsBy'), }), tracksAsCommentator: reverseReferenceList({ - data: 'trackData', - list: input.value('commentatorArtists'), + reverse: soupyReverse.input('tracksWithCommentaryBy'), }), - albumArtistContributions: reverseContributionList({ - data: 'albumData', - list: input.value('artistContribs'), + albumArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumArtistContributionsBy'), }), - albumCoverArtistContributions: reverseContributionList({ - data: 'albumData', - list: input.value('coverArtistContribs'), + albumCoverArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumCoverArtistContributionsBy'), }), - albumWallpaperArtistContributions: reverseContributionList({ - data: 'albumData', - list: input.value('wallpaperArtistContribs'), + albumWallpaperArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'), }), - albumBannerArtistContributions: reverseContributionList({ - data: 'albumData', - list: input.value('bannerArtistContribs'), + albumBannerArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumBannerArtistContributionsBy'), }), albumsAsCommentator: reverseReferenceList({ - data: 'albumData', - list: input.value('commentatorArtists'), + reverse: soupyReverse.input('albumsWithCommentaryBy'), }), - flashContributorContributions: reverseContributionList({ - data: 'flashData', - list: input.value('contributorContribs'), + flashContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('flashContributorContributionsBy'), }), flashesAsCommentator: reverseReferenceList({ - data: 'flashData', - list: input.value('commentatorArtists'), + reverse: soupyReverse.input('flashesWithCommentaryBy'), }), - closelyLinkedGroups: reverseAnnotatedReferenceList({ - data: 'groupData', - list: input.value('closelyLinkedArtists'), - - forward: input.value('artist'), - backward: input.value('group'), + closelyLinkedGroups: reverseReferenceList({ + reverse: soupyReverse.input('groupsCloselyLinkedTo'), }), totalDuration: artistTotalDuration(), @@ -230,6 +206,17 @@ export class Artist extends Thing { 'URLs': {property: 'urls'}, 'Context Notes': {property: 'contextNotes'}, + // note: doesn't really work as an independent field yet + 'Avatar Artwork': { + property: 'avatarArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'avatarArtwork', + fileExtensionFromThingProperty: 'avatarFileExtension', + }), + }, + 'Has Avatar': {property: 'hasAvatar'}, 'Avatar File Extension': {property: 'avatarFileExtension'}, @@ -275,7 +262,12 @@ export class Artist extends Thing { const artistData = [...artists, ...artistAliases]; - return {artistData}; + const artworkData = + artistData + .filter(artist => artist.hasAvatar) + .map(artist => artist.avatarArtwork); + + return {artistData, artworkData}; }, sort({artistData}) { @@ -294,7 +286,7 @@ export class Artist extends Thing { let aliasedArtist; try { aliasedArtist = this.aliasedArtist.name; - } catch (_error) { + } catch { aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); } @@ -303,4 +295,12 @@ export class Artist extends Thing { return parts.join(''); } + + getOwnArtworkPath(artwork) { + return [ + 'media.artistAvatar', + this.directory, + artwork.fileExtension, + ]; + } } diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js new file mode 100644 index 00000000..57c293ca --- /dev/null +++ b/src/data/things/artwork.js @@ -0,0 +1,510 @@ +import {inspect} from 'node:util'; + +import {colors} from '#cli'; +import {input} from '#composite'; +import find from '#find'; +import Thing from '#thing'; + +import { + isContentString, + isContributionList, + isDate, + isDimensions, + isFileExtension, + optional, + validateArrayItems, + validateProperties, + validateReference, + validateReferenceList, +} from '#validators'; + +import { + parseAnnotatedReferences, + parseContributors, + parseDate, + parseDimensions, +} from '#yaml'; + +import {withPropertyFromObject} from '#composite/data'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + withRecontextualizedContributionList, + withResolvedAnnotatedReferenceList, + withResolvedContribs, + withResolvedReferenceList, +} from '#composite/wiki-data'; + +import { + contentString, + directory, + flag, + reverseReferenceList, + simpleString, + soupyFind, + soupyReverse, + thing, + wikiData, +} from '#composite/wiki-properties'; + +import { + withAttachedArtwork, + withContainingArtworkList, + withContribsFromAttachedArtwork, + withPropertyFromAttachedArtwork, + withDate, +} from '#composite/things/artwork'; + +export class Artwork extends Thing { + static [Thing.referenceType] = 'artwork'; + + static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({ + // Update & expose + + unqualifiedDirectory: directory({ + name: input.value(null), + }), + + thing: thing(), + thingProperty: simpleString(), + + label: simpleString(), + source: contentString(), + originDetails: contentString(), + + dateFromThingProperty: simpleString(), + + date: [ + withDate({ + from: input.updateValue({validate: isDate}), + }), + + exposeDependency({dependency: '#date'}), + ], + + fileExtensionFromThingProperty: simpleString(), + + fileExtension: [ + { + compute: (continuation) => continuation({ + ['#default']: 'jpg', + }), + }, + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + exitWithoutDependency({ + dependency: 'thing', + value: '#default', + }), + + exitWithoutDependency({ + dependency: 'fileExtensionFromThingProperty', + value: '#default', + }), + + withPropertyFromObject({ + object: 'thing', + property: 'fileExtensionFromThingProperty', + }), + + exposeDependencyOrContinue({ + dependency: '#value', + }), + + exposeDependency({ + dependency: '#default', + }), + ], + + dimensionsFromThingProperty: simpleString(), + + dimensions: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDimensions), + }), + + exitWithoutDependency({ + dependency: 'dimensionsFromThingProperty', + value: input.value(null), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'dimensionsFromThingProperty', + }).outputs({ + ['#value']: '#dimensionsFromThing', + }), + + exitWithoutDependency({ + dependency: 'dimensionsFromThingProperty', + value: input.value(null), + }), + + exposeDependencyOrContinue({ + dependency: '#dimensionsFromThing', + }), + + exposeConstant({ + value: input.value(null), + }), + ], + + attachAbove: flag(false), + + artistContribsFromThingProperty: simpleString(), + artistContribsArtistProperty: simpleString(), + + artistContribs: [ + withDate(), + + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + date: '#date', + thingProperty: input.thisProperty(), + artistProperty: 'artistContribsArtistProperty', + }), + + exposeDependencyOrContinue({ + dependency: '#resolvedContribs', + mode: input.value('empty'), + }), + + withContribsFromAttachedArtwork(), + + exposeDependencyOrContinue({ + dependency: '#attachedArtwork.artistContribs', + }), + + exitWithoutDependency({ + dependency: 'artistContribsFromThingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'artistContribsFromThingProperty', + }).outputs({ + ['#value']: '#artistContribs', + }), + + withRecontextualizedContributionList({ + list: '#artistContribs', + }), + + exposeDependency({ + dependency: '#artistContribs', + }), + ], + + artTagsFromThingProperty: simpleString(), + + artTags: [ + withResolvedReferenceList({ + list: input.updateValue({ + validate: + validateReferenceList(ArtTag[Thing.referenceType]), + }), + + find: soupyFind.input('artTag'), + }), + + exposeDependencyOrContinue({ + dependency: '#resolvedReferenceList', + mode: input.value('empty'), + }), + + withPropertyFromAttachedArtwork({ + property: input.value('artTags'), + }), + + exposeDependencyOrContinue({ + dependency: '#attachedArtwork.artTags', + }), + + exitWithoutDependency({ + dependency: 'artTagsFromThingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'artTagsFromThingProperty', + }).outputs({ + ['#value']: '#artTags', + }), + + exposeDependencyOrContinue({ + dependency: '#artTags', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + referencedArtworksFromThingProperty: simpleString(), + + referencedArtworks: [ + { + compute: (continuation) => continuation({ + ['#find']: + find.mixed({ + track: find.trackPrimaryArtwork, + album: find.albumPrimaryArtwork, + }), + }), + }, + + withResolvedAnnotatedReferenceList({ + list: input.updateValue({ + validate: + // TODO: It's annoying to hardcode this when it's really the + // same behavior as through annotatedReferenceList and through + // referenceListUpdateDescription, the latter of which isn't + // available outside of #composite/wiki-data internals. + validateArrayItems( + validateProperties({ + reference: validateReference(['album', 'track']), + annotation: optional(isContentString), + })), + }), + + data: 'artworkData', + find: '#find', + + thing: input.value('artwork'), + }), + + exposeDependencyOrContinue({ + dependency: '#resolvedAnnotatedReferenceList', + mode: input.value('empty'), + }), + + exitWithoutDependency({ + dependency: 'referencedArtworksFromThingProperty', + value: input.value([]), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'referencedArtworksFromThingProperty', + }).outputs({ + ['#value']: '#referencedArtworks', + }), + + exposeDependencyOrContinue({ + dependency: '#referencedArtworks', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for referencedArtworks (mixedFind) + artworkData: wikiData({ + class: input.value(Artwork), + }), + + // Expose only + + referencedByArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichReference'), + }), + + isMainArtwork: [ + withContainingArtworkList(), + + exitWithoutDependency({ + dependency: '#containingArtworkList', + value: input.value(null), + }), + + { + dependencies: [input.myself(), '#containingArtworkList'], + compute: ({ + [input.myself()]: myself, + ['#containingArtworkList']: list, + }) => + list[0] === myself, + }, + ], + + mainArtwork: [ + withContainingArtworkList(), + + exitWithoutDependency({ + dependency: '#containingArtworkList', + value: input.value(null), + }), + + { + dependencies: ['#containingArtworkList'], + compute: ({'#containingArtworkList': list}) => + list[0], + }, + ], + + attachedArtwork: [ + withAttachedArtwork(), + + exposeDependency({ + dependency: '#attachedArtwork', + }), + ], + + attachingArtworks: reverseReferenceList({ + reverse: soupyReverse.input('artworksWhichAttach'), + }), + + groups: [ + withPropertyFromObject({ + object: 'thing', + property: input.value('groups'), + }), + + exposeDependencyOrContinue({ + dependency: '#thing.groups', + }), + + exposeConstant({ + value: input.value([]), + }), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Directory': {property: 'unqualifiedDirectory'}, + 'File Extension': {property: 'fileExtension'}, + + 'Dimensions': { + property: 'dimensions', + transform: parseDimensions, + }, + + 'Label': {property: 'label'}, + 'Source': {property: 'source'}, + 'Origin Details': {property: 'originDetails'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, + + 'Attach Above': {property: 'attachAbove'}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Tags': {property: 'artTags'}, + + 'Referenced Artworks': { + property: 'referencedArtworks', + transform: parseAnnotatedReferences, + }, + }, + }; + + static [Thing.reverseSpecs] = { + artworksWhichReference: { + bindTo: 'artworkData', + + referencing: referencingArtwork => + referencingArtwork.referencedArtworks + .map(({artwork: referencedArtwork, ...referenceDetails}) => ({ + referencingArtwork, + referencedArtwork, + referenceDetails, + })), + + referenced: ({referencedArtwork}) => [referencedArtwork], + + tidy: ({referencingArtwork, referenceDetails}) => ({ + artwork: referencingArtwork, + ...referenceDetails, + }), + + date: ({artwork}) => artwork.date, + }, + + artworksWhichAttach: { + bindTo: 'artworkData', + + referencing: referencingArtwork => + (referencingArtwork.attachAbove + ? [referencingArtwork] + : []), + + referenced: referencingArtwork => + [referencingArtwork.attachedArtwork], + }, + + artworksWhichFeature: { + bindTo: 'artworkData', + + referencing: artwork => [artwork], + referenced: artwork => artwork.artTags, + }, + }; + + get path() { + if (!this.thing) return null; + if (!this.thing.getOwnArtworkPath) return null; + + return this.thing.getOwnArtworkPath(this); + } + + countOwnContributionInContributionTotals(contrib) { + if (this.attachAbove) { + return false; + } + + if (contrib.annotation?.startsWith('edits for wiki')) { + return false; + } + + return true; + } + + [inspect.custom](depth, options, inspect) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (this.thing) { + if (depth >= 0) { + const newOptions = { + ...options, + depth: + (options.depth === null + ? null + : options.depth - 1), + }; + + parts.push(` for ${inspect(this.thing, newOptions)}`); + } else { + parts.push(` for ${colors.blue(Thing.getReference(this.thing))}`); + } + } + + return parts.join(''); + } +} diff --git a/src/data/things/content.js b/src/data/things/content.js new file mode 100644 index 00000000..ca41ccaa --- /dev/null +++ b/src/data/things/content.js @@ -0,0 +1,205 @@ +import {input} from '#composite'; +import Thing from '#thing'; +import {is, isDate} from '#validators'; +import {parseDate} from '#yaml'; + +import {contentString, simpleDate, soupyFind, thing} + from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + contentArtists, + hasAnnotationPart, + withAnnotationParts, + withHasAnnotationPart, + withSourceText, + withSourceURLs, + withWebArchiveDate, +} from '#composite/things/content'; + +export class ContentEntry extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + artists: contentArtists(), + + artistText: contentString(), + + annotation: contentString(), + + dateKind: { + flags: {update: true, expose: true}, + + update: { + validate: is(...[ + 'sometime', + 'throughout', + 'around', + ]), + }, + }, + + accessKind: [ + exitWithoutDependency({ + dependency: 'accessDate', + }), + + exposeUpdateValueOrContinue({ + validate: input.value( + is(...[ + 'captured', + 'accessed', + ])), + }), + + withWebArchiveDate(), + + withResultOfAvailabilityCheck({ + from: '#webArchiveDate', + }), + + { + dependencies: ['#availability'], + compute: (continuation, {['#availability']: availability}) => + (availability + ? continuation.exit('captured') + : continuation()), + }, + + exposeConstant({ + value: input.value('accessed'), + }), + ], + + date: simpleDate(), + + secondDate: simpleDate(), + + accessDate: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDate), + }), + + withWebArchiveDate(), + + exposeDependencyOrContinue({ + dependency: '#webArchiveDate', + }), + + exposeConstant({ + value: input.value(null), + }), + ], + + body: contentString(), + + // Update only + + find: soupyFind(), + + // Expose only + + annotationParts: [ + withAnnotationParts({ + mode: input.value('strings'), + }), + + exposeDependency({dependency: '#annotationParts'}), + ], + + sourceText: [ + withSourceText(), + exposeDependency({dependency: '#sourceText'}), + ], + + sourceURLs: [ + withSourceURLs(), + exposeDependency({dependency: '#sourceURLs'}), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Artists': {property: 'artists'}, + 'Artist Text': {property: 'artistText'}, + + 'Annotation': {property: 'annotation'}, + + 'Date Kind': {property: 'dateKind'}, + 'Access Kind': {property: 'accessKind'}, + + 'Date': {property: 'date', transform: parseDate}, + 'Second Date': {property: 'secondDate', transform: parseDate}, + 'Access Date': {property: 'accessDate', transform: parseDate}, + + 'Body': {property: 'body'}, + }, + }; +} + +export class CommentaryEntry extends ContentEntry { + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isWikiEditorCommentary: hasAnnotationPart({ + part: input.value('wiki editor'), + }), + }); +} + +export class LyricsEntry extends ContentEntry { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + originDetails: contentString(), + + // Expose only + + isWikiLyrics: hasAnnotationPart({ + part: input.value('wiki lyrics'), + }), + + hasSquareBracketAnnotations: [ + withHasAnnotationPart({ + part: input.value('wiki lyrics'), + }), + + exitWithoutDependency({ + dependency: '#hasAnnotationPart', + mode: input.value('falsy'), + value: input.value(false), + }), + + exitWithoutDependency({ + dependency: 'body', + value: input.value(false), + }), + + { + dependencies: ['body'], + compute: ({body}) => + /\[.*\]/m.test(body), + }, + ], + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, { + fields: { + 'Origin Details': {property: 'originDetails'}, + }, + }); +} + +export class CreditingSourcesEntry extends ContentEntry {} + +export class ReferencingSourcesEntry extends ContentEntry {} diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js index 2712af70..90e8eb79 100644 --- a/src/data/things/contribution.js +++ b/src/data/things/contribution.js @@ -5,11 +5,18 @@ import {colors} from '#cli'; import {input} from '#composite'; import {empty} from '#sugar'; import Thing from '#thing'; -import {isStringNonEmpty, isThing, validateReference} from '#validators'; +import {isBoolean, isStringNonEmpty, isThing, validateReference} + from '#validators'; -import {exitWithoutDependency, exposeDependency} from '#composite/control-flow'; -import {withResolvedReference} from '#composite/wiki-data'; -import {flag, simpleDate} from '#composite/wiki-properties'; +import {flag, simpleDate, soupyFind} from '#composite/wiki-properties'; + +import { + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; import { withFilteredList, @@ -71,7 +78,26 @@ export class Contribution extends Thing { property: input.thisProperty(), }), - flag(true), + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInContributionTotals?.(contribution) + ? true + : thing.countOwnContributionInContributionTotals + ? false + : continuation()), + }, + + exposeConstant({ + value: input.value(true), + }), ], countInDurationTotals: [ @@ -79,9 +105,43 @@ export class Contribution extends Thing { property: input.thisProperty(), }), - flag(true), + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromObject({ + object: 'thing', + property: input.value('duration'), + }), + + exitWithoutDependency({ + dependency: '#thing.duration', + mode: input.value('falsy'), + value: input.value(false), + }), + + { + dependencies: ['thing', input.myself()], + compute: (continuation, { + ['thing']: thing, + [input.myself()]: contribution, + }) => + (thing.countOwnContributionInDurationTotals?.(contribution) + ? true + : thing.countOwnContributionInDurationTotals + ? false + : continuation()), + }, + + exposeConstant({ + value: input.value(true), + }), ], + // Update only + + find: soupyFind(), + // Expose only context: [ @@ -235,6 +295,21 @@ export class Contribution extends Thing { dependency: '#nearbyItem', }), ], + + groups: [ + withPropertyFromObject({ + object: 'thing', + property: input.value('groups'), + }), + + exposeDependencyOrContinue({ + dependency: '#thing.groups', + }), + + exposeConstant({ + value: input.value([]), + }), + ], }); [inspect.custom](depth, options, inspect) { @@ -256,7 +331,7 @@ export class Contribution extends Thing { let artist; try { artist = this.artist; - } catch (_error) { + } catch { // Computing artist might crash for any reason - don't distract from // other errors as a result of inspecting this contribution. } diff --git a/src/data/things/flash.js b/src/data/things/flash.js index aa6b9cd1..160221f0 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -1,13 +1,21 @@ export const FLASH_DATA_FILE = 'flashes.yaml'; import {input} from '#composite'; -import find from '#find'; import {empty} from '#sugar'; import {sortFlashesChronologically} from '#sort'; import Thing from '#thing'; import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} from '#validators'; -import {parseContributors, parseDate, parseDimensions} from '#yaml'; + +import { + parseArtwork, + parseAdditionalNames, + parseCommentary, + parseContributors, + parseCreditingSources, + parseDate, + parseDimensions, +} from '#yaml'; import {withPropertyFromObject} from '#composite/data'; @@ -20,8 +28,8 @@ import { import { color, - commentary, commentatorArtists, + constitutibleArtwork, contentString, contributionList, dimensions, @@ -30,9 +38,11 @@ import { name, referenceList, simpleDate, + soupyFind, + soupyReverse, thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; import {withFlashAct} from '#composite/things/flash'; @@ -42,9 +52,10 @@ export class Flash extends Thing { static [Thing.referenceType] = 'flash'; static [Thing.getPropertyDescriptors] = ({ - Artist, + AdditionalName, + CommentaryEntry, + CreditingSourcesEntry, Track, - FlashAct, WikiInfo, }) => ({ // Update & expose @@ -98,6 +109,10 @@ export class Flash extends Thing { coverArtDimensions: dimensions(), + coverArtwork: + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), + contributorContribs: contributionList({ date: 'date', artistProperty: input.value('flashContributorContributions'), @@ -105,29 +120,29 @@ export class Flash extends Thing { featuredTracks: referenceList({ class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + find: soupyFind.input('track'), }), urls: urls(), - commentary: commentary(), - creditSources: commentary(), - - // Update only - - artistData: wikiData({ - class: input.value(Artist), + additionalNames: thingList({ + class: input.value(AdditionalName), }), - trackData: wikiData({ - class: input.value(Track), + commentary: thingList({ + class: input.value(CommentaryEntry), }), - flashActData: wikiData({ - class: input.value(FlashAct), + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), }), + // Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for withMatchingContributionPresets (indirectly by Contribution) wikiInfo: thing({ class: input.value(WikiInfo), }), @@ -173,6 +188,25 @@ export class Flash extends Thing { }, }; + static [Thing.reverseSpecs] = { + flashesWhichFeature: { + bindTo: 'flashData', + + referencing: flash => [flash], + referenced: flash => flash.featuredTracks, + }, + + flashContributorContributionsBy: + soupyReverse.contributionsBy('flashData', 'contributorContribs'), + + flashesWithCommentaryBy: { + bindTo: 'flashData', + + referencing: flash => [flash], + referenced: flash => flash.commentatorArtists, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Flash': {property: 'name'}, @@ -186,6 +220,22 @@ export class Flash extends Thing { transform: parseDate, }, + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Cover Artwork': { + property: 'coverArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'coverArtwork', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dimensionsFromThingProperty: 'coverArtDimensions', + }), + }, + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, 'Cover Art Dimensions': { @@ -200,12 +250,27 @@ export class Flash extends Thing { transform: parseContributors, }, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, 'Review Points': {ignore: true}, }, }; + + getOwnArtworkPath(artwork) { + return [ + 'media.flashArt', + this.directory, + artwork.fileExtension, + ]; + } } export class FlashAct extends Thing { @@ -242,19 +307,13 @@ export class FlashAct extends Thing { flashes: referenceList({ class: input.value(Flash), - find: input.value(find.flash), - data: 'flashData', + find: soupyFind.input('flash'), }), // Update only - flashData: wikiData({ - class: input.value(Flash), - }), - - flashSideData: wikiData({ - class: input.value(FlashSide), - }), + find: soupyFind(), + reverse: soupyReverse(), // Expose only @@ -271,6 +330,15 @@ export class FlashAct extends Thing { }, }; + static [Thing.reverseSpecs] = { + flashActsWhoseFlashesInclude: { + bindTo: 'flashActData', + + referencing: flashAct => [flashAct], + referenced: flashAct => flashAct.flashes, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Act': {property: 'name'}, @@ -298,15 +366,12 @@ export class FlashSide extends Thing { acts: referenceList({ class: input.value(FlashAct), - find: input.value(find.flashAct), - data: 'flashActData', + find: soupyFind.input('flashAct'), }), // Update only - flashActData: wikiData({ - class: input.value(FlashAct), - }), + find: soupyFind(), }); static [Thing.yamlDocumentSpec] = { @@ -325,6 +390,15 @@ export class FlashSide extends Thing { }, }; + static [Thing.reverseSpecs] = { + flashSidesWhoseActsInclude: { + bindTo: 'flashSideData', + + referencing: flashSide => [flashSide], + referenced: flashSide => flashSide.acts, + }, + }; + static [Thing.getYamlLoadingSpec] = ({ documentModes: {allInOne}, thingConstructors: {Flash, FlashAct}, @@ -383,7 +457,19 @@ export class FlashSide extends Thing { const flashActData = results.filter(x => x instanceof FlashAct); const flashSideData = results.filter(x => x instanceof FlashSide); - return {flashData, flashActData, flashSideData}; + const artworkData = flashData.map(flash => flash.coverArtwork); + const commentaryData = flashData.flatMap(flash => flash.commentary); + const creditingSourceData = flashData.flatMap(flash => flash.creditingSources); + + return { + flashData, + flashActData, + flashSideData, + + artworkData, + commentaryData, + creditingSourceData, + }; }, sort({flashData}) { diff --git a/src/data/things/group.js b/src/data/things/group.js index 8418cb99..0262a3a5 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,8 +1,11 @@ export const GROUP_DATA_FILE = 'groups.yaml'; +import {inspect} from 'node:util'; + +import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import Thing from '#thing'; +import {is} from '#validators'; import {parseAnnotatedReferences, parseSerieses} from '#yaml'; import { @@ -12,15 +15,16 @@ import { directory, name, referenceList, - seriesList, + soupyFind, + thing, + thingList, urls, - wikiData, } from '#composite/wiki-properties'; export class Group extends Thing { static [Thing.referenceType] = 'group'; - static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({ // Update & expose name: name('Unnamed Group'), @@ -32,10 +36,7 @@ export class Group extends Thing { closelyLinkedArtists: annotatedReferenceList({ class: input.value(Artist), - find: input.value(find.artist), - data: 'artistData', - - date: input.value(null), + find: soupyFind.input('artist'), reference: input.value('artist'), thing: input.value('artist'), @@ -43,27 +44,17 @@ export class Group extends Thing { featuredAlbums: referenceList({ class: input.value(Album), - find: input.value(find.album), - data: 'albumData', + find: soupyFind.input('album'), }), - serieses: seriesList({ - group: input.myself(), + serieses: thingList({ + class: input.value(Series), }), // Update only - albumData: wikiData({ - class: input.value(Album), - }), - - artistData: wikiData({ - class: input.value(Artist), - }), - - groupCategoryData: wikiData({ - class: input.value(GroupCategory), - }), + find: soupyFind(), + reverse: soupyFind(), // Expose only @@ -83,9 +74,9 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'albumData'], - compute: ({this: group, albumData}) => - albumData?.filter((album) => album.groups.includes(group)) ?? [], + dependencies: ['this', 'reverse'], + compute: ({this: group, reverse}) => + reverse.albumsWhoseGroupsInclude(group), }, }, @@ -93,9 +84,9 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'groupCategoryData'], - compute: ({this: group, groupCategoryData}) => - groupCategoryData.find((category) => category.groups.includes(group)) + dependencies: ['this', 'reverse'], + compute: ({this: group, reverse}) => + reverse.groupCategoriesWhichInclude(group, {unique: true}) ?.color, }, }, @@ -104,9 +95,9 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['this', 'groupCategoryData'], - compute: ({this: group, groupCategoryData}) => - groupCategoryData.find((category) => category.groups.includes(group)) ?? + dependencies: ['this', 'reverse'], + compute: ({this: group, reverse}) => + reverse.groupCategoriesWhichInclude(group, {unique: true}) ?? null, }, }, @@ -119,6 +110,25 @@ export class Group extends Thing { }, }; + static [Thing.reverseSpecs] = { + groupsCloselyLinkedTo: { + bindTo: 'groupData', + + referencing: group => + group.closelyLinkedArtists + .map(({artist, ...referenceDetails}) => ({ + group, + artist, + referenceDetails, + })), + + referenced: ({artist}) => [artist], + + tidy: ({group, referenceDetails}) => + ({group, ...referenceDetails}), + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Group': {property: 'name'}, @@ -186,8 +196,9 @@ export class Group extends Thing { const groupData = results.filter(x => x instanceof Group); const groupCategoryData = results.filter(x => x instanceof GroupCategory); + const seriesData = groupData.flatMap(group => group.serieses); - return {groupData, groupCategoryData}; + return {groupData, groupCategoryData, seriesData}; }, // Groups aren't sorted at all, always preserving the order in the data @@ -210,17 +221,23 @@ export class GroupCategory extends Thing { groups: referenceList({ class: input.value(Group), - find: input.value(find.group), - data: 'groupData', + find: soupyFind.input('group'), }), // Update only - groupData: wikiData({ - class: input.value(Group), - }), + find: soupyFind(), }); + static [Thing.reverseSpecs] = { + groupCategoriesWhichInclude: { + bindTo: 'groupCategoryData', + + referencing: groupCategory => [groupCategory], + referenced: groupCategory => groupCategory.groups, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Category': {property: 'name'}, @@ -228,3 +245,73 @@ export class GroupCategory extends Thing { }, }; } + +export class Series extends Thing { + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ + // Update & expose + + name: name('Unnamed Series'), + + showAlbumArtists: { + flags: {update: true, expose: true}, + update: { + validate: + is('all', 'differing', 'none'), + }, + }, + + description: contentString(), + + group: thing({ + class: input.value(Group), + }), + + albums: referenceList({ + class: input.value(Album), + find: soupyFind.input('album'), + }), + + // Update only + + find: soupyFind(), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + + 'Description': {property: 'description'}, + + 'Show Album Artists': {property: 'showAlbumArtists'}, + + 'Albums': {property: 'albums'}, + }, + }; + + [inspect.custom](depth, options, inspect) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (depth >= 0) showGroup: { + let group = null; + try { + group = this.group; + } catch { + break showGroup; + } + + const groupName = group.name; + const groupIndex = group.serieses.indexOf(this); + + const num = + (groupIndex === -1 + ? 'indeterminate position' + : `#${groupIndex + 1}`); + + parts.push(` (${colors.yellow(num)} in ${colors.green(`"${groupName}"`)})`); + } + + return parts.join(''); + } +} diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index 00d6aef5..3a11c287 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -1,8 +1,11 @@ export const HOMEPAGE_LAYOUT_DATA_FILE = 'homepage.yaml'; +import {inspect} from 'node:util'; + +import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import Thing from '#thing'; +import {empty} from '#sugar'; import { anyOf, @@ -11,19 +14,26 @@ import { isString, isStringNonEmpty, validateArrayItems, - validateInstanceOf, validateReference, } from '#validators'; import {exposeDependency} from '#composite/control-flow'; import {withResolvedReference} from '#composite/wiki-data'; -import {color, contentString, name, referenceList, wikiData} - from '#composite/wiki-properties'; + +import { + color, + contentString, + name, + referenceList, + soupyFind, + thing, + thingList, +} from '#composite/wiki-properties'; export class HomepageLayout extends Thing { static [Thing.friendlyName] = `Homepage Layout`; - static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({ // Update & expose sidebarContent: contentString(), @@ -31,15 +41,12 @@ export class HomepageLayout extends Thing { navbarLinks: { flags: {update: true, expose: true}, update: {validate: validateArrayItems(isStringNonEmpty)}, + expose: {transform: value => value ?? []}, }, - rows: { - flags: {update: true, expose: true}, - - update: { - validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), - }, - }, + sections: thingList({ + class: input.value(HomepageLayoutSection), + }), }); static [Thing.yamlDocumentSpec] = { @@ -50,85 +57,230 @@ export class HomepageLayout extends Thing { 'Navbar Links': {property: 'navbarLinks'}, }, }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: { + HomepageLayout, + HomepageLayoutSection, + }, + }) => ({ + title: `Process homepage layout file`, + file: HOMEPAGE_LAYOUT_DATA_FILE, + + documentMode: allInOne, + documentThing: document => { + if (document['Homepage']) { + return HomepageLayout; + } + + if (document['Section']) { + return HomepageLayoutSection; + } + + if (document['Row']) { + switch (document['Row']) { + case 'actions': + return HomepageLayoutActionsRow; + case 'album carousel': + return HomepageLayoutAlbumCarouselRow; + case 'album grid': + return HomepageLayoutAlbumGridRow; + default: + throw new TypeError(`Unrecognized row type ${document['Row']}`); + } + } + + return null; + }, + + save(results) { + if (!empty(results) && !(results[0] instanceof HomepageLayout)) { + throw new Error(`Expected 'Homepage' document at top of homepage layout file`); + } + + const homepageLayout = results[0]; + const sections = []; + + let currentSection = null; + let currentSectionRows = []; + + const closeCurrentSection = () => { + if (currentSection) { + for (const row of currentSectionRows) { + row.section = currentSection; + } + + currentSection.rows = currentSectionRows; + sections.push(currentSection); + + currentSection = null; + currentSectionRows = []; + } + }; + + for (const entry of results.slice(1)) { + if (entry instanceof HomepageLayout) { + throw new Error(`Expected only one 'Homepage' document in total`); + } else if (entry instanceof HomepageLayoutSection) { + closeCurrentSection(); + currentSection = entry; + } else if (entry instanceof HomepageLayoutRow) { + if (currentSection) { + currentSectionRows.push(entry); + } else { + throw new Error(`Expected a 'Section' document to add following rows into`); + } + } + } + + closeCurrentSection(); + + homepageLayout.sections = sections; + + return {homepageLayout}; + }, + }); +} + +export class HomepageLayoutSection extends Thing { + static [Thing.friendlyName] = `Homepage Section`; + + static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ + // Update & expose + + name: name(`Unnamed Homepage Section`), + + color: color(), + + rows: thingList({ + class: input.value(HomepageLayoutRow), + }), + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Section': {property: 'name'}, + 'Color': {property: 'color'}, + }, + }; } export class HomepageLayoutRow extends Thing { static [Thing.friendlyName] = `Homepage Row`; - static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({ // Update & expose - name: name('Unnamed Homepage Row'), + section: thing({ + class: input.value(HomepageLayoutSection), + }), + + // Update only + + find: soupyFind(), + + // Expose only type: { - flags: {update: true, expose: true}, + flags: {expose: true}, - update: { - validate() { + expose: { + compute() { throw new Error(`'type' property validator must be overridden`); }, }, }, + }); - color: color(), + static [Thing.yamlDocumentSpec] = { + fields: { + 'Row': {ignore: true}, + }, + }; - // Update only + [inspect.custom](depth) { + const parts = []; - // These wiki data arrays aren't necessarily used by every subclass, but - // to the convenience of providing these, the superclass accepts all wiki - // data arrays depended upon by any subclass. + parts.push(Thing.prototype[inspect.custom].apply(this)); - albumData: wikiData({ - class: input.value(Album), - }), + if (depth >= 0 && this.section) { + const sectionName = this.section.name; + const index = this.section.rows.indexOf(this); + const rowNum = + (index === -1 + ? 'indeterminate position' + : `#${index + 1}`); + parts.push(` (${colors.yellow(rowNum)} in ${colors.green(sectionName)})`); + } - groupData: wikiData({ - class: input.value(Group), - }), + return parts.join(''); + } +} + +export class HomepageLayoutActionsRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Actions Row`; + + static [Thing.getPropertyDescriptors] = (opts) => ({ + ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), + + // Update & expose + + actionLinks: { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isString)}, + }, + + // Expose only + + type: { + flags: {expose: true}, + expose: {compute: () => 'actions'}, + }, }); - static [Thing.yamlDocumentSpec] = { + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { fields: { - 'Row': {property: 'name'}, - 'Color': {property: 'color'}, - 'Type': {property: 'type'}, + 'Actions': {property: 'actionLinks'}, }, - }; + }); } -export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { - static [Thing.friendlyName] = `Homepage Albums Row`; +export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Album Carousel Row`; - static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ + static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), // Update & expose + albums: referenceList({ + class: input.value(Album), + find: soupyFind.input('album'), + }), + + // Expose only + type: { - flags: {update: true, expose: true}, - update: { - validate(value) { - if (value !== 'albums') { - throw new TypeError(`Expected 'albums'`); - } + flags: {expose: true}, + expose: {compute: () => 'album carousel'}, + }, + }); - return true; - }, - }, + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { + fields: { + 'Albums': {property: 'albums'}, }, + }); +} - displayStyle: { - flags: {update: true, expose: true}, +export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow { + static [Thing.friendlyName] = `Homepage Album Grid Row`; - update: { - validate: is('grid', 'carousel'), - }, + static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ + ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), - expose: { - transform: (displayStyle) => - displayStyle ?? 'grid', - }, - }, + // Update & expose sourceGroup: [ { @@ -151,8 +303,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { withResolvedReference({ ref: input.updateValue(), - data: 'groupData', - find: input.value(find.group), + find: soupyFind.input('group'), }), exposeDependency({dependency: '#resolvedReference'}), @@ -160,8 +311,7 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { sourceAlbums: referenceList({ class: input.value(Album), - find: input.value(find.album), - data: 'albumData', + find: soupyFind.input('album'), }), countAlbumsFromGroup: { @@ -169,55 +319,19 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { update: {validate: isCountingNumber}, }, - actionLinks: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isString)}, + // Expose only + + type: { + flags: {expose: true}, + expose: {compute: () => 'album grid'}, }, }); static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, { fields: { - 'Display Style': {property: 'displayStyle'}, 'Group': {property: 'sourceGroup'}, 'Count': {property: 'countAlbumsFromGroup'}, 'Albums': {property: 'sourceAlbums'}, - 'Actions': {property: 'actionLinks'}, - }, - }); - - static [Thing.getYamlLoadingSpec] = ({ - documentModes: {headerAndEntries}, // Kludge, see below - thingConstructors: { - HomepageLayout, - HomepageLayoutAlbumsRow, - }, - }) => ({ - title: `Process homepage layout file`, - - // Kludge: This benefits from the same headerAndEntries style messaging as - // albums and tracks (for example), but that document mode is designed to - // support multiple files, and only one is actually getting processed here. - files: [HOMEPAGE_LAYOUT_DATA_FILE], - - documentMode: headerAndEntries, - headerDocumentThing: HomepageLayout, - entryDocumentThing: document => { - switch (document['Type']) { - case 'albums': - return HomepageLayoutAlbumsRow; - default: - throw new TypeError(`No processDocument function for row type ${document['Type']}!`); - } - }, - - save(results) { - if (!results[0]) { - return; - } - - const {header: homepageLayout, entries: rows} = results[0]; - Object.assign(homepageLayout, {rows}); - return {homepageLayout}; }, }); } diff --git a/src/data/things/index.js b/src/data/things/index.js index f18e283a..11307b50 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -6,31 +6,42 @@ import CacheableObject from '#cacheable-object'; import {logError} from '#cli'; import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; +import {withEntries} from '#sugar'; import Thing from '#thing'; +import * as additionalFileClasses from './additional-file.js'; +import * as additionalNameClasses from './additional-name.js'; import * as albumClasses from './album.js'; import * as artTagClasses from './art-tag.js'; import * as artistClasses from './artist.js'; +import * as artworkClasses from './artwork.js'; +import * as contentClasses from './content.js'; import * as contributionClasses from './contribution.js'; import * as flashClasses from './flash.js'; import * as groupClasses from './group.js'; import * as homepageLayoutClasses from './homepage-layout.js'; import * as languageClasses from './language.js'; import * as newsEntryClasses from './news-entry.js'; +import * as sortingRuleClasses from './sorting-rule.js'; import * as staticPageClasses from './static-page.js'; import * as trackClasses from './track.js'; import * as wikiInfoClasses from './wiki-info.js'; const allClassLists = { + 'additional-file.js': additionalFileClasses, + 'additional-name.js': additionalNameClasses, 'album.js': albumClasses, 'art-tag.js': artTagClasses, 'artist.js': artistClasses, + 'artwork.js': artworkClasses, + 'content.js': contentClasses, 'contribution.js': contributionClasses, 'flash.js': flashClasses, 'group.js': groupClasses, 'homepage-layout.js': homepageLayoutClasses, 'language.js': languageClasses, 'news-entry.js': newsEntryClasses, + 'sorting-rule.js': sortingRuleClasses, 'static-page.js': staticPageClasses, 'track.js': trackClasses, 'wiki-info.js': wikiInfoClasses, @@ -79,13 +90,25 @@ function errorDuplicateClassNames() { } function flattenClassLists() { + let allClassesUnsorted = Object.create(null); + for (const classes of Object.values(allClassLists)) { for (const [name, constructor] of Object.entries(classes)) { if (typeof constructor !== 'function') continue; if (!(constructor.prototype instanceof Thing)) continue; - allClasses[name] = constructor; + allClassesUnsorted[name] = constructor; } } + + // Sort subclasses after their superclasses. + Object.assign(allClasses, + withEntries(allClassesUnsorted, entries => + entries.sort(({[1]: A}, {[1]: B}) => + (A.prototype instanceof B + ? +1 + : B.prototype instanceof A + ? -1 + : 0)))); } function descriptorAggregateHelper({ @@ -177,6 +200,20 @@ function evaluateSerializeDescriptors() { }); } +function finalizeCacheableObjectPrototypes() { + return descriptorAggregateHelper({ + message: `Errors finalizing Thing class prototypes`, + + op(constructor) { + constructor.finalizeCacheableObjectPrototype(); + }, + + showFailedClasses(failedClasses) { + logError`Failed to finalize cacheable object prototypes for classes: ${failedClasses.join(', ')}`; + }, + }); +} + if (!errorDuplicateClassNames()) process.exit(1); @@ -188,6 +225,9 @@ if (!evaluatePropertyDescriptors()) if (!evaluateSerializeDescriptors()) process.exit(1); +if (!finalizeCacheableObjectPrototypes()) + process.exit(1); + Object.assign(allClasses, {Thing}); export default allClasses; diff --git a/src/data/things/language.js b/src/data/things/language.js index e9aa58be..e3689643 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -115,7 +115,7 @@ export class Language extends Thing { }, // List of descriptors for providing to external link utilities when using - // language.formatExternalLink - refer to util/external-links.js for info. + // language.formatExternalLink - refer to #external-links for info. externalLinkSpec: { flags: {update: true, expose: true}, update: {validate: isExternalLinkSpec}, @@ -135,6 +135,7 @@ export class Language extends Thing { }, intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), + intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}), intl_number: this.#intlHelper(Intl.NumberFormat), intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}), @@ -488,22 +489,44 @@ export class Language extends Thing { // or both are undefined, that's just blank content. const hasStart = startDate !== null && startDate !== undefined; const hasEnd = endDate !== null && endDate !== undefined; - if (!hasStart || !hasEnd) { - if (startDate === endDate) { - return html.blank(); - } else if (hasStart) { - throw new Error(`Expected both start and end of date range, got only start`); - } else if (hasEnd) { - throw new Error(`Expected both start and end of date range, got only end`); - } else { - throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`); - } + if (!hasStart && !hasEnd) { + return html.blank(); + } else if (hasStart && !hasEnd) { + throw new Error(`Expected both start and end of date range, got only start`); + } else if (!hasStart && hasEnd) { + throw new Error(`Expected both start and end of date range, got only end`); } this.assertIntlAvailable('intl_date'); return this.intl_date.formatRange(startDate, endDate); } + formatYear(date) { + if (date === null || date === undefined) { + return html.blank(); + } + + this.assertIntlAvailable('intl_dateYear'); + return this.intl_dateYear.format(date); + } + + formatYearRange(startDate, endDate) { + // formatYearRange expects both values to be present, but if both are null + // or both are undefined, that's just blank content. + const hasStart = startDate !== null && startDate !== undefined; + const hasEnd = endDate !== null && endDate !== undefined; + if (!hasStart && !hasEnd) { + return html.blank(); + } else if (hasStart && !hasEnd) { + throw new Error(`Expected both start and end of date range, got only start`); + } else if (!hasStart && hasEnd) { + throw new Error(`Expected both start and end of date range, got only end`); + } + + this.assertIntlAvailable('intl_dateYear'); + return this.intl_dateYear.formatRange(startDate, endDate); + } + formatDateDuration({ years: numYears = 0, months: numMonths = 0, @@ -842,6 +865,18 @@ export class Language extends Thing { } } + typicallyLowerCase(string) { + // Utter nonsense implementation, so this only works on strings, + // not actual HTML content, and may rudely disrespect *intentful* + // capitalization of whatever goes into it. + + if (typeof string !== 'string') return string; + if (string.length <= 1) return string; + if (/^\S+?[A-Z]/.test(string)) return string; + + return string[0].toLowerCase() + string.slice(1); + } + // Utility function to quickly provide a useful string key // (generally a prefix) to stuff nested beneath it. encapsulate(...args) { @@ -896,6 +931,7 @@ const countHelper = (stringKey, optionName = stringKey) => Object.assign(Language.prototype, { countAdditionalFiles: countHelper('additionalFiles', 'files'), countAlbums: countHelper('albums'), + countArtTags: countHelper('artTags', 'tags'), countArtworks: countHelper('artworks'), countCommentaryEntries: countHelper('commentaryEntries', 'entries'), countContributions: countHelper('contributions'), @@ -903,6 +939,7 @@ Object.assign(Language.prototype, { countDays: countHelper('days'), countFlashes: countHelper('flashes'), countMonths: countHelper('months'), + countTimesFeatured: countHelper('timesFeatured'), countTimesReferenced: countHelper('timesReferenced'), countTimesUsed: countHelper('timesUsed'), countTracks: countHelper('tracks'), diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js new file mode 100644 index 00000000..ccc4ad89 --- /dev/null +++ b/src/data/things/sorting-rule.js @@ -0,0 +1,385 @@ +export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml'; + +import {readFile, writeFile} from 'node:fs/promises'; +import * as path from 'node:path'; + +import {chunkByProperties, compareArrays, unique} from '#sugar'; +import Thing from '#thing'; +import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators'; + +import { + compareCaseLessSensitive, + sortByDate, + sortByDirectory, + sortByName, +} from '#sort'; + +import { + documentModes, + flattenThingLayoutToDocumentOrder, + getThingLayoutForFilename, + reorderDocumentsInYAMLSourceText, +} from '#yaml'; + +import {flag} from '#composite/wiki-properties'; + +function isSelectFollowingEntry(value) { + isObject(value); + + const {length} = Object.keys(value); + if (length !== 1) { + throw new Error(`Expected object with 1 key, got ${length}`); + } + + return true; +} + +export class SortingRule extends Thing { + static [Thing.friendlyName] = `Sorting Rule`; + + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + active: flag(true), + + message: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Message': {property: 'message'}, + 'Active': {property: 'active'}, + }, + }; + + static [Thing.getYamlLoadingSpec] = ({ + documentModes: {allInOne}, + thingConstructors: {DocumentSortingRule}, + }) => ({ + title: `Process sorting rules file`, + file: SORTING_RULE_DATA_FILE, + + documentMode: allInOne, + documentThing: document => + (document['Sort Documents'] + ? DocumentSortingRule + : null), + + save: (results) => ({sortingRules: results}), + }); + + check(opts) { + return this.constructor.check(this, opts); + } + + apply(opts) { + return this.constructor.apply(this, opts); + } + + static check(rule, opts) { + const result = this.apply(rule, {...opts, dry: true}); + if (!result) return true; + if (!result.changed) return true; + return false; + } + + static async apply(_rule, _opts) { + throw new Error(`Not implemented`); + } + + static async* applyAll(_rules, _opts) { + throw new Error(`Not implemented`); + } + + static async* go({dataPath, wikiData, dry}) { + const rules = wikiData.sortingRules; + const constructors = unique(rules.map(rule => rule.constructor)); + + for (const constructor of constructors) { + yield* constructor.applyAll( + rules + .filter(rule => rule.active) + .filter(rule => rule.constructor === constructor), + {dataPath, wikiData, dry}); + } + } +} + +export class ThingSortingRule extends SortingRule { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + properties: { + flags: {update: true, expose: true}, + update: { + validate: strictArrayOf(isStringNonEmpty), + }, + }, + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, { + fields: { + 'By Properties': {property: 'properties'}, + }, + }); + + sort(sortable) { + if (this.properties) { + for (const property of this.properties.slice().reverse()) { + const get = thing => thing[property]; + const lc = property.toLowerCase(); + + if (lc.endsWith('date')) { + sortByDate(sortable, {getDate: get}); + continue; + } + + if (lc.endsWith('directory')) { + sortByDirectory(sortable, {getDirectory: get}); + continue; + } + + if (lc.endsWith('name')) { + sortByName(sortable, {getName: get}); + continue; + } + + const values = sortable.map(get); + + if (values.every(v => typeof v === 'string')) { + sortable.sort((a, b) => + compareCaseLessSensitive(get(a), get(b))); + continue; + } + + if (values.every(v => typeof v === 'number')) { + sortable.sort((a, b) => get(a) - get(b)); + continue; + } + + sortable.sort((a, b) => + (get(a).toString() < get(b).toString() + ? -1 + : get(a).toString() > get(b).toString() + ? +1 + : 0)); + } + } + + return sortable; + } +} + +export class DocumentSortingRule extends ThingSortingRule { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + // TODO: glob :plead: + filename: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + + message: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + + expose: { + dependencies: ['filename'], + transform: (value, {filename}) => + value ?? + `Sort ${filename}`, + }, + }, + + selectDocumentsFollowing: { + flags: {update: true, expose: true}, + + update: { + validate: + anyOf( + isSelectFollowingEntry, + strictArrayOf(isSelectFollowingEntry)), + }, + + compute: { + transform: value => + (Array.isArray(value) + ? value + : [value]), + }, + }, + + selectDocumentsUnder: { + flags: {update: true, expose: true}, + update: {validate: isStringNonEmpty}, + }, + }); + + static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, { + fields: { + 'Sort Documents': {property: 'filename'}, + 'Select Documents Following': {property: 'selectDocumentsFollowing'}, + 'Select Documents Under': {property: 'selectDocumentsUnder'}, + }, + + invalidFieldCombinations: [ + {message: `Specify only one of these`, fields: [ + 'Select Documents Following', + 'Select Documents Under', + ]}, + ], + }); + + static async apply(rule, {wikiData, dataPath, dry}) { + const oldLayout = getThingLayoutForFilename(rule.filename, wikiData); + if (!oldLayout) return null; + + const newLayout = rule.#processLayout(oldLayout); + + const oldOrder = flattenThingLayoutToDocumentOrder(oldLayout); + const newOrder = flattenThingLayoutToDocumentOrder(newLayout); + const changed = compareArrays(oldOrder, newOrder); + + if (dry) return {changed}; + + const realPath = + path.join( + dataPath, + rule.filename.split(path.posix.sep).join(path.sep)); + + const oldSourceText = await readFile(realPath, 'utf8'); + const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder); + + await writeFile(realPath, newSourceText); + + return {changed}; + } + + static async* applyAll(rules, {wikiData, dataPath, dry}) { + rules = + rules + .slice() + .sort((a, b) => a.filename.localeCompare(b.filename, 'en')); + + for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) { + const initialLayout = getThingLayoutForFilename(filename, wikiData); + if (!initialLayout) continue; + + let currLayout = initialLayout; + let prevLayout = initialLayout; + let anyChanged = false; + + for (const rule of chunk) { + currLayout = rule.#processLayout(currLayout); + + const prevOrder = flattenThingLayoutToDocumentOrder(prevLayout); + const currOrder = flattenThingLayoutToDocumentOrder(currLayout); + + if (compareArrays(currOrder, prevOrder)) { + yield {rule, changed: false}; + } else { + anyChanged = true; + yield {rule, changed: true}; + } + + prevLayout = currLayout; + } + + if (!anyChanged) continue; + if (dry) continue; + + const newLayout = currLayout; + const newOrder = flattenThingLayoutToDocumentOrder(newLayout); + + const realPath = + path.join( + dataPath, + filename.split(path.posix.sep).join(path.sep)); + + const oldSourceText = await readFile(realPath, 'utf8'); + const newSourceText = reorderDocumentsInYAMLSourceText(oldSourceText, newOrder); + + await writeFile(realPath, newSourceText); + } + } + + #processLayout(layout) { + const fresh = {...layout}; + + let sortable = null; + switch (fresh.documentMode) { + case documentModes.headerAndEntries: + sortable = fresh.entryThings = + fresh.entryThings.slice(); + break; + + case documentModes.allInOne: + sortable = fresh.things = + fresh.things.slice(); + break; + + default: + throw new Error(`Invalid document type for sorting`); + } + + if (this.selectDocumentsFollowing) { + for (const entry of this.selectDocumentsFollowing) { + const [field, value] = Object.entries(entry)[0]; + + const after = + sortable.findIndex(thing => + thing[Thing.yamlSourceDocument][field] === value); + + const different = + after + + sortable + .slice(after) + .findIndex(thing => + Object.hasOwn(thing[Thing.yamlSourceDocument], field) && + thing[Thing.yamlSourceDocument][field] !== value); + + const before = + (different === -1 + ? sortable.length + : different); + + const subsortable = + sortable.slice(after + 1, before); + + this.sort(subsortable); + + sortable.splice(after + 1, before - after - 1, ...subsortable); + } + } else if (this.selectDocumentsUnder) { + const field = this.selectDocumentsUnder; + + const indices = + Array.from(sortable.entries()) + .filter(([_index, thing]) => + Object.hasOwn(thing[Thing.yamlSourceDocument], field)) + .map(([index, _thing]) => index); + + for (const [indicesIndex, after] of indices.entries()) { + const before = + (indicesIndex === indices.length - 1 + ? sortable.length + : indices[indicesIndex + 1]); + + const subsortable = + sortable.slice(after + 1, before); + + this.sort(subsortable); + + sortable.splice(after + 1, before - after - 1, ...subsortable); + } + } else { + this.sort(sortable); + } + + return fresh; + } +} diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index 03274979..52a09c31 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -7,7 +7,7 @@ import {sortAlphabetically} from '#sort'; import Thing from '#thing'; import {isName} from '#validators'; -import {contentString, directory, name, simpleString} +import {contentString, directory, flag, name, simpleString} from '#composite/wiki-properties'; export class StaticPage extends Thing { @@ -30,9 +30,12 @@ export class StaticPage extends Thing { }, directory: directory(), - content: contentString(), + stylesheet: simpleString(), script: simpleString(), + content: contentString(), + + absoluteLinks: flag(), }); static [Thing.findSpecs] = { @@ -48,6 +51,8 @@ export class StaticPage extends Thing { 'Short Name': {property: 'nameShort'}, 'Directory': {property: 'directory'}, + 'Absolute Links': {property: 'absoluteLinks'}, + 'Style': {property: 'stylesheet'}, 'Script': {property: 'script'}, 'Content': {property: 'content'}, diff --git a/src/data/things/track.js b/src/data/things/track.js index a0d2f641..8b9420c7 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -3,7 +3,6 @@ import {inspect} from 'node:util'; import CacheableObject from '#cacheable-object'; import {colors} from '#cli'; import {input} from '#composite'; -import find from '#find'; import Thing from '#thing'; import {isBoolean, isColor, isContributionList, isDate, isFileExtension} from '#validators'; @@ -12,16 +11,20 @@ import { parseAdditionalFiles, parseAdditionalNames, parseAnnotatedReferences, + parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, + parseReferencingSources, parseDate, parseDimensions, parseDuration, + parseLyrics, } from '#yaml'; import {withPropertyFromObject} from '#composite/data'; import { - exitWithoutDependency, exposeConstant, exposeDependency, exposeDependencyOrContinue, @@ -36,11 +39,8 @@ import { } from '#composite/wiki-data'; import { - additionalFiles, - additionalNameList, - commentary, commentatorArtists, - contentString, + constitutibleArtworkList, contributionList, dimensions, directory, @@ -50,42 +50,49 @@ import { referenceList, referencedArtworkList, reverseReferenceList, - reverseReferencedArtworkList, simpleDate, simpleString, singleReference, + soupyFind, + soupyReverse, thing, + thingList, urls, wikiData, } from '#composite/wiki-properties'; import { exitWithoutUniqueCoverArt, - inheritContributionListFromOriginalRelease, - inheritFromOriginalRelease, - trackReverseReferenceList, - withAlbum, + inheritContributionListFromMainRelease, + inheritFromMainRelease, + withAllReleases, withAlwaysReferenceByDirectory, withContainingTrackSection, + withCoverArtistContribs, withDate, withDirectorySuffix, withHasUniqueCoverArt, - withOriginalRelease, + withMainRelease, withOtherReleases, withPropertyFromAlbum, withSuffixDirectoryFromAlbum, withTrackArtDate, + withTrackNumber, } from '#composite/things/track'; export class Track extends Thing { static [Thing.referenceType] = 'track'; static [Thing.getPropertyDescriptors] = ({ + AdditionalFile, + AdditionalName, Album, ArtTag, - Artist, - Flash, - TrackSection, + Artwork, + CommentaryEntry, + CreditingSourcesEntry, + LyricsEntry, + ReferencingSourcesEntry, WikiInfo, }) => ({ // Update & expose @@ -122,7 +129,13 @@ export class Track extends Thing { }) ], - additionalNames: additionalNameList(), + album: thing({ + class: input.value(Album), + }), + + additionalNames: thingList({ + class: input.value(AdditionalName), + }), bandcampTrackIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), @@ -157,6 +170,18 @@ export class Track extends Thing { exposeDependency({dependency: '#alwaysReferenceByDirectory'}), ], + countInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromAlbum({ + property: input.value('countTracksInArtistTotals'), + }), + + exposeDependency({dependency: '#album.countTracksInArtistTotals'}), + ], + // Disables presenting the track as though it has its own unique artwork. // This flag should only be used in select circumstances, i.e. to override // an album's trackCoverArtists. This flag supercedes that property, as well @@ -198,6 +223,8 @@ export class Track extends Thing { coverArtDimensions: [ exitWithoutUniqueCoverArt(), + exposeUpdateValueOrContinue(), + withPropertyFromAlbum({ property: input.value('trackDimensions'), }), @@ -207,35 +234,48 @@ export class Track extends Thing { dimensions(), ], - commentary: commentary(), - creditSources: commentary(), + commentary: thingList({ + class: input.value(CommentaryEntry), + }), + + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), + }), + + referencingSources: thingList({ + class: input.value(ReferencingSourcesEntry), + }), lyrics: [ - inheritFromOriginalRelease(), - contentString(), + // TODO: Inherited lyrics are literally the same objects, so of course + // their .thing properties aren't going to point back to this one, and + // certainly couldn't be recontextualized... + inheritFromMainRelease(), + + thingList({ + class: input.value(LyricsEntry), + }), ], - additionalFiles: additionalFiles(), - sheetMusicFiles: additionalFiles(), - midiProjectFiles: additionalFiles(), + additionalFiles: thingList({ + class: input.value(AdditionalFile), + }), - originalReleaseTrack: singleReference({ - class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + sheetMusicFiles: thingList({ + class: input.value(AdditionalFile), }), - // Internal use only - for directly identifying an album inside a track's - // util.inspect display, if it isn't indirectly available (by way of being - // included in an album's track list). - dataSourceAlbum: singleReference({ - class: input.value(Album), - find: input.value(find.album), - data: 'albumData', + midiProjectFiles: thingList({ + class: input.value(AdditionalFile), + }), + + mainReleaseTrack: singleReference({ + class: input.value(Track), + find: soupyFind.input('track'), }), artistContribs: [ - inheritContributionListFromOriginalRelease(), + inheritContributionListFromMainRelease(), withDate(), @@ -271,7 +311,7 @@ export class Track extends Thing { ], contributorContribs: [ - inheritContributionListFromOriginalRelease(), + inheritContributionListFromMainRelease(), withDate(), @@ -281,71 +321,45 @@ export class Track extends Thing { }), ], - // Cover artists aren't inherited from the original release, since it - // typically varies by release and isn't defined by the musical qualities - // of the track. coverArtistContribs: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), - }), - - withTrackArtDate({ - fallback: input.value(true), - }), - - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - thingProperty: input.thisProperty(), - artistProperty: input.value('trackCoverArtistContributions'), - date: '#trackArtDate', - }).outputs({ - '#resolvedContribs': '#coverArtistContribs', - }), - - exposeDependencyOrContinue({ - dependency: '#coverArtistContribs', - mode: input.value('empty'), - }), - - withPropertyFromAlbum({ - property: input.value('trackCoverArtistContribs'), - }), - - withRecontextualizedContributionList({ - list: '#album.trackCoverArtistContribs', - artistProperty: input.value('trackCoverArtistContributions'), - }), - - withRedatedContributionList({ - list: '#album.trackCoverArtistContribs', - date: '#trackArtDate', + withCoverArtistContribs({ + from: input.updateValue({ + validate: isContributionList, + }), }), - exposeDependency({dependency: '#album.trackCoverArtistContribs'}), + exposeDependency({dependency: '#coverArtistContribs'}), ], referencedTracks: [ - inheritFromOriginalRelease({ + inheritFromMainRelease({ notFoundValue: input.value([]), }), referenceList({ class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + find: soupyFind.input('track'), }), ], sampledTracks: [ - inheritFromOriginalRelease({ + inheritFromMainRelease({ notFoundValue: input.value([]), }), referenceList({ class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + find: soupyFind.input('track'), + }), + ], + + trackArtworks: [ + exitWithoutUniqueCoverArt({ + value: input.value([]), }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Track Artwork'), ], artTags: [ @@ -355,8 +369,7 @@ export class Track extends Thing { referenceList({ class: input.value(ArtTag), - find: input.value(find.artTag), - data: 'artTagData', + find: soupyFind.input('artTag'), }), ], @@ -365,41 +378,25 @@ export class Track extends Thing { value: input.value([]), }), - withTrackArtDate({ - fallback: input.value(true), - }), - - referencedArtworkList({ - date: '#trackArtDate', - }), + referencedArtworkList(), ], // Update only - albumData: wikiData({ - class: input.value(Album), - }), - - artistData: wikiData({ - class: input.value(Artist), - }), - - artTagData: wikiData({ - class: input.value(ArtTag), - }), + find: soupyFind(), + reverse: soupyReverse(), - flashData: wikiData({ - class: input.value(Flash), + // used for referencedArtworkList (mixedFind) + artworkData: wikiData({ + class: input.value(Artwork), }), + // used for withAlwaysReferenceByDirectory (for some reason) trackData: wikiData({ class: input.value(Track), }), - trackSectionData: wikiData({ - class: input.value(TrackSection), - }), - + // used for withMatchingContributionPresets (indirectly by Contribution) wikiInfo: thing({ class: input.value(WikiInfo), }), @@ -408,63 +405,75 @@ export class Track extends Thing { commentatorArtists: commentatorArtists(), - album: [ - withAlbum(), - exposeDependency({dependency: '#album'}), - ], - date: [ withDate(), exposeDependency({dependency: '#date'}), ], + trackNumber: [ + withTrackNumber(), + exposeDependency({dependency: '#trackNumber'}), + ], + hasUniqueCoverArt: [ withHasUniqueCoverArt(), exposeDependency({dependency: '#hasUniqueCoverArt'}), ], - isOriginalRelease: [ - withOriginalRelease(), + isMainRelease: [ + withMainRelease(), exposeWhetherDependencyAvailable({ - dependency: '#originalRelease', + dependency: '#mainRelease', negate: input.value(true), }), ], - isRerelease: [ - withOriginalRelease(), + isSecondaryRelease: [ + withMainRelease(), exposeWhetherDependencyAvailable({ - dependency: '#originalRelease', + dependency: '#mainRelease', }), ], + // Only has any value for main releases, because secondary releases + // are never secondary to *another* secondary release. + secondaryReleases: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'), + }), + + allReleases: [ + withAllReleases(), + exposeDependency({dependency: '#allReleases'}), + ], + otherReleases: [ withOtherReleases(), exposeDependency({dependency: '#otherReleases'}), ], - referencedByTracks: trackReverseReferenceList({ - list: input.value('referencedTracks'), + groups: [ + withPropertyFromAlbum({ + property: input.value('groups'), + }), + + exposeDependency({ + dependency: '#album.groups', + }), + ], + + referencedByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichReference'), }), - sampledByTracks: trackReverseReferenceList({ - list: input.value('sampledTracks'), + sampledByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichSample'), }), featuredInFlashes: reverseReferenceList({ - data: 'flashData', - list: input.value('featuredTracks'), + reverse: soupyReverse.input('flashesWhichFeature'), }), - - referencedByArtworks: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), - }), - - reverseReferencedArtworkList(), - ], }); static [Thing.yamlDocumentSpec] = { @@ -488,6 +497,8 @@ export class Track extends Thing { transform: String, }, + 'Count In Artist Totals': {property: 'countInArtistTotals'}, + 'Duration': { property: 'duration', transform: parseDuration, @@ -523,9 +534,25 @@ export class Track extends Thing { 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, - 'Lyrics': {property: 'lyrics'}, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Lyrics': { + property: 'lyrics', + transform: parseLyrics, + }, + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, + }, + + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, + }, + + 'Referencing Sources': { + property: 'referencingSources', + transform: parseReferencingSources, + }, 'Additional Files': { property: 'additionalFiles', @@ -542,7 +569,7 @@ export class Track extends Thing { transform: parseAdditionalFiles, }, - 'Originally Released As': {property: 'originalReleaseTrack'}, + 'Main Release': {property: 'mainReleaseTrack'}, 'Referenced Tracks': {property: 'referencedTracks'}, 'Sampled Tracks': {property: 'sampledTracks'}, @@ -569,34 +596,54 @@ export class Track extends Thing { transform: parseContributors, }, + 'Track Artwork': { + property: 'trackArtworks', + transform: + parseArtwork({ + thingProperty: 'trackArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'trackCoverArtistContributions', + }), + }, + 'Art Tags': {property: 'artTags'}, 'Review Points': {ignore: true}, }, invalidFieldCombinations: [ - {message: `Rereleases inherit references from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases never count in artist totals`, fields: [ + 'Main Release', + 'Count In Artist Totals', + ]}, + + {message: `Secondary releases inherit references from the main one`, fields: [ + 'Main Release', 'Referenced Tracks', ]}, - {message: `Rereleases inherit samples from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit samples from the main one`, fields: [ + 'Main Release', 'Sampled Tracks', ]}, - {message: `Rereleases inherit artists from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit artists from the main one`, fields: [ + 'Main Release', 'Artists', ]}, - {message: `Rereleases inherit contributors from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit contributors from the main one`, fields: [ + 'Main Release', 'Contributors', ]}, - {message: `Rereleases inherit lyrics from the original`, fields: [ - 'Originally Released As', + {message: `Secondary releases inherit lyrics from the main one`, fields: [ + 'Main Release', 'Lyrics', ]}, @@ -617,6 +664,7 @@ export class Track extends Thing { static [Thing.findSpecs] = { track: { referenceTypes: ['track'], + bindTo: 'trackData', getMatchableNames: track => @@ -625,12 +673,12 @@ export class Track extends Thing { : [track.name]), }, - trackOriginalReleasesOnly: { + trackMainReleasesOnly: { referenceTypes: ['track'], bindTo: 'trackData', include: track => - !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'), + !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'), // It's still necessary to check alwaysReferenceByDirectory here, since // it may be set manually (with `Always Reference By Directory: true`), @@ -643,7 +691,12 @@ export class Track extends Thing { }, trackWithArtwork: { - referenceTypes: ['track'], + referenceTypes: [ + 'track', + 'track-referencing-artworks', + 'track-referenced-artworks', + ], + bindTo: 'trackData', include: track => @@ -654,32 +707,144 @@ export class Track extends Thing { ? [] : [track.name]), }, + + trackPrimaryArtwork: { + [Thing.findThisThingOnly]: false, + + referenceTypes: [ + 'track', + 'track-referencing-artworks', + 'track-referenced-artworks', + ], + + bindTo: 'artworkData', + + include: (artwork, {Artwork, Track}) => + artwork instanceof Artwork && + artwork.thing instanceof Track && + artwork === artwork.thing.trackArtworks[0], + + getMatchableNames: ({thing: track}) => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + + getMatchableDirectories: ({thing: track}) => + [track.directory], + }, + }; + + static [Thing.reverseSpecs] = { + tracksWhichReference: { + bindTo: 'trackData', + + referencing: track => track.isMainRelease ? [track] : [], + referenced: track => track.referencedTracks, + }, + + tracksWhichSample: { + bindTo: 'trackData', + + referencing: track => track.isMainRelease ? [track] : [], + referenced: track => track.sampledTracks, + }, + + tracksWhoseArtworksFeature: { + bindTo: 'trackData', + + referencing: track => [track], + referenced: track => track.artTags, + }, + + trackArtistContributionsBy: + soupyReverse.contributionsBy('trackData', 'artistContribs'), + + trackContributorContributionsBy: + soupyReverse.contributionsBy('trackData', 'contributorContribs'), + + trackCoverArtistContributionsBy: + soupyReverse.artworkContributionsBy('trackData', 'trackArtworks'), + + tracksWithCommentaryBy: { + bindTo: 'trackData', + + referencing: track => [track], + referenced: track => track.commentatorArtists, + }, + + tracksWhichAreSecondaryReleasesOf: { + bindTo: 'trackData', + + referencing: track => track.isSecondaryRelease ? [track] : [], + referenced: track => [track.mainReleaseTrack], + }, }; // Track YAML loading is handled in album.js. static [Thing.getYamlLoadingSpec] = null; + getOwnAdditionalFilePath(_file, filename) { + if (!this.album) return null; + + return [ + 'media.albumAdditionalFile', + this.album.directory, + filename, + ]; + } + + getOwnArtworkPath(artwork) { + if (!this.album) return null; + + return [ + 'media.trackCover', + this.album.directory, + + (artwork.unqualifiedDirectory + ? this.directory + '-' + artwork.unqualifiedDirectory + : this.directory), + + artwork.fileExtension, + ]; + } + + countOwnContributionInContributionTotals(_contrib) { + if (!this.countInArtistTotals) { + return false; + } + + if (this.isSecondaryRelease) { + return false; + } + + return true; + } + + countOwnContributionInDurationTotals(_contrib) { + if (!this.countInArtistTotals) { + return false; + } + + if (this.isSecondaryRelease) { + return false; + } + + return true; + } + [inspect.custom](depth) { const parts = []; parts.push(Thing.prototype[inspect.custom].apply(this)); - if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) { - parts.unshift(`${colors.yellow('[rerelease]')} `); + if (CacheableObject.getUpdateValue(this, 'mainReleaseTrack')) { + parts.unshift(`${colors.yellow('[secrelease]')} `); } let album; if (depth >= 0) { - try { - album = this.album; - } catch (_error) { - // Computing album might crash for any reason, which we don't want to - // distract from another error we might be trying to work out at the - // moment (for which debugging might involve inspecting this track!). - } - - album ??= this.dataSourceAlbum; + album = this.album; } if (album) { diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index ef643681..f97f9027 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,9 +1,8 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml'; import {input} from '#composite'; -import find from '#find'; import Thing from '#thing'; -import {parseContributionPresets} from '#yaml'; +import {parseContributionPresets, parseWallpaperParts} from '#yaml'; import { isBoolean, @@ -15,8 +14,17 @@ import { } from '#validators'; import {exitWithoutDependency} from '#composite/control-flow'; -import {contentString, flag, name, referenceList, wikiData} - from '#composite/wiki-properties'; + +import { + contentString, + fileExtension, + flag, + name, + referenceList, + simpleString, + soupyFind, + wallpaperParts, +} from '#composite/wiki-properties'; export class WikiInfo extends Thing { static [Thing.friendlyName] = `Wiki Info`; @@ -69,10 +77,13 @@ export class WikiInfo extends Thing { }, }, + wikiWallpaperFileExtension: fileExtension('jpg'), + wikiWallpaperStyle: simpleString(), + wikiWallpaperParts: wallpaperParts(), + divideTrackListsByGroups: referenceList({ class: input.value(Group), - find: input.value(find.group), - data: 'groupData', + find: soupyFind.input('group'), }), contributionPresets: { @@ -99,6 +110,8 @@ export class WikiInfo extends Thing { // Update only + find: soupyFind(), + searchDataAvailable: { flags: {update: true}, update: { @@ -106,28 +119,40 @@ export class WikiInfo extends Thing { default: false, }, }, - - groupData: wikiData({ - class: input.value(Group), - }), }); static [Thing.yamlDocumentSpec] = { fields: { 'Name': {property: 'name'}, 'Short Name': {property: 'nameShort'}, + 'Color': {property: 'color'}, + 'Description': {property: 'description'}, + 'Footer Content': {property: 'footerContent'}, + 'Default Language': {property: 'defaultLanguage'}, + 'Canonical Base': {property: 'canonicalBase'}, - 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + + 'Wiki Wallpaper File Extension': {property: 'wikiWallpaperFileExtension'}, + + 'Wiki Wallpaper Style': {property: 'wikiWallpaperStyle'}, + + 'Wiki Wallpaper Parts': { + property: 'wikiWallpaperParts', + transform: parseWallpaperParts, + }, + 'Enable Flashes & Games': {property: 'enableFlashesAndGames'}, 'Enable Listings': {property: 'enableListings'}, 'Enable News': {property: 'enableNews'}, 'Enable Art Tag UI': {property: 'enableArtTagUI'}, 'Enable Group UI': {property: 'enableGroupUI'}, + 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + 'Contribution Presets': { property: 'contributionPresets', transform: parseContributionPresets, diff --git a/src/data/yaml.js b/src/data/yaml.js index 64223662..9a0295b8 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -8,11 +8,14 @@ import {inspect as nodeInspect} from 'node:util'; import yaml from 'js-yaml'; import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; +import {parseContentNodes, splitContentNodesAround} from '#replacer'; import {sortByName} from '#sort'; import Thing from '#thing'; import thingConstructors from '#things'; +import {matchContentEntries, multipleLyricsDetectionRegex} from '#wiki-data'; import { + aggregateThrows, annotateErrorWithFile, decorateErrorWithIndex, decorateErrorWithAnnotation, @@ -30,8 +33,10 @@ import { atOffset, empty, filterProperties, + getNestedProp, stitchArrays, typeAppearance, + unique, withEntries, } from '#sugar'; @@ -86,6 +91,10 @@ function makeProcessDocument(thingConstructor, { // A or B. // invalidFieldCombinations = [], + + // Bouncing function used to process subdocuments: this is a function which + // in turn calls the appropriate *result of* makeProcessDocument. + processDocument: bouncer, }) { if (!thingConstructor) { throw new Error(`Missing Thing class`); @@ -95,6 +104,10 @@ function makeProcessDocument(thingConstructor, { throw new Error(`Expected fields to be provided`); } + if (!bouncer) { + throw new Error(`Missing processDocument bouncer`); + } + const knownFields = Object.keys(fieldSpecs); const ignoredFields = @@ -142,9 +155,12 @@ function makeProcessDocument(thingConstructor, { : `document`); const aggregate = openAggregate({ + ...aggregateThrows(ProcessDocumentError), message: `Errors processing ${constructorPart}` + namePart, }); + const thing = Reflect.construct(thingConstructor, []); + const documentEntries = Object.entries(document) .filter(([field]) => !ignoredFields.includes(field)); @@ -192,13 +208,50 @@ function makeProcessDocument(thingConstructor, { const fieldValues = {}; + const subdocSymbol = Symbol('subdoc'); + const subdocLayouts = {}; + + const isSubdocToken = value => + typeof value === 'object' && + value !== null && + Object.hasOwn(value, subdocSymbol); + + const transformUtilities = { + ...thingConstructors, + + subdoc(documentType, data, { + bindInto = null, + provide = null, + } = {}) { + if (!documentType) + throw new Error(`Expected document type, got ${typeAppearance(documentType)}`); + if (!data) + throw new Error(`Expected data, got ${typeAppearance(data)}`); + if (typeof data !== 'object' || data === null) + throw new Error(`Expected data to be an object, got ${typeAppearance(data)}`); + if (typeof bindInto !== 'string' && bindInto !== null) + throw new Error(`Expected bindInto to be a string, got ${typeAppearance(bindInto)}`); + if (typeof provide !== 'object' && provide !== null) + throw new Error(`Expected provide to be an object, got ${typeAppearance(provide)}`); + + return { + [subdocSymbol]: { + documentType, + data, + bindInto, + provide, + }, + }; + }, + }; + for (const [field, documentValue] of documentEntries) { if (skippedFields.has(field)) continue; // This variable would like to certify itself as "not into capitalism". let propertyValue = (fieldSpecs[field].transform - ? fieldSpecs[field].transform(documentValue) + ? fieldSpecs[field].transform(documentValue, transformUtilities) : documentValue); // Completely blank items in a YAML list are read as null. @@ -221,10 +274,99 @@ function makeProcessDocument(thingConstructor, { } } + if (isSubdocToken(propertyValue)) { + subdocLayouts[field] = propertyValue[subdocSymbol]; + continue; + } + + if (Array.isArray(propertyValue) && propertyValue.every(isSubdocToken)) { + subdocLayouts[field] = + propertyValue + .map(token => token[subdocSymbol]); + continue; + } + fieldValues[field] = propertyValue; } - const thing = Reflect.construct(thingConstructor, []); + const subdocErrors = []; + + const followSubdocSetup = setup => { + let error = null; + + let subthing; + try { + const result = bouncer(setup.data, setup.documentType); + subthing = result.thing; + result.aggregate.close(); + } catch (caughtError) { + error = caughtError; + } + + if (subthing) { + if (setup.bindInto) { + subthing[setup.bindInto] = thing; + } + + if (setup.provide) { + Object.assign(subthing, setup.provide); + } + } + + return {error, subthing}; + }; + + for (const [field, layout] of Object.entries(subdocLayouts)) { + if (Array.isArray(layout)) { + const subthings = []; + let anySucceeded = false; + let anyFailed = false; + + for (const [index, setup] of layout.entries()) { + const {subthing, error} = followSubdocSetup(setup); + if (error) { + subdocErrors.push(new SubdocError( + {field, index}, + setup, + {cause: error})); + } + + if (subthing) { + subthings.push(subthing); + anySucceeded = true; + } else { + anyFailed = true; + } + } + + if (anySucceeded) { + fieldValues[field] = subthings; + } else if (anyFailed) { + skippedFields.add(field); + } + } else { + const setup = layout; + const {subthing, error} = followSubdocSetup(setup); + + if (error) { + subdocErrors.push(new SubdocError( + {field}, + setup, + {cause: error})); + } + + if (subthing) { + fieldValues[field] = subthing; + } else { + skippedFields.add(field); + } + } + } + + if (!empty(subdocErrors)) { + aggregate.push(new SubdocAggregateError( + subdocErrors, thingConstructor)); + } const fieldValueErrors = []; @@ -258,6 +400,8 @@ function makeProcessDocument(thingConstructor, { }); } +export class ProcessDocumentError extends AggregateError {} + export class UnknownFieldsError extends Error { constructor(fields) { super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`); @@ -345,12 +489,46 @@ export class SkippedFieldsSummaryError extends Error { : `${entries.length} fields`); super( - colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) + + colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:`)) + '\n' + lines.join('\n') + '\n' + colors.bright(colors.yellow(`See above errors for details.`))); } } +export class SubdocError extends Error { + constructor({field, index = null}, setup, options) { + const fieldText = + (index === null + ? colors.green(`"${field}"`) + : colors.yellow(`#${index + 1}`) + ' in ' + + colors.green(`"${field}"`)); + + const constructorText = + setup.documentType.name; + + if (options.cause instanceof ProcessDocumentError) { + options.cause[Symbol.for('hsmusic.aggregate.translucent')] = true; + } + + super( + `Errors processing ${constructorText} for ${fieldText} field`, + options); + } +} + +export class SubdocAggregateError extends AggregateError { + [Symbol.for('hsmusic.aggregate.translucent')] = true; + + constructor(errors, thingConstructor) { + const constructorText = + colors.green(thingConstructor.name); + + super( + errors, + `Errors processing subdocuments for ${constructorText}`); + } +} + export function parseDate(date) { return new Date(date); } @@ -433,49 +611,39 @@ export function parseContributors(entries) { }); } -export function parseAdditionalFiles(entries) { +export function parseAdditionalFiles(entries, {subdoc, AdditionalFile}) { return parseArrayEntries(entries, item => { if (typeof item !== 'object') return item; - return { - title: item['Title'], - description: item['Description'] ?? null, - files: item['Files'], - }; + return subdoc(AdditionalFile, item, {bindInto: 'thing'}); }); } -export function parseAdditionalNames(entries) { +export function parseAdditionalNames(entries, {subdoc, AdditionalName}) { return parseArrayEntries(entries, item => { - if (typeof item === 'object' && typeof item['Name'] === 'string') - return { - name: item['Name'], - annotation: item['Annotation'] ?? null, - }; + if (typeof item === 'object') { + return subdoc(AdditionalName, item, {bindInto: 'thing'}); + } if (typeof item !== 'string') return item; const match = item.match(extractAccentRegex); if (!match) return item; - return { - name: match.groups.main, - annotation: match.groups.accent ?? null, + const document = { + ['Name']: match.groups.main, + ['Annotation']: match.groups.accent ?? null, }; + + return subdoc(AdditionalName, document, {bindInto: 'thing'}); }); } -export function parseSerieses(entries) { +export function parseSerieses(entries, {subdoc, Series}) { return parseArrayEntries(entries, item => { if (typeof item !== 'object') return item; - return { - name: item['Name'], - description: item['Description'] ?? null, - albums: item['Albums'] ?? null, - - showAlbumArtists: item['Show Album Artists'] ?? null, - }; + return subdoc(Series, item, {bindInto: 'group'}); }); } @@ -613,6 +781,172 @@ export function parseAnnotatedReferences(entries, { }); } +export function parseArtwork({ + single = false, + thingProperty = null, + dimensionsFromThingProperty = null, + fileExtensionFromThingProperty = null, + dateFromThingProperty = null, + artistContribsFromThingProperty = null, + artistContribsArtistProperty = null, + artTagsFromThingProperty = null, + referencedArtworksFromThingProperty = null, +}) { + const provide = { + thingProperty, + dimensionsFromThingProperty, + fileExtensionFromThingProperty, + dateFromThingProperty, + artistContribsFromThingProperty, + artistContribsArtistProperty, + artTagsFromThingProperty, + referencedArtworksFromThingProperty, + }; + + const parseSingleEntry = (entry, {subdoc, Artwork}) => + subdoc(Artwork, entry, {bindInto: 'thing', provide}); + + const transform = (value, ...args) => + (Array.isArray(value) + ? value.map(entry => parseSingleEntry(entry, ...args)) + : single + ? parseSingleEntry(value, ...args) + : [parseSingleEntry(value, ...args)]); + + transform.provide = provide; + + return transform; +} + +export function parseContentEntriesFromSourceText(thingClass, sourceText, {subdoc}) { + function map(matchEntry) { + let artistText = null, artistReferences = null; + + const artistTextNodes = + Array.from( + splitContentNodesAround( + parseContentNodes(matchEntry.artistText), + /\|/g)); + + const separatorIndices = + artistTextNodes + .filter(node => node.type === 'separator') + .map(node => artistTextNodes.indexOf(node)); + + if (empty(separatorIndices)) { + if (artistTextNodes.length === 1 && artistTextNodes[0].type === 'text') { + artistReferences = matchEntry.artistText; + } else { + artistText = matchEntry.artistText; + } + } else { + const firstSeparatorIndex = + separatorIndices.at(0); + + const secondSeparatorIndex = + separatorIndices.at(1) ?? + artistTextNodes.length; + + artistReferences = + matchEntry.artistText.slice( + artistTextNodes.at(0).i, + artistTextNodes.at(firstSeparatorIndex - 1).iEnd); + + artistText = + matchEntry.artistText.slice( + artistTextNodes.at(firstSeparatorIndex).iEnd, + artistTextNodes.at(secondSeparatorIndex - 1).iEnd); + } + + if (artistReferences) { + artistReferences = + artistReferences + .split(',') + .map(ref => ref.trim()); + } + + return { + 'Artists': + artistReferences, + + 'Artist Text': + artistText, + + 'Annotation': + matchEntry.annotation, + + 'Date': + matchEntry.date, + + 'Second Date': + matchEntry.secondDate, + + 'Date Kind': + matchEntry.dateKind, + + 'Access Date': + matchEntry.accessDate, + + 'Access Kind': + matchEntry.accessKind, + + 'Body': + matchEntry.body, + }; + } + + const documents = + matchContentEntries(sourceText) + .map(matchEntry => + withEntries( + map(matchEntry), + entries => entries + .filter(([key, value]) => + value !== undefined && + value !== null))); + + const subdocs = + documents.map(document => + subdoc(thingClass, document, {bindInto: 'thing'})); + + return subdocs; +} + +export function parseContentEntries(thingClass, value, {subdoc}) { + if (typeof value === 'string') { + return parseContentEntriesFromSourceText(thingClass, value, {subdoc}); + } else if (Array.isArray(value)) { + return value.map(doc => subdoc(thingClass, doc, {bindInto: 'thing'})); + } else { + return value; + } +} + +export function parseCommentary(value, {subdoc, CommentaryEntry}) { + return parseContentEntries(CommentaryEntry, value, {subdoc}); +} + +export function parseCreditingSources(value, {subdoc, CreditingSourcesEntry}) { + return parseContentEntries(CreditingSourcesEntry, value, {subdoc}); +} + +export function parseReferencingSources(value, {subdoc, ReferencingSourcesEntry}) { + return parseContentEntries(ReferencingSourcesEntry, value, {subdoc}); +} + +export function parseLyrics(value, {subdoc, LyricsEntry}) { + if ( + typeof value === 'string' && + !multipleLyricsDetectionRegex.test(value) + ) { + const document = {'Body': value}; + + return [subdoc(LyricsEntry, document, {bindInto: 'thing'})]; + } + + return parseContentEntries(LyricsEntry, value, {subdoc}); +} + // documentModes: Symbols indicating sets of behavior for loading and processing // data files. export const documentModes = { @@ -688,16 +1022,23 @@ export const documentModes = { export function getAllDataSteps() { try { thingConstructors; - } catch (error) { + } catch { throw new Error(`Thing constructors aren't ready yet, can't get all data steps`); } const steps = []; + const seenLoadingFns = new Set(); + for (const thingConstructor of Object.values(thingConstructors)) { const getSpecFn = thingConstructor[Thing.getYamlLoadingSpec]; if (!getSpecFn) continue; + // Subclasses can expose literally the same static properties + // by inheritence. We don't want to double-count those! + if (seenLoadingFns.has(getSpecFn)) continue; + seenLoadingFns.add(getSpecFn); + steps.push(getSpecFn({ documentModes, thingConstructors, @@ -890,7 +1231,7 @@ export function processThingsFromDataStep(documents, dataStep) { throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`); } - fn = makeProcessDocument(thingClass, spec); + fn = makeProcessDocument(thingClass, {...spec, processDocument}); submap.set(thingClass, fn); } @@ -905,15 +1246,23 @@ export function processThingsFromDataStep(documents, dataStep) { const aggregate = openAggregate({message: `Errors processing documents`}); documents.forEach( - decorateErrorWithIndex(document => { + decorateErrorWithIndex((document, index) => { const {thing, aggregate: subAggregate} = processDocument(document, dataStep.documentThing); + thing[Thing.yamlSourceDocument] = document; + thing[Thing.yamlSourceDocumentPlacement] = + [documentModes.allInOne, index]; + result.push(thing); aggregate.call(subAggregate.close); })); - return {aggregate, result}; + return { + aggregate, + result, + things: result, + }; } case documentModes.oneDocumentTotal: { @@ -923,7 +1272,15 @@ export function processThingsFromDataStep(documents, dataStep) { const {thing, aggregate} = processDocument(documents[0], dataStep.documentThing); - return {aggregate, result: thing}; + thing[Thing.yamlSourceDocument] = documents[0]; + thing[Thing.yamlSourceDocumentPlacement] = + [documentModes.oneDocumentTotal]; + + return { + aggregate, + result: thing, + things: [thing], + }; } case documentModes.headerAndEntries: { @@ -938,6 +1295,10 @@ export function processThingsFromDataStep(documents, dataStep) { const {thing: headerThing, aggregate: headerAggregate} = processDocument(headerDocument, dataStep.headerDocumentThing); + headerThing[Thing.yamlSourceDocument] = headerDocument; + headerThing[Thing.yamlSourceDocumentPlacement] = + [documentModes.headerAndEntries, 'header']; + try { headerAggregate.close(); } catch (caughtError) { @@ -951,6 +1312,10 @@ export function processThingsFromDataStep(documents, dataStep) { const {thing: entryThing, aggregate: entryAggregate} = processDocument(entryDocument, dataStep.entryDocumentThing); + entryThing[Thing.yamlSourceDocument] = entryDocument; + entryThing[Thing.yamlSourceDocumentPlacement] = + [documentModes.headerAndEntries, 'entry', index]; + entryThings.push(entryThing); try { @@ -967,6 +1332,7 @@ export function processThingsFromDataStep(documents, dataStep) { header: headerThing, entries: entryThings, }, + things: [headerThing, ...entryThings], }; } @@ -980,7 +1346,15 @@ export function processThingsFromDataStep(documents, dataStep) { const {thing, aggregate} = processDocument(documents[0], dataStep.documentThing); - return {aggregate, result: thing}; + thing[Thing.yamlSourceDocument] = documents[0]; + thing[Thing.yamlSourceDocumentPlacement] = + [documentModes.onePerFile]; + + return { + aggregate, + result: thing, + things: [thing], + }; } default: @@ -1080,9 +1454,16 @@ export async function processThingsFromDataSteps(documentLists, fileLists, dataS file: files, documents: documentLists, }).map(({file, documents}) => { - const {result, aggregate} = + const {result, aggregate, things} = processThingsFromDataStep(documents, dataStep); + for (const thing of things) { + thing[Thing.yamlSourceFilename] = + path.relative(dataPath, file) + .split(path.sep) + .join(path.posix.sep); + } + const close = decorateErrorWithFileFromDataPath(aggregate.close, {dataPath}); aggregate.close = () => close({file}); @@ -1225,93 +1606,107 @@ export async function loadAndProcessDataDocuments(dataSteps, {dataPath}) { // Data linking! Basically, provide (portions of) wikiData to the Things which // require it - they'll expose dynamically computed properties as a result (many // of which are required for page HTML generation and other expected behavior). -export function linkWikiDataArrays(wikiData) { +export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) { const linkWikiDataSpec = new Map([ - [wikiData.albumData, [ - 'albumData', - 'artTagData', - 'artistData', - 'groupData', - 'trackData', + // entries must be present here even without any properties to explicitly + // link if the 'find' or 'reverse' properties will be implicitly linked + + ['albumData', [ + 'artworkData', 'wikiInfo', ]], - [wikiData.artTagData, [ - 'albumData', - 'trackData', - ]], + ['artTagData', [/* reverse */]], - [wikiData.artistData, [ - 'albumData', - 'artistData', - 'flashData', - 'groupData', - 'trackData', - ]], + ['artistData', [/* find, reverse */]], - [wikiData.flashData, [ - 'artistData', - 'flashActData', - 'trackData', + ['artworkData', ['artworkData']], + + ['commentaryData', [/* find */]], + + ['creditingSourceData', [/* find */]], + + ['flashData', [ 'wikiInfo', ]], - [wikiData.flashActData, [ - 'flashData', - 'flashSideData', - ]], + ['flashActData', [/* find, reverse */]], - [wikiData.flashSideData, [ - 'flashActData', - ]], + ['flashSideData', [/* find */]], - [wikiData.groupData, [ - 'albumData', - 'artistData', - 'groupCategoryData', - ]], + ['groupData', [/* find, reverse */]], - [wikiData.groupCategoryData, [ - 'groupData', - ]], + ['groupCategoryData', [/* find */]], - [wikiData.homepageLayout?.rows, [ - 'albumData', - 'groupData', - ]], + ['homepageLayout.sections.rows', [/* find */]], + + ['lyricsData', [/* find */]], - [wikiData.trackData, [ - 'albumData', - 'artTagData', - 'artistData', - 'flashData', + ['referencingSourceData', [/* find */]], + + ['seriesData', [/* find */]], + + ['trackData', [ + 'artworkData', 'trackData', - 'trackSectionData', 'wikiInfo', ]], - [wikiData.trackSectionData, [ - 'albumData', - ]], + ['trackSectionData', [/* reverse */]], - [[wikiData.wikiInfo], [ - 'groupData', - ]], + ['wikiInfo', [/* find */]], ]); - for (const [things, keys] of linkWikiDataSpec.entries()) { - if (things === undefined) continue; + const constructorHasFindMap = new Map(); + const constructorHasReverseMap = new Map(); + + const boundFind = bindFind(wikiData); + const boundReverse = bindReverse(wikiData); + + for (const [thingDataProp, keys] of linkWikiDataSpec.entries()) { + const thingData = getNestedProp(wikiData, thingDataProp); + const things = + (Array.isArray(thingData) + ? thingData.flat(Infinity) + : [thingData]); + for (const thing of things) { if (thing === undefined) continue; + + let hasFind; + if (constructorHasFindMap.has(thing.constructor)) { + hasFind = constructorHasFindMap.get(thing.constructor); + } else { + hasFind = 'find' in thing; + constructorHasFindMap.set(thing.constructor, hasFind); + } + + if (hasFind) { + thing.find = boundFind; + } + + let hasReverse; + if (constructorHasReverseMap.has(thing.constructor)) { + hasReverse = constructorHasReverseMap.get(thing.constructor); + } else { + hasReverse = 'reverse' in thing; + constructorHasReverseMap.set(thing.constructor, hasReverse); + } + + if (hasReverse) { + thing.reverse = boundReverse; + } + for (const key of keys) { if (!(key in wikiData)) continue; + thing[key] = wikiData[key]; } } } } -export function sortWikiDataArrays(dataSteps, wikiData) { +export function sortWikiDataArrays(dataSteps, wikiData, {bindFind, bindReverse}) { for (const [key, value] of Object.entries(wikiData)) { if (!Array.isArray(value)) continue; wikiData[key] = value.slice(); @@ -1327,7 +1722,7 @@ export function sortWikiDataArrays(dataSteps, wikiData) { // slices instead of the original arrays) - this is so that the object // caching system understands that it's working with a new ordering. // We still need to actually provide those updated arrays over again! - linkWikiDataArrays(wikiData); + linkWikiDataArrays(wikiData, {bindFind, bindReverse}); } // Utility function for loading all wiki data from the provided YAML data @@ -1339,6 +1734,7 @@ export function sortWikiDataArrays(dataSteps, wikiData) { export async function quickLoadAllFromYAML(dataPath, { find, bindFind, + bindReverse, getAllFindSpecs, showAggregate: customShowAggregate = showAggregate, @@ -1363,7 +1759,7 @@ export async function quickLoadAllFromYAML(dataPath, { } } - linkWikiDataArrays(wikiData); + linkWikiDataArrays(wikiData, {bindFind, bindReverse}); try { reportDirectoryErrors(wikiData, {getAllFindSpecs}); @@ -1389,7 +1785,203 @@ export async function quickLoadAllFromYAML(dataPath, { logWarn`Content text errors found.`; } - sortWikiDataArrays(dataSteps, wikiData); + sortWikiDataArrays(dataSteps, wikiData, {bindFind, bindReverse}); return wikiData; } + +export function cruddilyGetAllThings(wikiData) { + const allThings = []; + + for (const v of Object.values(wikiData)) { + if (Array.isArray(v)) { + allThings.push(...v); + } else { + allThings.push(v); + } + } + + return allThings; +} + +export function getThingLayoutForFilename(filename, wikiData) { + const things = + cruddilyGetAllThings(wikiData) + .filter(thing => + thing[Thing.yamlSourceFilename] === filename); + + if (empty(things)) { + return null; + } + + const allDocumentModes = + unique(things.map(thing => + thing[Thing.yamlSourceDocumentPlacement][0])); + + if (allDocumentModes.length > 1) { + throw new Error(`More than one document mode for documents from ${filename}`); + } + + const documentMode = allDocumentModes[0]; + + switch (documentMode) { + case documentModes.allInOne: { + return { + documentMode, + things: + things.sort((a, b) => + a[Thing.yamlSourceDocumentPlacement][1] - + b[Thing.yamlSourceDocumentPlacement][1]), + }; + } + + case documentModes.oneDocumentTotal: + case documentModes.onePerFile: { + if (things.length > 1) { + throw new Error(`More than one document for ${filename}`); + } + + return { + documentMode, + thing: things[0], + }; + } + + case documentModes.headerAndEntries: { + const headerThings = + things.filter(thing => + thing[Thing.yamlSourceDocumentPlacement][1] === 'header'); + + if (headerThings.length > 1) { + throw new Error(`More than one header document for ${filename}`); + } + + return { + documentMode, + headerThing: headerThings[0] ?? null, + entryThings: + things + .filter(thing => + thing[Thing.yamlSourceDocumentPlacement][1] === 'entry') + .sort((a, b) => + a[Thing.yamlSourceDocumentPlacement][2] - + b[Thing.yamlSourceDocumentPlacement][2]), + }; + } + + default: { + return {documentMode}; + } + } +} + +export function flattenThingLayoutToDocumentOrder(layout) { + switch (layout.documentMode) { + case documentModes.oneDocumentTotal: + case documentModes.onePerFile: { + if (layout.thing) { + return [0]; + } else { + return []; + } + } + + case documentModes.allInOne: { + const indices = + layout.things + .map(thing => thing[Thing.yamlSourceDocumentPlacement][1]); + + return indices; + } + + case documentModes.headerAndEntries: { + const entryIndices = + layout.entryThings + .map(thing => thing[Thing.yamlSourceDocumentPlacement][2]) + .map(index => index + 1); + + if (layout.headerThing) { + return [0, ...entryIndices]; + } else { + return entryIndices; + } + } + + default: { + throw new Error(`Unknown document mode`); + } + } +} + +export function* splitDocumentsInYAMLSourceText(sourceText) { + // Not multiline! + const dividerRegex = /(?:\r\n|\n|^)-{3,}(?:\r\n|\n|$)/g; + + let previousDivider = ''; + + while (true) { + const {lastIndex} = dividerRegex; + const match = dividerRegex.exec(sourceText); + if (match) { + const nextDivider = match[0]; + + yield { + previousDivider, + nextDivider, + text: sourceText.slice(lastIndex, match.index), + }; + + previousDivider = nextDivider; + } else { + const nextDivider = ''; + const lineBreak = previousDivider.match(/\r?\n/)?.[0] ?? ''; + + yield { + previousDivider, + nextDivider, + text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, lineBreak), + }; + + return; + } + } +} + +export function recombineDocumentsIntoYAMLSourceText(documents) { + const dividers = + unique( + documents + .flatMap(d => [d.previousDivider, d.nextDivider]) + .filter(Boolean)); + + const divider = dividers[0]; + + if (dividers.length > 1) { + // TODO: Accommodate mixed dividers as best as we can lol + logWarn`Found multiple dividers in this file, using only ${divider}`; + } + + let sourceText = ''; + + for (const document of documents) { + if (sourceText) { + sourceText += divider; + } + + sourceText += document.text; + } + + return sourceText; +} + +export function reorderDocumentsInYAMLSourceText(sourceText, order) { + const sourceDocuments = + Array.from(splitDocumentsInYAMLSourceText(sourceText)); + + const sortedDocuments = + Array.from( + order, + sourceIndex => sourceDocuments[sourceIndex]); + + return recombineDocumentsIntoYAMLSourceText(sortedDocuments); +} diff --git a/src/util/external-links.js b/src/external-links.js index 43c09265..ab1555bd 100644 --- a/src/util/external-links.js +++ b/src/external-links.js @@ -4,11 +4,9 @@ import { anyOf, is, isBoolean, - isObject, isStringNonEmpty, looseArrayOf, optional, - validateAllPropertyValues, validateArrayItems, validateInstanceOf, validateProperties, @@ -32,6 +30,9 @@ export const externalLinkContexts = [ 'generic', 'group', 'track', + + 'composerRelease', + 'officialRelease', ]; export const isExternalLinkContext = @@ -257,6 +258,30 @@ export const externalLinkSpec = [ }, { + match: { + domain: '.bandcamp.com', + context: 'composerRelease', + }, + + platform: 'bandcamp.composerRelease', + handle: {domain: /^[^.]+/}, + + icon: 'bandcamp', + }, + + { + match: { + domain: '.bandcamp.com', + context: 'officialRelease', + }, + + platform: 'bandcamp.officialRelease', + handle: {domain: /^[^.]+/}, + + icon: 'bandcamp', + }, + + { match: {domain: '.bandcamp.com'}, platform: 'bandcamp', @@ -417,6 +442,17 @@ export const externalLinkSpec = [ }, { + match: { + domain: 'media.hsmusic.wiki', + pathname: /^misc\/archive/, + }, + + platform: 'hsmusic.archive', + + icon: 'globe', + }, + + { match: {domain: 'hsmusic.wiki'}, platform: 'hsmusic', icon: 'globe', diff --git a/src/file-size-preloader.js b/src/file-size-preloader.js index 4eadde7b..b2a55407 100644 --- a/src/file-size-preloader.js +++ b/src/file-size-preloader.js @@ -18,8 +18,10 @@ // are very, very fast. import {stat} from 'node:fs/promises'; +import {relative, resolve, sep} from 'node:path'; import {logWarn} from '#cli'; +import {filterMultipleArrays, transposeArrays} from '#sugar'; export default class FileSizePreloader { #paths = []; @@ -31,6 +33,10 @@ export default class FileSizePreloader { hadErrored = false; + constructor({prefix = ''} = {}) { + this.prefix = prefix; + } + loadPaths(...paths) { this.#paths.push(...paths.filter((p) => !this.#paths.includes(p))); return this.#startLoadingPaths(); @@ -45,9 +51,9 @@ export default class FileSizePreloader { return this.#loadingPromise; } - this.#loadingPromise = new Promise((resolve) => { - this.#resolveLoadingPromise = resolve; - }); + ({promise: this.#loadingPromise, + resolve: this.#resolveLoadingPromise} = + Promise.withResolvers()); this.#loadNextPath(); @@ -96,9 +102,54 @@ export default class FileSizePreloader { } getSizeOfPath(path) { + let size = this.#getSizeOfPath(path); + if (size || !this.prefix) return size; + const path2 = resolve(this.prefix, path); + if (path2 === path) return null; + return this.#getSizeOfPath(path2); + } + + #getSizeOfPath(path) { const index = this.#paths.indexOf(path); if (index === -1) return null; if (index > this.#loadedPathIndex) return null; return this.#sizes[index]; } + + saveAsCache() { + const entries = + transposeArrays([ + this.#paths.slice(0, this.#loadedPathIndex) + .map(path => relative(this.prefix, path)), + + this.#sizes.slice(0, this.#loadedPathIndex), + ]); + + // Do not be alarmed: This cannot be meaningfully moved to + // the top because stringifyCache sorts alphabetically lol + entries.push(['_separator', sep]); + + return Object.fromEntries(entries); + } + + loadFromCache(cache) { + const {_separator: cacheSep, ...rest} = cache; + const entries = Object.entries(rest); + let [newPaths, newSizes] = transposeArrays(entries); + + if (sep !== cacheSep) { + newPaths = newPaths.map(p => p.split(cacheSep).join(sep)); + } + + newPaths = newPaths.map(p => resolve(this.prefix, p)); + + filterMultipleArrays( + newPaths, + newSizes, + path => !this.#paths.includes(path)); + + this.#paths.splice(this.#loadedPathIndex + 1, 0, ...newPaths); + this.#sizes.splice(this.#loadedPathIndex + 1, 0, ...newSizes); + this.#loadedPathIndex += entries.length; + } } diff --git a/src/find-reverse.js b/src/find-reverse.js new file mode 100644 index 00000000..c99a4a71 --- /dev/null +++ b/src/find-reverse.js @@ -0,0 +1,144 @@ +// Helpers common to #find and #reverse logic. + +import thingConstructors from '#things'; + +export function getAllSpecs({ + word, + constructorKey, + + hardcodedSpecs, + postprocessSpec, +}) { + try { + thingConstructors; + } catch { + throw new Error(`Thing constructors aren't ready yet, can't get all ${word} specs`); + } + + const specs = {...hardcodedSpecs}; + + const seenSpecs = new Set(); + + for (const thingConstructor of Object.values(thingConstructors)) { + const thingSpecs = thingConstructor[constructorKey]; + if (!thingSpecs) continue; + + // Subclasses can expose literally the same static properties + // by inheritence. We don't want to double-count those! + if (seenSpecs.has(thingSpecs)) continue; + seenSpecs.add(thingSpecs); + + for (const [key, spec] of Object.entries(thingSpecs)) { + specs[key] = + postprocessSpec(spec, { + thingConstructor, + }); + } + } + + return specs; +} + +export function findSpec(key, { + word, + constructorKey, + + hardcodedSpecs, + postprocessSpec, +}) { + if (Object.hasOwn(hardcodedSpecs, key)) { + return hardcodedSpecs[key]; + } + + try { + thingConstructors; + } catch { + throw new Error(`Thing constructors aren't ready yet, can't check if "${word}.${key}" available`); + } + + for (const thingConstructor of Object.values(thingConstructors)) { + const thingSpecs = thingConstructor[constructorKey]; + if (!thingSpecs) continue; + + if (Object.hasOwn(thingSpecs, key)) { + return postprocessSpec(thingSpecs[key], { + thingConstructor, + }); + } + } + + throw new Error(`"${word}.${key}" isn't available`); +} + +export function tokenProxy({ + findSpec, + prepareBehavior, + + handle: customHandle = + (_key) => undefined, +}) { + return new Proxy({}, { + get: (store, key) => { + const custom = customHandle(key); + if (custom !== undefined) { + return custom; + } + + if (!Object.hasOwn(store, key)) { + let behavior = (...args) => { + // This will error if the spec isn't available... + const spec = findSpec(key); + + // ...or, if it is available, replace this function with the + // ready-for-use find function made out of that spec. + return (behavior = prepareBehavior(spec))(...args); + }; + + store[key] = (...args) => behavior(...args); + store[key][tokenKey] = key; + } + + return store[key]; + }, + }); +} + +export function bind(wikiData, opts1, { + getAllSpecs, + prepareBehavior, +}) { + const specs = getAllSpecs(); + + const bound = {}; + + for (const [key, spec] of Object.entries(specs)) { + if (!spec.bindTo) continue; + + const behavior = prepareBehavior(spec); + + const data = + (spec.bindTo === 'wikiData' + ? wikiData + : wikiData[spec.bindTo]); + + bound[key] = + (opts1 + ? (ref, opts2) => + (opts2 + ? behavior(ref, data, {...opts1, ...opts2}) + : behavior(ref, data, opts1)) + : (ref, opts2) => + (opts2 + ? behavior(ref, data, opts2) + : behavior(ref, data))); + + bound[key][boundData] = data; + bound[key][boundOptions] = opts1 ?? {}; + } + + return bound; +} + +export const tokenKey = Symbol.for('find.tokenKey'); +export const boundData = Symbol.for('find.boundData'); +export const boundOptions = Symbol.for('find.boundOptions'); diff --git a/src/find.js b/src/find.js index d647419a..8f2170d4 100644 --- a/src/find.js +++ b/src/find.js @@ -5,6 +5,16 @@ import {compareObjects, stitchArrays, typeAppearance} from '#sugar'; import thingConstructors from '#things'; import {isFunction, validateArrayItems} from '#validators'; +import * as fr from './find-reverse.js'; + +import { + tokenKey as findTokenKey, + boundData as boundFindData, + boundOptions as boundFindOptions, +} from './find-reverse.js'; + +export {findTokenKey, boundFindData, boundFindOptions}; + function warnOrThrow(mode, message) { if (mode === 'error') { throw new Error(message); @@ -24,7 +34,7 @@ export function processAvailableMatchesByName(data, { include = _thing => true, getMatchableNames = thing => - (Object.hasOwn(thing, 'name') + (thing.constructor.hasPropertyDescriptor('name') ? [thing.name] : []), @@ -32,7 +42,7 @@ export function processAvailableMatchesByName(data, { multipleNameMatches = Object.create(null), }) { for (const thing of data) { - if (!include(thing)) continue; + if (!include(thing, thingConstructors)) continue; for (const name of getMatchableNames(thing)) { if (typeof name !== 'string') { @@ -46,11 +56,14 @@ export function processAvailableMatchesByName(data, { if (normalizedName in multipleNameMatches) { multipleNameMatches[normalizedName].push(thing); } else { - multipleNameMatches[normalizedName] = [results[normalizedName], thing]; + multipleNameMatches[normalizedName] = [ + results[normalizedName].thing, + thing, + ]; results[normalizedName] = null; } } else { - results[normalizedName] = thing; + results[normalizedName] = {thing, name}; } } } @@ -62,14 +75,14 @@ export function processAvailableMatchesByDirectory(data, { include = _thing => true, getMatchableDirectories = thing => - (Object.hasOwn(thing, 'directory') + (thing.constructor.hasPropertyDescriptor('directory') ? [thing.directory] : [null]), results = Object.create(null), }) { for (const thing of data) { - if (!include(thing)) continue; + if (!include(thing, thingConstructors)) continue; for (const directory of getMatchableDirectories(thing)) { if (typeof directory !== 'string') { @@ -77,7 +90,7 @@ export function processAvailableMatchesByDirectory(data, { continue; } - results[directory] = thing; + results[directory] = {thing, directory}; } } @@ -107,13 +120,50 @@ function oopsMultipleNameMatches(mode, { `Returning null for this reference.`); } +function oopsNameCapitalizationMismatch(mode, { + matchingName, + matchedName, +}) { + if (matchingName.length === matchedName.length) { + let a = '', b = ''; + for (let i = 0; i < matchingName.length; i++) { + if ( + matchingName[i] === matchedName[i] || + matchingName[i].toLowerCase() !== matchingName[i].toLowerCase() + ) { + a += matchingName[i]; + b += matchedName[i]; + } else { + a += colors.bright(colors.red(matchingName[i])); + b += colors.bright(colors.green(matchedName[i])); + } + } + + matchingName = a; + matchedName = b; + } + + return warnOrThrow(mode, + `Provided capitalization differs from the matched name. Please resolve:\n` + + `- provided: ${matchingName}\n` + + `- should be: ${matchedName}\n` + + `Returning null for this reference.`); +} + export function prepareMatchByName(mode, {byName, multipleNameMatches}) { return (name) => { const normalizedName = name.toLowerCase(); const match = byName[normalizedName]; if (match) { - return match; + if (name === match.name) { + return match.thing; + } else { + return oopsNameCapitalizationMismatch(mode, { + matchingName: name, + matchedName: match.name, + }); + } } else if (multipleNameMatches[normalizedName]) { return oopsMultipleNameMatches(mode, { name, @@ -144,7 +194,13 @@ export function prepareMatchByDirectory(mode, {referenceTypes, byDirectory}) { }); } - return byDirectory[directory]; + const match = byDirectory[directory]; + + if (match) { + return match.thing; + } else { + return null; + } }; } @@ -240,6 +296,14 @@ const hardcodedFindSpecs = { }, }; +const findReverseHelperConfig = { + word: `find`, + constructorKey: Symbol.for('Thing.findSpecs'), + + hardcodedSpecs: hardcodedFindSpecs, + postprocessSpec: postprocessFindSpec, +}; + export function postprocessFindSpec(spec, {thingConstructor}) { const newSpec = {...spec}; @@ -248,9 +312,9 @@ export function postprocessFindSpec(spec, {thingConstructor}) { if (spec[Symbol.for('Thing.findThisThingOnly')] !== false) { if (spec.include) { const oldInclude = spec.include; - newSpec.include = thing => + newSpec.include = (thing, ...args) => thing instanceof thingConstructor && - oldInclude(thing); + oldInclude(thing, ...args); } else { newSpec.include = thing => thing instanceof thingConstructor; @@ -261,58 +325,13 @@ export function postprocessFindSpec(spec, {thingConstructor}) { } export function getAllFindSpecs() { - try { - thingConstructors; - } catch (error) { - throw new Error(`Thing constructors aren't ready yet, can't get all find specs`); - } - - const findSpecs = {...hardcodedFindSpecs}; - - for (const thingConstructor of Object.values(thingConstructors)) { - const thingFindSpecs = thingConstructor[Symbol.for('Thing.findSpecs')]; - if (!thingFindSpecs) continue; - - for (const [key, spec] of Object.entries(thingFindSpecs)) { - findSpecs[key] = - postprocessFindSpec(spec, { - thingConstructor, - }); - } - } - - return findSpecs; + return fr.getAllSpecs(findReverseHelperConfig); } export function findFindSpec(key) { - if (Object.hasOwn(hardcodedFindSpecs, key)) { - return hardcodedFindSpecs[key]; - } - - try { - thingConstructors; - } catch (error) { - throw new Error(`Thing constructors aren't ready yet, can't check if "find.${key}" available`); - } - - for (const thingConstructor of Object.values(thingConstructors)) { - const thingFindSpecs = thingConstructor[Symbol.for('Thing.findSpecs')]; - if (!thingFindSpecs) continue; - - if (Object.hasOwn(thingFindSpecs, key)) { - return postprocessFindSpec(thingFindSpecs[key], { - thingConstructor, - }); - } - } - - throw new Error(`"find.${key}" isn't available`); + return fr.findSpec(key, findReverseHelperConfig); } -export const findTokenKey = Symbol.for('find.findTokenKey'); -export const boundFindData = Symbol.for('find.boundFindData'); -export const boundFindOptions = Symbol.for('find.boundFindOptions'); - function findMixedHelper(config) { const keys = Object.keys(config), @@ -372,7 +391,13 @@ function findMixedHelper(config) { }); } - return byDirectory[referenceType][directory]; + const match = byDirectory[referenceType][directory]; + + if (match) { + return match.thing; + } else { + return null; + } }, matchByName: @@ -425,27 +450,14 @@ export function findMixed(config) { return findMixedStore.get(config); } -export default new Proxy({}, { - get: (store, key) => { +export default fr.tokenProxy({ + findSpec: findFindSpec, + prepareBehavior: findHelper, + + handle(key) { if (key === 'mixed') { return findMixed; } - - if (!Object.hasOwn(store, key)) { - let behavior = (...args) => { - // This will error if the find spec isn't available... - const findSpec = findFindSpec(key); - - // ...or, if it is available, replace this function with the - // ready-for-use find function made out of that find spec. - return (behavior = findHelper(findSpec))(...args); - }; - - store[key] = (...args) => behavior(...args); - store[key][findTokenKey] = key; - } - - return store[key]; }, }); @@ -454,33 +466,13 @@ export default new Proxy({}, { // function. Note that this caches the arrays read from wikiData right when it's // called, so if their values change, you'll have to continue with a fresh call // to bindFind. -export function bindFind(wikiData, opts1) { - const findSpecs = getAllFindSpecs(); - - const boundFindFns = {}; - - for (const [key, spec] of Object.entries(findSpecs)) { - if (!spec.bindTo) continue; - - const findFn = findHelper(spec); - const thingData = wikiData[spec.bindTo]; - - boundFindFns[key] = - (opts1 - ? (ref, opts2) => - (opts2 - ? findFn(ref, thingData, {...opts1, ...opts2}) - : findFn(ref, thingData, opts1)) - : (ref, opts2) => - (opts2 - ? findFn(ref, thingData, opts2) - : findFn(ref, thingData))); - - boundFindFns[key][boundFindData] = thingData; - boundFindFns[key][boundFindOptions] = opts1 ?? {}; - } +export function bindFind(wikiData, opts) { + const boundFind = fr.bind(wikiData, opts, { + getAllSpecs: getAllFindSpecs, + prepareBehavior: findHelper, + }); - boundFindFns.mixed = findMixed; + boundFind.mixed = findMixed; - return boundFindFns; + return boundFind; } diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 6c82761f..40505189 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -162,7 +162,7 @@ import { import dimensionsOf from 'image-size'; -import CacheableObject from '#cacheable-object'; +import {stringifyCache} from '#cli'; import {commandExists, isMain, promisifyProcess, traverse} from '#node-utils'; import {sortByName} from '#sort'; @@ -346,28 +346,6 @@ export function getThumbnailsAvailableForDimensions([width, height]) { ]; } -function stringifyCache(cache) { - if (Object.keys(cache).length === 0) { - return `{}`; - } - - const entries = Object.entries(cache); - sortByName(entries, {getName: entry => entry[0]}); - - return [ - `{`, - entries - .map(([key, value]) => [JSON.stringify(key), JSON.stringify(value)]) - .map(([key, value]) => `${key}: ${value}`) - .map((line, index, array) => - (index < array.length - 1 - ? `${line},` - : line)) - .map(line => ` ${line}`), - `}`, - ].flat().join('\n'); -} - getThumbnailsAvailableForDimensions.all = Object.entries(thumbnailSpec) .map(([name, {size}]) => [name, size]) @@ -468,7 +446,7 @@ async function getImageMagickVersion(binary) { try { await promisifyProcess(proc, false); - } catch (error) { + } catch { return null; } @@ -577,42 +555,56 @@ async function determineThumbtacksNeededForFile({ return mismatchedWithinRightSize; } -async function generateImageThumbnail(imagePath, thumbtack, { +// Write all requested thumbtacks for a source image in one pass +// This saves a lot of disk reads which are probably the main bottleneck +function prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks) { + const args = [filePathInMedia, '-strip']; + + const basename = + path.basename(filePathInMedia, path.extname(filePathInMedia)); + + // do larger sizes first + thumbtacks.sort((a, b) => thumbnailSpec[b].size - thumbnailSpec[a].size); + + for (const tack of thumbtacks) { + const {size, quality} = thumbnailSpec[tack]; + const filename = `${basename}.${tack}.jpg`; + const filePathInCache = path.join(dirnameInCache, filename); + args.push( + '(', '+clone', + '-resize', `${size}x${size}>`, + '-interlace', 'Plane', + '-quality', `${quality}%`, + '-write', filePathInCache, + '+delete', ')', + ); + } + + // throw away the (already written) image stream + args.push('null:'); + + return args; +} + +async function generateImageThumbnails(imagePath, thumbtacks, { mediaPath, mediaCachePath, spawnConvert, }) { + if (empty(thumbtacks)) return; + const filePathInMedia = path.join(mediaPath, imagePath); const dirnameInCache = path.join(mediaCachePath, path.dirname(imagePath)); - const filename = - path.basename(imagePath, path.extname(imagePath)) + - `.${thumbtack}.jpg`; - - const filePathInCache = - path.join(dirnameInCache, filename); - await mkdir(dirnameInCache, {recursive: true}); - const specEntry = thumbnailSpec[thumbtack]; - const {size, quality} = specEntry; - - const convertProcess = spawnConvert([ - filePathInMedia, - '-strip', - '-resize', - `${size}x${size}>`, - '-interlace', - 'Plane', - '-quality', - `${quality}%`, - filePathInCache, - ]); - - await promisifyProcess(convertProcess, false); + const convertArgs = + prepareConvertArgs(filePathInMedia, dirnameInCache, thumbtacks); + + await promisifyProcess(spawnConvert(convertArgs), false); } export async function determineMediaCachePath({ @@ -649,7 +641,7 @@ export async function determineMediaCachePath({ try { const files = await readdir(mediaPath); mediaIncludesThumbnailCache = files.includes(CACHE_FILE); - } catch (error) { + } catch { mediaIncludesThumbnailCache = false; } @@ -882,7 +874,7 @@ export async function migrateThumbsIntoDedicatedCacheDirectory({ path.join(mediaPath, CACHE_FILE), path.join(mediaCachePath, CACHE_FILE)); logInfo`Moved thumbnail cache file.`; - } catch (error) { + } catch { logWarn`Failed to move cache file. (${CACHE_FILE})`; logWarn`Check its permissions, or try copying/pasting.`; } @@ -1121,33 +1113,23 @@ export default async function genThumbs({ const writeMessageFn = () => `Writing image thumbnails. [failed: ${numFailed}]`; - const generateCallImageIndices = - imageThumbtacksNeeded - .flatMap(({length}, index) => - Array.from({length}, () => index)); - - const generateCallImagePaths = - generateCallImageIndices - .map(index => imagePaths[index]); - - const generateCallThumbtacks = - imageThumbtacksNeeded.flat(); - const generateCallFns = stitchArrays({ - imagePath: generateCallImagePaths, - thumbtack: generateCallThumbtacks, - }).map(({imagePath, thumbtack}) => () => - generateImageThumbnail(imagePath, thumbtack, { + imagePath: imagePaths, + thumbtacks: imageThumbtacksNeeded, + }).map(({imagePath, thumbtacks}) => () => + generateImageThumbnails(imagePath, thumbtacks, { mediaPath, mediaCachePath, spawnConvert, }).catch(error => { numFailed++; - return ({error}); + return {error}; })); - logInfo`Generating ${generateCallFns.length} thumbnails for ${imagePaths.length} media files.`; + const totalThumbs = imageThumbtacksNeeded.reduce((sum, tacks) => sum + tacks.length, 0); + + logInfo`Generating ${totalThumbs} thumbnails for ${imagePaths.length} media files.`; if (generateCallFns.length > 500) { logInfo`Go get a latte - this could take a while!`; } @@ -1156,37 +1138,30 @@ export default async function genThumbs({ await progressPromiseAll(writeMessageFn, queue(generateCallFns, magickThreads)); - let successfulIndices; + let successfulPaths; { - const erroredIndices = generateCallImageIndices.slice(); - const erroredPaths = generateCallImagePaths.slice(); - const erroredThumbtacks = generateCallThumbtacks.slice(); + const erroredPaths = imagePaths.slice(); const errors = generateCallResults.map(result => result?.error); const {removed} = filterMultipleArrays( - erroredIndices, erroredPaths, - erroredThumbtacks, errors, - (_index, _imagePath, _thumbtack, error) => error); + (_imagePath, error) => error); - successfulIndices = new Set(removed[0]); - - const chunks = - chunkMultipleArrays(erroredPaths, erroredThumbtacks, errors, - (imagePath, lastImagePath) => imagePath !== lastImagePath); + ([successfulPaths] = removed); // TODO: This should obviously be an aggregate error. // ...Just like every other error report here, and those dang aggregates // should be constructable from within the queue, rather than after. - for (const [[imagePath], thumbtacks, errors] of chunks) { - logError`Failed to generate thumbnails for ${imagePath}:`; - for (const {thumbtack, error} of stitchArrays({thumbtack: thumbtacks, error: errors})) { - logError`- ${thumbtack}: ${error}`; - } - } + stitchArrays({ + imagePath: erroredPaths, + error: errors, + }).forEach(({imagePath, error}) => { + logError`Failed to generate thumbnails for ${imagePath}:`; + logError`- ${error}`; + }); if (empty(errors)) { logInfo`All needed thumbnails generated successfully - nice!`; @@ -1200,8 +1175,8 @@ export default async function genThumbs({ imagePaths, imageThumbtacksNeeded, imageDimensions, - (_imagePath, _thumbtacksNeeded, _dimensions, index) => - successfulIndices.has(index)); + (imagePath, _thumbtacksNeeded, _dimensions) => + successfulPaths.includes(imagePath)); for (const { imagePath, @@ -1263,41 +1238,20 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) { const fromRoot = urls.from('media.root'); const paths = [ + wikiData.artworkData + .filter(artwork => artwork.path) + .map(artwork => fromRoot.to(...artwork.path)), + wikiData.albumData - .map(album => [ - album.hasCoverArt && [ - fromRoot.to('media.albumCover', album.directory, album.coverArtFileExtension), - ], - - !empty(CacheableObject.getUpdateValue(album, 'bannerArtistContribs')) && [ - fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension), - ], - - !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) && - empty(album.wallpaperParts) && [ - fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension), - ], - - !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) && - !empty(album.wallpaperParts) && - album.wallpaperParts.flatMap(part => [ - part.asset && - fromRoot.to('media.albumWallpaperPart', album.directory, part.asset), - ]), - ]) - .flat(2) - .filter(Boolean), - - wikiData.artistData - .filter(artist => artist.hasAvatar) - .map(artist => fromRoot.to('media.artistAvatar', artist.directory, artist.avatarFileExtension)), - - wikiData.flashData - .map(flash => fromRoot.to('media.flashArt', flash.directory, flash.coverArtFileExtension)), - - wikiData.trackData - .filter(track => track.hasUniqueCoverArt) - .map(track => fromRoot.to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension)), + .flatMap(album => album.wallpaperParts + .filter(part => part.asset) + .map(part => + fromRoot.to('media.albumWallpaperPart', album.directory, part.asset))), + + wikiData.wikiInfo.wikiWallpaperParts + .filter(part => part.asset) + .map(part => + fromRoot.to('media.path', part.asset)), ].flat(); sortByName(paths, {getName: path => path}); diff --git a/src/util/html.js b/src/html.js index 0fe424df..dd1d1960 100644 --- a/src/util/html.js +++ b/src/html.js @@ -53,6 +53,17 @@ export const attributeSpec = { }, }; +let disabledSlotValidation = false; +let disabledTagTracing = false; + +export function disableSlotValidation() { + disabledSlotValidation = true; +} + +export function disableTagTracing() { + disabledTagTracing = true; +} + // Pass to tag() as an attributes key to make tag() return a 8lank tag if the // provided content is empty. Useful for when you'll only 8e showing an element // according to the presence of content that would 8elong there. @@ -223,7 +234,11 @@ export function isBlank(content) { // could include content. These need to be checked too. // Check each of the templates one at a time. for (const template of result) { - const content = template.content; + // Resolve the content all the way down to a tag - + // if it's a template that returns another template, + // that won't do, because we need to detect if its + // final content is a tag marked onlyIfSiblings. + const content = normalize(template); if (content instanceof Tag && content.onlyIfSiblings) { continue; @@ -352,8 +367,10 @@ export class Tag { this.attributes = attributes; this.content = content; - this.#traceError = new Error(); - } + if (!disabledTagTracing) { + this.#traceError = new Error(); + } +} clone() { return Reflect.construct(this.constructor, [ @@ -512,6 +529,10 @@ export class Tag { } } + #getAttributeRaw(attribute) { + return this.attributes.get(attribute); + } + set onlyIfContent(value) { this.#setAttributeFlag(onlyIfContent, value); } @@ -579,7 +600,7 @@ export class Tag { try { this.content = this.content; - } catch (error) { + } catch { this.#setAttributeFlag(imaginarySibling, false); } } @@ -662,7 +683,7 @@ export class Tag { const chunkwrapSplitter = (this.chunkwrap - ? this.#getAttributeString('split') + ? this.#getAttributeRaw('split') : null); let seenChunkwrapSplitter = @@ -702,17 +723,19 @@ export class Tag { `of ${inspect(this, {compact: true})}`, {cause: caughtError}); - error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; - error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError; + if (this.#traceError && !disabledTagTracing) { + error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; + error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = this.#traceError; - error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ - /content-function\.js/, - /util\/html\.js/, - ]; + error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ + /content-function\.js/, + /util\/html\.js/, + ]; - error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ - /content\/dependencies\/(.*\.js:.*(?=\)))/, - ]; + error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ + /content\/dependencies\/(.*\.js:.*(?=\)))/, + ]; + } throw error; } @@ -727,7 +750,7 @@ export class Tag { const chunkwrapChunks = (typeof nonTemplateItem === 'string' && chunkwrapSplitter - ? itemContent.split(chunkwrapSplitter) + ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter)) : null); const itemIncludesChunkwrapSplit = @@ -773,7 +796,7 @@ export class Tag { appendItemContent: { if (itemIncludesChunkwrapSplit) { - for (const [index, chunk] of chunkwrapChunks.entries()) { + for (const [index, {chunk, following}] of chunkwrapChunks.entries()) { if (index === 0) { // The first chunk isn't actually a chunk all on its own, it's // text that should be appended to the previous chunk. We will @@ -781,12 +804,27 @@ export class Tag { // the next chunk. content += chunk; } else { - const whitespace = chunk.match(/^\s+/) ?? ''; - content += chunkwrapSplitter; + const followingWhitespace = following.match(/\s+$/) ?? ''; + const chunkWhitespace = chunk.match(/^\s+/) ?? ''; + + if (followingWhitespace) { + content += following.slice(0, -followingWhitespace.length); + } else { + content += following; + } + content += '</span>'; - content += whitespace; + + content += followingWhitespace; + content += chunkWhitespace; + content += '<span class="chunkwrap">'; - content += chunk.slice(whitespace.length); + + if (chunkWhitespace) { + content += chunk.slice(chunkWhitespace.length); + } else { + content += chunk; + } } } @@ -1009,6 +1047,49 @@ export class Tag { } } +export function* getChunkwrapChunks(content, splitter) { + const splitString = + (typeof splitter === 'string' + ? splitter + : null); + + if (splitString) { + let following = ''; + for (const chunk of content.split(splitString)) { + yield {chunk, following}; + following = splitString; + } + + return; + } + + const splitRegExp = + (splitter instanceof RegExp + ? new RegExp( + splitter.source, + (splitter.flags.includes('g') + ? splitter.flags + : splitter.flags + 'g')) + : null); + + if (splitRegExp) { + let following = ''; + let prevIndex = 0; + for (const match of content.matchAll(splitRegExp)) { + const chunk = content.slice(prevIndex, match.index); + yield {chunk, following}; + + following = match[0]; + prevIndex = match.index + match[0].length; + } + + const chunk = content.slice(prevIndex); + yield {chunk, following}; + + return; + } +} + export function attributes(attributes) { return new Attributes(attributes); } @@ -1069,6 +1150,34 @@ export class Attributes { } add(...args) { + // Very common case: add({class: 'foo', id: 'bar'})¡ + // The argument is a plain object (no Template, no Attributes, + // no blessAttributes symbol). We can skip the expensive + // isAttributesAdditionSinglet() validation and flatten/array handling. + if ( + args.length === 1 && + args[0] && + typeof args[0] === 'object' && + !Array.isArray(args[0]) && + !(args[0] instanceof Attributes) && + !(args[0] instanceof Template) && + !Object.hasOwn(args[0], blessAttributes) + ) { + const obj = args[0]; + + // Preserve existing merge semantics by funnelling each key through + // the internal #addOneAttribute helper (handles class/style union, + // unique merging, etc.) but avoid *per-object* validation overhead. + for (const key of Reflect.ownKeys(obj)) { + this.#addOneAttribute(key, obj[key]); + } + + // Match the original return style (list of results) so callers that + // inspect the return continue to work. + return obj; + } + + // Fall back to the original slow-but-thorough implementation switch (args.length) { case 1: isAttributesAdditionSinglet(args[0]); @@ -1080,10 +1189,11 @@ export class Attributes { default: throw new Error( - `Expected array or object, or attribute and value`); + 'Expected array or object, or attribute and value'); } } + with(...args) { const clone = this.clone(); clone.add(...args); @@ -1254,6 +1364,9 @@ export class Attributes { return value.some(Boolean); } else if (value === null) { return false; + } else if (value instanceof RegExp) { + // Oooooooo. + return true; } else { // Other objects are an error. break; @@ -1285,13 +1398,16 @@ export class Attributes { case 'number': return value.toString(); - // If it's a kept object, it's an array. case 'object': { - const joiner = - (descriptor?.arraylike && descriptor?.join) - ?? ' '; + if (Array.isArray(value)) { + const joiner = + (descriptor?.arraylike && descriptor?.join) + ?? ' '; - return value.filter(Boolean).join(joiner); + return value.filter(Boolean).join(joiner); + } else { + return value; + } } default: @@ -1671,6 +1787,10 @@ export class Template { } static validateSlotValueAgainstDescription(value, description) { + if (disabledSlotValidation) { + return true; + } + if (value === undefined) { throw new TypeError(`Specify value as null or don't specify at all`); } @@ -1963,6 +2083,8 @@ export const isAttributeValue = anyOf( isString, isNumber, isBoolean, isArray, isTag, isTemplate, + // Evil. Ooooo + validateInstanceOf(RegExp), validateArrayItems(item => isAttributeValue(item))); export const isAttributesAdditionPair = pair => { diff --git a/src/listing-spec.js b/src/listing-spec.js index bfea397c..142c5976 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -196,15 +196,22 @@ listingSpec.push({ listingSpec.push({ directory: 'tags/by-name', - stringsKey: 'listTags.byName', - contentFunction: 'listTagsByName', + stringsKey: 'listArtTags.byName', + contentFunction: 'listArtTagsByName', featureFlag: 'enableArtTagUI', }); listingSpec.push({ directory: 'tags/by-uses', - stringsKey: 'listTags.byUses', - contentFunction: 'listTagsByUses', + stringsKey: 'listArtTags.byUses', + contentFunction: 'listArtTagsByUses', + featureFlag: 'enableArtTagUI', +}); + +listingSpec.push({ + directory: 'tags/network', + stringsKey: 'listArtTags.network', + contentFunction: 'listArtTagNetwork', featureFlag: 'enableArtTagUI', }); @@ -238,6 +245,27 @@ listingSpec.push({ groupUnderOther: true, }); +// Dunkass mock. Listings should be Things! In the fuuuuture! +class Listing { + static properties = {}; + + constructor() { + Object.assign(this, this.constructor.properties); + } + + static hasPropertyDescriptor(key) { + return Object.hasOwn(this.properties, key); + } +} + +for (const [index, listing] of listingSpec.entries()) { + class ListingSubclass extends Listing { + static properties = listing; + } + + listingSpec.splice(index, 1, new ListingSubclass); +} + { const errors = []; diff --git a/src/util/node-utils.js b/src/node-utils.js index 345d10aa..345d10aa 100644 --- a/src/util/node-utils.js +++ b/src/node-utils.js diff --git a/src/page/album.js b/src/page/album.js index 46b1446b..696e2854 100644 --- a/src/page/album.js +++ b/src/page/album.js @@ -38,20 +38,28 @@ export function pathsForTarget(album) { }, }, - !empty(album.referencedArtworks) && { + { type: 'page', path: ['albumReferencedArtworks', album.directory], + condition: () => + album.hasCoverArt && + !empty(album.coverArtworks[0].referencedArtworks), + contentFunction: { name: 'generateAlbumReferencedArtworksPage', args: [album], }, }, - !empty(album.referencedByArtworks) && { + { type: 'page', path: ['albumReferencingArtworks', album.directory], + condition: () => + album.hasCoverArt && + !empty(album.coverArtworks[0].referencedByArtworks), + contentFunction: { name: 'generateAlbumReferencingArtworksPage', args: [album], @@ -80,13 +88,15 @@ export function pathsTargetless({wikiData: {wikiInfo}}) { contentFunction: {name: 'generateCommentaryIndexPage'}, }, - wikiInfo.canonicalBase === 'https://hsmusic.wiki/' && - { - type: 'redirect', - fromPath: ['page', 'list/all-commentary'], - toPath: ['commentaryIndex'], - title: 'Album Commentary', - }, + { + type: 'redirect', + fromPath: ['page', 'list/all-commentary'], + toPath: ['commentaryIndex'], + title: 'Album Commentary', + + condition: () => + wikiInfo.canonicalBase === 'https://hsmusic.wiki/', + }, ]; } diff --git a/src/page/tag.js b/src/page/art-tag.js index 8942aea9..5b61229d 100644 --- a/src/page/tag.js +++ b/src/page/art-tag.js @@ -1,6 +1,6 @@ // Art tag page specification. -export const description = `per-artwork-tag gallery pages`; +export const description = `per-art-tag info & gallery pages`; export function condition({wikiData}) { return wikiData.wikiInfo.enableArtTagUI; @@ -14,7 +14,17 @@ export function pathsForTarget(tag) { return [ { type: 'page', - path: ['tag', tag.directory], + path: ['artTagInfo', tag.directory], + + contentFunction: { + name: 'generateArtTagInfoPage', + args: [tag], + }, + }, + + { + type: 'page', + path: ['artTagGallery', tag.directory], contentFunction: { name: 'generateArtTagGalleryPage', diff --git a/src/page/artist.js b/src/page/artist.js index b68cf05c..257e060d 100644 --- a/src/page/artist.js +++ b/src/page/artist.js @@ -8,10 +8,6 @@ export function targets({wikiData}) { } export function pathsForTarget(artist) { - const hasGalleryPage = - !empty(artist.albumCoverArtistContributions) || - !empty(artist.trackCoverArtistContributions); - return [ { type: 'page', @@ -23,10 +19,14 @@ export function pathsForTarget(artist) { }, }, - hasGalleryPage && { + { type: 'page', path: ['artistGallery', artist.directory], + condition: () => + !empty(artist.albumCoverArtistContributions) || + !empty(artist.trackCoverArtistContributions), + contentFunction: { name: 'generateArtistGalleryPage', args: [artist], diff --git a/src/page/group.js b/src/page/group.js index b0ed5baf..87590eaf 100644 --- a/src/page/group.js +++ b/src/page/group.js @@ -7,8 +7,6 @@ export function targets({wikiData}) { } export function pathsForTarget(group) { - const hasGalleryPage = !empty(group.albums); - return [ { type: 'page', @@ -20,10 +18,13 @@ export function pathsForTarget(group) { }, }, - hasGalleryPage && { + { type: 'page', path: ['groupGallery', group.directory], + condition: () => + !empty(group.albums), + contentFunction: { name: 'generateGroupGalleryPage', args: [group], @@ -34,20 +35,24 @@ export function pathsForTarget(group) { export function pathsTargetless({wikiData: {wikiInfo}}) { return [ - wikiInfo.canonicalBase === 'https://hsmusic.wiki/' && - { - type: 'redirect', - fromPath: ['page', 'albums/fandom'], - toPath: ['groupGallery', 'fandom'], - title: 'Fandom - Gallery', - }, + { + type: 'redirect', + fromPath: ['page', 'albums/fandom'], + toPath: ['groupGallery', 'fandom'], + title: 'Fandom - Gallery', - wikiInfo.canonicalBase === 'https://hsmusic.wiki/' && - { - type: 'redirect', - fromPath: ['page', 'albums/official'], - toPath: ['groupGallery', 'official'], - title: 'Official - Gallery', - }, + condition: () => + wikiInfo.canonicalBase === 'https://hsmusic.wiki/', + }, + + { + type: 'redirect', + fromPath: ['page', 'albums/official'], + toPath: ['groupGallery', 'official'], + title: 'Official - Gallery', + + condition: () => + wikiInfo.canonicalBase === 'https://hsmusic.wiki/', + }, ]; } diff --git a/src/page/homepage.js b/src/page/homepage.js index 53ee6e46..cfcdd6e1 100644 --- a/src/page/homepage.js +++ b/src/page/homepage.js @@ -7,7 +7,7 @@ export function pathsTargetless({wikiData}) { path: ['home'], contentFunction: { - name: 'generateWikiHomePage', + name: 'generateWikiHomepagePage', args: [wikiData.homepageLayout], }, }, diff --git a/src/page/index.js b/src/page/index.js index 21d93c8f..ae480136 100644 --- a/src/page/index.js +++ b/src/page/index.js @@ -1,6 +1,7 @@ export * as album from './album.js'; export * as artist from './artist.js'; export * as artistAlias from './artist-alias.js'; +export * as artTag from './art-tag.js'; export * as flash from './flash.js'; export * as flashAct from './flash-act.js'; export * as group from './group.js'; @@ -8,5 +9,4 @@ export * as homepage from './homepage.js'; export * as listing from './listing.js'; export * as news from './news.js'; export * as static from './static.js'; -export * as tag from './tag.js'; export * as track from './track.js'; diff --git a/src/page/static.js b/src/page/static.js index c9d806ff..733844de 100644 --- a/src/page/static.js +++ b/src/page/static.js @@ -12,6 +12,7 @@ export function pathsForTarget(staticPage) { { type: 'page', path: ['staticPage', staticPage.directory], + absoluteLinks: staticPage.absoluteLinks, contentFunction: { name: 'generateStaticPage', diff --git a/src/page/track.js b/src/page/track.js index 94a1e48d..95647334 100644 --- a/src/page/track.js +++ b/src/page/track.js @@ -20,20 +20,28 @@ export function pathsForTarget(track) { }, }, - !empty(track.referencedArtworks) && { + { type: 'page', path: ['trackReferencedArtworks', track.directory], + condition: () => + track.hasUniqueCoverArt && + !empty(track.trackArtworks[0].referencedArtworks), + contentFunction: { name: 'generateTrackReferencedArtworksPage', args: [track], }, }, - !empty(track.referencedByArtworks) && { + { type: 'page', path: ['trackReferencingArtworks', track.directory], + condition: () => + track.hasUniqueCoverArt && + !empty(track.trackArtworks[0].referencedByArtworks), + contentFunction: { name: 'generateTrackReferencingArtworksPage', args: [track], diff --git a/src/util/replacer.js b/src/replacer.js index e3f5623e..779ee78d 100644 --- a/src/util/replacer.js +++ b/src/replacer.js @@ -8,7 +8,8 @@ import * as marked from 'marked'; import * as html from '#html'; -import {escapeRegex, typeAppearance} from '#sugar'; +import {empty, escapeRegex, typeAppearance} from '#sugar'; +import {matchMarkdownLinks} from '#wiki-data'; export const replacerSpec = { 'album': { @@ -26,6 +27,16 @@ export const replacerSpec = { link: 'linkAlbumGallery', }, + 'album-referenced-artworks': { + find: 'albumWithArtwork', + link: 'linkAlbumReferencedArtworks', + }, + + 'album-referencing-artworks': { + find: 'albumWithArtwork', + link: 'linkAlbumReferencingArtworks', + }, + 'artist': { find: 'artist', link: 'linkArtist', @@ -74,6 +85,11 @@ export const replacerSpec = { link: 'linkFlashAct', }, + 'flash-side': { + find: 'flashSide', + link: 'linkFlashSide', + }, + 'group': { find: 'group', link: 'linkGroup', @@ -86,7 +102,7 @@ export const replacerSpec = { 'home': { find: null, - link: 'linkWikiHome', + link: 'linkWikiHomepage', }, 'listing-index': { @@ -137,13 +153,33 @@ export const replacerSpec = { 'tag': { find: 'artTag', - link: 'linkArtTag', + link: 'linkArtTagDynamically', + }, + + 'tag-info': { + find: 'artTag', + link: 'linkArtTagInfo', }, 'track': { find: 'track', link: 'linkTrackDynamically', }, + + 'track-referenced-artworks': { + find: 'trackWithArtwork', + link: 'linkTrackReferencedArtworks', + }, + + 'track-referencing-artworks': { + find: 'trackWithArtwork', + link: 'linkTrackReferencingArtworks', + }, + + 'tooltip': { + value: (ref) => ref, + link: null, + } }; // Syntax literals. @@ -428,7 +464,7 @@ export function squashBackslashes(text) { // a set of characters where the backslash carries meaning // into later formatting (i.e. markdown). Note that we do // NOT compress double backslashes into single backslashes. - return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>-])/g, '$1'); + return text.replace(/([^\\](?:\\{2})*)\\(?![\\*_~>.-])/g, '$1'); } export function restoreRawHTMLTags(text) { @@ -488,23 +524,30 @@ export function postprocessComments(inputNodes) { return outputNodes; } -export function postprocessImages(inputNodes) { +function postprocessHTMLTags(inputNodes, tagName, callback) { const outputNodes = []; - - let atStartOfLine = true; + const errors = []; const lastNode = inputNodes.at(-1); + const regexp = + new RegExp( + `<${tagName} (.*?)>` + + (html.selfClosingTags.includes(tagName) + ? '' + : `(?:</${tagName}>)?`), + 'g'); + + let atStartOfLine = true; + for (const node of inputNodes) { if (node.type === 'tag') { atStartOfLine = false; } if (node.type === 'text') { - const imageRegexp = /<img (.*?)>/g; - let match = null, parseFrom = 0; - while (match = imageRegexp.exec(node.data)) { + while (match = regexp.exec(node.data)) { const previousText = node.data.slice(parseFrom, match.index); outputNodes.push({ @@ -516,23 +559,19 @@ export function postprocessImages(inputNodes) { parseFrom = match.index + match[0].length; - const imageNode = {type: 'image'}; - const attributes = html.parseAttributes(match[1]); - - imageNode.src = attributes.get('src'); - if (previousText.endsWith('\n')) { atStartOfLine = true; } else if (previousText.length) { atStartOfLine = false; } - imageNode.inline = (() => { - // Images can force themselves to be rendered inline using a custom - // attribute - this style just works better for certain embeds, - // usually jokes or small images. - if (attributes.get('inline')) return true; + const attributes = + html.parseAttributes(match[1]); + + const remainingTextInNode = + node.data.slice(parseFrom); + const inline = (() => { // If we've already determined we're in the middle of a line, // we're inline. (Of course!) if (!atStartOfLine) { @@ -541,42 +580,33 @@ export function postprocessImages(inputNodes) { // If there's more text to go in this text node, and what's // remaining doesn't start with a line break, we're inline. - if ( - parseFrom !== node.data.length && - node.data[parseFrom] !== '\n' - ) { + if (remainingTextInNode && remainingTextInNode[0] !== '\n') { return true; } // If we're at the end of this text node, but this text node // isn't the last node overall, we're inline. - if ( - parseFrom === node.data.length && - node !== lastNode - ) { + if (!remainingTextInNode && node !== lastNode) { return true; } - // If no other condition matches, this image is on its own line. + // If no other condition matches, this tag is on its own line. return false; })(); - if (attributes.get('link')) imageNode.link = attributes.get('link'); - if (attributes.get('style')) imageNode.style = attributes.get('style'); - if (attributes.get('width')) imageNode.width = parseInt(attributes.get('width')); - if (attributes.get('height')) imageNode.height = parseInt(attributes.get('height')); - if (attributes.get('align')) imageNode.align = attributes.get('align'); - if (attributes.get('pixelate')) imageNode.pixelate = true; - - if (attributes.get('warning')) { - imageNode.warnings = - attributes.get('warning').split(', '); + try { + outputNodes.push( + callback(attributes, { + inline, + })); + } catch (caughtError) { + errors.push(new Error( + `Failed to process ${match[0]}`, + {cause: caughtError})); } - outputNodes.push(imageNode); - - // No longer at the start of a line after an image - there will at - // least be a text node with only '\n' before the next image that's + // No longer at the start of a line after the tag - there will at + // least be text with only '\n' before the next of this tag that's // on its own line. atStartOfLine = false; } @@ -596,57 +626,85 @@ export function postprocessImages(inputNodes) { outputNodes.push(node); } + if (!empty(errors)) { + throw new AggregateError( + errors, + `Errors postprocessing <${tagName}> tags`); + } + return outputNodes; } -export function postprocessVideos(inputNodes) { - const outputNodes = []; +function complainAboutMediaSrc(src) { + if (!src) { + throw new Error(`Missing "src" attribute`); + } - for (const node of inputNodes) { - if (node.type !== 'text') { - outputNodes.push(node); - continue; - } + if (src.startsWith('/media/')) { + throw new Error(`Start "src" with "media/", not "/media/"`); + } +} - const videoRegexp = /<video (.*?)>(<\/video>)?/g; +export function postprocessImages(inputNodes) { + return postprocessHTMLTags(inputNodes, 'img', + (attributes, {inline}) => { + const node = {type: 'image'}; - let match = null, parseFrom = 0; - while (match = videoRegexp.exec(node.data)) { - const previousText = node.data.slice(parseFrom, match.index); + node.src = attributes.get('src'); + complainAboutMediaSrc(node.src); - outputNodes.push({ - type: 'text', - data: previousText, - i: node.i + parseFrom, - iEnd: node.i + parseFrom + match.index, - }); + node.inline = attributes.get('inline') ?? inline; - parseFrom = match.index + match[0].length; + if (attributes.get('link')) node.link = attributes.get('link'); + if (attributes.get('style')) node.style = attributes.get('style'); + if (attributes.get('width')) node.width = parseInt(attributes.get('width')); + if (attributes.get('height')) node.height = parseInt(attributes.get('height')); + if (attributes.get('align')) node.align = attributes.get('align'); + if (attributes.get('pixelate')) node.pixelate = true; - const videoNode = {type: 'video'}; - const attributes = html.parseAttributes(match[1]); + if (attributes.get('warning')) { + node.warnings = + attributes.get('warning').split(', '); + } - videoNode.src = attributes.get('src'); + return node; + }); +} - if (attributes.get('width')) videoNode.width = parseInt(attributes.get('width')); - if (attributes.get('height')) videoNode.height = parseInt(attributes.get('height')); - if (attributes.get('align')) videoNode.align = attributes.get('align'); - if (attributes.get('pixelate')) videoNode.pixelate = true; +export function postprocessVideos(inputNodes) { + return postprocessHTMLTags(inputNodes, 'video', + (attributes, {inline}) => { + const node = {type: 'video'}; - outputNodes.push(videoNode); - } + node.src = attributes.get('src'); + complainAboutMediaSrc(node.src); - if (parseFrom !== node.data.length) { - outputNodes.push({ - type: 'text', - data: node.data.slice(parseFrom), - i: node.i + parseFrom, - iEnd: node.iEnd, - }); - } - } + node.inline = attributes.get('inline') ?? inline; - return outputNodes; + if (attributes.get('width')) node.width = parseInt(attributes.get('width')); + if (attributes.get('height')) node.height = parseInt(attributes.get('height')); + if (attributes.get('align')) node.align = attributes.get('align'); + if (attributes.get('pixelate')) node.pixelate = true; + + return node; + }); +} + +export function postprocessAudios(inputNodes) { + return postprocessHTMLTags(inputNodes, 'audio', + (attributes, {inline}) => { + const node = {type: 'audio'}; + + node.src = attributes.get('src'); + complainAboutMediaSrc(node.src); + + node.inline = attributes.get('inline') ?? inline; + + if (attributes.get('align')) node.align = attributes.get('align'); + if (attributes.get('nameless')) node.nameless = true; + + return node; + }); } export function postprocessHeadings(inputNodes) { @@ -744,109 +802,234 @@ export function postprocessExternalLinks(inputNodes) { continue; } - const plausibleLinkRegexp = /\[.*?\)/g; - - let textContent = ''; + let textNode = { + i: node.i, + iEnd: null, + type: 'text', + data: '', + }; - let plausibleMatch = null, parseFrom = 0; - while (plausibleMatch = plausibleLinkRegexp.exec(node.data)) { - textContent += node.data.slice(parseFrom, plausibleMatch.index); - - // Pedantic rules use more particular parentheses detection in link - // destinations - they allow one level of balanced parentheses, and - // otherwise, parentheses must be escaped. This allows for entire links - // to be wrapped in parentheses, e.g below: - // - // This is so cool. ([You know??](https://example.com)) - // - const definiteMatch = - marked.Lexer.rules.inline.pedantic.link - .exec(node.data.slice(plausibleMatch.index)); - - if (definiteMatch) { - const {1: label, 2: href} = definiteMatch; - - // Split the containing text node into two - the second of these will - // be added after iterating over matches, or by the next match. - if (textContent.length) { - outputNodes.push({type: 'text', data: textContent}); - textContent = ''; - } + let parseFrom = 0; + for (const match of matchMarkdownLinks(node.data, {marked})) { + const {label, href, index, length} = match; - const offset = plausibleMatch.index + definiteMatch.index; - const length = definiteMatch[0].length; + textNode.data += node.data.slice(parseFrom, index); - outputNodes.push({ - i: node.i + offset, - iEnd: node.i + offset + length, - type: 'external-link', - data: {label, href}, - }); + // Split the containing text node into two - the second of these will + // be filled in and pushed by the next match, or after iterating over + // all matches. + if (textNode.data) { + textNode.iEnd = textNode.i + textNode.data.length; + outputNodes.push(textNode); - parseFrom = offset + length; - } else { - parseFrom = plausibleMatch.index; + textNode = { + i: node.i + index + length, + iEnd: null, + type: 'text', + data: '', + }; } + + outputNodes.push({ + i: node.i + index, + iEnd: node.i + index + length, + type: 'external-link', + data: {label, href}, + }); + + parseFrom = index + length; } if (parseFrom !== node.data.length) { - textContent += node.data.slice(parseFrom); + textNode.data += node.data.slice(parseFrom); + textNode.iEnd = node.iEnd; } - if (textContent.length) { - outputNodes.push({type: 'text', data: textContent}); + if (textNode.data) { + outputNodes.push(textNode); } } return outputNodes; } -export function parseInput(input) { +export function parseContentNodes(input, { + errorMode = 'throw', +} = {}) { if (typeof input !== 'string') { throw new TypeError(`Expected input to be string, got ${typeAppearance(input)}`); } - try { - let output = parseNodes(input, 0); - output = postprocessComments(output); - output = postprocessImages(output); - output = postprocessVideos(output); - output = postprocessHeadings(output); - output = postprocessSummaries(output); - output = postprocessExternalLinks(output); - return output; - } catch (errorNode) { - if (errorNode.type !== 'error') { - throw errorNode; + let result = null, error = null; + + process: { + try { + result = parseNodes(input, 0); + } catch (caughtError) { + if (caughtError.type === 'error') { + const {i, data: {message}} = caughtError; + + let lineStart = input.slice(0, i).lastIndexOf('\n'); + if (lineStart >= 0) { + lineStart += 1; + } else { + lineStart = 0; + } + + let lineEnd = input.slice(i).indexOf('\n'); + if (lineEnd >= 0) { + lineEnd += i; + } else { + lineEnd = input.length; + } + + const line = input.slice(lineStart, lineEnd); + + const cursor = i - lineStart; + + error = + new SyntaxError( + `Parse error (at pos ${i}): ${message}\n` + + line + `\n` + + '-'.repeat(cursor) + '^'); + } else { + error = caughtError; + } + + // A parse error means there's no output to continue with at all, + // so stop here. + break process; } - const { - i, - data: {message}, - } = errorNode; + const postprocessErrors = []; + + for (const postprocess of [ + postprocessComments, + postprocessImages, + postprocessVideos, + postprocessAudios, + postprocessHeadings, + postprocessSummaries, + postprocessExternalLinks, + ]) { + try { + result = postprocess(result); + } catch (caughtError) { + const error = + new Error( + `Error in step ${`"${postprocess.name}"`}`, + {cause: caughtError}); + + error[Symbol.for('hsmusic.aggregate.translucent')] = true; + + postprocessErrors.push(error); + } + } - let lineStart = input.slice(0, i).lastIndexOf('\n'); - if (lineStart >= 0) { - lineStart += 1; - } else { - lineStart = 0; + if (!empty(postprocessErrors)) { + error = + new AggregateError( + postprocessErrors, + `Errors postprocessing content text`); + + error[Symbol.for('hsmusic.aggregate.translucent')] = 'single'; } + } - let lineEnd = input.slice(i).indexOf('\n'); - if (lineEnd >= 0) { - lineEnd += i; + if (errorMode === 'throw') { + if (error) { + throw error; } else { - lineEnd = input.length; + return result; + } + } else if (errorMode === 'return') { + if (!result) { + result = [{ + i: 0, + iEnd: input.length, + type: 'text', + data: input, + }]; + } + + return {error, result}; + } else { + throw new Error(`Unknown errorMode ${errorMode}`); + } +} + +export function* splitContentNodesAround(nodes, splitter) { + if (splitter instanceof RegExp) { + const regex = splitter; + + splitter = function*(text) { + for (const match of text.matchAll(regex)) { + yield { + index: match.index, + length: match[0].length, + }; + } + }; + } + + if (typeof splitter === 'string') { + throw new TypeError(`Expected generator or regular expression`); + } + + function* splitTextNode(node) { + let textNode = { + i: node.i, + iEnd: null, + type: 'text', + data: '', + }; + + let parseFrom = 0; + for (const match of splitter(node.data)) { + const {index, length} = match; + + textNode.data += node.data.slice(parseFrom, index); + + if (textNode.data) { + textNode.iEnd = textNode.i + textNode.data.length; + yield textNode; + } + + yield { + i: node.i + index, + iEnd: node.i + index + length, + type: 'separator', + data: { + text: node.data.slice(index, index + length), + match, + }, + }; + + textNode = { + i: node.i + index + length, + iEnd: null, + type: 'text', + data: '', + }; + + parseFrom = index + length; } - const line = input.slice(lineStart, lineEnd); + if (parseFrom !== node.data.length) { + textNode.data += node.data.slice(parseFrom); + textNode.iEnd = node.iEnd; + } - const cursor = i - lineStart; + if (textNode.data) { + yield textNode; + } + } - throw new SyntaxError([ - `Parse error (at pos ${i}): ${message}`, - line, - '-'.repeat(cursor) + '^', - ].join('\n')); + for (const node of nodes) { + if (node.type === 'text') { + yield* splitTextNode(node); + } else { + yield node; + } } } diff --git a/src/reverse.js b/src/reverse.js new file mode 100644 index 00000000..b4b225f0 --- /dev/null +++ b/src/reverse.js @@ -0,0 +1,141 @@ +import * as fr from './find-reverse.js'; + +import {sortByDate} from '#sort'; +import {stitchArrays} from '#sugar'; + +function checkUnique(value) { + if (value.length === 0) { + return null; + } else if (value.length === 1) { + return value[0]; + } else { + throw new Error( + `Requested unique referencing thing, ` + + `but ${value.length} reference this`); + } +} + +function reverseHelper(spec) { + const cache = new WeakMap(); + + return (thing, data, { + unique = false, + } = {}) => { + // Check for an existing cache record which corresponds to this data. + // If it exists, query it for the requested thing, and return that; + // if it doesn't, create it and put it where it needs to be. + + if (cache.has(data)) { + const value = cache.get(data).get(thing) ?? []; + + if (unique) { + return checkUnique(value); + } else { + return value; + } + } + + const cacheRecord = new WeakMap(); + cache.set(data, cacheRecord); + + // Get the referencing and referenced things. This is the meat of how + // one reverse spec is different from another. If the spec includes a + // 'tidy' step, use that to finalize the referencing things, the way + // they'll be recorded as results. + + const interstitialReferencingThings = + (spec.bindTo === 'wikiData' + ? spec.referencing(data) + : data.flatMap(thing => spec.referencing(thing))); + + const referencedThings = + interstitialReferencingThings.map(thing => spec.referenced(thing)); + + const referencingThings = + (spec.tidy + ? interstitialReferencingThings.map(thing => spec.tidy(thing)) + : interstitialReferencingThings); + + // Actually fill in the cache record. Since we're building up a *reverse* + // reference list, track connections in terms of the referenced thing. + // Also gather all referenced things into a set, for sorting purposes. + + const allReferencedThings = new Set(); + + stitchArrays({ + referencingThing: referencingThings, + referencedThings: referencedThings, + }).forEach(({referencingThing, referencedThings}) => { + for (const referencedThing of referencedThings) { + if (cacheRecord.has(referencedThing)) { + cacheRecord.get(referencedThing).push(referencingThing); + } else { + cacheRecord.set(referencedThing, [referencingThing]); + allReferencedThings.add(referencedThing); + } + } + }); + + // Sort the entries in the cache records, too, just by date. The rest of + // sorting should be handled externally - either preceding the reverse + // call (changing the data input) or following (sorting the output). + + for (const referencedThing of allReferencedThings) { + if (cacheRecord.has(referencedThing)) { + const referencingThings = cacheRecord.get(referencedThing); + sortByDate(referencingThings, { + getDate: spec.date ?? (thing => thing.date), + }); + } + } + + // Then just pluck out the requested thing from the now-filled + // cache record! + + const value = cacheRecord.get(thing) ?? []; + + if (unique) { + return checkUnique(value); + } else { + return value; + } + }; +} + +const hardcodedReverseSpecs = {}; + +const findReverseHelperConfig = { + word: `reverse`, + constructorKey: Symbol.for('Thing.reverseSpecs'), + + hardcodedSpecs: hardcodedReverseSpecs, + postprocessSpec: postprocessReverseSpec, +}; + +export function postprocessReverseSpec(spec, {thingConstructor}) { + const newSpec = {...spec}; + + void thingConstructor; + + return newSpec; +} + +export function getAllReverseSpecs() { + return fr.getAllSpecs(findReverseHelperConfig); +} + +export function findReverseSpec(key) { + return fr.findSpec(key, findReverseHelperConfig); +} + +export default fr.tokenProxy({ + findSpec: findReverseSpec, + prepareBehavior: reverseHelper, +}); + +export function bindReverse(wikiData, opts) { + return fr.bind(wikiData, opts, { + getAllSpecs: getAllReverseSpecs, + prepareBehavior: reverseHelper, + }); +} diff --git a/src/static/css/site.css b/src/static/css/site.css index 6c853161..a9ed90c6 100644 --- a/src/static/css/site.css +++ b/src/static/css/site.css @@ -61,7 +61,7 @@ body::before, .wallpaper-part { #page-container { max-width: 1100px; - margin: 0 auto 40px; + margin: 0 auto 38px; padding: 15px 0; } @@ -76,10 +76,25 @@ body::before, .wallpaper-part { height: unset; } +@property --banner-shine { + syntax: '<percentage>'; + initial-value: 0%; + inherits: false; +} + #banner { margin: 10px 0; width: 100%; position: relative; + + --banner-shine: 4%; + -webkit-box-reflect: below -12px linear-gradient(transparent, color-mix(in srgb, transparent, var(--banner-shine) white)); + transition: --banner-shine 0.8s; +} + +#banner:hover { + --banner-shine: 35%; + transition-delay: 0.3s; } #banner::after { @@ -161,10 +176,9 @@ body::before, .wallpaper-part { } .sidebar-column { - flex: 1 1 20%; + flex: 1 1 35%; min-width: 150px; max-width: 250px; - flex-basis: 250px; align-self: flex-start; } @@ -179,6 +193,16 @@ body::before, .wallpaper-part { display: none; } +.sidebar-column.always-content-column { + /* duplicated in thin & medium media query */ + position: static !important; + max-width: unset !important; + flex-basis: unset !important; + margin-right: 0 !important; + margin-left: 0 !important; + width: 100%; +} + .sidebar-multiple { display: flex; flex-direction: column; @@ -228,12 +252,19 @@ body::before, .wallpaper-part { /* Design & Appearance - Layout elements */ +:root { + color-scheme: dark; +} + body { background: black; } body::before { - background-image: url("../../media/bg.jpg"); + /* This is where the basic background-image rule + * gets applied... but the path *to* that media file + * isn't part of the CSS itself anymore! + */ } body::before, .wallpaper-part { @@ -245,7 +276,11 @@ body::before, .wallpaper-part { #page-container { background-color: var(--bg-color, rgba(35, 35, 35, 0.8)); color: #ffffff; - box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); + border-bottom: 2px solid #fff1; + box-shadow: + 0 0 40px #0008, + 0 2px 15px -3px #2221, + 0 2px 6px 2px #1113; } #skippers > * { @@ -335,6 +370,11 @@ body::before, .wallpaper-part { margin: 0; } +.sidebar h2:first-child { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + .sidebar h3 { font-size: 1.1em; font-style: oblique; @@ -386,6 +426,42 @@ body::before, .wallpaper-part { padding-left: 10px; } +.sidebar details.has-tree-list[open] summary { + font-weight: 800; +} + +.sidebar dl.tree-list { + margin-top: 0.25em; + line-height: 1.25em; + padding-left: 15px; +} + +.sidebar dl.tree-list dt { + display: list-item; + list-style-type: disc; + padding-left: 0; + margin-left: 20px; +} + +.sidebar dl.tree-list dl { + padding-left: 15px; +} + +.sidebar dl.tree-list dd { + margin-left: 0; +} + +.sidebar dl.tree-list dt.current a { + font-weight: 800; + border-bottom: 1px solid; +} + +.sidebar .times-used { + opacity: 0.7; + font-size: 0.9em; + cursor: default; +} + .sidebar li.current { font-weight: 800; } @@ -500,6 +576,40 @@ summary.underline-white > span:hover a:not(:hover) { margin-top: 0 !important; } +.track-release-sidebar-box { + --content-padding: 3px; +} + +.track-release-sidebar-box h1 { + margin: 0; + font-weight: normal; + font-size: 0.9em; + font-style: oblique; +} + +.track-release-sidebar-box + .track-release-sidebar-box, +.track-release-sidebar-box + .track-list-sidebar-box, +.track-list-sidebar-box + .track-release-sidebar-box { + margin-top: 5px !important; + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.track-release-sidebar-box:has(+ .track-list-sidebar-box), +.track-list-sidebar-box:has(+ .track-release-sidebar-box) { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.track-list-sidebar-box summary { + padding-left: 20px !important; + text-indent: -15px !important; +} + +.track-list-sidebar-box .track-section-range { + white-space: nowrap; +} + .wiki-search-sidebar-box { padding: 1px 0 0 0; @@ -651,6 +761,96 @@ summary.underline-white > span:hover a:not(:hover) { cursor: default; } +.wiki-search-filter-container { + padding: 4px; +} + +.wiki-search-filter-link { + display: inline-block; + margin: 2px; + padding: 2px 4px; + border: 2px solid transparent; + border-radius: 4px; +} + +.wiki-search-filter-link:where(.active.shown) { + animation: + 0.15s ease 0.00s forwards normal show-filter, + 0.60s linear 0.15s infinite alternate blink-filter; +} + +.wiki-search-filter-link:where(.active:not(.shown)) { + animation: + 0.00s linear 0.00s forwards normal show-filter, + 0.60s linear 0.00s infinite alternate blink-filter; +} + +.wiki-search-filter-link:where(:not(.active).hidden) { + /* We can't just reverse the show-filter animation, + * because that won't actually start it over again. + */ + animation: + 0.15s ease 0.00s forwards reverse show-filter-the-sequel; +} + +.wiki-search-filter-link.active-from-query { + background: var(--primary-color); + border-color: var(--primary-color); + color: #000a; + animation: none; +} + +.wiki-search-filter-link.active-from-query::after { + content: "I"; + color: black; + font-family: monospace; + font-weight: 800; + font-size: 1.2em; + margin-left: 0.5ch; + vertical-align: middle; + animation: 1s steps(2, jump-none) 0.6s infinite blink-caret; +} + +@keyframes show-filter { + from { + background: transparent; + border-color: transparent; + color: var(--primary-color); + } + + to { + background: var(--primary-color); + border-color: var(--primary-color); + color: black; + } +} + +/* Exactly the same as show-filter above. */ +@keyframes show-filter-the-sequel { + from { + background: transparent; + border-color: transparent; + color: var(--primary-color); + } + + to { + background: var(--primary-color); + border-color: var(--primary-color); + color: black; + } +} + +@keyframes blink-filter { + to { + background: color-mix(in srgb, var(--primary-color) 90%, transparent); + } +} + +@keyframes blink-caret { + from { opacity: 0; } + to { opacity: 1; } +} + .wiki-search-result { position: relative; display: flex; @@ -814,6 +1014,10 @@ a:not([href]):hover { text-decoration: none; } +a .normal-content { + color: white; +} + .external-link:not(.from-content) { white-space: nowrap; } @@ -827,8 +1031,26 @@ a:not([href]):hover { color: white; } -.external-link .normal-content { - color: white; +.image-media-link::after { + content: ''; + display: inline-block; + width: 22px; + height: 1em; + + background-color: var(--primary-color); + + /* mask-image is set in content JavaScript, + * because we can't identify the correct nor + * absolute path to the file from CSS. + */ + + mask-repeat: no-repeat; + mask-position: calc(100% - 2px); + vertical-align: text-bottom; +} + +.image-media-link:hover::after { + background-color: white; } .nav-link { @@ -849,29 +1071,46 @@ a:not([href]):hover { font-weight: 800; } -.nav-links-hierarchical .nav-link:not(:first-child)::before { +.nav-links-hierarchical .nav-link + .nav-link::before, +.nav-links-hierarchical .nav-link + .blockwrap .nav-link::before { content: "\0020/\0020"; } -.series-nav-link { +.series-nav-links { display: inline-block; } -.series-nav-link:not(:first-child)::before { +.series-nav-links:not(:first-child)::before { content: "\00a0»\00a0"; font-weight: normal; } -.series-nav-link:not(:last-child)::after { +.series-nav-links:not(:last-child)::after { content: ",\00a0"; } -.series-nav-link + .series-nav-link::before { +.series-nav-links + .series-nav-links::before { content: ""; } +.dot-switcher > span:not(:first-child) { + display: inline-block; + white-space: nowrap; +} + +/* Yeah, all this stuff only applies to elements of the dot switcher + * besides the first, which will necessarily have a bullet point at left. + */ +.dot-switcher *:where(.dot-switcher > span:not(:first-child) > *) { + display: inline-block; + white-space: wrap; + text-align: left; + vertical-align: top; +} + .dot-switcher > span:not(:first-child)::before { content: "\0020\00b7\0020"; + white-space: pre; font-weight: 800; } @@ -898,7 +1137,7 @@ a:not([href]):hover { display: block; } -#secondary-nav.album-secondary-nav.with-previous-next { +#secondary-nav.album-secondary-nav { display: flex; justify-content: space-around; padding-left: 7.5% !important; @@ -916,7 +1155,8 @@ a:not([href]):hover { margin-right: 5px; } -#secondary-nav.album-secondary-nav .dot-switcher { +#secondary-nav.album-secondary-nav .group-nav-links .dot-switcher, +#secondary-nav.album-secondary-nav .series-nav-links .dot-switcher { white-space: nowrap; } @@ -949,7 +1189,9 @@ a:not([href]):hover { .text-with-tooltip.datetimestamp .text-with-tooltip-interaction-cue, .text-with-tooltip.missing-duration .text-with-tooltip-interaction-cue, .text-with-tooltip.commentary-date .text-with-tooltip-interaction-cue, -.text-with-tooltip.wiki-edits .text-with-tooltip-interaction-cue { +.text-with-tooltip.wiki-edits .text-with-tooltip-interaction-cue, +.text-with-tooltip.rerelease .text-with-tooltip-interaction-cue, +.text-with-tooltip.first-release .text-with-tooltip-interaction-cue { cursor: default; } @@ -966,7 +1208,17 @@ a:not([href]):hover { text-decoration: none !important; } +.text-with-tooltip.wiki-edits > .hoverable { + white-space: nowrap; +} + +.isolate-tooltip-z-indexing > * { + position: relative; + z-index: -1; +} + .tooltip { + font-size: 1rem; position: absolute; z-index: 3; left: -10px; @@ -974,7 +1226,12 @@ a:not([href]):hover { display: none; } -li:not(:first-child:last-child) .tooltip, +.cover-artwork .tooltip, +#sidebar .tooltip { + font-size: 0.9rem; +} + +li:not(:first-child:last-child) .tooltip:where(:not(.cover-artwork .tooltip)), .offset-tooltips > :not(:first-child:last-child) .tooltip { left: 14px; } @@ -1006,7 +1263,10 @@ li:not(:first-child:last-child) .tooltip, .datetimestamp-tooltip, .missing-duration-tooltip, -.commentary-date-tooltip { +.commentary-date-tooltip, +.rerelease-tooltip, +.first-release-tooltip, +.content-tooltip { padding: 3px 4px 2px 2px; left: -10px; } @@ -1014,18 +1274,23 @@ li:not(:first-child:last-child) .tooltip, .thing-name-tooltip, .wiki-edits-tooltip { padding: 3px 4px 2px 2px; - left: -6px !important; + left: -6px; } -.wiki-edits-tooltip { +.thing-name-tooltip .tooltip-content, +.wiki-edits-tooltip .tooltip-content { font-size: 0.85em; } -/* Terrifying? - * https://stackoverflow.com/a/64424759/4633828 - */ -.thing-name-tooltip { margin-right: -120px; } -.wiki-edits-tooltip { margin-right: -200px; } +.thing-name-tooltip .tooltip-content { + width: max-content; + max-width: 120px; +} + +.wiki-edits-tooltip .tooltip-content { + width: max-content; + max-width: 200px; +} .contribution-tooltip .tooltip-content { padding: 6px 2px 2px 2px; @@ -1101,11 +1366,16 @@ li:not(:first-child:last-child) .tooltip, font-size: 0.85em; } -.contribution-tooltip .tooltip-divider { +.contribution-tooltip .tooltip-divider, +.tooltip-content hr.cute { grid-column-start: icon-start; grid-column-end: platform-end; - border-top: 1px dotted var(--primary-color); +} + +/* Don't mind me... */ +.tooltip-content .tooltip-divider, +.tooltip-content hr.cute { margin-top: 3px; margin-bottom: 4px; } @@ -1171,6 +1441,37 @@ li:not(:first-child:last-child) .tooltip, padding: 3px 4.5px; } +.rerelease-tooltip .tooltip-content, +.first-release-tooltip .tooltip-content { + padding: 3px 4.5px; + width: 260px; + font-size: 0.9em; +} + +.content-tooltip-guy .hoverable a { + text-decoration-color: transparent; + text-decoration-style: dotted; +} + +.content-tooltip-guy { + display: inline-block; +} + +.content-tooltip-guy.has-link .text-with-tooltip-interaction-cue { + text-decoration-color: var(--primary-color); +} + +.content-tooltip .tooltip-content { + padding: 3px 4.5px; + width: 240px; +} + +.cover-artwork .content-tooltip { + font-size: 0.85rem; + padding: 2px 3px; + width: 220px; +} + .external-icon { display: inline-block; padding: 0 3px; @@ -1187,8 +1488,8 @@ li:not(:first-child:last-child) .tooltip, fill: var(--primary-color); } -.rerelease, -.other-group-accent { +.other-group-accent, +.rerelease-line { opacity: 0.7; font-style: oblique; } @@ -1221,6 +1522,38 @@ s.spoiler::-moz-selection { background: white; } +span.path, code.filename { + font-size: 0.95em; + font-family: "courier new", monospace; + font-weight: 800; + background: #ccc3; + + padding: 0.05em 0.5ch; + border: 1px solid #ccce; + border-radius: 2px; + line-height: 1.4; +} + +.image-details code.filename { + margin-left: -0.4ch; + opacity: 0.8; +} + +.image-details code.filename:hover { + opacity: 1; + cursor: text; +} + +span.path i { + display: inline-block; + font-style: normal; +} + +span.path i::before { + content: "\0020/\0020"; + color: #ccc; +} + progress { accent-color: var(--primary-color); } @@ -1242,12 +1575,16 @@ p .current { font-weight: 800; } -#cover-art-container { +hr.cute, +#content hr.cute, +.sidebar hr.cute { + border-color: var(--primary-color); + border-style: none none dotted none; +} + +.cover-artwork { font-size: 0.8em; border: 2px solid var(--primary-color); - box-shadow: - 0 2px 14px -6px var(--primary-color), - 0 0 12px 12px #00000080; border-radius: 0 0 4px 4px; background: var(--bg-black-color); @@ -1256,37 +1593,64 @@ p .current { backdrop-filter: blur(3px); } -#cover-art-container:has(.image-details), -#cover-art-container.has-image-details { +.cover-artwork:has(.image-details), +.cover-artwork.has-image-details { border-radius: 0 0 6px 6px; } -#cover-art-container:not(:has(.image-details)), -#cover-art-container:not(.has-image-details) { +.cover-artwork:not(:has(.image-details)), +.cover-artwork:not(.has-image-details) { /* Hacky: `overflow: hidden` hides tag tooltips, so it can't be applied * if we've got tags/details visible. But it's okay, because we only * need to apply it if it *doesn't* - that's when the rounded border - * of #cover-art-container needs to cut off its child image-container + * of the .cover-artwork needs to cut off its child .image-container * (which has a background that otherwise causes sharp corners). */ overflow: hidden; } -#cover-art-container .image-container { - /* Border is handled on the cover-art-container. */ +#artwork-column .cover-artwork { + box-shadow: + 0 2px 14px -6px var(--primary-color), + 0 0 12px 12px #00000080; +} + +#artwork-column .cover-artwork:not(:first-child), +#artwork-column .cover-artwork-joiner { + margin-left: 30px; + margin-right: 5px; +} + +#artwork-column .cover-artwork:first-child + .cover-artwork-joiner, +#artwork-column .cover-artwork.attached-artwork-is-main-artwork, +#artwork-column .cover-artwork.attached-artwork-is-main-artwork + .cover-artwork-joiner { + margin-left: 17.5px; + margin-right: 17.5px; +} + +.cover-artwork:where(#artwork-column .cover-artwork:not(:first-child)) { + margin-top: 20px; +} + +#artwork-column .cover-artwork:last-child:not(:first-child) { + margin-bottom: 25px; +} + +.cover-artwork .image-container { + /* Border is handled on the .cover-artwork. */ border: none; - border-radius: 0; + border-radius: 0 !important; } -#cover-art-container .image-details { +.cover-artwork .image-details { border-top-color: var(--deep-color); } -#cover-art-container .image-details + .image-details { +.cover-artwork .image-details + .image-details { border-top-color: var(--primary-color); } -#cover-art-container .image { +.cover-artwork .image { display: block; width: 100%; height: 100%; @@ -1329,6 +1693,10 @@ p .current { margin-bottom: 2px; } +ul.image-details.art-tag-details { + padding-bottom: 0; +} + ul.image-details.art-tag-details li { display: inline-block; } @@ -1337,35 +1705,64 @@ ul.image-details.art-tag-details li:not(:last-child)::after { content: " \00b7 "; } -.image-details.non-unique-details { - font-style: oblique; -} - p.image-details.illustrator-details { text-align: center; font-style: oblique; } -#artist-commentary.first-entry-is-dated { - clear: right; +p.image-details.origin-details { + margin-bottom: 2px; } -.commentary-entry-heading { - display: flex; - flex-direction: row; +p.image-details.origin-details .origin-details { + display: block; + margin-top: 0.25em; +} - margin-left: 15px; - padding-left: 5px; - max-width: 625px; - padding-bottom: 0.2em; +.cover-artwork-joiner { + z-index: -2; +} - border-bottom: 1px solid var(--dim-color); +.cover-artwork-joiner::after { + content: ""; + display: block; + width: 0; + height: 15px; + margin-left: auto; + margin-right: auto; + border-right: 3px solid var(--primary-color); } -.commentary-entry-heading-text { - flex-grow: 1; - padding-left: 1.25ch; +.cover-artwork-joiner + .cover-artwork { + margin-top: 0 !important; +} + +.album-art-info { + font-size: 0.8em; + border: 2px solid var(--deep-color); + + margin: 10px min(15px, 1vw) 15px; + + background: var(--bg-black-color); + padding: 6px; + border-radius: 5px; + + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); +} + +.album-art-info p { + margin: 0; +} + +.commentary-entry-heading { + margin-left: 15px; + padding-left: calc(5px + 1.25ch); text-indent: -1.25ch; + margin-right: min(calc(8vw - 35px), 45px); + padding-bottom: 0.2em; + + border-bottom: 1px solid var(--dim-color); } .commentary-entry-accent { @@ -1373,13 +1770,12 @@ p.image-details.illustrator-details { } .commentary-entry-heading .commentary-date { - flex-shrink: 0; - - margin-left: 0.75ch; - align-self: flex-end; + display: inline-block; + text-indent: 0; +} - padding-left: 0.5ch; - padding-right: 0.25ch; +.commentary-entry-heading.dated .commentary-entry-heading-text { + margin-right: 0.75ch; } .commentary-entry-heading .hoverable { @@ -1394,6 +1790,15 @@ p.image-details.illustrator-details { color: var(--primary-color); } +.inherited-commentary-section { + clear: right; + margin-top: 1em; + margin-right: min(4vw, 60px); + border: 2px solid var(--deep-color); + border-radius: 4px; + background: #ffffff07; +} + .commentary-art { float: right; width: 30%; @@ -1408,6 +1813,33 @@ p.image-details.illustrator-details { box-shadow: 0 0 4px 5px rgba(0, 0, 0, 0.25) !important; } +.lyrics-switcher { + padding-left: 20px; +} + +.lyrics-switcher > span:not(:first-child)::before { + content: "\0020\00b7\0020"; + font-weight: 800; +} + +.lyrics-entry { + padding-left: 40px; +} + +.lyrics-entry .lyrics-details, +.lyrics-entry .origin-details { + font-size: 0.9em; + font-style: oblique; +} + +.lyrics-entry .lyrics-details { + margin-bottom: 0; +} + +.lyrics-entry .origin-details { + margin-top: 0.25em; +} + .js-hide, .js-show-once-data, .js-hide-once-data { @@ -1415,24 +1847,32 @@ p.image-details.illustrator-details { } .content-image-container, -.content-video-container { +.content-video-container, +.content-audio-container { margin-top: 1em; margin-bottom: 1em; } -.content-image-container.align-center, -.content-video-container.align-center { +.content-image-container.align-center { text-align: center; margin-top: 1.5em; margin-bottom: 1.5em; } -a.align-center, img.align-center { +.content-image-container.align-full { + width: 100%; +} + +a.align-center, img.align-center, audio.align-center, video.align-center { display: block; margin-left: auto; margin-right: auto; } +a.align-full, img.align-full, video.align-full { + width: 100%; +} + center { margin-top: 1em; margin-bottom: 1em; @@ -1489,11 +1929,6 @@ h1 { font-size: 2em; } -html[data-url-key="localized.home"] #content h1 { - text-align: center; - font-size: 2.5em; -} - #content.flash-index h2 { text-align: center; font-size: 2.5em; @@ -1548,6 +1983,54 @@ ul.quick-info li:not(:last-child)::after { margin-top: 25px; } +.gallery-set-switcher { + text-align: center; +} + +.gallery-view-switcher { + margin-left: auto; + margin-right: auto; + text-align: center; + line-height: 1.4; +} + +#content.top-index section { + margin-bottom: 1.5em; +} + +.expandable-gallery-section .section-expando { + margin-top: 1em; + margin-bottom: 2em; + + display: flex; + flex-direction: row; + justify-content: space-around; +} + +.expandable-gallery-section .section-expando-content { + text-align: center; + line-height: 1.5; +} + +.expandable-gallery-section .section-expando-toggle { + text-decoration: underline; + text-decoration-style: dotted; +} + +.expandable-gallery-section.expanded .section-content-below-cut { + animation: expand-gallery-section 0.8s forwards; +} + +@keyframes expand-gallery-section { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + .quick-description:not(.has-external-links-only) { --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06); margin-left: auto; @@ -1578,6 +2061,7 @@ ul.quick-info li:not(:last-child)::after { .quick-description > blockquote { margin-left: 0 !important; + margin-right: 0 !important; } .quick-description .description-content.long hr ~ p { @@ -1625,7 +2109,6 @@ ul.quick-info li:not(:last-child)::after { li .by { font-style: oblique; - max-width: 600px; } li .by a { @@ -1641,8 +2124,8 @@ p code { #content blockquote { margin-left: 40px; - max-width: 600px; - margin-right: 0; + margin-right: min(8vw, 75px); + width: auto; } #content blockquote blockquote { @@ -1689,7 +2172,6 @@ main.long-content > h1 { dl dt { padding-left: 40px; - max-width: 600px; } dl dt { @@ -1699,6 +2181,10 @@ dl dt { margin-bottom: 2px; } +dl dt[id]:not(.content-heading) { + --custom-scroll-offset: calc(2.5em - 2px); +} + dl dd { margin-bottom: 1em; } @@ -1714,6 +2200,13 @@ ul > li.has-details { margin-left: -17px; } +li .origin-details { + display: block; + margin-left: 2ch; + font-size: 0.9em; + font-style: oblique; +} + .album-group-list dt, .group-series-list dt { font-style: oblique; @@ -1725,6 +2218,15 @@ ul > li.has-details { margin-left: 0; } +.album-group-list li { + padding-left: 1.5ch; + text-indent: -1.5ch; +} + +.album-group-list li > * { + text-indent: 0; +} + .album-group-list blockquote { max-width: 540px; margin-bottom: 9px; @@ -1755,31 +2257,54 @@ ul > li.has-details { #content hr { border: 1px inset #808080; - width: 100%; +} + +#content hr.split { + color: #808080; } #content hr.split::before { content: "(split)"; - color: #808080; } -#content hr.split { +#content hr.main-separator { + color: var(--dim-color); + clear: none; + margin-top: -0.25em; + margin-bottom: 1.75em; +} + +#content hr.main-separator::before { + content: "♦"; + font-size: 1.2em; +} + +#content hr.split, +#content hr.main-separator { position: relative; overflow: hidden; border: none; } -#content hr.split::after { +#content hr.split::after, +#content hr.main-separator::after { display: inline-block; content: ""; - border: 1px inset #808080; - width: 100%; + width: calc(100% - min(calc(8vw - 35px), 45px)); position: absolute; top: 50%; - margin-top: -2px; margin-left: 10px; } +#content hr.split::after { + border: 1px inset currentColor; + margin-top: -2px; +} + +#content hr.main-separator::after { + border-bottom: 1px solid currentColor; +} + li > ul { margin-top: 5px; } @@ -1894,19 +2419,9 @@ h1 a[href="#additional-names-box"]:hover { --custom-scroll-offset: calc(0.5em - 2px); margin: 1em 0 1em -10px; - padding: 15px 20px 10px 20px; - width: max-content; max-width: min(60vw, 600px); - border: 1px dotted var(--primary-color); - border-radius: 6px; - - background: - linear-gradient(var(--bg-color), var(--bg-color)), - linear-gradient(#000000bb, #000000bb), - var(--primary-color); - - box-shadow: 0 -2px 6px -1px var(--dim-color) inset; + padding: 15px 20px 10px 20px; display: none; } @@ -1943,6 +2458,203 @@ h1 a[href="#additional-names-box"]:hover { vertical-align: text-bottom; } +#content.top-index #additional-names-box { + margin-left: auto; + margin-right: auto; + margin-bottom: 2em; +} + +#content.top-index #additional-names-box { + text-align: center; + margin-bottom: 0.75em; +} + +/* Specific pages - homepage */ + +html[data-url-key="localized.home"] #content h1 { + text-align: center; + font-size: 2.5em; +} + +html[data-language-code="preview-en"][data-url-key="localized.home"] #content h1::after { + font-family: cursive; + display: block; + content: "(Preview Build)"; + font-size: 0.8em; +} + +/* Specific pages - art tag gallery */ + +html[data-url-key="localized.artTagGallery"] #descends-from-line { + margin-bottom: 0.25em; +} + +html[data-url-key="localized.artTagGallery"] #descendants-line { + margin-top: 0.25em; +} + +html[data-url-key="localized.artTagGallery"] #descends-from-line a, +html[data-url-key="localized.artTagGallery"] #descendants-line a { + display: inline-block; +} + + +html[data-url-key="localized.artTagGallery"] #featured-direct-line, +html[data-url-key="localized.artTagGallery"] #featured-indirect-line, +html[data-url-key="localized.artTagGallery"] #showing-direct-line, +html[data-url-key="localized.artTagGallery"] #showing-indirect-line { + display: none; +} + +html[data-url-key="localized.artTagGallery"] #showing-all-line a, +html[data-url-key="localized.artTagGallery"] #showing-direct-line a, +html[data-url-key="localized.artTagGallery"] #showing-indirect-line a { + text-decoration: underline; + text-decoration-style: dotted; +} + +/* Specific pages - "Art Tag Network" listing */ + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd) { + margin-left: 20px; + margin-bottom: 0; + padding-left: 10px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd):not(:last-child) { + padding-bottom: 20px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-stat-line { + padding-left: 10px; + margin-left: 20px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-stat-line a { + text-decoration: underline; + text-decoration-style: dotted; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] .network-tag-stat { + display: inline-block; + text-align: right; + min-width: 5ch; + margin-right: 2px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] #network-top-dl > dt:has(.network-tag.with-stat:not([style*="display: none"])) { + padding-left: 20px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dt + dt:has(+ dd) { + padding-top: 20px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dt:has(+ dd) .network-tag-stat { + text-align: center; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt { + padding-left: 10px; + margin-left: 20px; + margin-bottom: 0; + padding-bottom: 2px; + max-width: unset; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd).even, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:not(#network-top-dl > dt).even { + border-left: 1px solid #eaeaea; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:not(#network-top-dl > dd).odd, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:not(#network-top-dl > dt).odd { + border-left: 1px solid #7b7b7b; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt { + position: relative; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:last-child:not(#network-top-dl > dd).odd::after, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:last-child:not(#network-top-dl > dt).odd::after { + content: ""; + display: block; + width: 7px; + height: 7px; + background: #7b7b7b; + position: absolute; + bottom: -4px; + left: -4px; +} + +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dd:last-child:not(#network-top-dl > dd).even::after, +html[data-url-key="localized.listing"][data-url-value0="tags/network"] dl dt:last-child:not(#network-top-dl > dt).even::after { + content: ""; + display: block; + width: 6px; + height: 6px; + background: #eaeaea; + position: absolute; + bottom: -3px; + left: -3px; + border-bottom-right-radius: 6px; + border-top-left-radius: 3px; +} + +/* "Drops" */ + +.drop { + padding: 15px 20px; + width: max-content; + max-width: min(60vw, 600px); + + border: 1px dotted var(--primary-color); + border-radius: 6px; + + background: + linear-gradient(var(--bg-color), var(--bg-color)), + linear-gradient(#000000bb, #000000bb), + var(--primary-color); + + --drop-shadow: 0 -2px 6px -1px var(--dim-color) inset; + box-shadow: var(--drop-shadow); +} + +.drop.shiny { + cursor: default; +} + +@supports (box-shadow: 1px 1px 1px color-mix(in srgb, blue, 40% red)) { + @property --drop-shine { + syntax: '<percentage>'; + initial-value: 0%; + inherits: false; + } + + .drop.shiny { + cursor: default; + transition: --drop-shine 0.2s; + } + + .drop.shiny:hover { + --drop-shine: 100%; + + box-shadow: + var(--drop-shadow), + 0 2px 4px -0.5px color-mix(in srgb, var(--primary-color), calc(100% - var(--drop-shine)) transparent); + } +} + +.commentary-drop { + margin-top: 25px; + margin-bottom: 15px; + margin-left: 20px; + padding: 10px 20px; + max-width: min(60vw, 300px); +} + /* Images */ .image-container { @@ -1961,15 +2673,46 @@ h1 a[href="#additional-names-box"]:hover { color: white; } -/* Videos (in content) get a lite version of image-container. */ -.content-video-container { - width: min-content; +/* Videos and audios (in content) get a lite version of image-container. */ +.content-video-container, +.content-audio-container { + width: fit-content; + max-width: 100%; background-color: var(--dark-color); border: 2px solid var(--primary-color); border-radius: 2.5px 2.5px 3px 3px; padding: 5px; } +.content-video-container video, +.content-audio-container audio { + display: block; + max-width: 100%; +} + +.content-video-container.align-center, +.content-audio-container.align-center { + margin-left: auto; + margin-right: auto; +} + +.content-video-container.align-full, +.content-audio-container.align-full { + width: 100%; +} + +.content-audio-container .filename { + color: white; + font-family: monospace; + display: block; + font-size: 0.9em; + padding-left: 1ch; + padding-right: 1ch; + padding-bottom: 0.25em; + margin-bottom: 0.5em; + border-bottom: 1px solid #fff4; +} + .image-text-area { position: absolute; top: 0; @@ -2024,6 +2767,12 @@ img { object-fit: cover; } +p > img { + max-width: 100%; + object-fit: contain; + height: auto; +} + .image-inner-area::after { content: ""; display: block; @@ -2254,6 +3003,10 @@ video.pixelate, .pixelate video { max-width: 200px; } +.grid-name-marker { + color: white; +} + .grid-actions { display: flex; flex-direction: row; @@ -2612,14 +3365,32 @@ h3.content-heading { ); } +.content-sticky-heading-root { + width: calc(100% + 2 * var(--content-padding)); + margin: calc(-1 * var(--content-padding)); + margin-bottom: 0; +} + +.content-sticky-heading-anchor, .content-sticky-heading-container { + width: 100%; +} + +.content-sticky-heading-root:not([inert]) { position: sticky; top: 0; +} - margin: calc(-1 * var(--content-padding)); - margin-bottom: calc(0.5 * var(--content-padding)); +.content-sticky-heading-anchor:not(:where(.content-sticky-heading-root[inert]) *) { + position: relative; +} - transform: translateY(-5px); +.content-sticky-heading-container:not(:where(.content-sticky-heading-root[inert]) *) { + position: absolute; +} + +.content-sticky-heading-root[inert] { + visibility: hidden; } main.long-content .content-sticky-heading-container { @@ -2659,9 +3430,60 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r grid-template-columns: 1fr min(40%, 400px); } +.content-sticky-heading-container.cover-visible .content-sticky-heading-row { + grid-template-columns: 1fr min(40%, 90px); +} + +.content-sticky-heading-root.has-cover { + padding-right: min(40%, 400px); +} + .content-sticky-heading-row h1 { + position: relative; margin: 0; padding-right: 20px; + line-height: 1.4; +} + +.content-sticky-heading-row h1 .reference-collapsed-heading { + position: absolute; + white-space: nowrap; + visibility: hidden; +} + +.content-sticky-heading-container.collapse h1 { + white-space: nowrap; + overflow-wrap: normal; + + animation: collapse-sticky-heading 0.35s forwards; + text-overflow: ellipsis; + overflow-x: hidden; +} + +@keyframes collapse-sticky-heading { + from { + height: var(--uncollapsed-heading-height); + } + + 99.9% { + height: var(--collapsed-heading-height); + } + + to { + height: auto; + } +} + +.content-sticky-heading-container h1 a { + transition: text-decoration-color 0.35s; +} + +.content-sticky-heading-container h1 a:not([href]) { + color: inherit; + cursor: text; + text-decoration: underline; + text-decoration-style: dotted; + text-decoration-color: transparent; } .content-sticky-heading-cover-container { @@ -2689,7 +3511,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r transition: transform 0.35s, opacity 0.30s; } -.content-sticky-heading-cover .image-container { +.content-sticky-heading-cover .cover-artwork { border-width: 1px; border-radius: 1.25px; box-shadow: none; @@ -2883,31 +3705,34 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r backdrop-filter: blur(3px); } -#image-overlay-image-container { +#image-overlay-image-area { display: block; - position: relative; overflow: hidden; width: 80vmin; - height: 80vmin; margin-left: auto; margin-right: auto; } +#image-overlay-image-layout { + display: block; + position: relative; + margin: 4px 3px; + background: rgba(0, 0, 0, 0.65); +} + #image-overlay-image, #image-overlay-image-thumb { - display: inline-block; - object-fit: contain; + display: block; width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.65); + height: auto; } #image-overlay-image { position: absolute; - top: 3px; - left: 3px; - width: calc(100% - 6px); - height: calc(100% - 4px); +} + +#image-overlay-container.no-thumb #image-overlay-image { + position: static; } #image-overlay-image-thumb { @@ -2921,7 +3746,7 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r transition: opacity 0.25s; } -#image-overlay-image-container::after { +#image-overlay-image-area::after { content: ""; display: block; position: absolute; @@ -2934,18 +3759,18 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r transition: 0.25s; } -#image-overlay-container.loaded #image-overlay-image-container::after { +#image-overlay-container.loaded #image-overlay-image-area::after { width: 100%; background: white; opacity: 0; } -#image-overlay-container.errored #image-overlay-image-container::after { +#image-overlay-container.errored #image-overlay-image-area::after { width: 100%; background: red; } -#image-overlay-container:not(.visible) #image-overlay-image-container::after { +#image-overlay-container:not(.visible) #image-overlay-image-area::after { width: 0 !important; } @@ -2973,21 +3798,11 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r font-size: 0.9em; } -/* important easter egg mode */ - -html[data-language-code="preview-en"][data-url-key="localized.home"] #content - h1::after { - font-family: cursive; - display: block; - content: "(Preview Build)"; - font-size: 0.8em; -} - /* Layout - Wide (most computers) */ -@media (min-width: 900px) { - #page-container.showing-sidebar-left #secondary-nav:not(.always-visible), - #page-container.showing-sidebar-right #secondary-nav:not(.always-visible) { +@media (min-width: 850px) { + #page-container.showing-sidebar-left:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible), + #page-container.showing-sidebar-right:not(.sidebars-in-content-column) #secondary-nav:not(.always-visible) { display: none; } } @@ -2999,7 +3814,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content * if so desired. */ -@media (min-width: 600px) and (max-width: 899.98px) { +@media (min-width: 600px) and (max-width: 849.98px) { /* Medium layout is mainly defined (to the user) by hiding the sidebar, so * don't apply the similar layout change of widening the long-content area * if this page doesn't have a sidebar to hide in the first place. @@ -3013,7 +3828,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* Layout - Wide or Medium */ @media (min-width: 600px) { - .content-sticky-heading-container { + .content-sticky-heading-root { /* Safari doesn't always play nicely with position: sticky, * this seems to fix images sometimes displaying above the * position: absolute subheading (h2) child @@ -3027,10 +3842,11 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* Cover art floats to the right. It's positioned in HTML beneath the * heading, so pull it up a little to "float" on top. */ - #cover-art-container { + #artwork-column { float: right; width: 40%; - max-width: 400px; + min-width: 220px; + max-width: 280px; margin: -60px 0 10px 20px; position: relative; @@ -3040,7 +3856,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* ...Except on top-indexes, where cover art is displayed prominently * between the heading and subheading. */ - #content.top-index #cover-art-container { + #content.top-index #artwork-column { float: none; margin: 2em auto 2.5em auto; max-width: 375px; @@ -3059,13 +3875,15 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* Layout - Medium or Thin */ -@media (max-width: 899.98px) { +@media (max-width: 849.98px) { .sidebar.collapsible, .sidebar-box-joiner.collapsible, .sidebar-column.all-boxes-collapsible { display: none; } + /* Duplicated for "sidebars in content column" */ + .layout-columns { flex-direction: column; } @@ -3087,11 +3905,53 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content display: none; } + .wiki-search-sidebar-box { + max-height: max(245px, 60vh, calc(100vh - 205px)); + } + + /* End duplicated for "sidebars in content column" */ + .grid-listing > .grid-item { flex-basis: 40%; } } +/* Layout - "sidebars in content column" + * This is the same code as immediately above, for medium and + * thin layouts, but can be opted into by the page itself + * instead of through a media query. + */ + +#page-container.sidebars-in-content-column +.layout-columns { + flex-direction: column; +} + +#page-container.sidebars-in-content-column +.layout-columns > *:not(:last-child) { + margin-bottom: 10px; +} + +#page-container.sidebars-in-content-column +.sidebar-column { + position: static !important; + max-width: unset !important; + flex-basis: unset !important; + margin-right: 0 !important; + margin-left: 0 !important; + width: 100%; +} + +#page-container.sidebars-in-content-column +.sidebar .news-entry:not(.first-news-entry) { + display: none; +} + +#page-container.sidebars-in-content-column +.wiki-search-sidebar-box { + max-height: max(245px, 60vh, calc(100vh - 205px)); +} + /* Layout - Thin (phones) */ @media (max-width: 600px) { @@ -3103,12 +3963,18 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content --responsive-padding-ratio: 0.02; } - #cover-art-container { + #artwork-column { margin: 25px 0 5px 0; width: 100%; max-width: unset; } + #artwork-column .cover-artwork:not(:first-child), + #artwork-column .cover-artwork-joiner { + margin-left: 30px; + margin-right: 30px; + } + #additional-names-box { width: unset; max-width: unset; @@ -3120,7 +3986,7 @@ html[data-language-code="preview-en"][data-url-key="localized.home"] #content /* Show sticky heading above cover art */ - .content-sticky-heading-container { + .content-sticky-heading-root { z-index: 2; } diff --git a/src/static/js/client-util.js b/src/static/js/client-util.js index f06b707a..71112313 100644 --- a/src/static/js/client-util.js +++ b/src/static/js/client-util.js @@ -1,12 +1,19 @@ /* eslint-env browser */ export function rebase(href, rebaseKey = 'rebaseLocalized') { - const relative = (document.documentElement.dataset[rebaseKey] || '.') + '/'; - if (relative) { - return relative + href; - } else { - return href; + let result = document.documentElement.dataset[rebaseKey] || './'; + + if (!result.endsWith('/')) { + result += '/'; + } + + if (href.startsWith('/')) { + href = href.slice(1); } + + result += href; + + return result; } export function cssProp(el, ...args) { diff --git a/src/static/js/client/additional-names-box.js b/src/static/js/client/additional-names-box.js index 558ef06f..195ba25d 100644 --- a/src/static/js/client/additional-names-box.js +++ b/src/static/js/client/additional-names-box.js @@ -3,12 +3,17 @@ import {cssProp} from '../client-util.js'; import {info as hashLinkInfo} from './hash-link.js'; +import {info as stickyHeadingInfo} from './sticky-heading.js'; export const info = { id: 'additionalNamesBoxInfo', box: null, + links: null, + stickyHeadingLink: null, + + contentContainer: null, mainContentContainer: null, state: { @@ -23,6 +28,16 @@ export function getPageReferences() { info.links = document.querySelectorAll('a[href="#additional-names-box"]'); + info.stickyHeadingLink = + document.querySelector( + '.content-sticky-heading-container' + + ' ' + + 'a[href="#additional-names-box"]' + + ':not(:where([inert] *))'); + + info.contentContainer = + document.querySelector('#content'); + info.mainContentContainer = document.querySelector('#content .main-content-container'); } @@ -33,6 +48,34 @@ export function addInternalListeners() { return false; } }); + + stickyHeadingInfo.event.whenStuckStatusChanges.push((index, stuck) => { + const {state} = info; + + if (!info.stickyHeadingLink) return; + + const container = stickyHeadingInfo.contentContainers[index]; + if (container !== info.contentContainer) return; + + if (stuck) { + if (!state.visible) { + info.stickyHeadingLink.removeAttribute('href'); + + if (info.stickyHeadingLink.hasAttribute('title')) { + info.stickyHeadingLink.dataset.restoreTitle = info.stickyHeadingLink.getAttribute('title'); + info.stickyHeadingLink.removeAttribute('title'); + } + } + } else { + info.stickyHeadingLink.setAttribute('href', '#additional-names-box'); + + const {restoreTitle} = info.stickyHeadingLink.dataset; + if (restoreTitle) { + info.stickyHeadingLink.setAttribute('title', restoreTitle); + delete info.stickyHeadingLink.dataset.restoreTitle; + } + } + }); } export function addPageListeners() { @@ -48,6 +91,7 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) { domEvent.preventDefault(); + if (!domEvent.target.hasAttribute('href')) return; if (!info.box || !info.mainContentContainer) return; const margin = @@ -58,7 +102,30 @@ function handleAdditionalNamesBoxLinkClicked(domEvent) { ? info.box.getBoundingClientRect() : info.mainContentContainer.getBoundingClientRect()); - if (top + 20 < margin || top > 0.4 * window.innerHeight) { + const {bottom, height} = + (state.visible + ? info.box.getBoundingClientRect() + : {bottom: null}); + + const boxFitsInFrame = + (height + ? height < window.innerHeight - margin - 60 + : null); + + const worthScrolling = + top + 20 < margin || + + (height && boxFitsInFrame + ? top > 0.7 * window.innerHeight + : height && !boxFitsInFrame + ? top > 0.4 * window.innerHeight + : top > 0.5 * window.innerHeight) || + + (bottom && boxFitsInFrame + ? bottom > window.innerHeight - 20 + : false); + + if (worthScrolling) { if (!state.visible) { toggleAdditionalNamesBox(); } diff --git a/src/static/js/client/art-tag-gallery-filter.js b/src/static/js/client/art-tag-gallery-filter.js new file mode 100644 index 00000000..fd40d1a2 --- /dev/null +++ b/src/static/js/client/art-tag-gallery-filter.js @@ -0,0 +1,151 @@ +/* eslint-env browser */ + +export const info = { + id: 'artTagGalleryFilterInfo', + + featuredAllLine: null, + showingAllLine: null, + showingAllLink: null, + + featuredDirectLine: null, + showingDirectLine: null, + showingDirectLink: null, + + featuredIndirectLine: null, + showingIndirectLine: null, + showingIndirectLink: null, + + gridItems: null, + gridItemsOnlyFeaturedIndirectly: null, + gridItemsFeaturedDirectly: null, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.artTagGallery') { + return; + } + + info.featuredAllLine = + document.getElementById('featured-all-line'); + + info.featuredDirectLine = + document.getElementById('featured-direct-line'); + + info.featuredIndirectLine = + document.getElementById('featured-indirect-line'); + + info.showingAllLine = + document.getElementById('showing-all-line'); + + info.showingDirectLine = + document.getElementById('showing-direct-line'); + + info.showingIndirectLine = + document.getElementById('showing-indirect-line'); + + info.showingAllLink = + info.showingAllLine?.querySelector('a') ?? null; + + info.showingDirectLink = + info.showingDirectLine?.querySelector('a') ?? null; + + info.showingIndirectLink = + info.showingIndirectLine?.querySelector('a') ?? null; + + info.gridItems = + Array.from( + document.querySelectorAll('#content .grid-listing .grid-item')); + + info.gridItemsOnlyFeaturedIndirectly = + info.gridItems + .filter(gridItem => gridItem.classList.contains('featured-indirectly')); + + info.gridItemsFeaturedDirectly = + info.gridItems + .filter(gridItem => !gridItem.classList.contains('featured-indirectly')); +} + +function filterArtTagGallery(showing) { + let gridItemsToShow; + + switch (showing) { + case 'all': + gridItemsToShow = info.gridItems; + break; + + case 'direct': + gridItemsToShow = info.gridItemsFeaturedDirectly; + break; + + case 'indirect': + gridItemsToShow = info.gridItemsOnlyFeaturedIndirectly; + break; + } + + for (const gridItem of info.gridItems) { + if (gridItemsToShow.includes(gridItem)) { + gridItem.style.removeProperty('display'); + } else { + gridItem.style.display = 'none'; + } + } +} + +export function addPageListeners() { + const orderShowing = [ + 'all', + 'direct', + 'indirect', + ]; + + const orderFeaturedLines = [ + info.featuredAllLine, + info.featuredDirectLine, + info.featuredIndirectLine, + ]; + + const orderShowingLines = [ + info.showingAllLine, + info.showingDirectLine, + info.showingIndirectLine, + ]; + + const orderShowingLinks = [ + info.showingAllLink, + info.showingDirectLink, + info.showingIndirectLink, + ]; + + for (let index = 0; index < orderShowing.length; index++) { + if (!orderShowingLines[index]) continue; + + let nextIndex = index; + do { + if (nextIndex === orderShowing.length) { + nextIndex = 0; + } else { + nextIndex++; + } + } while (!orderShowingLinks[nextIndex]); + + const currentFeaturedLine = orderFeaturedLines[index]; + const currentShowingLine = orderShowingLines[index]; + const currentShowingLink = orderShowingLinks[index]; + + const nextFeaturedLine = orderFeaturedLines[nextIndex]; + const nextShowingLine = orderShowingLines[nextIndex]; + const nextShowing = orderShowing[nextIndex]; + + currentShowingLink.addEventListener('click', event => { + event.preventDefault(); + + currentFeaturedLine.style.display = 'none'; + currentShowingLine.style.display = 'none'; + + nextFeaturedLine.style.display = 'block'; + nextShowingLine.style.display = 'block'; + + filterArtTagGallery(nextShowing); + }); + } +} diff --git a/src/static/js/client/art-tag-network.js b/src/static/js/client/art-tag-network.js new file mode 100644 index 00000000..44e10c11 --- /dev/null +++ b/src/static/js/client/art-tag-network.js @@ -0,0 +1,147 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {atOffset, stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'artTagNetworkInfo', + + noneStatLink: null, + totalUsesStatLink: null, + directUsesStatLink: null, + descendantsStatLink: null, + leavesStatLink: null, + + tagsWithoutStats: null, + tagsWithStats: null, + + totalUsesStats: null, + directUsesStats: null, + descendantsStats: null, + leavesStats: null, +}; + +export function getPageReferences() { + if ( + document.documentElement.dataset.urlKey !== 'localized.listing' || + document.documentElement.dataset.urlValue0 !== 'tags/network' + ) { + return; + } + + info.noneStatLink = + document.getElementById('network-stat-none'); + + info.totalUsesStatLink = + document.getElementById('network-stat-total-uses'); + + info.directUsesStatLink = + document.getElementById('network-stat-direct-uses'); + + info.descendantsStatLink = + document.getElementById('network-stat-descendants'); + + info.leavesStatLink = + document.getElementById('network-stat-leaves'); + + info.tagsWithoutStats = + document.querySelectorAll('.network-tag:not(.with-stat)'); + + info.tagsWithStats = + document.querySelectorAll('.network-tag.with-stat'); + + info.totalUsesStats = + Array.from(document.getElementsByClassName('network-tag-total-uses-stat')); + + info.directUsesStats = + Array.from(document.getElementsByClassName('network-tag-direct-uses-stat')); + + info.descendantsStats = + Array.from(document.getElementsByClassName('network-tag-descendants-stat')); + + info.leavesStats = + Array.from(document.getElementsByClassName('network-tag-leaves-stat')); +} + +export function addPageListeners() { + if (!info.noneStatLink) return; + + const linkOrder = [ + info.noneStatLink, + info.totalUsesStatLink, + info.directUsesStatLink, + info.descendantsStatLink, + info.leavesStatLink, + ]; + + const statsOrder = [ + null, + info.totalUsesStats, + info.directUsesStats, + info.descendantsStats, + info.leavesStats, + ]; + + const stitched = + stitchArrays({ + link: linkOrder, + stats: statsOrder, + }); + + for (const [index, {link}] of stitched.entries()) { + const next = atOffset(stitched, index, +1, {wrap: true}); + + link.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + cssProp(link, 'display', 'none'); + cssProp(next.link, 'display', null); + + if (next.stats === null) { + hideArtTagNetworkStats(); + } else { + showArtTagNetworkStats(next.stats); + } + }); + } +} + +function showArtTagNetworkStats(stats) { + for (const tagElement of info.tagsWithoutStats) { + cssProp(tagElement, 'display', 'none'); + } + + for (const tagElement of info.tagsWithStats) { + cssProp(tagElement, 'display', null); + } + + const allStats = [ + ...info.totalUsesStats, + ...info.directUsesStats, + ...info.descendantsStats, + ...info.leavesStats, + ]; + + const otherStats = + allStats + .filter(stat => !stats.includes(stat)); + + for (const statElement of otherStats) { + cssProp(statElement, 'display', 'none'); + } + + for (const statElement of stats) { + cssProp(statElement, 'display', null); + } +} + +function hideArtTagNetworkStats() { + for (const tagElement of info.tagsWithoutStats) { + cssProp(tagElement, 'display', null); + } + + for (const tagElement of info.tagsWithStats) { + cssProp(tagElement, 'display', 'none'); + } +} diff --git a/src/static/js/client/css-compatibility-assistant.js b/src/static/js/client/css-compatibility-assistant.js index 6e7b15b5..aa637cc4 100644 --- a/src/static/js/client/css-compatibility-assistant.js +++ b/src/static/js/client/css-compatibility-assistant.js @@ -1,22 +1,30 @@ /* eslint-env browser */ +import {stitchArrays} from '../../shared-util/sugar.js'; + export const info = { id: 'cssCompatibilityAssistantInfo', - coverArtContainer: null, - coverArtImageDetails: null, + coverArtworks: null, + coverArtworkImageDetails: null, }; export function getPageReferences() { - info.coverArtContainer = - document.getElementById('cover-art-container'); + info.coverArtworks = + Array.from(document.querySelectorAll('.cover-artwork')); - info.coverArtImageDetails = - info.coverArtContainer?.querySelector('.image-details'); + info.coverArtworkImageDetails = + info.coverArtworks + .map(artwork => artwork.querySelector('.image-details')); } export function mutatePageContent() { - if (info.coverArtImageDetails) { - info.coverArtContainer.classList.add('has-image-details'); - } + stitchArrays({ + coverArtwork: info.coverArtworks, + imageDetails: info.coverArtworkImageDetails, + }).forEach(({coverArtwork, imageDetails}) => { + if (imageDetails) { + coverArtwork.classList.add('has-image-details'); + } + }); } diff --git a/src/static/js/client/expandable-gallery-section.js b/src/static/js/client/expandable-gallery-section.js new file mode 100644 index 00000000..dc83e8b7 --- /dev/null +++ b/src/static/js/client/expandable-gallery-section.js @@ -0,0 +1,77 @@ +/* eslint-env browser */ + +// TODO: Combine this and quick-description.js + +import {cssProp} from '../client-util.js'; + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'expandableGallerySectionInfo', + + sections: null, + + sectionContentBelowCut: null, + + sectionExpandoToggles: null, + + sectionExpandCues: null, + sectionCollapseCues: null, +}; + +export function getPageReferences() { + info.sections = + Array.from(document.querySelectorAll('.expandable-gallery-section')) + .filter(section => section.querySelector('.section-expando-toggle')); + + info.sectionContentBelowCut = + info.sections + .map(section => section.querySelector('.section-content-below-cut')); + + info.sectionExpandoToggles = + info.sections + .map(section => section.querySelector('.section-expando-toggle')); + + info.sectionExpandCues = + info.sections + .map(section => section.querySelector('.section-expand-cue')); + + info.sectionCollapseCues = + info.sections + .map(section => section.querySelector('.section-collapse-cue')); +} + +export function addPageListeners() { + for (const { + section, + contentBelowCut, + expandoToggle, + expandCue, + collapseCue, + } of stitchArrays({ + section: info.sections, + contentBelowCut: info.sectionContentBelowCut, + expandoToggle: info.sectionExpandoToggles, + expandCue: info.sectionExpandCues, + collapseCue: info.sectionCollapseCues, + })) { + expandoToggle.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + const collapsed = + cssProp(contentBelowCut, 'display') === 'none'; + + if (collapsed) { + section.classList.add('expanded'); + cssProp(contentBelowCut, 'display', null); + cssProp(expandCue, 'display', 'none'); + cssProp(collapseCue, 'display', null); + } else { + section.classList.remove('expanded'); + cssProp(contentBelowCut, 'display', 'none'); + cssProp(expandCue, 'display', null); + cssProp(collapseCue, 'display', 'none'); + } + }); + } +} diff --git a/src/static/js/client/hoverable-tooltip.js b/src/static/js/client/hoverable-tooltip.js index 484f2ab0..89119a47 100644 --- a/src/static/js/client/hoverable-tooltip.js +++ b/src/static/js/client/hoverable-tooltip.js @@ -118,17 +118,17 @@ export function registerTooltipElement(tooltip) { handleTooltipMouseLeft(tooltip); }); - tooltip.addEventListener('focusin', event => { - handleTooltipReceivedFocus(tooltip, event.relatedTarget); + tooltip.addEventListener('focusin', domEvent => { + handleTooltipReceivedFocus(tooltip, domEvent.relatedTarget); }); - tooltip.addEventListener('focusout', event => { + tooltip.addEventListener('focusout', domEvent => { // This event gets activated for tabbing *between* links inside the // tooltip, which is no good and certainly doesn't represent the focus // leaving the tooltip. - if (currentlyShownTooltipHasFocus(event.relatedTarget)) return; + if (currentlyShownTooltipHasFocus(domEvent.relatedTarget)) return; - handleTooltipLostFocus(tooltip, event.relatedTarget); + handleTooltipLostFocus(tooltip, domEvent.relatedTarget); }); } @@ -158,20 +158,20 @@ export function registerTooltipHoverableElement(hoverable, tooltip) { handleTooltipHoverableMouseLeft(hoverable); }); - hoverable.addEventListener('focusin', event => { - handleTooltipHoverableReceivedFocus(hoverable, event); + hoverable.addEventListener('focusin', domEvent => { + handleTooltipHoverableReceivedFocus(hoverable, domEvent); }); - hoverable.addEventListener('focusout', event => { - handleTooltipHoverableLostFocus(hoverable, event); + hoverable.addEventListener('focusout', domEvent => { + handleTooltipHoverableLostFocus(hoverable, domEvent); }); - hoverable.addEventListener('touchend', event => { - handleTooltipHoverableTouchEnded(hoverable, event); + hoverable.addEventListener('touchend', domEvent => { + handleTooltipHoverableTouchEnded(hoverable, domEvent); }); - hoverable.addEventListener('click', event => { - handleTooltipHoverableClicked(hoverable, event); + hoverable.addEventListener('click', domEvent => { + handleTooltipHoverableClicked(hoverable, domEvent); }); } @@ -416,7 +416,7 @@ function handleTooltipHoverableTouchEnded(hoverable, domEvent) { }, 1200); } -function handleTooltipHoverableClicked(hoverable) { +function handleTooltipHoverableClicked(hoverable, domEvent) { const {state} = info; // Don't navigate away from the page if the this hoverable was recently @@ -426,7 +426,7 @@ function handleTooltipHoverableClicked(hoverable) { state.currentlyActiveHoverable === hoverable && state.hoverableWasRecentlyTouched ) { - event.preventDefault(); + domEvent.preventDefault(); } } @@ -576,6 +576,17 @@ export function showTooltipFromHoverable(hoverable) { hoverable.classList.add('has-visible-tooltip'); + const isolator = + hoverable.closest('.isolate-tooltip-z-indexing > *'); + + if (isolator) { + for (const child of isolator.parentElement.children) { + cssProp(child, 'z-index', null); + } + + cssProp(isolator, 'z-index', '1'); + } + positionTooltipFromHoverableWithBrains(hoverable); cssProp(tooltip, 'display', 'block'); @@ -667,12 +678,12 @@ export function positionTooltipFromHoverableWithBrains(hoverable) { for (let i = 0; i < numBaselineRects; i++) { for (const [dir1, dir2] of [ + ['down', 'right'], + ['down', 'left'], ['right', 'down'], ['left', 'down'], ['right', 'up'], ['left', 'up'], - ['down', 'right'], - ['down', 'left'], ['up', 'right'], ['up', 'left'], ]) { @@ -995,6 +1006,14 @@ export function getTooltipBaselineOpportunityAreas(tooltip) { return results; } +export function mutatePageContent() { + for (const isolatorRoot of document.querySelectorAll('.isolate-tooltip-z-indexing')) { + if (isolatorRoot.firstElementChild) { + cssProp(isolatorRoot.firstElementChild, 'z-index', '1'); + } + } +} + export function addPageListeners() { const {state} = info; diff --git a/src/static/js/client/image-overlay.js b/src/static/js/client/image-overlay.js index b51d57a4..e9e2708d 100644 --- a/src/static/js/client/image-overlay.js +++ b/src/static/js/client/image-overlay.js @@ -66,8 +66,13 @@ export function getPageReferences() { info.fileSizeWarning = document.getElementById('image-overlay-file-size-warning'); + const linkQuery = [ + '.image-link', + '.image-media-link', + ].join(', '); + info.links = - Array.from(document.querySelectorAll('.image-link')) + Array.from(document.querySelectorAll(linkQuery)) .filter(link => !link.closest('.no-image-preview')); } @@ -88,10 +93,13 @@ function handleContainerClicked(evt) { return; } - // If you clicked anything close to or beneath the action bar, don't hide - // the image overlay. + // If you clicked anything near the action bar, don't hide the + // image overlay. const rect = info.actionContainer.getBoundingClientRect(); - if (evt.clientY >= rect.top - 40) { + if ( + evt.clientY >= rect.top - 40 && evt.clientY <= rect.bottom + 40 && + evt.clientX >= rect.left + 20 && evt.clientX <= rect.right - 20 + ) { return; } @@ -141,13 +149,23 @@ function getImageLinkDetails(imageLink) { a.href, embeddedSrc: - img.src, + img?.src ?? + a.dataset.embedSrc, originalFileSize: - img.dataset.originalSize, + img?.dataset.originalSize ?? + a.dataset.originalSize ?? + null, availableThumbList: - img.dataset.thumbs, + img?.dataset.thumbs ?? + a.dataset.thumbs ?? + null, + + dimensions: + img?.dataset.dimensions?.split('x') ?? + a.dataset.dimensions?.split('x') ?? + null, color: cssProp(imageLink, '--primary-color'), @@ -170,7 +188,7 @@ function getImageSources(details) { }; } else { return { - mainSrc: originalSrc, + mainSrc: details.originalSrc, thumbSrc: null, mainThumb: '', thumbThumb: '', @@ -211,15 +229,31 @@ async function loadOverlayImage(details) { if (details.thumbSrc) { info.thumbImage.src = details.thumbSrc; info.thumbImage.style.display = null; + info.container.classList.remove('no-thumb'); } else { info.thumbImage.src = ''; info.thumbImage.style.display = 'none'; + info.container.classList.add('no-thumb'); } // Show the thumbnail size on each <img> element's data attributes. // Y'know, just for debugging convenience. info.mainImage.dataset.displayingThumb = details.mainThumb; - info.thumbImage.dataset.displayingThumb = details.thumbThubm; + info.thumbImage.dataset.displayingThumb = details.thumbThumb; + + if (details.dimensions) { + info.mainImage.width = details.dimensions[0]; + info.mainImage.height = details.dimensions[1]; + info.thumbImage.width = details.dimensions[0]; + info.thumbImage.height = details.dimensions[1]; + cssProp(info.thumbImage, 'aspect-ratio', details.dimensions.join('/')); + } else { + info.mainImage.removeAttribute('width'); + info.mainImage.removeAttribute('height'); + info.thumbImage.removeAttribute('width'); + info.thumbImage.removeAttribute('height'); + cssProp(info.thumbImage, 'aspect-ratio', null); + } info.mainImage.addEventListener('load', handleMainImageLoaded); info.mainImage.addEventListener('error', handleMainImageErrored); diff --git a/src/static/js/client/index.js b/src/static/js/client/index.js index 52d2afd6..aeb9264a 100644 --- a/src/static/js/client/index.js +++ b/src/static/js/client/index.js @@ -4,10 +4,13 @@ import '../group-contributions-table.js'; import * as additionalNamesBoxModule from './additional-names-box.js'; import * as albumCommentarySidebarModule from './album-commentary-sidebar.js'; +import * as artTagGalleryFilterModule from './art-tag-gallery-filter.js'; +import * as artTagNetworkModule from './art-tag-network.js'; import * as artistExternalLinkTooltipModule from './artist-external-link-tooltip.js'; import * as cssCompatibilityAssistantModule from './css-compatibility-assistant.js'; import * as datetimestampTooltipModule from './datetimestamp-tooltip.js'; import * as draggedLinkModule from './dragged-link.js'; +import * as expandableGallerySectionModule from './expandable-gallery-section.js'; import * as hashLinkModule from './hash-link.js'; import * as hoverableTooltipModule from './hoverable-tooltip.js'; import * as imageOverlayModule from './image-overlay.js'; @@ -24,10 +27,13 @@ import * as wikiSearchModule from './wiki-search.js'; export const modules = [ additionalNamesBoxModule, albumCommentarySidebarModule, + artTagGalleryFilterModule, + artTagNetworkModule, artistExternalLinkTooltipModule, cssCompatibilityAssistantModule, datetimestampTooltipModule, draggedLinkModule, + expandableGallerySectionModule, hashLinkModule, hoverableTooltipModule, imageOverlayModule, diff --git a/src/static/js/client/quick-description.js b/src/static/js/client/quick-description.js index cff82252..6a7a6023 100644 --- a/src/static/js/client/quick-description.js +++ b/src/static/js/client/quick-description.js @@ -1,5 +1,7 @@ /* eslint-env browser */ +// TODO: Combine this and expandable-gallery-section.js + import {stitchArrays} from '../../shared-util/sugar.js'; export const info = { diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js index c79fb837..eae1e74e 100644 --- a/src/static/js/client/sidebar-search.js +++ b/src/static/js/client/sidebar-search.js @@ -1,7 +1,7 @@ /* eslint-env browser */ import {getColors} from '../../shared-util/colors.js'; -import {accumulateSum, empty} from '../../shared-util/sugar.js'; +import {accumulateSum, empty, unique} from '../../shared-util/sugar.js'; import { cssProp, @@ -41,6 +41,14 @@ export const info = { failedRule: null, failedContainer: null, + filterContainer: null, + albumFilterLink: null, + artistFilterLink: null, + flashFilterLink: null, + groupFilterLink: null, + tagFilterLink: null, + trackFilterLink: null, + resultsRule: null, resultsContainer: null, results: null, @@ -49,6 +57,8 @@ export const info = { endSearchLine: null, endSearchLink: null, + standbyInputPlaceholder: null, + preparingString: null, loadingDataString: null, searchingString: null, @@ -63,12 +73,22 @@ export const info = { groupResultKindString: null, tagResultKindString: null, + albumResultFilterString: null, + artistResultFilterString: null, + flashResultFilterString: null, + groupResultFilterString: null, + tagResultFilterString: null, + trackResultFilterString: null, + state: { sidebarColumnShownForSearch: null, tidiedSidebar: null, collapsedDetailsForTidiness: null, + recallingRecentSearch: null, + recallingRecentSearchFromMouse: null, + currentValue: null, workerStatus: null, @@ -92,6 +112,10 @@ export const info = { maxLength: settings => settings.maxActiveResultsStorage, }, + activeFilterType: { + type: 'string', + }, + repeatQueryOnReload: { type: 'boolean', default: false, @@ -133,6 +157,9 @@ export function getPageReferences() { info.searchSidebarColumn = info.searchBox.closest('.sidebar-column'); + info.standbyInputPlaceholder = + info.searchInput.placeholder; + const findString = classPart => info.searchBox.querySelector(`.wiki-search-${classPart}-string`); @@ -168,6 +195,24 @@ export function getPageReferences() { info.tagResultKindString = findString('tag-result-kind'); + + info.albumResultFilterString = + findString('album-result-filter'); + + info.artistResultFilterString = + findString('artist-result-filter'); + + info.flashResultFilterString = + findString('flash-result-filter'); + + info.groupResultFilterString = + findString('group-result-filter'); + + info.tagResultFilterString = + findString('tag-result-filter'); + + info.trackResultFilterString = + findString('track-result-filter'); } export function addInternalListeners() { @@ -257,6 +302,38 @@ export function mutatePageContent() { info.searchBox.appendChild(info.failedRule); info.searchBox.appendChild(info.failedContainer); + // Filter section + + info.filterContainer = + document.createElement('div'); + + info.filterContainer.classList.add('wiki-search-filter-container'); + + cssProp(info.filterContainer, 'display', 'none'); + + forEachFilter((type, _filterLink) => { + // TODO: It's probably a sin to access `session` during this step LOL + const {session} = info; + + const filterLink = document.createElement('a'); + + filterLink.href = '#'; + filterLink.classList.add('wiki-search-filter-link'); + + if (session.activeFilterType === type) { + filterLink.classList.add('active'); + } + + const string = info[type + 'ResultFilterString']; + filterLink.appendChild(templateContent(string)); + + info[type + 'FilterLink'] = filterLink; + + info.filterContainer.appendChild(filterLink); + }); + + info.searchBox.appendChild(info.filterContainer); + // Results section info.resultsRule = @@ -310,6 +387,43 @@ export function mutatePageContent() { export function addPageListeners() { if (!info.searchInput) return; + info.searchInput.addEventListener('mousedown', _domEvent => { + const {state} = info; + + if (state.recallingRecentSearch) { + state.recallingRecentSearchFromMouse = true; + } + }); + + info.searchInput.addEventListener('focus', _domEvent => { + const {session, state} = info; + + if (state.recallingRecentSearch) { + info.searchInput.value = session.activeQuery; + info.searchInput.placeholder = info.standbyInputPlaceholder; + showSidebarSearchResults(session.activeQueryResults); + state.recallingRecentSearch = false; + } + }); + + info.searchLabel.addEventListener('click', domEvent => { + const {state} = info; + + if (state.recallingRecentSearchFromMouse) { + if (info.searchInput.selectionStart === info.searchInput.selectionEnd) { + info.searchInput.select(); + } + + state.recallingRecentSearchFromMouse = false; + return; + } + + const inputRect = info.searchInput.getBoundingClientRect(); + if (domEvent.clientX < inputRect.left - 3) { + info.searchInput.select(); + } + }); + info.searchInput.addEventListener('change', _domEvent => { const {state} = info; @@ -326,7 +440,7 @@ export function addPageListeners() { const {settings, state} = info; if (!info.searchInput.value) { - clearSidebarSearch(); + clearSidebarSearch(); // ...but don't clear filter return; } @@ -388,10 +502,18 @@ export function addPageListeners() { info.endSearchLink.addEventListener('click', domEvent => { domEvent.preventDefault(); clearSidebarSearch(); + clearSidebarFilter(); possiblyHideSearchSidebarColumn(); restoreSidebarSearchColumn(); }); + forEachFilter((type, filterLink) => { + filterLink.addEventListener('click', domEvent => { + domEvent.preventDefault(); + toggleSidebarSearchFilter(type); + }); + }); + info.resultsContainer.addEventListener('scroll', () => { const {settings, state} = info; @@ -412,11 +534,11 @@ export function initializeState() { if (!info.searchInput) return; if (session.activeQuery) { - info.searchInput.value = session.activeQuery; if (session.repeatQueryOnReload) { + info.searchInput.value = session.activeQuery; activateSidebarSearch(session.activeQuery); } else if (session.activeQueryResults) { - showSidebarSearchResults(session.activeQueryResults); + considerRecallingRecentSidebarSearch(); } } } @@ -473,9 +595,28 @@ function trackSidebarSearchDownloadEnds(event) { } } +function forEachFilter(callback) { + const filterOrder = [ + 'track', + 'album', + 'artist', + 'group', + 'flash', + 'tag', + ]; + + for (const type of filterOrder) { + callback(type, info[type + 'FilterLink']); + } +} + async function activateSidebarSearch(query) { const {session, state} = info; + if (!query) { + return; + } + if (state.stoppedTypingTimeout) { clearTimeout(state.stoppedTypingTimeout); state.stoppedTypingTimeout = null; @@ -535,6 +676,16 @@ function clearSidebarSearch() { hideSidebarSearchResults(); } +function clearSidebarFilter() { + const {session} = info; + + toggleSidebarSearchFilter(session.activeFilterType); + + forEachFilter((_type, filterLink) => { + filterLink.classList.remove('shown', 'hidden'); + }); +} + function updateSidebarSearchStatus() { const {state} = info; @@ -621,54 +772,122 @@ function showSidebarSearchFailed() { } function showSidebarSearchResults(results) { - console.debug(`Showing search results:`, results); + const {session} = info; - showSearchSidebarColumn(); + console.debug(`Showing search results:`, tidyResults(results)); - const flatResults = - Object.entries(results) - .filter(([index]) => index === 'generic') - .flatMap(([index, results]) => results - .flatMap(({doc, id}) => ({ - index, - reference: id ?? null, - referenceType: (id ? id.split(':')[0] : null), - directory: (id ? id.split(':')[1] : null), - data: doc, - }))); + showSearchSidebarColumn(); info.searchBox.classList.add('showing-results'); info.searchSidebarColumn.classList.add('search-showing-results'); - while (info.results.firstChild) { - info.results.firstChild.remove(); + let filterType = session.activeFilterType; + let shownAnyResults = + fillResultElements(results, {filterType: session.activeFilterType}); + + showFilterElements(results); + + if (!shownAnyResults) { + shownAnyResults = toggleSidebarSearchFilter(filterType); + filterType = null; } - cssProp(info.resultsRule, 'display', 'block'); - cssProp(info.resultsContainer, 'display', 'block'); + if (shownAnyResults) { + cssProp(info.endSearchRule, 'display', 'block'); + cssProp(info.endSearchLine, 'display', 'block'); - if (empty(flatResults)) { + tidySidebarSearchColumn(); + } else { const p = document.createElement('p'); p.classList.add('wiki-search-no-results'); p.appendChild(templateContent(info.noResultsString)); info.results.appendChild(p); } - for (const result of flatResults) { + restoreSidebarSearchResultsScrollOffset(); +} + +function tidyResults(results) { + const tidiedResults = + results.results.map(({doc, id}) => ({ + reference: id ?? null, + referenceType: (id ? id.split(':')[0] : null), + directory: (id ? id.split(':')[1] : null), + data: doc, + })); + + return tidiedResults; +} + +function fillResultElements(results, { + filterType = null, +} = {}) { + const tidiedResults = tidyResults(results); + + const filteredResults = + (filterType + ? tidiedResults.filter(result => result.referenceType === filterType) + : tidiedResults); + + while (info.results.firstChild) { + info.results.firstChild.remove(); + } + + cssProp(info.resultsRule, 'display', 'block'); + cssProp(info.resultsContainer, 'display', 'block'); + + if (empty(filteredResults)) { + return false; + } + + for (const result of filteredResults) { const el = generateSidebarSearchResult(result); if (!el) continue; info.results.appendChild(el); } - if (!empty(flatResults)) { - cssProp(info.endSearchRule, 'display', 'block'); - cssProp(info.endSearchLine, 'display', 'block'); + return true; +} - tidySidebarSearchColumn(); - } +function showFilterElements(results) { + const {queriedKind} = results; - restoreSidebarSearchResultsScrollOffset(); + const tidiedResults = tidyResults(results); + + const allReferenceTypes = + unique(tidiedResults.map(result => result.referenceType)); + + let shownAny = false; + + forEachFilter((type, filterLink) => { + filterLink.classList.remove('shown', 'hidden'); + + if (allReferenceTypes.includes(type)) { + shownAny = true; + cssProp(filterLink, 'display', null); + + if (queriedKind) { + filterLink.setAttribute('inert', 'inert'); + } else { + filterLink.removeAttribute('inert'); + } + + if (type === queriedKind) { + filterLink.classList.add('active-from-query'); + } else { + filterLink.classList.remove('active-from-query'); + } + } else { + cssProp(filterLink, 'display', 'none'); + } + }); + + if (shownAny) { + cssProp(info.filterContainer, 'display', null); + } else { + cssProp(info.filterContainer, 'display', 'none'); + } } function generateSidebarSearchResult(result) { @@ -859,6 +1078,8 @@ function generateSidebarSearchResultTemplate(slots) { } function hideSidebarSearchResults() { + cssProp(info.filterContainer, 'display', 'none'); + cssProp(info.resultsRule, 'display', 'none'); cssProp(info.resultsContainer, 'display', 'none'); @@ -991,6 +1212,36 @@ function tidySidebarSearchColumn() { } } +function toggleSidebarSearchFilter(toggleType) { + const {session} = info; + + if (!toggleType) return null; + + let shownAnyResults = null; + + forEachFilter((type, filterLink) => { + if (type === toggleType) { + const filterActive = filterLink.classList.toggle('active'); + const filterType = (filterActive ? type : null); + + if (cssProp(filterLink, 'display') !== 'none') { + filterLink.classList.add(filterActive ? 'shown' : 'hidden'); + } + + if (session.activeQueryResults) { + shownAnyResults = + fillResultElements(session.activeQueryResults, {filterType}); + } + + session.activeFilterType = filterType; + } else { + filterLink.classList.remove('active'); + } + }); + + return shownAnyResults; +} + function restoreSidebarSearchColumn() { const {state} = info; @@ -1004,6 +1255,28 @@ function restoreSidebarSearchColumn() { state.collapsedDetailsForTidiness = []; state.tidiedSidebar = null; + + info.searchInput.placeholder = info.standbyInputPlaceholder; +} + +function considerRecallingRecentSidebarSearch() { + const {session, state} = info; + + if (document.documentElement.dataset.urlKey === 'localized.home') { + return forgetRecentSidebarSearch(); + } + + info.searchInput.placeholder = session.activeQuery; + state.recallingRecentSearch = true; +} + +function forgetRecentSidebarSearch() { + const {session} = info; + + session.activeQuery = null; + session.activeQueryResults = null; + + clearSidebarFilter(); } async function handleDroppedIntoSearchInput(domEvent) { @@ -1032,7 +1305,7 @@ async function handleDroppedIntoSearchInput(domEvent) { let droppedURL; try { droppedURL = new URL(droppedText); - } catch (error) { + } catch { droppedURL = null; } diff --git a/src/static/js/client/sticky-heading.js b/src/static/js/client/sticky-heading.js index ae63eab5..b65574d0 100644 --- a/src/static/js/client/sticky-heading.js +++ b/src/static/js/client/sticky-heading.js @@ -1,13 +1,19 @@ /* eslint-env browser */ import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; -import {dispatchInternalEvent, templateContent} from '../client-util.js'; +import {cssProp, dispatchInternalEvent, templateContent} + from '../client-util.js'; export const info = { id: 'stickyHeadingInfo', + stickyRoots: null, + stickyContainers: null, + staticContainers: null, + stickyHeadingRows: null, + stickyHeadings: null, stickySubheadingRows: null, stickySubheadings: null, @@ -17,21 +23,33 @@ export const info = { contentContainers: null, contentHeadings: null, + contentCoverColumns: null, contentCovers: null, contentCoversReveal: null, + referenceCollapsedHeading: null, + state: { displayedHeading: null, }, event: { whenDisplayedHeadingChanges: [], + whenStuckStatusChanges: [], }, }; export function getPageReferences() { + info.stickyRoots = + Array.from(document.querySelectorAll('.content-sticky-heading-root:not([inert])')); + info.stickyContainers = - Array.from(document.getElementsByClassName('content-sticky-heading-container')); + info.stickyRoots + .map(el => el.querySelector('.content-sticky-heading-container')); + + info.staticContainers = + info.stickyRoots + .map(el => el.nextElementSibling); info.stickyCoverContainers = info.stickyContainers @@ -45,6 +63,14 @@ export function getPageReferences() { info.stickyCovers .map(el => el?.querySelector('.image-text-area')); + info.stickyHeadingRows = + info.stickyContainers + .map(el => el.querySelector('.content-sticky-heading-row')); + + info.stickyHeadings = + info.stickyHeadingRows + .map(el => el.querySelector('h1')); + info.stickySubheadingRows = info.stickyContainers .map(el => el.querySelector('.content-sticky-subheading-row')); @@ -55,11 +81,15 @@ export function getPageReferences() { info.contentContainers = info.stickyContainers - .map(el => el.parentElement); + .map(el => el.closest('.content-sticky-heading-root').parentElement); - info.contentCovers = + info.contentCoverColumns = info.contentContainers - .map(el => el.querySelector('#cover-art-container')); + .map(el => el.querySelector('#artwork-column')); + + info.contentCovers = + info.contentCoverColumns + .map(el => el ? el.querySelector('.cover-artwork') : null); info.contentCoversReveal = info.contentCovers @@ -68,6 +98,10 @@ export function getPageReferences() { info.contentHeadings = info.contentContainers .map(el => Array.from(el.querySelectorAll('.content-heading'))); + + info.referenceCollapsedHeading = + info.stickyHeadings + .map(el => el.querySelector('.reference-collapsed-heading')); } export function mutatePageContent() { @@ -137,15 +171,61 @@ function topOfViewInside(el, scroll = window.scrollY) { scroll < el.offsetTop + el.offsetHeight); } +function updateStuckStatus(index) { + const {event} = info; + + const contentContainer = info.contentContainers[index]; + const stickyContainer = info.stickyContainers[index]; + + const wasStuck = stickyContainer.classList.contains('stuck'); + const stuck = topOfViewInside(contentContainer); + + if (stuck === wasStuck) return; + + if (stuck) { + stickyContainer.classList.add('stuck'); + } else { + stickyContainer.classList.remove('stuck'); + } + + dispatchInternalEvent(event, 'whenStuckStatusChanges', index, stuck); +} + +function updateCollapseStatus(index) { + const stickyContainer = info.stickyContainers[index]; + const staticContainer = info.staticContainers[index]; + const stickyHeading = info.stickyHeadings[index]; + const referenceCollapsedHeading = info.referenceCollapsedHeading[index]; + + const {height: uncollapsedHeight} = stickyHeading.getBoundingClientRect(); + const {height: collapsedHeight} = referenceCollapsedHeading.getBoundingClientRect(); + + if ( + staticContainer.getBoundingClientRect().bottom < 4 || + staticContainer.getBoundingClientRect().top < -80 + ) { + if (!stickyContainer.classList.contains('collapse')) { + stickyContainer.classList.add('collapse'); + cssProp(stickyContainer, '--uncollapsed-heading-height', uncollapsedHeight + 'px'); + cssProp(stickyContainer, '--collapsed-heading-height', collapsedHeight + 'px'); + } + } else { + stickyContainer.classList.remove('collapse'); + } +} + function updateStickyCoverVisibility(index) { const stickyCoverContainer = info.stickyCoverContainers[index]; - const contentCover = info.contentCovers[index]; + const stickyContainer = info.stickyContainers[index]; + const contentCoverColumn = info.contentCoverColumns[index]; - if (contentCover && stickyCoverContainer) { - if (contentCover.getBoundingClientRect().bottom < 4) { + if (contentCoverColumn && stickyCoverContainer) { + if (contentCoverColumn.getBoundingClientRect().bottom < 4) { stickyCoverContainer.classList.add('visible'); + stickyContainer.classList.add('cover-visible'); } else { stickyCoverContainer.classList.remove('visible'); + stickyContainer.classList.remove('cover-visible'); } } } @@ -157,26 +237,27 @@ function getContentHeadingClosestToStickySubheading(index) { return null; } - const stickySubheading = info.stickySubheadings[index]; - - if (stickySubheading.childNodes.length === 0) { - // Supply a non-breaking space to ensure correct basic line height. - stickySubheading.appendChild(document.createTextNode('\xA0')); - } - - const stickyContainer = info.stickyContainers[index]; - const stickyRect = stickyContainer.getBoundingClientRect(); + const stickyHeadingRow = info.stickyHeadingRows[index]; + const stickyRect = stickyHeadingRow.getBoundingClientRect(); - // TODO: Should this compute with the subheading row instead of h2? - const subheadingRect = stickySubheading.getBoundingClientRect(); + // Subheadings only appear when the sticky heading is collapsed, + // so the used bottom edge should always be *as though* it's only + // displaying one line of text. Subtract the current discrepancy. + const stickyHeading = info.stickyHeadings[index]; + const referenceCollapsedHeading = info.referenceCollapsedHeading[index]; + const correctBottomEdge = + stickyHeading.getBoundingClientRect().height - + referenceCollapsedHeading.getBoundingClientRect().height; - const stickyBottom = stickyRect.bottom + subheadingRect.height; + const stickyBottom = + (stickyRect.bottom + - correctBottomEdge); // Iterate from bottom to top of the content area. const contentHeadings = info.contentHeadings[index]; for (const heading of contentHeadings.slice().reverse()) { const headingRect = heading.getBoundingClientRect(); - if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 20) { + if (headingRect.y + headingRect.height / 1.5 < stickyBottom + 40) { return heading; } } @@ -187,7 +268,12 @@ function getContentHeadingClosestToStickySubheading(index) { function updateStickySubheadingContent(index) { const {event, state} = info; - const closestHeading = getContentHeadingClosestToStickySubheading(index); + const stickyContainer = info.stickyContainers[index]; + + const closestHeading = + (stickyContainer.classList.contains('collapse') + ? getContentHeadingClosestToStickySubheading(index) + : null); if (state.displayedHeading === closestHeading) return; @@ -233,6 +319,8 @@ function updateStickySubheadingContent(index) { } export function updateStickyHeadings(index) { + updateStuckStatus(index); + updateCollapseStatus(index); updateStickyCoverVisibility(index); updateStickySubheadingContent(index); } diff --git a/src/static/js/rectangles.js b/src/static/js/rectangles.js index cdab2cb8..b00ed98e 100644 --- a/src/static/js/rectangles.js +++ b/src/static/js/rectangles.js @@ -510,4 +510,46 @@ export class WikiRect extends DOMRect { height: this.height, }); } + + // Other utilities + + #display = null; + + display() { + if (!this.#display) { + this.#display = document.createElement('div'); + document.body.appendChild(this.#display); + } + + Object.assign(this.#display.style, { + position: 'fixed', + background: '#000c', + border: '3px solid var(--primary-color)', + borderRadius: '4px', + top: this.top + 'px', + left: this.left + 'px', + width: this.width + 'px', + height: this.height + 'px', + pointerEvents: 'none', + }); + + let i = 0; + const int = setInterval(() => { + i++; + if (i >= 3) clearInterval(int); + if (!this.#display) return; + + this.#display.style.display = 'none'; + setTimeout(() => { + this.#display.style.display = ''; + }, 200); + }, 600); + } + + hide() { + if (this.#display) { + this.#display.remove(); + this.#display = null; + } + } } diff --git a/src/static/js/search-worker.js b/src/static/js/search-worker.js index 1b4684ad..e32b4ad5 100644 --- a/src/static/js/search-worker.js +++ b/src/static/js/search-worker.js @@ -130,7 +130,7 @@ async function loadDatabase() { try { idb = await promisifyIDBRequest(request); - } catch (error) { + } catch { console.warn(`Couldn't load search IndexedDB - won't use an internal cache.`); console.warn(request.error); idb = null; @@ -371,58 +371,75 @@ function postActionResult(id, status, value) { } function performSearchAction({query, options}) { - const {generic, ...otherIndexes} = indexes; + const {queriedKind} = processTerms(query); + const genericResults = queryGenericIndex(query, options); + const verbatimResults = queryVerbatimIndex(query, options); - const genericResults = - queryGenericIndex(generic, query, options); + const verbatimIDs = + new Set(verbatimResults?.map(result => result.id)); - const otherResults = - withEntries(otherIndexes, entries => entries - .map(([indexName, index]) => [ - indexName, - index.search(query, options), - ])); + const commonResults = + (verbatimResults && genericResults + ? genericResults + .filter(({id}) => verbatimIDs.has(id)) + : verbatimResults ?? genericResults); return { - generic: genericResults, - ...otherResults, + results: commonResults, + queriedKind, }; } -function queryGenericIndex(index, query, options) { - const interestingFieldCombinations = [ - ['primaryName', 'parentName', 'groups'], - ['primaryName', 'parentName'], - ['primaryName', 'groups', 'contributors'], - ['primaryName', 'groups', 'artTags'], - ['primaryName', 'groups'], - ['primaryName', 'contributors'], - ['primaryName', 'artTags'], - ['parentName', 'groups', 'artTags'], - ['parentName', 'artTags'], - ['groups', 'contributors'], - ['groups', 'artTags'], - - // This prevents just matching *everything* tagged "john" if you - // only search "john", but it actually supports matching more than - // *two* tags at once: "john rose lowas" works! This is thanks to - // flexsearch matching multiple field values in a single query. - ['artTags', 'artTags'], - - ['contributors', 'parentName'], - ['contributors', 'groups'], - ['primaryName', 'contributors'], - ['primaryName'], - ]; +const interestingFieldCombinations = [ + ['primaryName', 'parentName', 'groups'], + ['primaryName', 'parentName'], + ['primaryName', 'groups', 'contributors'], + ['primaryName', 'groups', 'artTags'], + ['primaryName', 'groups'], + ['primaryName', 'contributors'], + ['primaryName', 'artTags'], + ['parentName', 'groups', 'artTags'], + ['parentName', 'artTags'], + ['groups', 'contributors'], + ['groups', 'artTags'], + + // This prevents just matching *everything* tagged "john" if you + // only search "john", but it actually supports matching more than + // *two* tags at once: "john rose lowas" works! This is thanks to + // flexsearch matching multiple field values in a single query. + ['artTags', 'artTags'], + + ['contributors', 'parentName'], + ['contributors', 'groups'], + ['primaryName', 'contributors'], + ['primaryName'], +]; + +function queryGenericIndex(query, options) { + return queryIndex({ + indexKey: 'generic', + termsKey: 'genericTerms', + }, query, options); +} + +function queryVerbatimIndex(query, options) { + return queryIndex({ + indexKey: 'verbatim', + termsKey: 'verbatimTerms', + }, query, options); +} +function queryIndex({termsKey, indexKey}, query, options) { const interestingFields = unique(interestingFieldCombinations.flat()); - const {genericTerms, queriedKind} = + const {[termsKey]: terms, queriedKind} = processTerms(query); + if (empty(terms)) return null; + const particles = - particulate(genericTerms); + particulate(terms); const groupedParticles = groupArray(particles, ({length}) => length); @@ -437,7 +454,7 @@ function queryGenericIndex(index, query, options) { query: values, })); - const boilerplate = queryBoilerplate(index); + const boilerplate = queryBoilerplate(indexes[indexKey]); const particleResults = Object.fromEntries( @@ -459,62 +476,73 @@ function queryGenericIndex(index, query, options) { ])), ])); - const results = new Set(); + let matchedResults = new Set(); for (const interestingFieldCombination of interestingFieldCombinations) { for (const query of queriesBy(interestingFieldCombination)) { - const idToMatchingFieldsMap = new Map(); - for (const {field, query: fieldQuery} of query) { - for (const id of particleResults[field][fieldQuery]) { - if (idToMatchingFieldsMap.has(id)) { - idToMatchingFieldsMap.get(id).push(field); - } else { - idToMatchingFieldsMap.set(id, [field]); - } - } - } + const [firstQueryFieldLine, ...restQueryFieldLines] = query; const commonAcrossFields = - Array.from(idToMatchingFieldsMap.entries()) - .filter(([id, matchingFields]) => - matchingFields.length === interestingFieldCombination.length) - .map(([id]) => id); + new Set( + particleResults + [firstQueryFieldLine.field] + [firstQueryFieldLine.query]); + + for (const currQueryFieldLine of restQueryFieldLines) { + const tossResults = new Set(commonAcrossFields); + + const keepResults = + particleResults + [currQueryFieldLine.field] + [currQueryFieldLine.query]; + + for (const result of keepResults) { + tossResults.delete(result); + } + + for (const result of tossResults) { + commonAcrossFields.delete(result); + } + } for (const result of commonAcrossFields) { - results.add(result); + matchedResults.add(result); } } } - const constituted = - boilerplate.constitute(results); + matchedResults = Array.from(matchedResults); - const constitutedAndFiltered = - constituted - .filter(({id}) => - (queriedKind - ? id.split(':')[0] === queriedKind - : true)); + const filteredResults = + (queriedKind + ? matchedResults.filter(id => id.split(':')[0] === queriedKind) + : matchedResults); - return constitutedAndFiltered; + const constitutedResults = + boilerplate.constitute(filteredResults); + + return constitutedResults; } function processTerms(query) { const kindTermSpec = [ - {kind: 'album', terms: ['album']}, - {kind: 'artist', terms: ['artist']}, - {kind: 'flash', terms: ['flash']}, - {kind: 'group', terms: ['group']}, - {kind: 'tag', terms: ['art tag', 'tag']}, - {kind: 'track', terms: ['track']}, + {kind: 'album', terms: ['album', 'albums']}, + {kind: 'artist', terms: ['artist', 'artists']}, + {kind: 'flash', terms: ['flash', 'flashes']}, + {kind: 'group', terms: ['group', 'groups']}, + {kind: 'tag', terms: ['art tag', 'art tags', 'tag', 'tags']}, + {kind: 'track', terms: ['track', 'tracks']}, ]; const genericTerms = []; + const verbatimTerms = []; let queriedKind = null; const termRegexp = new RegExp( - String.raw`(?<kind>${kindTermSpec.flatMap(spec => spec.terms).join('|')})` + + String.raw`(?<kind>(?<=^|\s)(?:${kindTermSpec.flatMap(spec => spec.terms).join('|')})(?=$|\s))` + + String.raw`|(?<=^|\s)(?<quote>["'])(?<regularVerbatim>.+?)\k<quote>(?=$|\s)` + + String.raw`|(?<=^|\s)[“”‘’](?<curlyVerbatim>.+?)[“”‘’](?=$|\s)` + String.raw`|[^\s\-]+`, 'gi'); @@ -530,10 +558,16 @@ function processTerms(query) { continue; } + const verbatim = groups.regularVerbatim || groups.curlyVerbatim; + if (verbatim) { + verbatimTerms.push(verbatim); + continue; + } + genericTerms.push(match[0]); } - return {genericTerms, queriedKind}; + return {genericTerms, verbatimTerms, queriedKind}; } function particulate(terms) { diff --git a/src/static/misc/image.svg b/src/static/misc/image.svg new file mode 100644 index 00000000..a251b373 --- /dev/null +++ b/src/static/misc/image.svg @@ -0,0 +1,11 @@ +<!-- Copyright © (c) 2019-2023 The Bootstrap authors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the Software or the use or other dealings in the Software. --> + +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-image-fill" viewBox="0 0 16 16"> + <path d="M.002 3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-12a2 2 0 0 1-2-2V3zm1 9v1a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12zm5-6.5a1.5 1.5 0 1 0-3 0 1.5 1.5 0 0 0 3 0z"/> +</svg> diff --git a/src/strings-default.yaml b/src/strings-default.yaml index c63faeb5..ecc0cc14 100644 --- a/src/strings-default.yaml +++ b/src/strings-default.yaml @@ -57,6 +57,16 @@ count: many: "" other: "{ALBUMS} albums" + artTags: + _: "{TAGS}" + withUnit: + zero: "" + one: "{TAGS} tag" + two: "" + few: "" + many: "" + other: "{TAGS} tags" + artworks: _: "{ARTWORKS}" withUnit: @@ -139,6 +149,16 @@ count: many: "" other: "{MONTHS} months" + timesFeatured: + _: "{TIMES_FEATURED}" + withUnit: + zero: "" + one: "featured {TIMES_FEATURED} time" + two: "" + few: "" + many: "" + other: "featured {TIMES_FEATURED} times" + timesReferenced: _: "{TIMES_REFERENCED}" withUnit: @@ -255,28 +275,26 @@ releaseInfo: from: "From {ALBUM}." - coverArtBy: "Cover art by {ARTISTS}." - wallpaperArtBy: "Wallpaper art by {ARTISTS}." - bannerArtBy: "Banner art by {ARTISTS}." + wallpaperArtBy: "Wallpaper by {ARTISTS}" + bannerArtBy: "Banner by {ARTISTS}" released: "Released {DATE}." albumReleased: "Album released {DATE}." - artReleased: "Art released {DATE}." trackReleased: "Track released {DATE}." addedToWiki: "Added to wiki {DATE}." duration: "Duration: {DURATION}." contributors: "Contributors:" - lyrics: "Lyrics:" - note: "Context notes:" - alsoReleasedAs: - _: "Also released as:" + lyrics: + _: "Lyrics:" - item: - _: "{TRACK} ({ALBUM})" - withYear: "({YEAR}) {TRACK} ({ALBUM})" + switcher: "({ENTRIES})" + + note: "Context notes:" + + alsoReleasedOn: "Also released on {ALBUMS}." tracksReferenced: _: "Tracks that {TRACK} references:" @@ -291,16 +309,16 @@ releaseInfo: sticky: _: "Tracks that reference this one:" - fromGroup: "Tracks from {GROUP} that reference this one:" - fromOther: "Tracks from somewhere else that reference this one:" + fromGroup: "Tracks that reference this one — from {GROUP}:" + fromOther: "Tracks that reference this one — from somewhere else:" tracksThatSample: _: "Tracks that sample {TRACK}:" sticky: _: "Tracks that sample this one:" - fromGroup: "Tracks from {GROUP} that sample this one:" - fromOther: "Tracks from somewhere else that sample this one:" + fromGroup: "Tracks that sample this one — from {GROUP}:" + fromOther: "Tracks that sample this one — from somewhere else:" flashesThatFeature: _: "Flashes & games that feature {TRACK}:" @@ -352,10 +370,14 @@ releaseInfo: _: "Read {LINK}." link: "artist commentary" - readCreditSources: + readCreditingSources: _: "Read {LINK}." link: "crediting sources" + readReferencingSources: + _: "Read {LINK}." + link: "referencing sources" + additionalFiles: heading: "View or download additional files:" @@ -470,7 +492,8 @@ misc: # artistCommentary: artistCommentary: - _: "Artist commentary:" + _: "Artist commentary for {THING}:" + sticky: "Artist commentary:" entry: title: @@ -495,7 +518,18 @@ misc: date.sometime.range: "sometime {DATE_RANGE}" date.throughout.range: "throughout {DATE_RANGE}" - seeOriginalRelease: "See {ORIGINAL}!" + info: + fromMainRelease: >- + The following commentary is properly placed on this track's main release, {ALBUM}. + + fromMainRelease.namedDifferently: >- + The following commentary is properly placed on this track's main release, {ALBUM}, where it's named {NAME}. + + seeSpecificReleases: >- + For release-specific commentary, check out: {ALBUMS}. + + seeSpecificReleases.withMainCommentary: >- + For release-specific commentary, see also: {ALBUMS}. artistCredit: withNormalArtists: >- @@ -586,8 +620,9 @@ misc: trackArt: "{INDEX} track art by {ARTIST}" onlyIndex: "Only" - creditSources: - _: "Crediting sources:" + creditingSources: + _: "Crediting sources for {THING}:" + sticky: "Crediting sources:" # external: # Links which will generally bring you somewhere off of the wiki. @@ -615,7 +650,12 @@ misc: amazonMusic: "Amazon Music" appleMusic: "Apple Music" artstation: "ArtStation" - bandcamp: "Bandcamp" + + bandcamp: + _: "Bandcamp" + + composerRelease: "Bandcamp (composer's release)" + officialRelease: "Bandcamp (official release)" bgreco: _: "bgreco.net" @@ -679,6 +719,18 @@ misc: playlist: "YouTube (playlist)" fullAlbum: "YouTube (full album)" + # lyrics: + + lyrics: + source: >- + Via {SOURCE} + + contributors: >- + Contributions from {CONTRIBUTORS} + + squareBracketAnnotations: >- + Mind parts marked in [square brackets] + # missingImage: # Fallback text displayed in an image when it's sourced to a file # that isn't available under the wiki's media directory. While it @@ -786,6 +838,18 @@ misc: artist: "(artist)" group: "(group)" + resultFilter: + album: "Albums" + artTag: "Art Tags" + artist: "Artists" + flash: "Flashes" + group: "Groups" + track: "Tracks" + + referencingSources: + _: "Referencing sources for {THING}:" + sticky: "Referencing sources:" + # skippers: # # These are navigational links that only show up when you're @@ -813,7 +877,7 @@ misc: # Displayed on various info pages. artistCommentary: "Artist commentary" - creditSources: "Crediting sources" + creditingSources: "Crediting sources" # Displayed on artist info page. @@ -834,6 +898,7 @@ misc: sampledBy: "Sampled by..." features: "Features..." featuredIn: "Featured in..." + referencingSources: "Referencing sources" lyrics: "Lyrics" @@ -851,8 +916,6 @@ misc: socialEmbed: heading: "{WIKI_NAME} | {HEADING}" - trackArtFromAlbum: "Album cover for {ALBUM}" - # jumpTo: # Generic action displayed at the top of some longer pages, for # quickly scrolling down to a particular section. @@ -870,6 +933,48 @@ misc: warnings: "{WARNINGS}" reveal: "click to show" + # coverArtwork: + # Generic or particular strings for artworks outside a grid + # context, when just one cover is being spotlighted. + + coverArtwork: + artworkBy: >- + Artwork by {ARTISTS} + + artworkBy.customLabel: >- + {LABEL} by {ARTISTS} + + artworkBy.withYear: >- + Artwork ({YEAR}) by {ARTISTS} + + artworkBy.customLabel.withYear: >- + {LABEL} ({YEAR}) by {ARTISTS} + + source: >- + Via {SOURCE} + + source.customLabel: >- + {LABEL} via {SOURCE} + + source.withYear: >- + Via {SOURCE} ({YEAR}) + + source.customLabel.withYear: >- + {LABEL} ({YEAR}) via {SOURCE} + + customLabel: >- + {LABEL} + + customLabel.withYear: >- + {LABEL} ({YEAR}) + + year: >- + Released {YEAR} + + trackArtFromAlbum: "Album cover for {ALBUM}" + + sameTagsAsMainArtwork: "Same tags as main artwork" + # coverGrid: # Generic strings for various sorts of gallery grids, displayed # on the homepage, album galleries, artist artwork galleries, and @@ -881,12 +986,20 @@ misc: noCoverArt: "{ALBUM}" details: + notFromThisGroup: "{NAME}{MARKER}" + notFromThisGroup.marker: "*" + accent: "({DETAILS})" albumLength: "{TRACKS}, {TIME}" + coverArtists: "Artwork by {ARTISTS}" + coverArtists.customLabel: "{LABEL} by {ARTISTS}" + otherCoverArtists: "With {ARTISTS}" + expandCollapseCue: "({CUE})" + albumGalleryGrid: noCoverArt: "{NAME}" @@ -943,7 +1056,9 @@ albumSidebar: group: _: "{GROUP}" - withRange: "{GROUP} ({RANGE})" + + withRange: "{GROUP} {RANGE_PART}" + withRange.rangePart: "({RANGE})" # groupBox: # This is the box for groups. Apart from the next and previous @@ -955,6 +1070,13 @@ albumSidebar: next: "Next: {ALBUM}" previous: "Previous: {ALBUM}" + # releaseBox: + # This is the narrow box for alternate releases of the + # current track. + + releaseBox: + title: "{ALBUM}" + # # albumSecondaryNav: # The secondary nav bar is shown in medium and thin layouts, @@ -1053,6 +1175,15 @@ albumGalleryPage: noTrackArtworksLine: >- This album doesn't have any track artwork. + # setSwitcher: + # This is displayed if multiple sets of artwork are available + # across the album. + + setSwitcher: + _: "({SETS})" + + unlabeledSet: "Main album art" + # # albumCommentaryPage: # The album commentary page is a more minimal layout that brings @@ -1166,7 +1297,19 @@ artistPage: # artists or contributors, and get dimmed a little compared # to original release track entries. - rerelease: "{ENTRY} (rerelease)" + rerelease: + _: "{ENTRY} ({RERELEASE})" + term: "rerelease" + + firstRelease: >- + First released on {ALBUM} + + firstRelease: + _: "{ENTRY} ({FIRST_RELEASE})" + term: "first release" + + rerelease: >- + Also released on {ALBUM} # track: # The string without duration is used in both the artist's @@ -1192,6 +1335,16 @@ artistPage: flash: _: "{FLASH}" + artwork.accent: + withLabel: >- + {LABEL} + + withAnnotation: >- + {ANNOTATION} + + withLabel.withAnnotation: >- + {LABEL}: {ANNOTATION} + # contributedDurationLine: # This is shown at the top of the artist's track list, provided # any of their tracks have durations at all. @@ -1244,8 +1397,13 @@ artistPage: orBrowseList: "View {LINK}! Or browse the list:" link: "art gallery" - wikiEditArtworks: >- - {ARTIST} has edited these artworks for this wiki: + wikiEditArtworks: + _: "{ARTIST} has edited these artworks for this wiki:" + sticky: "Artworks — edited for this wiki" + + wikiEditorCommentary: + _: "{ARTIST} has written these commentary entries as an editor of this wiki:" + sticky: "Commentary — written for this wiki" # # artistGalleryPage: @@ -1260,6 +1418,111 @@ artistGalleryPage: Contributed to {COVER_ARTS}. # +# artTagPage: +# Stuff that's common between art tag pages. +# +artTagPage: + nav: + tag: "Tag: {TAG}" + + sidebar: + otherTagsExempt: "(…another {TAGS}…)" + +# +# artTagInfoPage: +# The art tag info page displays general information about a tag, +# including details about how it's networked with other tags in +# particular. +# +artTagInfoPage: + title: "{TAG}" + + viewArtGallery: + _: "View this tag's {LINK}!" + link: "art gallery" + + readMoreOn: "Read more about '{TAG}' on {LINKS}." + + seeAlso: + _: "See also: {TAGS}" + tagWithAnnotation: "{TAG} ({ANNOTATION})" + + featuredIn: + notFeatured: >- + This tag hasn't been featured in any artworks yet. + + directlyOnly: >- + This tag is featured in {ARTWORKS}. + + indirectlyOnly: >- + This tag is featured in {ARTWORKS}, but only indirectly. + + directlyAndIndirectly: >- + This tag is directly featured in {ARTWORKS_DIRECTLY}, and indirectly in {ARTWORKS_INDIRECTLY} more, for a total of {ARTWORKS_TOTAL}. + + descendsFromTags: + _: "Tags which '{TAG}' falls under:" + item: "{TAG}" + + descendantTags: + _: "Tags which fall under '{TAG}':" + + item: + _: "{TAG}" + + withGallery: + _: "{TAG} ({GALLERY})" + gallery: "view gallery" + + withTimesUsed: "{TAG} ({TIMES_USED})" + withGallery.withTimesUsed: "{TAG} ({GALLERY}; {TIMES_USED})" + +# +# artTagGalleryPage: +# The tag gallery page displays all the artworks that a tag has +# been featured in, in one neat grid, with each artwork displaying +# its illustrators, as well as a short info line that indicates +# how many artworks the tag's been featured in, whether directly, +# indirectly (via descendant tags), both, or neither. If a tag's +# been featured both directly and indirectly, there are buttons +# to switch what's being shown. +# +artTagGalleryPage: + title: "{TAG}" + + descendsFrom: "Up: {TAGS}." + descendants: "Down: {TAGS}." + + featuredLine: + all: >- + Featured in {COVER_ARTS}. + + direct: >- + Featured directly in {COVER_ARTS}. + + indirect: >- + Featured indirectly in {COVER_ARTS}. + + notFeatured: + _: >- + This tag hasn't been featured in any artworks yet. + + callToAction: >- + Maybe your track will be the first! + + showingLine: + _: "({SHOWING})" + + all: >- + Showing all artworks. + + indirect: >- + Showing artworks where this tag is only featured indirectly. + + direct: >- + Showing artworks where this tag is featured directly. + +# # commentaryIndex: # The commentary index page shows a summary of all the commentary # across the entire wiki, with a list linking to each album's @@ -1430,6 +1693,37 @@ groupGalleryPage: infoLine: >- {TRACKS} across {ALBUMS}, totaling {TIME}. + albumViewSwitcher: + _: "Showing albums:" + + bySeries: "By series" + byDate: "By date" + + albumsByDate: + title: "All albums" + + albumSection: + caption: >- + {TRACKS} across {ALBUMS}. + + caption.withDate: >- + {TRACKS} across {ALBUMS}, released {DATE}. + + caption.withYear: >- + {TRACKS} across {ALBUMS}, released during {YEAR}. + + caption.withYearRange: >- + {TRACKS} across {ALBUMS}, released {YEAR_RANGE}. + + caption.seriesAlbumsNotFromGroup: >- + Albums marked {MARKER} are part of {SERIES}, but not from {GROUP}. + + expand: >- + Show the rest! + + collapse: >- + Collapse these + # # listingIndex: # The listing index page shows all available listings on the wiki, @@ -1557,6 +1851,79 @@ listingPage: title: "{DATE}" item: "{ALBUM}" + listArtTags: + + # listArtTags.byName: + # List art tags alphabetically without sorting or chunking by + # any other criteria. Also displays the number of times each + # art tag has been featured. + + byName: + title: "Tags - by Name" + title.short: "...by Name" + item: "{TAG} ({TIMES_USED})" + + # listArtTags.byUses: + # List art tags by number of times used, falling back to an + # alphabetical sort if two art tags have been featured the same + # number of times. Art tags which haven't haven't been featured + # at all yet are totally excluded from the list. + + byUses: + title: "Tags - by Uses" + title.short: "...by Uses" + item: "{TAG} ({TIMES_USED})" + + # listArtTags.network: + # List art tags in a custom networked fashion, showing all + # connections between ancestors and descendants. Each top-level + # tag gets one section. Descendants are generally nested directly + # under their ancestors, except if they have two or more direct + # ancestors *and* at least one direct descendant, in which case + # they're moved to a dedicated section further down the page. + # "Orphan" art tags, if any - tags which don't have any ancestors + # nor any descendants - are displayed in a section at the bottom. + + network: + title: "Art Tag Network" + title.short: "Art Tag Network" + + jumpToRoot: + title: "Jump to one of the upper-most tags:" + item: "{TAG}" + + statLine: + _: "Displaying tag info: {STAT}" + none: "name only" + totalUses: "uses (total)" + directUses: "uses (direct only)" + descendants: "descendants (total)" + leaves: "descendants (leaves only)" + + root: + _: "{TAG}" + + root.jumpToTop: + _: "{TAG} ({LINK})" + link: "Jump back to top" + + root.withAncestors: + _: "{TAG} (descends from {ANCESTORS})" + + tag: + _: "{TAG}" + + jumpToRoot: "Jump to: {TAG}" + + withStat: + _: "{STAT} {TAG}" + stat: "({STAT})" + notApplicable: "-" + + orphanArtTags: + title: "These tags don't have any descendants or ancestors:" + item: "{TAG}" + listArtists: # listArtists.byName: @@ -1884,29 +2251,6 @@ listingPage: title.withDate: "{ALBUM} ({DATE})" item: "{TRACK}" - listTags: - - # listTags.byName: - # List art tags alphabetically without sorting or chunking by - # any other criteria. Also displays the number of times each - # art tag has been featured. - - byName: - title: "Tags - by Name" - title.short: "...by Name" - item: "{TAG} ({TIMES_USED})" - - # listTags.byUses: - # List art tags by number of times used, falling back to an - # alphabetical sort if two art tags have been featured the same - # number of times. Art tags which haven't haven't been featured - # at all yet are totally excluded from the list. - - byUses: - title: "Tags - by Uses" - title.short: "...by Uses" - item: "{TAG} ({TIMES_USED})" - other: # other.allSheetMusic: @@ -2097,22 +2441,6 @@ referencingArtworksPage: Referenced by {ARTWORKS}. # -# tagPage: -# The tag gallery page displays all the artworks that a tag has -# been featured in, in one neat grid, with each artwork displaying -# its illustrators, as well as a short info line that indicates -# how many artworks the tag's part of. -# -tagPage: - title: "{TAG}" - - nav: - tag: "Tag: {TAG}" - - infoLine: >- - Appears in {COVER_ARTS}. - -# # trackPage: # # The track info page is pretty much the most discrete and common diff --git a/src/upd8.js b/src/upd8.js index f4c6326a..ae072d5a 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -34,22 +34,25 @@ import '#import-heck'; import {execSync} from 'node:child_process'; -import {readdir, readFile, stat} from 'node:fs/promises'; +import {readdir, readFile, stat, writeFile} from 'node:fs/promises'; import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import wrap from 'word-wrap'; -import {mapAggregate, showAggregate} from '#aggregate'; +import {mapAggregate, openAggregate, showAggregate} from '#aggregate'; import CacheableObject from '#cacheable-object'; +import {formatDuration, stringifyCache} from '#cli'; import {displayCompositeCacheAnalysis} from '#composite'; +import * as html from '#html'; import find, {bindFind, getAllFindSpecs} from '#find'; import {processLanguageFile, watchLanguageFile, internalDefaultStringsFile} from '#language'; import {isMain, traverse} from '#node-utils'; +import {bindReverse} from '#reverse'; import {writeSearchData} from '#search'; import {sortByName} from '#sort'; -import {generateURLs, urlSpec} from '#urls'; +import thingConstructors from '#things'; import {identifyAllWebRoutes} from '#web-routes'; import { @@ -66,13 +69,15 @@ import { import { filterReferenceErrors, - reportDirectoryErrors, reportContentTextErrors, + reportDirectoryErrors, + reportOrphanedArtworks, } from '#data-checks'; import { bindOpts, empty, + filterMultipleArrays, indentWrap as unboundIndentWrap, withEntries, } from '#sugar'; @@ -87,6 +92,15 @@ import genThumbs, { } from '#thumbs'; import { + applyLocalizedWithBaseDirectory, + applyURLSpecOverriding, + generateURLs, + getOrigin, + internalDefaultURLSpecFile, + processURLSpecFromFile, +} from '#urls'; + +import { getAllDataSteps, linkWikiDataArrays, loadYAMLDocumentsFromDataSteps, @@ -104,7 +118,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); let COMMIT; try { COMMIT = execSync('git log --format="%h %B" -n 1 HEAD', {cwd: __dirname}).toString().trim(); -} catch (error) { +} catch { COMMIT = '(failed to detect)'; } @@ -122,7 +136,8 @@ const defaultStepStatus = {status: STATUS_NOT_STARTED, annotation: null}; // Defined globally for quick access outside the main() function's contents. // This will be initialized and mutated over the course of main(). let stepStatusSummary; -let showStepStatusSummary = false; +let shouldShowStepStatusSummary = false; +let shouldShowStepMemoryInSummary = false; async function main() { Error.stackTraceLimit = Infinity; @@ -138,8 +153,8 @@ async function main() { {...defaultStepStatus, name: `migrate thumbnails`, for: ['thumbs']}, - loadThumbnailCache: - {...defaultStepStatus, name: `load thumbnail cache file`, + loadOfflineThumbnailCache: + {...defaultStepStatus, name: `load offline thumbnail cache file`, for: ['thumbs', 'build']}, generateThumbnails: @@ -162,6 +177,10 @@ async function main() { {...defaultStepStatus, name: `report directory errors`, for: ['verify']}, + reportOrphanedArtworks: + {...defaultStepStatus, name: `report orphaned artworks`, + for: ['verify']}, + filterReferenceErrors: {...defaultStepStatus, name: `filter reference errors`, for: ['verify']}, @@ -178,6 +197,21 @@ async function main() { {...defaultStepStatus, name: `precache nearly all data`, for: ['build']}, + sortWikiDataSourceFiles: + {...defaultStepStatus, name: `apply sorting rules to wiki data files`, + for: ['build']}, + + checkWikiDataSourceFileSorting: + {...defaultStepStatus, name: `check sorting rules against wiki data files`}, + + loadURLFiles: + {...defaultStepStatus, name: `load internal & custom url spec files`, + for: ['build']}, + + loadOnlineThumbnailCache: + {...defaultStepStatus, name: `load online thumbnail cache file`, + for: ['thumbs', 'build']}, + // TODO: This should be split into load/watch steps. loadInternalDefaultLanguage: {...defaultStepStatus, name: `load internal default language`, @@ -203,6 +237,10 @@ async function main() { {...defaultStepStatus, name: `preload file sizes`, for: ['build']}, + loadOnlineFileSizeCache: + {...defaultStepStatus, name: `load online file size cache file`, + for: ['build']}, + buildSearchIndex: {...defaultStepStatus, name: `generate search index`, for: ['build', 'search']}, @@ -246,6 +284,35 @@ async function main() { })); let selectedBuildModeFlag; + let sortInAdditionToBuild = false; + + // As an exception, --sort can be combined with another build mode. + if (selectedBuildModeFlags.length >= 2 && selectedBuildModeFlags.includes('sort')) { + sortInAdditionToBuild = true; + selectedBuildModeFlags.splice(selectedBuildModeFlags.indexOf('sort'), 1); + } + + if (sortInAdditionToBuild) { + Object.assign(stepStatusSummary.sortWikiDataSourceFiles, { + status: STATUS_NOT_STARTED, + annotation: `--sort provided with another build mode`, + }); + + Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, { + status: STATUS_NOT_APPLICABLE, + annotation: `--sort provided, dry run not applicable`, + }); + } else { + Object.assign(stepStatusSummary.sortWikiDataSourceFiles, { + status: STATUS_NOT_APPLICABLE, + annotation: `--sort not provided, dry run only`, + }); + + Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, { + status: STATUS_NOT_STARTED, + annotation: `--sort not provided, dry run applicable`, + }); + } if (empty(selectedBuildModeFlags)) { // No build mode selected. This is not a valid state for building the wiki, @@ -323,11 +390,26 @@ async function main() { type: 'value', }, + 'urls': { + help: `Specify which optional URL specs to use for this build, customizing where pages are generated or resources are accessed from`, + type: 'value', + }, + + 'show-url-spec': { + help: `Displays the entire computed URL spec, after the data folder's default override and optional specs are applied. This is mostly useful for progammer debugging!`, + type: 'flag', + }, + 'skip-directory-validation': { help: `Skips checking for duplicated directories, which speeds up the build but may cause the wiki to catch on fire`, type: 'flag', }, + 'skip-orphaned-artwork-validation': { + help: `Skips checking for internally orphaned artworks, which is a bad idea, unless you're debugging those in particular`, + type: 'flag', + }, + 'skip-reference-validation': { help: `Skips checking and reporting reference errors, which speeds up the build but may silently allow erroneous data to pass through`, type: 'flag', @@ -363,11 +445,26 @@ async function main() { type: 'flag', }, + 'refresh-online-thumbs': { + help: `Downloads a fresh copy of the online file size cache, so changes there are immediately reflected`, + type: 'flag', + }, + 'skip-file-sizes': { help: `Skips preloading file sizes for images and additional files, which will be left blank in the build`, type: 'flag', }, + 'refresh-online-file-sizes': { + help: `Downloads a fresh copy of the online file size cache, so changes there are immediately reflected`, + type: 'flag', + }, + + 'skip-sorting-validation': { + help: `Skips checking the if custom sorting rules for this wiki are satisfied`, + type: 'flag', + }, + 'skip-media-validation': { help: `Skips checking and reporting missing and misplaced media files, which isn't necessary if you aren't adding or removing data or updating directories`, type: 'flag', @@ -417,6 +514,16 @@ async function main() { type: 'flag', }, + 'show-step-memory': { + help: `Include total process memory usage traces at the time each top-level build step ends. Use with --show-step-summary. This is mostly useful for programmer debugging!`, + type: 'flag', + }, + + 'skip-self-diagnosis': { + help: `Disable some runtime validation for the wiki's own code, which speeds up long builds, but may allow unpredicted corner cases to fail strangely and silently`, + type: 'flag', + }, + 'queue-size': { help: `Process more or fewer disk files at once to optimize performance or avoid I/O errors, unlimited if set to 0 (between 500 and 700 is usually a safe range for building HSMusic on Windows machines)\nDefaults to ${defaultQueueSize}`, type: 'value', @@ -439,14 +546,6 @@ async function main() { }, magick: {alias: 'magick-threads'}, - // This option is super slow and has the potential for bugs! It puts - // CacheableObject in a mode where every instance is a Proxy which will - // keep track of invalid property accesses. - 'show-invalid-property-accesses': { - help: `Report accesses at runtime to nonexistant properties on wiki data objects, at a dramatic performance cost\n(Internal/development use only)`, - type: 'flag', - }, - 'precache-mode': { help: `Change the way certain runtime-computed values are preemptively evaluated and cached\n\n` + @@ -484,7 +583,8 @@ async function main() { ...buildOptions, }); - showStepStatusSummary = cliOptions['show-step-summary'] ?? false; + shouldShowStepStatusSummary = cliOptions['show-step-summary'] ?? false; + shouldShowStepMemoryInSummary = cliOptions['show-step-memory'] ?? false; if (cliOptions['help']) { console.log( @@ -562,10 +662,13 @@ async function main() { const thumbsOnly = cliOptions['thumbs-only'] ?? false; const noInput = cliOptions['no-input'] ?? false; - const showAggregateTraces = cliOptions['show-traces'] ?? false; + const skipSelfDiagnosis = cliOptions['skip-self-diagnosis'] ?? false; + const showTraces = cliOptions['show-traces'] ?? false; const precacheMode = cliOptions['precache-mode'] ?? 'common'; - const showInvalidPropertyAccesses = cliOptions['show-invalid-property-accesses'] ?? false; + + const wantedURLSpecKeys = cliOptions['urls'] ?? []; + const showURLSpec = cliOptions['show-url-spec'] ?? false; // Makes writing nicer on the CPU and file I/O parts of the OS, with a // marginal performance deficit while waiting for file writes to finish @@ -757,6 +860,16 @@ async function main() { }, }); + fallbackStep('reportOrphanedArtworks', { + default: 'perform', + cli: { + flag: 'skip-orphaned-artwork-validation', + negate: true, + warn: + `Skipping orphaned artwork validation. Hopefully you're debugging!`, + }, + }); + fallbackStep('filterReferenceErrors', { default: 'perform', cli: { @@ -888,20 +1001,32 @@ async function main() { logInfo`Next scheduled is in ${whenst(delay - delta)}, or by using ${'--refresh-search'}.`; Object.assign(stepStatusSummary.buildSearchIndex, { status: STATUS_NOT_APPLICABLE, - annotation: `earlier than scheduled based on file mtime`, + annotation: `earlier than scheduled`, }); } else { logInfo`Search index hasn't been generated for a little while.`; logInfo`It'll be generated this build, then again in ${whenst(delay)}.`; Object.assign(stepStatusSummary.buildSearchIndex, { status: STATUS_NOT_STARTED, - annotation: `past when shceduled based on file mtime`, + annotation: `past when shceduled`, }); } paragraph = false; } + fallbackStep('checkWikiDataSourceFileSorting', { + default: 'perform', + buildConfig: 'sort', + cli: { + flag: 'skip-sorting-validation', + negate: true, + warning: + `Skipping sorting validation. If any of this wiki's sorting rules are not\n` + + `satisfied, those errors will be silently passed along to the build.`, + }, + }); + fallbackStep('verifyImagePaths', { default: 'perform', buildConfig: 'mediaValidation', @@ -929,7 +1054,7 @@ async function main() { } if (stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED) { - Object.assign(stepStatusSummary.loadThumbnailCache, { + Object.assign(stepStatusSummary.loadOfflineThumbnailCache, { status: STATUS_NOT_APPLICABLE, annotation: `using cache from thumbnail generation`, }); @@ -1038,6 +1163,18 @@ async function main() { return false; } + if (skipSelfDiagnosis) { + logWarn`${'Skipping code self-diagnosis.'} (--skip-self-diagnosis provided)`; + logWarn`This build should run substantially faster, but corner cases`; + logWarn`not previously predicted may fail strangely and silently.`; + + html.disableSlotValidation(); + } + + if (!showTraces) { + html.disableTagTracing(); + } + Object.assign(stepStatusSummary.determineMediaCachePath, { status: STATUS_STARTED_NOT_DONE, timeStart: Date.now(), @@ -1081,6 +1218,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `--new-thumbs provided but regeneration not needed`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1096,6 +1234,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: mediaCachePathAnnotation, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1162,6 +1301,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: mediaCachePathAnnotation, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1173,6 +1313,7 @@ async function main() { status: STATUS_DONE_CLEAN, annotation: mediaCachePathAnnotation, timeEnd: Date.now(), + memory: process.memoryUsage(), }); if (stepStatusSummary.migrateThumbnails.status === STATUS_NOT_STARTED) { @@ -1192,6 +1333,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `view log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1203,6 +1345,7 @@ async function main() { Object.assign(stepStatusSummary.migrateThumbnails, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return true; @@ -1210,23 +1353,24 @@ async function main() { const niceShowAggregate = (error, ...opts) => { showAggregate(error, { - showTraces: showAggregateTraces, + showTraces, pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), ...opts, }); }; if ( - stepStatusSummary.loadThumbnailCache.status === STATUS_NOT_STARTED && + stepStatusSummary.loadOfflineThumbnailCache.status === STATUS_NOT_STARTED && stepStatusSummary.generateThumbnails.status === STATUS_NOT_STARTED ) { - throw new Error(`Unable to continue with both loadThumbnailCache and generateThumbnails`); + throw new Error(`Unable to continue with both loadOfflineThumbnailCache and generateThumbnails`); } let thumbsCache; - if (stepStatusSummary.loadThumbnailCache.status === STATUS_NOT_STARTED) { - Object.assign(stepStatusSummary.loadThumbnailCache, { + // TODO: Skip this step if we're using online thumbs + if (stepStatusSummary.loadOfflineThumbnailCache.status === STATUS_NOT_STARTED) { + Object.assign(stepStatusSummary.loadOfflineThumbnailCache, { status: STATUS_STARTED_NOT_DONE, timeStart: Date.now(), }); @@ -1242,10 +1386,11 @@ async function main() { logError`that you'll be good to go and don't need to process thumbnails` logError`again!`; - Object.assign(stepStatusSummary.loadThumbnailCache, { + Object.assign(stepStatusSummary.loadOfflineThumbnailCache, { status: STATUS_FATAL_ERROR, annotation: `cache does not exist`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1259,10 +1404,11 @@ async function main() { logError`to help you out with troubleshooting!`; logError`${'https://hsmusic.wiki/discord/'}`; - Object.assign(stepStatusSummary.loadThumbnailCache, { + Object.assign(stepStatusSummary.loadOfflineThumbnailCache, { status: STATUS_FATAL_ERROR, annotation: `cache malformed or unreadable`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1271,9 +1417,10 @@ async function main() { logInfo`Thumbnail cache file successfully read.`; - Object.assign(stepStatusSummary.loadThumbnailCache, { + Object.assign(stepStatusSummary.loadOfflineThumbnailCache, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); logInfo`Skipping thumbnail generation.`; @@ -1301,6 +1448,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `view log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1309,6 +1457,7 @@ async function main() { Object.assign(stepStatusSummary.generateThumbnails, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); if (thumbsOnly) { @@ -1320,10 +1469,6 @@ async function main() { thumbsCache = {}; } - if (showInvalidPropertyAccesses) { - CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true; - } - Object.assign(stepStatusSummary.loadDataFiles, { status: STATUS_STARTED_NOT_DONE, timeStart: Date.now(), @@ -1346,6 +1491,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `javascript error - view log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1385,6 +1531,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `error loading data files`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1426,6 +1573,10 @@ async function main() { ? prop : wikiData[prop]); + if (array && empty(array)) { + return; + } + logInfo` - ${array?.length ?? colors.red('(Missing!)')} ${colors.normal(colors.dim(label))}`; } @@ -1452,9 +1603,14 @@ async function main() { logThings('newsData', 'news entries'); } logThings('staticPageData', 'static pages'); + logThings('sortingRules', 'sorting rules'); if (wikiData.homepageLayout) { logInfo` - ${1} homepage layout (${ - wikiData.homepageLayout.rows.length + wikiData.homepageLayout.sections.length + } sections, ${ + wikiData.homepageLayout.sections + .flatMap(section => section.rows) + .length } rows)`; } if (wikiData.wikiInfo) { @@ -1487,6 +1643,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `wiki info object not available`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1499,6 +1656,7 @@ async function main() { Object.assign(stepStatusSummary.loadDataFiles, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } else { logWarn`This might indicate some fields in the YAML data weren't formatted`; @@ -1513,6 +1671,7 @@ async function main() { status: STATUS_HAS_WARNINGS, annotation: `view log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } } @@ -1526,11 +1685,12 @@ async function main() { timeStart: Date.now(), }); - linkWikiDataArrays(wikiData); + linkWikiDataArrays(wikiData, {bindFind, bindReverse}); Object.assign(stepStatusSummary.linkWikiDataArrays, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); if (precacheMode === 'common') { @@ -1602,6 +1762,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `see log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1610,13 +1771,12 @@ async function main() { Object.assign(stepStatusSummary.precacheCommonData, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } - const urls = generateURLs(urlSpec); - - // Filter out any things with duplicate directories throughout the data, - // warning about them too. + // Check for things with duplicate directories throughout the data, + // and halt if any are found. if (stepStatusSummary.reportDirectoryErrors.status === STATUS_NOT_STARTED) { Object.assign(stepStatusSummary.reportDirectoryErrors, { @@ -1632,6 +1792,7 @@ async function main() { Object.assign(stepStatusSummary.reportDirectoryErrors, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } catch (aggregate) { if (!paragraph) console.log(''); @@ -1649,14 +1810,49 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `duplicate directories found`, timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + return false; + } + } + + // Check for artwork objects which have been orphaned from their things, + // and halt if any are found. + + if (stepStatusSummary.reportOrphanedArtworks.status === STATUS_NOT_STARTED) { + Object.assign(stepStatusSummary.reportOrphanedArtworks, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + try { + reportOrphanedArtworks(wikiData, {getAllFindSpecs}); + + Object.assign(stepStatusSummary.reportOrphanedArtworks, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + } catch (aggregate) { + if (!paragraph) console.log(''); + niceShowAggregate(aggregate); + + logError`Failed to initialize artwork data connections properly.`; + fileIssue(); + + Object.assign(stepStatusSummary.reportOrphanedArtworks, { + status: STATUS_FATAL_ERROR, + annotation: `orphaned artworks found`, + timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; } } - // Filter out any reference errors throughout the data, warning about them - // too. + // Filter out any reference errors throughout the data, warning about these. if (stepStatusSummary.filterReferenceErrors.status === STATUS_NOT_STARTED) { Object.assign(stepStatusSummary.filterReferenceErrors, { @@ -1676,6 +1872,7 @@ async function main() { Object.assign(stepStatusSummary.filterReferenceErrors, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } catch (error) { if (!paragraph) console.log(''); @@ -1693,6 +1890,7 @@ async function main() { status: STATUS_HAS_WARNINGS, annotation: `view log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } } @@ -1712,6 +1910,7 @@ async function main() { Object.assign(stepStatusSummary.reportContentTextErrors, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } catch (error) { if (!paragraph) console.log(''); @@ -1728,6 +1927,7 @@ async function main() { status: STATUS_HAS_WARNINGS, annotation: `view log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } } @@ -1740,11 +1940,12 @@ async function main() { timeStart: Date.now(), }); - sortWikiDataArrays(yamlDataSteps, wikiData); + sortWikiDataArrays(yamlDataSteps, wikiData, {bindFind, bindReverse}); Object.assign(stepStatusSummary.sortWikiDataArrays, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); if (precacheMode === 'all') { @@ -1768,9 +1969,81 @@ async function main() { Object.assign(stepStatusSummary.precacheAllData, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } + if (stepStatusSummary.sortWikiDataSourceFiles.status === STATUS_NOT_STARTED) { + Object.assign(stepStatusSummary.sortWikiDataSourceFiles, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + const {SortingRule} = thingConstructors; + const results = + await Array.fromAsync(SortingRule.go({dataPath, wikiData})); + + if (results.some(result => result.changed)) { + logInfo`Updated data files to satisfy sorting.`; + logInfo`Restarting automatically, since that's now needed!`; + + Object.assign(stepStatusSummary.sortWikiDataSourceFiles, { + status: STATUS_DONE_CLEAN, + annotation: `changes cueing restart`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + return 'restart'; + } else { + logInfo`All sorting rules are satisfied. Nice!`; + paragraph = false; + + Object.assign(stepStatusSummary.sortWikiDataSourceFiles, { + status: STATUS_DONE_CLEAN, + annotation: `no changes needed`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + } + } else if (stepStatusSummary.checkWikiDataSourceFileSorting.status === STATUS_NOT_STARTED) { + Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + const {SortingRule} = thingConstructors; + const results = + await Array.fromAsync(SortingRule.go({dataPath, wikiData, dry: true})); + + const needed = results.filter(result => result.changed); + + if (empty(needed)) { + logInfo`All sorting rules are satisfied. Nice!`; + paragraph = false; + + Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + } else { + logWarn`Some of this wiki's sorting rules currently aren't satisfied:`; + for (const {rule} of needed) { + logWarn`- ${rule.message}`; + } + logWarn`Run ${'hsmusic --sort'} to automatically update data files.`; + paragraph = false; + + Object.assign(stepStatusSummary.checkWikiDataSourceFileSorting, { + status: STATUS_HAS_WARNINGS, + annotation: `not all rules satisfied`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + } + } + if (stepStatusSummary.performBuild.status === STATUS_NOT_APPLICABLE) { displayCompositeCacheAnalysis(); @@ -1779,6 +2052,354 @@ async function main() { } } + Object.assign(stepStatusSummary.loadURLFiles, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + let internalURLSpec = {}; + + try { + let aggregate; + ({aggregate, result: internalURLSpec} = + await processURLSpecFromFile(internalDefaultURLSpecFile)); + + aggregate.close(); + } catch (error) { + niceShowAggregate(error); + logError`Couldn't load internal default URL spec.`; + logError`This is required to build the wiki, so stopping here.`; + fileIssue(); + + Object.assign(stepStatusSummary.loadURLFiles, { + status: STATUS_FATAL_ERROR, + annotation: `see log for details`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + return false; + } + + // We'll mutate this as we load other url spec files. + const urlSpec = structuredClone(internalURLSpec); + + const allURLSpecDataFiles = + (await readdir(dataPath)) + .filter(name => + name.startsWith('urls') && + ['.json', '.yaml'].includes(path.extname(name))) + .sort() /* Just in case... */ + .map(name => path.join(dataPath, name)); + + const getURLSpecKeyFromFile = file => { + const base = path.basename(file, path.extname(file)); + if (base === 'urls') { + return base; + } else { + return base.replace(/^urls-/, ''); + } + }; + + const isDefaultURLSpecFile = file => + getURLSpecKeyFromFile(file) === 'urls'; + + const overrideDefaultURLSpecFile = + allURLSpecDataFiles.find(file => isDefaultURLSpecFile(file)); + + const optionalURLSpecDataFiles = + allURLSpecDataFiles.filter(file => !isDefaultURLSpecFile(file)); + + const optionalURLSpecDataKeys = + optionalURLSpecDataFiles.map(file => getURLSpecKeyFromFile(file)); + + const selectedURLSpecDataKeys = optionalURLSpecDataKeys.slice(); + const selectedURLSpecDataFiles = optionalURLSpecDataFiles.slice(); + + const {removed: [unusedURLSpecDataKeys]} = + filterMultipleArrays( + selectedURLSpecDataKeys, + selectedURLSpecDataFiles, + (key, _file) => wantedURLSpecKeys.includes(key)); + + if (!empty(selectedURLSpecDataKeys)) { + logInfo`Using these optional URL specs: ${selectedURLSpecDataKeys.join(', ')}`; + if (!empty(unusedURLSpecDataKeys)) { + logInfo`Other available optional URL specs: ${unusedURLSpecDataKeys.join(', ')}`; + } + } else if (!empty(unusedURLSpecDataKeys)) { + logInfo`Not using any optional URL specs.`; + logInfo`These are available with --urls: ${unusedURLSpecDataKeys.join(', ')}`; + } + + if (overrideDefaultURLSpecFile) { + try { + let aggregate; + let overrideDefaultURLSpec; + + ({aggregate, result: overrideDefaultURLSpec} = + await processURLSpecFromFile(overrideDefaultURLSpecFile)); + + aggregate.close(); + + ({aggregate} = + applyURLSpecOverriding(overrideDefaultURLSpec, urlSpec)); + + aggregate.close(); + } catch (error) { + niceShowAggregate(error); + logError`Errors loading this data repo's ${'urls.yaml'} file.`; + logError`This provides essential overrides for this wiki,`; + logError`so stopping here. Debug the errors to continue.`; + + Object.assign(stepStatusSummary.loadURLFiles, { + status: STATUS_FATAL_ERROR, + annotation: `see log for details`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + return false; + } + } + + const processURLSpecsAggregate = + openAggregate({message: `Errors processing URL specs`}); + + const selectedURLSpecs = + processURLSpecsAggregate.receive( + await Promise.all( + selectedURLSpecDataFiles + .map(file => processURLSpecFromFile(file)))); + + for (const selectedURLSpec of selectedURLSpecs) { + processURLSpecsAggregate.receive( + applyURLSpecOverriding(selectedURLSpec, urlSpec)); + } + + try { + processURLSpecsAggregate.close(); + } catch (error) { + niceShowAggregate(error); + logWarn`There were errors loading the optional URL specs you`; + logWarn`selected using ${'--urls'}. Since they might misfunction,`; + logWarn`debug the errors or remove the failing ones from ${'--urls'}.`; + + Object.assign(stepStatusSummary.loadURLFiles, { + status: STATUS_FATAL_ERROR, + annotation: `see log for details`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + return false; + } + + if (showURLSpec) { + if (!paragraph) console.log(''); + + logInfo`Here's the final URL spec, via ${'--show-url-spec'}:` + console.log(urlSpec); + console.log(''); + + paragraph = true; + } + + Object.assign(stepStatusSummary.loadURLFiles, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + if (!getOrigin(urlSpec.thumb.prefix)) { + Object.assign(stepStatusSummary.loadOnlineThumbnailCache, { + status: STATUS_NOT_APPLICABLE, + annotation: `using offline thumbs`, + }); + } + + if (getOrigin(urlSpec.media.prefix)) { + Object.assign(stepStatusSummary.preloadFileSizes, { + status: STATUS_NOT_APPLICABLE, + annotation: `using online media`, + }); + } else { + Object.assign(stepStatusSummary.loadOnlineFileSizeCache, { + status: STATUS_NOT_APPLICABLE, + annotation: `using offline media`, + }); + } + + applyLocalizedWithBaseDirectory(urlSpec); + + const urls = generateURLs(urlSpec); + + if (stepStatusSummary.loadOnlineThumbnailCache.status === STATUS_NOT_STARTED) loadOnlineThumbnailCache: { + Object.assign(stepStatusSummary.loadOnlineThumbnailCache, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + let onlineThumbsCache = null; + + const cacheFile = path.join(wikiCachePath, 'online-thumbnail-cache.json'); + + let readError = null; + let writeError = null; + + if (!cliOptions['refresh-online-thumbs']) { + try { + onlineThumbsCache = JSON.parse(await readFile(cacheFile)); + } catch (caughtError) { + readError = caughtError; + } + } + + if (onlineThumbsCache) obliterateLocalCopy: { + if (!onlineThumbsCache._urlPrefix) { + // Well, it doesn't even count. + onlineThumbsCache = null; + break obliterateLocalCopy; + } + + if (onlineThumbsCache._urlPrefix !== urlSpec.thumb.prefix) { + logInfo`Local copy of online thumbs cache is for a different prefix.`; + logInfo`It'll be downloaded and replaced, for reuse next time.`; + paragraph = false; + + onlineThumbsCache = null; + break obliterateLocalCopy; + } + + let stats; + try { + stats = await stat(cacheFile); + } catch { + logInfo`Unable to get the stats of local copy of online thumbs cache...`; + logInfo`This is really weird, since we *were* able to read it...`; + logInfo`We're just going to try writing to it and download fresh!`; + paragraph = false; + + onlineThumbsCache = null; + break obliterateLocalCopy; + } + + const delta = Date.now() - stats.mtimeMs; + const minute = 60 * 1000; + const delay = 60 * minute; + + const whenst = duration => `~${Math.ceil(duration / minute)} min`; + + if (delta < delay) { + logInfo`Online thumbs cache was downloaded recently, skipping for this build.`; + logInfo`Next scheduled is in ${whenst(delay - delta)}, or by using ${'--refresh-online-thumbs'}.`; + paragraph = false; + + Object.assign(stepStatusSummary.loadOnlineThumbnailCache, { + status: STATUS_DONE_CLEAN, + annotation: `reusing local copy, earlier than scheduled`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + thumbsCache = onlineThumbsCache; + + break loadOnlineThumbnailCache; + } else { + logInfo`Online thumbs cache hasn't been downloaded for a little while.`; + logInfo`It'll be downloaded this build, then again in ${whenst(delay)}.`; + onlineThumbsCache = null; + paragraph = false; + } + } + + try { + await writeFile(cacheFile, stringifyCache(onlineThumbsCache)); + } catch (caughtError) { + writeError = caughtError; + } + + if (readError && writeError && readError.code !== 'ENOENT') { + console.error(readError); + logWarn`Wasn't able to read the local copy of the`; + logWarn`online thumbs cache file...`; + console.error(writeError); + logWarn`...or write to it, either.`; + logWarn`The online thumbs cache will be downloaded`; + logWarn`for every build until you investigate this path:`; + logWarn`${cacheFile}`; + paragraph = false; + } else if (readError && readError.code === 'ENOENT' && !writeError) { + logInfo`No local copy of online thumbs cache.`; + logInfo`It'll be downloaded this time and reused next time.`; + paragraph = false; + } else if (readError && readError.code === 'ENOENT' && writeError) { + console.error(writeError); + logWarn`Doesn't look like we can write a local copy of`; + logWarn`the offline thumbs cache, at this path:`; + logWarn`${cacheFile}`; + logWarn`The online thumbs cache will be downloaded`; + logWarn`for every build until you investigate that.`; + paragraph = false; + } + + const url = new URL(urlSpec.thumb.prefix); + url.pathname = path.posix.join(url.pathname, 'thumbnail-cache.json'); + + try { + onlineThumbsCache = await fetch(url).then(res => res.json()); + } catch (error) { + console.error(error); + logWarn`There was an error downloading the online thumbnail cache.`; + logWarn`The wiki will act as though no thumbs are available at all.`; + paragraph = false; + + Object.assign(stepStatusSummary.loadOnlineThumbnailCache, { + status: STATUS_HAS_WARNINGS, + annotation: `failed to download`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + onlineThumbsCache = {}; + thumbsCache = {}; + + break loadOnlineThumbnailCache; + } + + onlineThumbsCache._urlPrefix = urlSpec.thumb.prefix; + + thumbsCache = onlineThumbsCache; + + if (onlineThumbsCache && !writeError) { + try { + await writeFile(cacheFile, stringifyCache(onlineThumbsCache)); + } catch (error) { + console.error(error); + logWarn`There was an error saving a local copy of the`; + logWarn`online thumbnail cache. It'll be fetched again`; + logWarn`next time.`; + paragraph = false; + + Object.assign(stepStatusSummary.loadOnlineThumbnailCache, { + status: STATUS_HAS_WARNINGS, + annotation: `failed to download`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + break loadOnlineThumbnailCache; + } + } + + Object.assign(stepStatusSummary.loadOnlineThumbnailCache, { + status: STATUS_DONE_CLEAN, + timeStart: Date.now(), + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + } + const languageReloading = stepStatusSummary.watchLanguageFiles.status === STATUS_NOT_STARTED; @@ -1817,7 +2438,7 @@ async function main() { }); internalDefaultLanguage = internalDefaultLanguageWatcher.language; - } catch (_error) { + } catch { // No need to display the error here - it's already printed by // watchLanguageFile. errorLoadingInternalDefaultLanguage = true; @@ -1841,6 +2462,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `see log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -1854,6 +2476,7 @@ async function main() { Object.assign(stepStatusSummary.loadInternalDefaultLanguage, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); let customLanguageWatchers; @@ -1933,6 +2556,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `see log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); errorLoadingCustomLanguages = true; @@ -1964,6 +2588,7 @@ async function main() { Object.assign(stepStatusSummary.watchLanguageFiles, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } else { languages = {}; @@ -1987,11 +2612,13 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `see log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } else { Object.assign(stepStatusSummary.loadLanguageFiles, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } } @@ -2029,6 +2656,7 @@ async function main() { status: STATUS_FATAL_ERROR, annotation: `wiki specifies default language whose file is not available`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -2122,6 +2750,7 @@ async function main() { status: STATUS_DONE_CLEAN, annotation: finalDefaultLanguageAnnotation, timeEnd: Date.now(), + memory: process.memoryUsage(), }); let missingImagePaths; @@ -2144,85 +2773,225 @@ async function main() { Object.assign(stepStatusSummary.verifyImagePaths, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } else if (empty(missingImagePaths)) { Object.assign(stepStatusSummary.verifyImagePaths, { status: STATUS_HAS_WARNINGS, annotation: `misplaced images detected`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } else if (empty(misplacedImagePaths)) { Object.assign(stepStatusSummary.verifyImagePaths, { status: STATUS_HAS_WARNINGS, annotation: `missing images detected`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } else { Object.assign(stepStatusSummary.verifyImagePaths, { status: STATUS_HAS_WARNINGS, annotation: `missing and misplaced images detected`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } } - let getSizeOfAdditionalFile; - let getSizeOfImagePath; + let getSizeOfMediaFile = () => null; + + const fileSizePreloader = + new FileSizePreloader({ + prefix: mediaPath, + }); + + if (stepStatusSummary.loadOnlineFileSizeCache.status === STATUS_NOT_STARTED) loadOnlineFileSizeCache: { + Object.assign(stepStatusSummary.loadOnlineFileSizeCache, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + let onlineFileSizeCache = null; + + const makeFileSizeCacheAvailable = () => { + fileSizePreloader.loadFromCache(onlineFileSizeCache); + + getSizeOfMediaFile = p => + fileSizePreloader.getSizeOfPath( + path.resolve( + mediaPath, + decodeURIComponent(p).split('/').join(path.sep))); + }; + + const cacheFile = path.join(wikiCachePath, 'online-file-size-cache.json'); + + let readError = null; + let writeError = null; + + if (!cliOptions['refresh-online-file-sizes']) { + try { + onlineFileSizeCache = JSON.parse(await readFile(cacheFile)); + } catch (caughtError) { + readError = caughtError; + } + } + + if (onlineFileSizeCache) obliterateLocalCopy: { + if (!onlineFileSizeCache._urlPrefix) { + // Well, it doesn't even count. + onlineFileSizeCache = null; + break obliterateLocalCopy; + } + + if (onlineFileSizeCache._urlPrefix !== urlSpec.media.prefix) { + logInfo`Local copy of online file size cache is for a different prefix.`; + logInfo`It'll be downloaded and replaced, for reuse next time.`; + paragraph = false; + + onlineFileSizeCache = null; + break obliterateLocalCopy; + } + + let stats; + try { + stats = await stat(cacheFile); + } catch { + logInfo`Unable to get the stats of local copy of online file size cache...`; + logInfo`This is really weird, since we *were* able to read it...`; + logInfo`We're just going to try writing to it and download fresh!`; + paragraph = false; + + onlineFileSizeCache = null; + break obliterateLocalCopy; + } + + const delta = Date.now() - stats.mtimeMs; + const minute = 60 * 1000; + const delay = 60 * minute; + + const whenst = duration => `~${Math.ceil(duration / minute)} min`; + + if (delta < delay) { + logInfo`Online file size cache was downloaded recently, skipping for this build.`; + logInfo`Next scheduled is in ${whenst(delay - delta)}, or by using ${'--refresh-online-file-sizes'}.`; + paragraph = false; + + Object.assign(stepStatusSummary.loadOnlineFileSizeCache, { + status: STATUS_DONE_CLEAN, + annotation: `reusing local copy, earlier than scheduled`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + delete onlineFileSizeCache._urlPrefix; + + makeFileSizeCacheAvailable(); + + break loadOnlineFileSizeCache; + } else { + logInfo`Online file size hasn't been downloaded for a little while.`; + logInfo`It'll be downloaded this build, then again in ${whenst(delay)}.`; + onlineFileSizeCache = null; + paragraph = false; + } + } + + try { + await writeFile(cacheFile, stringifyCache(onlineFileSizeCache)); + } catch (caughtError) { + writeError = caughtError; + } + + if (readError && writeError && readError.code !== 'ENOENT') { + console.error(readError); + logWarn`Wasn't able to read the local copy of the`; + logWarn`online file size cache file...`; + console.error(writeError); + logWarn`...or write to it, either.`; + logWarn`The online file size cache will be downloaded`; + logWarn`for every build until you investigate this path:`; + logWarn`${cacheFile}`; + paragraph = false; + } else if (readError && readError.code === 'ENOENT' && !writeError) { + logInfo`No local copy of online file size cache.`; + logInfo`It'll be downloaded this time and reused next time.`; + paragraph = false; + } else if (readError && readError.code === 'ENOENT' && writeError) { + console.error(writeError); + logWarn`Doesn't look like we can write a local copy of`; + logWarn`the offline file size cache, at this path:`; + logWarn`${cacheFile}`; + logWarn`The online file size cache will be downloaded`; + logWarn`for every build until you investigate that.`; + paragraph = false; + } + + const url = new URL(urlSpec.media.prefix); + url.pathname = path.posix.join(url.pathname, 'file-size-cache.json'); + + try { + onlineFileSizeCache = await fetch(url).then(res => res.json()); + } catch (error) { + console.error(error); + logWarn`There was an error downloading the online file size cache.`; + logWarn`The wiki will act as though no file sizes are available at all.`; + paragraph = false; + + Object.assign(stepStatusSummary.loadOnlineFileSizeCache, { + status: STATUS_HAS_WARNINGS, + annotation: `failed to download`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + break loadOnlineFileSizeCache; + } + + makeFileSizeCacheAvailable(); + + onlineFileSizeCache._urlPrefix = urlSpec.media.prefix; + + if (onlineFileSizeCache && !writeError) { + try { + await writeFile(cacheFile, stringifyCache(onlineFileSizeCache)); + } catch (error) { + console.error(error); + logWarn`There was an error saving a local copy of the`; + logWarn`online file size cache. It'll be fetched again`; + logWarn`next time.`; + paragraph = false; - if (stepStatusSummary.preloadFileSizes.status === STATUS_NOT_APPLICABLE) { - getSizeOfAdditionalFile = () => null; - getSizeOfImagePath = () => null; - } else if (stepStatusSummary.preloadFileSizes.status === STATUS_NOT_STARTED) { + Object.assign(stepStatusSummary.loadOnlineFileSizeCache, { + status: STATUS_HAS_WARNINGS, + annotation: `failed to download`, + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + + break loadOnlineFileSizeCache; + } + } + + Object.assign(stepStatusSummary.loadOnlineFileSizeCache, { + status: STATUS_DONE_CLEAN, + timeStart: Date.now(), + timeEnd: Date.now(), + memory: process.memoryUsage(), + }); + } + + if (stepStatusSummary.preloadFileSizes.status === STATUS_NOT_STARTED) { Object.assign(stepStatusSummary.preloadFileSizes, { status: STATUS_STARTED_NOT_DONE, timeStart: Date.now(), }); - const fileSizePreloader = new FileSizePreloader(); - - // File sizes of additional files need to be precalculated before we can - // actually reference 'em in site building, so get those loading right - // away. We actually need to keep track of two things here - the on-device - // file paths we're actually reading, and the corresponding on-site media - // paths that will be exposed in site build code. We'll build a mapping - // function between them so that when site code requests a site path, - // it'll get the size of the file at the corresponding device path. - const additionalFilePaths = [ - ...wikiData.albumData.flatMap((album) => - [ - ...(album.additionalFiles ?? []), - ...album.tracks.flatMap((track) => [ - ...(track.additionalFiles ?? []), - ...(track.sheetMusicFiles ?? []), - ...(track.midiProjectFiles ?? []), - ]), - ] - .flatMap((fileGroup) => fileGroup.files ?? []) - .map((file) => ({ - device: path.join( - mediaPath, - urls - .from('media.root') - .toDevice('media.albumAdditionalFile', album.directory, file) - ), - media: urls - .from('media.root') - .to('media.albumAdditionalFile', album.directory, file), - })) - ), - ]; - - // Same dealio for images. Since just about any image can be embedded and - // we can't super easily know which ones are referenced at runtime, just - // cheat and get file sizes for all images under media. (This includes - // additional files which are images.) - const imageFilePaths = + const mediaFilePaths = await traverse(mediaPath, { pathStyle: 'device', filterDir: dir => dir !== '.git', - filterFile: file => - ['.png', '.gif', '.jpg'].includes(path.extname(file)) && - !isThumb(file), + filterFile: file => !isThumb(file), }).then(files => files .map(file => ({ device: file, @@ -2232,28 +3001,19 @@ async function main() { .to('media.path', path.relative(mediaPath, file).split(path.sep).join('/')), }))); - const getSizeOfMediaFileHelper = paths => (mediaPath) => { - const pair = paths.find(({media}) => media === mediaPath); + getSizeOfMediaFile = mediaPath => { + const pair = mediaFilePaths.find(({media}) => media === mediaPath); if (!pair) return null; return fileSizePreloader.getSizeOfPath(pair.device); }; - getSizeOfAdditionalFile = getSizeOfMediaFileHelper(additionalFilePaths); - getSizeOfImagePath = getSizeOfMediaFileHelper(imageFilePaths); - - logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`; - - fileSizePreloader.loadPaths(...additionalFilePaths.map((path) => path.device)); - await fileSizePreloader.waitUntilDoneLoading(); - - logInfo`Preloading filesizes for ${imageFilePaths.length} full-resolution images...`; - paragraph = false; + logInfo`Preloading file sizes for ${mediaFilePaths.length} media files...`; - fileSizePreloader.loadPaths(...imageFilePaths.map((path) => path.device)); + fileSizePreloader.loadPaths(...mediaFilePaths.map(path => path.device)); await fileSizePreloader.waitUntilDoneLoading(); if (fileSizePreloader.hasErrored) { - logWarn`Some media files couldn't be read for preloading filesizes.`; + logWarn`Some media files couldn't be read for preloading file sizes.`; logWarn`This means the wiki won't display file sizes for these files.`; logWarn`Investigate missing or unreadable files to get that fixed!`; @@ -2261,16 +3021,50 @@ async function main() { status: STATUS_HAS_WARNINGS, annotation: `see log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } else { - logInfo`Done preloading filesizes without any errors - nice!`; + logInfo`Done preloading file sizes without any errors - nice!`; paragraph = false; Object.assign(stepStatusSummary.preloadFileSizes, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } + + // TODO: kinda jank that this is out of band of any particular step, + // even though it's operationally a follow-up to preloadFileSizes + + let oopsCache = false; + saveFileSizeCache: { + let cache; + try { + cache = fileSizePreloader.saveAsCache(); + } catch (error) { + console.error(error); + logWarn`Couldn't compute file size preloader's cache.`; + oopsCache = true; + break saveFileSizeCache; + } + + const cacheFile = path.join(mediaPath, 'file-size-cache.json'); + + try { + await writeFile(cacheFile, stringifyCache(cache)); + } catch (error) { + console.error(error); + logWarn`Couldn't save preloaded file sizes to a cache file:`; + logWarn`${cacheFile}`; + oopsCache = true; + } + } + + if (oopsCache) { + logWarn`This won't affect the build, but this build should not be used`; + logWarn`as a model for another build accessing its media files online.`; + } } if (stepStatusSummary.buildSearchIndex.status === STATUS_NOT_STARTED) { @@ -2293,6 +3087,7 @@ async function main() { Object.assign(stepStatusSummary.buildSearchIndex, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } catch (error) { if (!paragraph) console.log(''); @@ -2310,6 +3105,7 @@ async function main() { status: STATUS_HAS_WARNINGS, annotation: `see log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } } @@ -2357,6 +3153,7 @@ async function main() { status: STATUS_FATAL_ERROR, message: `JavaScript error - view log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -2368,6 +3165,7 @@ async function main() { Object.assign(stepStatusSummary.identifyWebRoutes, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); } @@ -2422,13 +3220,13 @@ async function main() { console.log(''); const universalUtilities = { - getSizeOfAdditionalFile, - getSizeOfImagePath, + getSizeOfMediaFile, defaultLanguage: finalDefaultLanguage, developersComment, languages, missingImagePaths, + niceShowAggregate, thumbsCache, urlSpec, urls, @@ -2464,6 +3262,7 @@ async function main() { status: STATUS_FATAL_ERROR, message: `javascript error - view log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -2474,6 +3273,7 @@ async function main() { status: STATUS_HAS_WARNINGS, annotation: `may not have completed - view log for details`, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return false; @@ -2482,136 +3282,78 @@ async function main() { Object.assign(stepStatusSummary.performBuild, { status: STATUS_DONE_CLEAN, timeEnd: Date.now(), + memory: process.memoryUsage(), }); return true; } // TODO: isMain detection isn't consistent across platforms here -/* eslint-disable-next-line no-constant-condition */ +// eslint-disable-next-line no-constant-binary-expression if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmusic') { (async () => { let result; + let numRestarts = 0; const totalTimeStart = Date.now(); - try { - result = await main(); - } catch (error) { - if (error instanceof AggregateError) { - showAggregate(error); - } else if (error.cause) { - console.error(error); - showAggregate(error); - } else { - console.error(error); - } - } - - const totalTimeEnd = Date.now(); - - const formatDuration = timeDelta => { - const seconds = timeDelta / 1000; - - if (seconds > 90) { - const modSeconds = Math.floor(seconds % 60); - const minutes = Math.floor(seconds - seconds % 60) / 60; - return `${minutes}m${modSeconds}s`; - } - - if (seconds < 0.1) { - return 'instant'; + while (true) { + try { + result = await main(); + } catch (error) { + if (error instanceof AggregateError) { + showAggregate(error); + } else if (error.cause) { + console.error(error); + showAggregate(error); + } else { + console.error(error); + } } - const precision = (seconds > 1 ? 3 : 2); - return `${seconds.toPrecision(precision)}s`; - }; - - if (showStepStatusSummary) { - const totalDuration = formatDuration(totalTimeEnd - totalTimeStart); - - console.error(colors.bright(`Step summary:`)); - - const longestNameLength = - Math.max(... - Object.values(stepStatusSummary) - .map(({name}) => name.length)); - - const stepsNotClean = - Object.values(stepStatusSummary) - .map(({status}) => - status === STATUS_HAS_WARNINGS || - status === STATUS_FATAL_ERROR || - status === STATUS_STARTED_NOT_DONE); - - const anyStepsNotClean = - stepsNotClean.includes(true); - - const stepDetails = Object.values(stepStatusSummary); - - const stepDurations = - stepDetails.map(({status, timeStart, timeEnd}) => { - if ( - status === STATUS_NOT_APPLICABLE || - status === STATUS_NOT_STARTED || - status === STATUS_STARTED_NOT_DONE - ) { - return '-'; - } + if (result === 'restart') { + console.log(''); - if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') { - return 'unknown'; + if (shouldShowStepStatusSummary) { + if (numRestarts >= 1) { + console.error(colors.bright(`Step summary since latest restart:`)); + } else { + console.error(colors.bright(`Step summary before restart:`)); } - return formatDuration(timeEnd - timeStart); - }); - - const longestDurationLength = - Math.max(...stepDurations.map(duration => duration.length)); - - for (let index = 0; index < stepDetails.length; index++) { - const {name, status, annotation} = stepDetails[index]; - const duration = stepDurations[index]; - - let message = - (stepsNotClean[index] - ? `!! ` - : ` - `); - - message += `(${duration})`.padStart(longestDurationLength + 2, ' '); - message += ` `; - message += `${name}: `.padEnd(longestNameLength + 4, '.'); - message += ` `; - message += status; - - if (annotation) { - message += ` (${annotation})`; + showStepStatusSummary(); + console.log(''); } - switch (status) { - case STATUS_DONE_CLEAN: - console.error(colors.green(message)); - break; - - case STATUS_NOT_STARTED: - case STATUS_NOT_APPLICABLE: - console.error(colors.dim(message)); - break; + if (numRestarts > 5) { + logError`A restart was cued, but we've restarted a bunch already.`; + logError`Exiting because this is probably a bug!`; + console.log(''); + break; + } else { + console.log(''); + logInfo`A restart was cued. This is probably normal, and required`; + logInfo`to load updated data files. Restarting automatically now!`; + console.log(''); + numRestarts++; + } + } else { + break; + } + } - case STATUS_HAS_WARNINGS: - case STATUS_STARTED_NOT_DONE: - console.error(colors.yellow(message)); - break; + if (shouldShowStepStatusSummary) { + if (numRestarts >= 1) { + console.error(colors.bright(`Step summary after final restart:`)); + } else { + console.error(colors.bright(`Step summary:`)); + } - case STATUS_FATAL_ERROR: - console.error(colors.red(message)); - break; + const {anyStepsNotClean} = + showStepStatusSummary(); - default: - console.error(message); - break; - } - } + const totalTimeEnd = Date.now(); + const totalDuration = formatDuration(totalTimeEnd - totalTimeStart); console.error(colors.bright(`Done in ${totalDuration}.`)); @@ -2636,8 +3378,107 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus } decorateTime.displayTime(); - CacheableObject.showInvalidAccesses(); process.exit(0); })(); } + +function showStepStatusSummary() { + const longestNameLength = + Math.max(... + Object.values(stepStatusSummary) + .map(({name}) => name.length)); + + const stepsNotClean = + Object.values(stepStatusSummary) + .map(({status}) => + status === STATUS_HAS_WARNINGS || + status === STATUS_FATAL_ERROR || + status === STATUS_STARTED_NOT_DONE); + + const anyStepsNotClean = + stepsNotClean.includes(true); + + const stepDetails = Object.values(stepStatusSummary); + + const stepDurations = + stepDetails.map(({status, timeStart, timeEnd}) => { + if ( + status === STATUS_NOT_APPLICABLE || + status === STATUS_NOT_STARTED || + status === STATUS_STARTED_NOT_DONE + ) { + return '-'; + } + + if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') { + return 'unknown'; + } + + return formatDuration(timeEnd - timeStart); + }); + + const longestDurationLength = + Math.max(...stepDurations.map(duration => duration.length)); + + const stepMemories = + stepDetails.map(({memory}) => + (memory + ? Math.round(memory["heapUsed"] / 1024 / 1024) + 'MB' + : '-')); + + const longestMemoryLength = + Math.max(...stepMemories.map(memory => memory.length)); + + for (let index = 0; index < stepDetails.length; index++) { + const {name, status, annotation} = stepDetails[index]; + const duration = stepDurations[index]; + const memory = stepMemories[index]; + + let message = + (stepsNotClean[index] + ? `!! ` + : ` - `); + + message += `(${duration} `.padStart(longestDurationLength + 2, ' '); + + if (shouldShowStepMemoryInSummary) { + message += ` ${memory})`.padStart(longestMemoryLength + 2, ' '); + } + + message += ` `; + message += `${name}: `.padEnd(longestNameLength + 4, '.'); + message += ` `; + message += status; + + if (annotation) { + message += ` (${annotation})`; + } + + switch (status) { + case STATUS_DONE_CLEAN: + console.error(colors.green(message)); + break; + + case STATUS_NOT_STARTED: + case STATUS_NOT_APPLICABLE: + console.error(colors.dim(message)); + break; + + case STATUS_HAS_WARNINGS: + case STATUS_STARTED_NOT_DONE: + console.error(colors.yellow(message)); + break; + + case STATUS_FATAL_ERROR: + console.error(colors.red(message)); + break; + + default: + console.error(message); + break; + } + } + + return {anyStepsNotClean}; +} diff --git a/src/url-spec.js b/src/url-spec.js index 6ca75e7d..75cd8006 100644 --- a/src/url-spec.js +++ b/src/url-spec.js @@ -1,145 +1,220 @@ -import {withEntries} from '#sugar'; - -// Static files are all grouped under a `static-${STATIC_VERSION}` folder as -// part of a build. This is so that multiple builds of a wiki can coexist -// served from the same server / file system root: older builds' HTML files -// refer to earlier values of STATIC_VERSION, avoiding name collisions. -const STATIC_VERSION = '3p3'; - -const genericPaths = { - root: '', - path: '<>', -}; - -const urlSpec = { - data: { - prefix: 'data/', - - paths: { - ...genericPaths, - - album: 'album/<>', - artist: 'artist/<>', - track: 'track/<>', - }, - }, - - localized: { - // TODO: Implement this. - // prefix: '_languageCode', - - paths: { - ...genericPaths, - page: '<>/', - - home: '', - - album: 'album/<>/', - albumCommentary: 'commentary/album/<>/', - albumGallery: 'album/<>/gallery/', - albumReferencedArtworks: 'album/<>/referenced-art/', - albumReferencingArtworks: 'album/<>/referencing-art/', - - artist: 'artist/<>/', - artistGallery: 'artist/<>/gallery/', - - commentaryIndex: 'commentary/', - - flashIndex: 'flash/', - - flash: 'flash/<>/', - - flashActGallery: 'flash-act/<>/', - - groupInfo: 'group/<>/', - groupGallery: 'group/<>/gallery/', - - listingIndex: 'list/', - - listing: 'list/<>/', - - newsIndex: 'news/', - - newsEntry: 'news/<>/', - - staticPage: '<>/', - - tag: 'tag/<>/', - - track: 'track/<>/', - trackReferencedArtworks: 'track/<>/referenced-art/', - trackReferencingArtworks: 'track/<>/referencing-art/', - }, - }, - - shared: { - paths: genericPaths, - }, - - staticCSS: { - prefix: `static-${STATIC_VERSION}/css/`, - paths: genericPaths, - }, - - staticJS: { - prefix: `static-${STATIC_VERSION}/js/`, - paths: genericPaths, - }, - - staticLib: { - prefix: `static-${STATIC_VERSION}/lib/`, - paths: genericPaths, - }, - - staticMisc: { - prefix: `static-${STATIC_VERSION}/misc/`, - paths: { - ...genericPaths, - icon: 'icons.svg#icon-<>', - }, - }, - - staticSharedUtil: { - prefix: `static-${STATIC_VERSION}/shared-util/`, - paths: genericPaths, - }, - - media: { - prefix: 'media/', - - paths: { - ...genericPaths, - - albumAdditionalFile: 'album-additional/<>/<>', - albumBanner: 'album-art/<>/banner.<>', - albumCover: 'album-art/<>/cover.<>', - albumWallpaper: 'album-art/<>/bg.<>', - albumWallpaperPart: 'album-art/<>/<>', - - artistAvatar: 'artist-avatar/<>.<>', - - flashArt: 'flash-art/<>.<>', - - trackCover: 'album-art/<>/<>.<>', - }, - }, - - thumb: { - prefix: 'thumb/', - paths: genericPaths, - }, - - searchData: { - prefix: 'search-data/', - paths: genericPaths, - }, -}; - -// This gets automatically switched in place when working from a baseDirectory, -// so it should never be referenced manually. -urlSpec.localizedWithBaseDirectory = { - paths: withEntries(urlSpec.localized.paths, (entries) => - entries.map(([key, path]) => [key, '<>/' + path])), -}; - -export default urlSpec; +// Exports defined here are re-exported through urls.js, +// so they're generally imported from '#urls'. + +import {readFile} from 'node:fs/promises'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +import yaml from 'js-yaml'; + +import {annotateError, annotateErrorWithFile, openAggregate} from '#aggregate'; +import {empty, typeAppearance, withEntries} from '#sugar'; + +export const DEFAULT_URL_SPEC_FILE = 'urls-default.yaml'; + +export const internalDefaultURLSpecFile = + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + DEFAULT_URL_SPEC_FILE); + +function processStringToken(key, token) { + const oops = appearance => + new Error( + `Expected ${key} to be a string or an array of strings, ` + + `got ${appearance}`); + + if (typeof token === 'string') { + return token; + } else if (Array.isArray(token)) { + if (empty(token)) { + throw oops(`empty array`); + } else if (token.every(item => typeof item !== 'string')) { + throw oops(`array of non-strings`); + } else if (token.some(item => typeof item !== 'string')) { + throw oops(`array of mixed strings and non-strings`); + } else { + return token.join(''); + } + } else { + throw oops(typeAppearance(token)); + } +} + +function processObjectToken(key, token) { + const oops = appearance => + new Error( + `Expected ${key} to be an object or an array of objects, ` + + `got ${appearance}`); + + const looksLikeObject = value => + typeof value === 'object' && + value !== null && + !Array.isArray(value); + + if (looksLikeObject(token)) { + return {...token}; + } else if (Array.isArray(token)) { + if (empty(token)) { + throw oops(`empty array`); + } else if (token.every(item => !looksLikeObject(item))) { + throw oops(`array of non-objects`); + } else if (token.some(item => !looksLikeObject(item))) { + throw oops(`array of mixed objects and non-objects`); + } else { + return Object.assign({}, ...token); + } + } +} + +function makeProcessToken(aggregate) { + return (object, key, processFn) => { + if (key in object) { + const value = aggregate.call(processFn, key, object[key]); + if (value === null) { + delete object[key]; + } else { + object[key] = value; + } + } + }; +} + +export function processGroupSpec(groupKey, groupSpec) { + const aggregate = + openAggregate({message: `Errors processing group "${groupKey}"`}); + + const processToken = makeProcessToken(aggregate); + + groupSpec.key = groupKey; + + processToken(groupSpec, 'prefix', processStringToken); + processToken(groupSpec, 'paths', processObjectToken); + + return {aggregate, result: groupSpec}; +} + +export function processURLSpec(sourceSpec) { + const aggregate = + openAggregate({message: `Errors processing URL spec`}); + + sourceSpec ??= {}; + + const urlSpec = structuredClone(sourceSpec); + + delete urlSpec.yamlAliases; + delete urlSpec.localizedWithBaseDirectory; + + aggregate.nest({message: `Errors processing groups`}, groupsAggregate => { + Object.assign(urlSpec, + withEntries(urlSpec, entries => + entries.map(([groupKey, groupSpec]) => [ + groupKey, + groupsAggregate.receive( + processGroupSpec(groupKey, groupSpec)), + ]))); + }); + + switch (sourceSpec.localizedWithBaseDirectory) { + case '<auto>': { + if (!urlSpec.localized) { + aggregate.push(new Error( + `Not ready for 'localizedWithBaseDirectory' group, ` + + `'localized' not available`)); + } else if (!urlSpec.localized.paths) { + aggregate.push(new Error( + `Not ready for 'localizedWithBaseDirectory' group, ` + + `'localized' group's paths not available`)); + } + + break; + } + + case undefined: + break; + + default: + aggregate.push(new Error( + `Expected 'localizedWithBaseDirectory' group to have value '<auto>' ` + + `or not be set`)); + + break; + } + + return {aggregate, result: urlSpec}; +} + +export function applyURLSpecOverriding(overrideSpec, baseSpec) { + const aggregate = openAggregate({message: `Errors applying URL spec`}); + + for (const [groupKey, overrideGroupSpec] of Object.entries(overrideSpec)) { + const baseGroupSpec = baseSpec[groupKey]; + + if (!baseGroupSpec) { + aggregate.push(new Error(`Group key "${groupKey}" not available on base spec`)); + continue; + } + + if (overrideGroupSpec.prefix) { + baseGroupSpec.prefix = overrideGroupSpec.prefix; + } + + if (overrideGroupSpec.paths) { + for (const [pathKey, overridePathValue] of Object.entries(overrideGroupSpec.paths)) { + if (!baseGroupSpec.paths[pathKey]) { + aggregate.push(new Error(`Path key "${groupKey}.${pathKey}" not available on base spec`)); + continue; + } + + baseGroupSpec.paths[pathKey] = overridePathValue; + } + } + } + + return {aggregate}; +} + +export function applyLocalizedWithBaseDirectory(urlSpec) { + const paths = + withEntries(urlSpec.localized.paths, entries => + entries.map(([key, path]) => [key, '<>/' + path])); + + urlSpec.localizedWithBaseDirectory = + Object.assign( + structuredClone(urlSpec.localized), + {paths}); +} + +export async function processURLSpecFromFile(file) { + let contents; + + try { + contents = await readFile(file, 'utf-8'); + } catch (caughtError) { + throw annotateError( + new Error(`Failed to read URL spec file`, {cause: caughtError}), + error => annotateErrorWithFile(error, file)); + } + + let sourceSpec; + let parseLanguage; + + try { + if (path.extname(file) === '.yaml') { + parseLanguage = 'YAML'; + sourceSpec = yaml.load(contents); + } else { + parseLanguage = 'JSON'; + sourceSpec = JSON.parse(contents); + } + } catch (caughtError) { + throw annotateError( + new Error(`Failed to parse URL spec file as valid ${parseLanguage}`, {cause: caughtError}), + error => annotateErrorWithFile(error, file)); + } + + try { + return processURLSpec(sourceSpec); + } catch (caughtError) { + throw annotateErrorWithFile(caughtError, file); + } +} diff --git a/src/urls-default.yaml b/src/urls-default.yaml new file mode 100644 index 00000000..cbdd8a23 --- /dev/null +++ b/src/urls-default.yaml @@ -0,0 +1,144 @@ +# These are variables which are used to make expressing this +# YAML file more convenient. They are not exposed externally. +# (Stuff which uses this YAML file can't even see the names +# for each variable!) +yamlAliases: + - &genericPaths + root: '' + path: '<>' + + # Static files are all grouped under a `static-${STATIC_VERSION}` folder as + # part of a build. This is so that multiple builds of a wiki can coexist + # served from the same server / file system root: older builds' HTML files + # refer to earlier values of STATIC_VERSION, avoiding name collisions. + - &staticVersion 5p2 + +data: + prefix: 'data/' + + paths: + - *genericPaths + + - album: 'album/<>' + artist: 'artist/<>' + track: 'track/<>' + +localized: + paths: + - *genericPaths + - page: '<>/' + + home: '' + + album: 'album/<>/' + albumCommentary: 'commentary/album/<>/' + albumGallery: 'album/<>/gallery/' + albumReferencedArtworks: 'album/<>/referenced-art/' + albumReferencingArtworks: 'album/<>/referencing-art/' + + artTagInfo: 'tag/<>/info/' + artTagGallery: 'tag/<>/' + + artist: 'artist/<>/' + artistGallery: 'artist/<>/gallery/' + + commentaryIndex: 'commentary/' + + flashIndex: 'flash/' + + flash: 'flash/<>/' + + flashActGallery: 'flash-act/<>/' + + groupInfo: 'group/<>/' + groupGallery: 'group/<>/gallery/' + + listingIndex: 'list/' + + listing: 'list/<>/' + + newsIndex: 'news/' + + newsEntry: 'news/<>/' + + staticPage: '<>/' + + track: 'track/<>/' + trackReferencedArtworks: 'track/<>/referenced-art/' + trackReferencingArtworks: 'track/<>/referencing-art/' + +# This gets automatically switched in place when working from +# a baseDirectory, so it should never be referenced manually. +# It's also filled in externally to this YAML spec. +localizedWithBaseDirectory: '<auto>' + +shared: + paths: *genericPaths + +staticCSS: + prefix: + - 'static-' + - *staticVersion + - '/css/' + + paths: *genericPaths + +staticJS: + prefix: + - 'static-' + - *staticVersion + - '/js/' + + paths: *genericPaths + +staticLib: + prefix: + - 'static-' + - *staticVersion + - '/lib/' + + paths: *genericPaths + +staticMisc: + prefix: + - 'static-' + - *staticVersion + - '/misc/' + + paths: + - *genericPaths + - icon: 'icons.svg#icon-<>' + +staticSharedUtil: + prefix: + - 'static-' + - *staticVersion + - '/shared-util/' + + paths: *genericPaths + +media: + prefix: 'media/' + + paths: + - *genericPaths + + - albumAdditionalFile: 'album-additional/<>/<>' + albumBanner: 'album-art/<>/banner.<>' + albumCover: 'album-art/<>/cover.<>' + albumWallpaper: 'album-art/<>/bg.<>' + albumWallpaperPart: 'album-art/<>/<>' + + artistAvatar: 'artist-avatar/<>.<>' + + flashArt: 'flash-art/<>.<>' + + trackCover: 'album-art/<>/<>.<>' + +thumb: + prefix: 'thumb/' + paths: *genericPaths + +searchData: + prefix: 'search-data/' + paths: *genericPaths diff --git a/src/util/urls.js b/src/urls.js index 11b9b8b0..b51ea459 100644 --- a/src/util/urls.js +++ b/src/urls.js @@ -8,17 +8,16 @@ import * as path from 'node:path'; import {withEntries} from '#sugar'; -// This export is only provided for convenience, i.e. to enable the following: -// -// import {urlSpec} from '#urls'; -// -// It's not actually defined in this module's variable scope, and functions -// exported here require a urlSpec (whether this default one or another) to be -// passed directly. -// -export {default as urlSpec} from '../url-spec.js'; +export * from './url-spec.js'; export function generateURLs(urlSpec) { + if ( + typeof urlSpec.localized === 'object' && + typeof urlSpec.localizedWithBaseDirectory !== 'object' + ) { + throw new Error(`Provided urlSpec missing localizedWithBaseDirectory`); + } + const getValueForFullKey = (obj, fullKey) => { const [groupKey, subKey] = fullKey.split('.'); if (!groupKey || !subKey) { @@ -49,8 +48,12 @@ export function generateURLs(urlSpec) { const generateTo = (fromPath, fromGroup) => { const A = trimLeadingSlash(fromPath); - const rebasePrefix = '../' - .repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); + const fromPrefix = fromGroup.prefix || ''; + + const rebasePrefix = + '../'.repeat(fromPrefix.split('/').filter(Boolean).length); + + const fromOrigin = getOrigin(fromPrefix); const pathHelper = (toPath, toGroup) => { let B = trimLeadingSlash(toPath); @@ -58,40 +61,109 @@ export function generateURLs(urlSpec) { let argIndex = 0; B = B.replaceAll('<>', () => `<${argIndex++}>`); - if (toGroup.prefix !== fromGroup.prefix) { - // TODO: Handle differing domains in prefixes. - B = rebasePrefix + (toGroup.prefix || '') + B; - } - const suffix = toPath.endsWith('/') ? '/' : ''; - return { - posix: path.posix.relative(A, B) + suffix, - device: path.relative(A, B) + suffix, - }; - }; + const toPrefix = toGroup.prefix; + + if (toPrefix !== fromPrefix) { + // Compare origins. Note that getOrigin() can + // be null for both prefixes. + const toOrigin = getOrigin(toPrefix); + if (fromOrigin === toOrigin) { + // Go to the root, add the to-group's prefix, then + // continue with normal path.relative() behavior. + B = rebasePrefix + (toGroup.prefix || '') + B; + } else { + // Crossing origins never conceptually represents + // something you can interpret on-`.device()`. + return { + posix: toGroup.prefix + B + suffix, + device: null, + }; + } + } - const groupSymbol = Symbol(); + // If we're coming from a qualified origin (domain), + // then at this point, A and B represent paths on the + // same origin. We can use normal path.relative() behavior. + if (fromOrigin) { + // If we're working on an origin, there's no meaning to + // a `.device()`-local relative path. + return { + posix: path.posix.relative(A, B) + suffix, + device: null, + }; + } else { + return { + posix: path.posix.relative(A, B) + suffix, + device: path.relative(A, B) + suffix, + }; + } + }; - const groupHelper = (urlGroup) => ({ - [groupSymbol]: urlGroup, - ...withEntries(urlGroup.paths, (entries) => - entries.map(([key, path]) => [key, pathHelper(path, urlGroup)]) - ), - }); + const groupHelper = urlGroup => + withEntries(urlGroup.paths, entries => + entries.map(([key, path]) => [ + key, + pathHelper(path, urlGroup), + ])); - const relative = withEntries(urlSpec, (entries) => - entries.map(([key, urlGroup]) => [key, groupHelper(urlGroup)]) - ); + const relative = + withEntries(urlSpec, entries => + entries.map(([key, urlGroup]) => [ + key, + groupHelper(urlGroup), + ])); const toHelper = ({device}) => (key, ...args) => { - const { - value: { - [device ? 'device' : 'posix']: template, - }, - } = getValueForFullKey(relative, key); + const templateKey = (device ? 'device' : 'posix'); + + const {value: {[templateKey]: template}} = + getValueForFullKey(relative, key); + + // If we got past getValueForFullKey(), we've already ruled out + // the common errors, i.e. incorrectly formatted key or invalid + // group key or subkey. + if (template === null) { + // Self-diagnose, brutally. + + // TODO: This variable isn't used, and has never been used, + // but it might have been *meant* to be used? + // eslint-disable-next-line no-unused-vars + const otherTemplateKey = (device ? 'posix' : 'device'); + + const {value: {[templateKey]: otherTemplate}} = + getValueForFullKey(relative, key); + + const effectiveMode = + (otherTemplate + ? `${templateKey} mode` + : `either mode`); + + const toGroupKey = key.split('.')[0]; + + const anyOthers = + Object.values(relative[toGroupKey]) + .find(templates => + (otherTemplate + ? templates[templateKey] + : templates.posix || templates.device)); + + const effectiveTo = + (anyOthers + ? key + : `${toGroupKey}.*`); + + if (anyOthers) { + console.log(relative[toGroupKey]); + } + + throw new Error( + `from(${fromGroup.key}.*).to(${effectiveTo}) ` + + `not available in ${effectiveMode} with this url spec`); + } let missing = 0; let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => { @@ -111,19 +183,31 @@ export function generateURLs(urlSpec) { if (missing) { throw new Error( - `Expected ${missing + args.length} arguments, got ${ - args.length - } (key ${key}, args [${args}])` - ); + `Expected ${missing + args.length} arguments, ` + + `got ${args.length} (key ${key}, args [${args}])`); } return result; }; - return { - to: toHelper({device: false}), - toDevice: toHelper({device: true}), - }; + const toAvailableHelper = + ({device}) => + (key) => { + const templateKey = (device ? 'device' : 'posix'); + + const {value: {[templateKey]: template}} = + getValueForFullKey(relative, key); + + return !!template; + }; + + const to = toHelper({device: false}); + const toDevice = toHelper({device: true}); + + to.available = toAvailableHelper({device: false}); + toDevice.available = toAvailableHelper({device: true}); + + return {to, toDevice}; }; const generateFrom = () => { @@ -144,6 +228,14 @@ export function generateURLs(urlSpec) { return generateFrom(); } +export function getOrigin(prefix) { + try { + return new URL(prefix).origin; + } catch { + return null; + } +} + const thumbnailHelper = (name) => (file) => file.replace(/\.(jpg|png)$/, name + '.jpg'); @@ -194,9 +286,14 @@ export function getURLsFrom({ to = targetFullKey; } - return ( - subdirectoryPrefix + - urls.from(from).to(to, ...args)); + const toResult = + urls.from(from).to(to, ...args); + + if (getOrigin(toResult)) { + return toResult; + } else { + return subdirectoryPrefix + toResult; + } }; } @@ -211,16 +308,18 @@ export function getURLsFromRoot({ return (targetFullKey, ...args) => { const [groupKey, subKey] = targetFullKey.split('.'); - return ( - '/' + + const toResult = (groupKey === 'localized' && baseDirectory - ? to( - 'localizedWithBaseDirectory.' + subKey, - baseDirectory, - ...args - ) - : to(targetFullKey, ...args)) - ); + ? to('localizedWithBaseDirectory.' + subKey, baseDirectory, ...args) + : groupKey === 'localizedDefaultLanguage' + ? to('localized.' + subKey, ...args) + : to(targetFullKey, ...args)); + + if (getOrigin(toResult)) { + return toResult; + } else { + return '/' + toResult; + } }; } diff --git a/src/data/validators.js b/src/validators.js index 84e08cb8..59df80d4 100644 --- a/src/data/validators.js +++ b/src/validators.js @@ -3,8 +3,12 @@ import {inspect as nodeInspect} from 'node:util'; import {openAggregate, withAggregate} from '#aggregate'; import {colors, ENABLE_COLOR} from '#cli'; import {cut, empty, matchMultiline, typeAppearance} from '#sugar'; -import {commentaryRegexCaseInsensitive, commentaryRegexCaseSensitiveOneShot} - from '#wiki-data'; + +import { + commentaryRegexCaseInsensitive, + commentaryRegexCaseSensitiveOneShot, + multipleLyricsDetectionRegex, +} from '#wiki-data'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); @@ -288,69 +292,108 @@ export function isColor(color) { throw new TypeError(`Unknown color format`); } -export function isCommentary(commentaryText) { - isContentString(commentaryText); +export function validateContentEntries({ + headingPhrase, + entryPhrase, - const rawMatches = - Array.from(commentaryText.matchAll(commentaryRegexCaseInsensitive)); + caseInsensitiveRegex, + caseSensitiveOneShotRegex, +}) { + return content => { + isContentString(content); - if (empty(rawMatches)) { - throw new TypeError(`Expected at least one commentary heading`); - } + const rawMatches = + Array.from(content.matchAll(caseInsensitiveRegex)); - const niceMatches = - rawMatches.map(match => ({ - position: match.index, - length: match[0].length, - })); - - validateArrayItems(({position, length}, index) => { - if (index === 0 && position > 0) { - throw new TypeError(`Expected first commentary heading to be at top`); + if (empty(rawMatches)) { + throw new TypeError(`Expected at least one ${headingPhrase}`); } - const ownInput = commentaryText.slice(position, position + length); - const restOfInput = commentaryText.slice(position + length); + const niceMatches = + rawMatches.map(match => ({ + position: match.index, + length: match[0].length, + })); + + validateArrayItems(({position, length}, index) => { + if (index === 0 && position > 0) { + throw new TypeError(`Expected first ${headingPhrase} to be at top`); + } - const upToNextLineBreak = - (restOfInput.includes('\n') - ? restOfInput.slice(0, restOfInput.indexOf('\n')) - : restOfInput); + const ownInput = content.slice(position, position + length); + const restOfInput = content.slice(position + length); - if (/\S/.test(upToNextLineBreak)) { - throw new TypeError( - `Expected commentary heading to occupy entire line, got extra text:\n` + - `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + - `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + - `(Check for missing "|-" in YAML, or a misshapen annotation)`); - } + const upToNextLineBreak = + (restOfInput.includes('\n') + ? restOfInput.slice(0, restOfInput.indexOf('\n')) + : restOfInput); - if (!commentaryRegexCaseSensitiveOneShot.test(ownInput)) { - throw new TypeError( - `Miscapitalization in commentary heading:\n` + - `${colors.red(`"${cut(ownInput, 60)}"`)}\n` + - `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`); - } + if (/\S/.test(upToNextLineBreak)) { + throw new TypeError( + `Expected ${headingPhrase} to occupy entire line, got extra text:\n` + + `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + + `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + + `(Check for missing "|-" in YAML, or a misshapen annotation)`); + } - const nextHeading = - (index === niceMatches.length - 1 - ? commentaryText.length - : niceMatches[index + 1].position); + if (!caseSensitiveOneShotRegex.test(ownInput)) { + throw new TypeError( + `Miscapitalization in ${headingPhrase}:\n` + + `${colors.red(`"${cut(ownInput, 60)}"`)}\n` + + `(Check for ${colors.red(`"<I>"`)} instead of ${colors.green(`"<i>"`)})`); + } - const upToNextHeading = - commentaryText.slice(position + length, nextHeading); + const nextHeading = + (index === niceMatches.length - 1 + ? content.length + : niceMatches[index + 1].position); - if (!/\S/.test(upToNextHeading)) { - throw new TypeError( - `Expected commentary entry to have body text, only got a heading`); - } + const upToNextHeading = + content.slice(position + length, nextHeading); + + if (!/\S/.test(upToNextHeading)) { + throw new TypeError( + `Expected ${entryPhrase} to have body text, only got a heading`); + } + + return true; + })(niceMatches); return true; - })(niceMatches); + }; +} + +export const isCommentary = + validateContentEntries({ + headingPhrase: `commentary heading`, + entryPhrase: `commentary entry`, + + caseInsensitiveRegex: commentaryRegexCaseInsensitive, + caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot, + }); + +export function isOldStyleLyrics(content) { + isContentString(content); + + if (multipleLyricsDetectionRegex.test(content)) { + throw new TypeError( + `Expected old-style lyrics block not to include "<i> ... :</i>" at start of any line`); + } return true; } +export const isLyrics = + anyOf( + isOldStyleLyrics, + validateContentEntries({ + headingPhrase: `lyrics heading`, + entryPhrase: `lyrics entry`, + + caseInsensitiveRegex: commentaryRegexCaseInsensitive, + caseSensitiveOneShotRegex: commentaryRegexCaseSensitiveOneShot, + })); + const isArtistRef = validateReference('artist'); export function validateProperties(spec) { @@ -543,7 +586,7 @@ export function isContentString(content) { const parts = [ actionPart, surroundings, - `(${where})`, + `(${colors.yellow(where)})`, ].filter(Boolean); illegalAggregate.push(new TypeError(parts.join(` `))); @@ -695,14 +738,6 @@ export const isContributionPreset = validateProperties({ export const isContributionPresetList = validateArrayItems(isContributionPreset); -export const isAdditionalFile = validateProperties({ - title: isName, - description: optional(isContentString), - files: optional(validateArrayItems(isString)), -}); - -export const isAdditionalFileList = validateArrayItems(isAdditionalFile); - export const isTrackSection = validateProperties({ name: optional(isName), color: optional(isColor), @@ -713,17 +748,6 @@ export const isTrackSection = validateProperties({ export const isTrackSectionList = validateArrayItems(isTrackSection); -export const isSeries = validateProperties({ - name: isName, - description: optional(isContentString), - albums: optional(validateReferenceList('album')), - - showAlbumArtists: - optional(is('all', 'differing', 'none')), -}); - -export const isSeriesList = validateArrayItems(isSeries); - export const isWallpaperPart = validateProperties({ asset: optional(isString), style: optional(isString), @@ -944,13 +968,6 @@ export function validateWikiData({ }; } -export const isAdditionalName = validateProperties({ - name: isContentString, - annotation: optional(isContentString), -}); - -export const isAdditionalNameList = validateArrayItems(isAdditionalName); - // Compositional utilities export function anyOf(...validators) { diff --git a/src/web-routes.js b/src/web-routes.js index 762b26c3..a7115bbd 100644 --- a/src/web-routes.js +++ b/src/web-routes.js @@ -1,11 +1,13 @@ -import {readdir} from 'node:fs/promises'; +import {readdir, stat} from 'node:fs/promises'; import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/* eslint-disable no-unused-vars */ const codeSrcPath = __dirname; const codeRootPath = path.resolve(codeSrcPath, '..'); +/* eslint-enable no-unused-vars */ function getNodeDependencyRootPath(dependencyName) { return ( @@ -34,7 +36,7 @@ export const stationaryCodeRoutes = [ }, { - from: path.join(codeSrcPath, 'util'), + from: path.join(codeSrcPath, 'common-util'), to: ['staticSharedUtil.root'], statically: 'copy', }, @@ -106,6 +108,21 @@ export async function identifyDynamicWebRoutes({ ]), () => { + const from = + path.resolve(path.join(mediaPath, 'favicon.ico')); + + return stat(from).then( + // {statically: 'copy'} is not workable for individual files + // at the moment, so this remains a symlink. + () => [{ + from, + to: ['shared.path', 'favicon.ico'], + statically: 'symlink', + }], + () => []); + }, + + () => { if (!wikiCachePath) return []; const from = diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js index be702c8c..afbf8b2f 100644 --- a/src/write/bind-utilities.js +++ b/src/write/bind-utilities.js @@ -20,11 +20,11 @@ import { export function bindUtilities({ absoluteTo, defaultLanguage, - getSizeOfAdditionalFile, - getSizeOfImagePath, + getSizeOfMediaFile, language, languages, missingImagePaths, + niceShowAggregate, pagePath, pagePathStringFromRoot, thumbsCache, @@ -37,13 +37,13 @@ export function bindUtilities({ Object.assign(bound, { absoluteTo, defaultLanguage, - getSizeOfAdditionalFile, - getSizeOfImagePath, + getSizeOfMediaFile, getThumbnailsAvailableForDimensions, html, language, languages, missingImagePaths, + niceShowAggregate, pagePath, pagePathStringFromRoot, thumb, diff --git a/src/write/build-modes/index.js b/src/write/build-modes/index.js index 3ae2cfc6..4b61619d 100644 --- a/src/write/build-modes/index.js +++ b/src/write/build-modes/index.js @@ -1,3 +1,4 @@ export * as 'live-dev-server' from './live-dev-server.js'; export * as 'repl' from './repl.js'; +export * as 'sort' from './sort.js'; export * as 'static-build' from './static-build.js'; diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index f6eec334..5dece8d0 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -1,11 +1,14 @@ import {spawn} from 'node:child_process'; import * as http from 'node:http'; import {open, stat} from 'node:fs/promises'; +import * as os from 'node:os'; import * as path from 'node:path'; import {pipeline} from 'node:stream/promises'; import {inspect as nodeInspect} from 'node:util'; -import {ENABLE_COLOR, colors, logInfo, logWarn, progressCallAll} from '#cli'; +import {openAggregate} from '#aggregate'; +import {ENABLE_COLOR, colors, fileIssue, logInfo, logWarn, progressCallAll} + from '#cli'; import {watchContentDependencies} from '#content-dependencies'; import {quickEvaluate} from '#content-function'; import * as html from '#html'; @@ -165,21 +168,47 @@ export async function go({ const commonUtilities = {...universalUtilities}; + const pathAggregate = openAggregate({message: `Errors computing page paths`}); + let targetSpecPairs = getPageSpecsWithTargets({wikiData}); - const pages = progressCallAll(`Computing page data & paths for ${targetSpecPairs.length} targets.`, + const pages = progressCallAll(`Computing page paths for ${targetSpecPairs.length} targets.`, targetSpecPairs.flatMap(({ pageSpec, target, targetless, }) => () => { - if (targetless) { - const result = pageSpec.pathsTargetless({wikiData}); - return Array.isArray(result) ? result : [result]; - } else { - return pageSpec.pathsForTarget(target); + try { + if (targetless) { + const result = pageSpec.pathsTargetless({wikiData}); + return Array.isArray(result) ? result : [result]; + } else { + return pageSpec.pathsForTarget(target); + } + } catch (caughtError) { + if (targetless) { + pathAggregate.push(new Error( + `Failed to compute targetless paths for ` + + inspect(pageSpec, {compact: true}), + {cause: caughtError})); + } else { + pathAggregate.push(new Error( + `Failed to compute paths for ` + + inspect(target), + {cause: caughtError})); + } + return []; } })).flat(); + try { + pathAggregate.close(); + } catch (error) { + niceShowAggregate(error); + logWarn`Failed to compute page paths for some targets.`; + logWarn`This means some pages that normally exist will be 404s.`; + fileIssue(); + } + logInfo`Will be serving a total of ${pages.length} pages.`; const urlToPageMap = Object.fromEntries(pages @@ -224,7 +253,7 @@ export async function go({ let url; try { url = new URL(request.url, `http://${request.headers.host}`); - } catch (error) { + } catch { response.writeHead(500, contentTypePlain); response.end('Failed to parse request URL\n'); return; @@ -271,7 +300,7 @@ export async function go({ let filePath; try { filePath = path.resolve(localDirectory, decodeURI(safePath.split('/').join(path.sep))); - } catch (error) { + } catch { response.writeHead(404, contentTypePlain); response.end(`File not found for: ${safePath}`); console.log(`${requestHead} [404] ${pathname}`); @@ -361,7 +390,11 @@ export async function go({ // URL to page map expects trailing slash but no leading slash. const pathnameKey = pathname.replace(/^\//, '') + (pathname.endsWith('/') ? '' : '/'); - if (!Object.hasOwn(urlToPageMap, pathnameKey)) { + const is404 = + !Object.hasOwn(urlToPageMap, pathnameKey) || + !(urlToPageMap[pathnameKey].page.condition?.() ?? true); + + if (is404) { response.writeHead(404, contentTypePlain); response.end(`No page found for: ${pathnameKey}\n`); if (loudResponses) console.log(`${requestHead} [404] ${pathname} (no page)`); @@ -430,7 +463,7 @@ export async function go({ language, pagePath: servePath, pagePathStringFromRoot: pathname.replace(/^\//, ''), - to, + to: page.absoluteLinks ? absoluteTo : to, }); const topLevelResult = @@ -460,7 +493,13 @@ export async function go({ } }); - const address = `http://${host}:${port}/`; + const addresses = + (host === '0.0.0.0' + ? [`http://localhost:${port}/`, + `http://${os.hostname()}:${port}/`] + : host === '127.0.0.1' + ? [`http://localhost:${port}/`] + : [`http://${host}:${port}/`]); server.on('error', error => { if (error.code === 'EADDRINUSE') { @@ -477,7 +516,15 @@ export async function go({ }); server.on('listening', () => { - logInfo`${'All done!'} Listening at: ${address}`; + if (addresses.length === 1) { + logInfo`${'All done!'} Listening at: ${addresses[0]}`; + } else { + logInfo`${`All done!`} Listening at:`; + for (const address of addresses) { + logInfo`- ${address}`; + } + } + logInfo`Press ^C here (control+C) to stop the server and exit.`; if (showTimings && loudResponses) { logInfo`Printing all HTTP responses, plus page generation timings.`; diff --git a/src/write/build-modes/repl.js b/src/write/build-modes/repl.js index 957d2c2d..920ad9f7 100644 --- a/src/write/build-modes/repl.js +++ b/src/write/build-modes/repl.js @@ -36,6 +36,7 @@ import * as path from 'node:path'; import * as repl from 'node:repl'; import _find, {bindFind} from '#find'; +import _reverse, {bindReverse} from '#reverse'; import CacheableObject from '#cacheable-object'; import {logWarn} from '#cli'; import {debugComposite} from '#composite'; @@ -66,6 +67,15 @@ export async function getContextAssignments({ logWarn`\`find\` variable will be missing`; } + let reverse; + try { + reverse = bindReverse(wikiData); + } catch (error) { + console.error(error); + logWarn`Failed to prepare wikiData-bound reverse() functions`; + logWarn`\`reverse\` variable will be missing`; + } + const replContext = { universalUtilities, ...universalUtilities, @@ -95,6 +105,10 @@ export async function getContextAssignments({ find, bindFind, + _reverse, + reverse, + bindReverse, + showAggregate, }; diff --git a/src/write/build-modes/sort.js b/src/write/build-modes/sort.js new file mode 100644 index 00000000..1a738ac8 --- /dev/null +++ b/src/write/build-modes/sort.js @@ -0,0 +1,76 @@ +export const description = `Update data files in-place to satisfy custom sorting rules`; + +import {logInfo} from '#cli'; +import {empty} from '#sugar'; +import thingConstructors from '#things'; + +export const config = { + fileSizes: { + applicable: false, + }, + + languageReloading: { + applicable: false, + }, + + mediaValidation: { + applicable: false, + }, + + search: { + applicable: false, + }, + + thumbs: { + applicable: false, + }, + + webRoutes: { + applicable: false, + }, + + sort: { + applicable: false, + }, +}; + +export function getCLIOptions() { + return {}; +} + +export async function go({wikiData, dataPath}) { + if (empty(wikiData.sortingRules)) { + logInfo`There aren't any sorting rules in for this wiki.`; + return true; + } + + const {SortingRule} = thingConstructors; + + let numUpdated = 0; + let numActive = 0; + + for await (const result of SortingRule.go({wikiData, dataPath})) { + numActive++; + + const niceMessage = `"${result.rule.message}"`; + + if (result.changed) { + numUpdated++; + logInfo`Updating to satisfy ${niceMessage}.`; + } else { + logInfo`Already good: ${niceMessage}`; + } + } + + if (numUpdated > 1) { + logInfo`Updated data files to satisfy ${numUpdated} sorting rules.`; + } else if (numUpdated === 1) { + logInfo`Updated data files to satisfy ${1} sorting rule.` + } else if (numActive >= 1) { + logInfo`All sorting rules were already satisfied. Good to go!`; + } else { + logInfo`No sorting rules are currently active.`; + } + + return true; +} diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index d40e1cb7..b5ded04c 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -1,15 +1,6 @@ +import {cp, mkdir, stat, symlink, writeFile, unlink} from 'node:fs/promises'; import * as path from 'node:path'; -import { - copyFile, - cp, - mkdir, - stat, - symlink, - writeFile, - unlink, -} from 'node:fs/promises'; - import {rimraf} from 'rimraf'; import {quickLoadContentDependencies} from '#content-dependencies'; @@ -27,6 +18,7 @@ import { } from '#cli'; import { + getOrigin, getPagePathname, getURLsFrom, getURLsFromRoot, @@ -114,8 +106,6 @@ export async function go({ universalUtilities, - mediaPath, - defaultLanguage, languages, urls, @@ -165,11 +155,6 @@ export async function go({ }); if (writeAll) { - await writeFavicon({ - mediaPath, - outputPath, - }); - await writeSharedFilesAndPages({ outputPath, randomLinkDataJSON: generateRandomLinkDataJSON({wikiData}), @@ -194,7 +179,7 @@ export async function go({ return null; } - const paths = []; + let paths = []; if (pageSpec.pathsTargetless) { const result = pageSpec.pathsTargetless({wikiData}); @@ -224,6 +209,9 @@ export async function go({ // TODO: Validate each pathsForTargets entry } + paths = + paths.filter(path => path.condition?.() ?? true); + return paths; }) .filter(Boolean) @@ -321,7 +309,7 @@ export async function go({ language, pagePath, pagePathStringFromRoot: pathname, - to, + to: page.absoluteLinks ? absoluteTo : to, }); let pageHTML, oEmbedJSON; @@ -436,12 +424,18 @@ async function writePage({ ].filter(Boolean)); } +function filterNoOrigin(route) { + return !getOrigin(route.to); +} + function writeWebRouteSymlinks({ outputPath, webRoutes, }) { const symlinkRoutes = - webRoutes.filter(route => route.statically === 'symlink'); + webRoutes + .filter(route => route.statically === 'symlink') + .filter(filterNoOrigin); const promises = symlinkRoutes.map(async route => { @@ -481,7 +475,9 @@ async function writeWebRouteCopies({ webRoutes, }) { const copyRoutes = - webRoutes.filter(route => route.statically === 'copy'); + webRoutes + .filter(route => route.statically === 'copy') + .filter(filterNoOrigin); const promises = copyRoutes.map(async route => { @@ -583,30 +579,6 @@ async function writeWebRouteCopies({ } } -async function writeFavicon({ - mediaPath, - outputPath, -}) { - const faviconFile = 'favicon.ico'; - - try { - await stat(path.join(mediaPath, faviconFile)); - } catch (error) { - return; - } - - try { - await copyFile( - path.join(mediaPath, faviconFile), - path.join(outputPath, faviconFile)); - } catch (error) { - logWarn`Failed to copy favicon! ${error.message}`; - return; - } - - logInfo`Copied favicon to site root.`; -} - async function writeSharedFilesAndPages({ outputPath, randomLinkDataJSON, |