diff options
Diffstat (limited to 'src')
495 files changed, 23754 insertions, 9915 deletions
diff --git a/src/util/aggregate.js b/src/aggregate.js index e8f45f3b..3ff1846b 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`); } @@ -372,11 +371,12 @@ export function _withAggregate(mode, aggregateOpts, fn) { } export const unhelpfulTraceLines = [ - /sugar/, - /sort/, - /aggregate/, - /composite/, - /cacheable-object/, + /sugar\.js/, + /sort\.js/, + /aggregate\.js/, + /composite\.js/, + /cacheable-object\.js/, + /html\.js/, /node:/, /<anonymous>/, ]; @@ -441,7 +441,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 +487,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 +502,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 +539,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 +605,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 +620,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/common-util/search-shape.js b/src/common-util/search-shape.js new file mode 100644 index 00000000..e0819ed6 --- /dev/null +++ b/src/common-util/search-shape.js @@ -0,0 +1,58 @@ +// Index structures shared by client and server, and relevant interfaces. +// First and foremost, this is complemented by src/search-select.js, which +// actually fills the search indexes up with stuff. During build this all +// gets consumed by src/search.js to make an index, fill it with stuff +// (as described by search-select.js), and export it to disk; then on +// the client that export is consumed by src/static/js/search-worker.js, +// which builds an index in the same shape and imports the data for query. + +const baselineStore = [ + 'primaryName', + 'disambiguator', + 'artwork', + 'color', +]; + +const genericStore = baselineStore; + +const searchShape = { + generic: { + index: [ + 'primaryName', + 'parentName', + 'artTags', + 'additionalNames', + 'contributors', + 'groups', + ].map(field => ({field, tokenize: 'forward'})), + + store: genericStore, + }, + + verbatim: { + index: [ + 'primaryName', + 'parentName', + 'artTags', + 'additionalNames', + 'contributors', + 'groups', + ], + + store: genericStore, + }, +}; + +export default searchShape; + +export function makeSearchIndex(descriptor, {FlexSearch}) { + return new FlexSearch.Document({ + id: 'reference', + index: descriptor.index, + store: descriptor.store, + + // Disable scoring, always return results according to provided order + // (specified above in `genericQuery`, etc). + resolution: 1, + }); +} 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..bbe4e551 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 @@ -364,11 +370,12 @@ export function sortAlbumsTracksChronologically(data, { getDate, } = {}) { // Sort albums before tracks... - sortByConditions(data, [(t) => t.album === undefined]); + sortByConditions(data, [t => t.isAlbum]); - // Group tracks by album... - sortByDirectory(data, { - getDirectory: (t) => (t.album ? t.album.directory : t.directory), + // Put albums alphabetically, and group with them... + sortAlphabetically(data, { + getDirectory: t => t.isTrack ? t.album.directory : t.directory, + getName: t => t.isTrack ? t.album.name : t.name, }); // Sort tracks by position in album... @@ -383,6 +390,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 +430,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 +439,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..d6ce1410 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 @@ -72,6 +70,16 @@ export function pick(array) { return array[Math.floor(Math.random() * array.length)]; } +// Gets the only item in a single-item array (strictly, length === 1). +// If the array has more than one item, or is empty, this is null. +export function onlyItem(array) { + if (array.length === 1) { + return array[0]; + } else { + return null; + } +} + // Gets the item at an index relative to another index. export function atOffset(array, index, offset, { wrap = false, @@ -118,10 +126,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 +235,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 +268,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 +325,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 +422,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 +533,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 +847,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..3fde2495 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 @@ -11,7 +11,7 @@ export {filterMultipleArrays} from './sugar.js'; // Generic value operations -export function getKebabCase(name) { +export function getCaseSensitiveKebabCase(name) { return name // Spaces to dashes @@ -34,6 +34,9 @@ export function getKebabCase(name) { // General punctuation which always separates surrounding words .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-') + // More punctuation which always separates surrounding words + .replace(/[\u{2013}-\u{2014}]/u, '-') // En Dash, Em Dash + // Accented characters .replace(/[áâäàå]/gi, 'a') .replace(/[çč]/gi, 'c') @@ -50,17 +53,17 @@ export function getKebabCase(name) { // Trim dashes on boundaries .replace(/^-+|-+$/g, '') +} - // Always lowercase - .toLowerCase(); +export function getKebabCase(name) { + return getCaseSensitiveKebabCase(name).toLowerCase(); } // 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 +86,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 +98,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 +106,43 @@ 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; + + const trimBody = body => + body + .replace(/^\n*/, '') + .replace(/\n*$/, ''); + + for (const {0: matchText, index: startIndex, groups: matchEntry} + of sourceText.matchAll(commentaryRegexCaseSensitive)) { + if (previousMatchEntry) { + previousMatchEntry.body = + trimBody(sourceText.slice(previousEndIndex, startIndex)); + } + + matchEntries.push(matchEntry); + + previousMatchEntry = matchEntry; + previousEndIndex = startIndex + matchText.length; + } + + if (previousMatchEntry) { + previousMatchEntry.body = + trimBody(sourceText.slice(previousEndIndex)); + } + + return matchEntries; +} + export function filterAlbumsByCommentary(albums) { return albums .filter((album) => [album, ...album.tracks].some((x) => x.commentary)); @@ -167,10 +208,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 +233,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 +402,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 +533,52 @@ export function combineWikiDataArrays(arrays) { return combined; } } + +// Markdown stuff + +export function* matchMarkdownLinks(markdownSource, {marked}) { + const plausibleLinkRegexp = /\[(?=.*?\))/g; + + // 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 definiteLinkRegexp = marked.Lexer.rules.inline.pedantic.link; + + let plausibleMatch = null; + while (plausibleMatch = plausibleLinkRegexp.exec(markdownSource)) { + const definiteMatch = + definiteLinkRegexp.exec(markdownSource.slice(plausibleMatch.index)); + + if (!definiteMatch) { + continue; + } + + const [{length}, label, href] = definiteMatch; + const index = plausibleMatch.index + definiteMatch.index; + + yield {label, href, index, length}; + } +} + +export function* matchInlineLinks(source) { + const plausibleLinkRegexp = /\b[a-z]*:\/\/[^ ]*?(?=(?:[,.!?]*)(?:\s|$))/gm; + + let plausibleMatch = null; + while (plausibleMatch = plausibleLinkRegexp.exec(source)) { + const [href] = plausibleMatch; + const {index} = plausibleMatch; + const [{length}] = plausibleMatch; + + try { + new URL(href); + } catch { + continue; + } + + yield {href, length, index}; + } +} diff --git a/src/content-function.js b/src/content-function.js index 44f8b842..f3aa7c42 100644 --- a/src/content-function.js +++ b/src/content-function.js @@ -3,7 +3,7 @@ import {inspect as nodeInspect} from 'node:util'; import {decorateError} from '#aggregate'; import {colors, decorateTime, ENABLE_COLOR} from '#cli'; import {Template} from '#html'; -import {annotateFunction, empty, setIntersection} from '#sugar'; +import {empty} from '#sugar'; function inspect(value, opts = {}) { return nodeInspect(value, {colors: ENABLE_COLOR, ...opts}); @@ -13,166 +13,99 @@ const DECORATE_TIME = process.env.HSMUSIC_DEBUG_CONTENT_PERF === '1'; export class ContentFunctionSpecError extends Error {} -export default function contentFunction({ - contentDependencies = [], - extraDependencies = [], - - slots, - sprawl, - query, - relations, - data, - generate, -}) { - const expectedContentDependencyKeys = new Set(contentDependencies); - const expectedExtraDependencyKeys = new Set(extraDependencies); - - // Initial checks. These only need to be run once per description of a - // content function, and don't depend on any mutable context (e.g. which - // dependencies have been fulfilled so far). - - const overlappingContentExtraDependencyKeys = - setIntersection(expectedContentDependencyKeys, expectedExtraDependencyKeys); - - if (!empty(overlappingContentExtraDependencyKeys)) { - throw new ContentFunctionSpecError(`Overlap in content and extra dependency keys: ${[...overlappingContentExtraDependencyKeys].join(', ')}`); +function optionalDecorateTime(prefix, fn) { + if (DECORATE_TIME) { + return decorateTime(`${prefix}/${generate.name}`, fn); + } else { + return fn; } +} - if (!generate) { +export default function contentFunction(spec) { + if (!spec.generate) { throw new ContentFunctionSpecError(`Expected generate function`); } - if (sprawl && !expectedExtraDependencyKeys.has('wikiData')) { - throw new ContentFunctionSpecError(`Content functions which sprawl must specify wikiData in extraDependencies`); + if (spec.slots) { + Template.validateSlotsDescription(spec.slots); } - if (slots && !expectedExtraDependencyKeys.has('html')) { - throw new ContentFunctionSpecError(`Content functions with slots must specify html in extraDependencies`); - } + return expectExtraDependencies(spec, null); +} - if (slots) { - Template.validateSlotsDescription(slots); +contentFunction.identifyingSymbol = Symbol(`Is a content function?`); + +export function expectExtraDependencies(spec, boundExtraDependencies) { + const generate = + (boundExtraDependencies + ? prepareWorkingGenerateFunction(spec, boundExtraDependencies) + : () => { + throw new Error(`Not bound with extraDependencies yet`); + }); + + generate[contentFunction.identifyingSymbol] = true; + + for (const key of ['sprawl', 'query', 'relations', 'data']) { + if (spec[key]) { + generate[key] = optionalDecorateTime(`sprawl`, spec[key]); + } } - // Pass all the details to expectDependencies, which will recursively build - // up a set of fulfilled dependencies and make functions like `relations` - // and `generate` callable only with sufficient fulfilled dependencies. - - return expectDependencies({ - slots, - sprawl, - query, - relations, - data, - generate, - - expectedContentDependencyKeys, - expectedExtraDependencyKeys, - missingContentDependencyKeys: new Set(expectedContentDependencyKeys), - missingExtraDependencyKeys: new Set(expectedExtraDependencyKeys), - invalidatingDependencyKeys: new Set(), - fulfilledDependencyKeys: new Set(), - fulfilledDependencies: {}, - }); + generate.bindExtraDependencies = (extraDependencies) => + expectExtraDependencies(spec, extraDependencies); + + return generate; } -contentFunction.identifyingSymbol = Symbol(`Is a content function?`); +function prepareWorkingGenerateFunction(spec, boundExtraDependencies) { + let generate = ([arg1, arg2], ...extraArgs) => { + if (spec.data && !arg1) { + throw new Error(`Expected data`); + } -export function expectDependencies({ - slots, - sprawl, - query, - relations, - data, - generate, - - expectedContentDependencyKeys, - expectedExtraDependencyKeys, - missingContentDependencyKeys, - missingExtraDependencyKeys, - invalidatingDependencyKeys, - fulfilledDependencyKeys, - fulfilledDependencies, -}) { - const hasSprawlFunction = !!sprawl; - const hasQueryFunction = !!query; - const hasRelationsFunction = !!relations; - const hasDataFunction = !!data; - const hasSlotsDescription = !!slots; - - const isInvalidated = !empty(invalidatingDependencyKeys); - const isMissingContentDependencies = !empty(missingContentDependencyKeys); - const isMissingExtraDependencies = !empty(missingExtraDependencyKeys); - - let wrappedGenerate; - - const optionalDecorateTime = (prefix, fn) => - (DECORATE_TIME - ? decorateTime(`${prefix}/${generate.name}`, fn) - : fn); - - if (isInvalidated) { - wrappedGenerate = function() { - throw new Error(`Generate invalidated because unfulfilled dependencies provided: ${[...invalidatingDependencyKeys].join(', ')}`); - }; + if (spec.data && spec.relations && !arg2) { + throw new Error(`Expected relations`); + } - annotateFunction(wrappedGenerate, {name: generate, trait: 'invalidated'}); - wrappedGenerate.fulfilled = false; - } else if (isMissingContentDependencies || isMissingExtraDependencies) { - wrappedGenerate = function() { - throw new Error(`Dependencies still needed: ${[...missingContentDependencyKeys, ...missingExtraDependencyKeys].join(', ')}`); - }; + if (spec.relations && !arg1) { + throw new Error(`Expected relations`); + } - annotateFunction(wrappedGenerate, {name: generate, trait: 'unfulfilled'}); - wrappedGenerate.fulfilled = false; - } else { - let callUnderlyingGenerate = ([arg1, arg2], ...extraArgs) => { - if (hasDataFunction && !arg1) { - throw new Error(`Expected data`); + try { + if (spec.data && spec.relations) { + return spec.generate(arg1, arg2, ...extraArgs, boundExtraDependencies); + } else if (spec.data || spec.relations) { + return spec.generate(arg1, ...extraArgs, boundExtraDependencies); + } else { + return spec.generate(...extraArgs, boundExtraDependencies); } + } catch (caughtError) { + const error = new Error( + `Error generating content for ${spec.generate.name}`, + {cause: caughtError}); - if (hasDataFunction && hasRelationsFunction && !arg2) { - throw new Error(`Expected relations`); - } + error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; + error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = caughtError; - if (hasRelationsFunction && !arg1) { - throw new Error(`Expected relations`); - } + error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ + /content-function\.js/, + /util\/html\.js/, + ]; - try { - if (hasDataFunction && hasRelationsFunction) { - return generate(arg1, arg2, ...extraArgs, fulfilledDependencies); - } else if (hasDataFunction || hasRelationsFunction) { - return generate(arg1, ...extraArgs, fulfilledDependencies); - } else { - return generate(...extraArgs, fulfilledDependencies); - } - } catch (caughtError) { - const error = new Error( - `Error generating content for ${generate.name}`, - {cause: caughtError}); - - error[Symbol.for(`hsmusic.aggregate.alwaysTrace`)] = true; - error[Symbol.for(`hsmusic.aggregate.traceFrom`)] = caughtError; - - error[Symbol.for(`hsmusic.aggregate.unhelpfulTraceLines`)] = [ - /content-function\.js/, - /util\/html\.js/, - ]; - - error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ - /content\/dependencies\/(.*\.js:.*(?=\)))/, - ]; - - throw error; - } - }; + error[Symbol.for(`hsmusic.aggregate.helpfulTraceLines`)] = [ + /content\/dependencies\/(.*\.js:.*(?=\)))/, + ]; - callUnderlyingGenerate = - optionalDecorateTime(`generate`, callUnderlyingGenerate); + throw error; + } + }; - if (hasSlotsDescription) { - const stationery = fulfilledDependencies.html.stationery({ + generate = optionalDecorateTime(`generate`, generate); + + if (spec.slots) { + let stationery = null; + return (...args) => { + stationery ??= boundExtraDependencies.html.stationery({ annotation: generate.name, // These extra slots are for the data and relations (positional) args. @@ -182,170 +115,23 @@ export function expectDependencies({ slots: { _cfArg1: {validate: v => v.isObject}, _cfArg2: {validate: v => v.isObject}, - ...slots, + ...spec.slots, }, content(slots) { const args = [slots._cfArg1, slots._cfArg2]; - return callUnderlyingGenerate(args, slots); + return generate(args, slots); }, }); - wrappedGenerate = function(...args) { - return stationery.template().slots({ - _cfArg1: args[0] ?? null, - _cfArg2: args[1] ?? null, - }); - }; - } else { - wrappedGenerate = function(...args) { - return callUnderlyingGenerate(args); - }; - } - - wrappedGenerate.fulfill = function() { - throw new Error(`All dependencies already fulfilled (${generate.name})`); - }; - - annotateFunction(wrappedGenerate, {name: generate, trait: 'fulfilled'}); - wrappedGenerate.fulfilled = true; - } - - wrappedGenerate[contentFunction.identifyingSymbol] = true; - - if (hasSprawlFunction) { - wrappedGenerate.sprawl = optionalDecorateTime(`sprawl`, sprawl); - } - - if (hasQueryFunction) { - wrappedGenerate.query = optionalDecorateTime(`query`, query); - } - - if (hasRelationsFunction) { - wrappedGenerate.relations = optionalDecorateTime(`relations`, relations); - } - - if (hasDataFunction) { - wrappedGenerate.data = optionalDecorateTime(`data`, data); - } - - wrappedGenerate.fulfill ??= function fulfill(dependencies) { - // To avoid unneeded destructuring, `fullfillDependencies` is a mutating - // function. But `fulfill` itself isn't meant to mutate! We create a copy - // of these variables, so their original values are kept for additional - // calls to this same `fulfill`. - const newlyMissingContentDependencyKeys = new Set(missingContentDependencyKeys); - const newlyMissingExtraDependencyKeys = new Set(missingExtraDependencyKeys); - const newlyInvalidatingDependencyKeys = new Set(invalidatingDependencyKeys); - const newlyFulfilledDependencyKeys = new Set(fulfilledDependencyKeys); - const newlyFulfilledDependencies = {...fulfilledDependencies}; - - try { - fulfillDependencies(dependencies, { - missingContentDependencyKeys: newlyMissingContentDependencyKeys, - missingExtraDependencyKeys: newlyMissingExtraDependencyKeys, - invalidatingDependencyKeys: newlyInvalidatingDependencyKeys, - fulfilledDependencyKeys: newlyFulfilledDependencyKeys, - fulfilledDependencies: newlyFulfilledDependencies, + return stationery.template().slots({ + _cfArg1: args[0] ?? null, + _cfArg2: args[1] ?? null, }); - } catch (error) { - error.message += ` (${generate.name})`; - throw error; - } - - return expectDependencies({ - slots, - sprawl, - query, - relations, - data, - generate, - - expectedContentDependencyKeys, - expectedExtraDependencyKeys, - missingContentDependencyKeys: newlyMissingContentDependencyKeys, - missingExtraDependencyKeys: newlyMissingExtraDependencyKeys, - invalidatingDependencyKeys: newlyInvalidatingDependencyKeys, - fulfilledDependencyKeys: newlyFulfilledDependencyKeys, - fulfilledDependencies: newlyFulfilledDependencies, - }); - - }; - - Object.assign(wrappedGenerate, { - contentDependencies: expectedContentDependencyKeys, - extraDependencies: expectedExtraDependencyKeys, - }); - - return wrappedGenerate; -} - -export function fulfillDependencies(dependencies, { - missingContentDependencyKeys, - missingExtraDependencyKeys, - invalidatingDependencyKeys, - fulfilledDependencyKeys, - fulfilledDependencies, -}) { - // This is a mutating function. Be aware: it WILL mutate the provided sets - // and objects EVEN IF there are errors. This function doesn't exit early, - // so all provided dependencies which don't have an associated error should - // be treated as fulfilled (this is reflected via fulfilledDependencyKeys). - - const errors = []; - - for (let [key, value] of Object.entries(dependencies)) { - if (fulfilledDependencyKeys.has(key)) { - errors.push(new Error(`Dependency ${key} is already fulfilled`)); - continue; - } - - const isContentKey = missingContentDependencyKeys.has(key); - const isExtraKey = missingExtraDependencyKeys.has(key); - - if (!isContentKey && !isExtraKey) { - errors.push(new Error(`Dependency ${key} is not expected`)); - continue; - } - - if (value === undefined) { - errors.push(new Error(`Dependency ${key} was provided undefined`)); - continue; - } - - const isContentFunction = - !!value?.[contentFunction.identifyingSymbol]; - - const isFulfilledContentFunction = - isContentFunction && value.fulfilled; - - if (isContentKey) { - if (!isContentFunction) { - errors.push(new Error(`Content dependency ${key} is not a content function (got ${value})`)); - continue; - } - - if (!isFulfilledContentFunction) { - invalidatingDependencyKeys.add(key); - } - - missingContentDependencyKeys.delete(key); - } else if (isExtraKey) { - if (isContentFunction) { - errors.push(new Error(`Extra dependency ${key} is a content function`)); - continue; - } - - missingExtraDependencyKeys.delete(key); - } - - fulfilledDependencyKeys.add(key); - fulfilledDependencies[key] = value; + }; } - if (!empty(errors)) { - throw new AggregateError(errors, `Errors fulfilling dependencies`); - } + return (...args) => generate(args); } export function getArgsForRelationsAndData(contentFunction, wikiData, ...args) { @@ -393,8 +179,6 @@ export function getRelationsTree(dependencies, contentFunctionName, wikiData, .. }; if (contentFunction.relations) { - const listedDependencies = new Set(contentFunction.contentDependencies); - // Note: "slots" here is a completely separate concept from HTML template // slots, which are handled completely within the content function. Here, // relation slots are just references to a position within the relations @@ -408,10 +192,6 @@ export function getRelationsTree(dependencies, contentFunctionName, wikiData, .. })(); const relationFunction = (name, ...args) => { - if (!listedDependencies.has(name)) { - throw new Error(`Called relation('${name}') but ${contentFunctionName} doesn't list that dependency`); - } - const relationSymbol = Symbol(relationSymbolMessage(name)); const traceError = new Error(); @@ -502,22 +282,6 @@ export function fillRelationsLayoutFromSlotResults(relationIdentifier, results, return recursive(layout); } -export function getNeededContentDependencyNames(contentDependencies, name) { - const set = new Set(); - - function recursive(name) { - const contentFunction = contentDependencies[name]; - for (const dependencyName of contentFunction?.contentDependencies ?? []) { - recursive(dependencyName); - } - set.add(name); - } - - recursive(name); - - return set; -} - export const decorateErrorWithRelationStack = (fn, traceStack) => decorateError(fn, caughtError => { let cause = caughtError; @@ -579,65 +343,10 @@ export function quickEvaluate({ const flatTreeInfo = flattenRelationsTree(treeInfo); const {root, relationIdentifier, flatRelationSlots} = flatTreeInfo; - const neededContentDependencyNames = - getNeededContentDependencyNames(allContentDependencies, name); - - // Content functions aren't recursive, so by following the set above - // sequentually, we will always provide fulfilled content functions as the - // dependencies for later content functions. - const fulfilledContentDependencies = {}; - for (const name of neededContentDependencyNames) { - const unfulfilledContentFunction = allContentDependencies[name]; - if (!unfulfilledContentFunction) continue; - - const {contentDependencies, extraDependencies} = unfulfilledContentFunction; - - if (empty(contentDependencies) && empty(extraDependencies)) { - fulfilledContentDependencies[name] = unfulfilledContentFunction; - continue; - } - - const fulfillments = {}; - - for (const dependencyName of contentDependencies ?? []) { - if (dependencyName in fulfilledContentDependencies) { - fulfillments[dependencyName] = - fulfilledContentDependencies[dependencyName]; - } - } - - for (const dependencyName of extraDependencies ?? []) { - if (dependencyName in allExtraDependencies) { - fulfillments[dependencyName] = - allExtraDependencies[dependencyName]; - } - } - - fulfilledContentDependencies[name] = - unfulfilledContentFunction.fulfill(fulfillments); - } - - // There might still be unfulfilled content functions if dependencies weren't - // provided as part of allContentDependencies or allExtraDependencies. - // Catch and report these early, together in an aggregate error. - const unfulfilledErrors = []; - const unfulfilledNames = []; - for (const name of neededContentDependencyNames) { - const contentFunction = fulfilledContentDependencies[name]; - if (!contentFunction) continue; - if (!contentFunction.fulfilled) { - try { - contentFunction(); - } catch (error) { - error.message = `(${name}) ${error.message}`; - unfulfilledErrors.push(error); - unfulfilledNames.push(name); - } - } - } - - if (!empty(unfulfilledErrors)) { - throw new AggregateError(unfulfilledErrors, `Content functions unfulfilled (${unfulfilledNames.join(', ')})`); + allContentDependencies = {...allContentDependencies}; + for (const [name, contentFunction] of Object.entries(allContentDependencies)) { + allContentDependencies[name] = + contentFunction.bindExtraDependencies(allExtraDependencies); } const slotResults = {}; @@ -646,10 +355,9 @@ export function quickEvaluate({ const callDecorated = (fn, ...args) => decorateErrorWithRelationStack(fn, traceStack)(...args); - const contentFunction = fulfilledContentDependencies[name]; - + const contentFunction = allContentDependencies[name]; if (!contentFunction) { - throw new Error(`Content function ${name} unfulfilled or not listed`); + throw new Error(`Content function ${name} not listed`); } const generateArgs = []; diff --git a/src/content/dependencies/generateAbsoluteDatetimestamp.js b/src/content/dependencies/generateAbsoluteDatetimestamp.js index 930b6f13..2250ded3 100644 --- a/src/content/dependencies/generateAbsoluteDatetimestamp.js +++ b/src/content/dependencies/generateAbsoluteDatetimestamp.js @@ -1,11 +1,4 @@ export default { - contentDependencies: [ - 'generateDatetimestampTemplate', - 'generateTooltip', - ], - - extraDependencies: ['html', 'language'], - data: (date) => ({date}), diff --git a/src/content/dependencies/generateAdditionalFilesList.js b/src/content/dependencies/generateAdditionalFilesList.js index 68120b23..699c5f86 100644 --- a/src/content/dependencies/generateAdditionalFilesList.js +++ b/src/content/dependencies/generateAdditionalFilesList.js @@ -1,26 +1,19 @@ -import {stitchArrays} from '#sugar'; - export default { - extraDependencies: ['html'], + relations: (relation, additionalFiles) => ({ + chunks: + additionalFiles + .map(file => relation('generateAdditionalFilesListChunk', file)), + }), slots: { - chunks: { - validate: v => v.strictArrayOf(v.isHTML), - }, - - chunkItems: { - validate: v => v.strictArrayOf(v.isHTML), - }, + 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..466a5d8d 100644 --- a/src/content/dependencies/generateAdditionalFilesListChunk.js +++ b/src/content/dependencies/generateAdditionalFilesListChunk.js @@ -1,46 +1,78 @@ +import {stitchArrays} from '#sugar'; + export default { - extraDependencies: ['html', 'language'], + relations: (relation, file) => ({ + description: + relation('transformContent', file.description), - slots: { - title: { - type: 'html', - mutable: false, - }, + links: + file.filenames + .map(filename => relation('linkAdditionalFile', file, filename)), + }), - description: { - type: 'html', - mutable: false, - }, + 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..6bd1ab42 100644 --- a/src/content/dependencies/generateAdditionalNamesBox.js +++ b/src/content/dependencies/generateAdditionalNamesBox.js @@ -1,17 +1,25 @@ export default { - contentDependencies: ['generateAdditionalNamesBoxItem'], - extraDependencies: ['html', 'language'], - relations: (relation, additionalNames) => ({ items: additionalNames .map(entry => relation('generateAdditionalNamesBoxItem', entry)), }), - generate: (relations, {html, language}) => + slots: { + alwaysVisible: { + type: 'boolean', + default: false, + }, + }, + + generate: (relations, slots, {html, language}) => html.tag('div', {id: 'additional-names-box'}, + {class: 'drop'}, {[html.onlyIfContent]: true}, + slots.alwaysVisible && + {class: 'always-visible'}, + [ html.tag('p', {[html.onlyIfSiblings]: true}, diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js index e3e59a34..a39711c1 100644 --- a/src/content/dependencies/generateAdditionalNamesBoxItem.js +++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['transformContent'], - extraDependencies: ['html', 'language'], - relations: (relation, entry) => ({ nameContent: relation('transformContent', entry.name), 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..5491192a --- /dev/null +++ b/src/content/dependencies/generateAlbumArtInfoBox.js @@ -0,0 +1,36 @@ +export default { + 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..5346e56b --- /dev/null +++ b/src/content/dependencies/generateAlbumArtworkColumn.js @@ -0,0 +1,51 @@ +export default { + query: (album) => ({ + nonAttachingArtworkIndex: + (album.hasCoverArt + ? album.coverArtworks.findIndex((artwork, index) => + index > 1 && + !artwork.attachAbove) + : null), + }), + + relations: (relation, query, album) => ({ + firstCovers: + (album.hasCoverArt && query.nonAttachingArtworkIndex >= 1 + ? album.coverArtworks + .slice(0, query.nonAttachingArtworkIndex) + .map(artwork => relation('generateCoverArtwork', artwork)) + + : album.hasCoverArt + ? album.coverArtworks + .map(artwork => relation('generateCoverArtwork', artwork)) + + : []), + + albumArtInfoBox: + relation('generateAlbumArtInfoBox', album), + + restCovers: + (album.hasCoverArt && query.nonAttachingArtworkIndex >= 1 + ? album.coverArtworks + .slice(query.nonAttachingArtworkIndex) + .map(artwork => relation('generateCoverArtwork', artwork)) + + : []), + }), + + generate(relations, {html}) { + for (const cover of [...relations.firstCovers, ...relations.restCovers]) { + cover.setSlots({ + showOriginDetails: true, + showArtTagDetails: true, + showReferenceDetails: true, + }); + } + + return html.tags([ + relations.firstCovers, + relations.albumArtInfoBox, + relations.restCovers, + ]); + }, +}; diff --git a/src/content/dependencies/generateAlbumBanner.js b/src/content/dependencies/generateAlbumBanner.js index 3cc141bc..dce258de 100644 --- a/src/content/dependencies/generateAlbumBanner.js +++ b/src/content/dependencies/generateAlbumBanner.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateBanner'], - extraDependencies: ['html', 'language'], - relations(relation, album) { if (!album.hasBannerArt) { return {}; diff --git a/src/content/dependencies/generateAlbumCommentaryPage.js b/src/content/dependencies/generateAlbumCommentaryPage.js index 91ffeb04..4c203877 100644 --- a/src/content/dependencies/generateAlbumCommentaryPage.js +++ b/src/content/dependencies/generateAlbumCommentaryPage.js @@ -1,24 +1,22 @@ -import {stitchArrays} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateAlbumCommentarySidebar', - 'generateAlbumCoverArtwork', - 'generateAlbumNavAccent', - 'generateAlbumSecondaryNav', - 'generateAlbumStyleRules', - 'generateCommentaryEntry', - 'generateContentHeading', - 'generateTrackCoverArtwork', - 'generatePageLayout', - 'linkAlbum', - 'linkExternal', - 'linkTrack', - ], - - 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 +28,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 +37,7 @@ export default { relations.albumNavAccent = relation('generateAlbumNavAccent', album, null); - if (album.commentary) { + if (!empty(album.commentary)) { relations.albumCommentaryHeading = relation('generateContentHeading'); @@ -51,7 +49,7 @@ export default { if (album.hasCoverArt) { relations.albumCommentaryCover = - relation('generateAlbumCoverArtwork', album); + relation('generateCoverArtwork', album.coverArtworks[0]); } relations.albumCommentaryEntries = @@ -59,32 +57,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 +86,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 +107,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 +135,7 @@ export default { headingMode: 'sticky', color: data.color, - styleRules: [relations.albumStyleRules], + styleTags: relations.albumStyleTags, mainClasses: ['long-content'], mainContent: [ @@ -265,7 +250,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..4863f059 100644 --- a/src/content/dependencies/generateAlbumCommentarySidebar.js +++ b/src/content/dependencies/generateAlbumCommentarySidebar.js @@ -1,13 +1,6 @@ -export default { - contentDependencies: [ - 'generateAlbumSidebarTrackSection', - 'generatePageSidebar', - 'generatePageSidebarBox', - 'linkAlbum', - ], - - extraDependencies: ['html', 'language'], +import {empty} from '#sugar'; +export default { relations: (relation, album) => ({ sidebar: relation('generatePageSidebar'), @@ -28,10 +21,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..f9cd027e --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryAlbumGrid.js @@ -0,0 +1,82 @@ +import {stitchArrays} from '#sugar'; + +export default { + 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/generateAlbumGalleryCoverArtistsLine.js b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js index 7dcdf6de..0322e227 100644 --- a/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js +++ b/src/content/dependencies/generateAlbumGalleryCoverArtistsLine.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkArtistGallery'], - extraDependencies: ['html', 'language'], - relations(relation, coverArtists) { return { coverArtistLinks: diff --git a/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js index ad99cb87..5932514e 100644 --- a/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js +++ b/src/content/dependencies/generateAlbumGalleryNoTrackArtworksLine.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'language'], - generate: ({html, language}) => html.tag('p', {class: 'quick-info'}, language.$('albumGalleryPage.noTrackArtworksLine')), diff --git a/src/content/dependencies/generateAlbumGalleryPage.js b/src/content/dependencies/generateAlbumGalleryPage.js index b48d92af..85b0fb74 100644 --- a/src/content/dependencies/generateAlbumGalleryPage.js +++ b/src/content/dependencies/generateAlbumGalleryPage.js @@ -1,166 +1,86 @@ -import {compareArrays, stitchArrays} from '#sugar'; +import {stitchArrays, unique} from '#sugar'; +import {getKebabCase} from '#wiki-data'; export default { - contentDependencies: [ - 'generateAlbumGalleryCoverArtistsLine', - 'generateAlbumGalleryNoTrackArtworksLine', - 'generateAlbumGalleryStatsLine', - 'generateAlbumNavAccent', - 'generateAlbumSecondaryNav', - 'generateAlbumStyleRules', - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkAlbum', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], - 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.layout = - relation('generatePageLayout'); - - relations.albumStyleRules = - relation('generateAlbumStyleRules', album, null); - - relations.albumLink = - relation('linkAlbum', album); - - relations.albumNavAccent = - relation('generateAlbumNavAccent', album, null); - - relations.secondaryNav = - relation('generateAlbumSecondaryNav', album); + relations: (relation, query, album) => ({ + layout: + relation('generatePageLayout'), - relations.statsLine = - relation('generateAlbumGalleryStatsLine', album); + albumStyleTags: + relation('generateAlbumStyleTags', album, null), - 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')), - - ... - album.tracks.map(track => - (track.hasUniqueCoverArt - ? relation('image', track.artTags) - : relation('image'))), - ]; + albumNavAccent: + relation('generateAlbumNavAccent', album, null), - return relations; - }, - - data(query, album) { - const data = {}; - - data.name = album.name; - data.color = album.color; + secondaryNav: + relation('generateAlbumSecondaryNav', album), - data.names = [ - album.name, - ...album.tracks.map(track => track.name), - ]; + statsLine: + relation('generateAlbumGalleryStatsLine', album), - 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; - }), - ]; + trackGrids: + query.recurringTrackArtworkLabels.map(label => + relation('generateAlbumGalleryTrackGrid', album, label)), + }), - data.paths = [ - (album.hasCoverArt - ? ['media.albumCover', album.directory, album.coverArtFileExtension] - : null), + data: (query, album) => ({ + trackGridLabels: + query.recurringTrackArtworkLabels, - ... - album.tracks.map(track => - (track.hasUniqueCoverArt - ? ['media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension] - : null)), - ]; + trackGridIDs: + query.recurringTrackArtworkLabels.map(label => + 'track-grid-' + + (label + ? getKebabCase(label) + : 'no-label')), - data.dimensions = [ - (album.hasCoverArt - ? album.coverArtDimensions - : null), - - ... - 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 +91,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/generateAlbumGalleryStatsLine.js b/src/content/dependencies/generateAlbumGalleryStatsLine.js index 75bffb36..75341937 100644 --- a/src/content/dependencies/generateAlbumGalleryStatsLine.js +++ b/src/content/dependencies/generateAlbumGalleryStatsLine.js @@ -1,38 +1,56 @@ import {getTotalDuration} from '#wiki-data'; export default { - extraDependencies: ['html', 'language'], - - data(album) { - return { - name: album.name, - date: album.date, - duration: getTotalDuration(album.tracks), - numTracks: album.tracks.length, - }; - }, - - generate(data, {html, language}) { - const parts = ['albumGalleryPage.statsLine']; - const options = {}; - - options.tracks = - html.tag('b', - language.countTracks(data.numTracks, {unit: true})); - - options.duration = - html.tag('b', - language.formatDuration(data.duration, {unit: true})); - - if (data.date) { - parts.push('withDate'); - options.date = - html.tag('b', - language.formatDate(data.date)); - } - - return ( - html.tag('p', {class: 'quick-info'}, - language.formatString(...parts, options))); - }, + data: (album) => ({ + date: + album.date, + + hideDuration: + album.hideDuration, + + duration: + (album.hideDuration + ? null + : getTotalDuration(album.tracks)), + + tracks: + (album.hideDuration + ? null + : album.tracks.length), + }), + + generate: (data, {html, language}) => + html.tag('p', {class: 'quick-info'}, + {[html.onlyIfContent]: true}, + + language.encapsulate('albumGalleryPage.statsLine', workingCapsule => { + const workingOptions = {}; + + if (data.hideDuration && !data.date) { + return html.blank(); + } + + if (!data.hideDuration) { + workingOptions.tracks = + html.tag('b', + language.countTracks(data.tracks, {unit: true})); + + workingOptions.duration = + html.tag('b', + language.formatDuration(data.duration, {unit: true})); + } + + if (data.date) { + workingCapsule += '.withDate'; + workingOptions.date = + html.tag('b', + language.formatDate(data.date)); + } + + if (data.hideDuration) { + workingCapsule += '.noDuration'; + } + + return language.$(workingCapsule, workingOptions); + })), }; diff --git a/src/content/dependencies/generateAlbumGalleryTrackGrid.js b/src/content/dependencies/generateAlbumGalleryTrackGrid.js new file mode 100644 index 00000000..a50448c6 --- /dev/null +++ b/src/content/dependencies/generateAlbumGalleryTrackGrid.js @@ -0,0 +1,118 @@ +import {compareArrays, stitchArrays} from '#sugar'; + +export default { + 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)), + + allWarnings: + query.artworks.flatMap(artwork => artwork?.contentWarnings), + }), + + 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), + })), + + revealAllWarnings: + data.allWarnings, + }), + ]), +}; diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index 1f741a60..a27074ff 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -1,31 +1,12 @@ -export default { - contentDependencies: [ - 'generateAdditionalNamesBox', - 'generateAlbumAdditionalFilesList', - 'generateAlbumBanner', - 'generateAlbumCoverArtwork', - 'generateAlbumNavAccent', - 'generateAlbumReleaseInfo', - 'generateAlbumSecondaryNav', - 'generateAlbumSidebar', - 'generateAlbumSocialEmbed', - 'generateAlbumStyleRules', - 'generateAlbumTrackList', - 'generateCommentarySection', - 'generateContentHeading', - 'generatePageLayout', - 'linkAlbumCommentary', - 'linkAlbumGallery', - ], - - extraDependencies: ['html', 'language'], +import {empty} from '#sugar'; +export default { relations: (relation, album) => ({ layout: relation('generatePageLayout'), - albumStyleRules: - relation('generateAlbumStyleRules', album, null), + albumStyleTags: + relation('generateAlbumStyleTags', album, null), socialEmbed: relation('generateAlbumSocialEmbed', album), @@ -42,10 +23,8 @@ export default { additionalNamesBox: relation('generateAdditionalNamesBox', album.additionalNames), - cover: - (album.hasCoverArt - ? relation('generateAlbumCoverArtwork', album) - : null), + artworkColumn: + relation('generateAlbumArtworkColumn', album), banner: (album.hasBannerArt @@ -64,23 +43,30 @@ export default { : null), commentaryLink: - (album.commentary || album.tracks.some(t => t.commentary) + (album.tracks.some(track => !empty(track.commentary)) ? relation('linkAlbumCommentary', album) : null), + readCommentaryLine: + relation('generateReadCommentaryLine', album), + trackList: relation('generateAlbumTrackList', album), additionalFilesList: - relation('generateAlbumAdditionalFilesList', - album, - album.additionalFiles), + relation('generateAdditionalFilesList', album.additionalFiles), + + commentaryContentHeading: + relation('generateCommentaryContentHeading', album), - artistCommentarySection: - relation('generateCommentarySection', album.commentary), + artistCommentaryEntries: + album.commentary + .map(entry => relation('generateCommentaryEntry', entry)), - creditSourcesSection: - relation('generateCommentarySection', album.creditSources), + creditingSourcesSection: + relation('generateCollapsedContentEntrySection', + album.creditingSources, + album), }), data: (album) => ({ @@ -104,16 +90,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 +142,16 @@ export default { : html.blank()), - !html.isBlank(relations.creditSourcesSection) && - language.encapsulate(capsule, 'readCreditSources', capsule => + !relations.commentaryLink && + !html.isBlank(relations.artistCommentaryEntries) && + relations.readCommentaryLine, + + !html.isBlank(relations.creditingSourcesSection) && + language.encapsulate(capsule, 'readCreditingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#credit-sources'}, + {href: '#crediting-sources'}, language.$(capsule, 'link')), })), ])), @@ -174,14 +160,14 @@ export default { html.tag('p', {[html.onlyIfContent]: true}, - {[html.joinChildren]: html.tag('br')}, - language.encapsulate('releaseInfo', capsule => [ - language.$(capsule, 'addedToWiki', { - [language.onlyIfOptions]: ['date'], - date: language.formatDate(data.dateAddedToWiki), - }), - ])), + language.$('releaseInfo.addedToWiki', { + [language.onlyIfOptions]: ['date'], + date: language.formatDate(data.dateAddedToWiki), + })), + + !html.isBlank(relations.artistCommentaryEntries) && + html.tag('hr', {class: 'main-separator'}), language.encapsulate('releaseInfo.additionalFiles', capsule => html.tags([ @@ -194,11 +180,14 @@ export default { relations.additionalFilesList, ])), - relations.artistCommentarySection, + html.tags([ + relations.commentaryContentHeading, + relations.artistCommentaryEntries, + ]), - relations.creditSourcesSection.slots({ - id: 'credit-sources', - title: language.$('misc.creditSources'), + relations.creditingSourcesSection.slots({ + id: 'crediting-sources', + string: 'misc.creditingSources', }), ], diff --git a/src/content/dependencies/generateAlbumNavAccent.js b/src/content/dependencies/generateAlbumNavAccent.js index 3adb01be..237120f3 100644 --- a/src/content/dependencies/generateAlbumNavAccent.js +++ b/src/content/dependencies/generateAlbumNavAccent.js @@ -1,17 +1,6 @@ -import {atOffset} from '#sugar'; +import {atOffset, empty} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkTrack', - 'linkAlbumCommentary', - 'linkAlbumGallery', - ], - - extraDependencies: ['html', 'language'], - query(album, track) { const query = {}; @@ -64,9 +53,8 @@ export default { hasMultipleTracks: album.tracks.length > 1, - commentaryPageIsStub: - !album.commentary && - album.tracks.every(t => !t.commentary), + hasSubstantialCommentaryPage: + album.tracks.some(track => !empty(track.commentary)), galleryIsStub: album.tracks.every(t => !t.hasUniqueCoverArt), @@ -97,14 +85,16 @@ export default { relations.nextLink.slot('link', relations.nextTrackLink); const galleryLink = - (!data.galleryIsStub || slots.currentExtra === 'gallery') && + (!data.galleryIsStub || + slots.currentExtra === 'gallery') && relations.albumGalleryLink.slots({ attributes: {class: slots.currentExtra === 'gallery' && 'current'}, content: language.$(albumNavCapsule, 'gallery'), }); const commentaryLink = - (!data.commentaryPageIsStub || slots.currentExtra === 'commentary') && + (data.hasSubstantialCommentaryPage || + slots.currentExtra === 'commentary') && relations.albumCommentaryLink.slots({ attributes: {class: slots.currentExtra === 'commentary' && 'current'}, content: language.$(albumNavCapsule, 'commentary'), diff --git a/src/content/dependencies/generateAlbumReferencedArtworksPage.js b/src/content/dependencies/generateAlbumReferencedArtworksPage.js index 3f3d77b3..e4022f0d 100644 --- a/src/content/dependencies/generateAlbumReferencedArtworksPage.js +++ b/src/content/dependencies/generateAlbumReferencedArtworksPage.js @@ -1,37 +1,21 @@ export default { - contentDependencies: [ - 'generateAlbumCoverArtwork', - 'generateAlbumStyleRules', - 'generateBackToAlbumLink', - 'generateReferencedArtworksPage', - 'linkAlbum', - ], - - extraDependencies: ['html', 'language'], - 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 +26,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..0dc1bf15 100644 --- a/src/content/dependencies/generateAlbumReferencingArtworksPage.js +++ b/src/content/dependencies/generateAlbumReferencingArtworksPage.js @@ -1,37 +1,21 @@ export default { - contentDependencies: [ - 'generateAlbumCoverArtwork', - 'generateAlbumStyleRules', - 'generateBackToAlbumLink', - 'generateReferencingArtworksPage', - 'linkAlbum', - ], - - extraDependencies: ['html', 'language'], - 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 +26,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..4cec4120 100644 --- a/src/content/dependencies/generateAlbumReleaseInfo.js +++ b/src/content/dependencies/generateAlbumReleaseInfo.js @@ -1,31 +1,14 @@ import {accumulateSum, empty} from '#sugar'; export default { - contentDependencies: [ - 'generateReleaseInfoContributionsLine', - 'linkExternal', - ], - - extraDependencies: ['html', 'language'], - relations(relation, album) { const relations = {}; 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; }, @@ -46,7 +29,7 @@ export default { .map(track => track.duration) .filter(value => value > 0); - if (empty(durationTerms)) { + if (empty(durationTerms) || album.hideDuration) { data.duration = null; data.durationApproximate = null; } else { @@ -73,31 +56,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 +73,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/generateAlbumSecondaryNav.js b/src/content/dependencies/generateAlbumSecondaryNav.js index bfa48f03..2140bfdb 100644 --- a/src/content/dependencies/generateAlbumSecondaryNav.js +++ b/src/content/dependencies/generateAlbumSecondaryNav.js @@ -1,15 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateAlbumSecondaryNavGroupPart', - 'generateAlbumSecondaryNavSeriesPart', - 'generateDotSwitcherTemplate', - 'generateSecondaryNav', - ], - - extraDependencies: ['html', 'wikiData'], - sprawl: ({groupData}) => ({ // TODO: Series aren't their own things, so we access them weirdly. seriesData: diff --git a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js index 9f9aaf23..2f08804b 100644 --- a/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js +++ b/src/content/dependencies/generateAlbumSecondaryNavGroupPart.js @@ -2,15 +2,6 @@ import {sortChronologically} from '#sort'; import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateSecondaryNavParentSiblingsPart', - 'linkAlbumDynamically', - 'linkGroup', - ], - - extraDependencies: ['html'], - query(group, album) { const query = {}; @@ -67,6 +58,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..ee180f16 100644 --- a/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js +++ b/src/content/dependencies/generateAlbumSecondaryNavSeriesPart.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateSecondaryNavParentSiblingsPart', - 'linkAlbumDynamically', - 'linkGroup', - ], - - extraDependencies: ['html', 'language'], - query(series, album) { const query = {}; @@ -62,7 +53,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..83a637b0 100644 --- a/src/content/dependencies/generateAlbumSidebar.js +++ b/src/content/dependencies/generateAlbumSidebar.js @@ -1,23 +1,14 @@ -import {stitchArrays} from '#sugar'; +import {sortAlbumsTracksChronologically} from '#sort'; +import {stitchArrays, transposeArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateAlbumSidebarGroupBox', - 'generateAlbumSidebarSeriesBox', - 'generateAlbumSidebarTrackListBox', - 'generatePageSidebar', - 'generatePageSidebarConjoinedBox', - ], - - extraDependencies: ['html', 'wikiData'], - sprawl: ({groupData}) => ({ // TODO: Series aren't their own things, so we access them weirdly. seriesData: groupData.flatMap(group => group.serieses), }), - query(sprawl, album) { + query(sprawl, album, track) { const query = {}; query.groups = @@ -35,6 +26,34 @@ 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()), + {getDate: album => albumTrackMap.get(album).date}); + + 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,58 +82,92 @@ 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) => ({ + data: (_query, _sprawl, album, track) => ({ isAlbumPage: !track, + isTrackPage: !!track, + + albumStyle: album.style, }), generate(data, relations, {html}) { + const presentGroupsLikeAlbum = + data.isAlbumPage || + data.albumStyle === 'single'; + for (const box of [ ...relations.groupBoxes, ...relations.seriesBoxes.flat(), ...relations.disconnectedSeriesBoxes, ]) { - box.setSlot('mode', - data.isAlbumPage ? 'album' : 'track'); + box.setSlot('mode', presentGroupsLikeAlbum ? 'album' : 'track'); } + const groupBoxes = + (presentGroupsLikeAlbum + ? [ + relations.disconnectedSeriesBoxes, + + stitchArrays({ + groupBox: relations.groupBoxes, + seriesBoxes: relations.seriesBoxes, + }).map(({groupBox, seriesBoxes}) => [ + groupBox, + seriesBoxes.map(seriesBox => [ + html.tag('div', + {class: 'sidebar-box-joiner'}, + {class: 'collapsible'}), + seriesBox, + ]), + ]), + ] + : [ + relations.conjoinedBox.slots({ + attributes: {class: 'conjoined-group-sidebar-box'}, + boxes: + ([relations.disconnectedSeriesBoxes, + stitchArrays({ + groupBox: relations.groupBoxes, + seriesBoxes: relations.seriesBoxes, + }).flatMap(({groupBox, seriesBoxes}) => [ + groupBox, + ...seriesBoxes, + ]), + ]).flat() + .map(box => box.content), /* TODO: Kludge. */ + }) + ]); + return relations.sidebar.slots({ boxes: [ - data.isAlbumPage && [ - relations.disconnectedSeriesBoxes, - - stitchArrays({ - groupBox: relations.groupBoxes, - seriesBoxes: relations.seriesBoxes, - }).map(({groupBox, seriesBoxes}) => [ - groupBox, - seriesBoxes.map(seriesBox => [ - html.tag('div', - {class: 'sidebar-box-joiner'}, - {class: 'collapsible'}), - seriesBox, - ]), - ]), - ], + data.isAlbumPage && + groupBoxes, + + data.isTrackPage && + relations.earlierTrackReleaseBoxes, relations.trackListBox, - !data.isAlbumPage && - relations.conjoinedBox.slots({ - attributes: {class: 'conjoined-group-sidebar-box'}, - boxes: - ([relations.disconnectedSeriesBoxes, - stitchArrays({ - groupBox: relations.groupBoxes, - seriesBoxes: relations.seriesBoxes, - }).flatMap(({groupBox, seriesBoxes}) => [ - groupBox, - ...seriesBoxes, - ]), - ]).flat() - .map(box => box.content), /* TODO: Kludge. */ - }), + data.isTrackPage && + relations.laterTrackReleaseBoxes, + + data.isTrackPage && + groupBoxes, ], }); }, diff --git a/src/content/dependencies/generateAlbumSidebarGroupBox.js b/src/content/dependencies/generateAlbumSidebarGroupBox.js index f3be74f7..0a9c0db9 100644 --- a/src/content/dependencies/generateAlbumSidebarGroupBox.js +++ b/src/content/dependencies/generateAlbumSidebarGroupBox.js @@ -2,16 +2,6 @@ import {sortChronologically} from '#sort'; import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generatePageSidebarBox', - 'linkAlbum', - 'linkExternal', - 'linkGroup', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - query(album, group) { const query = {}; diff --git a/src/content/dependencies/generateAlbumSidebarSeriesBox.js b/src/content/dependencies/generateAlbumSidebarSeriesBox.js index 37616cb2..22f1fe72 100644 --- a/src/content/dependencies/generateAlbumSidebarSeriesBox.js +++ b/src/content/dependencies/generateAlbumSidebarSeriesBox.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generatePageSidebarBox', - 'linkAlbum', - 'linkGroup', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - query(album, series) { const query = {}; diff --git a/src/content/dependencies/generateAlbumSidebarTrackListBox.js b/src/content/dependencies/generateAlbumSidebarTrackListBox.js index 3a244e3a..4e9437c9 100644 --- a/src/content/dependencies/generateAlbumSidebarTrackListBox.js +++ b/src/content/dependencies/generateAlbumSidebarTrackListBox.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateAlbumSidebarTrackSection', - 'generatePageSidebarBox', - 'linkAlbum', - ], - - extraDependencies: ['html'], - relations: (relation, album, track) => ({ box: relation('generatePageSidebarBox'), @@ -24,7 +16,9 @@ export default { attributes: {class: 'track-list-sidebar-box'}, content: [ - html.tag('h1', relations.albumLink), + html.tag('h1', {[html.onlyIfSiblings]: true}, + relations.albumLink), + relations.trackSections, ], }) diff --git a/src/content/dependencies/generateAlbumSidebarTrackSection.js b/src/content/dependencies/generateAlbumSidebarTrackSection.js index bb788d65..68281bfe 100644 --- a/src/content/dependencies/generateAlbumSidebarTrackSection.js +++ b/src/content/dependencies/generateAlbumSidebarTrackSection.js @@ -1,7 +1,6 @@ -export default { - contentDependencies: ['linkTrack'], - extraDependencies: ['getColors', 'html', 'language'], +import {empty, stitchArrays} from '#sugar'; +export default { relations(relation, album, track, trackSection) { const relations = {}; @@ -15,23 +14,27 @@ export default { data(album, track, trackSection) { const data = {}; - data.hasTrackNumbers = album.hasTrackNumbers; + data.hasTrackNumbers = + album.hasTrackNumbers && + !empty(trackSection.tracks); + data.isTrackPage = !!track; + data.albumStyle = album.style; data.name = trackSection.name; data.color = trackSection.color; data.isDefaultTrackSection = trackSection.isDefaultTrackSection; + data.hasSiblingSections = album.trackSections.length > 1; - 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 +42,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 +80,54 @@ 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), + }))); + + const list = + (data.hasTrackNumbers + ? html.tag('ol', + {start: data.firstTrackNumber}, + trackListItems) + : html.tag('ul', trackListItems)); + + if (data.albumStyle === 'single' && !data.hasSiblingSections) { + if (trackListItems.length <= 1) { + return html.blank(); + } else { + return list; + } + } return html.tag('details', data.includesCurrentTrack && @@ -119,23 +154,24 @@ 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); - }))), - - (data.hasTrackNumbers - ? html.tag('ol', - {start: data.firstTrackNumber}, - trackListItems) - : html.tag('ul', trackListItems)), + 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); + })))), + + list, ]); }, }; diff --git a/src/content/dependencies/generateAlbumSocialEmbed.js b/src/content/dependencies/generateAlbumSocialEmbed.js index 54574e45..1200ec8b 100644 --- a/src/content/dependencies/generateAlbumSocialEmbed.js +++ b/src/content/dependencies/generateAlbumSocialEmbed.js @@ -1,13 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateSocialEmbed', - 'generateAlbumSocialEmbedDescription', - ], - - extraDependencies: ['absoluteTo', 'language', 'urls'], - relations(relation, album) { return { socialEmbed: @@ -32,8 +25,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 +33,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 +57,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/generateAlbumSocialEmbedDescription.js b/src/content/dependencies/generateAlbumSocialEmbedDescription.js index 69c39c3a..db6da5b7 100644 --- a/src/content/dependencies/generateAlbumSocialEmbedDescription.js +++ b/src/content/dependencies/generateAlbumSocialEmbedDescription.js @@ -1,8 +1,6 @@ import {accumulateSum} from '#sugar'; export default { - extraDependencies: ['language'], - data: (album) => ({ duration: accumulateSum(album.tracks, track => track.duration), 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..caf21dc4 --- /dev/null +++ b/src/content/dependencies/generateAlbumStyleTags.js @@ -0,0 +1,62 @@ +import {empty} from '#sugar'; + +export default { + 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..93cb420b 100644 --- a/src/content/dependencies/generateAlbumTrackList.js +++ b/src/content/dependencies/generateAlbumTrackList.js @@ -35,14 +35,6 @@ function getDisplayMode(album) { } export default { - contentDependencies: [ - 'generateAlbumTrackListItem', - 'generateContentHeading', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - query(album) { return { displayMode: getDisplayMode(album), @@ -102,11 +94,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 +139,7 @@ export default { name: data.trackSectionNames, duration: data.trackSectionDurations, durationApproximate: data.trackSectionDurationsApproximate, - startIndex: data.trackSectionStartIndices, + startCountingFrom: data.trackSectionsStartCountingFrom, }).map(({ heading, description, @@ -156,7 +148,7 @@ export default { name, duration, durationApproximate, - startIndex, + startCountingFrom, }) => [ language.encapsulate('trackList.section', capsule => heading.slots({ @@ -190,7 +182,7 @@ export default { html.tag(listTag, data.hasTrackNumbers && - {start: startIndex + 1}, + {start: startCountingFrom}, slotItems(items)), ]), diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js index 44297c15..ab8d477d 100644 --- a/src/content/dependencies/generateAlbumTrackListItem.js +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateTrackListItem'], - extraDependencies: ['html'], - query: (track, album) => ({ trackHasDuration: !!track.duration, @@ -20,7 +17,7 @@ export default { item: relation('generateTrackListItem', track, - track.album.artistContribs), + track.album.trackArtistContribs), }), data: (query, track, album) => ({ diff --git a/src/content/dependencies/generateAlbumWallpaperStyleTag.js b/src/content/dependencies/generateAlbumWallpaperStyleTag.js new file mode 100644 index 00000000..b3f74716 --- /dev/null +++ b/src/content/dependencies/generateAlbumWallpaperStyleTag.js @@ -0,0 +1,35 @@ +export default { + 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..37a32a94 --- /dev/null +++ b/src/content/dependencies/generateArtTagAncestorDescendantMapList.js @@ -0,0 +1,150 @@ +import { + filterMultipleArrays, + sortMultipleArrays, + stitchArrays, + unique, +} from '#sugar'; + +export default { + // 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..f20babba 100644 --- a/src/content/dependencies/generateArtTagGalleryPage.js +++ b/src/content/dependencies/generateArtTagGalleryPage.js @@ -1,92 +1,114 @@ -import {sortAlbumsTracksChronologically} from '#sort'; -import {stitchArrays} from '#sugar'; +import {sortArtworksChronologically} from '#sort'; +import {empty, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: [ - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkAlbum', - 'linkArtTag', - 'linkTrack', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({wikiInfo}) { return { enableListings: wikiInfo.enableListings, }; }, - 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.artworkArtists = + query.allArtworks + .map(artwork => artwork.artistContribs + .map(contrib => contrib.artist.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.artworkLabels = + query.allArtworks + .map(artwork => artwork.label) - data.dimensions = - query.things.map(thing => thing.coverArtDimensions); + data.onlyFeaturedIndirectly = + query.allArtworks.map(artwork => + !query.directArtworks.includes(artwork)); - data.coverArtists = - query.things.map(thing => - thing.coverArtistContribs - .map(({artist}) => artist.name)); + 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 +116,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..8593cc21 --- /dev/null +++ b/src/content/dependencies/generateArtTagGalleryPageFeaturedLine.js @@ -0,0 +1,21 @@ +export default { + 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..2a34ae57 --- /dev/null +++ b/src/content/dependencies/generateArtTagGalleryPageShowingLine.js @@ -0,0 +1,20 @@ +export default { + 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..683eeab6 --- /dev/null +++ b/src/content/dependencies/generateArtTagInfoPage.js @@ -0,0 +1,267 @@ +import {empty, stitchArrays, unique} from '#sugar'; + +export default { + 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}) => + (annotation + ? language.$(capsule, 'tagWithAnnotation', { + tag: artTagLink, + annotation, + }) + : artTagLink))), + }))), + + 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..1298ce99 --- /dev/null +++ b/src/content/dependencies/generateArtTagNavLinks.js @@ -0,0 +1,73 @@ +export default { + 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..60ea504f --- /dev/null +++ b/src/content/dependencies/generateArtTagSidebar.js @@ -0,0 +1,115 @@ +import {collectTreeLeaves, empty, stitchArrays, unique} from '#sugar'; + +export default { + 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..19c66b8a --- /dev/null +++ b/src/content/dependencies/generateArtistArtworkColumn.js @@ -0,0 +1,11 @@ +export default { + 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..389de740 100644 --- a/src/content/dependencies/generateArtistCredit.js +++ b/src/content/dependencies/generateArtistCredit.js @@ -1,14 +1,7 @@ -import {compareArrays, empty} from '#sugar'; +import {compareArrays, empty, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistCreditWikiEditsPart', - 'linkContribution', - ], - - extraDependencies: ['html', 'language'], - - query: (creditContributions, contextContributions) => { + query: (creditContributions, contextContributions, _formatText) => { const query = {}; const featuringFilter = contribution => @@ -36,16 +29,26 @@ 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; }, - relations: (relation, query, _creditContributions, _contextContributions) => ({ + relations: (relation, query, + _creditContributions, + _contextContributions, + formatText) => ({ normalContributionLinks: query.normalContributions .map(contrib => relation('linkContribution', contrib)), @@ -57,11 +60,25 @@ export default { wikiEditsPart: relation('generateArtistCreditWikiEditsPart', query.wikiEditContributions), + + formatText: + relation('transformContent', formatText), }), - data: (query, _creditContributions, _contextContributions) => ({ - normalContributionsDifferFromContext: - query.normalContributionsDifferFromContext, + data: (query, _creditContributions, _contextContributions, _formatText) => ({ + normalContributionArtistsDifferFromContext: + query.normalContributionArtistsDifferFromContext, + + normalContributionAnnotationsDifferFromContext: + query.normalContributionAnnotationsDifferFromContext, + + normalContributionArtistDirectories: + query.normalContributions + .map(contrib => contrib.artist.directory), + + featuringContributionArtistDirectories: + query.featuringContributions + .map(contrib => contrib.artist.directory), hasWikiEdits: !empty(query.wikiEditContributions), @@ -80,6 +97,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}, @@ -93,6 +112,10 @@ export default { generate(data, relations, slots, {html, language}) { if (!slots.normalStringKey) return html.blank(); + const effectivelyDiffers = + (slots.showAnnotation && data.normalContributionAnnotationsDifferFromContext) || + (data.normalContributionArtistsDifferFromContext); + for (const link of [ ...relations.normalContributionLinks, ...relations.featuringContributionLinks, @@ -120,49 +143,112 @@ export default { }); } - if (empty(relations.normalContributionLinks)) { - return html.blank(); + let formattedArtistList = null; + + if (!html.isBlank(relations.formatText)) { + formattedArtistList = relations.formatText; + + const substituteContrib = ({link, directory}) => ({ + match: {replacerKey: 'artist', replacerValue: directory}, + substitute: link, + + apply(link, node) { + if (node.data.label) { + link.setSlot('content', language.sanitize(node.data.label)); + } + }, + }); + + relations.formatText.setSlots({ + mode: 'inline', + + substitute: [ + stitchArrays({ + link: relations.normalContributionLinks, + directory: data.normalContributionArtistDirectories, + }).map(substituteContrib), + + stitchArrays({ + link: relations.featuringContributionLinks, + directory: data.featuringContributionArtistDirectories, + }).map(substituteContrib), + ].flat(), + }); } - const artistsList = - (data.hasWikiEdits && slots.showWikiEdits - ? language.$('misc.artistLink.withEditsForWiki', { - artists: - language.formatConjunctionList(relations.normalContributionLinks), - - edits: - relations.wikiEditsPart.slots({ - showAnnotation: slots.showAnnotation, - }), - }) - : language.formatConjunctionList(relations.normalContributionLinks)); - - const featuringList = - language.formatConjunctionList(relations.featuringContributionLinks); - - const everyoneList = - language.formatConjunctionList([ - ...relations.normalContributionLinks, - ...relations.featuringContributionLinks, - ]); - - if (empty(relations.featuringContributionLinks)) { - if (data.normalContributionsDifferFromContext) { - return language.$(slots.normalStringKey, {artists: artistsList}); - } else { + let content; + + if (formattedArtistList) { + if (effectivelyDiffers) { + content = + language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: formattedArtistList, + }); + } + } else { + if (empty(relations.normalContributionLinks)) { return html.blank(); } - } - if (data.normalContributionsDifferFromContext && slots.normalFeaturingStringKey) { - return language.$(slots.normalFeaturingStringKey, { - artists: artistsList, - featuring: featuringList, - }); - } else if (slots.featuringStringKey) { - return language.$(slots.featuringStringKey, {artists: featuringList}); - } else { - return language.$(slots.normalStringKey, {artists: everyoneList}); + const artistsList = + (data.hasWikiEdits && slots.showWikiEdits + ? language.$('misc.artistLink.withEditsForWiki', { + artists: + language.formatConjunctionList(relations.normalContributionLinks), + + edits: + relations.wikiEditsPart.slots({ + showAnnotation: slots.showAnnotation, + }), + }) + + : language.formatConjunctionList(relations.normalContributionLinks)); + + const featuringList = + language.formatConjunctionList(relations.featuringContributionLinks); + + const everyoneList = + language.formatConjunctionList([ + ...relations.normalContributionLinks, + ...relations.featuringContributionLinks, + ]); + + if (empty(relations.featuringContributionLinks)) { + if (effectivelyDiffers) { + content = + language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: artistsList, + }); + } else { + return html.blank(); + } + } else if (effectivelyDiffers && slots.normalFeaturingStringKey) { + content = + language.$(slots.normalFeaturingStringKey, { + ...slots.additionalStringOptions, + artists: artistsList, + featuring: featuringList, + }); + } else if (slots.featuringStringKey) { + content = + language.$(slots.featuringStringKey, { + ...slots.additionalStringOptions, + artists: featuringList, + }); + } else { + content = + language.$(slots.normalStringKey, { + ...slots.additionalStringOptions, + artists: everyoneList, + }); + } } + + // TODO: This is obviously evil. + return ( + html.metatag('chunkwrap', {split: /,| (?=and)/}, + html.resolve(content))); }, }; diff --git a/src/content/dependencies/generateArtistCreditWikiEditsPart.js b/src/content/dependencies/generateArtistCreditWikiEditsPart.js index 70296e39..4178928d 100644 --- a/src/content/dependencies/generateArtistCreditWikiEditsPart.js +++ b/src/content/dependencies/generateArtistCreditWikiEditsPart.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateTextWithTooltip', - 'generateTooltip', - 'linkContribution', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, contributions) => ({ textWithTooltip: relation('generateTextWithTooltip'), @@ -48,6 +40,7 @@ export default { showAnnotation: slots.showAnnotation, trimAnnotation: true, preventTooltip: true, + preventWrapping: true, }))), }), }), diff --git a/src/content/dependencies/generateArtistGalleryPage.js b/src/content/dependencies/generateArtistGalleryPage.js index 7a76188a..d8f1c4b1 100644 --- a/src/content/dependencies/generateArtistGalleryPage.js +++ b/src/content/dependencies/generateArtistGalleryPage.js @@ -1,89 +1,58 @@ -import {sortAlbumsTracksChronologically} from '#sort'; -import {stitchArrays} from '#sugar'; +import {sortArtworksChronologically} from '#sort'; export default { - contentDependencies: [ - 'generateArtistNavLinks', - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkAlbum', - 'linkTrack', - ], - - 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)), + + allWarnings: + query.artworks + .flatMap(artwork => artwork.contentWarnings), + }), generate: (data, relations, {html, language}) => language.encapsulate('artistGalleryPage', pageCapsule => @@ -100,7 +69,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 +77,18 @@ 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), + })), + + revealAllWarnings: data.allWarnings, }), ], diff --git a/src/content/dependencies/generateArtistGroupContributionsInfo.js b/src/content/dependencies/generateArtistGroupContributionsInfo.js index f84d00de..6940053f 100644 --- a/src/content/dependencies/generateArtistGroupContributionsInfo.js +++ b/src/content/dependencies/generateArtistGroupContributionsInfo.js @@ -1,83 +1,87 @@ -import {empty, filterProperties, stitchArrays, unique} from '#sugar'; +import {accumulateSum, empty, stitchArrays, withEntries} from '#sugar'; export default { - contentDependencies: ['linkGroup'], - extraDependencies: ['html', 'language', 'wikiData'], + sprawl: ({groupCategoryData}) => ({ + groupOrder: + groupCategoryData.flatMap(category => category.groups), + }), - sprawl({groupCategoryData}) { - return { - groupOrder: groupCategoryData.flatMap(category => category.groups), - } - }, + query(sprawl, contributions) { + const allGroupsUnordered = + new Set(contributions.flatMap(contrib => contrib.groups)); - query(sprawl, tracksAndAlbums) { - const filteredAlbums = tracksAndAlbums.filter(thing => !thing.album); - const filteredTracks = tracksAndAlbums.filter(thing => thing.album); + const allGroupsOrdered = + sprawl.groupOrder.filter(group => allGroupsUnordered.has(group)); - const allAlbums = unique([ - ...filteredAlbums, - ...filteredTracks.map(track => track.album), - ]); + const groupToThingsCountedForContributions = + new Map(allGroupsOrdered.map(group => [group, new Set])); - const allGroupsUnordered = new Set(Array.from(allAlbums).flatMap(album => album.groups)); - const allGroupsOrdered = sprawl.groupOrder.filter(group => allGroupsUnordered.has(group)); + const groupToThingsCountedForDuration = + 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); - - 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 +97,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..cf8ce994 100644 --- a/src/content/dependencies/generateArtistInfoPage.js +++ b/src/content/dependencies/generateArtistInfoPage.js @@ -1,48 +1,18 @@ import {empty, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistGroupContributionsInfo', - 'generateArtistInfoPageArtworksChunkedList', - 'generateArtistInfoPageCommentaryChunkedList', - 'generateArtistInfoPageFlashesChunkedList', - 'generateArtistInfoPageTracksChunkedList', - 'generateArtistNavLinks', - 'generateContentHeading', - 'generateCoverArtwork', - 'generatePageLayout', - 'linkArtistGallery', - 'linkExternal', - 'linkGroup', - 'transformContent', - ], - - 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 +38,8 @@ export default { artistNavLinks: relation('generateArtistNavLinks', artist), - cover: - (artist.hasAvatar - ? relation('generateCoverArtwork', [], []) - : null), + artworkColumn: + relation('generateArtistArtworkColumn', artist), contentHeading: relation('generateContentHeading'), @@ -95,7 +63,7 @@ export default { relation('generateArtistInfoPageTracksChunkedList', artist), tracksGroupInfo: - relation('generateArtistGroupContributionsInfo', query.allTracks), + relation('generateArtistGroupContributionsInfo', query.trackContributions), artworksChunkedList: relation('generateArtistInfoPageArtworksChunkedList', artist, false), @@ -104,7 +72,7 @@ export default { relation('generateArtistInfoPageArtworksChunkedList', artist, true), artworksGroupInfo: - relation('generateArtistGroupContributionsInfo', query.allArtworks), + relation('generateArtistGroupContributionsInfo', query.artworkContributions), artistGalleryLink: (query.hasGallery @@ -115,27 +83,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 +114,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 +221,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 +309,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 +345,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/generateArtistInfoPageArtworksChunk.js b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js index 66e4204a..f4c9439a 100644 --- a/src/content/dependencies/generateArtistInfoPageArtworksChunk.js +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunk.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateArtistInfoPageChunk', - 'generateArtistInfoPageArtworksChunkItem', - 'linkAlbum', - ], - - extraDependencies: ['html'], - relations: (relation, album, contribs) => ({ template: relation('generateArtistInfoPageChunk'), diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js index 089cfb8d..8259e91e 100644 --- a/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkItem.js @@ -1,19 +1,17 @@ -export default { - contentDependencies: [ - 'generateArtistInfoPageChunkItem', - 'generateArtistInfoPageOtherArtistLinks', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], +import {empty} from '#sugar'; +export default { query: (contrib) => ({ kind: - (contrib.isBannerArtistContribution + (contrib.thingProperty === 'bannerArtistContribs' || + (contrib.thing.isArtwork && + contrib.thing.thingProperty === 'bannerArtwork') ? 'banner' - : contrib.isWallpaperArtistContribution + : contrib.thingProperty === 'wallpaperArtistContribs' || + (contrib.thing.isArtwork && + contrib.thing.thingProperty === 'wallpaperArtwork') ? 'wallpaper' - : contrib.isForAlbum + : contrib.thing.isAlbum ? 'album-cover' : 'track-cover'), }), @@ -24,11 +22,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 +38,9 @@ export default { annotation: contrib.annotation, + + label: + contrib.thing.label, }), slots: { @@ -51,9 +55,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 +96,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..40ffc5dd 100644 --- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js @@ -3,11 +3,6 @@ import {sortAlbumsTracksChronologically, sortContributionsChronologically} import {chunkByConditions, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunkedList', - 'generateArtistInfoPageArtworksChunk', - ], - query(artist, filterEditsForWiki) { const query = {}; @@ -27,20 +22,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..80429912 100644 --- a/src/content/dependencies/generateArtistInfoPageChunk.js +++ b/src/content/dependencies/generateArtistInfoPageChunk.js @@ -1,13 +1,13 @@ import {empty} from '#sugar'; export default { - extraDependencies: ['html', 'language'], - slots: { mode: { validate: v => v.is('flash', 'album'), }, + id: {type: 'string'}, + albumLink: { type: 'html', mutable: false, @@ -99,9 +99,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..8117ca9a 100644 --- a/src/content/dependencies/generateArtistInfoPageChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageChunkItem.js @@ -1,7 +1,10 @@ import {empty} from '#sugar'; export default { - extraDependencies: ['html', 'language'], + relations: (relation) => ({ + textWithTooltip: + relation('generateTextWithTooltip'), + }), slots: { content: { @@ -18,41 +21,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/generateArtistInfoPageChunkedList.js b/src/content/dependencies/generateArtistInfoPageChunkedList.js index e7915ab7..54577885 100644 --- a/src/content/dependencies/generateArtistInfoPageChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageChunkedList.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { groupInfo: { type: 'html', diff --git a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js index 0f238d13..caec58d6 100644 --- a/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageCommentaryChunkedList.js @@ -7,19 +7,7 @@ import { } from '#sort'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunk', - 'generateArtistInfoPageChunkItem', - 'linkAlbum', - 'linkFlash', - 'linkFlashAct', - 'linkTrack', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - - query(artist) { + query(artist, filterWikiEditorCommentary) { const processEntry = ({ thing, entry, @@ -43,6 +31,7 @@ export default { flash, annotation: entry.annotation, + annotationParts: entry.annotationParts, }, }); @@ -87,6 +76,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 +141,7 @@ export default { return {chunks}; }, - relations: (relation, query) => ({ + relations: (relation, query, _artist, filterWikiEditorCommentary) => ({ chunks: query.chunks .map(() => relation('generateArtistInfoPageChunk')), @@ -178,13 +173,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 +230,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 +255,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..eb32cebf --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageFirstReleaseTooltip.js @@ -0,0 +1,67 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import {stitchArrays} from '#sugar'; + +export default { + query: (track) => ({ + rereleases: + sortAlbumsTracksChronologically(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/generateArtistInfoPageFlashesChunk.js b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js index 8aa7223a..acdb9897 100644 --- a/src/content/dependencies/generateArtistInfoPageFlashesChunk.js +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunk.js @@ -1,10 +1,4 @@ export default { - contentDependencies: [ - 'generateArtistInfoPageChunk', - 'generateArtistInfoPageFlashesChunkItem', - 'linkFlashAct', - ], - relations: (relation, flashAct, contribs) => ({ template: relation('generateArtistInfoPageChunk'), diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js index e4908bf9..36d7945d 100644 --- a/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkItem.js @@ -1,8 +1,4 @@ export default { - contentDependencies: ['generateArtistInfoPageChunkItem', 'linkFlash'], - - extraDependencies: ['language'], - relations: (relation, contrib) => ({ // Flashes and games can list multiple contributors as collaborative // credits, but we don't display these on the artist page, since they diff --git a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js index b347faf5..762386a2 100644 --- a/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageFlashesChunkedList.js @@ -3,13 +3,6 @@ import {sortContributionsChronologically, sortFlashesChronologically} import {chunkByConditions, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunkedList', - 'generateArtistInfoPageFlashesChunk', - ], - - extraDependencies: ['wikiData'], - sprawl: ({wikiInfo}) => ({ enableFlashesAndGames: wikiInfo.enableFlashesAndGames, diff --git a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js index dcee9c00..afb61c33 100644 --- a/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js +++ b/src/content/dependencies/generateArtistInfoPageOtherArtistLinks.js @@ -1,8 +1,6 @@ import {unique} from '#sugar'; export default { - contentDependencies: ['linkArtist'], - query(contribs) { const associatedContributionsByOtherArtists = contribs diff --git a/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js new file mode 100644 index 00000000..70bada19 --- /dev/null +++ b/src/content/dependencies/generateArtistInfoPageRereleaseTooltip.js @@ -0,0 +1,53 @@ +import {sortAlbumsTracksChronologically} from '#sort'; + +export default { + query: (track) => ({ + firstRelease: + sortAlbumsTracksChronologically(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..3e4cc4e9 100644 --- a/src/content/dependencies/generateArtistInfoPageTracksChunk.js +++ b/src/content/dependencies/generateArtistInfoPageTracksChunk.js @@ -2,12 +2,6 @@ import {unique} from '#sugar'; import {getTotalDuration} from '#wiki-data'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunk', - 'generateArtistInfoPageTracksChunkItem', - 'linkAlbum', - ], - relations: (relation, artist, album, trackContribLists) => ({ template: relation('generateArtistInfoPageChunk'), @@ -40,7 +34,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..895cc0d8 100644 --- a/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkItem.js @@ -1,15 +1,8 @@ +import {sortAlbumsTracksChronologically} from '#sort'; import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunkItem', - 'generateArtistInfoPageOtherArtistLinks', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], - - query (_artist, contribs) { + query(_artist, contribs) { const query = {}; // TODO: Very mysterious what to do if the set of contributions is, @@ -19,11 +12,11 @@ export default { const creditedAsArtist = contribs - .some(contrib => contrib.isArtistContribution); + .some(contrib => contrib.thingProperty === 'artistContribs'); const creditedAsContributor = contribs - .some(contrib => contrib.isContributorContribution); + .some(contrib => contrib.thingProperty === 'contributorContribs'); const annotatedContribs = contribs @@ -31,11 +24,11 @@ export default { const annotatedArtistContribs = annotatedContribs - .filter(contrib => contrib.isArtistContribution); + .filter(contrib => contrib.thingProperty === 'artistContribs'); const annotatedContributorContribs = annotatedContribs - .filter(contrib => contrib.isContributorContribution); + .filter(contrib => contrib.thingProperty === 'contributorContribs'); // Don't display annotations associated with crediting in the // Contributors field if the artist is also credited as an Artist @@ -61,6 +54,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 = + sortAlbumsTracksChronologically(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 +86,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 +112,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..15588ed3 100644 --- a/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageTracksChunkedList.js @@ -1,13 +1,9 @@ import {sortAlbumsTracksChronologically, sortContributionsChronologically} from '#sort'; -import {chunkByConditions, stitchArrays} from '#sugar'; +import {stitchArrays} from '#sugar'; +import {chunkArtistTrackContributions} from '#wiki-data'; export default { - contentDependencies: [ - 'generateArtistInfoPageChunkedList', - 'generateArtistInfoPageTracksChunk', - ], - query(artist) { const query = {}; @@ -21,19 +17,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 +42,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/generateArtistNavLinks.js b/src/content/dependencies/generateArtistNavLinks.js index 1b4b6eca..69ae3e19 100644 --- a/src/content/dependencies/generateArtistNavLinks.js +++ b/src/content/dependencies/generateArtistNavLinks.js @@ -1,14 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'linkArtist', - 'linkArtistGallery', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({ enableListings: wikiInfo.enableListings, @@ -34,6 +26,9 @@ export default { (query.hasGallery ? relation('linkArtistGallery', artist) : null), + + artistRollingWindowLink: + relation('linkArtistRollingWindow', artist), }), data: (_query, sprawl) => ({ @@ -45,7 +40,7 @@ export default { showExtraLinks: {type: 'boolean', default: false}, currentExtra: { - validate: v => v.is('gallery'), + validate: v => v.is('gallery', 'rolling-window'), }, }, @@ -79,6 +74,7 @@ export default { }), slots.showExtraLinks && + slots.currentExtra !== 'rolling-window' && relations.artistGalleryLink?.slots({ attributes: [ slots.currentExtra === 'gallery' && @@ -87,6 +83,12 @@ export default { content: language.$('misc.nav.gallery'), }), + + slots.currentExtra === 'rolling-window' && + relations.artistRollingWindowLink.slots({ + attributes: {class: 'current'}, + content: language.$('misc.nav.rollingWindow'), + }), ], }), }, diff --git a/src/content/dependencies/generateArtistRollingWindowPage.js b/src/content/dependencies/generateArtistRollingWindowPage.js new file mode 100644 index 00000000..aafd1b55 --- /dev/null +++ b/src/content/dependencies/generateArtistRollingWindowPage.js @@ -0,0 +1,418 @@ +import {sortAlbumsTracksChronologically} from '#sort'; +import Thing from '#thing'; + +import { + chunkByConditions, + filterMultipleArrays, + empty, + sortMultipleArrays, + stitchArrays, + unique, +} from '#sugar'; + +export default { + sprawl: ({groupCategoryData}) => ({ + groupCategoryData, + }), + + query(sprawl, artist) { + const query = {}; + + const musicContributions = + artist.musicContributions + .filter(contrib => contrib.date); + + const artworkContributions = + artist.artworkContributions + .filter(contrib => + contrib.date && + contrib.thingProperty !== 'wallpaperArtistContribs' && + contrib.thingProperty !== 'bannerArtistContribs'); + + const musicThings = + musicContributions + .map(contrib => contrib.thing); + + const artworkThings = + artworkContributions + .map(contrib => contrib.thing.thing); + + const musicContributionDates = + musicContributions + .map(contrib => contrib.date); + + const artworkContributionDates = + artworkContributions + .map(contrib => contrib.date); + + const musicContributionKinds = + musicContributions + .map(() => 'music'); + + const artworkContributionKinds = + artworkContributions + .map(() => 'artwork'); + + const allThings = [ + ...artworkThings, + ...musicThings, + ]; + + const allContributionDates = [ + ...artworkContributionDates, + ...musicContributionDates, + ]; + + const allContributionKinds = [ + ...artworkContributionKinds, + ...musicContributionKinds, + ]; + + const sortedThings = + sortAlbumsTracksChronologically(allThings.slice(), {latestFirst: true}); + + sortMultipleArrays( + allThings, + allContributionDates, + allContributionKinds, + (thing1, thing2) => + sortedThings.indexOf(thing1) - + sortedThings.indexOf(thing2)); + + const sourceIndices = + Array.from({length: allThings.length}, (_, i) => i); + + const sourceChunks = + chunkByConditions(sourceIndices, [ + (index1, index2) => + allThings[index1] !== + allThings[index2], + ]); + + const indicesTo = array => index => array[index]; + + query.things = + sourceChunks + .map(chunks => allThings[chunks[0]]); + + query.thingGroups = + query.things.map(thing => + (thing.constructor[Thing.referenceType] === 'album' + ? thing.groups + : thing.constructor[Thing.referenceType] === 'track' + ? thing.album.groups + : null)); + + query.thingContributionDates = + sourceChunks + .map(indices => indices + .map(indicesTo(allContributionDates))); + + query.thingContributionKinds = + sourceChunks + .map(indices => indices + .map(indicesTo(allContributionKinds))); + + // Matches the "kind" dropdown. + const kinds = ['artwork', 'music', 'flash']; + + const allKinds = + unique(query.thingContributionKinds.flat(2)); + + query.kinds = + kinds + .filter(kind => allKinds.includes(kind)); + + query.firstKind = + query.kinds.at(0); + + query.thingArtworks = + stitchArrays({ + thing: query.things, + kinds: query.thingContributionKinds, + }).map(({thing, kinds}) => + (kinds.includes('artwork') + ? (thing.coverArtworks ?? thing.trackArtworks ?? []) + .find(artwork => artwork.artistContribs + .some(contrib => contrib.artist === artist)) + : (thing.coverArtworks ?? thing.trackArtworks)?.[0] ?? + thing.album?.coverArtworks[0] ?? + null)); + + const allGroups = + unique(query.thingGroups.flat()); + + query.groupCategories = + sprawl.groupCategoryData.slice(); + + query.groupCategoryGroups = + sprawl.groupCategoryData + .map(category => category.groups + .filter(group => allGroups.includes(group))); + + filterMultipleArrays( + query.groupCategories, + query.groupCategoryGroups, + (_category, groups) => !empty(groups)); + + const groupsMatchingFirstKind = + unique( + stitchArrays({ + thing: query.things, + groups: query.thingGroups, + kinds: query.thingContributionKinds, + }).filter(({kinds}) => kinds.includes(query.firstKind)) + .flatMap(({groups}) => groups)); + + query.firstGroup = + sprawl.groupCategoryData + .flatMap(category => category.groups) + .find(group => groupsMatchingFirstKind.includes(group)); + + query.firstGroupCategory = + query.firstGroup.category; + + return query; + }, + + relations: (relation, query, sprawl, artist) => ({ + layout: + relation('generatePageLayout'), + + artistNavLinks: + relation('generateArtistNavLinks', artist), + + sourceGrid: + relation('generateCoverGrid'), + + sourceGridImages: + query.thingArtworks + .map(artwork => relation('image', artwork)), + + sourceGridLinks: + query.things + .map(thing => relation('linkAnythingMan', thing)), + }), + + data: (query, sprawl, artist) => ({ + name: + artist.name, + + categoryGroupDirectories: + query.groupCategoryGroups + .map(groups => groups + .map(group => group.directory)), + + categoryGroupNames: + query.groupCategoryGroups + .map(groups => groups + .map(group => group.name)), + + firstGroupCategoryIndex: + query.groupCategories + .indexOf(query.firstGroupCategory), + + firstGroupIndex: + stitchArrays({ + category: query.groupCategories, + groups: query.groupCategoryGroups, + }).find(({category}) => category === query.firstGroupCategory) + .groups + .indexOf(query.firstGroup), + + kinds: + query.kinds, + + sourceGridNames: + query.things + .map(thing => thing.name), + + sourceGridGroupDirectories: + query.thingGroups + .map(groups => groups + .map(group => group.directory)), + + sourceGridGroupNames: + query.thingGroups + .map(groups => groups + .map(group => group.name)), + + sourceGridContributionKinds: + query.thingContributionKinds, + + sourceGridContributionDates: + query.thingContributionDates, + }), + + generate: (data, relations, {html, language}) => + relations.layout.slots({ + title: + language.$('artistRollingWindowPage.title', { + artist: data.name, + }), + + mainClasses: ['top-index'], + mainContent: [ + html.tag('p', {id: 'timeframe-configuration'}, + language.$('artistRollingWindowPage.windowConfigurationLine', { + timeBefore: + language.$('artistRollingWindowPage.timeframe.months', { + input: + html.tag('input', {id: 'timeframe-months-before'}, + {type: 'number'}, + {value: 3, min: 0}), + }), + + timeAfter: + language.$('artistRollingWindowPage.timeframe.months', { + input: + html.tag('input', {id: 'timeframe-months-after'}, + {type: 'number'}, + {value: 3, min: 1}), + }), + + peek: + language.$('artistRollingWindowPage.timeframe.months', { + input: + html.tag('input', {id: 'timeframe-months-peek'}, + {type: 'number'}, + {value: 1, min: 0}), + }), + })), + + html.tag('p', {id: 'contribution-configuration'}, + language.$('artistRollingWindowPage.contributionConfigurationLine', { + kind: + html.tag('select', {id: 'contribution-kind'}, + data.kinds.map(kind => + html.tag('option', {value: kind}, + language.$('artistRollingWindowPage.contributionKind', kind)))), + + group: + html.tag('select', {id: 'contribution-group'}, [ + html.tag('option', {value: '-'}, + language.$('artistRollingWindowPage.contributionGroup.all')), + + stitchArrays({ + names: data.categoryGroupNames, + directories: data.categoryGroupDirectories, + }).map(({names, directories}, categoryIndex) => [ + html.tag('hr'), + + stitchArrays({name: names, directory: directories}) + .map(({name, directory}, groupIndex) => + html.tag('option', {value: directory}, + categoryIndex === data.firstGroupCategoryIndex && + groupIndex === data.firstGroupIndex && + {selected: true}, + + language.$('artistRollingWindowPage.contributionGroup.group', { + group: name, + }))), + ]), + ]), + })), + + html.tag('p', {id: 'timeframe-selection-info'}, [ + html.tag('span', {id: 'timeframe-selection-some'}, + {style: 'display: none'}, + + language.$('artistRollingWindowPage.timeframeSelectionLine', { + contributions: + html.tag('b', {id: 'timeframe-selection-contribution-count'}), + + timeframes: + html.tag('b', {id: 'timeframe-selection-timeframe-count'}), + + firstDate: + html.tag('b', {id: 'timeframe-selection-first-date'}), + + lastDate: + html.tag('b', {id: 'timeframe-selection-last-date'}), + })), + + html.tag('span', {id: 'timeframe-selection-none'}, + {style: 'display: none'}, + language.$('artistRollingWindowPage.timeframeSelectionLine.none')), + ]), + + html.tag('p', {id: 'timeframe-selection-control'}, + {style: 'display: none'}, + + language.$('artistRollingWindowPage.timeframeSelectionControl', { + timeframes: + html.tag('select', {id: 'timeframe-selection-menu'}), + + previous: + html.tag('a', {id: 'timeframe-selection-previous'}, + {href: '#'}, + language.$('artistRollingWindowPage.timeframeSelectionControl.previous')), + + next: + html.tag('a', {id: 'timeframe-selection-next'}, + {href: '#'}, + language.$('artistRollingWindowPage.timeframeSelectionControl.next')), + })), + + html.tag('div', {id: 'timeframe-source-area'}, [ + html.tag('p', {id: 'timeframe-empty'}, + {style: 'display: none'}, + language.$('artistRollingWindowPage.emptyTimeframeLine')), + + relations.sourceGrid.slots({ + attributes: {style: 'display: none'}, + + lazy: true, + + links: + relations.sourceGridLinks.map(link => + link.slot('attributes', {target: '_blank'})), + + names: + data.sourceGridNames, + + images: + relations.sourceGridImages, + + info: + stitchArrays({ + contributionKinds: data.sourceGridContributionKinds, + contributionDates: data.sourceGridContributionDates, + groupDirectories: data.sourceGridGroupDirectories, + groupNames: data.sourceGridGroupNames, + }).map(({ + contributionKinds, + contributionDates, + groupDirectories, + groupNames, + }) => [ + stitchArrays({ + directory: groupDirectories, + name: groupNames, + }).map(({directory, name}) => + html.tag('data', {class: 'contribution-group'}, + {value: directory}, + name)), + + stitchArrays({ + kind: contributionKinds, + date: contributionDates, + }).map(({kind, date}) => + html.tag('time', {class: `${kind}-contribution-date`}, + {datetime: date.toUTCString()}, + language.formatDate(date))), + ]), + }), + ]), + ], + + navLinkStyle: 'hierarchical', + navLinks: + relations.artistNavLinks + .slots({ + showExtraLinks: true, + currentExtra: 'rolling-window', + }) + .content, + }), +} diff --git a/src/content/dependencies/generateBackToAlbumLink.js b/src/content/dependencies/generateBackToAlbumLink.js index 6648b463..08d33348 100644 --- a/src/content/dependencies/generateBackToAlbumLink.js +++ b/src/content/dependencies/generateBackToAlbumLink.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkAlbum'], - extraDependencies: ['language'], - relations: (relation, track) => ({ trackLink: relation('linkAlbum', track), diff --git a/src/content/dependencies/generateBackToTrackLink.js b/src/content/dependencies/generateBackToTrackLink.js index 8677d811..90dfb6d5 100644 --- a/src/content/dependencies/generateBackToTrackLink.js +++ b/src/content/dependencies/generateBackToTrackLink.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkTrack'], - extraDependencies: ['language'], - relations: (relation, track) => ({ trackLink: relation('linkTrack', track), diff --git a/src/content/dependencies/generateBanner.js b/src/content/dependencies/generateBanner.js index 15eb08eb..509b15c2 100644 --- a/src/content/dependencies/generateBanner.js +++ b/src/content/dependencies/generateBanner.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'to'], - slots: { path: { validate: v => v.validateArrayItems(v.isString), diff --git a/src/content/dependencies/generateCollapsedContentEntrySection.js b/src/content/dependencies/generateCollapsedContentEntrySection.js new file mode 100644 index 00000000..aec5fe28 --- /dev/null +++ b/src/content/dependencies/generateCollapsedContentEntrySection.js @@ -0,0 +1,37 @@ +export default { + relations: (relation, entries, thing) => ({ + contentContentHeading: + relation('generateContentContentHeading', thing), + + entries: + entries + .map(entry => relation('generateCommentaryEntry', entry)), + }), + + slots: { + id: {type: 'string'}, + string: {type: 'string'}, + }, + + generate: (relations, slots, {html}) => + html.tag('details', + {[html.onlyIfContent]: true}, + + slots.id && [ + {class: 'memorable'}, + {'data-memorable-id': slots.id}, + ], + + [ + relations.contentContentHeading.slots({ + attributes: [ + slots.id && {id: slots.id}, + ], + + string: slots.string, + summary: true, + }), + + relations.entries, + ]), +}; diff --git a/src/content/dependencies/generateColorStyleAttribute.js b/src/content/dependencies/generateColorStyleAttribute.js index 03d95ac5..277ec434 100644 --- a/src/content/dependencies/generateColorStyleAttribute.js +++ b/src/content/dependencies/generateColorStyleAttribute.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateColorStyleVariables'], - extraDependencies: ['html'], - relations: (relation) => ({ colorVariables: relation('generateColorStyleVariables'), 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..b378fd1d --- /dev/null +++ b/src/content/dependencies/generateColorStyleTag.js @@ -0,0 +1,48 @@ +export default { + 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..0865ed3e 100644 --- a/src/content/dependencies/generateColorStyleVariables.js +++ b/src/content/dependencies/generateColorStyleVariables.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'getColors'], - slots: { color: { validate: v => v.isColor, @@ -18,7 +16,7 @@ export default { }, mode: { - validate: v => v.is('style', 'property-list'), + validate: v => v.is('style', 'declarations'), default: 'style', }, }, @@ -50,15 +48,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 +65,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 +80,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/generateCommentaryContentHeading.js b/src/content/dependencies/generateCommentaryContentHeading.js new file mode 100644 index 00000000..691762aa --- /dev/null +++ b/src/content/dependencies/generateCommentaryContentHeading.js @@ -0,0 +1,43 @@ +import {empty} from '#sugar'; + +export default { + query: (thing) => ({ + entries: + (thing.isTrack + ? [...thing.commentary, ...thing.commentaryFromMainRelease] + : thing.commentary), + }), + + relations: (relation, _query, thing) => ({ + contentContentHeading: + relation('generateContentContentHeading', thing), + }), + + data: (query, _thing) => ({ + hasWikiEditorCommentary: + query.entries.some(entry => entry.isWikiEditorCommentary), + + onlyWikiEditorCommentary: + !empty(query.entries) && + query.entries.every(entry => entry.isWikiEditorCommentary), + + hasAnyCommentary: + !empty(query.entries), + }), + + generate: (data, relations, {language}) => + relations.contentContentHeading.slots({ + // It's #artist-commentary for legacy reasons... Sorry... + attributes: {id: 'artist-commentary'}, + + string: + language.encapsulate('misc.artistCommentary', capsule => + (data.onlyWikiEditorCommentary + ? language.encapsulate(capsule, 'onlyWikiCommentary') + : data.hasWikiEditorCommentary + ? language.encapsulate(capsule, 'withWikiCommentary') + : data.hasAnyCommentary + ? capsule + : null)), + }), +}; diff --git a/src/content/dependencies/generateCommentaryEntry.js b/src/content/dependencies/generateCommentaryEntry.js index 9243a89c..38eb6b43 100644 --- a/src/content/dependencies/generateCommentaryEntry.js +++ b/src/content/dependencies/generateCommentaryEntry.js @@ -1,25 +1,16 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateCommentaryEntryDate', - 'generateColorStyleAttribute', - 'linkArtist', - 'transformContent', - ], - - extraDependencies: ['html', 'language'], - 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: @@ -39,11 +30,16 @@ export default { relation('generateCommentaryEntryDate', entry), }), + data: (entry) => ({ + isWikiEditorCommentary: + entry.isWikiEditorCommentary, + }), + slots: { color: {validate: v => v.isColor}, }, - generate: (relations, slots, {html, language}) => + generate: (data, relations, slots, {html, language}) => language.encapsulate('misc.artistCommentary.entry', entryCapsule => html.tags([ html.tag('p', {class: 'commentary-entry-heading'}, @@ -51,6 +47,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 => { @@ -104,6 +103,9 @@ export default { relations.colorStyle.clone() .slot('color', slots.color), + data.isWikiEditorCommentary && + {class: 'wiki-commentary'}, + relations.bodyContent.slot('mode', 'multiline')), ])), }; diff --git a/src/content/dependencies/generateCommentaryEntryDate.js b/src/content/dependencies/generateCommentaryEntryDate.js index f1cf5cb3..e924f244 100644 --- a/src/content/dependencies/generateCommentaryEntryDate.js +++ b/src/content/dependencies/generateCommentaryEntryDate.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateTextWithTooltip', 'generateTooltip'], - extraDependencies: ['html', 'language'], - relations: (relation, _entry) => ({ textWithTooltip: relation('generateTextWithTooltip'), diff --git a/src/content/dependencies/generateCommentaryIndexPage.js b/src/content/dependencies/generateCommentaryIndexPage.js index d68ba42e..4da3ecb9 100644 --- a/src/content/dependencies/generateCommentaryIndexPage.js +++ b/src/content/dependencies/generateCommentaryIndexPage.js @@ -2,9 +2,6 @@ import {sortChronologically} from '#sort'; import {accumulateSum, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generatePageLayout', 'linkAlbumCommentary'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, 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..9ed2d9f0 --- /dev/null +++ b/src/content/dependencies/generateContentContentHeading.js @@ -0,0 +1,73 @@ +export default { + relations: (relation, _thing) => ({ + contentHeading: + relation('generateContentHeading'), + }), + + data: (thing) => ({ + name: + (thing + ? thing.name + : null), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + + string: { + type: 'string', + }, + + summary: { + type: 'boolean', + default: false, + }, + }, + + generate: (data, relations, slots, {html, language}) => + relations.contentHeading.slots({ + attributes: slots.attributes, + + title: + (() => { + if (!slots.string) return html.blank(); + + const options = {}; + + if (slots.summary) { + options.cue = + html.tag('span', {class: 'cue'}, + language.$(slots.string, 'cue')); + } + + if (data.name) { + options.thing = html.tag('i', data.name); + } + + if (slots.summary) { + return html.tags([ + html.tag('span', {class: 'when-open'}, + language.$(slots.string, options)), + + html.tag('span', {class: 'when-collapsed'}, + language.$(slots.string, 'collapsed', options)), + ]); + } else { + return language.$(slots.string, options); + } + })(), + + stickyTitle: + (slots.string + ? language.$(slots.string, 'sticky') + : html.blank()), + + tag: + (slots.summary + ? 'summary' + : 'p'), + }), +}; diff --git a/src/content/dependencies/generateContentHeading.js b/src/content/dependencies/generateContentHeading.js index f52bc043..a7cf201f 100644 --- a/src/content/dependencies/generateContentHeading.js +++ b/src/content/dependencies/generateContentHeading.js @@ -1,7 +1,5 @@ export default { extraDependencies: ['html'], - contentDependencies: ['generateColorStyleAttribute'], - relations: (relation) => ({ colorStyle: relation('generateColorStyleAttribute'), }), diff --git a/src/content/dependencies/generateContributionList.js b/src/content/dependencies/generateContributionList.js index d1c3de0f..3716bcd6 100644 --- a/src/content/dependencies/generateContributionList.js +++ b/src/content/dependencies/generateContributionList.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkContribution'], - extraDependencies: ['html'], - relations: (relation, contributions) => ({ contributionLinks: contributions diff --git a/src/content/dependencies/generateContributionTooltip.js b/src/content/dependencies/generateContributionTooltip.js index 3a31014d..fd16371b 100644 --- a/src/content/dependencies/generateContributionTooltip.js +++ b/src/content/dependencies/generateContributionTooltip.js @@ -1,21 +1,79 @@ -export default { - contentDependencies: [ - 'generateContributionTooltipChronologySection', - 'generateContributionTooltipExternalLinkSection', - 'generateTooltip', - ], +function compareReleaseContributions(a, b) { + if (a === b) { + return true; + } + + const {previous: aPrev, next: aNext} = getSiblings(a); + const {previous: bPrev, next: bNext} = getSiblings(b); + + const effective = contrib => + (contrib?.thing.isAlbum && contrib.thing.style === 'single' + ? contrib.thing.tracks[0] + : contrib?.thing); + + return ( + effective(aPrev) === effective(bPrev) && + effective(aNext) === effective(bNext) + ); +} - extraDependencies: ['html'], +function getSiblings(contribution) { + let previous = contribution; + while (previous && previous.thing === contribution.thing) { + previous = previous.previousBySameArtist; + } - relations: (relation, contribution) => ({ + let next = contribution; + while (next && next.thing === contribution.thing) { + next = next.nextBySameArtist; + } + + return {previous, next}; +} + +export default { + query: (contribution) => ({ + albumArtistContribution: + (contribution.thing.isTrack + ? contribution.thing.album.artistContribs + .find(artistContrib => artistContrib.artist === contribution.artist) + : null), + }), + + relations: (relation, query, contribution) => ({ tooltip: relation('generateTooltip'), externalLinkSection: relation('generateContributionTooltipExternalLinkSection', contribution), - chronologySection: + ownChronologySection: relation('generateContributionTooltipChronologySection', contribution), + + artistReleaseChronologySection: + (query.albumArtistContribution + ? relation('generateContributionTooltipChronologySection', + query.albumArtistContribution) + : null), + }), + + data: (query, contribution) => ({ + artistName: + contribution.artist.name, + + isAlbumArtistContribution: + contribution.thing.isAlbum && + contribution.thingProperty === 'artistContribs', + + isSingleTrackArtistContribution: + contribution.thing.isTrack && + contribution.thingProperty === 'artistContribs' && + contribution.thing.album.style === 'single', + + artistReleaseChronologySectionDiffers: + (query.albumArtistContribution + ? !compareReleaseContributions(contribution, query.albumArtistContribution) + : null), }), slots: { @@ -25,24 +83,64 @@ export default { chronologyKind: {type: 'string'}, }, - generate: (relations, slots, {html}) => - relations.tooltip.slots({ - attributes: - {class: 'contribution-tooltip'}, - - contentAttributes: { - [html.joinChildren]: - html.tag('span', {class: 'tooltip-divider'}), - }, - - content: [ - slots.showExternalLinks && - relations.externalLinkSection, - - slots.showChronology && - relations.chronologySection.slots({ - kind: slots.chronologyKind, - }), - ], - }), + generate: (data, relations, slots, {html, language}) => + language.encapsulate('misc.artistLink', capsule => + relations.tooltip.slots({ + attributes: + {class: 'contribution-tooltip'}, + + contentAttributes: { + [html.joinChildren]: + html.tag('span', {class: 'tooltip-divider'}), + }, + + content: [ + slots.showExternalLinks && + relations.externalLinkSection, + + slots.showChronology && + language.encapsulate(capsule, 'chronology', capsule => { + const chronologySections = []; + + if (data.isAlbumArtistContribution) { + relations.ownChronologySection.setSlots({ + kind: 'release', + heading: + language.$(capsule, 'heading.artistReleases', { + artist: data.artistName, + }), + }); + } else { + relations.ownChronologySection.setSlot('kind', slots.chronologyKind); + } + + if ( + data.isSingleTrackArtistContribution && + relations.artistReleaseChronologySection + ) { + relations.artistReleaseChronologySection.setSlot('kind', 'release'); + + relations.artistReleaseChronologySection.setSlot('heading', + language.$(capsule, 'heading.artistReleases', { + artist: data.artistName, + })); + + chronologySections.push(relations.artistReleaseChronologySection); + + if (data.artistReleaseChronologySectionDiffers) { + relations.ownChronologySection.setSlot('heading', + language.$(capsule, 'heading.artistTracks', { + artist: data.artistName, + })); + + chronologySections.push(relations.ownChronologySection); + } + } else { + chronologySections.push(relations.ownChronologySection); + } + + return chronologySections; + }), + ], + })), }; diff --git a/src/content/dependencies/generateContributionTooltipChronologySection.js b/src/content/dependencies/generateContributionTooltipChronologySection.js index 78c9051c..e4b9bfda 100644 --- a/src/content/dependencies/generateContributionTooltipChronologySection.js +++ b/src/content/dependencies/generateContributionTooltipChronologySection.js @@ -1,20 +1,33 @@ -export default { - contentDependencies: ['linkAnythingMan'], - extraDependencies: ['html', 'language'], +function getName(thing) { + if (!thing) { + return null; + } - query(contribution) { - let previous = contribution; - while (previous && previous.thing === contribution.thing) { - previous = previous.previousBySameArtist; - } + if (thing.isArtwork) { + return thing.thing.name; + } - let next = contribution; - while (next && next.thing === contribution.thing) { - next = next.nextBySameArtist; - } + return thing.name; +} - return {previous, next}; - }, +function getSiblings(contribution) { + let previous = contribution; + while (previous && previous.thing === contribution.thing) { + previous = previous.previousBySameArtist; + } + + let next = contribution; + while (next && next.thing === contribution.thing) { + next = next.nextBySameArtist; + } + + return {previous, next}; +} + +export default { + query: (contribution) => ({ + ...getSiblings(contribution), + }), relations: (relation, query, _contribution) => ({ previousLink: @@ -30,34 +43,26 @@ 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: { - kind: { - validate: v => - v.is( - 'album', - 'bannerArt', - 'coverArt', - 'flash', - 'track', - 'trackArt', - 'trackContribution', - 'wallpaperArt'), - }, + heading: {type: 'html', mutable: false}, + kind: {type: 'string'}, }, generate: (data, relations, slots, {html, language}) => language.encapsulate('misc.artistLink.chronology', capsule => html.tags([ + html.tag('span', {class: 'chronology-heading'}, + {[html.onlyIfContent]: true}, + {[html.onlyIfSiblings]: true}, + + slots.heading), + html.tags([ relations.previousLink?.slots({ attributes: {class: 'chronology-link'}, diff --git a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js index 4f9a23ed..210db1e9 100644 --- a/src/content/dependencies/generateContributionTooltipExternalLinkSection.js +++ b/src/content/dependencies/generateContributionTooltipExternalLinkSection.js @@ -1,14 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateExternalHandle', - 'generateExternalIcon', - 'generateExternalPlatform', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, contribution) => ({ icons: contribution.artist.urls diff --git a/src/content/dependencies/generateCoverArtwork.js b/src/content/dependencies/generateCoverArtwork.js index 50ca89ae..89b66ce0 100644 --- a/src/content/dependencies/generateCoverArtwork.js +++ b/src/content/dependencies/generateCoverArtwork.js @@ -1,11 +1,49 @@ export default { - contentDependencies: ['image', 'linkArtistGallery'], - 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, + + style: + artwork.style, + }), slots: { - image: { - type: 'html', - mutable: true, + alt: {type: 'string'}, + + color: { + validate: v => v.anyOf(v.isBoolean, v.isColor), + default: false, }, mode: { @@ -13,13 +51,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 +62,96 @@ export default { }, }, - generate(slots, {html}) { + generate(data, relations, slots, {html}) { + const {image} = relations; + + const imgAttributes = html.attributes(); + + if (data.style) { + imgAttributes.add('style', data.style.split('\n').join(' ')); + } + + image.setSlot('imgAttributes', imgAttributes); + + 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..50571a4f 100644 --- a/src/content/dependencies/generateCoverArtworkArtTagDetails.js +++ b/src/content/dependencies/generateCoverArtworkArtTagDetails.js @@ -1,22 +1,39 @@ -import {stitchArrays} from '#sugar'; +import {compareArrays, empty, stitchArrays} from '#sugar'; -export default { - contentDependencies: ['linkArtTag'], - extraDependencies: ['html'], +function linkable(tag) { + return !tag.isContentWarning; +} - query: (artTags) => ({ +export default { + 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 +45,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..2773c6fc 100644 --- a/src/content/dependencies/generateCoverArtworkArtistDetails.js +++ b/src/content/dependencies/generateCoverArtworkArtistDetails.js @@ -1,10 +1,7 @@ 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 +14,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..db18e9e4 --- /dev/null +++ b/src/content/dependencies/generateCoverArtworkOriginDetails.js @@ -0,0 +1,176 @@ +export default { + query: (artwork) => ({ + 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: + (artwork.thing.isAlbum + ? relation('linkAlbum', artwork.thing) + : null), + + datetimestamp: + (artwork.date && artwork.date !== artwork.thing.date + ? relation('generateAbsoluteDatetimestamp', artwork.date) + : null), + }), + + + data: (query, artwork) => ({ + label: + artwork.label, + + forAlbum: + artwork.thing.isAlbum, + + forSingleStyleAlbum: + artwork.thing.isAlbum && + artwork.thing.style === 'single', + + showFilename: + artwork.showFilename, + }), + + generate: (data, relations, {html, language, pagePath}) => + language.encapsulate('misc.coverArtwork', capsule => + html.tag('p', {class: 'image-details'}, + {[html.onlyIfContent]: true}, + + {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.forAlbum && + !data.forSingleStyleAlbum && + 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 originDetailsLine = + html.tag('span', {class: 'origin-details-line'}, + {[html.onlyIfContent]: true}, + + relations.originDetails.slots({ + mode: 'inline', + absorbPunctuationFollowingExternalLinks: false, + })); + + const filenameLine = + html.tag('span', {class: 'filename-line'}, + {[html.onlyIfContent]: true}, + + html.tag('code', {class: 'filename'}, + {[html.onlyIfContent]: true}, + + language.sanitize(data.showFilename))); + + return [ + html.tags([ + artworkBy, + trackArtFromAlbum, + source, + label, + year, + ], {[html.joinChildren]: html.tag('br')}), + + originDetailsLine, + filenameLine, + ]; + })())), +}; diff --git a/src/content/dependencies/generateCoverArtworkReferenceDetails.js b/src/content/dependencies/generateCoverArtworkReferenceDetails.js index 006b2b4b..d4e4e7e4 100644 --- a/src/content/dependencies/generateCoverArtworkReferenceDetails.js +++ b/src/content/dependencies/generateCoverArtworkReferenceDetails.js @@ -1,20 +1,21 @@ export default { - extraDependencies: ['html', 'language'], + relations: (relation, artwork) => ({ + referencedArtworksLink: + relation('linkReferencedArtworks', artwork), - data: (referenced, referencedBy) => ({ + 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 +48,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..1ffeff8e 100644 --- a/src/content/dependencies/generateCoverCarousel.js +++ b/src/content/dependencies/generateCoverCarousel.js @@ -2,24 +2,14 @@ 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 +17,7 @@ export default { }); if (empty(stitched)) { - return; + return html.blank(); } const layout = getCarouselLayoutForNumberOfItems(stitched.length); @@ -58,9 +48,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..091833a9 100644 --- a/src/content/dependencies/generateCoverGrid.js +++ b/src/content/dependencies/generateCoverGrid.js @@ -1,38 +1,146 @@ -import {stitchArrays} from '#sugar'; +import {empty, stitchArrays, unique} from '#sugar'; export default { - contentDependencies: ['generateGridActionLinks'], - extraDependencies: ['html', 'language'], + relations: (relation) => ({ + actionLinks: + relation('generateGridActionLinks'), - relations(relation) { - return { - actionLinks: relation('generateGridActionLinks'), - }; - }, + expando: + relation('generateGridExpando'), + }), slots: { + attributes: {type: 'attributes', mutable: false}, + images: {validate: v => v.strictArrayOf(v.isHTML)}, links: {validate: v => v.strictArrayOf(v.isHTML)}, names: {validate: v => v.strictArrayOf(v.isHTML)}, info: {validate: v => v.strictArrayOf(v.isHTML)}, + tab: {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))), + }, + + itemAttributes: { + validate: v => + v.strictArrayOf( + v.optional(v.isAttributes)), + }, lazy: {validate: v => v.anyOf(v.isWholeNumber, v.isBoolean)}, actionLinks: {validate: v => v.sparseArrayOf(v.isHTML)}, + + revealAllWarnings: { + validate: v => v.looseArrayOf(v.isString), + }, + + bottomCaption: { + type: 'html', + mutable: false, + }, + + cutIndex: {validate: v => v.isWholeNumber}, }, - generate(relations, slots, {html, language}) { - return ( - html.tag('div', {class: 'grid-listing'}, [ + generate: (relations, slots, {html, language}) => + html.tag('div', {class: 'grid-listing'}, + slots.attributes, + {[html.onlyIfContent]: true}, + + [ + !empty((slots.revealAllWarnings ?? []).filter(Boolean)) && + language.encapsulate('misc.coverGrid.revealAll', capsule => + html.tag('div', {class: 'reveal-all-container'}, + ((slots.tab ?? []) + .slice(0, 4) + .some(tab => tab && !html.isBlank(tab))) && + + {class: 'has-nearby-tab'}, + + html.tag('p', {class: 'reveal-all'}, [ + html.tag('a', {href: '#'}, [ + html.tag('span', {class: 'reveal-label'}, + language.$(capsule, 'reveal')), + + html.tag('span', {class: 'conceal-label'}, + {style: 'display: none'}, + language.$(capsule, 'conceal')), + ]), + + html.tag('br'), + + html.tag('span', {class: 'warnings'}, + language.$(capsule, 'warnings', { + warnings: + language.formatUnitList( + unique(slots.revealAllWarnings.filter(Boolean)) + .sort() + .map(warning => html.tag('b', warning))), + })), + ]))), + stitchArrays({ + classes: slots.classes, + attributes: slots.itemAttributes, image: slots.images, link: slots.links, name: slots.names, info: slots.info, - }).map(({image, link, name, info}, index) => + tab: slots.tab, + + notFromThisGroup: + slots.notFromThisGroup ?? + Array.from(slots.links).fill(null) + }).map(({ + classes, + attributes, + image, + link, + name, + info, + tab, + notFromThisGroup, + }, index) => link.slots({ - attributes: {class: ['grid-item', 'box']}, + attributes: [ + link.getSlotValue('attributes'), + + {class: ['grid-item', 'box']}, + + tab && + !html.isBlank(tab) && + {class: 'has-tab'}, + + attributes, + + (classes + ? {class: classes} + : null), + + slots.cutIndex >= 1 && + index >= slots.cutIndex && + {class: 'hidden-by-expandable-cut'}, + ], + colorContext: 'image-box', + content: [ + html.tag('span', + {[html.onlyIfContent]: true}, + + tab), + image.slots({ thumb: 'medium', square: true, @@ -47,7 +155,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 +178,17 @@ export default { relations.actionLinks .slot('actionLinks', slots.actionLinks), - ])); - }, + + (slots.cutIndex >= 1 && + slots.cutIndex < slots.links.length + ? relations.expando.slots({ + caption: slots.bottomCaption, + }) + + : !html.isBlank(relations.bottomCaption) + ? html.tag('p', {class: 'grid-caption'}, + slots.caption) + + : html.blank()), + ]), }; diff --git a/src/content/dependencies/generateDatetimestampTemplate.js b/src/content/dependencies/generateDatetimestampTemplate.js index d9ed036a..56b2e595 100644 --- a/src/content/dependencies/generateDatetimestampTemplate.js +++ b/src/content/dependencies/generateDatetimestampTemplate.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateTextWithTooltip'], - extraDependencies: ['html'], - relations: (relation) => ({ textWithTooltip: relation('generateTextWithTooltip'), @@ -31,8 +28,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/generateDotSwitcherTemplate.js b/src/content/dependencies/generateDotSwitcherTemplate.js index 22205922..561a44bc 100644 --- a/src/content/dependencies/generateDotSwitcherTemplate.js +++ b/src/content/dependencies/generateDotSwitcherTemplate.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { attributes: { type: 'attributes', diff --git a/src/content/dependencies/generateExternalHandle.js b/src/content/dependencies/generateExternalHandle.js index 8c0368a4..8653b177 100644 --- a/src/content/dependencies/generateExternalHandle.js +++ b/src/content/dependencies/generateExternalHandle.js @@ -1,8 +1,6 @@ import {isExternalLinkContext} from '#external-links'; export default { - extraDependencies: ['html', 'language'], - data: (url) => ({url}), slots: { diff --git a/src/content/dependencies/generateExternalIcon.js b/src/content/dependencies/generateExternalIcon.js index 637af658..03af643e 100644 --- a/src/content/dependencies/generateExternalIcon.js +++ b/src/content/dependencies/generateExternalIcon.js @@ -1,8 +1,6 @@ import {isExternalLinkContext} from '#external-links'; export default { - extraDependencies: ['html', 'language', 'to'], - data: (url) => ({url}), slots: { diff --git a/src/content/dependencies/generateExternalPlatform.js b/src/content/dependencies/generateExternalPlatform.js index c4f63ecf..b2822d64 100644 --- a/src/content/dependencies/generateExternalPlatform.js +++ b/src/content/dependencies/generateExternalPlatform.js @@ -1,8 +1,6 @@ import {isExternalLinkContext} from '#external-links'; export default { - extraDependencies: ['html', 'language'], - data: (url) => ({url}), slots: { diff --git a/src/content/dependencies/generateFlashActGalleryPage.js b/src/content/dependencies/generateFlashActGalleryPage.js index 8f174b21..896ee224 100644 --- a/src/content/dependencies/generateFlashActGalleryPage.js +++ b/src/content/dependencies/generateFlashActGalleryPage.js @@ -1,21 +1,6 @@ -import {stitchArrays} from '#sugar'; - import striptags from 'striptags'; export default { - contentDependencies: [ - 'generateCoverGrid', - 'generateFlashActNavAccent', - 'generateFlashActSidebar', - 'generatePageLayout', - 'image', - 'linkFlash', - 'linkFlashAct', - 'linkFlashIndex', - ], - - extraDependencies: ['language'], - relations: (relation, act) => ({ layout: relation('generatePageLayout'), @@ -37,7 +22,7 @@ export default { coverGridImages: act.flashes - .map(_flash => relation('image')), + .map(flash => relation('image', flash.coverArtwork)), flashLinks: act.flashes @@ -50,10 +35,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 +52,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/generateFlashActNavAccent.js b/src/content/dependencies/generateFlashActNavAccent.js index c4ec77b8..7ad46051 100644 --- a/src/content/dependencies/generateFlashActNavAccent.js +++ b/src/content/dependencies/generateFlashActNavAccent.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkFlashAct', - ], - - extraDependencies: ['wikiData'], - sprawl: ({flashActData}) => ({flashActData}), diff --git a/src/content/dependencies/generateFlashActSidebar.js b/src/content/dependencies/generateFlashActSidebar.js index 1421dde9..0d952077 100644 --- a/src/content/dependencies/generateFlashActSidebar.js +++ b/src/content/dependencies/generateFlashActSidebar.js @@ -1,10 +1,4 @@ export default { - contentDependencies: [ - 'generateFlashActSidebarCurrentActBox', - 'generateFlashActSidebarSideMapBox', - 'generatePageSidebar', - ], - relations: (relation, act, flash) => ({ sidebar: relation('generatePageSidebar'), diff --git a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js index 6d152c7c..e08582fe 100644 --- a/src/content/dependencies/generateFlashActSidebarCurrentActBox.js +++ b/src/content/dependencies/generateFlashActSidebarCurrentActBox.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generatePageSidebarBox', - 'linkFlash', - 'linkFlashAct', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, act, _flash) => ({ box: relation('generatePageSidebarBox'), diff --git a/src/content/dependencies/generateFlashActSidebarSideMapBox.js b/src/content/dependencies/generateFlashActSidebarSideMapBox.js index 7b26ef31..4b97f21d 100644 --- a/src/content/dependencies/generateFlashActSidebarSideMapBox.js +++ b/src/content/dependencies/generateFlashActSidebarSideMapBox.js @@ -1,15 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generatePageSidebarBox', - 'linkFlashAct', - 'linkFlashIndex', - ], - - extraDependencies: ['html', 'wikiData'], - sprawl: ({flashSideData}) => ({flashSideData}), relations: (relation, sprawl, _act, _flash) => ({ diff --git a/src/content/dependencies/generateFlashArtworkColumn.js b/src/content/dependencies/generateFlashArtworkColumn.js new file mode 100644 index 00000000..207c3bf3 --- /dev/null +++ b/src/content/dependencies/generateFlashArtworkColumn.js @@ -0,0 +1,9 @@ +export default { + 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..1fb286c6 100644 --- a/src/content/dependencies/generateFlashIndexPage.js +++ b/src/content/dependencies/generateFlashIndexPage.js @@ -1,17 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkFlash', - 'linkFlashAct', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({flashActData}) => ({flashActData}), query(sprawl) { @@ -53,7 +42,7 @@ export default { actCoverGridImages: query.flashActs .map(act => act.flashes - .map(() => relation('image'))), + .map(flash => relation('image', flash.coverArtwork))), }), data: (query) => ({ @@ -73,11 +62,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 +100,6 @@ export default { coverGridImages: relations.actCoverGridImages, coverGridLinks: relations.actCoverGridLinks, coverGridNames: data.actCoverGridNames, - coverGridPaths: data.actCoverGridPaths, }).map(({ colorStyle, actLink, @@ -126,7 +109,6 @@ export default { coverGridImages, coverGridLinks, coverGridNames, - coverGridPaths, }, index) => [ html.tag('h2', {id: anchor}, @@ -135,15 +117,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..86ec6648 100644 --- a/src/content/dependencies/generateFlashInfoPage.js +++ b/src/content/dependencies/generateFlashInfoPage.js @@ -1,21 +1,19 @@ import {empty} from '#sugar'; -export default { - contentDependencies: [ - 'generateCommentarySection', - 'generateContentHeading', - 'generateContributionList', - 'generateFlashActSidebar', - 'generateFlashCoverArtwork', - 'generateFlashNavAccent', - 'generatePageLayout', - 'generateTrackList', - 'linkExternal', - 'linkFlashAct', - ], - - extraDependencies: ['html', 'language'], +function checkInterrupted(which, relations, {html}) { + if ( + !html.isBlank(relations.contributorContributionList) || + !html.isBlank(relations.featuredTracksList) + ) return true; + + if (which === 'crediting-sources') { + if (!html.isBlank(relations.artistCommentaryEntries)) return true; + } + return false; +} + +export default { query(flash) { const query = {}; @@ -39,16 +37,25 @@ 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'), + commentaryContentHeading: + relation('generateCommentaryContentHeading', flash), + + readCommentaryLine: + relation('generateReadCommentaryLine', flash), + flashActLink: relation('linkFlashAct', flash.act), @@ -61,11 +68,14 @@ 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), + creditingSourcesSection: + relation('generateCollapsedContentEntrySection', + flash.creditingSources, + flash), }), 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,21 +127,16 @@ export default { {[html.joinChildren]: html.tag('br')}, language.encapsulate('releaseInfo', capsule => [ - !html.isBlank(relations.artistCommentarySection) && - language.encapsulate(capsule, 'readCommentary', capsule => - language.$(capsule, { - link: - html.tag('a', - {href: '#artist-commentary'}, - language.$(capsule, 'link')), - })), + checkInterrupted('commentary', relations, {html}) && + relations.readCommentaryLine, - !html.isBlank(relations.creditSourcesSection) && - language.encapsulate(capsule, 'readCreditSources', capsule => + checkInterrupted('crediting-sources', relations, {html}) && + !html.isBlank(relations.creditingSourcesSection) && + language.encapsulate(capsule, 'readCreditingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#credit-sources'}, + {href: '#crediting-sources'}, language.$(capsule, 'link')), })), ])), @@ -159,11 +166,14 @@ export default { }), ]), - relations.artistCommentarySection, + html.tags([ + relations.commentaryContentHeading, + relations.artistCommentaryEntries, + ]), - relations.creditSourcesSection.slots({ - id: 'credit-sources', - title: language.$('misc.creditSources'), + relations.creditingSourcesSection.slots({ + id: 'crediting-sources', + string: 'misc.creditingSources', }), ], diff --git a/src/content/dependencies/generateFlashNavAccent.js b/src/content/dependencies/generateFlashNavAccent.js index 0f5d2d6b..db9d3c1e 100644 --- a/src/content/dependencies/generateFlashNavAccent.js +++ b/src/content/dependencies/generateFlashNavAccent.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkFlash', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({flashActData}) => ({flashActData}), diff --git a/src/content/dependencies/generateFooterLocalizationLinks.js b/src/content/dependencies/generateFooterLocalizationLinks.js index dfd83aef..efa1972a 100644 --- a/src/content/dependencies/generateFooterLocalizationLinks.js +++ b/src/content/dependencies/generateFooterLocalizationLinks.js @@ -2,15 +2,6 @@ import {sortByName} from '#sort'; import {stitchArrays} from '#sugar'; export default { - extraDependencies: [ - 'defaultLanguage', - 'html', - 'language', - 'languages', - 'pagePath', - 'to', - ], - generate({ defaultLanguage, html, diff --git a/src/content/dependencies/generateGridActionLinks.js b/src/content/dependencies/generateGridActionLinks.js index f5b1aaa6..5b3f9c1e 100644 --- a/src/content/dependencies/generateGridActionLinks.js +++ b/src/content/dependencies/generateGridActionLinks.js @@ -1,22 +1,14 @@ -import {empty} from '#sugar'; - export default { - extraDependencies: ['html'], - slots: { 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/generateGridExpando.js b/src/content/dependencies/generateGridExpando.js new file mode 100644 index 00000000..5a0cbce5 --- /dev/null +++ b/src/content/dependencies/generateGridExpando.js @@ -0,0 +1,37 @@ +export default { + slots: { + caption: {type: 'html', mutable: false}, + }, + + generate: (slots, {html, language}) => + language.encapsulate('misc.coverGrid', capsule => + html.tag('div', {class: 'grid-expando'}, + {[html.onlyIfSiblings]: true}, + + html.tag('p', {class: 'grid-expando-content'}, + {[html.joinChildren]: html.tag('br')}, + + [ + html.tag('span', {class: 'grid-caption'}, + slots.caption), + + !html.isBlank(slots.contentBelowCut) && + language.$(capsule, 'expandCollapseCue', { + cue: + html.tag('a', {class: 'grid-expando-toggle'}, + {href: '#'}, + + {[html.joinChildren]: ''}, + {[html.noEdgeWhitespace]: true}, + + [ + html.tag('span', {class: 'grid-expand-cue'}, + language.$(capsule, 'expand')), + + html.tag('span', {class: 'grid-collapse-cue'}, + {style: 'display: none'}, + language.$(capsule, 'collapse')), + ]), + }), + ]))), +}; diff --git a/src/content/dependencies/generateGroupGalleryPage.js b/src/content/dependencies/generateGroupGalleryPage.js index 79746cd0..e378f8a2 100644 --- a/src/content/dependencies/generateGroupGalleryPage.js +++ b/src/content/dependencies/generateGroupGalleryPage.js @@ -1,115 +1,77 @@ import {sortChronologically} from '#sort'; -import {empty, stitchArrays} from '#sugar'; import {filterItemsForCarousel, getTotalDuration} from '#wiki-data'; export default { - contentDependencies: [ - 'generateCoverCarousel', - 'generateCoverGrid', - 'generateGroupNavLinks', - 'generateGroupSecondaryNav', - 'generateGroupSidebar', - 'generatePageLayout', - 'generateQuickDescription', - 'image', - 'linkAlbum', - 'linkListing', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - 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'); - - relations.navLinks = - relation('generateGroupNavLinks', group); - - if (sprawl.enableGroupUI) { - relations.secondaryNav = - relation('generateGroupSecondaryNav', group); - - relations.sidebar = - relation('generateGroupSidebar', group); - } - - const carouselAlbums = filterItemsForCarousel(group.featuredAlbums); + query.allTracks = + query.allAlbums.flatMap((album) => album.tracks); - if (!empty(carouselAlbums)) { - relations.coverCarousel = - relation('generateCoverCarousel'); + query.carouselAlbums = + filterItemsForCarousel(group.featuredAlbums); - 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 +83,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 +111,81 @@ 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.slots({ + showTitle: + !html.isBlank(relations.albumsBySeriesView), + }), + + 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..37c1951d --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageAlbumGrid.js @@ -0,0 +1,97 @@ +import {stitchArrays} from '#sugar'; +import {getTotalDuration} from '#wiki-data'; + +export default { + query: (albums, _group) => ({ + artworks: + albums.map(album => + (album.hasCoverArt + ? album.coverArtworks[0] + : null)), + }), + + relations: (relation, query, albums, group) => ({ + coverGrid: + relation('generateCoverGrid'), + + links: + albums + .map(album => relation('linkAlbum', album)), + + images: + query.artworks + .map(artwork => relation('image', artwork)), + + tabs: + albums + .map(album => + relation('generateGroupGalleryPageAlbumGridTab', album, group)), + }), + + data: (query, albums, group) => ({ + names: + albums.map(album => album.name), + + styles: + albums.map(album => album.style), + + tracks: + albums.map(album => album.tracks.length), + + allWarnings: + query.artworks.flatMap(artwork => artwork?.contentWarnings), + + durations: + albums.map(album => + (album.hideDuration + ? null + : getTotalDuration(album.tracks))), + + 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, + }), + })), + + itemAttributes: + data.styles.map(style => ({'data-style': style})), + + tab: relations.tabs, + + info: + stitchArrays({ + style: data.styles, + tracks: data.tracks, + duration: data.durations, + }).map(({style, tracks, duration}) => + (style === 'single' && duration + ? language.$(capsule, 'details.albumLength.single', { + time: language.formatDuration(duration), + }) + : duration + ? language.$(capsule, 'details.albumLength', { + tracks: language.countTracks(tracks, {unit: true}), + time: language.formatDuration(duration), + }) + : null)), + + revealAllWarnings: data.allWarnings, + })), +}; diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js b/src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js new file mode 100644 index 00000000..c3b860e4 --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageAlbumGridTab.js @@ -0,0 +1,79 @@ +import {empty} from '#sugar'; + +export default { + query(album, group) { + if (album.groups.length > 1) { + const contextGroup = group; + + const candidateGroupCategory = + album.groups + .filter(group => !group.excludeFromGalleryTabs) + .find(group => group.category !== contextGroup.category) + ?.category ?? + null; + + const candidateGroups = + album.groups + .filter(group => !group.excludeFromGalleryTabs) + .filter(group => group.category === candidateGroupCategory); + + if (!empty(candidateGroups)) { + return { + mode: 'groups', + notedGroups: candidateGroups, + }; + } + } + + if (!empty(album.artistContribs)) { + if ( + album.artistContribs.length === 1 && + !empty(group.closelyLinkedArtists) && + (album.artistContribs[0].artist.name === + group.closelyLinkedArtists[0].artist.name) + ) { + return {mode: null}; + } + + return { + mode: 'artists', + notedArtistContribs: album.artistContribs, + }; + } + + return {mode: null};; + }, + + relations: (relation, query, _album, _group) => ({ + artistCredit: + (query.mode === 'artists' + ? relation('generateArtistCredit', query.notedArtistContribs, []) + : null), + }), + + data: (query, _album, _group) => ({ + mode: query.mode, + + groupNames: + (query.mode === 'groups' + ? query.notedGroups.map(group => group.name) + : null), + }), + + generate: (data, relations, {language}) => + language.encapsulate('misc.coverGrid.tab', capsule => + (data.mode === 'groups' + ? language.$(capsule, 'groups', { + groups: + language.formatUnitList(data.groupNames), + }) + : data.mode === 'artists' + ? relations.artistCredit.slots({ + normalStringKey: + capsule + '.artists', + + normalFeaturingStringKey: + capsule + '.artists.featuring', + }) + : null)), +}; diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js new file mode 100644 index 00000000..75ef1048 --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageAlbumsByDateView.js @@ -0,0 +1,48 @@ +import {sortChronologically} from '#sort'; + +export default { + query: (group) => ({ + albums: + sortChronologically(group.albums.slice(), {latestFirst: true}), + }), + + relations: (relation, query, group) => ({ + styleSelector: + (group.divideAlbumsByStyle + ? relation('generateGroupGalleryPageStyleSelector', group) + : null), + + albumGrid: + relation('generateGroupGalleryPageAlbumGrid', + query.albums, + group), + }), + + slots: { + showTitle: { + type: 'boolean', + }, + + 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', [ + slots.showTitle && + html.tag('h2', + language.$(capsule, 'title')), + + relations.styleSelector, + + relations.albumGrid, + ]))), +}; diff --git a/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js new file mode 100644 index 00000000..68cf249f --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageAlbumsBySeriesView.js @@ -0,0 +1,23 @@ +export default { + 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..1aa835d6 --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageSeriesSection.js @@ -0,0 +1,138 @@ +import {sortChronologically} from '#sort'; + +export default { + query(series) { + const query = {}; + + query.albums = + sortChronologically(series.albums.slice(), {latestFirst: true}); + + query.allAlbumsDated = + series.albums.every(album => album.date); + + query.anyAlbumNotFromThisGroup = + series.albums.some(album => !album.groups.includes(series.group)); + + query.latestAlbum = + query.albums + .filter(album => album.date) + .at(0) ?? + null; + + query.earliestAlbum = + query.albums + .filter(album => album.date) + .at(-1) ?? + null; + + return query; + }, + + relations: (relation, query, series) => ({ + contentHeading: + relation('generateContentHeading'), + + grid: + relation('generateGroupGalleryPageAlbumGrid', + query.albums, + 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 => + html.tags([ + relations.contentHeading.slots({ + tag: 'h2', + title: language.sanitize(data.name), + }), + + relations.grid.slots({ + cutIndex: 4, + + bottomCaption: + 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')})), + }), + ])), +}; diff --git a/src/content/dependencies/generateGroupGalleryPageStyleSelector.js b/src/content/dependencies/generateGroupGalleryPageStyleSelector.js new file mode 100644 index 00000000..9342e50f --- /dev/null +++ b/src/content/dependencies/generateGroupGalleryPageStyleSelector.js @@ -0,0 +1,60 @@ +import {unique} from '#sugar'; + +export default { + query: (group) => ({ + styles: + unique(group.albums.map(album => album.style)), + }), + + data: (query, group) => ({ + albums: + group.albums.length, + + styles: + query.styles, + }), + + generate: (data, {html, language}) => + language.encapsulate('groupGalleryPage', pageCapsule => + (data.styles.length <= 1 + ? html.blank() + : html.tag('p', {class: 'gallery-style-selector'}, + {class: ['drop', 'shiny']}, + + language.encapsulate(pageCapsule, 'albumStyleSwitcher', capsule => [ + html.tag('span', + language.$(capsule)), + + html.tag('br'), + + html.tag('span', {class: 'styles'}, + data.styles.map(style => + html.tag('label', {'data-style': style}, [ + html.tag('input', {type: 'checkbox'}, + {checked: true}), + + html.tag('span', + language.$(capsule, style)), + ]))), + + html.tag('br'), + + html.tag('span', {class: ['count', 'all']}, + language.$(capsule, 'count.all', { + total: data.albums, + })), + + html.tag('span', {class: ['count', 'filtered']}, + {style: 'display: none'}, + + language.$(capsule, 'count.filtered', { + count: html.tag('span'), + total: data.albums, + })), + + html.tag('span', {class: ['count', 'none']}, + {style: 'display: none'}, + + language.$(capsule, 'count.none')), + ])))), +}; diff --git a/src/content/dependencies/generateGroupInfoPage.js b/src/content/dependencies/generateGroupInfoPage.js index 7b9c2afa..0f3093b2 100644 --- a/src/content/dependencies/generateGroupInfoPage.js +++ b/src/content/dependencies/generateGroupInfoPage.js @@ -1,20 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateGroupInfoPageAlbumsSection', - 'generateGroupNavLinks', - 'generateGroupSecondaryNav', - 'generateGroupSidebar', - 'generatePageLayout', - 'linkArtist', - 'linkExternal', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({ enableGroupUI: wikiInfo.enableGroupUI, diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js index df42598d..de55f33a 100644 --- a/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListByDate.js @@ -1,10 +1,6 @@ import {sortChronologically} from '#sort'; export default { - contentDependencies: ['generateGroupInfoPageAlbumsListItem'], - - extraDependencies: ['html'], - query: (group) => ({ // Typically, a latestFirst: false (default) chronological sort would be // appropriate here, but navigation between adjacent albums in a group is a diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js index bcd5d288..f8314d71 100644 --- a/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListBySeries.js @@ -1,13 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateContentHeading', - 'generateGroupInfoPageAlbumsListItem', - ], - - extraDependencies: ['html', 'language'], - query: (group) => ({ closelyLinkedArtists: group.closelyLinkedArtists diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js index 99e7e8ff..09b0a542 100644 --- a/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js +++ b/src/content/dependencies/generateGroupInfoPageAlbumsListItem.js @@ -1,16 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateAbsoluteDatetimestamp', - 'generateArtistCredit', - 'generateColorStyleAttribute', - 'linkAlbum', - 'linkGroup', - ], - - extraDependencies: ['html', 'language'], - query: (album, group) => { const otherCategory = album.groups @@ -127,8 +117,7 @@ export default { workingCapsule += '.withArtists'; workingOptions.by = html.tag('span', {class: 'by'}, - html.metatag('chunkwrap', {split: ','}, - html.resolve(artistCredit))); + artistCredit); } return language.$(workingCapsule, workingOptions); diff --git a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js index 0b678e9d..4470eb2f 100644 --- a/src/content/dependencies/generateGroupInfoPageAlbumsSection.js +++ b/src/content/dependencies/generateGroupInfoPageAlbumsSection.js @@ -1,14 +1,4 @@ export default { - contentDependencies: [ - 'generateContentHeading', - 'generateGroupInfoPageAlbumsListByDate', - 'generateGroupInfoPageAlbumsListBySeries', - 'generateIntrapageDotSwitcher', - 'linkGroupGallery', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, group) => ({ contentHeading: relation('generateContentHeading'), diff --git a/src/content/dependencies/generateGroupNavAccent.js b/src/content/dependencies/generateGroupNavAccent.js index 0e4ebe8a..18281bf0 100644 --- a/src/content/dependencies/generateGroupNavAccent.js +++ b/src/content/dependencies/generateGroupNavAccent.js @@ -1,14 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'linkGroup', - 'linkGroupGallery', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, group) => ({ switcher: relation('generateInterpageDotSwitcher'), diff --git a/src/content/dependencies/generateGroupNavLinks.js b/src/content/dependencies/generateGroupNavLinks.js index bdc3ee4c..4f13e474 100644 --- a/src/content/dependencies/generateGroupNavLinks.js +++ b/src/content/dependencies/generateGroupNavLinks.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateGroupNavAccent', 'linkGroup'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({groupCategoryData, wikiInfo}) => ({ groupCategoryData, enableGroupUI: wikiInfo.enableGroupUI, diff --git a/src/content/dependencies/generateGroupSecondaryNav.js b/src/content/dependencies/generateGroupSecondaryNav.js index c48f3142..6b4347dd 100644 --- a/src/content/dependencies/generateGroupSecondaryNav.js +++ b/src/content/dependencies/generateGroupSecondaryNav.js @@ -1,9 +1,4 @@ export default { - contentDependencies: [ - 'generateSecondaryNav', - 'generateGroupSecondaryNavCategoryPart', - ], - relations: (relation, group) => ({ secondaryNav: relation('generateSecondaryNav'), diff --git a/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js index b2adb9f8..df627c99 100644 --- a/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js +++ b/src/content/dependencies/generateGroupSecondaryNavCategoryPart.js @@ -1,15 +1,6 @@ import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateSecondaryNavParentSiblingsPart', - 'linkGroupDynamically', - 'linkListing', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({listingSpec, wikiInfo}) => ({ groupsByCategoryListing: (wikiInfo.enableListings diff --git a/src/content/dependencies/generateGroupSidebar.js b/src/content/dependencies/generateGroupSidebar.js index 0888cbbe..1359eaca 100644 --- a/src/content/dependencies/generateGroupSidebar.js +++ b/src/content/dependencies/generateGroupSidebar.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateGroupSidebarCategoryDetails', - 'generatePageSidebar', - 'generatePageSidebarBox', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({groupCategoryData}) => ({groupCategoryData}), relations: (relation, sprawl, group) => ({ diff --git a/src/content/dependencies/generateGroupSidebarCategoryDetails.js b/src/content/dependencies/generateGroupSidebarCategoryDetails.js index 208ccd07..a7e1f240 100644 --- a/src/content/dependencies/generateGroupSidebarCategoryDetails.js +++ b/src/content/dependencies/generateGroupSidebarCategoryDetails.js @@ -1,14 +1,6 @@ import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'linkGroup', - 'linkGroupGallery', - ], - - extraDependencies: ['html', 'language'], - relations(relation, category) { return { colorStyle: diff --git a/src/content/dependencies/generateImageOverlay.js b/src/content/dependencies/generateImageOverlay.js new file mode 100644 index 00000000..006cfcce --- /dev/null +++ b/src/content/dependencies/generateImageOverlay.js @@ -0,0 +1,48 @@ +export default { + 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/generateInterpageDotSwitcher.js b/src/content/dependencies/generateInterpageDotSwitcher.js index 5a33444e..ddb7cb37 100644 --- a/src/content/dependencies/generateInterpageDotSwitcher.js +++ b/src/content/dependencies/generateInterpageDotSwitcher.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateDotSwitcherTemplate'], - extraDependencies: ['html', 'language'], - relations: (relation) => ({ template: relation('generateDotSwitcherTemplate'), diff --git a/src/content/dependencies/generateIntrapageDotSwitcher.js b/src/content/dependencies/generateIntrapageDotSwitcher.js index 3f300676..943d862c 100644 --- a/src/content/dependencies/generateIntrapageDotSwitcher.js +++ b/src/content/dependencies/generateIntrapageDotSwitcher.js @@ -1,9 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateDotSwitcherTemplate'], - extraDependencies: ['html', 'language'], - relations: (relation) => ({ template: relation('generateDotSwitcherTemplate'), @@ -39,9 +36,32 @@ export default { stitchArrays({ title: slots.titles, targetID: slots.targetIDs, - }).map(({title, targetID}) => - html.tag('a', {href: '#'}, - {'data-target-id': targetID}, - language.sanitize(title))), + }).map(({title, targetID}) => { + const {content} = html.smush(title); + + const customCue = + content.find(item => + item?.tagName === 'span' && + item.attributes.has('class', 'dot-switcher-interaction-cue')); + + const cue = + (customCue && !html.isBlank(customCue) + ? customCue.content + : language.sanitize(title)); + + const a = + html.tag('a', {href: '#'}, + {'data-target-id': targetID}, + {[html.onlyIfContent]: true}, + + cue); + + if (customCue) { + content.splice(content.indexOf(customCue), 1, a); + return html.tags(content, {[html.joinChildren]: ''}); + } else { + return a; + } + }), }), }; diff --git a/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js new file mode 100644 index 00000000..e381a745 --- /dev/null +++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumChunk.js @@ -0,0 +1,19 @@ +export default { + 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..0f14f12c --- /dev/null +++ b/src/content/dependencies/generateListAllAdditionalFilesAlbumSection.js @@ -0,0 +1,42 @@ +export default { + 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..d68e3bc1 100644 --- a/src/content/dependencies/generateListAllAdditionalFilesChunk.js +++ b/src/content/dependencies/generateListAllAdditionalFilesChunk.js @@ -1,7 +1,22 @@ -import {empty, stitchArrays} from '#sugar'; +import {stitchArrays} from '#sugar'; export default { - 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: { @@ -9,82 +24,73 @@ export default { 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..9ac79bb5 --- /dev/null +++ b/src/content/dependencies/generateListAllAdditionalFilesTrackChunk.js @@ -0,0 +1,20 @@ +export default { + 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/generateListRandomPageLinksAlbumLink.js b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js index b3560aca..29e7b1c9 100644 --- a/src/content/dependencies/generateListRandomPageLinksAlbumLink.js +++ b/src/content/dependencies/generateListRandomPageLinksAlbumLink.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkAlbum'], - data: (album) => ({directory: album.directory}), diff --git a/src/content/dependencies/generateListingIndexList.js b/src/content/dependencies/generateListingIndexList.js index 78622e6e..db494f37 100644 --- a/src/content/dependencies/generateListingIndexList.js +++ b/src/content/dependencies/generateListingIndexList.js @@ -1,9 +1,6 @@ import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: ['linkListing'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({listingTargetSpec, wikiInfo}) { return {listingTargetSpec, wikiInfo}; }, diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index 5f9a99a9..7ba59979 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -1,17 +1,6 @@ import {bindOpts, empty, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateContentHeading', - 'generateListingSidebar', - 'generatePageLayout', - 'linkListing', - 'linkListingIndex', - 'linkTemplate', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - relations(relation, listing) { const relations = {}; diff --git a/src/content/dependencies/generateListingSidebar.js b/src/content/dependencies/generateListingSidebar.js index aeac05cf..2d9429cf 100644 --- a/src/content/dependencies/generateListingSidebar.js +++ b/src/content/dependencies/generateListingSidebar.js @@ -1,13 +1,4 @@ export default { - contentDependencies: [ - 'generateListingIndexList', - 'generatePageSidebar', - 'generatePageSidebarBox', - 'linkListingIndex', - ], - - extraDependencies: ['html'], - relations: (relation, currentListing) => ({ sidebar: relation('generatePageSidebar'), diff --git a/src/content/dependencies/generateListingsIndexPage.js b/src/content/dependencies/generateListingsIndexPage.js index b57ebe15..80963d12 100644 --- a/src/content/dependencies/generateListingsIndexPage.js +++ b/src/content/dependencies/generateListingsIndexPage.js @@ -1,20 +1,14 @@ import {getTotalDuration} from '#wiki-data'; export default { - contentDependencies: [ - 'generateListingIndexList', - 'generateListingSidebar', - 'generatePageLayout', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({albumData, trackData, wikiInfo}) { return { wikiName: wikiInfo.name, numTracks: trackData.length, numAlbums: albumData.length, - totalDuration: getTotalDuration(trackData), + totalDuration: + getTotalDuration( + trackData.filter(track => track.countInArtistTotals)), }; }, diff --git a/src/content/dependencies/generateLyricsEntry.js b/src/content/dependencies/generateLyricsEntry.js new file mode 100644 index 00000000..15f84b27 --- /dev/null +++ b/src/content/dependencies/generateLyricsEntry.js @@ -0,0 +1,119 @@ +export default { + 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, + + numStanzas: + 1 + + + (Array.from( + entry.body + .matchAll(/\n\n|<br><br>/g)) + + .length) + + + (entry.body.includes('<br') + ? entry.body.split('\n').length + : 0), + + numLines: + 1 + + + (Array.from( + entry.body + .replaceAll(/(<br>){1,}/g, '\n') + .replaceAll(/\n{2,}/g, '\n') + .matchAll(/\n/g)) + + .length), + }), + + slots: { + attributes: { + type: 'attributes', + mutable: false, + }, + }, + + generate: (data, relations, slots, {html, language}) => + language.encapsulate('misc.lyrics', capsule => + html.tag('blockquote', {class: 'lyrics-entry'}, + slots.attributes, + + {'data-stanzas': data.numStanzas}, + {'data-lines': data.numLines}, + + (data.numStanzas > 1 || + data.numLines > 8) && + {class: 'long-lyrics'}, + + [ + 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..bbc3a776 --- /dev/null +++ b/src/content/dependencies/generateLyricsSection.js @@ -0,0 +1,85 @@ +import {stitchArrays} from '#sugar'; + +export default { + relations: (relation, entries) => ({ + heading: + relation('generateContentHeading'), + + switcher: + relation('generateIntrapageDotSwitcher'), + + entries: + entries + .map(entry => relation('generateLyricsEntry', entry)), + + annotationParts: + entries + .map(entry => entry.annotationParts + .map(part => relation('transformContent', part))), + }), + + 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.annotationParts + .map(([first, ...rest]) => + language.formatUnitList([ + html.tag('span', + {class: 'dot-switcher-interaction-cue'}, + {[html.onlyIfContent]: true}, + + first?.slots({ + mode: 'inline', + textOnly: true, + })), + + ...rest.map(part => + part.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/generateName.js b/src/content/dependencies/generateName.js new file mode 100644 index 00000000..e0d0c6d3 --- /dev/null +++ b/src/content/dependencies/generateName.js @@ -0,0 +1,33 @@ +export default { + contentDependencies: ['transformContent'], + extraDependencies: ['html', 'language'], + + relations: (relation, thing) => ({ + customName: + (thing.nameText + ? relation('transformContent', thing.nameText) + : null), + }), + + data: (thing) => ({ + normalName: + thing.name, + + shortName: + thing.nameShort, + }), + + slots: { + preferShortName: { + type: 'boolean', + default: false, + }, + }, + + generate: (data, relations, slots, {language}) => + (relations.customName + ? relations.customName.slot('mode', 'inline') + : slots.preferShortName && data.shortName + ? language.sanitize(data.shortName) + : language.sanitize(data.normalName)), +}; diff --git a/src/content/dependencies/generateNewsEntryNavAccent.js b/src/content/dependencies/generateNewsEntryNavAccent.js index 5d168e41..05248eb3 100644 --- a/src/content/dependencies/generateNewsEntryNavAccent.js +++ b/src/content/dependencies/generateNewsEntryNavAccent.js @@ -1,11 +1,4 @@ export default { - contentDependencies: [ - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkNewsEntry', - ], - relations: (relation, previousEntry, nextEntry) => ({ switcher: relation('generateInterpageDotSwitcher'), diff --git a/src/content/dependencies/generateNewsEntryPage.js b/src/content/dependencies/generateNewsEntryPage.js index 4abd87d1..bbfb886d 100644 --- a/src/content/dependencies/generateNewsEntryPage.js +++ b/src/content/dependencies/generateNewsEntryPage.js @@ -2,16 +2,6 @@ import {sortChronologically} from '#sort'; import {atOffset} from '#sugar'; export default { - contentDependencies: [ - 'generateNewsEntryNavAccent', - 'generateNewsEntryReadAnotherLinks', - 'generatePageLayout', - 'linkNewsIndex', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({newsData}) { return {newsData}; }, diff --git a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js index d978b0e4..a985742b 100644 --- a/src/content/dependencies/generateNewsEntryReadAnotherLinks.js +++ b/src/content/dependencies/generateNewsEntryReadAnotherLinks.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateAbsoluteDatetimestamp', - 'generateRelativeDatetimestamp', - 'linkNewsEntry', - ], - - extraDependencies: ['html', 'language'], - relations(relation, currentEntry, previousEntry, nextEntry) { const relations = {}; diff --git a/src/content/dependencies/generateNewsIndexPage.js b/src/content/dependencies/generateNewsIndexPage.js index 02964ce8..d88bfdba 100644 --- a/src/content/dependencies/generateNewsIndexPage.js +++ b/src/content/dependencies/generateNewsIndexPage.js @@ -2,14 +2,6 @@ import {sortChronologically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generatePageLayout', - 'linkNewsEntry', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({newsData}) { return {newsData}; }, diff --git a/src/content/dependencies/generateNextLink.js b/src/content/dependencies/generateNextLink.js index 2e48cd2b..2c497e12 100644 --- a/src/content/dependencies/generateNextLink.js +++ b/src/content/dependencies/generateNextLink.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generatePreviousNextLink'], - relations: (relation) => ({ link: relation('generatePreviousNextLink'), diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index fa2cdc18..23d5932d 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -1,26 +1,9 @@ +import striptags from 'striptags'; + import {openAggregate} from '#aggregate'; -import {empty, repeat} from '#sugar'; +import {atOffset, empty, repeat} from '#sugar'; export default { - contentDependencies: [ - 'generateColorStyleRules', - 'generateFooterLocalizationLinks', - 'generatePageSidebar', - 'generateSearchSidebarBox', - 'generateStickyHeadingContainer', - 'transformContent', - ], - - extraDependencies: [ - 'getColors', - 'html', - 'language', - 'pagePath', - 'pagePathStringFromRoot', - 'to', - 'wikiData', - ], - sprawl: ({wikiInfo}) => ({ enableSearch: wikiInfo.enableSearch, footerContent: wikiInfo.footerContent, @@ -57,8 +40,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 +81,7 @@ export default { mutable: false, }, - cover: { + artworkColumnContent: { type: 'html', mutable: false, }, @@ -103,9 +95,9 @@ export default { color: {validate: v => v.isColor}, - styleRules: { - validate: v => v.sparseArrayOf(v.isHTML), - default: [], + styleTags: { + type: 'html', + mutable: false, }, mainClasses: { @@ -253,24 +245,63 @@ 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 - : html.isBlank(slots.additionalNames) - ? language.sanitize(slots.title) - : html.tag('a', { + + : (!html.isBlank(slots.additionalNames) && + !html.resolve(slots.additionalNames, {slots: ['alwaysVisible']}) + .getSlotValue('alwaysVisible')) + + ? html.tag('a', { href: '#additional-names-box', title: language.$('misc.additionalNames.tooltip').toString(), - }, language.sanitize(slots.title))); + }, language.sanitize(slots.title)) + + : language.sanitize(slots.title)); const titleHTML = (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 +332,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 +379,7 @@ export default { slots.navLinks ?.filter(Boolean) - ?.map((cur, i) => { + ?.map((cur, i, entries) => { let content; if (cur.html) { @@ -380,20 +413,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 +430,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 +489,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 +574,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'}, @@ -637,11 +656,25 @@ export default { language.encapsulate('misc.pageTitle', workingCapsule => { const workingOptions = {}; - workingOptions.title = slots.title; + // Slightly jank: The output of striptags is, of course, a string, + // and as far as language.formatString() is concerned, that means + // it needs to be sanitized - including turning ampersands into + // &'s. But the title is already HTML that has implicitly been + // sanitized, however it got here, and includes HTML entities that + // are properly escaped. Those need to get included as they are, + // so we wrap the title in a tag and pass it off as good to go. + workingOptions.title = + html.tags([ + striptags(slots.title.toString()), + ]); if (!html.isBlank(slots.subtitle)) { + // Same shenanigans here, as far as wrapping striptags goes. workingCapsule += '.withSubtitle'; - workingOptions.subtitle = slots.subtitle; + workingOptions.subtitle = + html.tags([ + striptags(slots.subtitle.toString()), + ]); } const showWikiName = @@ -689,13 +722,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 +738,6 @@ export default { hreflang: lang, href, }))), - */ hasSocialEmbed && @@ -722,11 +756,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 +792,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/generatePageSidebar.js b/src/content/dependencies/generatePageSidebar.js index d3b55580..dfe85632 100644 --- a/src/content/dependencies/generatePageSidebar.js +++ b/src/content/dependencies/generatePageSidebar.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { // Attributes to apply to the whole sidebar. This be added to the // containing sidebar-column, arr - specify attributes on each section if diff --git a/src/content/dependencies/generatePageSidebarBox.js b/src/content/dependencies/generatePageSidebarBox.js index 26b30494..3133aa64 100644 --- a/src/content/dependencies/generatePageSidebarBox.js +++ b/src/content/dependencies/generatePageSidebarBox.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { content: { type: 'html', diff --git a/src/content/dependencies/generatePageSidebarConjoinedBox.js b/src/content/dependencies/generatePageSidebarConjoinedBox.js index 05b1d469..4ed0ff22 100644 --- a/src/content/dependencies/generatePageSidebarConjoinedBox.js +++ b/src/content/dependencies/generatePageSidebarConjoinedBox.js @@ -4,9 +4,6 @@ // templates' resolved content), take care when slotting into this. export default { - contentDependencies: ['generatePageSidebarBox'], - extraDependencies: ['html'], - relations: (relation) => ({ box: relation('generatePageSidebarBox'), @@ -32,11 +29,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/generatePreviousLink.js b/src/content/dependencies/generatePreviousLink.js index 775367f9..29146a21 100644 --- a/src/content/dependencies/generatePreviousLink.js +++ b/src/content/dependencies/generatePreviousLink.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generatePreviousNextLink'], - relations: (relation) => ({ link: relation('generatePreviousNextLink'), diff --git a/src/content/dependencies/generatePreviousNextLink.js b/src/content/dependencies/generatePreviousNextLink.js index afae1228..1e98358f 100644 --- a/src/content/dependencies/generatePreviousNextLink.js +++ b/src/content/dependencies/generatePreviousNextLink.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'language'], - slots: { link: { type: 'html', diff --git a/src/content/dependencies/generateQuickDescription.js b/src/content/dependencies/generateQuickDescription.js index e144503e..f67f9514 100644 --- a/src/content/dependencies/generateQuickDescription.js +++ b/src/content/dependencies/generateQuickDescription.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['transformContent'], - extraDependencies: ['html', 'language'], - query: (thing) => ({ hasDescription: !!thing.description, diff --git a/src/content/dependencies/generateReadCommentaryLine.js b/src/content/dependencies/generateReadCommentaryLine.js new file mode 100644 index 00000000..05700536 --- /dev/null +++ b/src/content/dependencies/generateReadCommentaryLine.js @@ -0,0 +1,43 @@ +import {empty} from '#sugar'; + +export default { + query: (thing) => ({ + entries: + (thing.isTrack + ? [...thing.commentary, ...thing.commentaryFromMainRelease] + : thing.commentary), + }), + + data: (query, _thing) => ({ + hasWikiEditorCommentary: + query.entries.some(entry => entry.isWikiEditorCommentary), + + onlyWikiEditorCommentary: + !empty(query.entries) && + query.entries.every(entry => entry.isWikiEditorCommentary), + + hasAnyCommentary: + !empty(query.entries), + }), + + generate: (data, {html, language}) => + language.encapsulate('releaseInfo.readCommentary', capsule => + language.$(capsule, { + [language.onlyIfOptions]: ['link'], + + link: + html.tag('a', + {[html.onlyIfContent]: true}, + + {href: '#artist-commentary'}, + + language.encapsulate(capsule, 'link', capsule => + (data.onlyWikiEditorCommentary + ? language.$(capsule, 'onlyWikiCommentary') + : data.hasWikiEditorCommentary + ? language.$(capsule, 'withWikiCommentary') + : data.hasAnyCommentary + ? language.$(capsule) + : html.blank()))), + })), +}; diff --git a/src/content/dependencies/generateReferencedArtworksPage.js b/src/content/dependencies/generateReferencedArtworksPage.js index 3d21b15d..2f47b7a5 100644 --- a/src/content/dependencies/generateReferencedArtworksPage.js +++ b/src/content/dependencies/generateReferencedArtworksPage.js @@ -1,67 +1,45 @@ -import {stitchArrays} from '#sugar'; - export default { - contentDependencies: [ - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkAlbum', - 'linkTrack', - ], - - 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 +51,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 +71,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..abb92732 100644 --- a/src/content/dependencies/generateReferencingArtworksPage.js +++ b/src/content/dependencies/generateReferencingArtworksPage.js @@ -1,67 +1,45 @@ -import {stitchArrays} from '#sugar'; - export default { - contentDependencies: [ - 'generateCoverGrid', - 'generatePageLayout', - 'image', - 'linkAlbum', - 'linkTrack', - ], - - 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 +51,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 +71,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/generateRelativeDatetimestamp.js b/src/content/dependencies/generateRelativeDatetimestamp.js index a997de0e..b3fe6239 100644 --- a/src/content/dependencies/generateRelativeDatetimestamp.js +++ b/src/content/dependencies/generateRelativeDatetimestamp.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateAbsoluteDatetimestamp', - 'generateDatetimestampTemplate', - 'generateTooltip', - ], - - extraDependencies: ['html', 'language'], - data: (currentDate, referenceDate) => (currentDate.getTime() === referenceDate.getTime() ? {equal: true, date: currentDate} diff --git a/src/content/dependencies/generateReleaseInfoContributionsLine.js b/src/content/dependencies/generateReleaseInfoContributionsLine.js index 016e0a2c..4353ccf4 100644 --- a/src/content/dependencies/generateReleaseInfoContributionsLine.js +++ b/src/content/dependencies/generateReleaseInfoContributionsLine.js @@ -1,16 +1,15 @@ export default { - contentDependencies: ['generateArtistCredit'], - extraDependencies: ['html'], - - relations: (relation, contributions) => ({ + relations: (relation, contributions, formatText) => ({ credit: - relation('generateArtistCredit', contributions, []), + relation('generateArtistCredit', contributions, [], formatText), }), slots: { stringKey: {type: 'string'}, featuringStringKey: {type: 'string'}, + additionalStringOptions: {validate: v => v.isObject}, + chronologyKind: {type: 'string'}, }, @@ -27,5 +26,6 @@ export default { normalStringKey: slots.stringKey, normalFeaturingStringKey: slots.featuringStringKey, + additionalStringOptions: slots.additionalStringOptions, }), }; diff --git a/src/content/dependencies/generateReleaseInfoListenLine.js b/src/content/dependencies/generateReleaseInfoListenLine.js new file mode 100644 index 00000000..97f248d6 --- /dev/null +++ b/src/content/dependencies/generateReleaseInfoListenLine.js @@ -0,0 +1,156 @@ +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 { + query(thing) { + const query = {}; + + query.album = + (thing.album + ? thing.album + : thing); + + query.urls = + (!empty(thing.urls) + ? thing.urls + : thing.album && + thing.album.style === 'single' && + thing.album.tracks[0] === thing + ? thing.album.urls + : []); + + 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: + query.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 = + query.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 = + query.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..701a01ac 100644 --- a/src/content/dependencies/generateSearchSidebarBox.js +++ b/src/content/dependencies/generateSearchSidebarBox.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generatePageSidebarBox'], - extraDependencies: ['html', 'language'], - relations: (relation) => ({ sidebarBox: relation('generatePageSidebarBox'), @@ -57,6 +54,43 @@ export default { html.tag('template', {class: 'wiki-search-tag-result-kind-string'}, language.$(capsule, 'artTag')), ]), + + language.encapsulate(capsule, 'resultDisambiguator', capsule => [ + html.tag('template', {class: 'wiki-search-group-result-disambiguator-string'}, + language.$(capsule, 'group', { + disambiguator: html.tag('slot', {name: 'disambiguator'}), + })), + + html.tag('template', {class: 'wiki-search-flash-result-disambiguator-string'}, + language.$(capsule, 'flash', { + disambiguator: html.tag('slot', {name: 'disambiguator'}), + })), + + html.tag('template', {class: 'wiki-search-track-result-disambiguator-string'}, + language.$(capsule, 'track', { + disambiguator: html.tag('slot', {name: 'disambiguator'}), + })), + ]), + + 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/generateSecondaryNav.js b/src/content/dependencies/generateSecondaryNav.js index 9ce7ce9b..63b3839b 100644 --- a/src/content/dependencies/generateSecondaryNav.js +++ b/src/content/dependencies/generateSecondaryNav.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { content: { type: 'html', diff --git a/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js index f204f1fb..fe7c17ac 100644 --- a/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js +++ b/src/content/dependencies/generateSecondaryNavParentSiblingsPart.js @@ -1,15 +1,4 @@ export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateInterpageDotSwitcher', - 'generateNextLink', - 'generatePreviousLink', - 'linkAlbumDynamically', - 'linkGroup', - ], - - extraDependencies: ['html', 'language'], - relations: (relation) => ({ switcher: relation('generateInterpageDotSwitcher'), diff --git a/src/content/dependencies/generateSocialEmbed.js b/src/content/dependencies/generateSocialEmbed.js index 85a0f4d3..5fa9376c 100644 --- a/src/content/dependencies/generateSocialEmbed.js +++ b/src/content/dependencies/generateSocialEmbed.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({wikiInfo}) { return { canonicalBase: wikiInfo.canonicalBase, @@ -23,10 +21,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 +38,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..485b802e 100644 --- a/src/content/dependencies/generateStaticPage.js +++ b/src/content/dependencies/generateStaticPage.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generatePageLayout', 'transformContent'], - extraDependencies: ['html'], - relations(relation, staticPage) { return { layout: relation('generatePageLayout'), @@ -23,17 +20,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..443a4d08 --- /dev/null +++ b/src/content/dependencies/generateStaticURLStyleTag.js @@ -0,0 +1,20 @@ +export default { + 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..f7388d60 100644 --- a/src/content/dependencies/generateStickyHeadingContainer.js +++ b/src/content/dependencies/generateStickyHeadingContainer.js @@ -1,7 +1,10 @@ export default { - extraDependencies: ['html'], - slots: { + rootAttributes: { + type: 'attributes', + mutable: false, + }, + title: { type: 'html', mutable: false, @@ -13,27 +16,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..cdeadcfe --- /dev/null +++ b/src/content/dependencies/generateStyleTag.js @@ -0,0 +1,46 @@ +import {empty} from '#sugar'; + +const indent = text => + text + .split('\n') + .map(line => ' '.repeat(4) + line) + .join('\n'); + +export default { + 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/generateTextWithTooltip.js b/src/content/dependencies/generateTextWithTooltip.js index 49ce1f61..360cfebc 100644 --- a/src/content/dependencies/generateTextWithTooltip.js +++ b/src/content/dependencies/generateTextWithTooltip.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { attributes: { type: 'attributes', diff --git a/src/content/dependencies/generateTooltip.js b/src/content/dependencies/generateTooltip.js index b09ee230..6f23af6d 100644 --- a/src/content/dependencies/generateTooltip.js +++ b/src/content/dependencies/generateTooltip.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { attributes: { type: 'attributes', diff --git a/src/content/dependencies/generateTrackArtistCommentarySection.js b/src/content/dependencies/generateTrackArtistCommentarySection.js new file mode 100644 index 00000000..39a3e145 --- /dev/null +++ b/src/content/dependencies/generateTrackArtistCommentarySection.js @@ -0,0 +1,134 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + query: (track) => ({ + otherSecondaryReleasesWithCommentary: + track.otherReleases + .filter(track => !track.isMainRelease) + .filter(track => !empty(track.commentary)), + }), + + relations: (relation, query, track) => ({ + commentaryContentHeading: + relation('generateCommentaryContentHeading', track), + + mainReleaseTrackLink: + (track.isSecondaryRelease + ? relation('linkTrack', track.mainReleaseTrack) + : null), + + mainReleaseArtistCommentaryEntries: + (track.isSecondaryRelease + ? track.commentaryFromMainRelease + .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.commentaryContentHeading, + 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..234586e0 --- /dev/null +++ b/src/content/dependencies/generateTrackArtworkColumn.js @@ -0,0 +1,30 @@ +export default { + 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..92e00a41 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -1,41 +1,46 @@ +function checkInterrupted(which, relations, {html}) { + if ( + !html.isBlank(relations.additionalFilesList) || + !html.isBlank(relations.contributorContributionList) || + !html.isBlank(relations.flashesThatFeatureList) || + !html.isBlank(relations.lyricsSection) || + !html.isBlank(relations.midiProjectFilesList) || + !html.isBlank(relations.referencedByTracksList) || + !html.isBlank(relations.referencedTracksList) || + !html.isBlank(relations.sampledByTracksList) || + !html.isBlank(relations.sampledTracksList) || + !html.isBlank(relations.sheetMusicFilesList) + ) return true; + + if (which === 'crediting-sources' || which === 'referencing-sources') { + if (!html.isBlank(relations.artistCommentarySection)) return true; + } + + return false; +} + export default { - contentDependencies: [ - 'generateAdditionalNamesBox', - 'generateAlbumAdditionalFilesList', - 'generateAlbumNavAccent', - 'generateAlbumSecondaryNav', - 'generateAlbumSidebar', - 'generateAlbumStyleRules', - 'generateCommentarySection', - 'generateContentHeading', - 'generateContributionList', - 'generatePageLayout', - 'generateTrackCoverArtwork', - 'generateTrackInfoPageFeaturedByFlashesList', - 'generateTrackInfoPageOtherReleasesList', - 'generateTrackList', - 'generateTrackListDividedByGroups', - 'generateTrackNavLinks', - 'generateTrackReleaseInfo', - 'generateTrackSocialEmbed', - 'linkAlbum', - 'linkTrack', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - - sprawl: ({wikiInfo}) => ({ - divideTrackListsByGroups: - wikiInfo.divideTrackListsByGroups, + query: (track) => ({ + mainReleaseTrack: + (track.isMainRelease + ? track + : track.mainReleaseTrack), + + singleTrackSingle: + track.album.style === 'single' && + track.album.tracks.length === 1, + + firstTrackInSingle: + track.album.style === 'single' && + track === track.album.tracks[0], }), - 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), @@ -43,6 +48,9 @@ export default { navLinks: relation('generateTrackNavLinks', track), + albumNavLink: + relation('linkAlbum', track.album), + albumNavAccent: relation('generateAlbumNavAccent', track.album, track), @@ -55,73 +63,92 @@ export default { additionalNamesBox: relation('generateAdditionalNamesBox', track.additionalNames), - cover: - (track.hasUniqueCoverArt || track.album.hasCoverArt - ? relation('generateTrackCoverArtwork', track) - : null), + artworkColumn: + (query.firstTrackInSingle + ? relation('generateAlbumArtworkColumn', track.album) + : relation('generateTrackArtworkColumn', track)), contentHeading: relation('generateContentHeading'), + name: + relation('generateName', track), + releaseInfo: relation('generateTrackReleaseInfo', track), + readCommentaryLine: + relation('generateReadCommentaryLine', track), + otherReleasesList: - relation('generateTrackInfoPageOtherReleasesList', track), + relation('generateTrackInfoPageOtherReleasesList', track), contributorContributionList: relation('generateContributionList', track.contributorContribs), referencedTracksList: - relation('generateTrackList', track.referencedTracks), + relation('generateTrackList', track.referencedTracks, track), sampledTracksList: - relation('generateTrackList', track.sampledTracks), + relation('generateTrackList', track.sampledTracks, track), referencedByTracksList: relation('generateTrackListDividedByGroups', - track.referencedByTracks, - sprawl.divideTrackListsByGroups), + query.mainReleaseTrack.referencedByTracks, + track), sampledByTracksList: relation('generateTrackListDividedByGroups', - track.sampledByTracks, - sprawl.divideTrackListsByGroups), + query.mainReleaseTrack.sampledByTracks, + track), 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), + + creditingSourcesSection: + relation('generateCollapsedContentEntrySection', + track.creditingSources, + track), - creditSourcesSection: - relation('generateCommentarySection', track.creditSources), + referencingSourcesSection: + relation('generateCollapsedContentEntrySection', + track.referencingSources, + track), }), - data: (sprawl, track) => ({ + data: (query, track) => ({ name: track.name, color: track.color, + + dateAlbumAddedToWiki: + track.album.dateAddedToWiki, + + needsLyrics: + track.needsLyrics, + + singleTrackSingle: + query.singleTrackSingle, + + firstTrackInSingle: + query.firstTrackInSingle, }), generate: (data, relations, {html, language}) => @@ -129,7 +156,7 @@ export default { relations.layout.slots({ title: language.$(pageCapsule, 'title', { - track: data.name, + track: relations.name, }), headingMode: 'sticky', @@ -137,15 +164,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, @@ -182,34 +204,31 @@ export default { language.$(capsule, 'link')), })), - !html.isBlank(relations.artistCommentarySection) && - language.encapsulate(capsule, 'readCommentary', capsule => + checkInterrupted('commentary', relations, {html}) && + relations.readCommentaryLine, + + !html.isBlank(relations.creditingSourcesSection) && + checkInterrupted('crediting-sources', relations, {html}) && + language.encapsulate(capsule, 'readCreditingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#artist-commentary'}, + {href: '#crediting-sources'}, language.$(capsule, 'link')), })), - !html.isBlank(relations.creditSourcesSection) && - language.encapsulate(capsule, 'readCreditSources', capsule => + !html.isBlank(relations.referencingSourcesSection) && + checkInterrupted('referencing-sources', relations, {html}) && + language.encapsulate(capsule, 'readReferencingSources', capsule => language.$(capsule, { link: html.tag('a', - {href: '#credit-sources'}, + {href: '#referencing-sources'}, language.$(capsule, 'link')), })), ])), - html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'also-released-as'}, - title: language.$('releaseInfo.alsoReleasedAs'), - }), - - relations.otherReleasesList, - ]), + relations.otherReleasesList, html.tags([ relations.contentHeading.clone() @@ -321,17 +340,26 @@ export default { relations.flashesThatFeatureList, ]), - html.tags([ - relations.contentHeading.clone() - .slots({ - attributes: {id: 'lyrics'}, - title: language.$('releaseInfo.lyrics'), - }), - - html.tag('blockquote', + data.firstTrackInSingle && + html.tag('p', {[html.onlyIfContent]: true}, - relations.lyrics.slot('mode', 'lyrics')), - ]), + + language.$('releaseInfo.addedToWiki', { + [language.onlyIfOptions]: ['date'], + date: language.formatDate(data.dateAlbumAddedToWiki), + })), + + data.firstTrackInSingle && + (!html.isBlank(relations.lyricsSection) || + !html.isBlank(relations.artistCommentarySection)) && + html.tag('hr', {class: 'main-separator'}), + + data.needsLyrics && + html.isBlank(relations.lyricsSection) && + html.tag('p', + language.$(pageCapsule, 'needsLyrics')), + + relations.lyricsSection, html.tags([ relations.contentHeading.clone() @@ -365,24 +393,40 @@ export default { relations.artistCommentarySection, - relations.creditSourcesSection.slots({ - id: 'credit-sources', - title: language.$('misc.creditSources'), + relations.creditingSourcesSection.slots({ + id: 'crediting-sources', + string: 'misc.creditingSources', + }), + + relations.referencingSourcesSection.slots({ + id: 'referencing-sources', + string: 'misc.referencingSources', }), ], navLinkStyle: 'hierarchical', - navLinks: html.resolve(relations.navLinks), + navLinks: + (data.singleTrackSingle + ? [ + {auto: 'home'}, + { + html: relations.albumNavLink, + accent: language.$(pageCapsule, 'nav.singleAccent'), + }, + ] + : html.resolve(relations.navLinks)), navBottomRowContent: - relations.albumNavAccent.slots({ - showTrackNavigation: true, - showExtraLinks: false, - }), + (data.singleTrackSingle + ? null + : relations.albumNavAccent.slots({ + showTrackNavigation: true, + showExtraLinks: false, + })), secondaryNav: relations.secondaryNav - .slot('mode', 'track'), + .slot('mode', data.firstTrackInSingle ? 'album' : 'track'), leftSidebar: relations.sidebar, diff --git a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js index 5958be9a..cd7bb014 100644 --- a/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js +++ b/src/content/dependencies/generateTrackInfoPageFeaturedByFlashesList.js @@ -2,9 +2,6 @@ import {sortFlashesChronologically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['linkFlash', 'linkTrack'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({wikiInfo}) => ({ enableFlashesAndGames: wikiInfo.enableFlashesAndGames, @@ -14,7 +11,7 @@ export default { sortedFeatures: (sprawl.enableFlashesAndGames ? sortFlashesChronologically( - [track, ...track.otherReleases].flatMap(track => + track.allReleases.flatMap(track => track.featuredInFlashes.map(flash => ({ flash, track, @@ -36,6 +33,8 @@ export default { .map(({track: directlyFeaturedTrack}) => (directlyFeaturedTrack === track ? null + : directlyFeaturedTrack.name === track.name + ? null : relation('linkTrack', directlyFeaturedTrack))), }), @@ -52,7 +51,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..ca6c3fb7 100644 --- a/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js +++ b/src/content/dependencies/generateTrackInfoPageOtherReleasesList.js @@ -1,80 +1,83 @@ -import {stitchArrays} from '#sugar'; +import {onlyItem, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateAbsoluteDatetimestamp', - 'generateColorStyleAttribute', - 'generateRelativeDatetimestamp', - 'linkAlbum', - 'linkTrack', - ], + query(track) { + const query = {}; - extraDependencies: ['html', 'language'], + query.singleSingle = + onlyItem( + track.otherReleases.filter(track => track.album.style === 'single')); - relations: (relation, track) => ({ - colorStyles: - track.otherReleases - .map(track => relation('generateColorStyleAttribute', track.color)), + query.regularReleases = + (query.singleSingle + ? track.otherReleases.filter(track => track !== query.singleSingle) + : track.otherReleases); + + return query; + }, + + relations: (relation, query, _track) => ({ + singleLink: + (query.singleSingle + ? relation('linkTrack', query.singleSingle) + : null), trackLinks: - track.otherReleases + query.regularReleases .map(track => relation('linkTrack', track)), + }), - albumLinks: - 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)), + data: (query, _track) => ({ + albumNames: + query.regularReleases + .map(track => track.album.name), - items: - track.otherReleases.map(track => ({ - trackLink: relation('linkTrack', track), - albumLink: relation('linkAlbum', track.album), - })), + albumColors: + query.regularReleases + .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 = {}; + language.encapsulate('releaseInfo.alsoReleased', capsule => + language.encapsulate(capsule, workingCapsule => { + const workingOptions = {}; - options.track = trackLink.slot('color', false); - options.album = albumLink; + let any = false; - if (datetimestamp) { - parts.push('withYear'); - options.year = - datetimestamp.slots({ - style: 'year', - tooltip: true, + const albumList = + language.formatConjunctionList( + stitchArrays({ + trackLink: relations.trackLinks, + albumName: data.albumNames, + albumColor: data.albumColors, + }).map(({trackLink, albumName, albumColor}) => + trackLink.slots({ + content: language.sanitize(albumName), + color: albumColor, + }))); + + if (!html.isBlank(albumList)) { + any = true; + workingCapsule += '.onAlbums'; + workingOptions.albums = albumList; + } + + if (relations.singleLink) { + any = true; + workingCapsule += '.asSingle'; + workingOptions.single = + relations.singleLink.slots({ + content: language.$(capsule, 'single'), }); } - return ( - html.tag('li', - colorStyle, - language.$(...parts, options))); - })), + if (any) { + return language.$(workingCapsule, workingOptions); + } else { + return html.blank(); + } + }))), }; diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js index 53a32536..e30feb23 100644 --- a/src/content/dependencies/generateTrackList.js +++ b/src/content/dependencies/generateTrackList.js @@ -1,10 +1,16 @@ export default { - contentDependencies: ['generateTrackListItem'], - extraDependencies: ['html'], + query: (tracks, contextTrack) => ({ + presentedTracks: + (contextTrack + ? tracks.map(track => + track.otherReleases.find(({album}) => album === contextTrack.album) ?? + track) + : tracks), + }), - relations: (relation, tracks) => ({ + relations: (relation, query, _tracks, _contextTrack) => ({ items: - tracks + query.presentedTracks .map(track => relation('generateTrackListItem', track, [])), }), diff --git a/src/content/dependencies/generateTrackListDividedByGroups.js b/src/content/dependencies/generateTrackListDividedByGroups.js index 3cba479e..d7342891 100644 --- a/src/content/dependencies/generateTrackListDividedByGroups.js +++ b/src/content/dependencies/generateTrackListDividedByGroups.js @@ -1,15 +1,14 @@ import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateContentHeading', - 'generateTrackList', - 'linkGroup', - ], + sprawl: ({wikiInfo}) => ({ + divideTrackListsByGroups: + wikiInfo.divideTrackListsByGroups, + }), - extraDependencies: ['html', 'language'], + query(sprawl, tracks, _contextTrack) { + const dividingGroups = sprawl.divideTrackListsByGroups; - query(tracks, dividingGroups) { const groupings = new Map(); const ungroupedTracks = []; @@ -43,10 +42,10 @@ export default { return {groups, groupedTracks, ungroupedTracks}; }, - relations: (relation, query, tracks, groups) => ({ + relations: (relation, query, sprawl, tracks, contextTrack) => ({ flatList: - (empty(groups) - ? relation('generateTrackList', tracks) + (empty(sprawl.divideTrackListsByGroups) + ? relation('generateTrackList', tracks, contextTrack) : null), contentHeading: @@ -58,15 +57,15 @@ export default { groupedTrackLists: query.groupedTracks - .map(tracks => relation('generateTrackList', tracks)), + .map(tracks => relation('generateTrackList', tracks, contextTrack)), ungroupedTrackList: (empty(query.ungroupedTracks) ? null - : relation('generateTrackList', query.ungroupedTracks)), + : relation('generateTrackList', query.ungroupedTracks, contextTrack)), }), - 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..9de9c3a6 100644 --- a/src/content/dependencies/generateTrackListItem.js +++ b/src/content/dependencies/generateTrackListItem.js @@ -1,13 +1,4 @@ export default { - contentDependencies: [ - 'generateArtistCredit', - 'generateColorStyleAttribute', - 'generateTrackListMissingDuration', - 'linkTrack', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, track, contextContributions) => ({ trackLink: relation('linkTrack', track), @@ -15,7 +6,8 @@ export default { credit: relation('generateArtistCredit', track.artistContribs, - contextContributions), + contextContributions, + track.artistText), colorStyle: relation('generateColorStyleAttribute', track.color), @@ -97,8 +89,7 @@ export default { workingCapsule += '.withArtists'; workingOptions.by = html.tag('span', {class: 'by'}, - html.metatag('chunkwrap', {split: ','}, - html.resolve(relations.credit))); + relations.credit); } return language.$(workingCapsule, workingOptions); diff --git a/src/content/dependencies/generateTrackListMissingDuration.js b/src/content/dependencies/generateTrackListMissingDuration.js index b5917982..da3113a2 100644 --- a/src/content/dependencies/generateTrackListMissingDuration.js +++ b/src/content/dependencies/generateTrackListMissingDuration.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateTextWithTooltip', 'generateTooltip'], - extraDependencies: ['html', 'language'], - relations: (relation) => ({ textWithTooltip: relation('generateTextWithTooltip'), diff --git a/src/content/dependencies/generateTrackNavLinks.js b/src/content/dependencies/generateTrackNavLinks.js index e01653f0..d18e6cad 100644 --- a/src/content/dependencies/generateTrackNavLinks.js +++ b/src/content/dependencies/generateTrackNavLinks.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkAlbum', 'linkTrack'], - extraDependencies: ['html', 'language'], - relations: (relation, track) => ({ albumLink: relation('linkAlbum', track.album), @@ -11,11 +8,14 @@ export default { }), data: (track) => ({ + albumStyle: + track.album.style, + hasTrackNumbers: track.album.hasTrackNumbers, trackNumber: - track.album.tracks.indexOf(track) + 1, + track.trackNumber, }), slots: { @@ -28,7 +28,13 @@ export default { language.encapsulate('trackPage.nav', navCapsule => [ {auto: 'home'}, - {html: relations.albumLink.slot('color', false)}, + { + html: relations.albumLink.slot('color', false), + accent: + (data.albumStyle === 'single' + ? language.$(navCapsule, 'singleAccent') + : null), + }, { html: diff --git a/src/content/dependencies/generateTrackReferencedArtworksPage.js b/src/content/dependencies/generateTrackReferencedArtworksPage.js index ac81e525..a2612067 100644 --- a/src/content/dependencies/generateTrackReferencedArtworksPage.js +++ b/src/content/dependencies/generateTrackReferencedArtworksPage.js @@ -1,37 +1,21 @@ export default { - contentDependencies: [ - 'generateAlbumStyleRules', - 'generateBackToTrackLink', - 'generateReferencedArtworksPage', - 'generateTrackCoverArtwork', - 'generateTrackNavLinks', - ], - - extraDependencies: ['html', 'language'], - 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 +26,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..be13dd79 100644 --- a/src/content/dependencies/generateTrackReferencingArtworksPage.js +++ b/src/content/dependencies/generateTrackReferencingArtworksPage.js @@ -1,37 +1,21 @@ export default { - contentDependencies: [ - 'generateAlbumStyleRules', - 'generateBackToTrackLink', - 'generateReferencingArtworksPage', - 'generateTrackCoverArtwork', - 'generateTrackNavLinks', - ], - - extraDependencies: ['html', 'language'], - 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 +26,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..c880fe63 --- /dev/null +++ b/src/content/dependencies/generateTrackReleaseBox.js @@ -0,0 +1,38 @@ +export default { + 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..0207e574 100644 --- a/src/content/dependencies/generateTrackReleaseInfo.js +++ b/src/content/dependencies/generateTrackReleaseInfo.js @@ -1,29 +1,19 @@ -import {empty} from '#sugar'; +import {compareArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateReleaseInfoContributionsLine', - 'linkExternal', - ], - - extraDependencies: ['html', 'language'], - relations(relation, track) { const relations = {}; - relations.artistContributionLinks = - relation('generateReleaseInfoContributionsLine', track.artistContribs); + relations.artistContributionsLine = + relation('generateReleaseInfoContributionsLine', + track.artistContribs, + track.artistText); - if (track.hasUniqueCoverArt) { - relations.coverArtistContributionsLine = - relation('generateReleaseInfoContributionsLine', track.coverArtistContribs); - } + relations.listenLine = + relation('generateReleaseInfoListenLine', track); - if (!empty(track.urls)) { - relations.externalLinks = - track.urls.map(url => - relation('linkExternal', url)); - } + relations.albumLink = + relation('linkAlbum', track.album); return relations; }, @@ -35,9 +25,18 @@ export default { data.date = track.date; data.duration = track.duration; + const {album} = track; + + data.showAlbum = + album.showAlbumInTracksWithoutArtists && + track.artistContribs.every(({annotation}) => !annotation) && + compareArrays( + track.artistContribs.map(({artist}) => artist), + album.artistContribs.map(({artist}) => artist), + {checkOrder: true}); + if ( track.hasUniqueCoverArt && - track.coverArtDate && +track.coverArtDate !== +track.date ) { data.coverArtDate = track.coverArtDate; @@ -54,15 +53,21 @@ export default { {[html.joinChildren]: html.tag('br')}, [ - relations.artistContributionLinks.slots({ - stringKey: capsule + '.by', - featuringStringKey: capsule + '.by.featuring', - chronologyKind: 'track', - }), + language.encapsulate(capsule, 'by', capsule => { + const withAlbum = + (data.showAlbum ? '.withAlbum' : ''); - relations.coverArtistContributionsLine?.slots({ - stringKey: capsule + '.coverArtBy', - chronologyKind: 'trackArt', + const albumOptions = + (data.showAlbum ? {album: relations.albumLink} : {}); + + return relations.artistContributionsLine.slots({ + stringKey: capsule + withAlbum, + featuringStringKey: capsule + '.featuring' + withAlbum, + + additionalStringOptions: albumOptions, + + chronologyKind: 'track', + }); }), language.$(capsule, 'released', { @@ -70,11 +75,6 @@ export default { 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 +82,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..94453f7d 100644 --- a/src/content/dependencies/generateTrackSocialEmbed.js +++ b/src/content/dependencies/generateTrackSocialEmbed.js @@ -1,11 +1,4 @@ export default { - contentDependencies: [ - 'generateSocialEmbed', - 'generateTrackSocialEmbedDescription', - ], - - extraDependencies: ['absoluteTo', 'language', 'urls'], - relations(relation, track) { return { socialEmbed: @@ -26,20 +19,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 +50,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/generateTrackSocialEmbedDescription.js b/src/content/dependencies/generateTrackSocialEmbedDescription.js index 4706aa26..97a4017f 100644 --- a/src/content/dependencies/generateTrackSocialEmbedDescription.js +++ b/src/content/dependencies/generateTrackSocialEmbedDescription.js @@ -1,8 +1,6 @@ import {empty} from '#sugar'; export default { - extraDependencies: ['html', 'language'], - data: (track) => ({ artistNames: track.artistContribs diff --git a/src/content/dependencies/generateUnsafeMunchy.js b/src/content/dependencies/generateUnsafeMunchy.js index c11aadc7..df8231ef 100644 --- a/src/content/dependencies/generateUnsafeMunchy.js +++ b/src/content/dependencies/generateUnsafeMunchy.js @@ -1,6 +1,4 @@ export default { - extraDependencies: ['html'], - slots: { contentSource: {type: 'string'}, }, diff --git a/src/content/dependencies/generateWallpaperStyleTag.js b/src/content/dependencies/generateWallpaperStyleTag.js new file mode 100644 index 00000000..b89f01c2 --- /dev/null +++ b/src/content/dependencies/generateWallpaperStyleTag.js @@ -0,0 +1,77 @@ +import {empty, stitchArrays} from '#sugar'; + +export default { + 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..5e3ff381 --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageActionsRow.js @@ -0,0 +1,20 @@ +export default { + 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..8f4b3400 --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageAlbumCarouselRow.js @@ -0,0 +1,20 @@ +export default { + 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..6d167bdd --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageAlbumGridRow.js @@ -0,0 +1,75 @@ +import {empty, stitchArrays} from '#sugar'; +import {getNewAdditions, getNewReleases} from '#wiki-data'; + +export default { + 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..3a06a7c3 100644 --- a/src/content/dependencies/generateWikiHomeNewsBox.js +++ b/src/content/dependencies/generateWikiHomepageNewsBox.js @@ -1,14 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generatePageSidebarBox', - 'linkNewsEntry', - 'transformContent', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({newsData}) => ({ entries: newsData.slice(0, 3), diff --git a/src/content/dependencies/generateWikiHomepagePage.js b/src/content/dependencies/generateWikiHomepagePage.js new file mode 100644 index 00000000..9029131b --- /dev/null +++ b/src/content/dependencies/generateWikiHomepagePage.js @@ -0,0 +1,86 @@ +export default { + 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..5fc0c76f --- /dev/null +++ b/src/content/dependencies/generateWikiHomepageSection.js @@ -0,0 +1,30 @@ +export default { + 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..be52bcc1 --- /dev/null +++ b/src/content/dependencies/generateWikiWallpaperStyleTag.js @@ -0,0 +1,35 @@ +export default { + 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..1b6b08dd 100644 --- a/src/content/dependencies/image.js +++ b/src/content/dependencies/image.js @@ -2,88 +2,89 @@ import {logWarn} from '#cli'; import {empty} from '#sugar'; export default { - extraDependencies: [ - 'checkIfImagePathHasCachedThumbnails', - 'getDimensionsOfImagePath', - 'getSizeOfImagePath', - 'getThumbnailEqualOrSmaller', - 'getThumbnailsAvailableForDimensions', - 'html', - 'language', - 'missingImagePaths', - 'to', - ], - - 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}, - - dimensions: { - validate: v => v.isDimensions, + // Added to the <img>. + imgAttributes: { + type: 'attributes', + mutable: false, }, + // Added to the <img> itself. alt: {type: 'string'}, - attributes: { - type: 'attributes', - mutable: false, + // 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. + + src: {type: 'string'}, + + 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 +92,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 +121,29 @@ 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.imgAttributes, + 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 +169,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 +224,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 +323,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..cfa6346c 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); @@ -87,6 +94,8 @@ export function watchContentDependencies({ const filePaths = files.map(file => path.join(watchPath, file)); for (const filePath of filePaths) { if (filePath === metaPath) continue; + if (filePath.endsWith('.DS_Store')) continue; + const functionName = getFunctionName(filePath); if (!isMocked(functionName)) { contentDependencies[functionName] = null; @@ -98,8 +107,9 @@ export function watchContentDependencies({ watcher.on('all', (event, filePath) => { if (!['add', 'change'].includes(event)) return; if (filePath === metaPath) return; - handlePathUpdated(filePath); + if (filePath.endsWith('.DS_Store')) return; + handlePathUpdated(filePath); }); watcher.on('unlink', (filePath) => { @@ -108,6 +118,8 @@ export function watchContentDependencies({ return; } + if (filePath.endsWith('.DS_Store')) return; + handlePathRemoved(filePath); }); diff --git a/src/content/dependencies/linkAdditionalFile.js b/src/content/dependencies/linkAdditionalFile.js new file mode 100644 index 00000000..1b5e650f --- /dev/null +++ b/src/content/dependencies/linkAdditionalFile.js @@ -0,0 +1,27 @@ +export default { + 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/linkAlbum.js b/src/content/dependencies/linkAlbum.js index 36b0d13a..085d5f62 100644 --- a/src/content/dependencies/linkAlbum.js +++ b/src/content/dependencies/linkAlbum.js @@ -1,8 +1,18 @@ export default { - contentDependencies: ['linkThing'], + relations: (relation, album) => ({ + link: + (album.style === 'single' + ? relation('linkTrack', album.tracks[0]) + : relation('linkThing', 'localized.album', album)), + }), - relations: (relation, album) => - ({link: relation('linkThing', 'localized.album', album)}), + data: (album) => ({ + style: album.style, + name: album.name, + }), - generate: (relations) => relations.link, + generate: (data, relations, {language}) => + (data.style === 'single' + ? relations.link.slot('content', language.sanitize(data.name)) + : relations.link), }; 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/linkAlbumCommentary.js b/src/content/dependencies/linkAlbumCommentary.js index ab519fd6..f1917345 100644 --- a/src/content/dependencies/linkAlbumCommentary.js +++ b/src/content/dependencies/linkAlbumCommentary.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, album) => ({link: relation('linkThing', 'localized.albumCommentary', album)}), diff --git a/src/content/dependencies/linkAlbumDynamically.js b/src/content/dependencies/linkAlbumDynamically.js index 7173b417..ba572c8d 100644 --- a/src/content/dependencies/linkAlbumDynamically.js +++ b/src/content/dependencies/linkAlbumDynamically.js @@ -1,12 +1,6 @@ -export default { - contentDependencies: [ - 'linkAlbumCommentary', - 'linkAlbumGallery', - 'linkAlbum', - ], - - extraDependencies: ['html', 'pagePath'], +import {empty} from '#sugar'; +export default { relations: (relation, album) => ({ galleryLink: relation('linkAlbumGallery', album), @@ -23,7 +17,7 @@ export default { album.directory, albumHasCommentary: - !!album.commentary, + !empty(album.commentary), }), slots: { diff --git a/src/content/dependencies/linkAlbumGallery.js b/src/content/dependencies/linkAlbumGallery.js index e3f30a29..efba66d1 100644 --- a/src/content/dependencies/linkAlbumGallery.js +++ b/src/content/dependencies/linkAlbumGallery.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, album) => ({link: relation('linkThing', 'localized.albumGallery', album)}), diff --git a/src/content/dependencies/linkAlbumReferencedArtworks.js b/src/content/dependencies/linkAlbumReferencedArtworks.js index ba51b5e3..411bd2ab 100644 --- a/src/content/dependencies/linkAlbumReferencedArtworks.js +++ b/src/content/dependencies/linkAlbumReferencedArtworks.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, album) => ({link: relation('linkThing', 'localized.albumReferencedArtworks', album)}), diff --git a/src/content/dependencies/linkAlbumReferencingArtworks.js b/src/content/dependencies/linkAlbumReferencingArtworks.js index 4d5e799d..3aee9a4b 100644 --- a/src/content/dependencies/linkAlbumReferencingArtworks.js +++ b/src/content/dependencies/linkAlbumReferencingArtworks.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, album) => ({link: relation('linkThing', 'localized.albumReferencingArtworks', album)}), diff --git a/src/content/dependencies/linkAnythingMan.js b/src/content/dependencies/linkAnythingMan.js index d4697403..cb22baee 100644 --- a/src/content/dependencies/linkAnythingMan.js +++ b/src/content/dependencies/linkAnythingMan.js @@ -1,21 +1,13 @@ export default { - contentDependencies: [ - 'linkAlbum', - 'linkFlash', - 'linkTrack', - ], - - query: (thing) => ({ - referenceType: thing.constructor[Symbol.for('Thing.referenceType')], - }), - - relations: (relation, query, thing) => ({ + relations: (relation, thing) => ({ link: - (query.referenceType === 'album' + (thing.isAlbum ? relation('linkAlbum', thing) - : query.referenceType === 'flash' + : thing.isArtwork + ? relation('linkArtwork', thing) + : thing.isFlash ? relation('linkFlash', thing) - : query.referenceType === 'track' + : thing.isTrack ? relation('linkTrack', thing) : null), }), diff --git a/src/content/dependencies/linkArtTag.js b/src/content/dependencies/linkArtTag.js deleted file mode 100644 index 7ddb7786..00000000 --- a/src/content/dependencies/linkArtTag.js +++ /dev/null @@ -1,8 +0,0 @@ -export default { - contentDependencies: ['linkThing'], - - relations: (relation, artTag) => - ({link: relation('linkThing', 'localized.tag', artTag)}), - - generate: (relations) => relations.link, -}; diff --git a/src/content/dependencies/linkArtTagDynamically.js b/src/content/dependencies/linkArtTagDynamically.js new file mode 100644 index 00000000..4514b7c1 --- /dev/null +++ b/src/content/dependencies/linkArtTagDynamically.js @@ -0,0 +1,11 @@ +export default { + 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..92ab1ed3 --- /dev/null +++ b/src/content/dependencies/linkArtTagGallery.js @@ -0,0 +1,6 @@ +export default { + relations: (relation, artTag) => + ({link: relation('linkThing', 'localized.artTagGallery', artTag)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtTagInfo.js b/src/content/dependencies/linkArtTagInfo.js new file mode 100644 index 00000000..5eb2ac56 --- /dev/null +++ b/src/content/dependencies/linkArtTagInfo.js @@ -0,0 +1,6 @@ +export default { + relations: (relation, artTag) => + ({link: relation('linkThing', 'localized.artTagInfo', artTag)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtist.js b/src/content/dependencies/linkArtist.js index 718ee6fa..917ae6b6 100644 --- a/src/content/dependencies/linkArtist.js +++ b/src/content/dependencies/linkArtist.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, artist) => ({link: relation('linkThing', 'localized.artist', artist)}), diff --git a/src/content/dependencies/linkArtistGallery.js b/src/content/dependencies/linkArtistGallery.js index 66dc172d..001eec1f 100644 --- a/src/content/dependencies/linkArtistGallery.js +++ b/src/content/dependencies/linkArtistGallery.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, artist) => ({link: relation('linkThing', 'localized.artistGallery', artist)}), diff --git a/src/content/dependencies/linkArtistRollingWindow.js b/src/content/dependencies/linkArtistRollingWindow.js new file mode 100644 index 00000000..6ab516ac --- /dev/null +++ b/src/content/dependencies/linkArtistRollingWindow.js @@ -0,0 +1,6 @@ +export default { + relations: (relation, artist) => + ({link: relation('linkThing', 'localized.artistRollingWindow', artist)}), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkArtwork.js b/src/content/dependencies/linkArtwork.js new file mode 100644 index 00000000..fce89229 --- /dev/null +++ b/src/content/dependencies/linkArtwork.js @@ -0,0 +1,13 @@ +export default { + relations: (relation, artwork) => ({ + link: + (artwork.thing.isAlbum + ? relation('linkAlbum', artwork.thing) + : artwork.thing.isTrack + ? relation('linkTrack', artwork.thing) + : null), + }), + + generate: (relations) => + relations.link, +}; diff --git a/src/content/dependencies/linkCommentaryIndex.js b/src/content/dependencies/linkCommentaryIndex.js index 5568ff84..e59b3641 100644 --- a/src/content/dependencies/linkCommentaryIndex.js +++ b/src/content/dependencies/linkCommentaryIndex.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkStationaryIndex'], - relations: (relation) => ({link: relation( diff --git a/src/content/dependencies/linkContribution.js b/src/content/dependencies/linkContribution.js index c658d461..aa9bdef9 100644 --- a/src/content/dependencies/linkContribution.js +++ b/src/content/dependencies/linkContribution.js @@ -1,12 +1,4 @@ export default { - contentDependencies: [ - 'generateContributionTooltip', - 'generateTextWithTooltip', - 'linkArtist', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, contribution) => ({ artistLink: relation('linkArtist', contribution.artist), @@ -24,13 +16,15 @@ export default { }), slots: { + content: {type: 'html', mutable: false}, + showAnnotation: {type: 'boolean', default: false}, showExternalLinks: {type: 'boolean', default: false}, showChronology: {type: 'boolean', default: false}, trimAnnotation: {type: 'boolean', default: false}, - preventWrapping: {type: 'boolean', default: true}, + preventWrapping: {type: 'boolean', default: false}, preventTooltip: {type: 'boolean', default: false}, chronologyKind: {type: 'string'}, @@ -46,6 +40,10 @@ export default { language.encapsulate('misc.artistLink', workingCapsule => { const workingOptions = {}; + if (!html.isBlank(slots.content)) { + relations.artistLink.setSlot('content', slots.content); + } + // Filling slots early is necessary to actually give the tooltip // content. Otherwise, the coming-up html.isBlank() always reports // the tooltip as blank! diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 073c821e..ad8d4f23 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -1,9 +1,20 @@ import {isExternalLinkContext, isExternalLinkStyle} from '#external-links'; export default { - extraDependencies: ['html', 'language', 'wikiData'], + sprawl: ({wikiInfo}) => ({ + canonicalBase: + wikiInfo.canonicalBase, - data: (url) => ({url}), + canonicalMediaBase: + wikiInfo.canonicalMediaBase, + }), + + data: (sprawl, url) => ({ + url, + + canonicalBase: + sprawl.canonicalBase, + }), slots: { content: { @@ -39,25 +50,44 @@ export default { default: false, }, + disableBrowserTooltip: { + type: 'boolean', + default: false, + }, + tab: { validate: v => v.is('default', 'separate'), default: 'default', }, }, - generate(data, slots, {html, language}) { + generate(data, slots, {html, language, to}) { + const {url} = data; + let urlIsValid; try { - new URL(data.url); + new URL(url); urlIsValid = true; - } catch (error) { + } catch { urlIsValid = false; } + let href; + if (urlIsValid) { + const {canonicalBase, canonicalMediaBase} = data; + if (canonicalMediaBase && url.startsWith(canonicalMediaBase)) { + href = to('media.path', url.slice(canonicalMediaBase.length)); + } else if (canonicalBase && url.startsWith(canonicalBase)) { + href = to('shared.path', url.slice(canonicalBase.length)); + } else { + href = url; + } + } + let formattedLink; if (urlIsValid) { formattedLink = - language.formatExternalLink(data.url, { + language.formatExternalLink(url, { style: slots.style, context: slots.context, }); @@ -65,7 +95,7 @@ export default { // Fall back to platform if nothing matched the desired style. if (html.isBlank(formattedLink) && slots.style !== 'platform') { formattedLink = - language.formatExternalLink(data.url, { + language.formatExternalLink(url, { style: 'platform', context: slots.context, }); @@ -80,7 +110,7 @@ export default { let linkContent; if (urlIsValid) { - linkAttributes.set('href', data.url); + linkAttributes.set('href', href); if (html.isBlank(slots.content)) { linkContent = formattedLink; @@ -111,7 +141,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/linkFlash.js b/src/content/dependencies/linkFlash.js index 93dd5a28..cfc01079 100644 --- a/src/content/dependencies/linkFlash.js +++ b/src/content/dependencies/linkFlash.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, flash) => ({link: relation('linkThing', 'localized.flash', flash)}), diff --git a/src/content/dependencies/linkFlashAct.js b/src/content/dependencies/linkFlashAct.js index 82c23325..069bedf4 100644 --- a/src/content/dependencies/linkFlashAct.js +++ b/src/content/dependencies/linkFlashAct.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['generateUnsafeMunchy', 'linkThing'], - relations: (relation, flashAct) => ({ unsafeMunchy: relation('generateUnsafeMunchy'), diff --git a/src/content/dependencies/linkFlashIndex.js b/src/content/dependencies/linkFlashIndex.js index 6dd0710e..9c1b076e 100644 --- a/src/content/dependencies/linkFlashIndex.js +++ b/src/content/dependencies/linkFlashIndex.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkStationaryIndex'], - relations: (relation) => ({link: relation( diff --git a/src/content/dependencies/linkFlashSide.js b/src/content/dependencies/linkFlashSide.js new file mode 100644 index 00000000..6407ef25 --- /dev/null +++ b/src/content/dependencies/linkFlashSide.js @@ -0,0 +1,20 @@ +export default { + 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/linkGroup.js b/src/content/dependencies/linkGroup.js index ebab1b5b..10bec2fb 100644 --- a/src/content/dependencies/linkGroup.js +++ b/src/content/dependencies/linkGroup.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, group) => ({link: relation('linkThing', 'localized.groupInfo', group)}), diff --git a/src/content/dependencies/linkGroupDynamically.js b/src/content/dependencies/linkGroupDynamically.js index 90303ed1..0b5bd85c 100644 --- a/src/content/dependencies/linkGroupDynamically.js +++ b/src/content/dependencies/linkGroupDynamically.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkGroupGallery', 'linkGroup'], - extraDependencies: ['pagePath'], - relations: (relation, group) => ({ galleryLink: relation('linkGroupGallery', group), infoLink: relation('linkGroup', group), diff --git a/src/content/dependencies/linkGroupExtra.js b/src/content/dependencies/linkGroupExtra.js index bc3c0580..1a6161c1 100644 --- a/src/content/dependencies/linkGroupExtra.js +++ b/src/content/dependencies/linkGroupExtra.js @@ -1,13 +1,6 @@ import {empty} from '#sugar'; export default { - contentDependencies: [ - 'linkGroup', - 'linkGroupGallery', - ], - - extraDependencies: ['html'], - relations(relation, group) { const relations = {}; diff --git a/src/content/dependencies/linkGroupGallery.js b/src/content/dependencies/linkGroupGallery.js index 86c4a0f3..957756d8 100644 --- a/src/content/dependencies/linkGroupGallery.js +++ b/src/content/dependencies/linkGroupGallery.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, group) => ({link: relation('linkThing', 'localized.groupGallery', group)}), diff --git a/src/content/dependencies/linkListing.js b/src/content/dependencies/linkListing.js index ac66919a..4eb2dce6 100644 --- a/src/content/dependencies/linkListing.js +++ b/src/content/dependencies/linkListing.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkThing'], - extraDependencies: ['language'], - relations: (relation, listing) => ({link: relation('linkThing', 'localized.listing', listing)}), diff --git a/src/content/dependencies/linkListingIndex.js b/src/content/dependencies/linkListingIndex.js index 1bfaf46e..209066a9 100644 --- a/src/content/dependencies/linkListingIndex.js +++ b/src/content/dependencies/linkListingIndex.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkStationaryIndex'], - relations: (relation) => ({link: relation( diff --git a/src/content/dependencies/linkNewsEntry.js b/src/content/dependencies/linkNewsEntry.js index 1fb32dd9..9ef7ac0e 100644 --- a/src/content/dependencies/linkNewsEntry.js +++ b/src/content/dependencies/linkNewsEntry.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, newsEntry) => ({link: relation('linkThing', 'localized.newsEntry', newsEntry)}), diff --git a/src/content/dependencies/linkNewsIndex.js b/src/content/dependencies/linkNewsIndex.js index e911a384..4414afc6 100644 --- a/src/content/dependencies/linkNewsIndex.js +++ b/src/content/dependencies/linkNewsIndex.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkStationaryIndex'], - relations: (relation) => ({link: relation( diff --git a/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js new file mode 100644 index 00000000..5a16256e --- /dev/null +++ b/src/content/dependencies/linkOtherReleaseOnArtistInfoPage.js @@ -0,0 +1,59 @@ +import {sortAlbumsTracksChronologically, sortContributionsChronologically} + from '#sort'; +import {chunkArtistTrackContributions} from '#wiki-data'; + +export default { + 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..344b7d2c 100644 --- a/src/content/dependencies/linkPathFromMedia.js +++ b/src/content/dependencies/linkPathFromMedia.js @@ -1,13 +1,53 @@ -export default { - contentDependencies: ['linkTemplate'], +import {empty} from '#sugar'; +export default { 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/linkPathFromRoot.js b/src/content/dependencies/linkPathFromRoot.js index dab3ac1f..b4a90c07 100644 --- a/src/content/dependencies/linkPathFromRoot.js +++ b/src/content/dependencies/linkPathFromRoot.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkTemplate'], - relations: (relation) => ({link: relation('linkTemplate')}), diff --git a/src/content/dependencies/linkPathFromSite.js b/src/content/dependencies/linkPathFromSite.js index 64676465..67a43059 100644 --- a/src/content/dependencies/linkPathFromSite.js +++ b/src/content/dependencies/linkPathFromSite.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkTemplate'], - relations: (relation) => ({link: relation('linkTemplate')}), diff --git a/src/content/dependencies/linkReferencedArtworks.js b/src/content/dependencies/linkReferencedArtworks.js new file mode 100644 index 00000000..f8b3f3c8 --- /dev/null +++ b/src/content/dependencies/linkReferencedArtworks.js @@ -0,0 +1,12 @@ +export default { + relations: (relation, artwork) => ({ + link: + (artwork.thing.isAlbum + ? relation('linkAlbumReferencedArtworks', artwork.thing) + : artwork.thing.isTrack + ? 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..6b7e4f9a --- /dev/null +++ b/src/content/dependencies/linkReferencingArtworks.js @@ -0,0 +1,12 @@ +export default { + relations: (relation, artwork) => ({ + link: + (artwork.thing.isAlbum + ? relation('linkAlbumReferencingArtworks', artwork.thing) + : artwork.thing.isTrack + ? relation('linkTrackReferencingArtworks', artwork.thing) + : null), + }), + + generate: (relations) => relations.link, +}; diff --git a/src/content/dependencies/linkStaticPage.js b/src/content/dependencies/linkStaticPage.js index 032af6c9..c3ac69fa 100644 --- a/src/content/dependencies/linkStaticPage.js +++ b/src/content/dependencies/linkStaticPage.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, staticPage) => ({link: relation('linkThing', 'localized.staticPage', staticPage)}), diff --git a/src/content/dependencies/linkStationaryIndex.js b/src/content/dependencies/linkStationaryIndex.js index d5506e60..10f8ba44 100644 --- a/src/content/dependencies/linkStationaryIndex.js +++ b/src/content/dependencies/linkStationaryIndex.js @@ -1,9 +1,6 @@ // Not to be confused with "html.Stationery". export default { - contentDependencies: ['linkTemplate'], - extraDependencies: ['language'], - relations(relation) { return { linkTemplate: relation('linkTemplate'), diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js index 63cc82e8..10466b43 100644 --- a/src/content/dependencies/linkTemplate.js +++ b/src/content/dependencies/linkTemplate.js @@ -3,13 +3,6 @@ import {empty} from '#sugar'; import striptags from 'striptags'; export default { - extraDependencies: [ - 'appendIndexHTML', - 'html', - 'language', - 'to', - ], - slots: { href: {type: 'string'}, path: {validate: v => v.validateArrayItems(v.isString)}, @@ -26,6 +19,11 @@ export default { type: 'html', mutable: false, }, + + suffixNormalContent: { + type: 'html', + mutable: false, + }, }, generate(slots, { @@ -61,13 +59,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/linkThing.js b/src/content/dependencies/linkThing.js index 3902f380..7784afe7 100644 --- a/src/content/dependencies/linkThing.js +++ b/src/content/dependencies/linkThing.js @@ -1,13 +1,4 @@ export default { - contentDependencies: [ - 'generateColorStyleAttribute', - 'generateTextWithTooltip', - 'generateTooltip', - 'linkTemplate', - ], - - extraDependencies: ['html', 'language'], - relations: (relation, _pathKey, thing) => ({ linkTemplate: relation('linkTemplate'), @@ -20,11 +11,15 @@ export default { tooltip: relation('generateTooltip'), + + name: + relation('generateName', thing), }), data: (pathKey, thing) => ({ name: thing.name, nameShort: thing.nameShort ?? thing.shortName, + nameText: thing.nameText, path: (pathKey @@ -75,7 +70,7 @@ export default { hash: {type: 'string'}, }, - generate(data, relations, slots, {html, language}) { + generate(data, relations, slots, {html}) { const path = slots.path ?? data.path; @@ -83,14 +78,12 @@ export default { const wrapperAttributes = html.attributes(); const showShortName = - (slots.preferShortName - ? data.nameShort && data.nameShort !== data.name - : false); + slots.preferShortName && + !data.nameText && + data.nameShort && + data.nameShort !== data.name; - const name = - (showShortName - ? data.nameShort - : data.name); + const name = relations.name; const showWikiTooltip = (slots.tooltipStyle === 'auto' @@ -114,7 +107,7 @@ export default { const content = (html.isBlank(slots.content) - ? language.sanitize(name) + ? name : slots.content); if (slots.color !== false) { diff --git a/src/content/dependencies/linkTrack.js b/src/content/dependencies/linkTrack.js index d5d96726..8ee715f0 100644 --- a/src/content/dependencies/linkTrack.js +++ b/src/content/dependencies/linkTrack.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, track) => ({link: relation('linkThing', 'localized.track', track)}), diff --git a/src/content/dependencies/linkTrackDynamically.js b/src/content/dependencies/linkTrackDynamically.js index 242cd4cb..088bbe09 100644 --- a/src/content/dependencies/linkTrackDynamically.js +++ b/src/content/dependencies/linkTrackDynamically.js @@ -1,7 +1,6 @@ -export default { - contentDependencies: ['linkTrack'], - extraDependencies: ['pagePath'], +import {empty} from '#sugar'; +export default { relations: (relation, track) => ({ infoLink: relation('linkTrack', track), }), @@ -14,7 +13,7 @@ export default { track.album.directory, trackHasCommentary: - !!track.commentary, + !empty(track.commentary), }), generate(data, relations, {pagePath}) { diff --git a/src/content/dependencies/linkTrackReferencedArtworks.js b/src/content/dependencies/linkTrackReferencedArtworks.js index b4cb08fe..6da6504e 100644 --- a/src/content/dependencies/linkTrackReferencedArtworks.js +++ b/src/content/dependencies/linkTrackReferencedArtworks.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, track) => ({link: relation('linkThing', 'localized.trackReferencedArtworks', track)}), diff --git a/src/content/dependencies/linkTrackReferencingArtworks.js b/src/content/dependencies/linkTrackReferencingArtworks.js index c9c9f4d1..4d113ba7 100644 --- a/src/content/dependencies/linkTrackReferencingArtworks.js +++ b/src/content/dependencies/linkTrackReferencingArtworks.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['linkThing'], - relations: (relation, track) => ({link: relation('linkThing', 'localized.trackReferencingArtworks', track)}), diff --git a/src/content/dependencies/linkWikiHome.js b/src/content/dependencies/linkWikiHomepage.js index d8d3d0a0..91fbe410 100644 --- a/src/content/dependencies/linkWikiHome.js +++ b/src/content/dependencies/linkWikiHomepage.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['linkTemplate'], - extraDependencies: ['wikiData'], - sprawl({wikiInfo}) { return {wikiShortName: wikiInfo.nameShort}; }, diff --git a/src/content/dependencies/listAlbumsByDate.js b/src/content/dependencies/listAlbumsByDate.js index c83ffc97..eaf9eecf 100644 --- a/src/content/dependencies/listAlbumsByDate.js +++ b/src/content/dependencies/listAlbumsByDate.js @@ -2,9 +2,6 @@ import {sortChronologically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listAlbumsByDateAdded.js b/src/content/dependencies/listAlbumsByDateAdded.js index d462ad46..940da67d 100644 --- a/src/content/dependencies/listAlbumsByDateAdded.js +++ b/src/content/dependencies/listAlbumsByDateAdded.js @@ -2,9 +2,6 @@ import {sortAlphabetically} from '#sort'; import {chunkByProperties} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listAlbumsByDuration.js b/src/content/dependencies/listAlbumsByDuration.js index c60685ab..8de2bb84 100644 --- a/src/content/dependencies/listAlbumsByDuration.js +++ b/src/content/dependencies/listAlbumsByDuration.js @@ -3,16 +3,17 @@ import {filterByCount, stitchArrays} from '#sugar'; import {getTotalDuration} from '#wiki-data'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, query({albumData}, spec) { - const albums = sortAlphabetically(albumData.slice()); - const durations = albums.map(album => getTotalDuration(album.tracks)); + const albums = + sortAlphabetically( + albumData.filter(album => !album.hideDuration)); + + const durations = + albums.map(album => getTotalDuration(album.tracks)); filterByCount(albums, durations); sortByCount(albums, durations, {greatestFirst: true}); diff --git a/src/content/dependencies/listAlbumsByName.js b/src/content/dependencies/listAlbumsByName.js index 21419537..a7939292 100644 --- a/src/content/dependencies/listAlbumsByName.js +++ b/src/content/dependencies/listAlbumsByName.js @@ -2,9 +2,6 @@ import {sortAlphabetically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listAlbumsByTracks.js b/src/content/dependencies/listAlbumsByTracks.js index 798e6c2e..b1f62a82 100644 --- a/src/content/dependencies/listAlbumsByTracks.js +++ b/src/content/dependencies/listAlbumsByTracks.js @@ -2,21 +2,25 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, query({albumData}, spec) { - const albums = sortAlphabetically(albumData.slice()); - const counts = albums.map(album => album.tracks.length); + const albums = + sortAlphabetically( + albumData.filter(album => !album.hideDuration)); + + const counts = + albums.map(album => album.tracks.length); filterByCount(albums, counts); sortByCount(albums, counts, {greatestFirst: true}); - return {spec, albums, counts}; + const styles = + albums.map(album => album.style); + + return {spec, albums, counts, styles}; }, relations(relation, query) { @@ -32,6 +36,7 @@ export default { data(query) { return { counts: query.counts, + styles: query.styles, }; }, @@ -42,10 +47,19 @@ export default { stitchArrays({ link: relations.albumLinks, count: data.counts, - }).map(({link, count}) => ({ - album: link, - tracks: language.countTracks(count, {unit: true}), - })), + style: data.styles, + }).map(({link, count, style}) => { + const row = { + album: link, + tracks: language.countTracks(count, {unit: true}), + }; + + if (style === 'single') { + row.stringsKey = 'single'; + } + + return row; + }), }); }, }; diff --git a/src/content/dependencies/listAllAdditionalFiles.js b/src/content/dependencies/listAllAdditionalFiles.js index a6e34b9a..2d338916 100644 --- a/src/content/dependencies/listAllAdditionalFiles.js +++ b/src/content/dependencies/listAllAdditionalFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listAllAdditionalFilesTemplate'], - relations: (relation, spec) => ({page: relation('listAllAdditionalFilesTemplate', spec, 'additionalFiles')}), diff --git a/src/content/dependencies/listAllAdditionalFilesTemplate.js b/src/content/dependencies/listAllAdditionalFilesTemplate.js index e33ad7b5..f298233c 100644 --- a/src/content/dependencies/listAllAdditionalFilesTemplate.js +++ b/src/content/dependencies/listAllAdditionalFilesTemplate.js @@ -1,209 +1,37 @@ import {sortChronologically} from '#sort'; -import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateListingPage', - 'generateListAllAdditionalFilesChunk', - 'linkAlbum', - 'linkTrack', - 'linkAlbumAdditionalFile', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({albumData}) => ({albumData}), - query(sprawl, spec, property) { - const albums = - sortChronologically(sprawl.albumData.slice()); - - 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))); + query: (sprawl, spec, property) => ({ + spec, + property, - 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/listAllMidiProjectFiles.js b/src/content/dependencies/listAllMidiProjectFiles.js index 31a70ef0..109cf2e7 100644 --- a/src/content/dependencies/listAllMidiProjectFiles.js +++ b/src/content/dependencies/listAllMidiProjectFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listAllAdditionalFilesTemplate'], - relations: (relation, spec) => ({page: relation('listAllAdditionalFilesTemplate', spec, 'midiProjectFiles')}), diff --git a/src/content/dependencies/listAllSheetMusicFiles.js b/src/content/dependencies/listAllSheetMusicFiles.js index 166b2068..4f3bdb96 100644 --- a/src/content/dependencies/listAllSheetMusicFiles.js +++ b/src/content/dependencies/listAllSheetMusicFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listAllAdditionalFilesTemplate'], - relations: (relation, spec) => ({page: relation('listAllAdditionalFilesTemplate', spec, 'sheetMusicFiles')}), diff --git a/src/content/dependencies/listArtTagNetwork.js b/src/content/dependencies/listArtTagNetwork.js index b3a54747..98f81019 100644 --- a/src/content/dependencies/listArtTagNetwork.js +++ b/src/content/dependencies/listArtTagNetwork.js @@ -1 +1,363 @@ -export default {generate() {}}; +import {sortAlphabetically} from '#sort'; +import {empty, stitchArrays, unique} from '#sugar'; + +export default { + 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..10e9e873 100644 --- a/src/content/dependencies/listTagsByName.js +++ b/src/content/dependencies/listArtTagsByName.js @@ -1,10 +1,7 @@ import {sortAlphabetically} from '#sort'; -import {stitchArrays} from '#sugar'; +import {stitchArrays, unique} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtTag'], - extraDependencies: ['language', 'wikiData'], - sprawl({artTagData}) { return {artTagData}; }, @@ -16,7 +13,7 @@ export default { artTags: sortAlphabetically( artTagData - .filter(tag => !tag.isContentWarning)), + .filter(artTag => !artTag.isContentWarning)), }; }, @@ -26,15 +23,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..5131580f --- /dev/null +++ b/src/content/dependencies/listArtTagsByUses.js @@ -0,0 +1,51 @@ +import {sortAlphabetically, sortByCount} from '#sort'; +import {filterByCount, stitchArrays, unique} from '#sugar'; + +export default { + 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/listArtistsByCommentaryEntries.js b/src/content/dependencies/listArtistsByCommentaryEntries.js index eff2dba3..ab7bde6c 100644 --- a/src/content/dependencies/listArtistsByCommentaryEntries.js +++ b/src/content/dependencies/listArtistsByCommentaryEntries.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtist'], - extraDependencies: ['language', 'wikiData'], - sprawl({artistData}) { return {artistData}; }, diff --git a/src/content/dependencies/listArtistsByContributions.js b/src/content/dependencies/listArtistsByContributions.js index 41944959..2f8d6391 100644 --- a/src/content/dependencies/listArtistsByContributions.js +++ b/src/content/dependencies/listArtistsByContributions.js @@ -1,18 +1,8 @@ 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'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({artistData, wikiInfo}) { return { artistData, @@ -41,37 +31,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/listArtistsByDuration.js b/src/content/dependencies/listArtistsByDuration.js index 6b2a18a0..1d550b26 100644 --- a/src/content/dependencies/listArtistsByDuration.js +++ b/src/content/dependencies/listArtistsByDuration.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtist'], - extraDependencies: ['language', 'wikiData'], - sprawl({artistData}) { return {artistData}; }, diff --git a/src/content/dependencies/listArtistsByGroup.js b/src/content/dependencies/listArtistsByGroup.js index 0bf9dd2d..44564b4b 100644 --- a/src/content/dependencies/listArtistsByGroup.js +++ b/src/content/dependencies/listArtistsByGroup.js @@ -10,9 +10,6 @@ import { } from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl({artistData, wikiInfo}) { return {artistData, wikiInfo}; }, @@ -37,20 +34,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..dc7341cf 100644 --- a/src/content/dependencies/listArtistsByLatestContribution.js +++ b/src/content/dependencies/listArtistsByLatestContribution.js @@ -11,15 +11,6 @@ import { const {Album, Flash} = T; export default { - contentDependencies: [ - 'generateListingPage', - 'linkAlbum', - 'linkArtist', - 'linkFlash', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({albumData, artistData, flashData, trackData, wikiInfo}) => ({albumData, artistData, flashData, trackData, enableFlashesAndGames: wikiInfo.enableFlashesAndGames}), @@ -98,13 +89,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/listArtistsByName.js b/src/content/dependencies/listArtistsByName.js index 93218492..8bee4947 100644 --- a/src/content/dependencies/listArtistsByName.js +++ b/src/content/dependencies/listArtistsByName.js @@ -3,9 +3,6 @@ import {stitchArrays} from '#sugar'; import {getArtistNumContributions} from '#wiki-data'; export default { - contentDependencies: ['generateListingPage', 'linkArtist', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl: ({artistData, wikiInfo}) => ({artistData, wikiInfo}), diff --git a/src/content/dependencies/listGroupsByAlbums.js b/src/content/dependencies/listGroupsByAlbums.js index 4adfb6d9..64814640 100644 --- a/src/content/dependencies/listGroupsByAlbums.js +++ b/src/content/dependencies/listGroupsByAlbums.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, diff --git a/src/content/dependencies/listGroupsByCategory.js b/src/content/dependencies/listGroupsByCategory.js index 43919bef..4c10a1e4 100644 --- a/src/content/dependencies/listGroupsByCategory.js +++ b/src/content/dependencies/listGroupsByCategory.js @@ -1,9 +1,6 @@ import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupCategoryData}) { return {groupCategoryData}; }, diff --git a/src/content/dependencies/listGroupsByDuration.js b/src/content/dependencies/listGroupsByDuration.js index da2f26db..089915c2 100644 --- a/src/content/dependencies/listGroupsByDuration.js +++ b/src/content/dependencies/listGroupsByDuration.js @@ -3,9 +3,6 @@ import {filterByCount, stitchArrays} from '#sugar'; import {getTotalDuration} from '#wiki-data'; export default { - contentDependencies: ['generateListingPage', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, @@ -16,7 +13,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/listGroupsByLatestAlbum.js b/src/content/dependencies/listGroupsByLatestAlbum.js index 48319314..2d83a354 100644 --- a/src/content/dependencies/listGroupsByLatestAlbum.js +++ b/src/content/dependencies/listGroupsByLatestAlbum.js @@ -2,15 +2,6 @@ import {compareDates, sortChronologically} from '#sort'; import {filterMultipleArrays, sortMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: [ - 'generateListingPage', - 'linkAlbum', - 'linkGroup', - 'linkGroupGallery', - ], - - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, diff --git a/src/content/dependencies/listGroupsByName.js b/src/content/dependencies/listGroupsByName.js index 696a49bd..e3308158 100644 --- a/src/content/dependencies/listGroupsByName.js +++ b/src/content/dependencies/listGroupsByName.js @@ -2,9 +2,6 @@ import {sortAlphabetically} from '#sort'; import {stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkGroup', 'linkGroupGallery'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, diff --git a/src/content/dependencies/listGroupsByTracks.js b/src/content/dependencies/listGroupsByTracks.js index 0b5e4e97..c9d97614 100644 --- a/src/content/dependencies/listGroupsByTracks.js +++ b/src/content/dependencies/listGroupsByTracks.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {accumulateSum, filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkGroup'], - extraDependencies: ['language', 'wikiData'], - sprawl({groupData}) { return {groupData}; }, diff --git a/src/content/dependencies/listRandomPageLinks.js b/src/content/dependencies/listRandomPageLinks.js index 79bba441..81eca274 100644 --- a/src/content/dependencies/listRandomPageLinks.js +++ b/src/content/dependencies/listRandomPageLinks.js @@ -2,14 +2,6 @@ import {sortChronologically} from '#sort'; import {empty} from '#sugar'; export default { - contentDependencies: [ - 'generateListingPage', - 'generateListRandomPageLinksAlbumLink', - 'linkGroup', - ], - - extraDependencies: ['html', 'language', 'wikiData'], - sprawl: ({albumData, wikiInfo}) => ({albumData, wikiInfo}), query(sprawl, spec) { 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/listTracksByAlbum.js b/src/content/dependencies/listTracksByAlbum.js index b2405034..f6858ada 100644 --- a/src/content/dependencies/listTracksByAlbum.js +++ b/src/content/dependencies/listTracksByAlbum.js @@ -1,7 +1,4 @@ export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listTracksByDate.js b/src/content/dependencies/listTracksByDate.js index 0a2bfd6c..9d63f19b 100644 --- a/src/content/dependencies/listTracksByDate.js +++ b/src/content/dependencies/listTracksByDate.js @@ -2,52 +2,54 @@ import {sortAlbumsTracksChronologically} from '#sort'; import {chunkByProperties, stitchArrays} from '#sugar'; 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)); - chunks: - chunkByProperties( - sortAlbumsTracksChronologically( - trackData.filter(track => track.date)), - ['album', 'date']), - }; + query.chunks = + chunkByProperties(query.tracks, ['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 +81,7 @@ export default { data.rereleases.map(rereleases => rereleases.map(rerelease => (rerelease - ? {class: 'rerelease'} + ? {class: 'rerelease-line'} : null))), }); }, diff --git a/src/content/dependencies/listTracksByDuration.js b/src/content/dependencies/listTracksByDuration.js index 64feb4f1..95fd28b2 100644 --- a/src/content/dependencies/listTracksByDuration.js +++ b/src/content/dependencies/listTracksByDuration.js @@ -2,9 +2,6 @@ import {sortAlphabetically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({trackData}) { return {trackData}; }, diff --git a/src/content/dependencies/listTracksByDurationInAlbum.js b/src/content/dependencies/listTracksByDurationInAlbum.js index c1ea32a1..ad44c7b2 100644 --- a/src/content/dependencies/listTracksByDurationInAlbum.js +++ b/src/content/dependencies/listTracksByDurationInAlbum.js @@ -2,9 +2,6 @@ import {sortByCount, sortChronologically} from '#sort'; import {filterByCount, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listTracksByName.js b/src/content/dependencies/listTracksByName.js index 773b0473..a9c2c504 100644 --- a/src/content/dependencies/listTracksByName.js +++ b/src/content/dependencies/listTracksByName.js @@ -1,9 +1,6 @@ import {sortAlphabetically} from '#sort'; export default { - contentDependencies: ['generateListingPage', 'linkTrack'], - extraDependencies: ['wikiData'], - sprawl({trackData}) { return {trackData}; }, diff --git a/src/content/dependencies/listTracksByTimesReferenced.js b/src/content/dependencies/listTracksByTimesReferenced.js index 5838ded0..8a57e1a6 100644 --- a/src/content/dependencies/listTracksByTimesReferenced.js +++ b/src/content/dependencies/listTracksByTimesReferenced.js @@ -2,9 +2,6 @@ import {sortAlbumsTracksChronologically, sortByCount} from '#sort'; import {filterByCount, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({trackData}) { return {trackData}; }, diff --git a/src/content/dependencies/listTracksInFlashesByAlbum.js b/src/content/dependencies/listTracksInFlashesByAlbum.js index 8ca0d993..db5472db 100644 --- a/src/content/dependencies/listTracksInFlashesByAlbum.js +++ b/src/content/dependencies/listTracksInFlashesByAlbum.js @@ -2,9 +2,6 @@ import {sortChronologically} from '#sort'; import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'], - extraDependencies: ['language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listTracksInFlashesByFlash.js b/src/content/dependencies/listTracksInFlashesByFlash.js index 6ab954ed..325b3cb5 100644 --- a/src/content/dependencies/listTracksInFlashesByFlash.js +++ b/src/content/dependencies/listTracksInFlashesByFlash.js @@ -2,9 +2,6 @@ import {sortFlashesChronologically} from '#sort'; import {empty, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkFlash', 'linkTrack'], - extraDependencies: ['wikiData'], - sprawl({flashData}) { return {flashData}; }, diff --git a/src/content/dependencies/listTracksNeedingLyrics.js b/src/content/dependencies/listTracksNeedingLyrics.js new file mode 100644 index 00000000..d21fcd06 --- /dev/null +++ b/src/content/dependencies/listTracksNeedingLyrics.js @@ -0,0 +1,7 @@ +export default { + relations: (relation, spec) => + ({page: relation('listTracksWithExtra', spec, 'needsLyrics', 'truthy')}), + + generate: (relations) => + relations.page, +}; diff --git a/src/content/dependencies/listTracksWithExtra.js b/src/content/dependencies/listTracksWithExtra.js index c7f42f9d..09d8ee21 100644 --- a/src/content/dependencies/listTracksWithExtra.js +++ b/src/content/dependencies/listTracksWithExtra.js @@ -2,9 +2,6 @@ import {sortChronologically} from '#sort'; import {empty, filterMultipleArrays, stitchArrays} from '#sugar'; export default { - contentDependencies: ['generateListingPage', 'linkAlbum', 'linkTrack'], - extraDependencies: ['html', 'language', 'wikiData'], - sprawl({albumData}) { return {albumData}; }, diff --git a/src/content/dependencies/listTracksWithLyrics.js b/src/content/dependencies/listTracksWithLyrics.js index a13a76f0..79d76bf3 100644 --- a/src/content/dependencies/listTracksWithLyrics.js +++ b/src/content/dependencies/listTracksWithLyrics.js @@ -1,8 +1,6 @@ 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/listTracksWithMidiProjectFiles.js b/src/content/dependencies/listTracksWithMidiProjectFiles.js index 418af4c2..9a48f6ae 100644 --- a/src/content/dependencies/listTracksWithMidiProjectFiles.js +++ b/src/content/dependencies/listTracksWithMidiProjectFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listTracksWithExtra'], - relations: (relation, spec) => ({page: relation('listTracksWithExtra', spec, 'midiProjectFiles', 'array')}), diff --git a/src/content/dependencies/listTracksWithSheetMusicFiles.js b/src/content/dependencies/listTracksWithSheetMusicFiles.js index 0c6761eb..f0ba4196 100644 --- a/src/content/dependencies/listTracksWithSheetMusicFiles.js +++ b/src/content/dependencies/listTracksWithSheetMusicFiles.js @@ -1,6 +1,4 @@ export default { - contentDependencies: ['listTracksWithExtra'], - relations: (relation, spec) => ({page: relation('listTracksWithExtra', spec, 'sheetMusicFiles', 'array')}), diff --git a/src/content/dependencies/transformContent.js b/src/content/dependencies/transformContent.js index 48e20f94..73452cfa 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,26 +49,47 @@ function getPlaceholder(node, content) { return {type: 'text', data: content.slice(node.i, node.iEnd)}; } -export default { - contentDependencies: [ - ...( - Object.values(replacerSpec) - .map(description => description.link) - .filter(Boolean)), - 'image', - 'linkExternal', - ], - - extraDependencies: ['html', 'language', 'to', 'wikiData'], +function getArg(node, argKey) { + return ( + node.data.args + ?.find(({key}) => key.data === argKey) + ?.value ?? + null); +} +export default { sprawl(wikiData, content) { - const find = bindFind(wikiData); + const find = + bindFind(wikiData, { + mode: 'quiet', + fuzz: { + capitalization: true, + kebab: true, + }, + }); - const parsedNodes = parseInput(content ?? ''); + const {result: parsedNodes, error} = + parseContentNodes(content ?? '', {errorMode: 'return'}); return { + error, + nodes: parsedNodes .map(node => { + if (node.type === 'tooltip') { + return { + i: node.i, + iEnd: node.iEnd, + type: 'tooltip', + data: { + // No recursion yet. Sorry! + tooltip: node.data.content[0].data, + label: node.data.label[0].data, + link: null, + }, + }; + } + if (node.type !== 'tag') { return node; } @@ -85,7 +110,7 @@ export default { } if (spec.link) { - let data = {link: spec.link}; + let data = {link: spec.link, replacerKey, replacerValue}; determineData: { // No value at all: this is an index link. @@ -124,15 +149,46 @@ export default { data.label = enteredLabel ?? - (transformName && data.thing.name - ? transformName(data.thing.name, node, content) - : null); + + (transformName && data.thing.name && + replacerKeyImplied && replacerValue === data.thing.name + + ? transformName(data.thing.name, node, content) + : null) ?? + + (replacerKeyImplied + ? replacerValue + : null); data.hash = enteredHash ?? null; 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. @@ -140,8 +196,8 @@ export default { ...node, data: { ...node.data, - replacerKey: node.data.replacerKey.data, - replacerValue: node.data.replacerValue[0].data, + replacerKey, + replacerValue, }, }; }), @@ -152,25 +208,11 @@ export default { return { content, + error: + sprawl.error, + nodes: - sprawl.nodes - .map(node => { - switch (node.type) { - // Replace internal link nodes with a stub. It'll be replaced - // (by position) with an item from relations. - // - // TODO: This should be where label and hash get passed through, - // rather than in relations... (in which case there's no need to - // handle it specially here, and we can really just return - // data.nodes = sprawl.nodes) - case 'internal-link': - return {type: 'internal-link'}; - - // Other nodes will get processed in generate. - default: - return node; - } - }), + sprawl.nodes, }; }, @@ -184,10 +226,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 +256,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,23 +295,100 @@ export default { default: true, }, + textOnly: { + type: 'boolean', + default: false, + }, + thumb: { validate: v => v.is('small', 'medium', 'large'), default: 'large', }, + + substitute: { + validate: v => + v.strictArrayOf( + v.validateProperties({ + match: v.validateProperties({ + replacerKey: v.isString, + replacerValue: v.isString, + }), + + substitute: v.isHTML, + + apply: v.optional(v.isFunction), + })), + }, }, - 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; + const substitutions = + (slots.substitute + ? slots.substitute.slice() + : []); + + const pickSubstitution = node => { + const index = + substitutions.findIndex(({match}) => + match.replacerKey === node.data.replacerKey && + match.replacerValue === node.data.replacerValue); + + if (index === -1) { + return null; + } + + return substitutions.splice(index, 1).at(0); + }; + const contentFromNodes = 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; + } + }; + + const substitution = pickSubstitution(node); + + if (substitution) { + const source = + substitution.substitute; + + let substitute = source; + + if (substitution.apply) { + const result = substitution.apply(source, node); + + if (result !== undefined) { + substitute = result; + } + } + + return {type: 'substitution', data: substitute}; + } + switch (node.type) { case 'text': { const text = node.data.slice(offsetTextNode); @@ -291,9 +422,8 @@ export default { height && {height}, style && {style}, - align === 'center' && - !link && - {class: 'align-center'}, + align && !link && + {class: 'align-' + align}, pixelate && {class: 'pixelate'}); @@ -304,8 +434,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 +485,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 +498,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 +583,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 +607,7 @@ export default { try { link.getSlotDescription('preferShortName'); hasPreferShortNameSlot = true; - } catch (error) { + } catch { hasPreferShortNameSlot = false; } @@ -438,7 +620,7 @@ export default { try { link.getSlotDescription('tooltipStyle'); hasTooltipStyleSlot = true; - } catch (error) { + } catch { hasTooltipStyleSlot = false; } @@ -446,26 +628,39 @@ 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}; } case 'external-link': { - const {label} = node.data; const externalLink = relations.externalLinks[externalLinkIndex++]; + const label = + node.data.label ?? + node.data.href.replace(/^https?:\/\//, ''); + + 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 +674,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 +736,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 +832,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; @@ -622,8 +871,8 @@ export default { extractNonTextNodes() // Compress multiple line breaks into single line breaks, // except when they're preceding or following indented - // text (by at least two spaces). - .replace(/(?<! .*)\n{2,}(?!^ )/gm, '\n') /* eslint-disable-line no-regex-spaces */ + // text (by at least two spaces) or blockquotes. + .replace(/(?<!^ .*|^>.*)\n{2,}(?!^ |^>)/gm, '\n') /* eslint-disable-line no-regex-spaces */ // Expand line breaks which don't follow a list, quote, // or <br> / " ", and which don't precede or follow // indented text (by at least two spaces). @@ -661,25 +910,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..e68b2ed0 100644 --- a/src/data/checks.js +++ b/src/data/checks.js @@ -4,14 +4,14 @@ import {inspect as nodeInspect} from 'node:util'; import {colors, ENABLE_COLOR} from '#cli'; import CacheableObject from '#cacheable-object'; -import {replacerSpec, parseInput} from '#replacer'; +import {replacerSpec, parseContentNodes} from '#replacer'; import {compareArrays, cut, cutStart, empty, getNestedProp, iterateMultiline} from '#sugar'; import Thing from '#thing'; import thingConstructors from '#things'; -import {combineWikiDataArrays, commentaryRegexCaseSensitive} from '#wiki-data'; import { + annotateError, annotateErrorWithIndex, conditionallySuppressError, decorateErrorWithIndex, @@ -50,7 +50,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; } @@ -166,6 +166,69 @@ function getFieldPropertyMessage(yamlDocumentSpec, property) { return fieldPropertyMessage; } +function decoAnnotateFindErrors(findFn) { + function annotateMultipleNameMatchesIncludingUnfortunatelyUnsecondary(error) { + const matches = error[Symbol.for('hsmusic.find.multipleNameMatches')]; + if (!matches) return; + + const notSoSecondary = + matches + .map(match => match.thing ?? match) + .filter(match => + match.isTrack && + match.isMainRelease && + CacheableObject.getUpdateValue(match, 'mainRelease')); + + if (empty(notSoSecondary)) return; + + let {message} = error; + message += (message.includes('\n') ? '\n\n' : '\n'); + message += colors.bright(colors.yellow('<!>')) + ' '; + message += colors.yellow(`Some of these tracks are meant to be secondary releases,`) + '\n'; + message += ' '.repeat(4); + message += colors.yellow(`but another error is keeping that from processing correctly!`) + '\n'; + message += ' '.repeat(4); + message += colors.yellow(`Probably look for an error to do with "Main Release", first.`); + Object.assign(error, {message}); + } + + return (...args) => { + try { + return findFn(...args); + } catch (caughtError) { + throw annotateError(caughtError, ...[ + annotateMultipleNameMatchesIncludingUnfortunatelyUnsecondary, + ]); + } + }; +} + +function decoSuppressFindErrors(findFn, {property}) { + void property; + + return conditionallySuppressError(_error => { + // We're not suppressing any errors at the moment. + // An old suppression is kept below for reference. + + /* + if (property === 'sampledTracks') { + // Suppress "didn't match anything" errors in particular, just for samples. + // In hsmusic-data we have a lot of "stub" sample data which don't have + // corresponding tracks yet, so it won't be useful to report such reference + // errors until we take the time to address that. But other errors, like + // malformed reference strings or miscapitalized existing tracks, should + // still be reported, as samples of existing tracks *do* display on the + // website! + if (error.message.includes(`Didn't match anything`)) { + return true; + } + } + */ + + return false; + }, findFn); +} + // Warn about references across data which don't match anything. This involves // using the find() functions on all references, setting it to 'error' mode, and // collecting everything in a structured logged (which gets logged if there are @@ -185,18 +248,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 +289,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', + mainRelease: '_mainRelease', + commentary: '_content', + creditingSources: '_content', + referencingSources: '_content', + lyrics: '_content', }], ['wikiInfo', { @@ -237,21 +319,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 +359,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 +376,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 +392,7 @@ export function filterReferenceErrors(wikiData, { findFn = boundFind.artTag; break; - case '_commentary': + case '_content': findFn = findArtistOrAlias; break; @@ -328,37 +410,129 @@ export function filterReferenceErrors(wikiData, { }; break; - case '_serieses': - findFn = boundFind.album; + case '_mainRelease': + findFn = ref => { + // Mocking what's going on in `withMainRelease`. + + if (ref === 'same name single') { + // Accessing the current thing here. + try { + return boundFind.albumSinglesOnly(thing.name, { + fuzz: { + capitalization: true, + kebab: true, + }, + }); + } catch (caughtError) { + throw new Error( + `Didn't match a single with the same name`, + {cause: caughtError}); + } + } + + let track, trackError; + let album, albumError; + + try { + track = boundFind.trackMainReleasesOnly(ref); + } catch (caughtError) { + trackError = new Error( + `Didn't match a track`, {cause: caughtError}); + } + + try { + album = boundFind.album(ref); + } catch (caughtError) { + albumError = new Error( + `Didn't match an album`, {cause: caughtError}); + } + + if (track && album) { + if (album.tracks.includes(track)) { + return track; + } else { + throw new Error( + `Unrelated album and track matched for reference "${ref}". Please resolve:\n` + + `- ${inspect(track)}\n` + + `- ${inspect(album)}\n` + + `Returning null for this reference.`); + } + } + + if (track) { + return track; + } + + if (album) { + // At this point verification depends on the thing itself, + // which is currently in lexical scope, but if this code + // gets refactored, there might be trouble here... + + if (thing.mainReleaseTrack === null) { + if (album === thing.album) { + throw new Error( + `Matched album for reference "${ref}":\n` + + `- ` + inspect(album) + `\n` + + `...but this is the album that includes this secondary release, itself.\n` + + `Please resolve by pointing to aonther album here, or by removing this\n` + + `Main Release field, if this track is meant to be the main release.`); + } else { + throw new Error( + `Matched album for reference "${ref}":\n` + + `- ` + inspect(album) + `\n` + + `...but none of its tracks automatically match this secondary release.\n` + + `Please resolve by specifying the track here, instead of the album.`); + } + } else { + return album; + } + } + + const aggregateCause = + new AggregateError([albumError, trackError]); + + aggregateCause[Symbol.for('hsmusic.aggregate.translucent')] = true; + + throw new Error(`Trouble matching "${ref}"`, { + cause: aggregateCause, + }); + } + break; case '_trackArtwork': findFn = ref => boundFind.track(ref.reference); break; - case '_trackNotRerelease': + case '_trackMainReleasesOnly': findFn = trackRef => { - const track = boundFind.track(trackRef); - const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack'); + let track = boundFind.trackMainReleasesOnly(trackRef, {mode: 'quiet'}); + if (track) { + return track; + } - 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'}); + // Will error normally, if this can't unambiguously resolve + // or doesn't match any track. + track = boundFind.track(trackRef); + + const mainRef = CacheableObject.getUpdateValue(track, 'mainRelease'); + if (mainRef) { + // It's possible for the main release to not actually exist, in this case. + // It should still be reported since the 'Main Release' field was present. + 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,22 +546,8 @@ export function filterReferenceErrors(wikiData, { break; } - const suppress = fn => conditionallySuppressError(error => { - if (property === 'sampledTracks') { - // Suppress "didn't match anything" errors in particular, just for samples. - // In hsmusic-data we have a lot of "stub" sample data which don't have - // corresponding tracks yet, so it won't be useful to report such reference - // errors until we take the time to address that. But other errors, like - // malformed reference strings or miscapitalized existing tracks, should - // still be reported, as samples of existing tracks *do* display on the - // website! - if (error.message.includes(`Didn't match anything`)) { - return true; - } - } - - return false; - }, fn); + findFn = decoSuppressFindErrors(findFn, {property}); + findFn = decoAnnotateFindErrors(findFn); const fieldPropertyMessage = getFieldPropertyMessage( @@ -438,15 +598,15 @@ export function filterReferenceErrors(wikiData, { } } - if (findFnKey === '_commentary') { + if (findFnKey === '_content') { filter( value, {message: errorMessage}, decorateErrorWithIndex(refs => (refs.length === 1 - ? suppress(findFn)(refs[0]) + ? findFn(refs[0]) : filterAggregate( refs, {message: `Errors in entry's artist references`}, - decorateErrorWithIndex(suppress(findFn))) + decorateErrorWithIndex(findFn)) .aggregate .close()))); @@ -458,19 +618,18 @@ export function filterReferenceErrors(wikiData, { if (Array.isArray(value)) { newPropertyValue = filter( value, {message: errorMessage}, - decorateErrorWithIndex(suppress(findFn))); + decorateErrorWithIndex(findFn)); break determineNewPropertyValue; } - nest({message: errorMessage}, - suppress(({call}) => { - try { - call(findFn, value); - } catch (error) { - newPropertyValue = null; - throw error; - } - })); + nest({message: errorMessage}, ({call}) => { + try { + call(findFn, value); + } catch (error) { + newPropertyValue = null; + throw error; + } + }); } if (writeProperty) { @@ -494,7 +653,11 @@ export class ContentNodeError extends Error { message, }) { const headingLine = - `(${where}) ${message}`; + (message.includes('\n\n') + ? `(${where})\n\n` + message + '\n' + : message.includes('\n') + ? `(${where})\n` + message + : `(${where}) ${message}`); const textUpToNode = containingLine.slice(0, columnNumber); @@ -539,16 +702,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 +737,8 @@ export function reportContentTextErrors(wikiData, { ['flashData', { commentary: commentaryShape, + creditingSources: commentaryShape, + coverArtwork: artworkShape, }], ['flashActData', { @@ -586,10 +768,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', { @@ -598,11 +782,19 @@ export function reportContentTextErrors(wikiData, { }], ]; - const boundFind = bindFind(wikiData, {mode: 'error'}); + const boundFind = + bindFind(wikiData, { + mode: 'error', + fuzz: { + capitalization: true, + kebab: true, + }, + }); + const findArtistOrAlias = bindFindArtistOrAlias(boundFind); function* processContent(input) { - const nodes = parseInput(input); + const nodes = parseContentNodes(input); for (const node of nodes) { const index = node.i; @@ -639,6 +831,9 @@ export function reportContentTextErrors(wikiData, { break; } + findFn = decoSuppressFindErrors(findFn, {property: null}); + findFn = decoAnnotateFindErrors(findFn); + const findRef = (replacerKeyImplied ? replacerValue @@ -659,7 +854,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 +905,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 +925,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 +957,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 +977,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..de1d37c3 100644 --- a/src/data/composite/things/album/index.js +++ b/src/data/composite/things/album/index.js @@ -1 +1,2 @@ +export {default as withCoverArtDate} from './withCoverArtDate.js'; export {default as withTracks} from './withTracks.js'; diff --git a/src/data/composite/things/album/withCoverArtDate.js b/src/data/composite/things/album/withCoverArtDate.js new file mode 100644 index 00000000..978f566a --- /dev/null +++ b/src/data/composite/things/album/withCoverArtDate.js @@ -0,0 +1,50 @@ +import {input, templateCompositeFrom} from '#composite'; +import {isDate} from '#validators'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withHasArtwork} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withCoverArtDate`, + + inputs: { + from: input({ + validate: isDate, + defaultDependency: 'coverArtDate', + acceptsNull: true, + }), + }, + + outputs: ['#coverArtDate'], + + steps: () => [ + withHasArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + }), + + raiseOutputWithoutDependency({ + dependency: '#hasArtwork', + mode: input.value('falsy'), + output: input.value({'#coverArtDate': null}), + }), + + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: from, + }) => + (from + ? continuation.raiseOutput({'#coverArtDate': from}) + : continuation()), + }, + + { + dependencies: ['date'], + compute: (continuation, {date}) => + (date + ? continuation({'#coverArtDate': date}) + : continuation({'#coverArtDate': null})), + }, + ], +}); diff --git a/src/data/composite/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..b5e5e167 --- /dev/null +++ b/src/data/composite/things/artwork/index.js @@ -0,0 +1,7 @@ +export {default as withArtTags} from './withArtTags.js'; +export {default as withAttachedArtwork} from './withAttachedArtwork.js'; +export {default as withContainingArtworkList} from './withContainingArtworkList.js'; +export {default as withContentWarningArtTags} from './withContentWarningArtTags.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/withArtTags.js b/src/data/composite/things/artwork/withArtTags.js new file mode 100644 index 00000000..1fed3c31 --- /dev/null +++ b/src/data/composite/things/artwork/withArtTags.js @@ -0,0 +1,99 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; +import {withResolvedReferenceList} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; + +import withPropertyFromAttachedArtwork + from './withPropertyFromAttachedArtwork.js'; + +export default templateCompositeFrom({ + annotation: `withArtTags`, + + inputs: { + from: input({ + type: 'array', + acceptsNull: true, + defaultDependency: 'artTags', + }), + }, + + outputs: ['#artTags'], + + steps: () => [ + withResolvedReferenceList({ + list: input('from'), + find: soupyFind.input('artTag'), + }), + + withResultOfAvailabilityCheck({ + from: '#resolvedReferenceList', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability', '#resolvedReferenceList'], + compute: (continuation, { + ['#availability']: availability, + ['#resolvedReferenceList']: resolvedReferenceList, + }) => + (availability + ? continuation.raiseOutput({ + '#artTags': resolvedReferenceList, + }) + : continuation()), + }, + + withPropertyFromAttachedArtwork({ + property: input.value('artTags'), + }), + + withResultOfAvailabilityCheck({ + from: '#attachedArtwork.artTags', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability', '#attachedArtwork.artTags'], + compute: (continuation, { + ['#availability']: availability, + ['#attachedArtwork.artTags']: attachedArtworkArtTags, + }) => + (availability + ? continuation.raiseOutput({ + '#artTags': attachedArtworkArtTags, + }) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'artTagsFromThingProperty', + output: input.value({'#artTags': []}), + }), + + withPropertyFromObject({ + object: 'thing', + property: 'artTagsFromThingProperty', + }).outputs({ + ['#value']: '#thing.artTags', + }), + + withResultOfAvailabilityCheck({ + from: '#thing.artTags', + mode: input.value('empty'), + }), + + { + dependencies: ['#availability', '#thing.artTags'], + compute: (continuation, { + ['#availability']: availability, + ['#thing.artTags']: thingArtTags, + }) => + (availability + ? continuation({'#artTags': thingArtTags}) + : continuation({'#artTags': []})), + }, + ], +}); 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/withContentWarningArtTags.js b/src/data/composite/things/artwork/withContentWarningArtTags.js new file mode 100644 index 00000000..4c07e837 --- /dev/null +++ b/src/data/composite/things/artwork/withContentWarningArtTags.js @@ -0,0 +1,27 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {withFilteredList, withPropertyFromList} from '#composite/data'; + +import withArtTags from './withArtTags.js'; + +export default templateCompositeFrom({ + annotation: `withContentWarningArtTags`, + + outputs: ['#contentWarningArtTags'], + + steps: () => [ + withArtTags(), + + withPropertyFromList({ + list: '#artTags', + property: input.value('isContentWarning'), + }), + + withFilteredList({ + list: '#artTags', + filter: '#artTags.isContentWarning', + }).outputs({ + '#filteredList': '#contentWarningArtTags', + }), + ], +}); 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..69da8c75 --- /dev/null +++ b/src/data/composite/things/content/helpers/withExpressedOrImplicitArtistReferences.js @@ -0,0 +1,61 @@ +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withFilteredList, withMappedList} from '#composite/data'; +import {withContentNodes} from '#composite/wiki-data'; + +export default templateCompositeFrom({ + annotation: `withExpressedOrImplicitArtistReferences`, + + inputs: { + from: input({type: 'array', acceptsNull: true}), + }, + + outputs: ['#artistReferences'], + + steps: () => [ + { + dependencies: [input('from')], + compute: (continuation, { + [input('from')]: expressedArtistReferences, + }) => + (expressedArtistReferences + ? continuation.raiseOutput({'#artistReferences': expressedArtistReferences}) + : continuation()), + }, + + raiseOutputWithoutDependency({ + dependency: 'artistText', + output: input.value({'#artistReferences': null}), + }), + + withContentNodes({ + from: 'artistText', + }), + + withMappedList({ + list: '#contentNodes', + map: input.value(node => + node.type === 'tag' && + node.data.replacerKey?.data === 'artist'), + }).outputs({ + '#mappedList': '#artistTagFilter', + }), + + withFilteredList({ + list: '#contentNodes', + filter: '#artistTagFilter', + }).outputs({ + '#filteredList': '#artistTags', + }), + + withMappedList({ + list: '#artistTags', + map: input.value(node => + 'artist:' + + node.data.replacerValue[0].data), + }).outputs({ + '#mappedList': '#artistReferences', + }), + ], +}); diff --git a/src/data/composite/things/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..0c5a0294 --- /dev/null +++ b/src/data/composite/things/content/withAnnotationParts.js @@ -0,0 +1,103 @@ +import {input, templateCompositeFrom} from '#composite'; +import {empty, 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'], + compute: (continuation, { + ['#contentNodeLists']: nodeLists, + }) => continuation({ + ['#contentNodeLists']: + nodeLists.filter(list => !empty(list)), + }), + }, + + { + 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/index.js b/src/data/composite/things/contribution/index.js index 9b22be2e..31d86b8b 100644 --- a/src/data/composite/things/contribution/index.js +++ b/src/data/composite/things/contribution/index.js @@ -1,6 +1,4 @@ export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js'; -export {default as thingPropertyMatches} from './thingPropertyMatches.js'; -export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js'; export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js'; export {default as withContributionArtist} from './withContributionArtist.js'; export {default as withContributionContext} from './withContributionContext.js'; 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 deleted file mode 100644 index 4a37f2cf..00000000 --- a/src/data/composite/things/contribution/thingPropertyMatches.js +++ /dev/null @@ -1,33 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exitWithoutDependency} from '#composite/control-flow'; - -export default templateCompositeFrom({ - annotation: `thingPropertyMatches`, - - compose: false, - - inputs: { - value: input({type: 'string'}), - }, - - steps: () => [ - exitWithoutDependency({ - dependency: 'thingProperty', - value: input.value(false), - }), - - { - dependencies: [ - 'thingProperty', - input('value'), - ], - - compute: ({ - ['thingProperty']: thingProperty, - [input('value')]: value, - }) => - thingProperty === value, - }, - ], -}); diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js deleted file mode 100644 index 2ee811af..00000000 --- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js +++ /dev/null @@ -1,39 +0,0 @@ -import {input, templateCompositeFrom} from '#composite'; - -import {exitWithoutDependency} from '#composite/control-flow'; -import {withPropertyFromObject} from '#composite/data'; - -export default templateCompositeFrom({ - annotation: `thingReferenceTypeMatches`, - - compose: false, - - inputs: { - value: input({type: 'string'}), - }, - - steps: () => [ - exitWithoutDependency({ - dependency: 'thing', - value: input.value(false), - }), - - withPropertyFromObject({ - object: 'thing', - property: input.value('constructor'), - }), - - { - dependencies: [ - '#thing.constructor', - input('value'), - ], - - compute: ({ - ['#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/alwaysReferenceByDirectory.js b/src/data/composite/things/track/alwaysReferenceByDirectory.js new file mode 100644 index 00000000..a342d38b --- /dev/null +++ b/src/data/composite/things/track/alwaysReferenceByDirectory.js @@ -0,0 +1,69 @@ +// Controls how find.track works - it'll never be matched by a reference +// just to the track's name, which means you don't have to always reference +// some *other* (much more commonly referenced) track by directory instead +// of more naturally by name. + +import {input, templateCompositeFrom} from '#composite'; +import {isBoolean} from '#validators'; +import {getKebabCase} from '#wiki-data'; + +import {withPropertyFromObject} from '#composite/data'; + +import { + exitWithoutDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import withMainReleaseTrack from './withMainReleaseTrack.js'; +import withPropertyFromAlbum from './withPropertyFromAlbum.js'; + +export default templateCompositeFrom({ + annotation: `alwaysReferenceByDirectory`, + + compose: false, + + steps: () => [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withPropertyFromAlbum({ + property: input.value('alwaysReferenceTracksByDirectory'), + }), + + // Falsy mode means this exposes true if the album's property is true, + // but continues if the property is false (which is also the default). + exposeDependencyOrContinue({ + dependency: '#album.alwaysReferenceTracksByDirectory', + mode: input.value('falsy'), + }), + + exitWithoutDependency({ + dependency: 'mainRelease', + value: input.value(false), + }), + + withMainReleaseTrack(), + + exitWithoutDependency({ + dependency: '#mainReleaseTrack', + value: input.value(false), + }), + + withPropertyFromObject({ + object: '#mainReleaseTrack', + property: input.value('name'), + }), + + { + dependencies: ['name', '#mainReleaseTrack.name'], + compute: ({ + ['name']: name, + ['#mainReleaseTrack.name']: mainReleaseName, + }) => + getKebabCase(name) === + getKebabCase(mainReleaseName), + }, + ], +}); diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js index 05ccaaba..1c203cd9 100644 --- a/src/data/composite/things/track/index.js +++ b/src/data/composite/things/track/index.js @@ -1,16 +1,18 @@ +export {default as alwaysReferenceByDirectory} from './alwaysReferenceByDirectory.js'; 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 withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.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 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 withMainReleaseTrack} from './withMainReleaseTrack.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..bd54384f --- /dev/null +++ b/src/data/composite/things/track/withAllReleases.js @@ -0,0 +1,50 @@ +// 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 withMainReleaseTrack from './withMainReleaseTrack.js'; + +export default templateCompositeFrom({ + annotation: `withAllReleases`, + + outputs: ['#allReleases'], + + steps: () => [ + withMainReleaseTrack({ + 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: '#mainReleaseTrack', + property: input.value('secondaryReleases'), + }), + + { + dependencies: [ + '#mainReleaseTrack', + '#mainReleaseTrack.secondaryReleases', + ], + + compute: (continuation, { + ['#mainReleaseTrack']: mainReleaseTrack, + ['#mainReleaseTrack.secondaryReleases']: secondaryReleases, + }) => continuation({ + ['#allReleases']: + sortByDate([mainReleaseTrack, ...secondaryReleases]), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js deleted file mode 100644 index e01720b4..00000000 --- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js +++ /dev/null @@ -1,105 +0,0 @@ -// Controls how find.track works - it'll never be matched by a reference -// just to the track's name, which means you don't have to always reference -// some *other* (much more commonly referenced) track by directory instead -// of more naturally by name. - -import {input, templateCompositeFrom} from '#composite'; -import find from '#find'; -import {isBoolean} from '#validators'; - -import {withPropertyFromObject} from '#composite/data'; -import {withResolvedReference} from '#composite/wiki-data'; - -import { - exitWithoutDependency, - exposeDependencyOrContinue, - exposeUpdateValueOrContinue, -} from '#composite/control-flow'; - -export default templateCompositeFrom({ - annotation: `withAlwaysReferenceByDirectory`, - - outputs: ['#alwaysReferenceByDirectory'], - - steps: () => [ - exposeUpdateValueOrContinue({ - 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', - property: input.value('alwaysReferenceTracksByDirectory'), - }), - - // Falsy mode means this exposes true if the album's property is true, - // but continues if the property is false (which is also the default). - exposeDependencyOrContinue({ - dependency: '#album.alwaysReferenceTracksByDirectory', - mode: input.value('falsy'), - }), - - // Remaining code is for defaulting to true if this track is a rerelease of - // another with the same name, so everything further depends on access to - // trackData as well as originalReleaseTrack. - - exitWithoutDependency({ - dependency: 'trackData', - mode: input.value('empty'), - value: input.value(false), - }), - - exitWithoutDependency({ - dependency: 'originalReleaseTrack', - value: input.value(false), - }), - - // It's necessary to use the custom trackOriginalReleasesOnly 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 - // 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', - data: 'trackData', - find: input.value(find.trackOriginalReleasesOnly), - }).outputs({ - '#resolvedReference': '#originalRelease', - }), - - exitWithoutDependency({ - dependency: '#originalRelease', - value: input.value(false), - }), - - withPropertyFromObject({ - object: '#originalRelease', - property: input.value('name'), - }), - - { - dependencies: ['name', '#originalRelease.name'], - compute: (continuation, { - name, - ['#originalRelease.name']: originalName, - }) => continuation({ - ['#alwaysReferenceByDirectory']: name === originalName, - }), - }, - ], -}); 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/withDate.js b/src/data/composite/things/track/withDate.js index b5a770e9..1851c0d2 100644 --- a/src/data/composite/things/track/withDate.js +++ b/src/data/composite/things/track/withDate.js @@ -12,6 +12,14 @@ export default templateCompositeFrom({ steps: () => [ { + dependencies: ['disableDate'], + compute: (continuation, {disableDate}) => + (disableDate + ? continuation.raiseOutput({'#date': null}) + : continuation()), + }, + + { dependencies: ['dateFirstReleased'], compute: (continuation, {dateFirstReleased}) => (dateFirstReleased diff --git a/src/data/composite/things/track/withDirectorySuffix.js b/src/data/composite/things/track/withDirectorySuffix.js index c063e158..c3651491 100644 --- a/src/data/composite/things/track/withDirectorySuffix.js +++ b/src/data/composite/things/track/withDirectorySuffix.js @@ -1,8 +1,9 @@ import {input, templateCompositeFrom} from '#composite'; import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; +import withContainingTrackSection from './withContainingTrackSection.js'; import withSuffixDirectoryFromAlbum from './withSuffixDirectoryFromAlbum.js'; export default templateCompositeFrom({ @@ -16,21 +17,16 @@ export default templateCompositeFrom({ raiseOutputWithoutDependency({ dependency: '#suffixDirectoryFromAlbum', mode: input.value('falsy'), - output: input.value({['#directorySuffix']: null}), + output: input.value({'#directorySuffix': null}), }), - withPropertyFromAlbum({ + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', property: input.value('directorySuffix'), + }).outputs({ + '#trackSection.directorySuffix': '#directorySuffix', }), - - { - dependencies: ['#album.directorySuffix'], - compute: (continuation, { - ['#album.directorySuffix']: directorySuffix, - }) => continuation({ - ['#directorySuffix']: - directorySuffix, - }), - }, ], }); diff --git a/src/data/composite/things/track/withHasUniqueCoverArt.js b/src/data/composite/things/track/withHasUniqueCoverArt.js 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/withMainRelease.js b/src/data/composite/things/track/withMainRelease.js new file mode 100644 index 00000000..67a312ae --- /dev/null +++ b/src/data/composite/things/track/withMainRelease.js @@ -0,0 +1,137 @@ +// Resolves this track's `mainRelease` reference, using weird-ass atypical +// machinery that operates on soupyFind and does not operate on findMixed, +// let alone a prim and proper standalone find spec. +// +// Raises null only if there is no `mainRelease` reference provided at all. +// This will early exit (with notFoundValue) if the reference doesn't resolve. +// + +import {input, templateCompositeFrom} from '#composite'; + +import {raiseOutputWithoutDependency} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; +import {withResolvedReference} from '#composite/wiki-data'; +import {soupyFind} from '#composite/wiki-properties'; + +export default templateCompositeFrom({ + annotation: `withMainRelease`, + + inputs: { + from: input({ + defaultDependency: 'mainRelease', + acceptsNull: true, + }), + + notFoundValue: input({defaultValue: null}), + }, + + outputs: ['#mainRelease'], + + steps: () => [ + raiseOutputWithoutDependency({ + dependency: input('from'), + output: input.value({'#mainRelease': null}), + }), + + { + dependencies: [input('from'), 'name'], + compute: (continuation, { + [input('from')]: ref, + ['name']: ownName, + }) => + (ref === 'same name single' + ? continuation({ + ['#albumOrTrackReference']: null, + ['#sameNameSingleReference']: ownName, + }) + : continuation({ + ['#albumOrTrackReference']: ref, + ['#sameNameSingleReference']: null, + })), + }, + + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('trackMainReleasesOnly'), + }).outputs({ + '#resolvedReference': '#matchingTrack', + }), + + withResolvedReference({ + ref: '#albumOrTrackReference', + find: soupyFind.input('album'), + }).outputs({ + '#resolvedReference': '#matchingAlbum', + }), + + withResolvedReference({ + ref: '#sameNameSingleReference', + find: soupyFind.input('albumSinglesOnly'), + findOptions: input.value({ + fuzz: { + capitalization: true, + kebab: true, + }, + }), + }).outputs({ + '#resolvedReference': '#sameNameSingle', + }), + + { + dependencies: ['#sameNameSingle'], + compute: (continuation, { + ['#sameNameSingle']: sameNameSingle, + }) => + (sameNameSingle + ? continuation.raiseOutput({ + ['#mainRelease']: + sameNameSingle, + }) + : continuation()), + }, + + { + dependencies: [ + '#matchingTrack', + '#matchingAlbum', + input('notFoundValue'), + ], + + compute: (continuation, { + ['#matchingTrack']: matchingTrack, + ['#matchingAlbum']: matchingAlbum, + [input('notFoundValue')]: notFoundValue, + }) => + (matchingTrack && matchingAlbum + ? continuation() + : matchingTrack ?? matchingAlbum + ? continuation.raiseOutput({ + ['#mainRelease']: + matchingTrack ?? matchingAlbum, + }) + : continuation.exit(notFoundValue)), + }, + + withPropertyFromObject({ + object: '#matchingAlbum', + property: input.value('tracks'), + }), + + { + dependencies: [ + '#matchingAlbum.tracks', + '#matchingTrack', + input('notFoundValue'), + ], + + compute: (continuation, { + ['#matchingAlbum.tracks']: matchingAlbumTracks, + ['#matchingTrack']: matchingTrack, + [input('notFoundValue')]: notFoundValue, + }) => + (matchingAlbumTracks.includes(matchingTrack) + ? continuation.raiseOutput({'#mainRelease': matchingTrack}) + : continuation.exit(notFoundValue)), + }, + ], +}); diff --git a/src/data/composite/things/track/withMainReleaseTrack.js b/src/data/composite/things/track/withMainReleaseTrack.js new file mode 100644 index 00000000..6371e895 --- /dev/null +++ b/src/data/composite/things/track/withMainReleaseTrack.js @@ -0,0 +1,248 @@ +// Just provides 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 {onlyItem} from '#sugar'; +import {getKebabCase} from '#wiki-data'; + +import { + exitWithoutDependency, + withAvailabilityFilter, + withResultOfAvailabilityCheck, +} from '#composite/control-flow'; + +import { + withFilteredList, + withMappedList, + withPropertyFromList, + withPropertyFromObject, +} from '#composite/data'; + +import withMainRelease from './withMainRelease.js'; + +export default templateCompositeFrom({ + annotation: `withMainReleaseTrack`, + + inputs: { + selfIfMain: input({type: 'boolean', defaultValue: false}), + notFoundValue: input({defaultValue: null}), + }, + + outputs: ['#mainReleaseTrack'], + + steps: () => [ + withResultOfAvailabilityCheck({ + from: 'mainRelease', + }), + + { + dependencies: [ + input.myself(), + input('selfIfMain'), + '#availability', + ], + + compute: (continuation, { + [input.myself()]: track, + [input('selfIfMain')]: selfIfMain, + '#availability': availability, + }) => + (availability + ? continuation() + : continuation.raiseOutput({ + ['#mainReleaseTrack']: + (selfIfMain ? track : null), + })), + }, + + withMainRelease(), + + exitWithoutDependency({ + dependency: '#mainRelease', + value: input('notFoundValue'), + }), + + withPropertyFromObject({ + object: '#mainRelease', + property: input.value('isTrack'), + }), + + { + dependencies: ['#mainRelease', '#mainRelease.isTrack'], + + compute: (continuation, { + ['#mainRelease']: mainRelease, + ['#mainRelease.isTrack']: mainReleaseIsTrack, + }) => + (mainReleaseIsTrack + ? continuation.raiseOutput({ + ['#mainReleaseTrack']: mainRelease, + }) + : continuation()), + }, + + { + dependencies: ['name', 'directory'], + compute: (continuation, { + ['name']: ownName, + ['directory']: ownDirectory, + }) => { + const ownNameKebabed = getKebabCase(ownName); + + return continuation({ + ['#mapItsNameLikeName']: + name => getKebabCase(name) === ownNameKebabed, + + ['#mapItsDirectoryLikeDirectory']: + (ownDirectory + ? directory => directory === ownDirectory + : () => false), + + ['#mapItsNameLikeDirectory']: + (ownDirectory + ? name => getKebabCase(name) === ownDirectory + : () => false), + + ['#mapItsDirectoryLikeName']: + directory => directory === ownNameKebabed, + }); + }, + }, + + withPropertyFromObject({ + object: '#mainRelease', + property: input.value('tracks'), + }), + + withPropertyFromList({ + list: '#mainRelease.tracks', + property: input.value('mainRelease'), + internal: input.value(true), + }), + + withAvailabilityFilter({ + from: '#mainRelease.tracks.mainRelease', + }), + + withMappedList({ + list: '#availabilityFilter', + map: input.value(item => !item), + }).outputs({ + '#mappedList': '#availabilityFilter', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#availabilityFilter', + }).outputs({ + '#filteredList': '#mainRelease.tracks', + }), + + withPropertyFromList({ + list: '#mainRelease.tracks', + property: input.value('name'), + }), + + withPropertyFromList({ + list: '#mainRelease.tracks', + property: input.value('directory'), + internal: input.value(true), + }), + + withMappedList({ + list: '#mainRelease.tracks.name', + map: '#mapItsNameLikeName', + }).outputs({ + '#mappedList': '#filterItsNameLikeName', + }), + + withMappedList({ + list: '#mainRelease.tracks.directory', + map: '#mapItsDirectoryLikeDirectory', + }).outputs({ + '#mappedList': '#filterItsDirectoryLikeDirectory', + }), + + withMappedList({ + list: '#mainRelease.tracks.name', + map: '#mapItsNameLikeDirectory', + }).outputs({ + '#mappedList': '#filterItsNameLikeDirectory', + }), + + withMappedList({ + list: '#mainRelease.tracks.directory', + map: '#mapItsDirectoryLikeName', + }).outputs({ + '#mappedList': '#filterItsDirectoryLikeName', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#filterItsNameLikeName', + }).outputs({ + '#filteredList': '#matchingItsNameLikeName', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#filterItsDirectoryLikeDirectory', + }).outputs({ + '#filteredList': '#matchingItsDirectoryLikeDirectory', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#filterItsNameLikeDirectory', + }).outputs({ + '#filteredList': '#matchingItsNameLikeDirectory', + }), + + withFilteredList({ + list: '#mainRelease.tracks', + filter: '#filterItsDirectoryLikeName', + }).outputs({ + '#filteredList': '#matchingItsDirectoryLikeName', + }), + + { + dependencies: [ + '#matchingItsNameLikeName', + '#matchingItsDirectoryLikeDirectory', + '#matchingItsNameLikeDirectory', + '#matchingItsDirectoryLikeName', + ], + + compute: (continuation, { + ['#matchingItsNameLikeName']: NLN, + ['#matchingItsDirectoryLikeDirectory']: DLD, + ['#matchingItsNameLikeDirectory']: NLD, + ['#matchingItsDirectoryLikeName']: DLN, + }) => continuation({ + ['#mainReleaseTrack']: + onlyItem(DLD) ?? + onlyItem(NLN) ?? + onlyItem(DLN) ?? + onlyItem(NLD) ?? + null, + }), + }, + + { + dependencies: ['#mainReleaseTrack', input.myself()], + + compute: (continuation, { + ['#mainReleaseTrack']: mainReleaseTrack, + [input.myself()]: thisTrack, + }) => continuation({ + ['#mainReleaseTrack']: + (mainReleaseTrack === thisTrack + ? null + : mainReleaseTrack), + }), + }, + ], +}); diff --git a/src/data/composite/things/track/withOriginalRelease.js b/src/data/composite/things/track/withOriginalRelease.js deleted file mode 100644 index c7f49657..00000000 --- a/src/data/composite/things/track/withOriginalRelease.js +++ /dev/null @@ -1,78 +0,0 @@ -// 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 -// 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'; - -export default templateCompositeFrom({ - annotation: `withOriginalRelease`, - - inputs: { - selfIfOriginal: input({type: 'boolean', defaultValue: false}), - - data: input({ - validate: validateWikiData({referenceType: 'track'}), - defaultDependency: 'trackData', - }), - - notFoundValue: input({defaultValue: null}), - }, - - outputs: ['#originalRelease'], - - steps: () => [ - withResultOfAvailabilityCheck({ - from: 'originalReleaseTrack', - }), - - { - dependencies: [ - input.myself(), - input('selfIfOriginal'), - '#availability', - ], - - compute: (continuation, { - [input.myself()]: track, - [input('selfIfOriginal')]: selfIfOriginal, - '#availability': availability, - }) => - (availability - ? continuation() - : continuation.raiseOutput({ - ['#originalRelease']: - (selfIfOriginal ? track : null), - })), - }, - - withResolvedReference({ - ref: 'originalReleaseTrack', - data: input('data'), - find: input.value(find.track), - }), - - exitWithoutDependency({ - dependency: '#resolvedReference', - value: input('notFoundValue'), - }), - - { - dependencies: ['#resolvedReference'], - - compute: (continuation, { - ['#resolvedReference']: resolvedReference, - }) => - continuation({ - ['#originalRelease']: 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..c6f65653 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 withMainReleaseTrack from './withMainReleaseTrack.js'; export default templateCompositeFrom({ - annotation: `inheritFromOriginalRelease`, + annotation: `withPropertyFromMainRelease`, 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({ + withMainReleaseTrack({ notFoundValue: input('notFoundValue'), }), withResultOfAvailabilityCheck({ - from: '#originalRelease', + from: '#mainReleaseTrack', }), { @@ -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: '#mainReleaseTrack', 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/withSuffixDirectoryFromAlbum.js b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js index 7159a3f4..30c777b6 100644 --- a/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js +++ b/src/data/composite/things/track/withSuffixDirectoryFromAlbum.js @@ -1,8 +1,9 @@ import {input, templateCompositeFrom} from '#composite'; import {withResultOfAvailabilityCheck} from '#composite/control-flow'; +import {withPropertyFromObject} from '#composite/data'; -import withPropertyFromAlbum from './withPropertyFromAlbum.js'; +import withContainingTrackSection from './withContainingTrackSection.js'; export default templateCompositeFrom({ annotation: `withSuffixDirectoryFromAlbum`, @@ -36,18 +37,13 @@ export default templateCompositeFrom({ : continuation()), }, - withPropertyFromAlbum({ + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', property: input.value('suffixTrackDirectories'), + }).outputs({ + '#trackSection.suffixTrackDirectories': '#suffixDirectoryFromAlbum', }), - - { - dependencies: ['#album.suffixTrackDirectories'], - compute: (continuation, { - ['#album.suffixTrackDirectories']: suffixTrackDirectories, - }) => continuation({ - ['#suffixDirectoryFromAlbum']: - suffixTrackDirectories, - }), - }, ], }); diff --git a/src/data/composite/things/track/withTrackArtDate.js b/src/data/composite/things/track/withTrackArtDate.js 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/exitWithoutArtwork.js b/src/data/composite/wiki-data/exitWithoutArtwork.js new file mode 100644 index 00000000..8e799fda --- /dev/null +++ b/src/data/composite/wiki-data/exitWithoutArtwork.js @@ -0,0 +1,45 @@ +import {input, templateCompositeFrom} from '#composite'; +import {isContributionList, isThing, strictArrayOf} from '#validators'; + +import {exitWithoutDependency} from '#composite/control-flow'; + +import withHasArtwork from './withHasArtwork.js'; + +export default templateCompositeFrom({ + annotation: `exitWithoutArtwork`, + + inputs: { + contribs: input({ + validate: isContributionList, + defaultValue: null, + }), + + artwork: input({ + validate: isThing, + defaultValue: null, + }), + + artworks: input({ + validate: strictArrayOf(isThing), + defaultValue: null, + }), + + value: input({ + defaultValue: null, + }), + }, + + steps: () => [ + withHasArtwork({ + contribs: input('contribs'), + artwork: input('artwork'), + artworks: input('artworks'), + }), + + exitWithoutDependency({ + dependency: '#hasArtwork', + mode: input.value('falsy'), + value: input('value'), + }), + ], +}); 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..d70d7c56 100644 --- a/src/data/composite/wiki-data/index.js +++ b/src/data/composite/wiki-data/index.js @@ -5,23 +5,27 @@ // export {default as exitWithoutContribs} from './exitWithoutContribs.js'; +export {default as exitWithoutArtwork} from './exitWithoutArtwork.js'; +export {default as gobbleSoupyFind} from './gobbleSoupyFind.js'; +export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js'; +export {default as inputFindOptions} from './inputFindOptions.js'; export {default as inputNotFoundMode} from './inputNotFoundMode.js'; +export {default as inputSoupyFind} from './inputSoupyFind.js'; +export {default as inputSoupyReverse} from './inputSoupyReverse.js'; export {default as inputWikiData} from './inputWikiData.js'; +export {default as splitContentNodesAround} from './splitContentNodesAround.js'; export {default as withClonedThings} from './withClonedThings.js'; +export {default as withConstitutedArtwork} from './withConstitutedArtwork.js'; +export {default as withContentNodes} from './withContentNodes.js'; export {default as withContributionListSums} from './withContributionListSums.js'; -export {default as withCoverArtDate} from './withCoverArtDate.js'; export {default as withDirectory} from './withDirectory.js'; -export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js'; +export {default as withHasArtwork} from './withHasArtwork.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/inputFindOptions.js b/src/data/composite/wiki-data/inputFindOptions.js new file mode 100644 index 00000000..07ed4bce --- /dev/null +++ b/src/data/composite/wiki-data/inputFindOptions.js @@ -0,0 +1,5 @@ +import {input} from '#composite'; + +export default function inputFindOptions() { + return input({type: 'object', defaultValue: null}); +} diff --git a/src/data/composite/wiki-data/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 deleted file mode 100644 index 0c644c77..00000000 --- a/src/data/composite/wiki-data/withCoverArtDate.js +++ /dev/null @@ -1,70 +0,0 @@ -// 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'; - -import {raiseOutputWithoutDependency} from '#composite/control-flow'; - -import withResolvedContribs from './withResolvedContribs.js'; - -export default templateCompositeFrom({ - annotation: `withCoverArtDate`, - - inputs: { - from: input({ - validate: isDate, - defaultDependency: 'coverArtDate', - acceptsNull: true, - }), - - fallback: input({ - type: 'boolean', - defaultValue: false, - }), - }, - - outputs: ['#coverArtDate'], - - steps: () => [ - withResolvedContribs({ - from: 'coverArtistContribs', - date: input.value(null), - }), - - raiseOutputWithoutDependency({ - dependency: '#resolvedContribs', - mode: input.value('empty'), - output: input.value({'#coverArtDate': null}), - }), - - { - dependencies: [input('from')], - compute: (continuation, { - [input('from')]: from, - }) => - (from - ? continuation.raiseOutput({'#coverArtDate': from}) - : continuation()), - }, - - { - dependencies: [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})), - }, - ], -}); diff --git a/src/data/composite/wiki-data/withHasArtwork.js b/src/data/composite/wiki-data/withHasArtwork.js new file mode 100644 index 00000000..9c22f439 --- /dev/null +++ b/src/data/composite/wiki-data/withHasArtwork.js @@ -0,0 +1,97 @@ +import {input, templateCompositeFrom} from '#composite'; +import {isContributionList, isThing, strictArrayOf} from '#validators'; + +import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck} + from '#composite/control-flow'; +import {fillMissingListItems, withFlattenedList, withPropertyFromList} + from '#composite/data'; + +export default templateCompositeFrom({ + annotation: 'withHasArtwork', + + inputs: { + contribs: input({ + validate: isContributionList, + defaultValue: null, + }), + + artwork: input({ + validate: isThing, + defaultValue: null, + }), + + artworks: input({ + validate: strictArrayOf(isThing), + defaultValue: null, + }), + }, + + outputs: ['#hasArtwork'], + + steps: () => [ + withResultOfAvailabilityCheck({ + from: input('contribs'), + mode: input.value('empty'), + }), + + { + dependencies: ['#availability'], + compute: (continuation, { + ['#availability']: availability, + }) => + (availability + ? continuation.raiseOutput({ + ['#hasArtwork']: true, + }) + : continuation()), + }, + + { + dependencies: [input('artwork'), input('artworks')], + compute: (continuation, { + [input('artwork')]: artwork, + [input('artworks')]: artworks, + }) => + continuation({ + ['#artworks']: + (artwork && artworks + ? [artwork, ...artworks] + : artwork + ? [artwork] + : artworks + ? artworks + : []), + }), + }, + + raiseOutputWithoutDependency({ + dependency: '#artworks', + mode: input.value('empty'), + output: input.value({'#hasArtwork': false}), + }), + + withPropertyFromList({ + list: '#artworks', + property: input.value('artistContribs'), + internal: input.value(true), + }), + + // Since we're getting the update value for each artwork's artistContribs, + // it may not be set at all, and in that case won't be exposing as []. + fillMissingListItems({ + list: '#artworks.artistContribs', + fill: input.value([]), + }), + + withFlattenedList({ + list: '#artworks.artistContribs', + }), + + withResultOfAvailabilityCheck({ + from: '#flattenedList', + mode: input.value('empty'), + }).outputs({ + '#availability': '#hasArtwork', + }), + ], +}); 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..670dc422 100644 --- a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js @@ -1,15 +1,14 @@ 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 inputFindOptions from './inputFindOptions.js'; +import inputSoupyFind from './inputSoupyFind.js'; import inputNotFoundMode from './inputNotFoundMode.js'; import inputWikiData from './inputWikiData.js'; import raiseResolvedReferenceList from './raiseResolvedReferenceList.js'; @@ -24,17 +23,13 @@ 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(), + findOptions: inputFindOptions(), notFoundMode: inputNotFoundMode(), }, @@ -42,11 +37,6 @@ export default templateCompositeFrom({ outputs: ['#resolvedAnnotatedReferenceList'], steps: () => [ - exitWithoutDependency({ - dependency: input('data'), - value: input.value([]), - }), - raiseOutputWithoutDependency({ dependency: input('list'), mode: input.value('empty'), @@ -73,6 +63,7 @@ export default templateCompositeFrom({ list: '#references', data: input('data'), find: input('find'), + findOptions: input('findOptions'), notFoundMode: input.value('null'), }), @@ -98,17 +89,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..d9a05367 100644 --- a/src/data/composite/wiki-data/withResolvedReference.js +++ b/src/data/composite/wiki-data/withResolvedReference.js @@ -1,16 +1,15 @@ // 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 inputFindOptions from './inputFindOptions.js'; +import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; export default templateCompositeFrom({ @@ -19,8 +18,9 @@ export default templateCompositeFrom({ inputs: { ref: input({type: 'string', acceptsNull: true}), - data: inputWikiData({allowMixedTypes: false}), - find: input({type: 'function'}), + data: inputWikiData({allowMixedTypes: true}), + find: inputSoupyFind(), + findOptions: inputFindOptions(), }, outputs: ['#resolvedReference'], @@ -33,24 +33,40 @@ export default templateCompositeFrom({ }), }), - exitWithoutDependency({ - dependency: input('data'), + gobbleSoupyFind({ + find: input('find'), }), { + dependencies: [input('findOptions')], + compute: (continuation, { + [input('findOptions')]: findOptions, + }) => continuation({ + ['#findOptions']: + (findOptions + ? {...findOptions, mode: 'quiet'} + : {mode: 'quiet'}), + }), + }, + + { dependencies: [ input('ref'), input('data'), - input('find'), + '#find', + '#findOptions', ], compute: (continuation, { [input('ref')]: ref, [input('data')]: data, - [input('find')]: findFunction, + ['#find']: findFunction, + ['#findOptions']: findOptions, }) => continuation({ ['#resolvedReference']: - findFunction(ref, data, {mode: 'quiet'}) ?? null, + (data + ? findFunction(ref, data, findOptions) ?? null + : findFunction(ref, findOptions) ?? null), }), }, ], diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js index 790a962f..14ce6919 100644 --- a/src/data/composite/wiki-data/withResolvedReferenceList.js +++ b/src/data/composite/wiki-data/withResolvedReferenceList.js @@ -1,19 +1,19 @@ // 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 inputFindOptions from './inputFindOptions.js'; import inputNotFoundMode from './inputNotFoundMode.js'; +import inputSoupyFind from './inputSoupyFind.js'; import inputWikiData from './inputWikiData.js'; import raiseResolvedReferenceList from './raiseResolvedReferenceList.js'; @@ -27,7 +27,8 @@ export default templateCompositeFrom({ }), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), + find: inputSoupyFind(), + findOptions: inputFindOptions(), notFoundMode: inputNotFoundMode(), }, @@ -35,11 +36,6 @@ export default templateCompositeFrom({ outputs: ['#resolvedReferenceList'], steps: () => [ - exitWithoutDependency({ - dependency: input('data'), - value: input.value([]), - }), - raiseOutputWithoutDependency({ dependency: input('list'), mode: input.value('empty'), @@ -48,18 +44,43 @@ export default templateCompositeFrom({ }), }), + gobbleSoupyFind({ + find: input('find'), + }), + + { + dependencies: [input('findOptions')], + compute: (continuation, { + [input('findOptions')]: findOptions, + }) => continuation({ + ['#findOptions']: + (findOptions + ? {...findOptions, mode: 'quiet'} + : {mode: 'quiet'}), + }), + }, + { - dependencies: [input('list'), input('data'), input('find')], + dependencies: [input('data'), '#find', '#findOptions'], compute: (continuation, { - [input('list')]: list, [input('data')]: data, - [input('find')]: findFunction, - }) => - continuation({ - '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})), - }), + ['#find']: findFunction, + ['#findOptions']: findOptions, + }) => continuation({ + ['#map']: + (data + ? ref => findFunction(ref, data, findOptions) + : ref => findFunction(ref, findOptions)), + }), }, + 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..aea0f22c 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,8 +9,13 @@ import { } from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withResolvedAnnotatedReferenceList} - from '#composite/wiki-data'; + +import { + inputFindOptions, + inputSoupyFind, + inputWikiData, + withResolvedAnnotatedReferenceList, +} from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} from './helpers/reference-list-helpers.js'; @@ -27,12 +29,8 @@ export default templateCompositeFrom({ ...referenceListInputDescriptions(), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), - - date: input({ - validate: isDate, - acceptsNull: true, - }), + find: inputSoupyFind(), + findOptions: inputFindOptions(), reference: input.staticValue({type: 'string', defaultValue: 'reference'}), annotation: input.staticValue({type: 'string', defaultValue: 'annotation'}), @@ -59,14 +57,13 @@ export default templateCompositeFrom({ withResolvedAnnotatedReferenceList({ list: input.updateValue(), - date: input('date'), - reference: input('reference'), annotation: input('annotation'), thing: input('thing'), data: input('data'), find: input('find'), + findOptions: input('findOptions'), }), exposeDependency({dependency: '#resolvedAnnotatedReferenceList'}), diff --git a/src/data/composite/wiki-properties/canonicalBase.js b/src/data/composite/wiki-properties/canonicalBase.js new file mode 100644 index 00000000..81740d6c --- /dev/null +++ b/src/data/composite/wiki-properties/canonicalBase.js @@ -0,0 +1,16 @@ +import {isURL} from '#validators'; + +export default function() { + return { + flags: {update: true, expose: true}, + update: {validate: isURL}, + expose: { + transform: (value) => + (value === null + ? null + : value.endsWith('/') + ? value + : value + '/'), + }, + }; +} diff --git a/src/data/composite/wiki-properties/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..57a2b8f2 100644 --- a/src/data/composite/wiki-properties/index.js +++ b/src/data/composite/wiki-properties/index.js @@ -3,12 +3,12 @@ // Entries here may depend on entries in #composite/control-flow, // #composite/data, and #composite/wiki-data. -export {default as additionalFiles} from './additionalFiles.js'; -export {default as additionalNameList} from './additionalNameList.js'; export {default as annotatedReferenceList} from './annotatedReferenceList.js'; +export {default as canonicalBase} from './canonicalBase.js'; export {default as color} from './color.js'; -export {default as 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 +21,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..663349ee 100644 --- a/src/data/composite/wiki-properties/referenceList.js +++ b/src/data/composite/wiki-properties/referenceList.js @@ -11,7 +11,13 @@ import {input, templateCompositeFrom} from '#composite'; import {validateReferenceList} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withResolvedReferenceList} from '#composite/wiki-data'; + +import { + inputFindOptions, + inputSoupyFind, + inputWikiData, + withResolvedReferenceList, +} from '#composite/wiki-data'; import {referenceListInputDescriptions, referenceListUpdateDescription} from './helpers/reference-list-helpers.js'; @@ -25,7 +31,8 @@ export default templateCompositeFrom({ ...referenceListInputDescriptions(), data: inputWikiData({allowMixedTypes: true}), - find: input({type: 'function'}), + find: inputSoupyFind(), + findOptions: inputFindOptions(), }, update: @@ -38,6 +45,7 @@ export default templateCompositeFrom({ list: input.updateValue(), data: input('data'), find: input('find'), + findOptions: input('findOptions'), }), exposeDependency({dependency: '#resolvedReferenceList'}), diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js index 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..25b97907 100644 --- a/src/data/composite/wiki-properties/singleReference.js +++ b/src/data/composite/wiki-properties/singleReference.js @@ -8,10 +8,19 @@ // import {input, templateCompositeFrom} from '#composite'; -import {isThingClass, validateReference} from '#validators'; +import {validateReference} from '#validators'; import {exposeDependency} from '#composite/control-flow'; -import {inputWikiData, withResolvedReference} from '#composite/wiki-data'; + +import { + inputFindOptions, + inputSoupyFind, + inputWikiData, + withResolvedReference, +} from '#composite/wiki-data'; + +import {referenceListInputDescriptions, referenceListUpdateDescription} + from './helpers/reference-list-helpers.js'; export default templateCompositeFrom({ annotation: `singleReference`, @@ -19,26 +28,24 @@ export default templateCompositeFrom({ compose: false, inputs: { - class: input.staticValue({validate: isThingClass}), + ...referenceListInputDescriptions(), - find: input({type: 'function'}), - - data: inputWikiData({allowMixedTypes: false}), + data: inputWikiData({allowMixedTypes: true}), + find: inputSoupyFind(), + findOptions: inputFindOptions(), }, - update: ({ - [input.staticValue('class')]: thingClass, - }) => ({ - validate: - validateReference( - thingClass[Symbol.for('Thing.referenceType')]), - }), + update: + referenceListUpdateDescription({ + validateReferenceList: validateReference, + }), steps: () => [ withResolvedReference({ ref: input.updateValue(), data: input('data'), find: input('find'), + findOptions: input('findOptions'), }), exposeDependency({dependency: '#resolvedReference'}), diff --git a/src/data/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..b15f62e0 --- /dev/null +++ b/src/data/things/additional-file.js @@ -0,0 +1,54 @@ +import {input} from '#composite'; +import Thing from '#thing'; +import {isString, validateArrayItems} from '#validators'; + +import {exposeConstant, exposeUpdateValueOrContinue} + from '#composite/control-flow'; +import {contentString, simpleString, thing} from '#composite/wiki-properties'; + +export class AdditionalFile extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + title: simpleString(), + + description: contentString(), + + filenames: [ + exposeUpdateValueOrContinue({ + validate: input.value(validateArrayItems(isString)), + }), + + exposeConstant({ + value: input.value([]), + }), + ], + + // Expose only + + isAdditionalFile: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Title': {property: 'title'}, + 'Description': {property: 'description'}, + 'Files': {property: 'filenames'}, + }, + }; + + get paths() { + if (!this.thing) return null; + if (!this.thing.getOwnAdditionalFilePath) return null; + + return ( + this.filenames.map(filename => + this.thing.getOwnAdditionalFilePath(this, filename))); + } +} diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js new file mode 100644 index 00000000..99f3ee46 --- /dev/null +++ b/src/data/things/additional-name.js @@ -0,0 +1,31 @@ +import {input} from '#composite'; +import Thing from '#thing'; + +import {exposeConstant} from '#composite/control-flow'; +import {contentString, thing} from '#composite/wiki-properties'; + +export class AdditionalName extends Thing { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + thing: thing(), + + name: contentString(), + annotation: contentString(), + + // Expose only + + isAdditionalName: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + static [Thing.yamlDocumentSpec] = { + fields: { + 'Name': {property: 'name'}, + 'Annotation': {property: 'annotation'}, + }, + }; +} diff --git a/src/data/things/album.js b/src/data/things/album.js index bd54a356..58d5253c 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -6,40 +6,56 @@ 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 { + is, + isBoolean, + isColor, + isContributionList, + isDate, + isDirectory, + isNumber, +} from '#validators'; import { parseAdditionalFiles, parseAdditionalNames, parseAnnotatedReferences, + parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, parseDate, parseDimensions, parseWallpaperParts, } from '#yaml'; -import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} - from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; import { - exitWithoutContribs, + exitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + +import { + exitWithoutArtwork, withDirectory, - withResolvedReference, - withCoverArtDate, + withHasArtwork, + withResolvedContribs, } from '#composite/wiki-data'; import { - additionalFiles, - additionalNameList, - commentary, color, commentatorArtists, + constitutibleArtwork, + constitutibleArtworkList, contentString, contribsPresent, contributionList, @@ -50,10 +66,10 @@ import { name, referencedArtworkList, referenceList, - reverseReferencedArtworkList, simpleDate, simpleString, - singleReference, + soupyFind, + soupyReverse, thing, thingList, urls, @@ -61,21 +77,31 @@ import { wikiData, } from '#composite/wiki-properties'; -import {withTracks} from '#composite/things/album'; -import {withAlbum} from '#composite/things/track-section'; +import {withCoverArtDate, 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, }) => ({ - // Update & expose + // > Update & expose - Internal relationships + + trackSections: thingList({ + class: input.value(TrackSection), + }), + + // > Update & expose - Identifying metadata name: name('Unnamed Album'), directory: directory(), @@ -92,109 +118,164 @@ export class Album extends Thing { }), ], + alwaysReferenceByDirectory: flag(false), alwaysReferenceTracksByDirectory: flag(false), suffixTrackDirectories: flag(false), - color: color(), - urls: urls(), + style: [ + exposeUpdateValueOrContinue({ + validate: input.value(is(...[ + 'album', + 'single', + ])), + }), - additionalNames: additionalNameList(), + exposeConstant({ + value: input.value('album'), + }), + ], bandcampAlbumIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), + additionalNames: thingList({ + class: input.value(AdditionalName), + }), + date: simpleDate(), - trackArtDate: simpleDate(), 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, - }), + // > Update & expose - Credits and contributors + + artistContribs: contributionList({ + date: 'date', + artistProperty: input.value('albumArtistContributions'), + }), - fallback: input.value(true), + trackArtistText: contentString(), + + trackArtistContribs: [ + withResolvedContribs({ + from: input.updateValue({validate: isContributionList}), + thingProperty: input.thisProperty(), + artistProperty: input.value('albumTrackArtistContributions'), + date: 'date', + }).outputs({ + '#resolvedContribs': '#trackArtistContribs', }), - exposeDependency({dependency: '#coverArtDate'}), - ], + exposeDependencyOrContinue({ + dependency: '#trackArtistContribs', + mode: input.value('empty'), + }), - coverArtFileExtension: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), - fileExtension('jpg'), + withResolvedContribs({ + from: 'artistContribs', + thingProperty: input.thisProperty(), + artistProperty: input.value('albumTrackArtistContributions'), + date: 'date', + }).outputs({ + '#resolvedContribs': '#trackArtistContribs', + }), + + exposeDependency({dependency: '#trackArtistContribs'}), ], - trackCoverArtFileExtension: fileExtension('jpg'), + // > Update & expose - General configuration - wallpaperFileExtension: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), - fileExtension('jpg'), - ], + countTracksInArtistTotals: flag(true), - bannerFileExtension: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - fileExtension('jpg'), - ], + showAlbumInTracksWithoutArtists: flag(false), - wallpaperStyle: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), - simpleString(), - ], + hasTrackNumbers: flag(true), + isListedOnHomepage: flag(true), + isListedInGalleries: flag(true), - wallpaperParts: [ - exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), - wallpaperParts(), - ], + hideDuration: flag(false), - bannerStyle: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - simpleString(), + // > Update & expose - General metadata + + color: color(), + + urls: urls(), + + // > Update & expose - Artworks + + coverArtworks: [ + // This works, lol, because this array describes `expose.transform` for + // the coverArtworks property, and compositions generally access the + // update value, not what's exposed by property access out in the open. + // There's no recursion going on here. + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + value: input.value([]), + }), + + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Cover Artwork'), ], - coverArtDimensions: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), - dimensions(), + coverArtistContribs: [ + withCoverArtDate(), + + contributionList({ + date: '#coverArtDate', + artistProperty: input.value('albumCoverArtistContributions'), + }), ], - trackDimensions: dimensions(), + coverArtDate: [ + withCoverArtDate({ + from: input.updateValue({ + validate: isDate, + }), + }), - bannerDimensions: [ - exitWithoutContribs({contribs: 'bannerArtistContribs'}), - dimensions(), + exposeDependency({dependency: '#coverArtDate'}), ], - hasTrackNumbers: flag(true), - isListedOnHomepage: flag(true), - isListedInGalleries: flag(true), + coverArtFileExtension: [ + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + }), - commentary: commentary(), - creditSources: commentary(), - additionalFiles: additionalFiles(), + fileExtension('jpg'), + ], - trackSections: thingList({ - class: input.value(TrackSection), - }), + coverArtDimensions: [ + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + }), - artistContribs: contributionList({ - date: 'date', - artistProperty: input.value('albumArtistContributions'), - }), + dimensions(), + ], - coverArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), + artTags: [ + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + value: input.value([]), }), - contributionList({ - date: '#coverArtDate', - artistProperty: input.value('albumCoverArtistContributions'), + referenceList({ + class: input.value(ArtTag), + find: soupyFind.input('artTag'), }), ], + referencedArtworks: [ + exitWithoutArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + value: input.value([]), + }), + + referencedArtworkList(), + ], + trackCoverArtistContribs: contributionList({ // May be null, indicating cover art was added for tracks on the date // each track specifies, or else the track's own release date. @@ -205,100 +286,165 @@ export class Album extends Thing { artistProperty: input.value('trackCoverArtistContributions'), }), - wallpaperArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), + trackArtDate: simpleDate(), + + trackCoverArtFileExtension: fileExtension('jpg'), + + trackDimensions: dimensions(), + + wallpaperArtwork: [ + exitWithoutDependency({ + dependency: 'wallpaperArtistContribs', + mode: input.value('empty'), + value: input.value(null), }), + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Wallpaper Artwork'), + ], + + wallpaperArtistContribs: [ + withCoverArtDate(), + contributionList({ date: '#coverArtDate', artistProperty: input.value('albumWallpaperArtistContributions'), }), ], - bannerArtistContribs: [ - withCoverArtDate({ - fallback: input.value(true), + wallpaperFileExtension: [ + exitWithoutArtwork({ + contribs: 'wallpaperArtistContribs', + artwork: 'wallpaperArtwork', + }), + + fileExtension('jpg'), + ], + + wallpaperStyle: [ + exitWithoutArtwork({ + contribs: 'wallpaperArtistContribs', + artwork: 'wallpaperArtwork', }), + simpleString(), + ], + + wallpaperParts: [ + // kinda nonsensical or at least unlikely lol, but y'know + exitWithoutArtwork({ + contribs: 'wallpaperArtistContribs', + artwork: 'wallpaperArtwork', + value: input.value([]), + }), + + wallpaperParts(), + ], + + bannerArtwork: [ + exitWithoutDependency({ + dependency: 'bannerArtistContribs', + mode: input.value('empty'), + value: input.value(null), + }), + + constitutibleArtwork.fromYAMLFieldSpec + .call(this, 'Banner Artwork'), + ], + + bannerArtistContribs: [ + withCoverArtDate(), + contributionList({ date: '#coverArtDate', artistProperty: input.value('albumBannerArtistContributions'), }), ], - groups: referenceList({ - class: input.value(Group), - find: input.value(find.group), - data: 'groupData', - }), - - artTags: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), + bannerFileExtension: [ + exitWithoutArtwork({ + contribs: 'bannerArtistContribs', + artwork: 'bannerArtwork', }), - referenceList({ - class: input.value(ArtTag), - find: input.value(find.artTag), - data: 'artTagData', - }), + fileExtension('jpg'), ], - referencedArtworks: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), + bannerDimensions: [ + exitWithoutArtwork({ + contribs: 'bannerArtistContribs', + artwork: 'bannerArtwork', }), - { - dependencies: ['coverArtDate', 'date'], - compute: (continuation, { - coverArtDate, - date, - }) => continuation({ - ['#date']: - coverArtDate ?? date, - }), - }, + dimensions(), + ], - referencedArtworkList({ - date: '#date', + bannerStyle: [ + exitWithoutArtwork({ + contribs: 'bannerArtistContribs', + artwork: 'bannerArtwork', }), + + simpleString(), ], - // Update only + // > Update & expose - Groups - albumData: wikiData({ - class: input.value(Album), + groups: referenceList({ + class: input.value(Group), + find: soupyFind.input('group'), }), - artistData: wikiData({ - class: input.value(Artist), + // > Update & expose - Content entries + + commentary: thingList({ + class: input.value(CommentaryEntry), }), - artTagData: wikiData({ - class: input.value(ArtTag), + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), }), - groupData: wikiData({ - class: input.value(Group), + // > Update & expose - Additional files + + additionalFiles: thingList({ + class: input.value(AdditionalFile), }), - trackData: wikiData({ - class: input.value(Track), + // > Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for referencedArtworkList (mixedFind) + artworkData: wikiData({ + class: input.value(Artwork), }), + // used for withMatchingContributionPresets (indirectly by Contribution) wikiInfo: thing({ class: input.value(WikiInfo), }), - // Expose only + // > Expose only + + isAlbum: [ + exposeConstant({ + value: input.value(true), + }), + ], commentatorArtists: commentatorArtists(), - hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasCoverArt: [ + withHasArtwork({ + contribs: 'coverArtistContribs', + artworks: 'coverArtworks', + }), + + exposeDependency({dependency: '#hasArtwork'}), + ], + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), @@ -306,15 +452,6 @@ export class Album extends Thing { withTracks(), exposeDependency({dependency: '#tracks'}), ], - - referencedByArtworks: [ - exitWithoutContribs({ - contribs: 'coverArtistContribs', - value: input.value([]), - }), - - reverseReferencedArtworkList(), - ], }); static [Thing.getSerializeDescriptors] = ({ @@ -359,35 +496,141 @@ 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]), + }, + + albumSinglesOnly: { + referencing: ['album'], + bindTo: 'albumData', + + incldue: album => + album.style === 'single', + + 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'), + + albumTrackArtistContributionsBy: + soupyReverse.contributionsBy('albumData', 'trackArtistContribs'), + + 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, }, }; static [Thing.yamlDocumentSpec] = { fields: { - 'Album': {property: 'name'}, + // Identifying metadata + 'Album': {property: 'name'}, 'Directory': {property: 'directory'}, 'Directory Suffix': {property: 'directorySuffix'}, 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, - - 'Always Reference Tracks By Directory': { - property: 'alwaysReferenceTracksByDirectory', - }, - - 'Additional Names': { - property: 'additionalNames', - transform: parseAdditionalNames, - }, + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + 'Always Reference Tracks By Directory': {property: 'alwaysReferenceTracksByDirectory'}, + 'Style': {property: 'style'}, 'Bandcamp Album ID': { property: 'bandcampAlbumIdentifier', @@ -399,41 +642,129 @@ export class Album extends Thing { transform: String, }, + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + 'Date': { property: 'date', transform: parseDate, }, - 'Color': {property: 'color'}, - 'URLs': {property: 'urls'}, + 'Date Added': { + property: 'dateAddedToWiki', + transform: parseDate, + }, + + // Credits and contributors + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Track Artist Text': { + property: 'trackArtistText', + }, + + 'Track Artists': { + property: 'trackArtistContribs', + transform: parseContributors, + }, + + // General configuration + + 'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'}, + + 'Show Album In Tracks Without Artists': { + property: 'showAlbumInTracksWithoutArtists', + }, 'Has Track Numbers': {property: 'hasTrackNumbers'}, 'Listed on Homepage': {property: 'isListedOnHomepage'}, 'Listed in Galleries': {property: 'isListedInGalleries'}, - 'Cover Art Date': { - property: 'coverArtDate', - transform: parseDate, + 'Hide Duration': {property: 'hideDuration'}, + + // General metadata + + 'Color': {property: 'color'}, + + 'URLs': {property: 'urls'}, + + // Artworks + // (Note - this YAML section is deliberately ordered differently + // than the corresponding property descriptors.) + + 'Cover Artwork': { + property: 'coverArtworks', + transform: + parseArtwork({ + thingProperty: 'coverArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'albumCoverArtistContributions', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + }), }, - 'Default Track Cover Art Date': { - property: 'trackArtDate', - transform: parseDate, + 'Banner Artwork': { + property: 'bannerArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'bannerArtwork', + dimensionsFromThingProperty: 'bannerDimensions', + fileExtensionFromThingProperty: 'bannerFileExtension', + dateFromThingProperty: 'date', + artistContribsFromThingProperty: 'bannerArtistContribs', + artistContribsArtistProperty: 'albumBannerArtistContributions', + }), }, - 'Date Added': { - property: 'dateAddedToWiki', - transform: parseDate, + 'Wallpaper Artwork': { + property: 'wallpaperArtwork', + transform: + parseArtwork({ + single: true, + thingProperty: 'wallpaperArtwork', + dimensionsFromThingProperty: null, + fileExtensionFromThingProperty: 'wallpaperFileExtension', + dateFromThingProperty: 'date', + artistContribsFromThingProperty: 'wallpaperArtistContribs', + artistContribsArtistProperty: 'albumWallpaperArtistContributions', + }), }, - 'Cover Art File Extension': {property: 'coverArtFileExtension'}, - 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, + }, + + 'Cover Art Date': { + property: 'coverArtDate', + transform: parseDate, + }, 'Cover Art Dimensions': { property: 'coverArtDimensions', transform: parseDimensions, }, + 'Default Track Cover Artists': { + property: 'trackCoverArtistContribs', + transform: parseContributors, + }, + + 'Default Track Cover Art Date': { + property: 'trackArtDate', + transform: parseDate, + }, + 'Default Track Dimensions': { property: 'trackDimensions', transform: parseDimensions, @@ -445,7 +776,6 @@ export class Album extends Thing { }, 'Wallpaper Style': {property: 'wallpaperStyle'}, - 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, 'Wallpaper Parts': { property: 'wallpaperParts', @@ -457,51 +787,70 @@ export class Album extends Thing { transform: parseContributors, }, - 'Banner Style': {property: 'bannerStyle'}, - 'Banner File Extension': {property: 'bannerFileExtension'}, - 'Banner Dimensions': { property: 'bannerDimensions', transform: parseDimensions, }, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + 'Banner Style': {property: 'bannerStyle'}, - 'Additional Files': { - property: 'additionalFiles', - transform: parseAdditionalFiles, - }, + 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Track Art File Extension': {property: 'trackCoverArtFileExtension'}, + 'Wallpaper File Extension': {property: 'wallpaperFileExtension'}, + 'Banner File Extension': {property: 'bannerFileExtension'}, + + 'Art Tags': {property: 'artTags'}, 'Referenced Artworks': { property: 'referencedArtworks', transform: parseAnnotatedReferences, }, - 'Franchises': {ignore: true}, + // Groups - 'Artists': { - property: 'artistContribs', - transform: parseContributors, + 'Groups': {property: 'groups'}, + + // Content entries + + 'Commentary': { + property: 'commentary', + transform: parseCommentary, }, - 'Cover Artists': { - property: 'coverArtistContribs', - transform: parseContributors, + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, }, - 'Default Track Cover Artists': { - property: 'trackCoverArtistContribs', - transform: parseContributors, + // Additional files + + 'Additional Files': { + property: 'additionalFiles', + transform: parseAdditionalFiles, }, - 'Groups': {property: 'groups'}, - 'Art Tags': {property: 'artTags'}, + // Shenanigans + 'Franchises': {ignore: true}, 'Review Points': {ignore: true}, }, invalidFieldCombinations: [ + {message: `Move commentary on singles to the track`, fields: [ + ['Style', 'single'], + 'Commentary', + ]}, + + {message: `Move crediting sources on singles to the track`, fields: [ + ['Style', 'single'], + 'Crediting Sources', + ]}, + + {message: `Move additional names on singles to the track`, fields: [ + ['Style', 'single'], + 'Additional Names', + ]}, + {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [ 'Wallpaper Parts', 'Wallpaper Style', @@ -516,7 +865,7 @@ export class Album extends Thing { static [Thing.getYamlLoadingSpec] = ({ documentModes: {headerAndEntries}, - thingConstructors: {Album, Track, TrackSectionHelper}, + thingConstructors: {Album, Track}, }) => ({ title: `Process album files`, @@ -538,6 +887,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 +904,6 @@ export class Album extends Thing { isDefaultTrackSection: true, }); - const albumRef = Thing.getReference(album); - const closeCurrentTrackSection = () => { if ( currentTrackSection.isDefaultTrackSection && @@ -577,17 +930,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,19 +984,101 @@ 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'), unqualifiedDirectory: directory(), + directorySuffix: [ + exposeUpdateValueOrContinue({ + validate: input.value(isDirectory), + }), + + withAlbum(), + + withPropertyFromObject({ + object: '#album', + property: input.value('directorySuffix'), + }), + + exposeDependency({dependency: '#album.directorySuffix'}), + ], + + suffixTrackDirectories: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withAlbum(), + + withPropertyFromObject({ + object: '#album', + property: input.value('suffixTrackDirectories'), + }), + + exposeDependency({dependency: '#album.suffixTrackDirectories'}), + ], + color: [ exposeUpdateValueOrContinue({ validate: input.value(isColor), @@ -623,8 +1094,31 @@ export class TrackSection extends Thing { exposeDependency({dependency: '#album.color'}), ], + startCountingFrom: [ + withStartCountingFrom({ + from: input.updateValue({validate: isNumber}), + }), + + exposeDependency({dependency: '#startCountingFrom'}), + ], + dateOriginallyReleased: simpleDate(), + countTracksInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withAlbum(), + + withPropertyFromObject({ + object: '#album', + property: input.value('countTracksInArtistTotals'), + }), + + exposeDependency({dependency: '#album.countTracksInArtistTotals'}), + ], + isDefaultTrackSection: flag(false), description: contentString(), @@ -640,12 +1134,16 @@ export class TrackSection extends Thing { // Update only - albumData: wikiData({ - class: input.value(Album), - }), + reverse: soupyReverse(), // Expose only + isTrackSection: [ + exposeConstant({ + value: input.value(true), + }), + ], + directory: [ withAlbum(), @@ -674,42 +1172,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), - }), - }, + continueCountingFrom: [ + withContinueCountingFrom(), - exitWithoutDependency({ - dependency: '#index', - mode: input.value('index'), - value: input.value(0), - }), - - { - dependencies: ['#album.trackSections', '#index'], - compute: ({ - ['#album.trackSections']: trackSections, - ['#index']: index, - }) => - accumulateSum( - trackSections - .slice(0, index) - .map(section => section.tracks.length)), - }, + exposeDependency({dependency: '#continueCountingFrom'}), ], }); @@ -727,16 +1193,31 @@ 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'}, + 'Directory Suffix': {property: 'directorySuffix'}, + 'Suffix Track Directories': {property: 'suffixTrackDirectories'}, + 'Color': {property: 'color'}, + 'Start Counting From': {property: 'startCountingFrom'}, 'Date Originally Released': { property: 'dateOriginallyReleased', transform: parseDate, }, + 'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'}, + 'Description': {property: 'description'}, }, }; @@ -746,38 +1227,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..fff724cb 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,31 +1,54 @@ +export const DATA_ART_TAGS_DIRECTORY = 'art-tags'; export const ART_TAG_DATA_FILE = 'tags.yaml'; +import {readFile} from 'node:fs/promises'; +import * as path from 'node:path'; + import {input} from '#composite'; -import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort'; +import {traverse} from '#node-utils'; +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, + exposeConstant, + 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 +62,80 @@ export class ArtTag extends Thing { }, ], - // Update only + additionalNames: thingList({ + class: input.value(AdditionalName), + }), + + description: contentString(), - albumData: wikiData({ - class: input.value(Album), + 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}, + isArtTag: [ + exposeConstant({ + value: input.value(true), + }), + ], + + descriptionShort: [ + exitWithoutDependency({ + dependency: 'description', + mode: input.value('falsy'), + }), - expose: { - dependencies: ['this', 'albumData', 'trackData'], - compute: ({this: artTag, albumData, trackData}) => - sortAlbumsTracksChronologically( - [...albumData, ...trackData] - .filter(({artTags}) => artTags.includes(artTag)), - {getDate: thing => thing.coverArtDate ?? thing.date}), + { + dependencies: ['description'], + compute: ({description}) => + description.split('<hr class="split">')[0], }, - }, + ], + + 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 +143,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,20 +164,50 @@ 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', + }), + }, }, }; static [Thing.getYamlLoadingSpec] = ({ - documentModes: {allInOne}, + documentModes: {allTogether}, thingConstructors: {ArtTag}, }) => ({ title: `Process art tags file`, - file: ART_TAG_DATA_FILE, - documentMode: allInOne, + files: dataPath => + Promise.allSettled([ + readFile(path.join(dataPath, ART_TAG_DATA_FILE)) + .then(() => [ART_TAG_DATA_FILE]), + + traverse(path.join(dataPath, DATA_ART_TAGS_DIRECTORY), { + filterFile: name => path.extname(name) === '.yaml', + prefixPath: DATA_ART_TAGS_DIRECTORY, + }), + ]).then(results => results + .filter(({status}) => status === 'fulfilled') + .flatMap(({value}) => value)), + + documentMode: allTogether, documentThing: ArtTag, save: (results) => ({artTagData: results}), diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 8fdb8a12..2905d893 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -5,28 +5,34 @@ 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 { + sortAlbumsTracksChronologically, + sortArtworksChronologically, + sortAlphabetically, + sortContributionsChronologically, +} from '#sort'; + +import {exitWithoutDependency, exposeConstant} from '#composite/control-flow'; +import {withReverseReferenceList} from '#composite/wiki-data'; 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 +41,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 +53,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,97 +73,170 @@ 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), - }), + find: soupyFind(), + reverse: soupyReverse(), - flashData: wikiData({ - class: input.value(Flash), - }), + // Expose only - groupData: wikiData({ - class: input.value(Group), - }), + isArtist: [ + exposeConstant({ + value: input.value(true), + }), + ], - trackData: wikiData({ - class: input.value(Track), + trackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackArtistContributionsBy'), }), - // Expose only - - trackArtistContributions: reverseContributionList({ - data: 'trackData', - list: input.value('artistContribs'), + trackContributorContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackContributorContributionsBy'), }), - trackContributorContributions: reverseContributionList({ - data: 'trackData', - list: input.value('contributorContribs'), + trackCoverArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('trackCoverArtistContributionsBy'), }), - trackCoverArtistContributions: reverseContributionList({ - data: 'trackData', - list: input.value('coverArtistContribs'), + tracksAsCommentator: reverseReferenceList({ + reverse: soupyReverse.input('tracksWithCommentaryBy'), }), - tracksAsCommentator: reverseReferenceList({ - data: 'trackData', - list: input.value('commentatorArtists'), + albumArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumArtistContributionsBy'), }), - albumArtistContributions: reverseContributionList({ - data: 'albumData', - list: input.value('artistContribs'), + albumTrackArtistContributions: reverseReferenceList({ + reverse: soupyReverse.input('albumTrackArtistContributionsBy'), }), - 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'), }), + musicContributions: [ + withReverseReferenceList({ + reverse: soupyReverse.input('trackArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#trackArtistContribs', + }), + + withReverseReferenceList({ + reverse: soupyReverse.input('trackContributorContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#trackContributorContribs', + }), + + { + dependencies: [ + '#trackArtistContribs', + '#trackContributorContribs', + ], + + compute: (continuation, { + ['#trackArtistContribs']: trackArtistContribs, + ['#trackContributorContribs']: trackContributorContribs, + }) => continuation({ + ['#contributions']: [ + ...trackArtistContribs, + ...trackContributorContribs, + ], + }), + }, + + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortAlbumsTracksChronologically), + }, + ], + + artworkContributions: [ + withReverseReferenceList({ + reverse: soupyReverse.input('trackCoverArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#trackCoverArtistContribs', + }), + + withReverseReferenceList({ + reverse: soupyReverse.input('albumCoverArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#albumCoverArtistContribs', + }), + + withReverseReferenceList({ + reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#albumWallpaperArtistContribs', + }), + + withReverseReferenceList({ + reverse: soupyReverse.input('albumBannerArtistContributionsBy'), + }).outputs({ + '#reverseReferenceList': '#albumBannerArtistContribs', + }), + + { + dependencies: [ + '#trackCoverArtistContribs', + '#albumCoverArtistContribs', + '#albumWallpaperArtistContribs', + '#albumBannerArtistContribs', + ], + + compute: (continuation, { + ['#trackCoverArtistContribs']: trackCoverArtistContribs, + ['#albumCoverArtistContribs']: albumCoverArtistContribs, + ['#albumWallpaperArtistContribs']: albumWallpaperArtistContribs, + ['#albumBannerArtistContribs']: albumBannerArtistContribs, + }) => continuation({ + ['#contributions']: [ + ...trackCoverArtistContribs, + ...albumCoverArtistContribs, + ...albumWallpaperArtistContribs, + ...albumBannerArtistContribs, + ], + }), + }, + + { + dependencies: ['#contributions'], + compute: ({'#contributions': contributions}) => + sortContributionsChronologically( + contributions, + sortArtworksChronologically), + }, + ], + totalDuration: artistTotalDuration(), }); @@ -230,6 +319,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 +375,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 +399,7 @@ export class Artist extends Thing { let aliasedArtist; try { aliasedArtist = this.aliasedArtist.name; - } catch (_error) { + } catch { aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist'); } @@ -303,4 +408,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..116d14d0 --- /dev/null +++ b/src/data/things/artwork.js @@ -0,0 +1,514 @@ +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 {withPropertyFromList, 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 { + withArtTags, + withAttachedArtwork, + withContainingArtworkList, + withContentWarningArtTags, + 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(), + showFilename: simpleString(), + + 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', + }), + ], + + style: simpleString(), + + artTagsFromThingProperty: simpleString(), + + artTags: [ + withArtTags({ + from: input.updateValue({ + validate: + validateReferenceList(ArtTag[Thing.referenceType]), + }), + }), + + exposeDependency({ + dependency: '#artTags', + }), + ], + + 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 + + isArtwork: [ + exposeConstant({ + value: input.value(true), + }), + ], + + 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([]), + }), + ], + + contentWarningArtTags: [ + withContentWarningArtTags(), + + exposeDependency({ + dependency: '#contentWarningArtTags', + }), + ], + + contentWarnings: [ + withContentWarningArtTags(), + + withPropertyFromList({ + list: '#contentWarningArtTags', + property: input.value('name'), + }), + + exposeDependency({ + dependency: '#contentWarningArtTags.name', + }), + ], + }); + + 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'}, + 'Show Filename': {property: 'showFilename'}, + + 'Date': { + property: 'date', + transform: parseDate, + }, + + 'Attach Above': {property: 'attachAbove'}, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Style': {property: 'style'}, + + '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..a3dfc183 --- /dev/null +++ b/src/data/things/content.js @@ -0,0 +1,247 @@ +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 + + isContentEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + + 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 + + isCommentaryEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + + isWikiEditorCommentary: hasAnnotationPart({ + part: input.value('wiki editor'), + }), + }); +} + +export class LyricsEntry extends ContentEntry { + static [Thing.getPropertyDescriptors] = () => ({ + // Update & expose + + originDetails: contentString(), + + // Expose only + + isLyricsEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + + isWikiLyrics: hasAnnotationPart({ + part: input.value('wiki lyrics'), + }), + + helpNeeded: hasAnnotationPart({ + part: input.value('help needed'), + }), + + 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 { + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isCreditingSourcesEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); +} + +export class ReferencingSourcesEntry extends ContentEntry { + static [Thing.getPropertyDescriptors] = () => ({ + // Expose only + + isReferencingSourceEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); +} diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js index 2712af70..006aeec0 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, @@ -20,8 +27,6 @@ import { import { inheritFromContributionPresets, - thingPropertyMatches, - thingReferenceTypeMatches, withContainingReverseContributionList, withContributionArtist, withContributionContext, @@ -71,7 +76,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,11 +103,51 @@ 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 + isContribution: [ + exposeConstant({ + value: input.value(true), + }), + ], + context: [ withContributionContext(), @@ -164,38 +228,6 @@ export class Contribution extends Thing { }), ], - isArtistContribution: thingPropertyMatches({ - value: input.value('artistContribs'), - }), - - isContributorContribution: thingPropertyMatches({ - value: input.value('contributorContribs'), - }), - - isCoverArtistContribution: thingPropertyMatches({ - value: input.value('coverArtistContribs'), - }), - - isBannerArtistContribution: thingPropertyMatches({ - value: input.value('bannerArtistContribs'), - }), - - isWallpaperArtistContribution: thingPropertyMatches({ - value: input.value('wallpaperArtistContribs'), - }), - - isForTrack: thingReferenceTypeMatches({ - value: input.value('track'), - }), - - isForAlbum: thingReferenceTypeMatches({ - value: input.value('album'), - }), - - isForFlash: thingReferenceTypeMatches({ - value: input.value('flash'), - }), - previousBySameArtist: [ withContainingReverseContributionList().outputs({ '#containingReverseContributionList': '#list', @@ -235,6 +267,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 +303,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..73b22746 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,35 +120,41 @@ 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), }), // Expose only + isFlash: [ + exposeConstant({ + value: input.value(true), + }), + ], + commentatorArtists: commentatorArtists(), act: [ @@ -173,6 +194,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 +226,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 +256,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,22 +313,22 @@ 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 + isFlashAct: [ + exposeConstant({ + value: input.value(true), + }), + ], + side: [ withFlashSide(), exposeDependency({dependency: '#flashSide'}), @@ -271,6 +342,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 +378,20 @@ 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(), + + // Expose only + + isFlashSide: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { @@ -325,6 +410,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 +477,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..0935dc93 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,41 +1,80 @@ 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, isBoolean} from '#validators'; import {parseAnnotatedReferences, parseSerieses} from '#yaml'; +import {withPropertyFromObject} from '#composite/data'; +import {withUniqueReferencingThing} from '#composite/wiki-data'; + +import { + exposeConstant, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, +} from '#composite/control-flow'; + import { annotatedReferenceList, color, contentString, directory, + flag, name, referenceList, - seriesList, + soupyFind, + soupyReverse, + 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'), directory: directory(), + excludeFromGalleryTabs: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), + }), + + withUniqueReferencingThing({ + reverse: soupyReverse.input('groupCategoriesWhichInclude'), + }).outputs({ + '#uniqueReferencingThing': '#category', + }), + + withPropertyFromObject({ + object: '#category', + property: input.value('excludeGroupsFromGalleryTabs'), + }), + + exposeDependencyOrContinue({ + dependency: '#category.excludeGroupsFromGalleryTabs', + }), + + exposeConstant({ + value: input.value(false), + }), + ], + + divideAlbumsByStyle: flag(false), + description: contentString(), urls: urls(), 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,30 +82,26 @@ 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: soupyReverse(), // Expose only + isGroup: [ + exposeConstant({ + value: input.value(true), + }), + ], + descriptionShort: { flags: {expose: true}, @@ -83,9 +118,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 +128,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 +139,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,10 +154,33 @@ 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'}, 'Directory': {property: 'directory'}, + + 'Exclude From Gallery Tabs': {property: 'excludeFromGalleryTabs'}, + 'Divide Albums By Style': {property: 'divideAlbumsByStyle'}, + 'Description': {property: 'description'}, 'URLs': {property: 'urls'}, @@ -186,8 +244,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 @@ -206,25 +265,116 @@ export class GroupCategory extends Thing { name: name('Unnamed Group Category'), directory: directory(), + excludeGroupsFromGalleryTabs: flag(false), + color: color(), 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(), + + // Expose only + + isGroupCategory: [ + exposeConstant({ + value: input.value(true), + }), + ], }); + static [Thing.reverseSpecs] = { + groupCategoriesWhichInclude: { + bindTo: 'groupCategoryData', + + referencing: groupCategory => [groupCategory], + referenced: groupCategory => groupCategory.groups, + }, + }; + static [Thing.yamlDocumentSpec] = { fields: { 'Category': {property: 'name'}, + 'Color': {property: 'color'}, + + 'Exclude Groups From Gallery Tabs': { + property: 'excludeGroupsFromGalleryTabs', + }, + }, + }; +} + +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..2456ca95 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 {exposeConstant, 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,20 @@ 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}, + sections: thingList({ + class: input.value(HomepageLayoutSection), + }), - update: { - validate: validateArrayItems(validateInstanceOf(HomepageLayoutRow)), - }, - }, + // Expose only + + isHomepageLayout: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { @@ -50,85 +65,256 @@ 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), + }), + + // Expose only + + isHomepageLayoutSection: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + 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 + + isHomepageLayoutRow: [ + exposeConstant({ + value: input.value(true), + }), + ], 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 + + isHomepageLayoutActionsRow: [ + exposeConstant({ + value: input.value(true), + }), + ], + + 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 + + isHomepageLayoutAlbumCarouselRow: [ + exposeConstant({ + value: input.value(true), + }), + ], + 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 +337,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 +345,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 +353,25 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { update: {validate: isCountingNumber}, }, - actionLinks: { - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isString)}, + // Expose only + + isHomepageLayoutAlbumGridRow: [ + exposeConstant({ + value: input.value(true), + }), + ], + + 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..08c52cb8 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,8 +1,9 @@ -import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; +import {Temporal, toTemporalInstant} from '@js-temporal/polyfill'; import {withAggregate} from '#aggregate'; import CacheableObject from '#cacheable-object'; import {logWarn} from '#cli'; +import {input} from '#composite'; import * as html from '#html'; import {empty} from '#sugar'; import {isLanguageCode} from '#validators'; @@ -16,6 +17,7 @@ import { isExternalLinkStyle, } from '#external-links'; +import {exposeConstant} from '#composite/control-flow'; import {externalFunction, flag, name} from '#composite/wiki-properties'; export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g; @@ -115,7 +117,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}, @@ -127,6 +129,12 @@ export class Language extends Thing { // Expose only + isLanguage: [ + exposeConstant({ + value: input.value(true), + }), + ], + onlyIfOptions: { flags: {expose: true}, expose: { @@ -135,6 +143,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'}), @@ -203,6 +212,10 @@ export class Language extends Thing { } formatString(...args) { + if (typeof args.at(-1) === 'function') { + throw new Error(`Passed function - did you mean language.encapsulate() instead?`); + } + const hasOptions = typeof args.at(-1) === 'object' && args.at(-1) !== null; @@ -309,7 +322,7 @@ export class Language extends Thing { return undefined; } - return optionValue; + return this.sanitize(optionValue); }, }); @@ -374,26 +387,16 @@ export class Language extends Thing { partInProgress += template.slice(lastIndex, match.index); - // Sanitize string arguments in particular. These are taken to come from - // (raw) data and may include special characters that aren't meant to be - // rendered as HTML markup. - const sanitizedInsertion = - this.#sanitizeValueForInsertion(insertion); - - if (typeof sanitizedInsertion === 'string') { - // Join consecutive strings together. - partInProgress += sanitizedInsertion; - } else if ( - sanitizedInsertion instanceof html.Tag && - sanitizedInsertion.contentOnly - ) { - // Collapse string-only tag contents onto the current string part. - partInProgress += sanitizedInsertion.toString(); - } else { - // Push the string part in progress, then the insertion as-is. - outputParts.push(partInProgress); - outputParts.push(sanitizedInsertion); - partInProgress = ''; + for (const insertionItem of html.smush(insertion).content) { + if (typeof insertionItem === 'string') { + // Join consecutive strings together. + partInProgress += insertionItem; + } else { + // Push the string part in progress, then the insertion as-is. + outputParts.push(partInProgress); + outputParts.push(insertionItem); + partInProgress = ''; + } } lastIndex = match.index + match[0].length; @@ -488,22 +491,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 +867,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,13 +933,14 @@ 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'), - countCoverArts: countHelper('coverArts'), 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/news-entry.js b/src/data/things/news-entry.js index 43d1638e..28289f53 100644 --- a/src/data/things/news-entry.js +++ b/src/data/things/news-entry.js @@ -1,9 +1,11 @@ export const NEWS_DATA_FILE = 'news.yaml'; +import {input} from '#composite'; import {sortChronologically} from '#sort'; import Thing from '#thing'; import {parseDate} from '#yaml'; +import {exposeConstant} from '#composite/control-flow'; import {contentString, directory, name, simpleDate} from '#composite/wiki-properties'; @@ -22,6 +24,12 @@ export class NewsEntry extends Thing { // Expose only + isNewsEntry: [ + exposeConstant({ + value: input.value(true), + }), + ], + contentShort: { flags: {expose: true}, diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js new file mode 100644 index 00000000..808a0085 --- /dev/null +++ b/src/data/things/sorting-rule.js @@ -0,0 +1,411 @@ +export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml'; + +import {readFile, writeFile} from 'node:fs/promises'; +import * as path from 'node:path'; + +import {input} from '#composite'; +import {chunkByProperties, compareArrays, unique} from '#sugar'; +import 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 {exposeConstant} from '#composite/control-flow'; +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}, + }, + + // Expose only + + isSortingRule: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + 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), + }, + }, + + // Expose only + + isThingSortingRule: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + 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}, + }, + + // Expose only + + isDocumentSortingRule: [ + exposeConstant({ + value: input.value(true), + }), + ], + }); + + 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..28167df2 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -2,12 +2,14 @@ export const DATA_STATIC_PAGE_DIRECTORY = 'static-page'; import * as path from 'node:path'; +import {input} from '#composite'; import {traverse} from '#node-utils'; import {sortAlphabetically} from '#sort'; import Thing from '#thing'; import {isName} from '#validators'; -import {contentString, directory, name, simpleString} +import {exposeConstant} from '#composite/control-flow'; +import {contentString, directory, flag, name, simpleString} from '#composite/wiki-properties'; export class StaticPage extends Thing { @@ -30,9 +32,20 @@ export class StaticPage extends Thing { }, directory: directory(), - content: contentString(), + stylesheet: simpleString(), script: simpleString(), + content: contentString(), + + absoluteLinks: flag(), + + // Expose only + + isStaticPage: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.findSpecs] = { @@ -48,6 +61,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..93193b6a 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -3,22 +3,34 @@ 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'; + +import { + isBoolean, + isColor, + isContentString, + isContributionList, + isDate, + isFileExtension, + validateReference, +} from '#validators'; import { parseAdditionalFiles, parseAdditionalNames, parseAnnotatedReferences, + parseArtwork, + parseCommentary, parseContributors, + parseCreditingSources, + parseReferencingSources, parseDate, parseDimensions, parseDuration, + parseLyrics, } from '#yaml'; -import {withPropertyFromObject} from '#composite/data'; +import {withPropertyFromList, withPropertyFromObject} from '#composite/data'; import { exitWithoutDependency, @@ -36,10 +48,8 @@ import { } from '#composite/wiki-data'; import { - additionalFiles, - additionalNameList, - commentary, commentatorArtists, + constitutibleArtworkList, contentString, contributionList, dimensions, @@ -50,47 +60,61 @@ import { referenceList, referencedArtworkList, reverseReferenceList, - reverseReferencedArtworkList, simpleDate, simpleString, - singleReference, + soupyFind, + soupyReverse, thing, + thingList, urls, wikiData, } from '#composite/wiki-properties'; import { + alwaysReferenceByDirectory, exitWithoutUniqueCoverArt, - inheritContributionListFromOriginalRelease, - inheritFromOriginalRelease, - trackReverseReferenceList, - withAlbum, - withAlwaysReferenceByDirectory, + inheritContributionListFromMainRelease, + inheritFromMainRelease, + withAllReleases, withContainingTrackSection, + withCoverArtistContribs, withDate, withDirectorySuffix, withHasUniqueCoverArt, - withOriginalRelease, + withMainRelease, + withMainReleaseTrack, 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 + // > Update & expose - Internal relationships + + album: thing({ + class: input.value(Album), + }), + + // > Update & expose - Identifying metadata name: name('Unnamed Track'), + nameText: contentString(), directory: [ withDirectorySuffix(), @@ -122,120 +146,51 @@ export class Track extends Thing { }) ], - additionalNames: additionalNameList(), - - bandcampTrackIdentifier: simpleString(), - bandcampArtworkIdentifier: simpleString(), - - duration: duration(), - urls: urls(), - dateFirstReleased: simpleDate(), - - color: [ - exposeUpdateValueOrContinue({ - validate: input.value(isColor), - }), - - withContainingTrackSection(), + alwaysReferenceByDirectory: alwaysReferenceByDirectory(), - withPropertyFromObject({ - object: '#trackSection', - property: input.value('color'), + // Album or track. The exposed value is really just what's provided here, + // whether or not a matching track is found on a provided album, for + // example. When presenting or processing, read `mainReleaseTrack`. + mainRelease: [ + withMainRelease({ + from: input.updateValue({ + validate: + validateReference(['album', 'track']), + }), }), - exposeDependencyOrContinue({dependency: '#trackSection.color'}), - - withPropertyFromAlbum({ - property: input.value('color'), + exposeDependency({ + dependency: '#mainRelease', }), - - exposeDependency({dependency: '#album.color'}), ], - alwaysReferenceByDirectory: [ - withAlwaysReferenceByDirectory(), - exposeDependency({dependency: '#alwaysReferenceByDirectory'}), - ], + bandcampTrackIdentifier: simpleString(), + bandcampArtworkIdentifier: simpleString(), - // Disables presenting the track as though it has its own unique artwork. - // This flag should only be used in select circumstances, i.e. to override - // an album's trackCoverArtists. This flag supercedes that property, as well - // as the track's own coverArtists. - disableUniqueCoverArt: flag(), + additionalNames: thingList({ + class: input.value(AdditionalName), + }), - // File extension for track's corresponding media file. This represents the - // track's unique cover artwork, if any, and does not inherit the extension - // of the album's main artwork. It does inherit trackCoverArtFileExtension, - // if present on the album. - coverArtFileExtension: [ - exitWithoutUniqueCoverArt(), + dateFirstReleased: simpleDate(), + + // > Update & expose - Credits and contributors + artistText: [ exposeUpdateValueOrContinue({ - validate: input.value(isFileExtension), + validate: input.value(isContentString), }), withPropertyFromAlbum({ - property: input.value('trackCoverArtFileExtension'), - }), - - exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), - - exposeConstant({ - value: input.value('jpg'), + property: input.value('trackArtistText'), }), - ], - coverArtDate: [ - withTrackArtDate({ - from: input.updateValue({ - validate: isDate, - }), - }), - - exposeDependency({dependency: '#trackArtDate'}), - ], - - coverArtDimensions: [ - exitWithoutUniqueCoverArt(), - - withPropertyFromAlbum({ - property: input.value('trackDimensions'), + exposeDependency({ + dependency: '#album.trackArtistText', }), - - exposeDependencyOrContinue({dependency: '#album.trackDimensions'}), - - dimensions(), ], - commentary: commentary(), - creditSources: commentary(), - - lyrics: [ - inheritFromOriginalRelease(), - contentString(), - ], - - additionalFiles: additionalFiles(), - sheetMusicFiles: additionalFiles(), - midiProjectFiles: additionalFiles(), - - originalReleaseTrack: singleReference({ - class: input.value(Track), - find: input.value(find.track), - data: 'trackData', - }), - - // 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', - }), - artistContribs: [ - inheritContributionListFromOriginalRelease(), + inheritContributionListFromMainRelease(), withDate(), @@ -254,24 +209,24 @@ export class Track extends Thing { }), withPropertyFromAlbum({ - property: input.value('artistContribs'), + property: input.value('trackArtistContribs'), }), withRecontextualizedContributionList({ - list: '#album.artistContribs', + list: '#album.trackArtistContribs', artistProperty: input.value('trackArtistContributions'), }), withRedatedContributionList({ - list: '#album.artistContribs', + list: '#album.trackArtistContribs', date: '#date', }), - exposeDependency({dependency: '#album.artistContribs'}), + exposeDependency({dependency: '#album.trackArtistContribs'}), ], contributorContribs: [ - inheritContributionListFromOriginalRelease(), + inheritContributionListFromMainRelease(), withDate(), @@ -281,71 +236,140 @@ 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([]), + // > Update & expose - General configuration + + countInArtistTotals: [ + exposeUpdateValueOrContinue({ + validate: input.value(isBoolean), }), - withTrackArtDate({ - fallback: input.value(true), + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', + property: input.value('countTracksInArtistTotals'), }), - withResolvedContribs({ - from: input.updateValue({validate: isContributionList}), - thingProperty: input.thisProperty(), - artistProperty: input.value('trackCoverArtistContributions'), - date: '#trackArtDate', - }).outputs({ - '#resolvedContribs': '#coverArtistContribs', + exposeDependency({dependency: '#trackSection.countTracksInArtistTotals'}), + ], + + disableUniqueCoverArt: flag(), + disableDate: flag(), + + // > Update & expose - General metadata + + duration: duration(), + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), }), - exposeDependencyOrContinue({ - dependency: '#coverArtistContribs', - mode: input.value('empty'), + withContainingTrackSection(), + + withPropertyFromObject({ + object: '#trackSection', + property: input.value('color'), }), + exposeDependencyOrContinue({dependency: '#trackSection.color'}), + withPropertyFromAlbum({ - property: input.value('trackCoverArtistContribs'), + property: input.value('color'), }), - withRecontextualizedContributionList({ - list: '#album.trackCoverArtistContribs', - artistProperty: input.value('trackCoverArtistContributions'), + exposeDependency({dependency: '#album.color'}), + ], + + needsLyrics: [ + exposeUpdateValueOrContinue({ + mode: input.value('falsy'), + validate: input.value(isBoolean), }), - withRedatedContributionList({ - list: '#album.trackCoverArtistContribs', - date: '#trackArtDate', + exitWithoutDependency({ + dependency: 'lyrics', + mode: input.value('empty'), + value: input.value(false), + }), + + withPropertyFromList({ + list: 'lyrics', + property: input.value('helpNeeded'), }), - exposeDependency({dependency: '#album.trackCoverArtistContribs'}), + { + dependencies: ['#lyrics.helpNeeded'], + compute: ({ + ['#lyrics.helpNeeded']: helpNeeded, + }) => + helpNeeded.includes(true) + }, ], - referencedTracks: [ - inheritFromOriginalRelease({ - notFoundValue: input.value([]), + urls: urls(), + + // > Update & expose - Artworks + + trackArtworks: [ + exitWithoutUniqueCoverArt({ + value: input.value([]), }), - referenceList({ - class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + constitutibleArtworkList.fromYAMLFieldSpec + .call(this, 'Track Artwork'), + ], + + coverArtistContribs: [ + withCoverArtistContribs({ + from: input.updateValue({ + validate: isContributionList, + }), }), + + exposeDependency({dependency: '#coverArtistContribs'}), ], - sampledTracks: [ - inheritFromOriginalRelease({ - notFoundValue: input.value([]), + coverArtDate: [ + withTrackArtDate({ + from: input.updateValue({ + validate: isDate, + }), }), - referenceList({ - class: input.value(Track), - find: input.value(find.track), - data: 'trackData', + exposeDependency({dependency: '#trackArtDate'}), + ], + + coverArtFileExtension: [ + exitWithoutUniqueCoverArt(), + + exposeUpdateValueOrContinue({ + validate: input.value(isFileExtension), + }), + + withPropertyFromAlbum({ + property: input.value('trackCoverArtFileExtension'), + }), + + exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), + + exposeConstant({ + value: input.value('jpg'), + }), + ], + + coverArtDimensions: [ + exitWithoutUniqueCoverArt(), + + exposeUpdateValueOrContinue(), + + withPropertyFromAlbum({ + property: input.value('trackDimensions'), }), + + exposeDependencyOrContinue({dependency: '#album.trackDimensions'}), + + dimensions(), ], artTags: [ @@ -355,8 +379,7 @@ export class Track extends Thing { referenceList({ class: input.value(ArtTag), - find: input.value(find.artTag), - data: 'artTagData', + find: soupyFind.input('artTag'), }), ], @@ -365,118 +388,204 @@ export class Track extends Thing { value: input.value([]), }), - withTrackArtDate({ - fallback: input.value(true), + referencedArtworkList(), + ], + + // > Update & expose - Referenced tracks + + referencedTracks: [ + inheritFromMainRelease({ + notFoundValue: input.value([]), }), - referencedArtworkList({ - date: '#trackArtDate', + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackMainReleasesOnly'), }), ], - // Update only + sampledTracks: [ + inheritFromMainRelease({ + notFoundValue: input.value([]), + }), - albumData: wikiData({ - class: input.value(Album), + referenceList({ + class: input.value(Track), + find: soupyFind.input('trackMainReleasesOnly'), + }), + ], + + // > Update & expose - Additional files + + additionalFiles: thingList({ + class: input.value(AdditionalFile), }), - artistData: wikiData({ - class: input.value(Artist), + sheetMusicFiles: thingList({ + class: input.value(AdditionalFile), }), - artTagData: wikiData({ - class: input.value(ArtTag), + midiProjectFiles: thingList({ + class: input.value(AdditionalFile), }), - flashData: wikiData({ - class: input.value(Flash), + // > Update & expose - Content entries + + lyrics: [ + // TODO: Inherited lyrics are literally the same objects, so of course + // their .thing properties aren't going to point back to this one, and + // certainly couldn't be recontextualized... + inheritFromMainRelease(), + + thingList({ + class: input.value(LyricsEntry), + }), + ], + + commentary: thingList({ + class: input.value(CommentaryEntry), }), - trackData: wikiData({ - class: input.value(Track), + creditingSources: thingList({ + class: input.value(CreditingSourcesEntry), }), - trackSectionData: wikiData({ - class: input.value(TrackSection), + referencingSources: thingList({ + class: input.value(ReferencingSourcesEntry), + }), + + // > Update only + + find: soupyFind(), + reverse: soupyReverse(), + + // used for referencedArtworkList (mixedFind) + artworkData: wikiData({ + class: input.value(Artwork), }), + // used for withMatchingContributionPresets (indirectly by Contribution) wikiInfo: thing({ class: input.value(WikiInfo), }), - // Expose only + // > Expose only - commentatorArtists: commentatorArtists(), - - album: [ - withAlbum(), - exposeDependency({dependency: '#album'}), + isTrack: [ + exposeConstant({ + value: input.value(true), + }), ], + commentatorArtists: commentatorArtists(), + date: [ withDate(), exposeDependency({dependency: '#date'}), ], + trackNumber: [ + withTrackNumber(), + exposeDependency({dependency: '#trackNumber'}), + ], + hasUniqueCoverArt: [ withHasUniqueCoverArt(), exposeDependency({dependency: '#hasUniqueCoverArt'}), ], - isOriginalRelease: [ - withOriginalRelease(), + isMainRelease: [ + withMainReleaseTrack(), exposeWhetherDependencyAvailable({ - dependency: '#originalRelease', + dependency: '#mainReleaseTrack', negate: input.value(true), }), ], - isRerelease: [ - withOriginalRelease(), + isSecondaryRelease: [ + withMainReleaseTrack(), exposeWhetherDependencyAvailable({ - dependency: '#originalRelease', + dependency: '#mainReleaseTrack', + }), + ], + + mainReleaseTrack: [ + withMainReleaseTrack(), + + exposeDependency({ + dependency: '#mainReleaseTrack', }), ], + // Only has any value for main releases, because secondary releases + // are never secondary to *another* secondary release. + secondaryReleases: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichAreSecondaryReleasesOf'), + }), + + allReleases: [ + withAllReleases(), + exposeDependency({dependency: '#allReleases'}), + ], + otherReleases: [ withOtherReleases(), exposeDependency({dependency: '#otherReleases'}), ], - referencedByTracks: trackReverseReferenceList({ - list: input.value('referencedTracks'), - }), + commentaryFromMainRelease: [ + withMainReleaseTrack(), - sampledByTracks: trackReverseReferenceList({ - list: input.value('sampledTracks'), - }), + exitWithoutDependency({ + dependency: '#mainReleaseTrack', + value: input.value([]), + }), - featuredInFlashes: reverseReferenceList({ - data: 'flashData', - list: input.value('featuredTracks'), - }), + withPropertyFromObject({ + object: '#mainReleaseTrack', + property: input.value('commentary'), + }), - referencedByArtworks: [ - exitWithoutUniqueCoverArt({ - value: input.value([]), + exposeDependency({ + dependency: '#mainReleaseTrack.commentary', }), + ], - reverseReferencedArtworkList(), + groups: [ + withPropertyFromAlbum({ + property: input.value('groups'), + }), + + exposeDependency({ + dependency: '#album.groups', + }), ], + + referencedByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichReference'), + }), + + sampledByTracks: reverseReferenceList({ + reverse: soupyReverse.input('tracksWhichSample'), + }), + + featuredInFlashes: reverseReferenceList({ + reverse: soupyReverse.input('flashesWhichFeature'), + }), }); static [Thing.yamlDocumentSpec] = { fields: { + // Identifying metadata + 'Track': {property: 'name'}, + 'Track Text': {property: 'nameText'}, 'Directory': {property: 'directory'}, 'Suffix Directory': {property: 'suffixDirectoryFromAlbum'}, - - 'Additional Names': { - property: 'additionalNames', - transform: parseAdditionalNames, - }, + 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + 'Main Release': {property: 'mainRelease'}, 'Bandcamp Track ID': { property: 'bandcampTrackIdentifier', @@ -488,17 +597,87 @@ export class Track extends Thing { transform: String, }, + 'Additional Names': { + property: 'additionalNames', + transform: parseAdditionalNames, + }, + + 'Date First Released': { + property: 'dateFirstReleased', + transform: parseDate, + }, + + // Credits and contributors + + 'Artist Text': { + property: 'artistText', + }, + + 'Artists': { + property: 'artistContribs', + transform: parseContributors, + }, + + 'Contributors': { + property: 'contributorContribs', + transform: parseContributors, + }, + + // General configuration + + 'Count In Artist Totals': {property: 'countInArtistTotals'}, + + 'Has Cover Art': { + property: 'disableUniqueCoverArt', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + 'Has Date': { + property: 'disableDate', + transform: value => + (typeof value === 'boolean' + ? !value + : value), + }, + + // General metadata + 'Duration': { property: 'duration', transform: parseDuration, }, 'Color': {property: 'color'}, + + 'Needs Lyrics': { + property: 'needsLyrics', + }, + 'URLs': {property: 'urls'}, - 'Date First Released': { - property: 'dateFirstReleased', - transform: parseDate, + // Artworks + + 'Track Artwork': { + property: 'trackArtworks', + transform: + parseArtwork({ + thingProperty: 'trackArtworks', + dimensionsFromThingProperty: 'coverArtDimensions', + fileExtensionFromThingProperty: 'coverArtFileExtension', + dateFromThingProperty: 'coverArtDate', + artTagsFromThingProperty: 'artTags', + referencedArtworksFromThingProperty: 'referencedArtworks', + artistContribsFromThingProperty: 'coverArtistContribs', + artistContribsArtistProperty: 'trackCoverArtistContributions', + }), + }, + + 'Cover Artists': { + property: 'coverArtistContribs', + transform: parseContributors, }, 'Cover Art Date': { @@ -513,19 +692,19 @@ export class Track extends Thing { transform: parseDimensions, }, - 'Has Cover Art': { - property: 'disableUniqueCoverArt', - transform: value => - (typeof value === 'boolean' - ? !value - : value), + 'Art Tags': {property: 'artTags'}, + + 'Referenced Artworks': { + property: 'referencedArtworks', + transform: parseAnnotatedReferences, }, - 'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'}, + // Referenced tracks + + 'Referenced Tracks': {property: 'referencedTracks'}, + 'Sampled Tracks': {property: 'sampledTracks'}, - 'Lyrics': {property: 'lyrics'}, - 'Commentary': {property: 'commentary'}, - 'Credit Sources': {property: 'creditSources'}, + // Additional files 'Additional Files': { property: 'additionalFiles', @@ -542,61 +721,63 @@ export class Track extends Thing { transform: parseAdditionalFiles, }, - 'Originally Released As': {property: 'originalReleaseTrack'}, - 'Referenced Tracks': {property: 'referencedTracks'}, - 'Sampled Tracks': {property: 'sampledTracks'}, + // Content entries - 'Referenced Artworks': { - property: 'referencedArtworks', - transform: parseAnnotatedReferences, + 'Lyrics': { + property: 'lyrics', + transform: parseLyrics, }, - 'Franchises': {ignore: true}, - 'Inherit Franchises': {ignore: true}, - - 'Artists': { - property: 'artistContribs', - transform: parseContributors, + 'Commentary': { + property: 'commentary', + transform: parseCommentary, }, - 'Contributors': { - property: 'contributorContribs', - transform: parseContributors, + 'Crediting Sources': { + property: 'creditingSources', + transform: parseCreditingSources, }, - 'Cover Artists': { - property: 'coverArtistContribs', - transform: parseContributors, + 'Referencing Sources': { + property: 'referencingSources', + transform: parseReferencingSources, }, - 'Art Tags': {property: 'artTags'}, + // Shenanigans + 'Franchises': {ignore: true}, + 'Inherit Franchises': {ignore: true}, '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 +798,7 @@ export class Track extends Thing { static [Thing.findSpecs] = { track: { referenceTypes: ['track'], + bindTo: 'trackData', getMatchableNames: track => @@ -625,12 +807,12 @@ export class Track extends Thing { : [track.name]), }, - trackOriginalReleasesOnly: { + trackMainReleasesOnly: { referenceTypes: ['track'], bindTo: 'trackData', include: track => - !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'), + !CacheableObject.getUpdateValue(track, 'mainRelease'), // It's still necessary to check alwaysReferenceByDirectory here, since // it may be set manually (with `Always Reference By Directory: true`), @@ -643,7 +825,12 @@ export class Track extends Thing { }, trackWithArtwork: { - referenceTypes: ['track'], + referenceTypes: [ + 'track', + 'track-referencing-artworks', + 'track-referenced-artworks', + ], + bindTo: 'trackData', include: track => @@ -654,32 +841,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, 'mainRelease')) { + 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..7fb6a350 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, @@ -11,12 +10,21 @@ import { isContributionPresetList, isLanguageCode, isName, - isURL, } from '#validators'; -import {exitWithoutDependency} from '#composite/control-flow'; -import {contentString, flag, name, referenceList, wikiData} - from '#composite/wiki-properties'; +import {exitWithoutDependency, exposeConstant} from '#composite/control-flow'; + +import { + canonicalBase, + contentString, + fileExtension, + flag, + name, + referenceList, + simpleString, + soupyFind, + wallpaperParts, +} from '#composite/wiki-properties'; export class WikiInfo extends Thing { static [Thing.friendlyName] = `Wiki Info`; @@ -56,23 +64,16 @@ export class WikiInfo extends Thing { update: {validate: isLanguageCode}, }, - canonicalBase: { - flags: {update: true, expose: true}, - update: {validate: isURL}, - expose: { - transform: (value) => - (value === null - ? null - : value.endsWith('/') - ? value - : value + '/'), - }, - }, + canonicalBase: canonicalBase(), + canonicalMediaBase: canonicalBase(), + + 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 +100,8 @@ export class WikiInfo extends Thing { // Update only + find: soupyFind(), + searchDataAvailable: { flags: {update: true}, update: { @@ -107,27 +110,48 @@ export class WikiInfo extends Thing { }, }, - groupData: wikiData({ - class: input.value(Group), - }), + // Expose only + + isWikiInfo: [ + exposeConstant({ + value: input.value(true), + }), + ], }); static [Thing.yamlDocumentSpec] = { fields: { 'Name': {property: 'name'}, 'Short Name': {property: 'nameShort'}, + 'Color': {property: 'color'}, + 'Description': {property: 'description'}, + 'Footer Content': {property: 'footerContent'}, + 'Default Language': {property: 'defaultLanguage'}, + 'Canonical Base': {property: 'canonicalBase'}, - 'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'}, + 'Canonical Media Base': {property: 'canonicalMediaBase'}, + + 'Wiki Wallpaper 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..719a4d93 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'; @@ -82,10 +87,14 @@ function makeProcessDocument(thingConstructor, { // ] // // ...means A can't coexist with B or C, B can't coexist with A or C, and - // C can't coexist iwth A, B, or D - but it's okay for D to coexist with + // C can't coexist with A, B, or D - but it's okay for D to coexist with // A or B. // invalidFieldCombinations = [], + + // 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)); @@ -166,9 +182,22 @@ function makeProcessDocument(thingConstructor, { const fieldCombinationErrors = []; - for (const {message, fields} of invalidFieldCombinations) { + for (const {message, fields: fieldsSpec} of invalidFieldCombinations) { const fieldsPresent = - presentFields.filter(field => fields.includes(field)); + fieldsSpec.flatMap(fieldSpec => { + if (Array.isArray(fieldSpec)) { + const [field, match] = fieldSpec; + if (!presentFields.includes(field)) return []; + if (typeof match === 'function') { + return match(document[field]) ? [field] : []; + } else { + return document[field] === match ? [field] : []; + } + } + + const field = fieldSpec; + return presentFields.includes(field) ? [field] : []; + }); if (fieldsPresent.length >= 2) { const filteredDocument = @@ -178,7 +207,10 @@ function makeProcessDocument(thingConstructor, { {preserveOriginalOrder: true}); fieldCombinationErrors.push( - new FieldCombinationError(filteredDocument, message)); + new FieldCombinationError( + filteredDocument, + fieldsSpec, + message)); for (const field of Object.keys(filteredDocument)) { skippedFields.add(field); @@ -192,13 +224,52 @@ 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) + (documentValue === null + ? null + : fieldSpecs[field].transform + ? fieldSpecs[field].transform(documentValue, transformUtilities) : documentValue); // Completely blank items in a YAML list are read as null. @@ -221,10 +292,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 +418,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(', ')}`); @@ -272,19 +434,36 @@ export class FieldCombinationAggregateError extends AggregateError { } export class FieldCombinationError extends Error { - constructor(fields, message) { - const fieldNames = Object.keys(fields); + constructor(filteredDocument, fieldsSpec, message) { + const fieldNames = Object.keys(filteredDocument); const fieldNamesText = fieldNames - .map(field => colors.red(field)) + .map(field => { + if (fieldsSpec.includes(field)) { + return colors.red(field); + } + + const match = + fieldsSpec + .find(fieldSpec => + Array.isArray(fieldSpec) && + fieldSpec[0] === field) + .at(1); + + if (typeof match === 'function') { + return colors.red(`${field}: ${filteredDocument[field]}`); + } else { + return colors.red(`${field}: ${match}`); + } + }) .join(', '); const mainMessage = `Don't combine ${fieldNamesText}`; const causeMessage = (typeof message === 'function' - ? message(fields) + ? message(filteredFields) : typeof message === 'string' ? message : null); @@ -296,7 +475,7 @@ export class FieldCombinationError extends Error { : null), }); - this.fields = fields; + this.fields = fieldNames; } } @@ -345,12 +524,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 +646,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 +816,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 = { @@ -642,6 +1011,12 @@ export const documentModes = { // array of processed documents (wiki objects). allInOne: Symbol('Document mode: allInOne'), + // allTogether: One or more documens, spread across any number of files. + // Expects files array (or function) and processDocument function. + // Calls save with an array of processed documents (wiki objects) - this is + // a flat array, *not* an array of the documents processed from *each* file. + allTogether: Symbol('Document mode: allTogether'), + // oneDocumentTotal: Just a single document, represented in one file. // Expects file string (or function) and processDocument function. Calls // save with the single processed wiki document (data object). @@ -688,16 +1063,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, @@ -745,6 +1127,7 @@ export async function getFilesFromDataStep(dataStep, {dataPath}) { } } + case documentModes.allTogether: case documentModes.headerAndEntries: case documentModes.onePerFile: { if (!dataStep.files) { @@ -890,7 +1273,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); } @@ -900,20 +1283,29 @@ export function processThingsFromDataStep(documents, dataStep) { const {documentMode} = dataStep; switch (documentMode) { - case documentModes.allInOne: { + case documentModes.allInOne: + case documentModes.allTogether: { const result = []; 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 +1315,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 +1338,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 +1355,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 +1375,7 @@ export function processThingsFromDataStep(documents, dataStep) { header: headerThing, entries: entryThings, }, + things: [headerThing, ...entryThings], }; } @@ -980,7 +1389,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 +1497,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}); @@ -1135,6 +1559,10 @@ export function saveThingsFromDataStep(thingLists, dataStep) { return dataStep.save(thing); } + case documentModes.allTogether: { + return dataStep.save(thingLists.flat()); + } + case documentModes.headerAndEntries: case documentModes.onePerFile: { return dataStep.save(thingLists); @@ -1225,93 +1653,106 @@ 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 */]], - [wikiData.trackData, [ - 'albumData', - 'artTagData', - 'artistData', - 'flashData', - 'trackData', - 'trackSectionData', + ['lyricsData', [/* find */]], + + ['referencingSourceData', [/* find */]], + + ['seriesData', [/* find */]], + + ['trackData', [ + 'artworkData', '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 +1768,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 +1780,7 @@ export function sortWikiDataArrays(dataSteps, wikiData) { export async function quickLoadAllFromYAML(dataPath, { find, bindFind, + bindReverse, getAllFindSpecs, showAggregate: customShowAggregate = showAggregate, @@ -1363,7 +1805,7 @@ export async function quickLoadAllFromYAML(dataPath, { } } - linkWikiDataArrays(wikiData); + linkWikiDataArrays(wikiData, {bindFind, bindReverse}); try { reportDirectoryErrors(wikiData, {getAllFindSpecs}); @@ -1389,7 +1831,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..a4e16325 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', @@ -546,6 +582,17 @@ export const externalLinkSpec = [ }, { + match: {domains: ['reddit.com', 'old.reddit.com']}, + platform: 'reddit', + icon: 'globe', + + detail: { + substring: 'subreddit', + subreddit: {pathname: /^r\/[^\/]+(?=\/)?/}, + }, + }, + + { match: {domain: 'soundcloud.com'}, platform: 'soundcloud', 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..7b605e97 100644 --- a/src/find.js +++ b/src/find.js @@ -4,6 +4,17 @@ import {colors, logWarn} from '#cli'; import {compareObjects, stitchArrays, typeAppearance} from '#sugar'; import thingConstructors from '#things'; import {isFunction, validateArrayItems} from '#validators'; +import {getCaseSensitiveKebabCase} from '#wiki-data'; + +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') { @@ -20,11 +31,38 @@ function warnOrThrow(mode, message) { export const keyRefRegex = new RegExp(String.raw`^(?:(?<key>[a-z-]*):(?=\S))?(?<ref>.*)$`); -export function processAvailableMatchesByName(data, { +function getFuzzHash(fuzz = {}) { + if (!fuzz) { + return 0; + } + + return ( + fuzz.capitalization << 0 + + fuzz.kebab << 1 + ); +} + +export function fuzzName(name, fuzz = {}) { + if (!fuzz) { + return name; + } + + if (fuzz.capitalization) { + name = name.toLowerCase(); + } + + if (fuzz.kebab) { + name = getCaseSensitiveKebabCase(name); + } + + return name; +} + +export function processAvailableMatchesByName(data, fuzz, { include = _thing => true, getMatchableNames = thing => - (Object.hasOwn(thing, 'name') + (thing.constructor.hasPropertyDescriptor('name') ? [thing.name] : []), @@ -32,7 +70,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') { @@ -40,17 +78,20 @@ export function processAvailableMatchesByName(data, { continue; } - const normalizedName = name.toLowerCase(); + const normalizedName = fuzzName(name, fuzz); if (normalizedName in results) { 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 +103,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,16 +118,16 @@ export function processAvailableMatchesByDirectory(data, { continue; } - results[directory] = thing; + results[directory] = {thing, directory}; } } return {results}; } -export function processAllAvailableMatches(data, spec) { +export function processAllAvailableMatches(data, fuzz, spec) { const {results: byName, multipleNameMatches} = - processAvailableMatchesByName(data, spec); + processAvailableMatchesByName(data, fuzz, spec); const {results: byDirectory} = processAvailableMatchesByDirectory(data, spec); @@ -99,21 +140,69 @@ function oopsMultipleNameMatches(mode, { normalizedName, multipleNameMatches, }) { + try { + return warnOrThrow(mode, + `Multiple matches for reference "${name}". Please resolve:\n` + + multipleNameMatches[normalizedName] + .map(match => `- ${inspect(match)}\n`) + .join('') + + `Returning null for this reference.`); + } catch (caughtError) { + throw Object.assign(caughtError, { + [Symbol.for('hsmusic.find.multipleNameMatches')]: + multipleNameMatches[normalizedName], + }); + } +} + +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, - `Multiple matches for reference "${name}". Please resolve:\n` + - multipleNameMatches[normalizedName] - .map(match => `- ${inspect(match)}\n`) - .join('') + + `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}) { +export function prepareMatchByName(mode, fuzz, {byName, multipleNameMatches}) { return (name) => { - const normalizedName = name.toLowerCase(); + const normalizedName = fuzzName(name, fuzz); const match = byName[normalizedName]; if (match) { - return match; + if ( + !fuzz?.capitalization && + name !== match.name && + name.toLowerCase === match.name.toLowerCase() + ) { + return oopsNameCapitalizationMismatch(mode, { + matchingName: name, + matchedName: match.name, + }); + } else { + return match.thing; + } } else if (multipleNameMatches[normalizedName]) { return oopsMultipleNameMatches(mode, { name, @@ -144,7 +233,13 @@ export function prepareMatchByDirectory(mode, {referenceTypes, byDirectory}) { }); } - return byDirectory[directory]; + const match = byDirectory[directory]; + + if (match) { + return match.thing; + } else { + return null; + } }; } @@ -186,11 +281,18 @@ function findHelper({ // hasn't changed! const cache = new WeakMap(); - // The mode argument here may be 'warn', 'error', or 'quiet'. 'error' throws - // errors for null matches (with details about the error), while 'warn' and - // 'quiet' both return null, with 'warn' logging details directly to the - // console. - return (fullRef, data, {mode = 'warn'} = {}) => { + return (fullRef, data, { + // The mode argument here may be 'warn', 'error', or 'quiet'. 'error' throws + // errors for null matches (with details about the error), while 'warn' and + // 'quiet' both return null, with 'warn' logging details directly to the + // console. + mode = 'warn', + + fuzz = { + capitalization: false, + kebab: false, + }, + } = {}) => { if (!fullRef) return null; if (typeof fullRef !== 'string') { @@ -201,19 +303,23 @@ function findHelper({ throw new TypeError(`Expected data to be present`); } - let subcache = cache.get(data); - if (!subcache) { - subcache = - processAllAvailableMatches(data, { + let dataSubcache = cache.get(data); + if (!dataSubcache) { + cache.set(data, dataSubcache = new Map()); + } + + const fuzzHash = getFuzzHash(fuzz); + let fuzzSubcache = dataSubcache.get(fuzzHash); + if (!fuzzSubcache) { + dataSubcache.set(fuzzHash, fuzzSubcache = + processAllAvailableMatches(data, fuzz, { include, getMatchableNames, getMatchableDirectories, - }); - - cache.set(data, subcache); + })); } - const {byDirectory, byName, multipleNameMatches} = subcache; + const {byDirectory, byName, multipleNameMatches} = fuzzSubcache; return matchHelper(fullRef, mode, { matchByDirectory: @@ -223,7 +329,7 @@ function findHelper({ }), matchByName: - prepareMatchByName(mode, { + prepareMatchByName(mode, fuzz, { byName, multipleNameMatches, }), @@ -240,6 +346,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 +362,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 +375,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), @@ -339,7 +408,7 @@ function findMixedHelper(config) { const multipleNameMatches = Object.create(null); for (const spec of specs) { - processAvailableMatchesByName(data, { + processAvailableMatchesByName(data, null, { ...spec, results: byName, @@ -372,11 +441,17 @@ function findMixedHelper(config) { }); } - return byDirectory[referenceType][directory]; + const match = byDirectory[referenceType][directory]; + + if (match) { + return match.thing; + } else { + return null; + } }, matchByName: - prepareMatchByName(mode, { + prepareMatchByName(mode, null, { byName, multipleNameMatches, }), @@ -425,27 +500,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 +516,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..42083845 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; @@ -271,7 +286,11 @@ export const validators = { }, }; -export function blank() { +export function blank(...args) { + if (args.length) { + throw new Error(`Passed arguments - did you mean isBlank() instead?`) + } + return []; } @@ -352,8 +371,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 +533,10 @@ export class Tag { } } + #getAttributeRaw(attribute) { + return this.attributes.get(attribute); + } + set onlyIfContent(value) { this.#setAttributeFlag(onlyIfContent, value); } @@ -579,7 +604,7 @@ export class Tag { try { this.content = this.content; - } catch (error) { + } catch { this.#setAttributeFlag(imaginarySibling, false); } } @@ -662,7 +687,7 @@ export class Tag { const chunkwrapSplitter = (this.chunkwrap - ? this.#getAttributeString('split') + ? this.#getAttributeRaw('split') : null); let seenChunkwrapSplitter = @@ -702,17 +727,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 +754,7 @@ export class Tag { const chunkwrapChunks = (typeof nonTemplateItem === 'string' && chunkwrapSplitter - ? itemContent.split(chunkwrapSplitter) + ? Array.from(getChunkwrapChunks(itemContent, chunkwrapSplitter)) : null); const itemIncludesChunkwrapSplit = @@ -773,7 +800,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 +808,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 +1051,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 +1154,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 +1193,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 +1368,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 +1402,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 +1791,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`); } @@ -1848,17 +1972,11 @@ export class Template { return this.content.toString(); } - static resolve(tagOrTemplate) { + static resolve(content) { // Flattens contents of a template, recursively "resolving" until a // non-template is ready (or just returns a provided non-template // argument as-is). - if (!(tagOrTemplate instanceof Template)) { - return tagOrTemplate; - } - - let {content} = tagOrTemplate; - while (content instanceof Template) { content = content.content; } @@ -1866,7 +1984,7 @@ export class Template { return content; } - static resolveForSlots(tagOrTemplate, slots) { + static resolveForSlots(content, slots) { if (!slots || typeof slots !== 'object') { throw new Error( `Expected slots to be an object or array, ` + @@ -1874,18 +1992,18 @@ export class Template { } if (!Array.isArray(slots)) { - return Template.resolveForSlots(tagOrTemplate, Object.keys(slots)).slots(slots); + return Template.resolveForSlots(content, Object.keys(slots)).slots(slots); } - while (tagOrTemplate && tagOrTemplate instanceof Template) { + while (content instanceof Template) { try { for (const slot of slots) { - tagOrTemplate.getSlotDescription(slot); + content.getSlotDescription(slot); } - return tagOrTemplate; + return content; } catch { - tagOrTemplate = tagOrTemplate.content; + content = content.content; } } @@ -1963,6 +2081,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..a47bd38c 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -178,6 +178,7 @@ listingSpec.push({ directory: 'tracks/with-lyrics', stringsKey: 'listTracks.withLyrics', contentFunction: 'listTracksWithLyrics', + seeAlso: ['tracks/needing-lyrics'], }); listingSpec.push({ @@ -195,16 +196,30 @@ listingSpec.push({ }); listingSpec.push({ + directory: 'tracks/needing-lyrics', + stringsKey: 'listTracks.needingLyrics', + contentFunction: 'listTracksNeedingLyrics', + seeAlso: ['tracks/with-lyrics'], +}); + +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 +253,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..e585618c 100644 --- a/src/page/album.js +++ b/src/page/album.js @@ -8,15 +8,22 @@ export function targets({wikiData}) { export function pathsForTarget(album) { return [ - { - type: 'page', - path: ['album', album.directory], - - contentFunction: { - name: 'generateAlbumInfoPage', - args: [album], - }, - }, + (album.style === 'single' + ? { + type: 'redirect', + fromPath: ['album', album.directory], + toPath: ['track', album.tracks[0].directory], + title: album.name, + } + : { + type: 'page', + path: ['album', album.directory], + + contentFunction: { + name: 'generateAlbumInfoPage', + args: [album], + }, + }), { type: 'page', @@ -38,20 +45,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 +95,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..7cd50bb3 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,15 +19,36 @@ export function pathsForTarget(artist) { }, }, - hasGalleryPage && { + { type: 'page', path: ['artistGallery', artist.directory], + condition: () => + !empty(artist.albumCoverArtistContributions) || + !empty(artist.trackCoverArtistContributions), + contentFunction: { name: 'generateArtistGalleryPage', args: [artist], }, }, + + { + type: 'page', + path: ['artistRollingWindow', artist.directory], + + condition: () => + artist.musicContributions.some(contrib => contrib.date) || + artist.artworkContributions.some(contrib => + contrib.date && + contrib.thingProperty !== 'wallpaperArtistContribs' && + contrib.thingProperty !== 'bannerArtistContribs'), + + contentFunction: { + name: 'generateArtistRollingWindowPage', + 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..8a929444 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 {matchInlineLinks, 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. @@ -154,6 +190,9 @@ const tagHash = '#'; const tagArgument = '*'; const tagArgumentValue = '='; const tagLabel = '|'; +const tooltipBeginning = '<<'; +const tooltipEnding = '>>'; +const tooltipContent = ':'; const noPrecedingWhitespace = '(?<!\\s)'; @@ -172,6 +211,14 @@ const R_tagArgumentValue = escapeRegex(tagArgumentValue); const R_tagLabel = escapeRegex(tagLabel); +const R_tooltipBeginning = + '(?<=[^<]|^)' + escapeRegex(tooltipBeginning) + '(?!<)'; + +const R_tooltipEnding = + '(?<=[^>]|^)' + escapeRegex(tooltipEnding) + '(?!>)'; + +const R_tooltipContent = escapeRegex(tooltipContent); + const regexpCache = {}; const makeError = (i, message) => ({i, type: 'error', data: {message}}); @@ -211,9 +258,13 @@ function parseNodes(input, i, stopAt, textOnly) { } }; - const literalsToMatch = stopAt - ? stopAt.concat([R_tagBeginning]) - : [R_tagBeginning]; + const beginnings = [tagBeginning, tooltipBeginning]; + const R_beginnings = [R_tagBeginning, R_tooltipBeginning]; + + const literalsToMatch = + (stopAt + ? stopAt.concat(R_beginnings) + : R_beginnings); // The 8ackslash stuff here is to only match an even (or zero) num8er // of sequential 'slashes. Even amounts always cancel out! Odd amounts @@ -264,8 +315,10 @@ function parseNodes(input, i, stopAt, textOnly) { if (textOnly && closestMatch === tagBeginning) throw makeError(i, `Unexpected [[tag]] - expected only text here.`); + if (textOnly && closestMatch === tooltipBeginning) + throw makeError(i, `Unexpected <<tooltip>> - expected only text here.`); - const stopHere = closestMatch !== tagBeginning; + const stopHere = !beginnings.includes(closestMatch); iString = i; i = closestMatchIndex; @@ -417,6 +470,51 @@ function parseNodes(input, i, stopAt, textOnly) { continue; } + + if (closestMatch === tooltipBeginning) { + const iTooltip = closestMatchIndex; + + let N; + + // Label (hoverable text) + + let label; + + N = parseNodes(input, i, [R_tooltipContent, R_tooltipEnding]); + + if (!stopped) + throw endOfInput(i, `reading tooltip label`); + if (input.slice(i).startsWith(tooltipEnding)) + throw makeError(i, `Expected tooltip label and content.`); + if (!N.length) + throw makeError(i, `Expected tooltip label before content.`); + + label = N; + i = stop_iParse; + + // Content (tooltip text) + + let content; + + N = parseNodes(input, i, [R_tooltipEnding]); + + if (!stopped) + throw endOfInput(i, `reading tooltip content`); + if (!N.length) + throw makeError(i, `Expected tooltip content`); + + content = N; + i = stop_iParse; + + nodes.push({ + i: iTooltip, + iEnd: i, + type: 'tooltip', + data: {label, content}, + }); + + continue; + } } return nodes; @@ -428,7 +526,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 +586,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 +621,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 +642,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 +688,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) { @@ -736,7 +856,7 @@ export function postprocessSummaries(inputNodes) { } export function postprocessExternalLinks(inputNodes) { - const outputNodes = []; + let outputNodes = []; for (const node of inputNodes) { if (node.type !== 'text') { @@ -744,109 +864,292 @@ export function postprocessExternalLinks(inputNodes) { continue; } - const plausibleLinkRegexp = /\[.*?\)/g; + let textNode = { + i: node.i, + iEnd: null, + type: 'text', + data: '', + }; - let textContent = ''; + let parseFrom = 0; + for (const match of matchMarkdownLinks(node.data, {marked})) { + const {label, href, index, length} = match; - 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 = ''; - } + textNode.data += node.data.slice(parseFrom, index); - const offset = plausibleMatch.index + definiteMatch.index; - const length = definiteMatch[0].length; + // 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); - outputNodes.push({ - i: node.i + offset, - iEnd: node.i + offset + length, - type: 'external-link', - data: {label, href}, - }); + textNode = { + i: node.i + index + length, + iEnd: null, + type: 'text', + data: '', + }; + } - parseFrom = offset + length; - } else { - parseFrom = plausibleMatch.index; + 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) { + textNode.data += node.data.slice(parseFrom); + textNode.iEnd = node.iEnd; + } + + if (textNode.data) { + outputNodes.push(textNode); + } + } + + // Repeat everything, but for inline links, which are just a URL on its own, + // not formatted as a Markdown link. These don't have provided labels, and + // get labels automatically filled in by content code. + + inputNodes = outputNodes; + outputNodes = []; + + for (const node of inputNodes) { + if (node.type !== 'text') { + outputNodes.push(node); + continue; + } + + let textNode = { + i: node.i, + iEnd: null, + type: 'text', + data: '', + }; + + let parseFrom = 0; + for (const match of matchInlineLinks(node.data)) { + const {href, index, length} = match; + + textNode.data += node.data.slice(parseFrom, index); + + if (textNode.data) { + textNode.iEnd = textNode.i + textNode.data.length; + outputNodes.push(textNode); + + 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: null, 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; - } - - const { - i, - data: {message}, - } = errorNode; - - let lineStart = input.slice(0, i).lastIndexOf('\n'); - if (lineStart >= 0) { - lineStart += 1; - } else { - lineStart = 0; + 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 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 lineEnd = input.slice(i).indexOf('\n'); - if (lineEnd >= 0) { - lineEnd += i; + if (!empty(postprocessErrors)) { + error = + new AggregateError( + postprocessErrors, + `Errors postprocessing content text`); + + error[Symbol.for('hsmusic.aggregate.translucent')] = 'single'; + } + } + + 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/search-select.js b/src/search-select.js new file mode 100644 index 00000000..68d2f4e9 --- /dev/null +++ b/src/search-select.js @@ -0,0 +1,217 @@ +// Complements the specs in search-shape.js with the functions that actually +// process live wiki data into records that are appropriate for storage. +// These files totally go together, so read them side by side, okay? + +import baseSearchSpec from '#search-shape'; +import {getKebabCase} from '#wiki-data'; + +function prepareArtwork(artwork, thing, { + checkIfImagePathHasCachedThumbnails, + getThumbnailEqualOrSmaller, + urls, +}) { + if (!artwork) { + return undefined; + } + + const hasWarnings = + artwork.artTags?.some(artTag => artTag.isContentWarning); + + const artworkPath = + artwork.path; + + if (!artworkPath) { + return undefined; + } + + const mediaSrc = + urls + .from('media.root') + .to(...artworkPath); + + if (!checkIfImagePathHasCachedThumbnails(mediaSrc)) { + return undefined; + } + + const selectedSize = + getThumbnailEqualOrSmaller( + (hasWarnings ? 'mini' : 'adorb'), + mediaSrc); + + const mediaSrcJpeg = + mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`); + + const displaySrc = + urls + .from('thumb.root') + .to('thumb.path', mediaSrcJpeg); + + const serializeSrc = + displaySrc.replace(thing.directory, '<>'); + + return serializeSrc; +} + +function baselineProcess(thing, opts) { + const fields = {}; + + fields.primaryName = + thing.name; + + fields.artwork = + null; + + fields.color = + thing.color; + + fields.disambiguator = + null; + + return fields; +} + +function genericSelect(wikiData) { + const groupOrder = + wikiData.wikiInfo.divideTrackListsByGroups; + + const getGroupRank = thing => { + const relevantRanks = + Array.from(groupOrder.entries()) + .filter(({1: group}) => thing.groups.includes(group)) + .map(({0: index}) => index); + + if (relevantRanks.length === 0) { + return Infinity; + } else if (relevantRanks.length === 1) { + return relevantRanks[0]; + } else { + return relevantRanks[0] + 0.5; + } + } + + const sortByGroupRank = things => + things.sort((a, b) => getGroupRank(a) - getGroupRank(b)); + + return [ + sortByGroupRank(wikiData.albumData.slice()), + + wikiData.artTagData, + + wikiData.artistData + .filter(artist => !artist.isAlias), + + wikiData.flashData, + + wikiData.groupData, + + sortByGroupRank( + wikiData.trackData + .filter(track => + track.isMainRelease || + (getKebabCase(track.name) !== + getKebabCase(track.mainReleaseTrack.name)))), + ].flat(); +} + +function genericProcess(thing, opts) { + const fields = baselineProcess(thing, opts); + + const kind = + thing.constructor[Symbol.for('Thing.referenceType')]; + + const boundPrepareArtwork = artwork => + prepareArtwork(artwork, thing, opts); + + fields.artwork = + (kind === 'track' && thing.hasUniqueCoverArt + ? boundPrepareArtwork(thing.trackArtworks[0]) + : kind === 'track' + ? boundPrepareArtwork(thing.album.coverArtworks[0]) + : kind === 'album' + ? boundPrepareArtwork(thing.coverArtworks[0]) + : kind === 'flash' + ? boundPrepareArtwork(thing.coverArtwork) + : null); + + fields.parentName = + (kind === 'track' + ? thing.album.name + : kind === 'group' + ? thing.category.name + : kind === 'flash' + ? thing.act.name + : null); + + fields.disambiguator = + fields.parentName; + + fields.artTags = + (Array.from(new Set( + (kind === 'track' + ? thing.trackArtworks.flatMap(artwork => artwork.artTags) + : kind === 'album' + ? thing.coverArtworks.flatMap(artwork => artwork.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; +} + +const spiffySearchSpec = { + generic: { + ...baseSearchSpec.generic, + + select: genericSelect, + process: genericProcess, + }, + + verbatim: { + ...baseSearchSpec.verbatim, + + select: genericSelect, + process: genericProcess, + }, +}; + +export default spiffySearchSpec; diff --git a/src/search.js b/src/search.js index a2dae9e1..138a2d2c 100644 --- a/src/search.js +++ b/src/search.js @@ -9,11 +9,53 @@ import FlexSearch from 'flexsearch'; import {pack} from 'msgpackr'; import {logWarn} from '#cli'; -import {makeSearchIndex, populateSearchIndex, searchSpec} from '#search-spec'; +import {makeSearchIndex} from '#search-shape'; +import searchSpec from '#search-select'; import {stitchArrays} from '#sugar'; import {checkIfImagePathHasCachedThumbnails, getThumbnailEqualOrSmaller} from '#thumbs'; +// TODO: This function basically mirrors bind-utilities.js, which isn't +// exactly robust, but... binding might need some more thought across the +// codebase in *general.* +function bindSearchUtilities({ + checkIfImagePathHasCachedThumbnails, + getThumbnailEqualOrSmaller, + thumbsCache, + urls, +}) { + const bound = { + urls, + }; + + bound.checkIfImagePathHasCachedThumbnails = + (imagePath) => + checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache); + + bound.getThumbnailEqualOrSmaller = + (preferred, imagePath) => + getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache); + + return bound; +} + +function populateSearchIndex(index, descriptor, wikiData, utilities) { + for (const thing of descriptor.select(wikiData)) { + const reference = thing.constructor.getReference(thing); + + let processed; + try { + processed = descriptor.process(thing, utilities); + } catch (caughtError) { + throw new Error( + `Failed to process searchable thing ${reference}`, + {cause: caughtError}); + } + + index.add({reference, ...processed}); + } +} + async function serializeIndex(index) { const results = {}; @@ -60,17 +102,20 @@ export async function writeSearchData({ .map(descriptor => makeSearchIndex(descriptor, {FlexSearch})); + const utilities = + bindSearchUtilities({ + checkIfImagePathHasCachedThumbnails, + getThumbnailEqualOrSmaller, + thumbsCache, + urls, + wikiData, + }); + stitchArrays({ index: indexes, descriptor: descriptors, }).forEach(({index, descriptor}) => - populateSearchIndex(index, descriptor, { - checkIfImagePathHasCachedThumbnails, - getThumbnailEqualOrSmaller, - thumbsCache, - urls, - wikiData, - })); + populateSearchIndex(index, descriptor, wikiData, utilities)); const serializedIndexes = await Promise.all(indexes.map(serializeIndex)); diff --git a/src/static/css/site.css b/src/static/css/site.css index 6c853161..61803c9d 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; @@ -718,6 +918,11 @@ summary.underline-white > span:hover a:not(:hover) { display: inline-block; } +.wiki-search-result-disambiguator { + opacity: 0.9; + display: inline-block; +} + .wiki-search-result-image-container { align-self: flex-start; flex-shrink: 0; @@ -814,6 +1019,10 @@ a:not([href]):hover { text-decoration: none; } +a .normal-content { + color: white; +} + .external-link:not(.from-content) { white-space: nowrap; } @@ -827,8 +1036,28 @@ a:not([href]):hover { color: white; } -.external-link .normal-content { - color: white; +.image-media-link::after { + /* Thanks to Jay Freestone for being awesome: + * https://www.jayfreestone.com/writing/wrapping-and-inline-pseudo-elements/ + */ + + pointer-events: none; + content: '\200b'; + padding-left: 22px; + + 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); +} + +.image-media-link:hover::after { + background-color: white; } .nav-link { @@ -849,33 +1078,59 @@ 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; } +.dot-switcher > span { + color: #ffffffcc; +} + .dot-switcher > span.current { + font-weight: normal; + color: white; +} + +.dot-switcher > span.current a { font-weight: 800; } @@ -889,6 +1144,15 @@ a:not([href]):hover { text-decoration: none !important; } +label:hover span { + text-decoration: underline; + text-decoration-style: solid; +} + +label > input[type=checkbox]:not(:checked) + span { + opacity: 0.8; +} + #secondary-nav { text-align: center; @@ -898,7 +1162,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 +1180,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 +1214,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 +1233,22 @@ a:not([href]):hover { text-decoration: none !important; } +.text-with-tooltip.wiki-edits > .hoverable { + white-space: nowrap; +} + +:where(.isolate-tooltip-z-indexing) { + position: relative; + z-index: 1; +} + +:where(.isolate-tooltip-z-indexing > *) { + position: relative; + z-index: -1; +} + .tooltip { + font-size: 1rem; position: absolute; z-index: 3; left: -10px; @@ -974,7 +1256,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 +1293,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 +1304,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; @@ -1050,6 +1345,16 @@ li:not(:first-child:last-child) .tooltip, height: 1.4em; } +.contribution-tooltip .chronology-heading { + grid-column-start: handle-start; + grid-column-end: platform-end; + min-width: 30ch; + + font-size: 0.85em; + font-style: oblique; + margin-bottom: 2px; +} + .contribution-tooltip .chronology-link { display: grid; grid-column-start: icon-start; @@ -1101,11 +1406,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 +1481,43 @@ 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:not(.has-link) .hoverable { + cursor: default; +} + +.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: max-content; + max-width: 240px; +} + +.cover-artwork .content-tooltip { + font-size: 0.85rem; + padding: 2px 3px; + width: max-content; + max-width: 220px; +} + .external-icon { display: inline-block; padding: 0 3px; @@ -1187,8 +1534,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; } @@ -1201,6 +1548,12 @@ li:not(:first-child:last-child) .tooltip, color: var(--page-primary-color); } +.wiki-commentary s:not(.spoiler) { + text-decoration-color: #fff9; + text-decoration-thickness: 1.4px; + color: #fffb; +} + s.spoiler { display: inline-block; color: transparent; @@ -1221,6 +1574,42 @@ 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; +} + +blockquote :is(span.path, code.filename) { + font-size: 0.9em; +} + +.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 +1631,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 +1649,70 @@ 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 { + --normal-shadow: 0 0 12px 12px #00000080; + + box-shadow: + 0 2px 14px -6px var(--primary-color), + var(--normal-shadow); +} + +#artwork-column .cover-artwork:not(:first-child), +#artwork-column .cover-artwork-joiner { + margin-left: 30px; + margin-right: 5px; +} + +#artwork-column .cover-artwork:not(:first-child) { + --normal-shadow: 0 0 9px 9px #00000068; +} + +#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 +1755,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 +1767,69 @@ 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-line { + display: block; + margin-top: 0.25em; +} - margin-left: 15px; - padding-left: 5px; - max-width: 625px; - padding-bottom: 0.2em; +p.image-details.origin-details .filename-line { + display: block; + margin-top: 0.25em; +} - border-bottom: 1px solid var(--dim-color); +.cover-artwork-joiner { + z-index: -2; } -.commentary-entry-heading-text { - flex-grow: 1; - padding-left: 1.25ch; +.cover-artwork-joiner::after { + content: ""; + display: block; + width: 0; + height: 15px; + margin-left: auto; + margin-right: auto; + border-right: 3px solid var(--primary-color); +} + +.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 +1837,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 +1857,16 @@ p.image-details.illustrator-details { color: var(--primary-color); } +.inherited-commentary-section { + clear: right; + margin-top: 1em; + margin-bottom: 1.5em; + margin-right: min(4vw, 60px); + border: 2px solid var(--deep-color); + border-radius: 4px; + background: #ffffff07; +} + .commentary-art { float: right; width: 30%; @@ -1408,6 +1881,58 @@ 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 .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; +} + +.lyrics-entry.long-lyrics { + clip-path: inset(-15px -20px); +} + +.lyrics-entry.long-lyrics::after { + content: ""; + pointer-events: none; + display: block; + + /* Slight stretching past the bottom of the screen seems + * to make resizing the window (and "revealing" that area) + * a bit smoother. + */ + position: fixed; + bottom: -20px; + left: 0; + right: 0; + + height: calc(20px + min(90px, 13.5vh)); + background: linear-gradient(to bottom, transparent, black 70%, black); + opacity: 0.6; +} + +.lyrics-entry sup { + vertical-align: text-top; + opacity: 0.8; + cursor: default; +} + .js-hide, .js-show-once-data, .js-hide-once-data { @@ -1415,24 +1940,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, a.align-full img, img.align-full, video.align-full { + width: 100%; +} + center { margin-top: 1em; margin-bottom: 1em; @@ -1483,17 +2016,17 @@ h1 { white-space: nowrap; } +#content details { + margin-top: 0.25em; + margin-bottom: 0.25em; +} + #content.top-index h1, #content.flash-index h1 { text-align: center; 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 +2081,40 @@ ul.quick-info li:not(:last-child)::after { margin-top: 25px; } +.gallery-set-switcher { + text-align: center; +} + +.gallery-view-switcher, +.gallery-style-selector { + margin-left: auto; + margin-right: auto; + text-align: center; + line-height: 1.4; +} + +.gallery-style-selector .styles { + display: inline-flex; + justify-content: center; +} + +.gallery-style-selector .styles label:not(:last-child) { + margin-right: 1.25ch; +} + +.gallery-style-selector .count { + font-size: 0.85em; + + position: relative; + bottom: -0.25em; + + opacity: 0.9; +} + +#content.top-index section { + margin-bottom: 1.5em; +} + .quick-description:not(.has-external-links-only) { --clamped-padding-ratio: max(var(--responsive-padding-ratio), 0.06); margin-left: auto; @@ -1578,6 +2145,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 +2193,6 @@ ul.quick-info li:not(:last-child)::after { li .by { font-style: oblique; - max-width: 600px; } li .by a { @@ -1641,8 +2208,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 +2256,6 @@ main.long-content > h1 { dl dt { padding-left: 40px; - max-width: 600px; } dl dt { @@ -1699,6 +2265,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 +2284,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 +2302,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 +2341,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; } @@ -1859,6 +2468,65 @@ html[data-url-key="localized.albumCommentary"] p.track-info { margin-left: 20px; } +html[data-url-key="localized.artistRollingWindow"] #content p { + text-align: center; +} + +html[data-url-key="localized.artistRollingWindow"] #content input[type=number] { + width: 3em; + margin: 0 0.25em; + background: black; + color: white; + border: 1px dotted var(--primary-color); + padding: 4px; + border-radius: 3px; +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-selection-control a { + display: inline-block; + padding: 5px; + text-decoration: underline; + text-decoration-style: dotted; +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-selection-control a:not([href]) { + text-decoration: none; + opacity: 0.7; +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-source-area { + border: 1px dashed #ffffff42; + border-top-style: solid; + border-bottom-style: solid; + + display: flex; + flex-direction: column; + justify-content: center; + min-height: calc(100vh - 260px); +} + +html[data-url-key="localized.artistRollingWindow"] #timeframe-source-area .grid-listing { + width: 100%; +} + +html[data-url-key="localized.artistRollingWindow"] .grid-item.peeking { + opacity: 0.8; + background: #ffffff24; +} + +html[data-url-key="localized.artistRollingWindow"] .grid-item > span:not(:first-of-type) { + display: flex; + flex-direction: row; + justify-content: center; + flex-wrap: wrap; +} + +html[data-url-key="localized.artistRollingWindow"] .grid-item > span:not(:first-of-type) > *:not([style*="display: none"]) ~ *::before { + content: '\00b7'; + margin-left: 0.5ch; + margin-right: 0.5ch; +} + html[data-url-key="localized.groupInfo"] .by a { color: var(--page-primary-color); } @@ -1894,20 +2562,12 @@ 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; +} +#additional-names-box:not(.always-visible) { display: none; } @@ -1943,6 +2603,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 +2818,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 +2912,23 @@ img { object-fit: cover; } +.image { + --reveal-filter: ; + --shadow-filter: ; + + backdrop-filter: blur(0); + filter: + var(--reveal-filter) + var(--shadow-filter); +} + +p > img, li > img { + max-width: 100%; + object-fit: contain; + height: auto; + vertical-align: text-bottom; +} + .image-inner-area::after { content: ""; display: block; @@ -2083,9 +2988,9 @@ video.pixelate, .pixelate video { text-decoration-style: dotted; } -.reveal .image { +.reveal:not(.revealed) .image { opacity: 0.7; - filter: blur(20px) brightness(0.7); + --reveal-filter: blur(20px) brightness(0.7); } .reveal .image.reveal-thumbnail { @@ -2109,7 +3014,6 @@ video.pixelate, .pixelate video { } .reveal.revealed .image { - filter: none; opacity: 1; } @@ -2120,7 +3024,6 @@ video.pixelate, .pixelate video { .reveal:not(.revealed) .image-outer-area > * { --reveal-border-radius: 6px; position: relative; - overflow: hidden; border-radius: var(--reveal-border-radius); } @@ -2156,7 +3059,7 @@ video.pixelate, .pixelate video { } .reveal:not(.revealed) .image-outer-area > *:hover .image { - filter: blur(20px) brightness(0.6); + --reveal-filter: blur(20px) brightness(0.6); opacity: 0.6; } @@ -2182,20 +3085,88 @@ video.pixelate, .pixelate video { justify-content: center; align-items: flex-start; padding: 5px 15px; + box-sizing: border-box; +} + +.grid-listing:not(:has(.grid-item:not([class*="hidden-by-"]))) { + padding-bottom: 140px; + background: #cccccc07; + border-radius: 10px; + border: 1px dashed #fff3; +} + +.grid-listing .reveal-all-container { + flex-basis: 100%; +} + +.grid-listing:not(:has(.grid-item:not([class*="hidden-by-"]))) .reveal-all-container { + display: none; +} + +.grid-listing .reveal-all-container.has-nearby-tab { + margin-bottom: 0.6em; +} + +.grid-listing .reveal-all { + max-width: 400px; + margin: 0.20em auto 0; + text-align: center; +} + +.grid-listing .reveal-all .warnings:not(.reveal-all:hover *) { + opacity: 0.4; +} + +.grid-listing .reveal-all a { + display: inline-block; + margin-bottom: 0.15em; + + text-decoration: underline; + text-decoration-style: dotted; +} + +.grid-listing .reveal-all b { + white-space: nowrap; } .grid-item { + line-height: 1.2; font-size: 0.9em; } .grid-item { + --tab-pull: 0px; + --tabnt-offset: 0px; + display: inline-block; text-align: center; background-color: #111111; border: 1px dotted var(--primary-color); border-radius: 2px; padding: 5px; + margin: 10px; + margin-top: + calc( + 10px + - var(--tab-pull) + + var(--tabnt-offset)); +} + +.grid-item.has-tab { + border-radius: 8px 8px 3px 3px; +} + +.grid-item.has-tab:hover { + --tab-pull: 3px; +} + +.grid-item:not(.has-tab) { + --tabnt-offset: calc(1.2em - 4px); +} + +.grid-item[class*="hidden-by-"] { + display: none; } .grid-item .image-container { @@ -2212,10 +3183,16 @@ video.pixelate, .pixelate video { } .grid-item .image { + --shadow-filter: + drop-shadow(0 3px 2px #0004) + drop-shadow(0 1px 5px #0001) + drop-shadow(0 3px 4px #0001); + width: 100%; height: 100% !important; margin-top: auto; margin-bottom: auto; + object-fit: contain; } .grid-item:hover { @@ -2232,20 +3209,27 @@ video.pixelate, .pixelate video { hyphens: auto; } -.grid-item > span:not(:first-child) { - margin-top: 2px; -} +/* tab */ +.grid-item > span:first-child { + margin-bottom: calc(3px + var(--tab-pull)); -.grid-item > span:first-of-type { - margin-top: 6px; + font-style: oblique; } -.grid-item > span:not(:first-of-type) { +/* info */ +.grid-item > .image-container + span ~ span { + margin-top: 2px; + font-size: 0.9em; opacity: 0.8; } -.grid-item:hover > span:first-of-type { +/* title */ +.grid-item > .image-container + span { + margin-top: 6px; +} + +.grid-item:hover > .image-container + span { text-decoration: underline; } @@ -2254,6 +3238,10 @@ video.pixelate, .pixelate video { max-width: 200px; } +.grid-name-marker { + color: white; +} + .grid-actions { display: flex; flex-direction: row; @@ -2271,6 +3259,47 @@ video.pixelate, .pixelate video { --dim-color: inherit !important; } +.grid-caption { + flex-basis: 100%; + text-align: center; + line-height: 1.5; +} + +.grid-expando { + margin-top: 1em; + margin-bottom: 2em; + flex-basis: 100%; + + display: flex; + flex-direction: row; + justify-content: space-around; +} + +.grid-expando-content { + margin: 0; + text-align: center; + line-height: 1.5; +} + +.grid-expando-toggle { + text-decoration: underline; + text-decoration-style: dotted; +} + +.grid-item.shown-by-expandable-cut { + animation: expand-cover-grid 0.8s forwards; +} + +@keyframes expand-cover-grid { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + /* Carousel */ .carousel-container { @@ -2291,7 +3320,6 @@ video.pixelate, .pixelate video { left: 0; right: 0; bottom: 0; - z-index: -20; background-color: var(--dim-color); filter: brightness(0.6); } @@ -2576,6 +3604,48 @@ h3.content-heading { clear: both; } +summary.content-heading { + list-style-type: none; +} + +summary.content-heading .cue { + display: inline-flex; + color: var(--primary-color); +} + +summary.content-heading .cue::after { + content: ""; + padding-left: 0.5ch; + display: list-item; + list-style-type: disclosure-closed; + list-style-position: inside; +} + +details[open] > summary.content-heading .cue::after { + list-style-type: disclosure-open; +} + +summary.content-heading > span:hover { + text-decoration: none !important; +} + +summary.content-heading > span:hover .cue { + text-decoration: underline; + text-decoration-style: wavy; +} + +summary.content-heading .when-open { + display: none; +} + +details[open] > summary.content-heading .when-open { + display: unset; +} + +details[open] > summary.content-heading .when-collapsed { + display: none; +} + /* This animation's name is referenced in JavaScript */ @keyframes highlight-hash-link { 0% { @@ -2612,14 +3682,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 +3747,56 @@ 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-row h1 { + position: relative; margin: 0; padding-right: 20px; + overflow-x: hidden; +} + +.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 +3824,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; @@ -2760,7 +3895,9 @@ main.long-content .content-sticky-heading-container .content-sticky-subheading-r } #content, .sidebar { - contain: paint; + /* In the year of our pizza 2025, we try commenting this out. + */ + /*contain: paint;*/ } /* Sticky sidebar */ @@ -2883,31 +4020,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 +4061,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 +4074,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 +4113,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 +4129,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 +4143,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 +4157,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 +4171,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 +4190,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 +4220,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 +4278,22 @@ 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 { + --normal-shadow: 0 0 transparent; + } + + #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 +4305,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..396c4889 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) { @@ -30,7 +37,7 @@ export function cssProp(el, ...args) { } } -export function templateContent(el) { +export function templateContent(el, slots = {}) { if (el === null) { return null; } @@ -39,7 +46,25 @@ export function templateContent(el) { throw new Error(`Expected a <template> element`); } - return el.content.cloneNode(true); + const content = el.content.cloneNode(true); + + for (const [key, value] of Object.entries(slots)) { + const slot = content.querySelector(`slot[name="${key}"]`); + + if (!slot) { + console.warn(`Slot ${key} missing in template:`, el); + continue; + } + + if (value === null || value === undefined) { + console.warn(`Valueless slot ${key} in template:`, el); + continue; + } + + slot.replaceWith(value); + } + + return content; } // Curry-style, so multiple points can more conveniently be tested at once. @@ -120,3 +145,10 @@ export function dispatchInternalEvent(event, eventName, ...args) { return results; } + +const languageCode = document.documentElement.getAttribute('lang'); + +export function formatDate(inputDate) { + const date = new Date(inputDate); + return date.toLocaleDateString(languageCode); +} 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/artist-rolling-window.js b/src/static/js/client/artist-rolling-window.js new file mode 100644 index 00000000..b201e7df --- /dev/null +++ b/src/static/js/client/artist-rolling-window.js @@ -0,0 +1,573 @@ +/* eslint-env browser */ + +import {cssProp, formatDate} from '../client-util.js'; + +import {sortByDate} from '../../shared-util/sort.js'; +import {chunkByConditions, chunkByProperties, empty, stitchArrays} + from '../../shared-util/sugar.js'; + +export const info = { + id: 'artistRollingWindowInfo', + + timeframeMonthsBefore: null, + timeframeMonthsAfter: null, + timeframeMonthsPeek: null, + + contributionKind: null, + contributionGroup: null, + + timeframeSelectionSomeLine: null, + timeframeSelectionNoneLine: null, + + timeframeSelectionContributionCount: null, + timeframeSelectionTimeframeCount: null, + timeframeSelectionFirstDate: null, + timeframeSelectionLastDate: null, + + timeframeSelectionControl: null, + timeframeSelectionMenu: null, + timeframeSelectionPrevious: null, + timeframeSelectionNext: null, + + timeframeEmptyLine: null, + + sourceArea: null, + sourceGrid: null, + sources: null, +}; + +export function getPageReferences() { + if (document.documentElement.dataset.urlKey !== 'localized.artistRollingWindow') { + return; + } + + info.timeframeMonthsBefore = + document.getElementById('timeframe-months-before'); + + info.timeframeMonthsAfter = + document.getElementById('timeframe-months-after'); + + info.timeframeMonthsPeek = + document.getElementById('timeframe-months-peek'); + + info.contributionKind = + document.getElementById('contribution-kind'); + + info.contributionGroup = + document.getElementById('contribution-group'); + + info.timeframeSelectionSomeLine = + document.getElementById('timeframe-selection-some'); + + info.timeframeSelectionNoneLine = + document.getElementById('timeframe-selection-none'); + + info.timeframeSelectionContributionCount = + document.getElementById('timeframe-selection-contribution-count'); + + info.timeframeSelectionTimeframeCount = + document.getElementById('timeframe-selection-timeframe-count'); + + info.timeframeSelectionFirstDate = + document.getElementById('timeframe-selection-first-date'); + + info.timeframeSelectionLastDate = + document.getElementById('timeframe-selection-last-date'); + + info.timeframeSelectionControl = + document.getElementById('timeframe-selection-control'); + + info.timeframeSelectionMenu = + document.getElementById('timeframe-selection-menu'); + + info.timeframeSelectionPrevious = + document.getElementById('timeframe-selection-previous'); + + info.timeframeSelectionNext = + document.getElementById('timeframe-selection-next'); + + info.timeframeEmptyLine = + document.getElementById('timeframe-empty'); + + info.sourceArea = + document.getElementById('timeframe-source-area'); + + info.sourceGrid = + info.sourceArea.querySelector('.grid-listing'); + + info.sources = + info.sourceGrid.getElementsByClassName('grid-item'); +} + +export function addPageListeners() { + if (!info.sourceArea) { + return; + } + + for (const input of [ + info.timeframeMonthsBefore, + info.timeframeMonthsAfter, + info.timeframeMonthsPeek, + info.contributionKind, + info.contributionGroup, + ]) { + input.addEventListener('change', () => { + updateArtistRollingWindow() + }); + } + + info.timeframeSelectionMenu.addEventListener('change', () => { + updateRollingWindowTimeframeSelection(); + }); + + const eatClicks = (element, callback) => { + element.addEventListener('click', domEvent => { + domEvent.preventDefault(); + callback(); + }); + + element.addEventListener('mousedown', domEvent => { + if (domEvent.detail > 1) { + domEvent.preventDefault(); + } + }); + }; + + eatClicks(info.timeframeSelectionNext, nextRollingTimeframeSelection); + eatClicks(info.timeframeSelectionPrevious, previousRollingTimeframeSelection); +} + +export function mutatePageContent() { + if (!info.sourceArea) { + return; + } + + updateArtistRollingWindow(); +} + +function previousRollingTimeframeSelection() { + const menu = info.timeframeSelectionMenu; + + if (menu.selectedIndex > 0) { + menu.selectedIndex--; + } + + updateRollingWindowTimeframeSelection(); +} + +function nextRollingTimeframeSelection() { + const menu = info.timeframeSelectionMenu; + + if (menu.selectedIndex < menu.length - 1) { + menu.selectedIndex++; + } + + updateRollingWindowTimeframeSelection(); +} + +function getArtistRollingWindowSourceInfo() { + const sourceElements = + Array.from(info.sources); + + const sourceTimeElements = + sourceElements + .map(el => Array.from(el.getElementsByTagName('time'))); + + const sourceTimeClasses = + sourceTimeElements + .map(times => times + .map(time => Array.from(time.classList))); + + const sourceKinds = + sourceTimeClasses + .map(times => times + .map(classes => classes + .find(cl => cl.endsWith('-contribution-date')) + .slice(0, -'-contribution-date'.length))); + + const sourceGroups = + sourceElements + .map(el => + Array.from(el.querySelectorAll('.contribution-group')) + .map(data => data.value)); + + const sourceDates = + sourceTimeElements + .map(times => times + .map(time => new Date(time.getAttribute('datetime')))); + + return stitchArrays({ + element: sourceElements, + kinds: sourceKinds, + groups: sourceGroups, + dates: sourceDates, + }); +} + +function getArtistRollingWindowTimeframeInfo() { + const contributionKind = + info.contributionKind.value; + + const contributionGroup = + info.contributionGroup.value; + + const sourceInfo = + getArtistRollingWindowSourceInfo(); + + const principalSources = + sourceInfo.filter(source => { + if (!source.kinds.includes(contributionKind)) { + return false; + } + + if (contributionGroup !== '-') { + if (!source.groups.includes(contributionGroup)) { + return false; + } + } + + return true; + }); + + const principalSourceDates = + principalSources.map(source => + stitchArrays({ + kind: source.kinds, + date: source.dates, + }).find(({kind}) => kind === contributionKind) + .date); + + const getPeekDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + - parseInt(info.timeframeMonthsBefore.value) + - parseInt(info.timeframeMonthsPeek.value))); + + return date; + }; + + const getEntranceDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + - parseInt(info.timeframeMonthsBefore.value))); + + return date; + }; + + const getExitDate = inputDate => { + const date = new Date(inputDate); + + date.setMonth( + (date.getMonth() + + parseInt(info.timeframeMonthsAfter.value))); + + return date; + }; + + const principalSourceIndices = + Array.from({length: principalSources.length}, (_, i) => i); + + const timeframeSourceChunks = + chunkByConditions(principalSourceIndices, [ + (previous, next) => + +principalSourceDates[previous] !== + +principalSourceDates[next], + ]); + + const timeframeSourceChunkDates = + timeframeSourceChunks + .map(indices => indices[0]) + .map(index => principalSourceDates[index]); + + const timeframeSourceChunkPeekDates = + timeframeSourceChunkDates + .map(getPeekDate); + + const timeframeSourceChunkEntranceDates = + timeframeSourceChunkDates + .map(getEntranceDate); + + const timeframeSourceChunkExitDates = + timeframeSourceChunkDates + .map(getExitDate); + + const peekDateInfo = + stitchArrays({ + peek: timeframeSourceChunkPeekDates, + indices: timeframeSourceChunks, + }).map(({peek, indices}) => ({ + date: peek, + peek: indices, + })); + + const entranceDateInfo = + stitchArrays({ + entrance: timeframeSourceChunkEntranceDates, + indices: timeframeSourceChunks, + }).map(({entrance, indices}) => ({ + date: entrance, + entrance: indices, + })); + + const exitDateInfo = + stitchArrays({ + exit: timeframeSourceChunkExitDates, + indices: timeframeSourceChunks, + }).map(({exit, indices}) => ({ + date: exit, + exit: indices, + })); + + const dateInfoChunks = + chunkByProperties( + sortByDate([ + ...peekDateInfo, + ...entranceDateInfo, + ...exitDateInfo, + ]), + ['date']); + + const dateInfo = + dateInfoChunks + .map(({chunk}) => + Object.assign({ + peek: null, + entrance: null, + exit: null, + }, ...chunk)); + + const timeframeInfo = + dateInfo.reduce( + (accumulator, {date, peek, entrance, exit}) => { + const previous = accumulator.at(-1); + + // These mustn't be mutated! + let peeking = (previous ? previous.peeking : []); + let tracking = (previous ? previous.tracking : []); + + if (peek) { + peeking = + peeking.concat(peek); + } + + if (entrance) { + peeking = + peeking.filter(index => !entrance.includes(index)); + + tracking = + tracking.concat(entrance); + } + + if (exit) { + tracking = + tracking.filter(index => !exit.includes(index)); + } + + return [...accumulator, { + date, + peeking, + tracking, + peek, + entrance, + exit, + }]; + }, + []); + + const indicesToSources = indices => + (indices + ? indices.map(index => principalSources[index]) + : null); + + const finalizedTimeframeInfo = + timeframeInfo.map(({ + date, + peeking, + tracking, + peek, + entrance, + exit, + }) => ({ + date, + peeking: indicesToSources(peeking), + tracking: indicesToSources(tracking), + peek: indicesToSources(peek), + entrance: indicesToSources(entrance), + exit: indicesToSources(exit), + })); + + return finalizedTimeframeInfo; +} + +function updateArtistRollingWindow() { + const timeframeInfo = + getArtistRollingWindowTimeframeInfo(); + + if (empty(timeframeInfo)) { + cssProp(info.timeframeSelectionControl, 'display', 'none'); + cssProp(info.timeframeSelectionSomeLine, 'display', 'none'); + cssProp(info.timeframeSelectionNoneLine, 'display', null); + + updateRollingWindowTimeframeSelection(timeframeInfo); + + return; + } + + cssProp(info.timeframeSelectionControl, 'display', null); + cssProp(info.timeframeSelectionSomeLine, 'display', null); + cssProp(info.timeframeSelectionNoneLine, 'display', 'none'); + + // The last timeframe is just the exit of the final tracked sources, + // so we aren't going to display a menu option for it, and will just use + // it as the end of the final option's date range. + + const usedTimeframes = timeframeInfo.slice(0, -1); + const firstTimeframe = timeframeInfo.at(0); + const lastTimeframe = timeframeInfo.at(-1); + + const sourceCount = + timeframeInfo + .flatMap(({entrance}) => entrance ?? []) + .length; + + const timeframeCount = + usedTimeframes.length; + + info.timeframeSelectionContributionCount.innerText = sourceCount; + info.timeframeSelectionTimeframeCount.innerText = timeframeCount; + + const firstDate = firstTimeframe.date; + const lastDate = lastTimeframe.date; + + info.timeframeSelectionFirstDate.innerText = formatDate(firstDate); + info.timeframeSelectionLastDate.innerText = formatDate(lastDate); + + while (info.timeframeSelectionMenu.firstChild) { + info.timeframeSelectionMenu.firstChild.remove(); + } + + for (const [index, timeframe] of usedTimeframes.entries()) { + const nextTimeframe = timeframeInfo[index + 1]; + + const option = document.createElement('option'); + + option.appendChild(document.createTextNode( + `${formatDate(timeframe.date)} – ${formatDate(nextTimeframe.date)}`)); + + info.timeframeSelectionMenu.appendChild(option); + } + + updateRollingWindowTimeframeSelection(timeframeInfo); +} + +function updateRollingWindowTimeframeSelection(timeframeInfo) { + timeframeInfo ??= getArtistRollingWindowTimeframeInfo(); + + updateRollingWindowTimeframeSelectionControls(timeframeInfo); + updateRollingWindowTimeframeSelectionSources(timeframeInfo); +} + +function updateRollingWindowTimeframeSelectionControls(timeframeInfo) { + const currentIndex = + info.timeframeSelectionMenu.selectedIndex; + + const atFirstTimeframe = + currentIndex === 0; + + // The last actual timeframe is empty and not displayed as a menu option. + const atLastTimeframe = + currentIndex === timeframeInfo.length - 2; + + if (atFirstTimeframe) { + info.timeframeSelectionPrevious.removeAttribute('href'); + } else { + info.timeframeSelectionPrevious.setAttribute('href', '#'); + } + + if (atLastTimeframe) { + info.timeframeSelectionNext.removeAttribute('href'); + } else { + info.timeframeSelectionNext.setAttribute('href', '#'); + } +} + +function updateRollingWindowTimeframeSelectionSources(timeframeInfo) { + const currentIndex = + info.timeframeSelectionMenu.selectedIndex; + + const contributionGroup = + info.contributionGroup.value; + + cssProp(info.sourceGrid, 'display', null); + + const {peeking: peekingSources, tracking: trackingSources} = + (empty(timeframeInfo) + ? {peeking: [], tracking: []} + : timeframeInfo[currentIndex]); + + const peekingElements = + peekingSources.map(source => source.element); + + const trackingElements = + trackingSources.map(source => source.element); + + const showingElements = + [...trackingElements, ...peekingElements]; + + const hidingElements = + Array.from(info.sources) + .filter(element => + !peekingElements.includes(element) && + !trackingElements.includes(element)); + + for (const element of peekingElements) { + element.classList.add('peeking'); + element.classList.remove('tracking'); + } + + for (const element of trackingElements) { + element.classList.remove('peeking'); + element.classList.add('tracking'); + } + + for (const element of hidingElements) { + element.classList.remove('peeking'); + element.classList.remove('tracking'); + cssProp(element, 'display', 'none'); + } + + for (const element of showingElements) { + cssProp(element, 'display', null); + + for (const time of element.getElementsByTagName('time')) { + for (const className of time.classList) { + if (!className.endsWith('-contribution-date')) continue; + + const kind = className.slice(0, -'-contribution-date'.length); + if (kind === info.contributionKind.value) { + cssProp(time, 'display', null); + } else { + cssProp(time, 'display', 'none'); + } + } + } + + for (const data of element.getElementsByClassName('contribution-group')) { + if (contributionGroup === '-' || data.value !== contributionGroup) { + cssProp(data, 'display', null); + } else { + cssProp(data, 'display', 'none'); + } + } + } + + if (empty(peekingElements) && empty(trackingElements)) { + cssProp(info.timeframeEmptyLine, 'display', null); + } else { + cssProp(info.timeframeEmptyLine, '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-grid-section.js b/src/static/js/client/expandable-grid-section.js new file mode 100644 index 00000000..ce9a4c06 --- /dev/null +++ b/src/static/js/client/expandable-grid-section.js @@ -0,0 +1,85 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'expandableGallerySectionInfo', + + items: null, + toggles: null, + expandCues: null, + collapseCues: null, +}; + +export function getPageReferences() { + const expandos = + Array.from(document.querySelectorAll('.grid-expando')); + + const grids = + expandos + .map(expando => expando.closest('.grid-listing')); + + info.items = + grids + .map(grid => grid.querySelectorAll('.grid-item')) + .map(items => Array.from(items)); + + info.toggles = + expandos + .map(expando => expando.querySelector('.grid-expando-toggle')); + + info.expandCues = + info.toggles + .map(toggle => toggle.querySelector('.grid-expand-cue')); + + info.collapseCues = + info.toggles + .map(toggle => toggle.querySelector('.grid-collapse-cue')); +} + +export function addPageListeners() { + stitchArrays({ + items: info.items, + toggle: info.toggles, + expandCue: info.expandCues, + collapseCue: info.collapseCues, + }).forEach(({ + items, + toggle, + expandCue, + collapseCue, + }) => { + toggle.addEventListener('click', domEvent => { + domEvent.preventDefault(); + + const collapsed = + items.some(item => + item.classList.contains('hidden-by-expandable-cut')); + + for (const item of items) { + if ( + !item.classList.contains('hidden-by-expandable-cut') && + !item.classList.contains('shown-by-expandable-cut') + ) continue; + + if (collapsed) { + item.classList.remove('hidden-by-expandable-cut'); + item.classList.add('shown-by-expandable-cut'); + } else { + item.classList.add('hidden-by-expandable-cut'); + item.classList.remove('shown-by-expandable-cut'); + } + } + + if (collapsed) { + cssProp(expandCue, 'display', 'none'); + cssProp(collapseCue, 'display', null); + } else { + cssProp(expandCue, 'display', null); + cssProp(collapseCue, 'display', 'none'); + } + }); + }); +} diff --git a/src/static/js/client/gallery-style-selector.js b/src/static/js/client/gallery-style-selector.js new file mode 100644 index 00000000..c7086eae --- /dev/null +++ b/src/static/js/client/gallery-style-selector.js @@ -0,0 +1,123 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'galleryStyleSelectorInfo', + + selectors: null, + sections: null, + + selectorStyleInputs: null, + selectorStyleInputStyles: null, + + selectorReleaseItems: null, + selectorReleaseItemStyles: null, + + selectorCountAll: null, + selectorCountFiltered: null, + selectorCountFilteredCount: null, + selectorCountNone: null, +}; + +export function getPageReferences() { + info.selectors = + Array.from(document.querySelectorAll('.gallery-style-selector')); + + info.sections = + info.selectors + .map(selector => selector.closest('section')); + + info.selectorStyleInputs = + info.selectors + .map(selector => selector.querySelectorAll('.styles input')) + .map(inputs => Array.from(inputs)); + + info.selectorStyleInputStyles = + info.selectorStyleInputs + .map(inputs => inputs + .map(input => input.closest('label').dataset.style)); + + info.selectorReleaseItems = + info.sections + .map(section => section.querySelectorAll('.grid-item')) + .map(items => Array.from(items)); + + info.selectorReleaseItemStyles = + info.selectorReleaseItems + .map(items => items + .map(item => item.dataset.style)); + + info.selectorCountAll = + info.selectors + .map(selector => selector.querySelector('.count.all')); + + info.selectorCountFiltered = + info.selectors + .map(selector => selector.querySelector('.count.filtered')); + + info.selectorCountFilteredCount = + info.selectorCountFiltered + .map(selector => selector.querySelector('span')); + + info.selectorCountNone = + info.selectors + .map(selector => selector.querySelector('.count.none')); +} + +export function addPageListeners() { + for (const index of info.selectors.keys()) { + for (const input of info.selectorStyleInputs[index]) { + input.addEventListener('input', () => updateVisibleReleases(index)); + } + } +} + +function updateVisibleReleases(index) { + const inputs = info.selectorStyleInputs[index]; + const inputStyles = info.selectorStyleInputStyles[index]; + + const selectedStyles = + stitchArrays({input: inputs, style: inputStyles}) + .filter(({input}) => input.checked) + .map(({style}) => style); + + const releases = info.selectorReleaseItems[index]; + const releaseStyles = info.selectorReleaseItemStyles[index]; + + let visible = 0; + + stitchArrays({ + release: releases, + style: releaseStyles, + }).forEach(({release, style}) => { + if (selectedStyles.includes(style)) { + release.classList.remove('hidden-by-style-mismatch'); + visible++; + } else { + release.classList.add('hidden-by-style-mismatch'); + } + }); + + const countAll = info.selectorCountAll[index]; + const countFiltered = info.selectorCountFiltered[index]; + const countFilteredCount = info.selectorCountFilteredCount[index]; + const countNone = info.selectorCountNone[index]; + + if (visible === releases.length) { + cssProp(countAll, 'display', null); + cssProp(countFiltered, 'display', 'none'); + cssProp(countNone, 'display', 'none'); + } else if (visible === 0) { + cssProp(countAll, 'display', 'none'); + cssProp(countFiltered, 'display', 'none'); + cssProp(countNone, 'display', null); + } else { + cssProp(countAll, 'display', 'none'); + cssProp(countFiltered, 'display', null); + cssProp(countNone, 'display', 'none'); + countFilteredCount.innerHTML = visible; + } +} diff --git a/src/static/js/client/hash-link.js b/src/static/js/client/hash-link.js index 27035e29..e82e06c5 100644 --- a/src/static/js/client/hash-link.js +++ b/src/static/js/client/hash-link.js @@ -1,6 +1,7 @@ /* eslint-env browser */ -import {filterMultipleArrays, stitchArrays} from '../../shared-util/sugar.js'; +import {filterMultipleArrays, stitchArrays, unique} + from '../../shared-util/sugar.js'; import {dispatchInternalEvent} from '../client-util.js'; @@ -11,6 +12,9 @@ export const info = { hrefs: null, targets: null, + details: null, + detailsIDs: null, + state: { highlightedTarget: null, scrollingAfterClick: false, @@ -40,6 +44,19 @@ export function getPageReferences() { info.hrefs, info.targets, (_link, _href, target) => target); + + info.details = + unique([ + ...document.querySelectorAll('details[id]'), + ... + Array.from(document.querySelectorAll('summary[id]')) + .map(summary => summary.closest('details')), + ]); + + info.detailsIDs = + info.details.map(details => + details.id || + details.querySelector('summary').id); } function processScrollingAfterHashLinkClicked() { @@ -60,6 +77,15 @@ function processScrollingAfterHashLinkClicked() { }, 200); } +export function mutatePageContent() { + if (location.hash.length > 1) { + const target = document.getElementById(location.hash.slice(1)); + if (target) { + expandDetails(target); + } + } +} + export function addPageListeners() { // Instead of defining a scroll offset (to account for the sticky heading) // in JavaScript, we interface with the CSS property 'scroll-margin-top'. @@ -94,6 +120,8 @@ export function addPageListeners() { return; } + expandDetails(target); + // Hide skipper box right away, so the layout is updated on time for the // math operations coming up next. const skipper = document.getElementById('skippers'); @@ -143,4 +171,32 @@ export function addPageListeners() { state.highlightedTarget = null; }); } + + stitchArrays({ + details: info.details, + id: info.detailsIDs, + }).forEach(({details, id}) => { + details.addEventListener('toggle', () => { + if (!details.open) { + detractHash(id); + } + }); + }); +} + +function expandDetails(target) { + if (target.nodeName === 'SUMMARY') { + const details = target.closest('details'); + if (details) { + details.open = true; + } + } else if (target.nodeName === 'DETAILS') { + details.open = true; + } +} + +function detractHash(id) { + if (location.hash === '#' + id) { + history.pushState({}, undefined, location.href.replace(/#.*$/, '')); + } } 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..0f22810c 100644 --- a/src/static/js/client/index.js +++ b/src/static/js/client/index.js @@ -4,16 +4,23 @@ 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 artistRollingWindowModule from './artist-rolling-window.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 expandableGridSectionModule from './expandable-grid-section.js'; +import * as galleryStyleSelectorModule from './gallery-style-selector.js'; import * as hashLinkModule from './hash-link.js'; import * as hoverableTooltipModule from './hoverable-tooltip.js'; import * as imageOverlayModule from './image-overlay.js'; import * as intrapageDotSwitcherModule from './intrapage-dot-switcher.js'; import * as liveMousePositionModule from './live-mouse-position.js'; +import * as memorableDetailsModule from './memorable-details.js'; import * as quickDescriptionModule from './quick-description.js'; +import * as revealAllGridControlModule from './reveal-all-grid-control.js'; import * as scriptedLinkModule from './scripted-link.js'; import * as sidebarSearchModule from './sidebar-search.js'; import * as stickyHeadingModule from './sticky-heading.js'; @@ -24,16 +31,23 @@ import * as wikiSearchModule from './wiki-search.js'; export const modules = [ additionalNamesBoxModule, albumCommentarySidebarModule, + artTagGalleryFilterModule, + artTagNetworkModule, artistExternalLinkTooltipModule, + artistRollingWindowModule, cssCompatibilityAssistantModule, datetimestampTooltipModule, draggedLinkModule, + expandableGridSectionModule, + galleryStyleSelectorModule, hashLinkModule, hoverableTooltipModule, imageOverlayModule, intrapageDotSwitcherModule, liveMousePositionModule, + memorableDetailsModule, quickDescriptionModule, + revealAllGridControlModule, scriptedLinkModule, sidebarSearchModule, stickyHeadingModule, diff --git a/src/static/js/client/memorable-details.js b/src/static/js/client/memorable-details.js new file mode 100644 index 00000000..07482b29 --- /dev/null +++ b/src/static/js/client/memorable-details.js @@ -0,0 +1,64 @@ +/* eslint-env browser */ + +import {stitchArrays} from '../../shared-util/sugar.js'; + +export const info = { + id: 'memorableDetailsInfo', + + details: null, + ids: null, + + session: { + openDetails: { + type: 'json', + maxLength: settings => settings.maxOpenDetailsStorage, + }, + }, + + settings: { + maxOpenDetailsStorage: 1000, + }, +}; + +export function getPageReferences() { + info.details = + Array.from(document.querySelectorAll('details.memorable')); + + info.ids = + info.details.map(details => details.getAttribute('data-memorable-id')); +} + +export function mutatePageContent() { + stitchArrays({ + details: info.details, + id: info.ids, + }).forEach(({details, id}) => { + if (info.session.openDetails?.includes(id)) { + details.open = true; + } + }); +} + +export function addPageListeners() { + for (const [index, details] of info.details.entries()) { + details.addEventListener('toggle', () => { + handleDetailsToggled(index); + }); + } +} + +function handleDetailsToggled(index) { + const details = info.details[index]; + const id = info.ids[index]; + + if (details.open) { + if (info.session.openDetails) { + info.session.openDetails = [...info.session.openDetails, id]; + } else { + info.session.openDetails = [id]; + } + } else if (info.session.openDetails?.includes(id)) { + info.session.openDetails = + info.session.openDetails.filter(item => item !== id); + } +} diff --git a/src/static/js/client/reveal-all-grid-control.js b/src/static/js/client/reveal-all-grid-control.js new file mode 100644 index 00000000..1b362bea --- /dev/null +++ b/src/static/js/client/reveal-all-grid-control.js @@ -0,0 +1,72 @@ +/* eslint-env browser */ + +import {cssProp} from '../client-util.js'; + +export const info = { + id: 'revealAllGridControlInfo', + + revealAllLinks: null, + revealables: null, + + revealLabels: null, + concealLabels: null, +}; + +export function getPageReferences() { + info.revealAllLinks = + Array.from(document.querySelectorAll('.reveal-all a')); + + info.revealables = + info.revealAllLinks + .map(link => link.closest('.grid-listing')) + .map(listing => listing.querySelectorAll('.reveal')); + + info.revealLabels = + info.revealAllLinks + .map(link => link.querySelector('.reveal-label')); + + info.concealLabels = + info.revealAllLinks + .map(link => link.querySelector('.conceal-label')); +} + +export function addPageListeners() { + for (const [index, link] of info.revealAllLinks.entries()) { + link.addEventListener('click', domEvent => { + domEvent.preventDefault(); + handleRevealAllLinkClicked(index); + }); + } +} + +export function addInternalListeners() { + // Don't even think about it. "Reveal all artworks" is a stable control, + // meaning it only changes because the user interacted with it directly. +} + +function handleRevealAllLinkClicked(index) { + const revealables = info.revealables[index]; + const revealLabel = info.revealLabels[index]; + const concealLabel = info.concealLabels[index]; + + const shouldReveal = + (cssProp(revealLabel, 'display') === 'none' + ? false + : true); + + for (const revealable of revealables) { + if (shouldReveal) { + revealable.classList.add('revealed'); + } else { + revealable.classList.remove('revealed'); + } + } + + if (shouldReveal) { + cssProp(revealLabel, 'display', 'none'); + cssProp(concealLabel, 'display', null); + } else { + cssProp(revealLabel, 'display', null); + cssProp(concealLabel, 'display', 'none'); + } +} diff --git a/src/static/js/client/sidebar-search.js b/src/static/js/client/sidebar-search.js index c79fb837..4467766c 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,26 @@ export const info = { groupResultKindString: null, tagResultKindString: null, + groupResultDisambiguatorString: null, + flashResultDisambiguatorString: null, + trackResultDisambiguatorString: 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 +116,10 @@ export const info = { maxLength: settings => settings.maxActiveResultsStorage, }, + activeFilterType: { + type: 'string', + }, + repeatQueryOnReload: { type: 'boolean', default: false, @@ -133,6 +161,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 +199,33 @@ export function getPageReferences() { info.tagResultKindString = findString('tag-result-kind'); + + info.groupResultDisambiguatorString = + findString('group-result-disambiguator'); + + info.flashResultDisambiguatorString = + findString('flash-result-disambiguator'); + + info.trackResultDisambiguatorString = + findString('track-result-disambiguator'); + + 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 +315,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 +400,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 +453,7 @@ export function addPageListeners() { const {settings, state} = info; if (!info.searchInput.value) { - clearSidebarSearch(); + clearSidebarSearch(); // ...but don't clear filter return; } @@ -388,10 +515,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 +547,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 +608,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 +689,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,63 +785,131 @@ 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) { - const el = generateSidebarSearchResult(result); + 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, filteredResults); 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) { +function generateSidebarSearchResult(result, results) { const preparedSlots = { color: result.data.color ?? null, name: - result.data.name ?? result.data.primaryName ?? null, + getSearchResultName(result), imageSource: getSearchResultImageSource(result), @@ -742,9 +974,37 @@ function generateSidebarSearchResult(result) { return null; } + const compareReferenceType = otherResult => + otherResult.referenceType === result.referenceType; + + const compareName = otherResult => + getSearchResultName(otherResult) === getSearchResultName(result); + + const ambiguous = + results.some(otherResult => + otherResult !== result && + compareReferenceType(otherResult) && + compareName(otherResult)); + + if (ambiguous) { + preparedSlots.disambiguate = + result.data.disambiguator; + + preparedSlots.disambiguatorString = + info[result.referenceType + 'ResultDisambiguatorString']; + } + return generateSidebarSearchResultTemplate(preparedSlots); } +function getSearchResultName(result) { + return ( + result.data.name ?? + result.data.primaryName ?? + null + ); +} + function getSearchResultImageSource(result) { const {artwork} = result.data; if (!artwork) return null; @@ -820,6 +1080,15 @@ function generateSidebarSearchResultTemplate(slots) { } } + if (!accentSpan && slots.disambiguate) { + accentSpan = document.createElement('span'); + accentSpan.classList.add('wiki-search-result-disambiguator'); + accentSpan.appendChild( + templateContent(slots.disambiguatorString, { + disambiguator: slots.disambiguate, + })); + } + if (!accentSpan && slots.kindString) { accentSpan = document.createElement('span'); accentSpan.classList.add('wiki-search-result-kind'); @@ -859,6 +1128,8 @@ function generateSidebarSearchResultTemplate(slots) { } function hideSidebarSearchResults() { + cssProp(info.filterContainer, 'display', 'none'); + cssProp(info.resultsRule, 'display', 'none'); cssProp(info.resultsContainer, 'display', 'none'); @@ -991,6 +1262,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 +1305,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 +1355,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..4660013a 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,31 @@ 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()) { + if (heading.nodeName === 'SUMMARY' && !heading.closest('details').open) { + continue; + } + 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 +272,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 +323,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..387cbca0 100644 --- a/src/static/js/search-worker.js +++ b/src/static/js/search-worker.js @@ -2,7 +2,8 @@ import FlexSearch from '../lib/flexsearch/flexsearch.bundle.module.min.js'; -import {makeSearchIndex, searchSpec} from '../shared-util/search-spec.js'; +import {default as searchSpec, makeSearchIndex} + from '../shared-util/search-shape.js'; import { empty, @@ -130,7 +131,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 +372,76 @@ 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'], + + ['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'], +]; + +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 +456,7 @@ function queryGenericIndex(index, query, options) { query: values, })); - const boilerplate = queryBoilerplate(index); + const boilerplate = queryBoilerplate(indexes[indexKey]); const particleResults = Object.fromEntries( @@ -459,62 +478,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 +560,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..5bbecbf3 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: @@ -87,16 +97,6 @@ count: many: "" other: "{CONTRIBUTIONS} contributions" - coverArts: - _: "{COVER_ARTS}" - withUnit: - zero: "" - one: "{COVER_ARTS} cover art" - two: "" - few: "" - many: "" - other: "{COVER_ARTS} cover arts" - flashes: _: "{FLASHES}" withUnit: @@ -139,6 +139,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: @@ -249,34 +259,47 @@ releaseInfo: # Descriptions - by: - _: "By {ARTISTS}." - featuring: "By {ARTISTS}, featuring {FEATURING}." + by: >- + By {ARTISTS}. + by.featuring: >- + By {ARTISTS}, featuring {FEATURING}. + by.withAlbum: >- + From {ALBUM}, by {ARTISTS}. + by.featuring.withAlbum: >- + From {ALBUM}, by {ARTISTS} feat. {FEATURING}. 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:" + + lyrics: + _: "Lyrics:" + + switcher: "({ENTRIES})" + note: "Context notes:" - alsoReleasedAs: - _: "Also released as:" + alsoReleased: + onAlbums: >- + Also released on {ALBUMS}. - item: - _: "{TRACK} ({ALBUM})" - withYear: "({YEAR}) {TRACK} ({ALBUM})" + asSingle: >- + Also released {SINGLE}. + + onAlbums.asSingle: >- + Also released on {ALBUMS}, and {SINGLE}. + + single: "as a single" tracksReferenced: _: "Tracks that {TRACK} references:" @@ -291,16 +314,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}:" @@ -350,12 +373,20 @@ releaseInfo: readCommentary: _: "Read {LINK}." - link: "artist commentary" - readCreditSources: + link: + _: "artist commentary" + withWikiCommentary: "artist and wiki commentary" + onlyWikiCommentary: "wiki commentary" + + readCreditingSources: _: "Read {LINK}." link: "crediting sources" + readReferencingSources: + _: "Read {LINK}." + link: "referencing sources" + additionalFiles: heading: "View or download additional files:" @@ -470,7 +501,16 @@ misc: # artistCommentary: artistCommentary: - _: "Artist commentary:" + _: "Artist commentary for {THING}:" + sticky: "Artist commentary:" + + withWikiCommentary: + _: "Artist and wiki commentary for {THING}:" + sticky: "Artist and wiki commentary:" + + onlyWikiCommentary: + _: "Wiki commentary for {THING}:" + sticky: "Wiki commentary:" entry: title: @@ -495,7 +535,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: >- @@ -530,6 +581,10 @@ misc: noExternalLinkPlatformName: "Other" chronology: + heading: + artistReleases: "Releases by {ARTIST}:" + artistTracks: "Tracks by {ARTIST}:" + previous: symbol: "←" info: @@ -547,47 +602,17 @@ misc: bannerArt: "banner art" coverArt: "cover art" flash: "flash" + release: "release" track: "track" trackArt: "track art" trackContribution: "track contribution" wallpaperArt: "wallpaper art" - # chronology: - # - # "Chronology links" are a section that appear in the nav bar for - # most things with individual contributors across the wiki! These - # allow for quick navigation between older and newer releases of - # a given artist, or seeing at a glance how many contributions an - # artist made before the one you're currently viewing. - # - # Chronology information is described for each artist and shows - # the kind of thing which is being contributed to, since all of - # the entries are displayed together in one list. - # - - chronology: - - # seeArtistPages: - # If the thing you're viewing has a lot of contributors, their - # chronology info will be exempt from the nav bar, which'll - # show this message instead. - - seeArtistPages: "(See artist pages for chronology info!)" - - # withNavigation: - # Navigation refers to previous/next links. - - withNavigation: "{HEADING} ({NAVIGATION})" - - heading: - coverArt: "{INDEX} cover art by {ARTIST}" - flash: "{INDEX} flash/game by {ARTIST}" - track: "{INDEX} track by {ARTIST}" - trackArt: "{INDEX} track art by {ARTIST}" - onlyIndex: "Only" - - creditSources: - _: "Crediting sources:" + creditingSources: + _: "{CUE} for {THING}:" + collapsed: "{CUE} for {THING}…" + cue: "Crediting sources" + sticky: "Crediting sources:" # external: # Links which will generally bring you somewhere off of the wiki. @@ -615,7 +640,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" @@ -662,6 +692,11 @@ misc: nintendoMusic: "Nintendo Music" patreon: "Patreon" poetryFoundation: "Poetry Foundation" + + reddit: + _: "Reddit" + subreddit: "Reddit ({SUBREDDIT})" + soundcloud: "SoundCloud" spotify: "Spotify" steam: "Steam" @@ -679,6 +714,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 @@ -737,8 +784,10 @@ misc: nav: previous: "Previous" next: "Next" + info: "Info" gallery: "Gallery" + rollingWindow: "Rolling Window" # pageTitle: # Title set under the page's <title> HTML element, which is @@ -786,6 +835,25 @@ misc: artist: "(artist)" group: "(group)" + resultDisambiguator: + group: "({DISAMBIGUATOR})" + flash: "(in {DISAMBIGUATOR})" + track: "(from {DISAMBIGUATOR})" + + resultFilter: + album: "Albums" + artTag: "Art Tags" + artist: "Artists" + flash: "Flashes" + group: "Groups" + track: "Tracks" + + referencingSources: + _: "{CUE} for {THING}:" + collapsed: "{CUE} for {THING}…" + cue: "Referencing sources" + sticky: "Referencing sources:" + # skippers: # # These are navigational links that only show up when you're @@ -813,7 +881,7 @@ misc: # Displayed on various info pages. artistCommentary: "Artist commentary" - creditSources: "Crediting sources" + creditingSources: "Crediting sources" # Displayed on artist info page. @@ -834,6 +902,7 @@ misc: sampledBy: "Sampled by..." features: "Features..." featuredIn: "Featured in..." + referencingSources: "Referencing sources" lyrics: "Lyrics" @@ -851,8 +920,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 +937,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 @@ -878,13 +987,34 @@ misc: # that thing. coverGrid: + revealAll: + reveal: "Reveal all artworks" + conceal: "Conceal all artworks" + warnings: "In this gallery: {WARNINGS}" + + expandCollapseCue: "({CUE})" + expand: "Show the rest!" + collapse: "Collapse these" + noCoverArt: "{ALBUM}" + tab: + groups: "{GROUPS}" + artists: "{ARTISTS}" + artists.featuring: "{ARTISTS} feat. {FEATURING}" + details: + notFromThisGroup: "{NAME}{MARKER}" + notFromThisGroup.marker: "*" + accent: "({DETAILS})" albumLength: "{TRACKS}, {TIME}" + albumLength.single: "single, {TIME}" + coverArtists: "Artwork by {ARTISTS}" + coverArtists.customLabel: "{LABEL} by {ARTISTS}" + otherCoverArtists: "With {ARTISTS}" albumGalleryGrid: @@ -943,7 +1073,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 +1087,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, @@ -1037,6 +1176,9 @@ albumGalleryPage: statsLine.withDate: >- {TRACKS} totaling {DURATION}. Released {DATE}. + statsLine.withDate.noDuration: >- + Released {DATE}. + # coverArtistsLine: # This is displayed if every track (which has artwork at all) # has the same illustration credits. @@ -1053,6 +1195,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 +1317,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 +1355,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 +1417,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: @@ -1259,6 +1437,146 @@ artistGalleryPage: infoLine: >- Contributed to {COVER_ARTS}. +artistRollingWindowPage: + title: "{ARTIST} - Rolling Window" + + windowConfigurationLine: >- + With a rolling window of {TIME_BEFORE} before a given date, and {TIME_AFTER} after, peeking ahead {PEEK}... + + contributionConfigurationLine: >- + Selecting {KIND} contributions from group {GROUP}... + + timeframeSelectionLine: + _: >- + There are {CONTRIBUTIONS} contributions, making {TIMEFRAMES} timeframes between {FIRST_DATE} and {LAST_DATE}. + none: >- + There aren't any matching contributions, or those which do aren't dated, so there are no timeframes. + + emptyTimeframeLine: >- + This timeframe is empty, since no contributions are in range. + + timeframeSelectionControl: + _: "{PREVIOUS} {TIMEFRAMES} {NEXT}" + previous: "← Previous" + next: "Next →" + + contributionKind: + artwork: "Artwork" + music: "Music" + flash: "Flash" + + contributionGroup: + all: "All groups" + group: "{GROUP}" + + timeframe: + months: "{INPUT} months" + +# +# 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 @@ -1430,6 +1748,42 @@ groupGalleryPage: infoLine: >- {TRACKS} across {ALBUMS}, totaling {TIME}. + albumViewSwitcher: + _: "Showing albums:" + + bySeries: "By series" + byDate: "By date" + + albumStyleSwitcher: + _: "Showing these releases:" + + album: "Albums" + single: "Singles" + + count: + all: "all {TOTAL}" + filtered: "{COUNT} of {TOTAL}" + none: "none at all" + + 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}. + # # listingIndex: # The listing index page shows all available listings on the wiki, @@ -1521,6 +1875,7 @@ listingPage: title: "Albums - by Tracks" title.short: "...by Tracks" item: "{ALBUM} ({TRACKS})" + item.single: "{ALBUM} ({TRACKS}—single)" # listAlbums.byDuration: # Lists albums by total duration of all tracks, longest to @@ -1557,6 +1912,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,28 +2312,22 @@ 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})" + # listTracks.needingLyrics: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # displaying only tracks which are marked as needing lyrics. + # The chunk titles also display the date each album was released, + # and tracks' own custom "Date First Released" fields are totally + # ignored. - # 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. + needingLyrics: + title: "Tracks - which need Lyrics" + title.short: "...which need Lyrics" - byUses: - title: "Tags - by Uses" - title.short: "...by Uses" - item: "{TAG} ({TIMES_USED})" + chunk: + title: "{ALBUM}" + title.withDate: "{ALBUM} ({DATE})" + item: "{TRACK}" other: @@ -2097,22 +2519,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 @@ -2134,15 +2540,14 @@ trackPage: backToTrack: "Return to track page" + singleAccent: "single" + track: _: "{TRACK}" withNumber: "{NUMBER}. {TRACK}" - chronology: - scope: - title: "Chronology links {SCOPE}" - wiki: "across this wiki" - album: "within this album" + needsLyrics: >- + This track has vocals, but there aren't lyrics available for it on this wiki, yet! socialEmbed: heading: "{ALBUM}" 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..667f7d8b --- /dev/null +++ b/src/urls-default.yaml @@ -0,0 +1,145 @@ +# 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/' + artistRollingWindow: 'artist/<>/rolling-window/' + + 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/util/search-spec.js b/src/util/search-spec.js deleted file mode 100644 index bc24e1a1..00000000 --- a/src/util/search-spec.js +++ /dev/null @@ -1,259 +0,0 @@ -// Index structures shared by client and server, and relevant interfaces. - -function getArtworkPath(thing) { - switch (thing.constructor[Symbol.for('Thing.referenceType')]) { - case 'album': { - return [ - 'media.albumCover', - thing.directory, - thing.coverArtFileExtension, - ]; - } - - case 'flash': { - return [ - 'media.flashArt', - thing.directory, - thing.coverArtFileExtension, - ]; - } - - case 'track': { - if (thing.hasUniqueCoverArt) { - return [ - 'media.trackCover', - thing.album.directory, - thing.directory, - thing.coverArtFileExtension, - ]; - } else if (thing.album.hasCoverArt) { - return [ - 'media.albumCover', - thing.album.directory, - thing.album.coverArtFileExtension, - ]; - } else { - return null; - } - } - - default: - return null; - } -} - -function prepareArtwork(thing, { - checkIfImagePathHasCachedThumbnails, - getThumbnailEqualOrSmaller, - urls, -}) { - const hasWarnings = - thing.artTags?.some(artTag => artTag.isContentWarning); - - const artworkPath = - getArtworkPath(thing); - - if (!artworkPath) { - return undefined; - } - - const mediaSrc = - urls - .from('media.root') - .to(...artworkPath); - - if (!checkIfImagePathHasCachedThumbnails(mediaSrc)) { - return undefined; - } - - const selectedSize = - getThumbnailEqualOrSmaller( - (hasWarnings ? 'mini' : 'adorb'), - mediaSrc); - - const mediaSrcJpeg = - mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`); - - const displaySrc = - urls - .from('thumb.root') - .to('thumb.path', mediaSrcJpeg); - - const serializeSrc = - displaySrc.replace(thing.directory, '<>'); - - 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', - ]; - - 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); - - fields.artwork = - prepareArtwork(thing, opts); - - return fields; - }, - - index: [ - 'primaryName', - 'parentName', - 'artTags', - 'additionalNames', - 'contributors', - 'groups', - ], - - store: [ - 'primaryName', - 'artwork', - 'color', - ], - }, -}; - -export function makeSearchIndex(descriptor, {FlexSearch}) { - return new FlexSearch.Document({ - id: 'reference', - index: descriptor.index, - store: descriptor.store, - }); -} - -// TODO: This function basically mirrors bind-utilities.js, which isn't -// exactly robust, but... binding might need some more thought across the -// codebase in *general.* -function bindSearchUtilities({ - checkIfImagePathHasCachedThumbnails, - getThumbnailEqualOrSmaller, - thumbsCache, - urls, -}) { - const bound = { - urls, - }; - - bound.checkIfImagePathHasCachedThumbnails = - (imagePath) => - checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache); - - bound.getThumbnailEqualOrSmaller = - (preferred, imagePath) => - getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache); - - return bound; -} - -export function populateSearchIndex(index, descriptor, opts) { - const {wikiData} = opts; - const bound = bindSearchUtilities(opts); - - const collection = descriptor.query(wikiData); - - for (const thing of collection) { - const reference = thing.constructor.getReference(thing); - - let processed; - try { - processed = descriptor.process(thing, bound); - } catch (caughtError) { - throw new Error( - `Failed to process searchable thing ${reference}`, - {cause: caughtError}); - } - - index.add({reference, ...processed}); - } -} 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..c0df2d35 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -4,6 +4,7 @@ import { copyFile, cp, mkdir, + readFile, stat, symlink, writeFile, @@ -27,6 +28,7 @@ import { } from '#cli'; import { + getOrigin, getPagePathname, getURLsFrom, getURLsFromRoot, @@ -94,6 +96,11 @@ export function getCLIOptions() { type: 'value', }, + 'paths': { + help: `Skip rest and build only pages matching paths in this text file`, + type: 'value', + }, + // NOT for neatly ena8ling or disa8ling specific features of the site! // This is only in charge of what general groups of files to write. // They're here to make development quicker when you're only working @@ -114,8 +121,6 @@ export async function go({ universalUtilities, - mediaPath, - defaultLanguage, languages, urls, @@ -127,6 +132,7 @@ export async function go({ const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT; const appendIndexHTML = cliOptions['append-index-html'] ?? false; const writeOneLanguage = cliOptions['lang'] ?? null; + const pathsFromFile = cliOptions['paths'] ?? null; if (!outputPath) { logError`Expected ${'--out-path'} option or ${'HSMUSIC_OUT'} to be set`; @@ -146,6 +152,36 @@ export async function go({ logInfo`Writing all languages.`; } + let filterPaths = null; + if (pathsFromFile) { + let pathsText; + try { + pathsText = await readFile(pathsFromFile, 'utf8'); + } catch (error) { + logError`Failed to read file specified in ${'--paths'}:`; + logError`${error.code}: ${pathsFromFile}`; + return false; + } + + filterPaths = pathsText.split('\n').filter(Boolean); + + if (empty(filterPaths)) { + logWarn`Specified to build only paths in file ${'--paths'}:`; + logWarn`${pathsFromFile}`; + logWarn`But this file is totally empty...`; + } + + if (filterPaths.some(path => !path.startsWith('/'))) { + logError`All lines in ${'--paths'} file should start with slash ('${'/'}')`; + logError`These lines don't:`; + console.error(filterPaths.filter(path => !path.startsWith('/')).join('\n')); + logError`Please check file contents, or specified path, and try again.`; + return false; + } + + logInfo`Writing ${filterPaths.length} paths specified in: ${pathsFromFile} (${'--paths'})`; + } + const selectedPageFlags = Object.keys(cliOptions) .filter(key => pageFlags.includes(key)); @@ -165,11 +201,6 @@ export async function go({ }); if (writeAll) { - await writeFavicon({ - mediaPath, - outputPath, - }); - await writeSharedFilesAndPages({ outputPath, randomLinkDataJSON: generateRandomLinkDataJSON({wikiData}), @@ -194,7 +225,7 @@ export async function go({ return null; } - const paths = []; + let paths = []; if (pageSpec.pathsTargetless) { const result = pageSpec.pathsTargetless({wikiData}); @@ -224,6 +255,30 @@ export async function go({ // TODO: Validate each pathsForTargets entry } + if (!empty(filterPaths)) { + paths = + paths.filter(path => + filterPaths.includes( + (path.type === 'page' + ? '/' + + getPagePathname({ + baseDirectory: '', + pagePath: path.path, + urls, + }) + : path.type === 'redirect' + ? '/' + + getPagePathname({ + baseDirectory: '', + pagePath: path.fromPath, + urls, + }) + : null))); + } + + paths = + paths.filter(path => path.condition?.() ?? true); + return paths; }) .filter(Boolean) @@ -321,7 +376,7 @@ export async function go({ language, pagePath, pagePathStringFromRoot: pathname, - to, + to: page.absoluteLinks ? absoluteTo : to, }); let pageHTML, oEmbedJSON; @@ -436,12 +491,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 +542,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 +646,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, |