diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/aggregate.js | 3 | ||||
-rw-r--r-- | src/util/cli.js | 73 | ||||
-rw-r--r-- | src/util/colors.js | 2 | ||||
-rw-r--r-- | src/util/html.js | 91 | ||||
-rw-r--r-- | src/util/search-spec.js | 259 | ||||
-rw-r--r-- | src/util/sort.js | 35 | ||||
-rw-r--r-- | src/util/sugar.js | 58 | ||||
-rw-r--r-- | src/util/wiki-data.js | 21 |
8 files changed, 511 insertions, 31 deletions
diff --git a/src/util/aggregate.js b/src/util/aggregate.js index 3ad8bdba..e8f45f3b 100644 --- a/src/util/aggregate.js +++ b/src/util/aggregate.js @@ -373,7 +373,10 @@ export function _withAggregate(mode, aggregateOpts, fn) { export const unhelpfulTraceLines = [ /sugar/, + /sort/, /aggregate/, + /composite/, + /cacheable-object/, /node:/, /<anonymous>/, ]; diff --git a/src/util/cli.js b/src/util/cli.js index ce513f08..72979d3f 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -201,6 +201,79 @@ export async function parseOptions(options, optionDescriptorMap) { return result; } +// Takes precisely the same sort of structure as `parseOptions` above, +// and displays associated help messages. Radical! +// +// 'indentWrap' should be the function from '#sugar', with its wrap option +// already bound. +// +// 'sort' should take care of sorting a list of {name, descriptor} entries. +export function showHelpForOptions({ + heading, + options, + indentWrap, + sort = entries => entries, +}) { + if (heading) { + console.log(colors.bright(heading)); + } + + const sortedOptions = + sort( + Object.entries(options) + .map(([name, descriptor]) => ({name, descriptor}))); + + if (!sortedOptions.length) { + console.log(`(No options available)`) + } + + let justInsertedPaddingLine = false; + + for (const {name, descriptor} of sortedOptions) { + if (descriptor.alias) { + continue; + } + + const aliases = + Object.entries(options) + .filter(([_name, {alias}]) => alias === name) + .map(([name]) => name); + + let wrappedHelp, wrappedHelpLines = 0; + if (descriptor.help) { + wrappedHelp = indentWrap(descriptor.help, {spaces: 4}); + wrappedHelpLines = wrappedHelp.split('\n').length; + } + + if (wrappedHelpLines > 0 && !justInsertedPaddingLine) { + console.log(''); + } + + console.log(colors.bright(` --` + name) + + (aliases.length + ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})` + : '') + + (descriptor.help + ? '' + : colors.dim(' (no help provided)'))); + + if (wrappedHelp) { + console.log(wrappedHelp); + } + + if (wrappedHelpLines > 1) { + console.log(''); + justInsertedPaddingLine = true; + } else { + justInsertedPaddingLine = false; + } + } + + if (!justInsertedPaddingLine) { + console.log(``); + } +} + export const handleDashless = Symbol(); export const handleUnknown = Symbol(); diff --git a/src/util/colors.js b/src/util/colors.js index 50339cd3..7298c46a 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -15,6 +15,7 @@ export function getColors(themeColor, { const deep = primary.saturate(1.2).luminance(0.035); const deepGhost = deep.alpha(0.8); const light = chroma.average(['#ffffff', primary], 'rgb', [4, 1]); + const lightGhost = primary.luminance(0.8).saturate(4).alpha(0.08); const bg = primary.luminance(0.008).desaturate(3.5).alpha(0.8); const bgBlack = primary.saturate(1).luminance(0.0025).alpha(0.8); @@ -31,6 +32,7 @@ export function getColors(themeColor, { deep: deep.hex(), deepGhost: deepGhost.hex(), light: light.hex(), + lightGhost: lightGhost.hex(), bg: bg.hex(), bgBlack: bgBlack.hex(), diff --git a/src/util/html.js b/src/util/html.js index 9e07f9ba..6efedb31 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -53,11 +53,18 @@ export const attributeSpec = { }, }; -// Pass to tag() as an attributes key to make tag() return a 8lank string if the +// 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. export const onlyIfContent = Symbol(); +// Pass to tag() as an attributes key to make tag() return a blank tag if +// this tag doesn't get shown beside any siblings! (I.e, siblings who don't +// also have the [html.onlyIfSiblings] attribute.) Since they'd just be blank, +// tags with [html.onlyIfSiblings] never make the difference in counting as +// content for [html.onlyIfContent]. Useful for <summary> and such. +export const onlyIfSiblings = Symbol(); + // Pass to tag() as an attributes key to make children be joined together by the // provided string. This is handy, for example, for joining lines by <br> tags, // or putting some other divider between each child. Note this will only have an @@ -124,13 +131,18 @@ function isBlankArrayHelper(content) { // content of tags marked onlyIfContent) into one array, // and templates into another. And if there's anything // else, that's a non-blank condition we'll detect now. + // We'll flat-out skip items marked onlyIfSiblings, + // since they could never count as content alone + // (some other item will have to count). const arrayContent = []; const templateContent = []; for (const item of nonStringContent) { if (item instanceof Tag) { - if (item.onlyIfContent || item.contentOnly) { + if (item.onlyIfSiblings) { + continue; + } else if (item.onlyIfContent || item.contentOnly) { arrayContent.push(item.content); } else { return false; @@ -205,9 +217,17 @@ 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) { - if (!template.blank) { - return false; + const content = template.content; + + if (content instanceof Tag && content.onlyIfSiblings) { + continue; + } + + if (isBlank(content)) { + continue; } + + return false; } // If none of the templates included content either, @@ -416,6 +436,10 @@ export class Tag { } get blank() { + // Tags don't have a reference to their parent, so this only evinces + // something about this tag's own content or attributes. It does *not* + // account for [html.onlyIfSiblings]! + if (this.onlyIfContent && isBlank(this.content)) { return true; } @@ -477,6 +501,14 @@ export class Tag { return this.#getAttributeFlag(onlyIfContent); } + set onlyIfSiblings(value) { + this.#setAttributeFlag(onlyIfSiblings, value); + } + + get onlyIfSiblings() { + return this.#getAttributeFlag(onlyIfSiblings); + } + set joinChildren(value) { this.#setAttributeString(joinChildren, value); } @@ -593,6 +625,8 @@ export class Tag { let content = ''; let blockwrapClosers = ''; + let seenSiblingIndependentContent = false; + const chunkwrapSplitter = (this.chunkwrap ? this.#getAttributeString('split') @@ -615,10 +649,12 @@ export class Tag { } for (const [index, item] of contentItems.entries()) { - let itemContent; + const nonTemplateItem = + Template.resolve(item); + let itemContent; try { - itemContent = item.toString(); + itemContent = nonTemplateItem.toString(); } catch (caughtError) { const indexPart = colors.yellow(`child #${index + 1}`); @@ -647,8 +683,12 @@ export class Tag { continue; } + if (!(nonTemplateItem instanceof Tag) || !nonTemplateItem.onlyIfSiblings) { + seenSiblingIndependentContent = true; + } + const chunkwrapChunks = - (typeof item === 'string' && chunkwrapSplitter + (typeof nonTemplateItem === 'string' && chunkwrapSplitter ? itemContent.split(chunkwrapSplitter) : null); @@ -658,28 +698,25 @@ export class Tag { : null); if (content) { - if (itemIncludesChunkwrapSplit) { - if (!seenChunkwrapSplitter) { - // The first time we see a chunkwrap splitter, backtrack and wrap - // the content *so far* in a chunk. - content = `<span class="chunkwrap">` + content; - } - - // Close the existing chunk. We'll add the new chunks after the - // (normal) joiner. - content += `</span>`; + if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) { + // The first time we see a chunkwrap splitter, backtrack and wrap + // the content *so far* in a chunk. This will be treated just like + // any other open chunkwrap, and closed after the first chunk of + // this item! (That means the existing content is part of the same + // chunk as the first chunk included in this content, which makes + // sense, because that first chink is really just more text that + // precedes the first split.) + content = `<span class="chunkwrap">` + content; } content += joiner; - } else { + } else if (itemIncludesChunkwrapSplit) { // We've encountered a chunkwrap split before any other content. // This means there's no content to wrap, no existing chunkwrap // to close, and no reason to add a joiner, but we *do* need to // enter a chunkwrap wrapper *now*, so the first chunk of this // item will be properly wrapped. - if (itemIncludesChunkwrapSplit) { - content = `<span class="chunkwrap">`; - } + content = `<span class="chunkwrap">`; } if (itemIncludesChunkwrapSplit) { @@ -691,7 +728,7 @@ export class Tag { // itemContent check. They also never apply at the very start of content, // because at that point there aren't any preceding words from which the // blockwrap would differentiate its content. - if (item instanceof Tag && item.blockwrap && content) { + if (nonTemplateItem instanceof Tag && nonTemplateItem.blockwrap && content) { content += `<span class="blockwrap">`; blockwrapClosers += `</span>`; } @@ -700,6 +737,10 @@ export class Tag { if (itemIncludesChunkwrapSplit) { for (const [index, chunk] 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 + // close this chunk as the first appended content as we process + // the next chunk. content += chunk; } else { const whitespace = chunk.match(/^\s+/) ?? ''; @@ -718,6 +759,12 @@ export class Tag { } } + // If we've only seen sibling-dependent content (or just no content), + // then the content in total is blank. + if (!seenSiblingIndependentContent) { + return ''; + } + if (chunkwrapSplitter) { if (seenChunkwrapSplitter) { content += '</span>'; diff --git a/src/util/search-spec.js b/src/util/search-spec.js new file mode 100644 index 00000000..bc24e1a1 --- /dev/null +++ b/src/util/search-spec.js @@ -0,0 +1,259 @@ +// 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/util/sort.js b/src/util/sort.js index b3a90812..ea1e024a 100644 --- a/src/util/sort.js +++ b/src/util/sort.js @@ -388,7 +388,8 @@ export function sortFlashesChronologically(data, { getDate, } = {}) { // Group flashes by act... - sortByDirectory(data, { + sortAlphabetically(data, { + getName: flash => flash.act.name, getDirectory: flash => flash.act.directory, }); @@ -403,3 +404,35 @@ export function sortFlashesChronologically(data, { return data; } + +export function sortContributionsChronologically(data, sortThings, { + latestFirst = false, +} = {}) { + // Contributions only have one date property (which is provided when + // the contribution is created). They're sorted by this most primarily, + // but otherwise use the same sort as is provided. + + const entries = + data.map(contrib => ({ + entry: contrib, + thing: contrib.thing, + })); + + sortEntryThingPairs( + entries, + things => + sortThings(things, {latestFirst})); + + const contribs = + entries + .map(({entry: contrib}) => contrib); + + sortByDate(contribs, {latestFirst}); + + // We're not actually operating on the original data array at any point, + // so since this is meant to be a mutating function like any other, splice + // the sorted contribs into the original array. + data.splice(0, data.length, ...contribs); + + return data; +} diff --git a/src/util/sugar.js b/src/util/sugar.js index e060f458..3fa3fb46 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -136,6 +136,23 @@ export function stitchArrays(keyToArray) { return results; } +// Like Map.groupBy! Collects the items of an unsorted array into buckets +// according to a per-item computed value. +export function groupArray(items, fn) { + const buckets = new Map(); + + for (const [index, item] of Array.prototype.entries.call(items)) { + const key = fn(item, index); + if (buckets.has(key)) { + buckets.get(key).push(item); + } else { + buckets.set(key, [item]); + } + } + + return buckets; +} + // Turns this: // // [ @@ -183,8 +200,14 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => : arr1.every((x) => arr2.includes(x))); // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice. -export const withEntries = (obj, fn) => - Object.fromEntries(fn(Object.entries(obj))); +export const withEntries = (obj, fn) => { + const result = fn(Object.entries(obj)); + if (result instanceof Promise) { + return result.then(entries => Object.fromEntries(entries)); + } else { + return Object.fromEntries(result); + } +} export function setIntersection(set1, set2) { const intersection = new Set(); @@ -260,6 +283,16 @@ export function delay(ms) { return new Promise((res) => setTimeout(res, ms)); } +export function promiseWithResolvers() { + let obj = {}; + + obj.promise = + new Promise((...opts) => + ([obj.resolve, obj.reject] = opts)); + + return obj; +} + // Stolen from here: https://stackoverflow.com/a/3561711 // // There's a proposal for a native JS function like this, 8ut it's not even @@ -315,6 +348,27 @@ export function cutStart(text, length = 40) { } } +// Wrapper function around wrap(), ha, ha - this requires the Node module +// 'node-wrap'. +export function indentWrap(str, { + wrap, + spaces = 0, + width = 60, + bullet = false, +}) { + const wrapped = + wrap(str, { + width: width - spaces, + indent: ' '.repeat(spaces), + }); + + if (bullet) { + return wrapped.trimStart(); + } else { + return wrapped; + } +} + // Annotates {index, length} results from another iterator with contextual // details, including: // diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index f8ab3ef3..c0cb5418 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -1,6 +1,6 @@ // Utility functions for interacting with wiki data. -import {accumulateSum, empty} from './sugar.js'; +import {accumulateSum, empty, unique} from './sugar.js'; import {sortByDate} from './sort.js'; // This is a duplicate binding of filterMultipleArrays that's included purely @@ -138,11 +138,20 @@ export function getAllTracks(albumData) { } export function getArtistNumContributions(artist) { - return ( - (artist.tracksAsAny?.length ?? 0) + - (artist.albumsAsCoverArtist?.length ?? 0) + - (artist.flashesAsContributor?.length ?? 0) - ); + return accumulateSum( + [ + unique( + ([ + artist.trackArtistContributions, + artist.trackContributorContributions, + artist.trackCoverArtistContributions, + ]).flat() + .map(({thing}) => thing)), + + artist.albumCoverArtistContributions, + artist.flashContributorContributions, + ], + ({length}) => length); } export function getFlashCover(flash, {to}) { |