diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/aggregate.js | 110 | ||||
-rw-r--r-- | src/util/cli.js | 73 | ||||
-rw-r--r-- | src/util/colors.js | 2 | ||||
-rw-r--r-- | src/util/external-links.js | 12 | ||||
-rw-r--r-- | src/util/html.js | 98 | ||||
-rw-r--r-- | src/util/search-spec.js | 259 | ||||
-rw-r--r-- | src/util/serialize.js | 6 | ||||
-rw-r--r-- | src/util/sort.js | 3 | ||||
-rw-r--r-- | src/util/sugar.js | 58 |
9 files changed, 583 insertions, 38 deletions
diff --git a/src/util/aggregate.js b/src/util/aggregate.js index f0023359..e8f45f3b 100644 --- a/src/util/aggregate.js +++ b/src/util/aggregate.js @@ -91,6 +91,46 @@ export function openAggregate({ return aggregate.callAsync(() => withAggregateAsync(...args)); }; + aggregate.receive = (results) => { + if (!Array.isArray(results)) { + if (typeof results === 'object' && results.aggregate) { + const {aggregate, result} = results; + + try { + aggregate.close(); + } catch (error) { + errors.push(error); + } + + return result; + } + + throw new Error(`Expected an array or {aggregate, result} object`); + } + + return results.map(({aggregate, result}) => { + if (!aggregate) { + console.log('nope:', results); + throw new Error(`Expected an array of {aggregate, result} objects`); + } + + try { + aggregate.close(); + } catch (error) { + errors.push(error); + } + + return result; + }); + }; + + aggregate.contain = (results) => { + return { + aggregate, + result: aggregate.receive(results), + }; + }; + aggregate.map = (...args) => { const parent = aggregate; const {result, aggregate: child} = mapAggregate(...args); @@ -136,18 +176,33 @@ export function aggregateThrows(errorClass) { return {[openAggregate.errorClassSymbol]: errorClass}; } -// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn) -// in aggregate utilities. -function _reorganizeAggregateArguments(arg1, arg2) { - if (typeof arg1 === 'function') { - return {fn: arg1, opts: arg2 ?? {}}; - } else if (typeof arg2 === 'function') { - return {fn: arg2, opts: arg1 ?? {}}; +// Helper function for allowing both (fn, opts) and (opts, fn) in aggregate +// utilities (or other shapes besides functions). +function _reorganizeAggregateArguments(arg1, arg2, desire = v => typeof v === 'function') { + if (desire(arg1)) { + return [arg1, arg2 ?? {}]; + } else if (desire(arg2)) { + return [arg2, arg1]; } else { - throw new Error(`Expected a function`); + return [undefined, undefined]; } } +// Takes a list of {aggregate, result} objects, puts all the aggregates into +// a new aggregate, and puts all the results into an array, returning both on +// a new {aggregate, result} object. This is essentailly the generalized +// composable version of functions like mapAggregate or filterAggregate. +export function receiveAggregate(arg1, arg2) { + const [array, opts] = _reorganizeAggregateArguments(arg1, arg2, Array.isArray); + if (!array) { + throw new Error(`Expected an array`); + } + + const aggregate = openAggregate(opts); + const result = aggregate.receive(array); + return {aggregate, result}; +} + // Performs an ordinary array map with the given function, collating into a // results array (with errored inputs filtered out) and an error aggregate. // @@ -158,12 +213,20 @@ function _reorganizeAggregateArguments(arg1, arg2) { // use aggregate.close() to throw the error. (This aggregate may be passed to a // parent aggregate: `parent.call(aggregate.close)`!) export function mapAggregate(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + return _mapAggregate('sync', null, array, fn, opts); } export function mapAggregateAsync(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; return _mapAggregate('async', promiseAll, array, fn, remainingOpts); } @@ -200,12 +263,20 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) { // // As with mapAggregate, the returned aggregate property is not yet closed. export function filterAggregate(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + return _filterAggregate('sync', null, array, fn, opts); } export async function filterAggregateAsync(array, arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; return _filterAggregate('async', promiseAll, array, fn, remainingOpts); } @@ -268,12 +339,20 @@ function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) { // function with it, then closing the function and returning the result (if // there's no throw). export function withAggregate(arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + return _withAggregate('sync', opts, fn); } export function withAggregateAsync(arg1, arg2) { - const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2); + if (!fn) { + throw new Error(`Expected a function`); + } + return _withAggregate('async', opts, fn); } @@ -294,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/external-links.js b/src/util/external-links.js index 3b779afc..a616efb3 100644 --- a/src/util/external-links.js +++ b/src/util/external-links.js @@ -211,6 +211,18 @@ export const externalLinkSpec = [ // Generic domains, sorted alphabetically (by string) { + match: { + domains: [ + 'music.amazon.co.jp', + 'music.amazon.com', + ], + }, + + platform: 'amazonMusic', + icon: 'globe', + }, + + { match: {domain: 'music.apple.com'}, platform: 'appleMusic', icon: 'appleMusic', diff --git a/src/util/html.js b/src/util/html.js index d1d509e2..6e892031 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; @@ -416,6 +428,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 +493,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 +617,8 @@ export class Tag { let content = ''; let blockwrapClosers = ''; + let seenSiblingIndependentContent = false; + const chunkwrapSplitter = (this.chunkwrap ? this.#getAttributeString('split') @@ -647,6 +673,10 @@ export class Tag { continue; } + if (!(item instanceof Tag && item.onlyIfSiblings)) { + seenSiblingIndependentContent = true; + } + const chunkwrapChunks = (typeof item === 'string' && chunkwrapSplitter ? itemContent.split(chunkwrapSplitter) @@ -658,28 +688,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) { @@ -700,6 +727,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 +749,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>'; @@ -1101,8 +1138,17 @@ export class Attributes { return this.#attributes[attribute]; } - has(attribute) { - return attribute in this.#attributes; + has(attribute, pattern) { + if (typeof pattern === 'undefined') { + return attribute in this.#attributes; + } else if (this.has(attribute)) { + const value = this.get(attribute); + if (Array.isArray(value)) { + return value.includes(pattern); + } else { + return value === pattern; + } + } } remove(attribute) { @@ -1338,6 +1384,22 @@ export function smush(smushee) { return smush(Tag.normalize(smushee)); } +// Much gentler version of smush - this only flattens nested html.tags(), and +// guarantees the result is itself an html.tags(). It doesn't manipulate text +// content, and it doesn't resolve templates. +export function smooth(smoothie) { + // Helper function to avoid intermediate html.tags() calls. + function helper(tag) { + if (tag instanceof Tag && tag.contentOnly) { + return tag.content.flatMap(helper); + } else { + return tag; + } + } + + return tags(helper(smoothie)); +} + export function template(description) { return new Template(description); } 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/serialize.js b/src/util/serialize.js index 4992e2bf..eb18a759 100644 --- a/src/util/serialize.js +++ b/src/util/serialize.js @@ -14,10 +14,10 @@ export function serializeLink(thing) { } export function serializeContribs(contribs) { - return contribs.map(({who, what}) => { + return contribs.map(({artist, annotation}) => { const ret = {}; - ret.artist = serializeLink(who); - if (what) ret.contribution = what; + ret.artist = serializeLink(artist); + if (annotation) ret.contribution = annotation; return ret; }); } diff --git a/src/util/sort.js b/src/util/sort.js index b3a90812..9e9de641 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, }); 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: // |