From 725a33ef50e836553c89a6576f5d281978fe7c47 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 26 Jan 2025 17:28:01 -0400 Subject: util -> common-util --- package.json | 10 +- src/common-util/colors.js | 44 +++ src/common-util/search-spec.js | 259 +++++++++++++ src/common-util/serialize.js | 77 ++++ src/common-util/sort.js | 438 +++++++++++++++++++++ src/common-util/sugar.js | 845 +++++++++++++++++++++++++++++++++++++++++ src/common-util/wiki-data.js | 475 +++++++++++++++++++++++ src/util/colors.js | 44 --- src/util/search-spec.js | 259 ------------- src/util/serialize.js | 77 ---- src/util/sort.js | 438 --------------------- src/util/sugar.js | 845 ----------------------------------------- src/util/wiki-data.js | 475 ----------------------- src/web-routes.js | 2 +- 14 files changed, 2144 insertions(+), 2144 deletions(-) create mode 100644 src/common-util/colors.js create mode 100644 src/common-util/search-spec.js create mode 100644 src/common-util/serialize.js create mode 100644 src/common-util/sort.js create mode 100644 src/common-util/sugar.js create mode 100644 src/common-util/wiki-data.js delete mode 100644 src/util/colors.js delete mode 100644 src/util/search-spec.js delete mode 100644 src/util/serialize.js delete mode 100644 src/util/sort.js delete mode 100644 src/util/sugar.js delete mode 100644 src/util/wiki-data.js diff --git a/package.json b/package.json index b5d8a298..73bd40a1 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "imports": { "#aggregate": "./src/aggregate.js", "#cacheable-object": "./src/data/cacheable-object.js", - "#colors": "./src/util/colors.js", + "#colors": "./src/common-util/colors.js", "#composite": "./src/data/composite.js", "#composite/control-flow": "./src/data/composite/control-flow/index.js", "#composite/data": "./src/data/composite/data/index.js", @@ -42,10 +42,10 @@ "#replacer": "./src/replacer.js", "#reverse": "./src/reverse.js", "#search": "./src/search.js", - "#search-spec": "./src/util/search-spec.js", + "#search-spec": "./src/common-util/search-spec.js", "#serialize": "./src/data/serialize.js", - "#sort": "./src/util/sort.js", - "#sugar": "./src/util/sugar.js", + "#sort": "./src/common-util/sort.js", + "#sugar": "./src/common-util/sugar.js", "#test-lib": "./test/lib/index.js", "#thing": "./src/data/thing.js", "#things": "./src/data/things/index.js", @@ -53,7 +53,7 @@ "#urls": "./src/urls.js", "#validators": "./src/validators.js", "#web-routes": "./src/web-routes.js", - "#wiki-data": "./src/util/wiki-data.js", + "#wiki-data": "./src/common-util/wiki-data.js", "#yaml": "./src/data/yaml.js" }, "engines": { diff --git a/src/common-util/colors.js b/src/common-util/colors.js new file mode 100644 index 00000000..7298c46a --- /dev/null +++ b/src/common-util/colors.js @@ -0,0 +1,44 @@ +// Color and theming utility functions! Handy. + +export function getColors(themeColor, { + // chroma.js external dependency (https://gka.github.io/chroma.js/) + chroma, +} = {}) { + if (!chroma) { + throw new Error('chroma.js library must be passed or bound for color manipulation'); + } + + const primary = chroma(themeColor); + + const dark = primary.luminance(0.02); + const dim = primary.desaturate(2).darken(1.5); + 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); + const shadow = primary.desaturate(4).set('hsl.l', 0.05).alpha(0.8); + + const hsl = primary.hsl(); + if (isNaN(hsl[0])) hsl[0] = 0; + + return { + primary: primary.hex(), + + dark: dark.hex(), + dim: dim.hex(), + deep: deep.hex(), + deepGhost: deepGhost.hex(), + light: light.hex(), + lightGhost: lightGhost.hex(), + + bg: bg.hex(), + bgBlack: bgBlack.hex(), + shadow: shadow.hex(), + + rgb: primary.rgb(), + hsl, + }; +} diff --git a/src/common-util/search-spec.js b/src/common-util/search-spec.js new file mode 100644 index 00000000..3d05c021 --- /dev/null +++ b/src/common-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 = + (thing.constructor.hasPropertyDescriptor('artTags') + ? thing.artTags.map(artTag => artTag.nameShort) + : []); + + fields.additionalNames = + (thing.constructor.hasPropertyDescriptor('additionalNames') + ? thing.additionalNames.map(entry => entry.name) + : thing.constructor.hasPropertyDescriptor('aliasNames') + ? thing.aliasNames + : []); + + const contribKeys = [ + 'artistContribs', + '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/common-util/serialize.js b/src/common-util/serialize.js new file mode 100644 index 00000000..eb18a759 --- /dev/null +++ b/src/common-util/serialize.js @@ -0,0 +1,77 @@ +// Utils used when per-wiki-object data files. +// Retained for reference and/or later reorganization. +// +// Not to be confused with data/serialize.js, which provides a generic +// interface for serializing any Thing object. + +/* +export function serializeLink(thing) { + const ret = {}; + ret.name = thing.name; + ret.directory = thing.directory; + if (thing.color) ret.color = thing.color; + return ret; +} + +export function serializeContribs(contribs) { + return contribs.map(({artist, annotation}) => { + const ret = {}; + ret.artist = serializeLink(artist); + if (annotation) ret.contribution = annotation; + return ret; + }); +} + +export function serializeImagePaths(original, {thumb}) { + return { + original, + medium: thumb.medium(original), + small: thumb.small(original), + }; +} + +export function serializeCover(thing, pathFunction, { + serializeImagePaths, + urls, +}) { + const coverPath = pathFunction(thing, { + to: urls.from('media.root').to, + }); + + const {artTags} = thing; + + const cwTags = artTags.filter((tag) => tag.isContentWarning); + const linkTags = artTags.filter((tag) => !tag.isContentWarning); + + return { + paths: serializeImagePaths(coverPath), + tags: linkTags.map(serializeLink), + warnings: cwTags.map((tag) => tag.name), + }; +} + +export function serializeGroupsForAlbum(album, {serializeLink}) { + return album.groups + .map((group) => { + const index = group.albums.indexOf(album); + const next = group.albums[index + 1] || null; + const previous = group.albums[index - 1] || null; + return {group, index, next, previous}; + }) + .map(({group, index, next, previous}) => ({ + link: serializeLink(group), + descriptionShort: group.descriptionShort, + albumIndex: index, + nextAlbum: next && serializeLink(next), + previousAlbum: previous && serializeLink(previous), + urls: group.urls, + })); +} + +export function serializeGroupsForTrack(track, {serializeLink}) { + return track.album.groups.map((group) => ({ + link: serializeLink(group), + urls: group.urls, + })); +} +*/ diff --git a/src/common-util/sort.js b/src/common-util/sort.js new file mode 100644 index 00000000..ea1e024a --- /dev/null +++ b/src/common-util/sort.js @@ -0,0 +1,438 @@ +// Sorting functions - all utils here are mutating, so make sure to initially +// slice/filter/somehow generate a new array from input data if retaining the +// initial sort matters! (Spoilers: If what you're doing involves any kind of +// parallelization, it definitely matters.) + +import {empty, sortMultipleArrays, unique} + from './sugar.js'; + +// General sorting utilities! These don't do any sorting on their own but are +// handy in the sorting functions below (or if you're making your own sort). + +export function compareCaseLessSensitive(a, b) { + // Compare two strings without considering capitalization... unless they + // happen to be the same that way. + + const al = a.toLowerCase(); + const bl = b.toLowerCase(); + + return al === bl + ? a.localeCompare(b, undefined, {numeric: true}) + : al.localeCompare(bl, undefined, {numeric: true}); +} + +// Subtract common prefixes and other characters which some people don't like +// to have considered while sorting. The words part of this is English-only for +// now, which is totally evil. +export function normalizeName(s) { + // Turn (some) ligatures into expanded variant for cleaner sorting, e.g. + // "ff" into "ff", in decompose mode, so that "ü" is represented as two + // bytes ("u" + \u0308 combining diaeresis). + s = s.normalize('NFKD'); + + // Replace one or more whitespace of any kind in a row, as well as certain + // punctuation, with a single typical space, then trim the ends. + s = s + .replace( + /[\p{Separator}\p{Dash_Punctuation}\p{Connector_Punctuation}]+/gu, + ' ' + ) + .trim(); + + // Discard anything that isn't a letter, number, or space. + s = s.replace(/[^\p{Letter}\p{Number} ]/gu, '').trim(); + + // Remove common English (only, for now) prefixes. + s = s.replace(/^(?:an?|the) /i, ''); + + return s; +} + +// Component sort functions - these sort by one particular property, applying +// unique particulars where appropriate. Usually you don't want to use these +// directly, but if you're making a custom sort they can come in handy. + +// Universal method for sorting things into a predictable order, as directory +// is taken to be unique. There are two exceptions where this function (and +// thus any of the composite functions that start with it) *can't* be taken as +// deterministic: +// +// 1) Mixed data of two different Things, as directories are only taken as +// unique within one given class of Things. For example, this function +// won't be deterministic if its array contains both and +// . +// +// 2) Duplicate directories, or multiple instances of the "same" Thing. +// This function doesn't differentiate between two objects of the same +// directory, regardless of any other properties or the overall "identity" +// of the object. +// +// These exceptions are unavoidable except for not providing that kind of data +// in the first place, but you can still ensure the overall program output is +// deterministic by ensuring the input is arbitrarily sorted according to some +// other criteria - ex, although sortByDirectory itself isn't determinstic when +// given mixed track and album data, the final output (what goes on the site) +// will always be the same if you're doing sortByDirectory([...albumData, +// ...trackData]), because the initial sort places albums before tracks - and +// sortByDirectory will handle the rest, given all directories are unique +// except when album and track directories overlap with each other. +export function sortByDirectory(data, { + getDirectory = object => object.directory, +} = {}) { + const directories = data.map(getDirectory); + + sortMultipleArrays(data, directories, + (a, b, directoryA, directoryB) => + compareCaseLessSensitive(directoryA, directoryB)); + + return data; +} + +export function sortByName(data, { + getName = object => object.name, +} = {}) { + const names = data.map(getName); + const normalizedNames = names.map(normalizeName); + + sortMultipleArrays(data, normalizedNames, names, + ( + a, b, + normalizedA, normalizedB, + nonNormalizedA, nonNormalizedB, + ) => + compareNormalizedNames( + normalizedA, normalizedB, + nonNormalizedA, nonNormalizedB, + )); + + return data; +} + +export function compareNormalizedNames( + normalizedA, normalizedB, + nonNormalizedA, nonNormalizedB, +) { + const comparison = compareCaseLessSensitive(normalizedA, normalizedB); + return ( + (comparison === 0 + ? compareCaseLessSensitive(nonNormalizedA, nonNormalizedB) + : comparison)); +} + +export function sortByDate(data, { + getDate = object => object.date, + latestFirst = false, +} = {}) { + const dates = data.map(getDate); + + sortMultipleArrays(data, dates, + (a, b, dateA, dateB) => + compareDates(dateA, dateB, {latestFirst})); + + return data; +} + +export function compareDates(a, b, { + latestFirst = false, +} = {}) { + if (a && b) { + return (latestFirst ? b - a : a - b); + } + + // It's possible for objects with and without dates to be mixed + // together in the same array. If that's the case, we put all items + // without dates at the end. + if (a) return -1; + if (b) return 1; + + // If neither of the items being compared have a date, don't move + // them relative to each other. This is basically the same as + // filtering out all non-date items and then pushing them at the + // end after sorting the rest. + return 0; +} + +export function getLatestDate(dates) { + const filtered = dates.filter(Boolean); + if (empty(filtered)) return null; + + return filtered + .reduce( + (accumulator, date) => + date > accumulator ? date : accumulator, + -Infinity); +} + +export function getEarliestDate(dates) { + const filtered = dates.filter(Boolean); + if (empty(filtered)) return null; + + return filtered + .reduce( + (accumulator, date) => + date < accumulator ? date : accumulator, + Infinity); +} + +// Funky sort which takes a data set and a corresponding list of "counts", +// which are really arbitrary numbers representing some property of each data +// object defined by the caller. It sorts and mutates *both* of these, so the +// sorted data will still correspond to the same indexed count. +export function sortByCount(data, counts, { + greatestFirst = false, +} = {}) { + sortMultipleArrays(data, counts, (data1, data2, count1, count2) => + (greatestFirst + ? count2 - count1 + : count1 - count2)); + + return data; +} + +export function sortByPositionInParent(data, { + getParent, + getChildren, +}) { + return data.sort((a, b) => { + const parentA = getParent(a); + const parentB = getParent(b); + + // Don't change the sort when the two items are from separate parents. + // This function doesn't change the order of parents or try to "merge" + // two separated chunks of items from the same parent together. + if (parentA !== parentB) { + return 0; + } + + // Don't change the sort when either (or both) of the items doesn't + // even have a parent (e.g. it's the passed data is a mixed array of + // children and parents). + if (!parentA || !parentB) { + return 0; + } + + const indexA = getChildren(parentA).indexOf(a); + const indexB = getChildren(parentB).indexOf(b); + + // If the getParent/getChildren relationship doesn't go both ways for + // some reason, don't change the sort. + if (indexA === -1 || indexB === -1) { + return 0; + } + + return indexA - indexB; + }); +} + +export function sortByPositionInAlbum(data) { + return sortByPositionInParent(data, { + getParent: track => track.album, + getChildren: album => album.tracks, + }); +} + +export function sortByPositionInFlashAct(data) { + return sortByPositionInParent(data, { + getParent: flash => flash.act, + getChildren: act => act.flashes, + }); +} + +// Sorts data so that items are grouped together according to whichever of a +// set of arbitrary given conditions is true first. If no conditions are met +// for a given item, it's moved over to the end! +export function sortByConditions(data, conditions) { + return data.sort((a, b) => { + const ai = conditions.findIndex((f) => f(a)); + const bi = conditions.findIndex((f) => f(b)); + + if (ai >= 0 && bi >= 0) { + return ai - bi; + } else if (ai >= 0) { + return -1; + } else if (bi >= 0) { + return 1; + } else { + return 0; + } + }); +} + +// Composite sorting functions - these consider multiple properties, generally +// always returning the same output regardless of how the input was originally +// sorted (or left unsorted). If you're working with arbitrarily sorted inputs +// (typically wiki data, either in full or unsorted filter), these make sure +// what gets put on the actual website (or wherever) is deterministic. Also +// they're just handy sorting utilities. +// +// Note that because these are each comprised of multiple component sorting +// functions, they expect more than just one property to be present for full +// sorting (listed above each function). If you're mapping thing objects to +// another representation, try to include all of these listed properties. + +// Expects thing properties: +// * directory (or override getDirectory) +// * name (or override getName) +export function sortAlphabetically(data, { + getDirectory, + getName, +} = {}) { + sortByDirectory(data, {getDirectory}); + sortByName(data, {getName}); + return data; +} + +// Expects thing properties: +// * directory (or override getDirectory) +// * name (or override getName) +// * date (or override getDate) +export function sortChronologically(data, { + latestFirst = false, + getDirectory, + getName, + getDate, +} = {}) { + sortAlphabetically(data, {getDirectory, getName}); + sortByDate(data, {latestFirst, getDate}); + return data; +} + +// This one's a little odd! Sorts an array of {entry, thing} pairs using +// the provided sortFunction, which will operate on each item's `thing`, not +// its entry (or the item as a whole). If multiple entries are associated +// with the same thing, they'll end up bunched together in the output, +// retaining their original relative positioning. +export function sortEntryThingPairs(data, sortFunction) { + const things = unique(data.map(item => item.thing)); + sortFunction(things); + + const outputArrays = []; + const thingToOutputArray = new Map(); + + for (const thing of things) { + const array = []; + thingToOutputArray.set(thing, array); + outputArrays.push(array); + } + + for (const item of data) { + thingToOutputArray.get(item.thing).push(item); + } + + data.splice(0, data.length, ...outputArrays.flat()); + + return data; +} + +/* +// Alternate draft version of sortEntryThingPairs. +// See: https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607412168 + +// Maps the provided "preparation" function across a list of arbitrary values, +// building up a list of sortable values; sorts these with the provided sorting +// function; and reorders the sources to match their corresponding prepared +// values. As usual, if multiple source items correspond to the same sorting +// data, this retains the source relative positioning. +export function prepareAndSort(sources, prepareForSort, sortFunction) { + const prepared = []; + const preparedToSource = new Map(); + + for (const original of originals) { + const prep = prepareForSort(source); + prepared.push(prep); + preparedToSource.set(prep, source); + } + + sortFunction(prepared); + + sources.splice(0, ...sources.length, prepared.map(prep => preparedToSource.get(prep))); + + return sources; +} +*/ + +// Highly contextual sort functions - these are only for very specific types +// of Things, and have appropriately hard-coded behavior. + +// Sorts so that tracks from the same album are generally grouped together in +// their original (album track list) order, while prioritizing date (by default +// release date but can be overridden) above all else. +// +// This function also works for data lists which contain only tracks. +export function sortAlbumsTracksChronologically(data, { + latestFirst = false, + getDate, +} = {}) { + // Sort albums before tracks... + sortByConditions(data, [(t) => t.album === undefined]); + + // Group tracks by album... + sortByDirectory(data, { + getDirectory: (t) => (t.album ? t.album.directory : t.directory), + }); + + // Sort tracks by position in album... + sortByPositionInAlbum(data); + + // ...and finally sort by date. If tracks from more than one album were + // released on the same date, they'll still be grouped together by album, + // and tracks within an album will retain their relative positioning (i.e. + // stay in the same order as part of the album's track listing). + sortByDate(data, {latestFirst, getDate}); + + return data; +} + +export function sortFlashesChronologically(data, { + latestFirst = false, + getDate, +} = {}) { + // Group flashes by act... + sortAlphabetically(data, { + getName: flash => flash.act.name, + getDirectory: flash => flash.act.directory, + }); + + // Sort flashes by position in act... + sortByPositionInFlashAct(data); + + // ...and finally sort by date. If flashes from more than one act were + // released on the same date, they'll still be grouped together by act, + // and flashes within an act will retain their relative positioning (i.e. + // stay in the same order as the act's flash listing). + sortByDate(data, {latestFirst, getDate}); + + 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/common-util/sugar.js b/src/common-util/sugar.js new file mode 100644 index 00000000..90d47b7c --- /dev/null +++ b/src/common-util/sugar.js @@ -0,0 +1,845 @@ +// Syntactic sugar! (Mostly.) +// Generic functions - these are useful just a8out everywhere. +// +// Friendly(!) disclaimer: these utility functions haven't 8een tested all that +// much. Do not assume it will do exactly what you want it to do in all cases. +// It will likely only do exactly what I want it to, and only in the cases I +// decided were relevant enough to 8other handling. + +// 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 +// actually use this. 8ut it's still awesome, 8ecause I say so. +export function* splitArray(array, fn) { + let lastIndex = 0; + while (lastIndex < array.length) { + let nextIndex = array.findIndex((item, index) => index >= lastIndex && fn(item)); + if (nextIndex === -1) { + nextIndex = array.length; + } + yield array.slice(lastIndex, nextIndex); + // Plus one because we don't want to include the dividing line in the + // next array we yield. + lastIndex = nextIndex + 1; + } +} + +// Null-accepting function to check if an array or set is empty. Accepts null +// (which is treated as empty) as a shorthand for "hey, check if this property +// is an array with/without stuff in it" for objects where properties that are +// PRESENT but don't currently have a VALUE are null (rather than undefined). +export function empty(value) { + if (value === null) { + return true; + } + + if (Array.isArray(value)) { + return value.length === 0; + } + + if (value instanceof Set) { + return value.size === 0; + } + + throw new Error(`Expected array, set, or null`); +} + +// Repeats all the items of an array a number of times. +export function repeat(times, array) { + if (times === 0) return []; + if (array === null || array === undefined) return []; + if (Array.isArray(array) && empty(array)) return []; + + const out = []; + + for (let n = 1; n <= times; n++) { + const value = + (typeof array === 'function' + ? array() + : array); + + if (Array.isArray(value)) out.push(...value); + else out.push(value); + } + + return out; +} + +// Gets a random item from an array. +export function pick(array) { + return array[Math.floor(Math.random() * array.length)]; +} + +// Gets the item at an index relative to another index. +export function atOffset(array, index, offset, { + wrap = false, + valuePastEdge = null, +} = {}) { + if (index === -1) { + return valuePastEdge; + } + + if (offset === 0) { + return array[index]; + } + + if (wrap) { + return array[(index + offset) % array.length]; + } + + if (offset > 0 && index + offset > array.length - 1) { + return valuePastEdge; + } + + if (offset < 0 && index + offset < 0) { + return valuePastEdge; + } + + return array[index + offset]; +} + +// Gets the index of the first item that satisfies the provided function, +// or, if none does, returns the length of the array (the index just past the +// final item). +export function findIndexOrEnd(array, fn) { + const index = array.findIndex(fn); + if (index >= 0) { + return index; + } else { + return array.length; + } +} + +// Sums the values in an array, optionally taking a function which maps each +// item to a number (handy for accessing a certain property on an array of like +// objects). This also coalesces null values to zero, so if the mapping function +// returns null (or values in the array are nullish), they'll just be skipped in +// the sum. +export function accumulateSum(array, fn = x => x) { + return array.reduce( + (accumulator, value, index, array) => + accumulator + + fn(value, index, array) ?? 0, + 0); +} + +// Stitches together the items of separate arrays into one array of objects +// whose keys are the corresponding items from each array at that index. +// This is mostly useful for iterating over multiple arrays at once! +export function stitchArrays(keyToArray) { + const errors = []; + + for (const [key, value] of Object.entries(keyToArray)) { + if (value === null) continue; + if (Array.isArray(value)) continue; + errors.push(new TypeError(`(${key}) Expected array or null, got ${typeAppearance(value)}`)); + } + + if (!empty(errors)) { + throw new AggregateError(errors, `Expected arrays or null`); + } + + const keys = Object.keys(keyToArray); + const arrays = Object.values(keyToArray).filter(val => Array.isArray(val)); + const length = Math.max(...arrays.map(({length}) => length)); + const results = []; + + for (let i = 0; i < length; i++) { + const object = {}; + for (const key of keys) { + object[key] = + (Array.isArray(keyToArray[key]) + ? keyToArray[key][i] + : null); + } + results.push(object); + } + + 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: +// +// [ +// [123, 'orange', null], +// [456, 'apple', true], +// [789, 'banana', false], +// [1000, 'pear', undefined], +// ] +// +// Into this: +// +// [ +// [123, 456, 789, 1000], +// ['orange', 'apple', 'banana', 'pear'], +// [null, true, false, undefined], +// ] +// +// And back again, if you call it again on its results. +export function transposeArrays(arrays) { + if (empty(arrays)) { + return []; + } + + const length = arrays[0].length; + const results = new Array(length).fill(null).map(() => []); + + for (const array of arrays) { + for (let i = 0; i < length; i++) { + results[i].push(array[i]); + } + } + + return results; +} + +export const mapInPlace = (array, fn) => + array.splice(0, array.length, ...array.map(fn)); + +export const unique = (arr) => Array.from(new Set(arr)); + +export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => + arr1.length === arr2.length && + (checkOrder + ? arr1.every((x, i) => arr2[i] === x) + : arr1.every((x) => arr2.includes(x))); + +export function compareObjects(obj1, obj2, { + checkOrder = false, + checkSymbols = true, +} = {}) { + const keys1 = Object.keys(obj1); + const keys2 = Object.keys(obj2); + if (!compareArrays(keys1, keys2, {checkOrder})) return false; + + let syms1, syms2; + if (checkSymbols) { + syms1 = Object.getOwnPropertySymbols(obj1); + syms2 = Object.getOwnPropertySymbols(obj2); + if (!compareArrays(syms1, syms2, {checkOrder})) return false; + } + + for (const key of keys1) { + if (obj2[key] !== obj1[key]) return false; + } + + if (checkSymbols) { + for (const sym of syms1) { + if (obj2[sym] !== obj1[sym]) return false; + } + } + + return true; +} + +// 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)); + } else { + return Object.fromEntries(result); + } +} + +export function setIntersection(set1, set2) { + const intersection = new Set(); + for (const item of set1) { + if (set2.has(item)) { + intersection.add(item); + } + } + return intersection; +} + +export function filterProperties(object, properties, { + preserveOriginalOrder = false, +} = {}) { + if (typeof object !== 'object' || object === null) { + throw new TypeError(`Expected object to be an object, got ${typeAppearance(object)}`); + } + + if (!Array.isArray(properties)) { + throw new TypeError(`Expected properties to be an array, got ${typeAppearance(properties)}`); + } + + const filteredObject = {}; + + if (preserveOriginalOrder) { + for (const property of Object.keys(object)) { + if (properties.includes(property)) { + filteredObject[property] = object[property]; + } + } + } else { + for (const property of properties) { + if (Object.hasOwn(object, property)) { + filteredObject[property] = object[property]; + } + } + } + + return filteredObject; +} + +export function queue(array, max = 50) { + if (max === 0) { + return array.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); + }); + }) + ); + + for (let i = 0; i < max && begin.length; i++) { + begin.shift()(); + } + + return ret; +} + +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 +// past stage ~~1~~ 2 yet: https://github.com/tc39/proposal-regex-escaping +export function escapeRegex(string) { + return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); +} + +export function splitKeys(key) { + return key.split(/(?<=(? + (k.length === 1 + ? o[k[0]] + : recursive(o[k[0]], k.slice(1))); + + return recursive(obj, splitKeys(key)); +} + +// Gets the "look" of some arbitrary value. It's like typeof, but smarter. +// Don't use this for actually validating types - it's only suitable for +// inclusion in error messages. +export function typeAppearance(value) { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) return 'array'; + return typeof value; +} + +// Limits a string to the desired length, filling in an ellipsis at the end +// if it cuts any text off. +export function cut(text, length = 40) { + if (text.length >= length) { + const index = Math.max(1, length - 3); + return text.slice(0, index) + '...'; + } else { + return text; + } +} + +// Limits a string to the desired length, filling in an ellipsis at the start +// if it cuts any text off. +export function cutStart(text, length = 40) { + if (text.length >= length) { + const index = Math.min(text.length - 1, text.length - length + 3); + return '...' + text.slice(index); + } else { + return text; + } +} + +// 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: +// +// * its line and column numbers; +// * if `formatWhere` is true (the default), a pretty-formatted, +// human-readable indication of the match's placement in the string; +// * if `getContainingLine` is true, the entire line (or multiple lines) +// of text containing the match. +// +export function* iterateMultiline(content, iterator, { + formatWhere = true, + getContainingLine = false, +} = {}) { + const lineRegexp = /\n/g; + const isMultiline = content.includes('\n'); + + let lineNumber = 0; + let startOfLine = 0; + let previousIndex = 0; + + const countLineBreaks = (index, length) => { + const range = content.slice(index, index + length); + const lineBreaks = Array.from(range.matchAll(lineRegexp)); + if (!empty(lineBreaks)) { + lineNumber += lineBreaks.length; + startOfLine = index + lineBreaks.at(-1).index + 1; + } + }; + + for (const result of iterator) { + const {index, length} = result; + + countLineBreaks(previousIndex, index - previousIndex); + + const matchStartOfLine = startOfLine; + + previousIndex = index + length; + + const columnNumber = index - startOfLine; + + const where = + (formatWhere && isMultiline + ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}` + : formatWhere + ? `pos: ${index + 1}` + : null); + + countLineBreaks(index, length); + + let containingLine = null; + if (getContainingLine) { + const nextLineResult = + content + .slice(previousIndex) + .matchAll(lineRegexp) + .next(); + + const nextStartOfLine = + (nextLineResult.done + ? content.length + : previousIndex + nextLineResult.value.index); + + containingLine = + content.slice(matchStartOfLine, nextStartOfLine); + } + + yield { + ...result, + lineNumber, + columnNumber, + where, + containingLine, + }; + } +} + +// Iterates over regular expression matches within a single- or multiline +// string, yielding each match as well as contextual details; this accepts +// the same options (and provides the same context) as iterateMultiline. +export function* matchMultiline(content, matchRegexp, options) { + const matchAllIterator = + content.matchAll(matchRegexp); + + const cleanMatchAllIterator = + (function*() { + for (const match of matchAllIterator) { + yield { + index: match.index, + length: match[0].length, + match, + }; + } + })(); + + const multilineIterator = + iterateMultiline(content, cleanMatchAllIterator, options); + + yield* multilineIterator; +} + +// Binds default values for arguments in a {key: value} type function argument +// (typically the second argument, but may be overridden by providing a +// [bindOpts.bindIndex] argument). Typically useful for preparing a function for +// reuse within one or multiple other contexts, which may not be aware of +// required or relevant values provided in the initial context. +// +// This function also passes the identity of `this` through (the returned value +// is not an arrow function), though note it's not a true bound function either +// (since Function.prototype.bind only supports positional arguments, not +// "options" specified via key/value). +// +export function bindOpts(fn, bind) { + const bindIndex = bind[bindOpts.bindIndex] ?? 1; + + const bound = function (...args) { + const opts = args[bindIndex] ?? {}; + return Reflect.apply(fn, this, [ + ...args.slice(0, bindIndex), + {...bind, ...opts} + ]); + }; + + annotateFunction(bound, { + name: fn, + trait: 'options-bound', + }); + + for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) { + if (key === 'length') continue; + if (key === 'name') continue; + if (key === 'arguments') continue; + if (key === 'caller') continue; + if (key === 'prototype') continue; + Object.defineProperty(bound, key, descriptor); + } + + return bound; +} + +bindOpts.bindIndex = Symbol(); + +// Sorts multiple arrays by an arbitrary function (which is the last argument). +// Paired values from each array are provided to the callback sequentially: +// +// (a_fromFirstArray, b_fromFirstArray, +// a_fromSecondArray, b_fromSecondArray, +// a_fromThirdArray, b_fromThirdArray) => +// relative positioning (negative, positive, or zero) +// +// Like native single-array sort, this is a mutating function. +export function sortMultipleArrays(...args) { + const arrays = args.slice(0, -1); + const fn = args.at(-1); + + const length = arrays[0].length; + const symbols = new Array(length).fill(null).map(() => Symbol()); + const indexes = Object.fromEntries(symbols.map((symbol, index) => [symbol, index])); + + symbols.sort((a, b) => { + const indexA = indexes[a]; + const indexB = indexes[b]; + + const args = []; + for (let i = 0; i < arrays.length; i++) { + args.push(arrays[i][indexA]); + args.push(arrays[i][indexB]); + } + + return fn(...args); + }); + + for (const array of arrays) { + // Note: We're mutating this array pulling values from itself, but only all + // at once after all those values have been pulled. + array.splice(0, array.length, ...symbols.map(symbol => array[indexes[symbol]])); + } + + return arrays; +} + +// Filters multiple arrays by an arbitrary function (which is the last argument). +// Values from each array are provided to the callback sequentially: +// +// (value_fromFirstArray, +// value_fromSecondArray, +// value_fromThirdArray, +// index, +// [firstArray, secondArray, thirdArray]) => +// true or false +// +// Please be aware that this is a mutating function, unlike native single-array +// filter. The mutated arrays are returned. Also attached under `.removed` are +// corresponding arrays of items filtered out. +export function filterMultipleArrays(...args) { + const arrays = args.slice(0, -1); + const fn = args.at(-1); + + const removed = new Array(arrays.length).fill(null).map(() => []); + + for (let i = arrays[0].length - 1; i >= 0; i--) { + const args = arrays.map(array => array[i]); + args.push(i, arrays); + + if (!fn(...args)) { + for (let j = 0; j < arrays.length; j++) { + const item = arrays[j][i]; + arrays[j].splice(i, 1); + removed[j].unshift(item); + } + } + } + + Object.assign(arrays, {removed}); + return arrays; +} + +// Corresponding filter function for sortByCount. By default, items whose +// corresponding count is zero will be removed. +export function filterByCount(data, counts, { + min = 1, + max = Infinity, +} = {}) { + filterMultipleArrays(data, counts, (data, count) => + count >= min && count <= max); +} + +// Reduces multiple arrays with an arbitrary function (which is the last +// argument). Note that this reduces into multiple accumulators, one for +// each input array, not just a single value. That's reflected in both the +// callback parameters: +// +// (accumulator1, +// accumulator2, +// value_fromFirstArray, +// value_fromSecondArray, +// index, +// [firstArray, secondArray]) => +// [newAccumulator1, newAccumulator2] +// +// As well as the final return value of reduceMultipleArrays: +// +// [finalAccumulator1, finalAccumulator2] +// +// This is not a mutating function. +export function reduceMultipleArrays(...args) { + const [arrays, fn, initialAccumulators] = + (typeof args.at(-1) === 'function' + ? [args.slice(0, -1), args.at(-1), null] + : [args.slice(0, -2), args.at(-2), args.at(-1)]); + + if (empty(arrays[0])) { + throw new TypeError(`Reduce of empty arrays with no initial value`); + } + + let [accumulators, i] = + (initialAccumulators + ? [initialAccumulators, 0] + : [arrays.map(array => array[0]), 1]); + + for (; i < arrays[0].length; i++) { + const args = [...accumulators, ...arrays.map(array => array[i])]; + args.push(i, arrays); + accumulators = fn(...args); + } + + return accumulators; +} + +export function chunkByConditions(array, conditions) { + if (empty(array)) { + return []; + } + + if (empty(conditions)) { + return [array]; + } + + const out = []; + let cur = [array[0]]; + for (let i = 1; i < array.length; i++) { + const item = array[i]; + const prev = array[i - 1]; + let chunk = false; + for (const condition of conditions) { + if (condition(item, prev)) { + chunk = true; + break; + } + } + if (chunk) { + out.push(cur); + cur = [item]; + } else { + cur.push(item); + } + } + out.push(cur); + return out; +} + +export function chunkByProperties(array, properties) { + return chunkByConditions( + array, + properties.map((p) => (a, b) => { + if (a[p] instanceof Date && b[p] instanceof Date) return +a[p] !== +b[p]; + + if (a[p] !== b[p]) return true; + + // Not sure if this line is still necessary with the specific check for + // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....? + if (a[p] != b[p]) return true; + + return false; + }) + ).map((chunk) => ({ + ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])), + chunk, + })); +} + +export function chunkMultipleArrays(...args) { + const arrays = args.slice(0, -1); + const fn = args.at(-1); + + if (arrays[0].length === 0) { + return []; + } + + const newChunk = index => arrays.map(array => [array[index]]); + const results = [newChunk(0)]; + + for (let i = 1; i < arrays[0].length; i++) { + const current = results.at(-1); + + const args = []; + for (let j = 0; j < arrays.length; j++) { + const item = arrays[j][i]; + const previous = current[j].at(-1); + args.push(item, previous); + } + + if (fn(...args)) { + results.push(newChunk(i)); + continue; + } + + for (let j = 0; j < arrays.length; j++) { + current[j].push(arrays[j][i]); + } + } + + return results; +} + +// Delicious function annotations, such as: +// +// (*bound) soWeAreBackInTheMine +// (data *unfulfilled) generateShrekTwo +// +export function annotateFunction(fn, { + name: nameOrFunction = null, + description: newDescription, + trait: newTrait, +}) { + let name; + + if (typeof nameOrFunction === 'function') { + name = nameOrFunction.name; + } else if (typeof nameOrFunction === 'string') { + name = nameOrFunction; + } + + name ??= fn.name ?? 'anonymous'; + + const match = name.match(/^ *(?.*?) *\((?.*)( #(?.*))?\) *(?.*) *$/); + + let prefix, suffix, description, trait; + if (match) { + ({prefix, suffix, description, trait} = match.groups); + } + + prefix ??= ''; + suffix ??= name; + description ??= ''; + trait ??= ''; + + if (newDescription) { + if (description) { + description += '; ' + newDescription; + } else { + description = newDescription; + } + } + + if (newTrait) { + if (trait) { + trait += ' #' + newTrait; + } else { + trait = '#' + newTrait; + } + } + + let parenthesesPart; + + if (description && trait) { + parenthesesPart = `${description} ${trait}`; + } else if (description || trait) { + parenthesesPart = description || trait; + } else { + parenthesesPart = ''; + } + + let finalName; + + if (prefix && parenthesesPart) { + finalName = `${prefix} (${parenthesesPart}) ${suffix}`; + } else if (parenthesesPart) { + finalName = `(${parenthesesPart}) ${suffix}`; + } else { + finalName = suffix; + } + + Object.defineProperty(fn, 'name', {value: finalName}); +} diff --git a/src/common-util/wiki-data.js b/src/common-util/wiki-data.js new file mode 100644 index 00000000..f97ecd63 --- /dev/null +++ b/src/common-util/wiki-data.js @@ -0,0 +1,475 @@ +// Utility functions for interacting with wiki data. + +import {accumulateSum, empty, unique} from './sugar.js'; +import {sortByDate} from './sort.js'; + +// This is a duplicate binding of filterMultipleArrays that's included purely +// to leave wiki-data.js compatible with the release build of HSMusic. +// Sorry! This is really ridiculous!! If the next update after 10/25/2023 has +// released, this binding is no longer needed! +export {filterMultipleArrays} from './sugar.js'; + +// Generic value operations + +export function getKebabCase(name) { + return name + + // Spaces to dashes + .split(' ') + .join('-') + + // Punctuation as words + .replace(/&/g, '-and-') + .replace(/\+/g, '-plus-') + .replace(/%/g, '-percent-') + + // Punctuation which only divides words, not single characters + .replace(/(\b[^\s-.]{2,})\./g, '$1-') + .replace(/\.([^\s-.]{2,})\b/g, '-$1') + + // Punctuation which doesn't divide a number following a non-number + .replace(/(?<=[0-9])\^/g, '-') + .replace(/\^(?![0-9])/g, '-') + + // General punctuation which always separates surrounding words + .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-') + + // Accented characters + .replace(/[áâäàå]/gi, 'a') + .replace(/[çč]/gi, 'c') + .replace(/[éêëè]/gi, 'e') + .replace(/[íîïì]/gi, 'i') + .replace(/[óôöò]/gi, 'o') + .replace(/[úûüù]/gi, 'u') + + // Strip other characters + .replace(/[^a-z0-9-]/gi, '') + + // Combine consecutive dashes + .replace(/-{2,}/g, '-') + + // Trim dashes on boundaries + .replace(/^-+|-+$/g, '') + + // Always lowercase + .toLowerCase(); +} + +// Specific data utilities + +// Matches heading details from commentary data in roughly the formats: +// +// artistReference: (annotation, date) +// artistReference|artistDisplayText: (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 +// as a date, is the capturing group "date". "Parsing as a date" means matching +// one of these formats: +// +// * "25 December 2019" - one or two number digits, followed by any text, +// followed by four number digits +// * "December 25, 2019" - one all-letters word, a space, one or two number +// digits, a comma, and four number digits +// * "12/25/2019" etc - three sets of one to four number digits, separated +// by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD) +// +// Note that the annotation and date are always wrapped by one opening and one +// closing parentheses. The whole heading does NOT need to match the entire +// line it occupies (though it does always start at the first position on that +// line), and if there is more than one closing parenthesis on the line, the +// annotation will always cut off only at the last parenthesis, or a comma +// preceding a date and then the last parenthesis. This is to ensure that +// parentheses can be part of the actual annotation content. +// +// Capturing group "artistReference" is all the characters between and +// (apart from the pipe and "artistDisplayText" text, if present), and is either +// the name of an artist or an "artist:directory"-style reference. +// +// 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. +// + +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>(?: \((?(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?captured|accessed) ${dateRegex('accessDate')})?)?\))?`; +export const commentaryRegexCaseInsensitive = + new RegExp(commentaryRegexRaw, 'gmi'); +export const commentaryRegexCaseSensitive = + new RegExp(commentaryRegexRaw, 'gm'); +export const commentaryRegexCaseSensitiveOneShot = + new RegExp(commentaryRegexRaw); + +export function filterAlbumsByCommentary(albums) { + return albums + .filter((album) => [album, ...album.tracks].some((x) => x.commentary)); +} + +export function getAlbumCover(album, {to}) { + // Some albums don't have art! This function returns null in that case. + if (album.hasCoverArt) { + return to('media.albumCover', album.directory, album.coverArtFileExtension); + } else { + return null; + } +} + +export function getAlbumListTag(album) { + return album.hasTrackNumbers ? 'ol' : 'ul'; +} + +// This gets all the track o8jects defined in every al8um, and sorts them 8y +// date released. Generally, albumData will pro8a8ly already 8e sorted 8efore +// you pass it to this function, 8ut individual tracks can have their own +// original release d8, distinct from the al8um's d8. I allowed that 8ecause +// in Homestuck, the first four Vol.'s were com8ined into one al8um really +// early in the history of the 8andcamp, and I still want to use that as the +// al8um listing (not the original four al8um listings), 8ut if I only did +// that, all the tracks would 8e sorted as though they were released at the +// same time as the compilation al8um - i.e, after some other al8ums (including +// Vol.'s 5 and 6!) were released. That would mess with chronological listings +// including tracks from multiple al8ums, like artist pages. So, to fix that, +// I gave tracks an Original Date field, defaulting to the release date of the +// al8um if not specified. Pretty reasona8le, I think! Oh, and this feature can +// 8e used for other projects too, like if you wanted to have an al8um listing +// compiling a 8unch of songs with radically different & interspersed release +// d8s, 8ut still keep the al8um listing in a specific order, since that isn't +// sorted 8y date. +export function getAllTracks(albumData) { + return sortByDate(albumData.flatMap((album) => album.tracks)); +} + +export function getArtistNumContributions(artist) { + return accumulateSum( + [ + unique( + ([ + artist.trackArtistContributions, + artist.trackContributorContributions, + artist.trackCoverArtistContributions, + ]).flat() + .map(({thing}) => thing)), + + artist.albumCoverArtistContributions, + artist.flashContributorContributions, + ], + ({length}) => length); +} + +export function getFlashCover(flash, {to}) { + return to('media.flashArt', flash.directory, flash.coverArtFileExtension); +} + +export function getFlashLink(flash) { + return `https://homestuck.com/story/${flash.page}`; +} + +export function getTotalDuration(tracks, { + originalReleasesOnly = false, +} = {}) { + if (originalReleasesOnly) { + tracks = tracks.filter(t => !t.originalReleaseTrack); + } + + return accumulateSum(tracks, track => track.duration); +} + +export function getTrackCover(track, {to}) { + // Some albums don't have any track art at all, and in those, every track + // just inherits the album's own cover art. Note that since cover art isn't + // guaranteed on albums either, it's possible that this function returns + // null! + if (!track.hasUniqueCoverArt) { + return getAlbumCover(track.album, {to}); + } else { + return to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension); + } +} + +export function getArtistAvatar(artist, {to}) { + return to('media.artistAvatar', artist.directory, artist.avatarFileExtension); +} + +// Big-ass homepage row functions + +export function getNewAdditions(numAlbums, {albumData}) { + const sortedAlbums = albumData + .filter((album) => album.isListedOnHomepage) + .sort((a, b) => { + if (a.dateAddedToWiki > b.dateAddedToWiki) return -1; + if (a.dateAddedToWiki < b.dateAddedToWiki) return 1; + if (a.date > b.date) return -1; + if (a.date < b.date) return 1; + return 0; + }); + + // When multiple al8ums are added to the wiki at a time, we want to show + // all of them 8efore pulling al8ums from the next (earlier) date. We also + // want to show a diverse selection of al8ums - with limited space, we'd + // rather not show only the latest al8ums, if those happen to all 8e + // closely rel8ted! + // + // Specifically, we're concerned with avoiding too much overlap amongst + // the primary (first/top-most) group. We do this 8y collecting every + // primary group present amongst the al8ums for a given d8 into one + // (ordered) array, initially sorted (inherently) 8y latest al8um from + // the group. Then we cycle over the array, adding one al8um from each + // group until all the al8ums from that release d8 have 8een added (or + // we've met the total target num8er of al8ums). Once we've added all the + // al8ums for a given group, it's struck from the array (so the groups + // with the most additions on one d8 will have their oldest releases + // collected more towards the end of the list). + + const albums = []; + + let i = 0; + outerLoop: while (i < sortedAlbums.length) { + // 8uild up a list of groups and their al8ums 8y order of decending + // release, iter8ting until we're on a different d8. (We use a map for + // indexing so we don't have to iter8te through the entire array each + // time we access one of its entries. This is 8asically unnecessary + // since this will never 8e an expensive enough task for that to + // matter.... 8ut it's nicer code. BBBB) ) + const currentDate = sortedAlbums[i].dateAddedToWiki; + const groupMap = new Map(); + const groupArray = []; + for (let album; (album = sortedAlbums[i]) && +album.dateAddedToWiki === +currentDate; i++) { + const primaryGroup = album.groups[0]; + if (groupMap.has(primaryGroup)) { + groupMap.get(primaryGroup).push(album); + } else { + const entry = [album]; + groupMap.set(primaryGroup, entry); + groupArray.push(entry); + } + } + + // Then cycle over that sorted array, adding one al8um from each to + // the main array until we've run out or have met the target num8er + // of al8ums. + while (!empty(groupArray)) { + let j = 0; + while (j < groupArray.length) { + const entry = groupArray[j]; + const album = entry.shift(); + albums.push(album); + + // This is the only time we ever add anything to the main al8um + // list, so it's also the only place we need to check if we've + // met the target length. + if (albums.length === numAlbums) { + // If we've met it, 8r8k out of the outer loop - we're done + // here! + break outerLoop; + } + + if (empty(entry)) { + groupArray.splice(j, 1); + } else { + j++; + } + } + } + } + + return albums; +} + +export function getNewReleases(numReleases, {albumData}) { + return albumData + .filter((album) => album.isListedOnHomepage) + .reverse() + .slice(0, numReleases); +} + +// Carousel layout and utilities + +// Layout constants: +// +// Carousels support fitting 4-18 items, with a few "dead" zones to watch out +// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles. +// +// Carousels are limited to 1-3 rows and 4-6 columns. +// Lower edge case: 1-3 items are treated as 4 items (with blank space). +// Upper edge case: all items past 18 are dropped (treated as 18 items). +// +// This is all done through JS instead of CSS because it's just... ANNOYING... +// to write a mapping like this in CSS lol. +const carouselLayoutMap = [ + // 0-3 + null, null, null, null, + + // 4-6 + {rows: 1, columns: 4}, // 4: 1x4, drop 0 + {rows: 1, columns: 5}, // 5: 1x5, drop 0 + {rows: 1, columns: 6}, // 6: 1x6, drop 0 + + // 7-12 + {rows: 1, columns: 6}, // 7: 1x6, drop 1 + {rows: 2, columns: 4}, // 8: 2x4, drop 0 + {rows: 2, columns: 4}, // 9: 2x4, drop 1 + {rows: 2, columns: 5}, // 10: 2x5, drop 0 + {rows: 2, columns: 5}, // 11: 2x5, drop 1 + {rows: 2, columns: 6}, // 12: 2x6, drop 0 + + // 13-18 + {rows: 2, columns: 6}, // 13: 2x6, drop 1 + {rows: 2, columns: 6}, // 14: 2x6, drop 2 + {rows: 3, columns: 5}, // 15: 3x5, drop 0 + {rows: 3, columns: 5}, // 16: 3x5, drop 1 + {rows: 3, columns: 5}, // 17: 3x5, drop 2 + {rows: 3, columns: 6}, // 18: 3x6, drop 0 +]; + +const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null); +const maxCarouselLayoutItems = carouselLayoutMap.length - 1; +const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems]; +const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems]; + +export function getCarouselLayoutForNumberOfItems(numItems) { + return ( + numItems < minCarouselLayoutItems ? shortestCarouselLayout : + numItems > maxCarouselLayoutItems ? longestCarouselLayout : + carouselLayoutMap[numItems]); +} + +export function filterItemsForCarousel(items) { + if (empty(items)) { + return []; + } + + return items + .filter(item => item.hasCoverArt) + .filter(item => item.artTags.every(tag => !tag.isContentWarning)) + .slice(0, maxCarouselLayoutItems + 1); +} + +// Ridiculous caching support nonsense + +export class TupleMap { + static maxNestedTupleLength = 25; + + #store = [undefined, null, null, null]; + + #lifetime(value) { + if (Array.isArray(value) && value.length <= TupleMap.maxNestedTupleLength) { + return 'tuple'; + } else if ( + typeof value === 'object' && value !== null || + typeof value === 'function' + ) { + return 'weak'; + } else { + return 'strong'; + } + } + + #getSubstoreShallow(value, store) { + const lifetime = this.#lifetime(value); + const mapIndex = {weak: 1, strong: 2, tuple: 3}[lifetime]; + + let map = store[mapIndex]; + if (map === null) { + map = store[mapIndex] = + (lifetime === 'weak' ? new WeakMap() + : lifetime === 'strong' ? new Map() + : lifetime === 'tuple' ? new TupleMap() + : null); + } + + if (map.has(value)) { + return map.get(value); + } else { + const substore = [undefined, null, null, null]; + map.set(value, substore); + return substore; + } + } + + #getSubstoreDeep(tuple, store = this.#store) { + if (tuple.length === 0) { + return store; + } else { + const [first, ...rest] = tuple; + return this.#getSubstoreDeep(rest, this.#getSubstoreShallow(first, store)); + } + } + + get(tuple) { + const store = this.#getSubstoreDeep(tuple); + return store[0]; + } + + has(tuple) { + const store = this.#getSubstoreDeep(tuple); + return store[0] !== undefined; + } + + set(tuple, value) { + const store = this.#getSubstoreDeep(tuple); + store[0] = value; + return value; + } +} + +export class TupleMapForBabies { + #here = new WeakMap(); + #next = new WeakMap(); + + set(...args) { + const first = args.at(0); + const last = args.at(-1); + const rest = args.slice(1, -1); + + if (empty(rest)) { + this.#here.set(first, last); + } else if (this.#next.has(first)) { + this.#next.get(first).set(...rest, last); + } else { + const tupleMap = new TupleMapForBabies(); + this.#next.set(first, tupleMap); + tupleMap.set(...rest, last); + } + } + + get(...args) { + const first = args.at(0); + const rest = args.slice(1); + + if (empty(rest)) { + return this.#here.get(first); + } else if (this.#next.has(first)) { + return this.#next.get(first).get(...rest); + } else { + return undefined; + } + } + + has(...args) { + const first = args.at(0); + const rest = args.slice(1); + + if (empty(rest)) { + return this.#here.has(first); + } else if (this.#next.has(first)) { + return this.#next.get(first).has(...rest); + } else { + return false; + } + } +} + +const combinedWikiDataTupleMap = new TupleMapForBabies(); + +export function combineWikiDataArrays(arrays) { + const map = combinedWikiDataTupleMap; + if (map.has(...arrays)) { + return map.get(...arrays); + } else { + const combined = arrays.flat(); + map.set(...arrays, combined); + return combined; + } +} diff --git a/src/util/colors.js b/src/util/colors.js deleted file mode 100644 index 7298c46a..00000000 --- a/src/util/colors.js +++ /dev/null @@ -1,44 +0,0 @@ -// Color and theming utility functions! Handy. - -export function getColors(themeColor, { - // chroma.js external dependency (https://gka.github.io/chroma.js/) - chroma, -} = {}) { - if (!chroma) { - throw new Error('chroma.js library must be passed or bound for color manipulation'); - } - - const primary = chroma(themeColor); - - const dark = primary.luminance(0.02); - const dim = primary.desaturate(2).darken(1.5); - 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); - const shadow = primary.desaturate(4).set('hsl.l', 0.05).alpha(0.8); - - const hsl = primary.hsl(); - if (isNaN(hsl[0])) hsl[0] = 0; - - return { - primary: primary.hex(), - - dark: dark.hex(), - dim: dim.hex(), - deep: deep.hex(), - deepGhost: deepGhost.hex(), - light: light.hex(), - lightGhost: lightGhost.hex(), - - bg: bg.hex(), - bgBlack: bgBlack.hex(), - shadow: shadow.hex(), - - rgb: primary.rgb(), - hsl, - }; -} diff --git a/src/util/search-spec.js b/src/util/search-spec.js deleted file mode 100644 index 3d05c021..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 = - (thing.constructor.hasPropertyDescriptor('artTags') - ? thing.artTags.map(artTag => artTag.nameShort) - : []); - - fields.additionalNames = - (thing.constructor.hasPropertyDescriptor('additionalNames') - ? thing.additionalNames.map(entry => entry.name) - : thing.constructor.hasPropertyDescriptor('aliasNames') - ? thing.aliasNames - : []); - - const contribKeys = [ - 'artistContribs', - '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 deleted file mode 100644 index eb18a759..00000000 --- a/src/util/serialize.js +++ /dev/null @@ -1,77 +0,0 @@ -// Utils used when per-wiki-object data files. -// Retained for reference and/or later reorganization. -// -// Not to be confused with data/serialize.js, which provides a generic -// interface for serializing any Thing object. - -/* -export function serializeLink(thing) { - const ret = {}; - ret.name = thing.name; - ret.directory = thing.directory; - if (thing.color) ret.color = thing.color; - return ret; -} - -export function serializeContribs(contribs) { - return contribs.map(({artist, annotation}) => { - const ret = {}; - ret.artist = serializeLink(artist); - if (annotation) ret.contribution = annotation; - return ret; - }); -} - -export function serializeImagePaths(original, {thumb}) { - return { - original, - medium: thumb.medium(original), - small: thumb.small(original), - }; -} - -export function serializeCover(thing, pathFunction, { - serializeImagePaths, - urls, -}) { - const coverPath = pathFunction(thing, { - to: urls.from('media.root').to, - }); - - const {artTags} = thing; - - const cwTags = artTags.filter((tag) => tag.isContentWarning); - const linkTags = artTags.filter((tag) => !tag.isContentWarning); - - return { - paths: serializeImagePaths(coverPath), - tags: linkTags.map(serializeLink), - warnings: cwTags.map((tag) => tag.name), - }; -} - -export function serializeGroupsForAlbum(album, {serializeLink}) { - return album.groups - .map((group) => { - const index = group.albums.indexOf(album); - const next = group.albums[index + 1] || null; - const previous = group.albums[index - 1] || null; - return {group, index, next, previous}; - }) - .map(({group, index, next, previous}) => ({ - link: serializeLink(group), - descriptionShort: group.descriptionShort, - albumIndex: index, - nextAlbum: next && serializeLink(next), - previousAlbum: previous && serializeLink(previous), - urls: group.urls, - })); -} - -export function serializeGroupsForTrack(track, {serializeLink}) { - return track.album.groups.map((group) => ({ - link: serializeLink(group), - urls: group.urls, - })); -} -*/ diff --git a/src/util/sort.js b/src/util/sort.js deleted file mode 100644 index ea1e024a..00000000 --- a/src/util/sort.js +++ /dev/null @@ -1,438 +0,0 @@ -// Sorting functions - all utils here are mutating, so make sure to initially -// slice/filter/somehow generate a new array from input data if retaining the -// initial sort matters! (Spoilers: If what you're doing involves any kind of -// parallelization, it definitely matters.) - -import {empty, sortMultipleArrays, unique} - from './sugar.js'; - -// General sorting utilities! These don't do any sorting on their own but are -// handy in the sorting functions below (or if you're making your own sort). - -export function compareCaseLessSensitive(a, b) { - // Compare two strings without considering capitalization... unless they - // happen to be the same that way. - - const al = a.toLowerCase(); - const bl = b.toLowerCase(); - - return al === bl - ? a.localeCompare(b, undefined, {numeric: true}) - : al.localeCompare(bl, undefined, {numeric: true}); -} - -// Subtract common prefixes and other characters which some people don't like -// to have considered while sorting. The words part of this is English-only for -// now, which is totally evil. -export function normalizeName(s) { - // Turn (some) ligatures into expanded variant for cleaner sorting, e.g. - // "ff" into "ff", in decompose mode, so that "ü" is represented as two - // bytes ("u" + \u0308 combining diaeresis). - s = s.normalize('NFKD'); - - // Replace one or more whitespace of any kind in a row, as well as certain - // punctuation, with a single typical space, then trim the ends. - s = s - .replace( - /[\p{Separator}\p{Dash_Punctuation}\p{Connector_Punctuation}]+/gu, - ' ' - ) - .trim(); - - // Discard anything that isn't a letter, number, or space. - s = s.replace(/[^\p{Letter}\p{Number} ]/gu, '').trim(); - - // Remove common English (only, for now) prefixes. - s = s.replace(/^(?:an?|the) /i, ''); - - return s; -} - -// Component sort functions - these sort by one particular property, applying -// unique particulars where appropriate. Usually you don't want to use these -// directly, but if you're making a custom sort they can come in handy. - -// Universal method for sorting things into a predictable order, as directory -// is taken to be unique. There are two exceptions where this function (and -// thus any of the composite functions that start with it) *can't* be taken as -// deterministic: -// -// 1) Mixed data of two different Things, as directories are only taken as -// unique within one given class of Things. For example, this function -// won't be deterministic if its array contains both and -// . -// -// 2) Duplicate directories, or multiple instances of the "same" Thing. -// This function doesn't differentiate between two objects of the same -// directory, regardless of any other properties or the overall "identity" -// of the object. -// -// These exceptions are unavoidable except for not providing that kind of data -// in the first place, but you can still ensure the overall program output is -// deterministic by ensuring the input is arbitrarily sorted according to some -// other criteria - ex, although sortByDirectory itself isn't determinstic when -// given mixed track and album data, the final output (what goes on the site) -// will always be the same if you're doing sortByDirectory([...albumData, -// ...trackData]), because the initial sort places albums before tracks - and -// sortByDirectory will handle the rest, given all directories are unique -// except when album and track directories overlap with each other. -export function sortByDirectory(data, { - getDirectory = object => object.directory, -} = {}) { - const directories = data.map(getDirectory); - - sortMultipleArrays(data, directories, - (a, b, directoryA, directoryB) => - compareCaseLessSensitive(directoryA, directoryB)); - - return data; -} - -export function sortByName(data, { - getName = object => object.name, -} = {}) { - const names = data.map(getName); - const normalizedNames = names.map(normalizeName); - - sortMultipleArrays(data, normalizedNames, names, - ( - a, b, - normalizedA, normalizedB, - nonNormalizedA, nonNormalizedB, - ) => - compareNormalizedNames( - normalizedA, normalizedB, - nonNormalizedA, nonNormalizedB, - )); - - return data; -} - -export function compareNormalizedNames( - normalizedA, normalizedB, - nonNormalizedA, nonNormalizedB, -) { - const comparison = compareCaseLessSensitive(normalizedA, normalizedB); - return ( - (comparison === 0 - ? compareCaseLessSensitive(nonNormalizedA, nonNormalizedB) - : comparison)); -} - -export function sortByDate(data, { - getDate = object => object.date, - latestFirst = false, -} = {}) { - const dates = data.map(getDate); - - sortMultipleArrays(data, dates, - (a, b, dateA, dateB) => - compareDates(dateA, dateB, {latestFirst})); - - return data; -} - -export function compareDates(a, b, { - latestFirst = false, -} = {}) { - if (a && b) { - return (latestFirst ? b - a : a - b); - } - - // It's possible for objects with and without dates to be mixed - // together in the same array. If that's the case, we put all items - // without dates at the end. - if (a) return -1; - if (b) return 1; - - // If neither of the items being compared have a date, don't move - // them relative to each other. This is basically the same as - // filtering out all non-date items and then pushing them at the - // end after sorting the rest. - return 0; -} - -export function getLatestDate(dates) { - const filtered = dates.filter(Boolean); - if (empty(filtered)) return null; - - return filtered - .reduce( - (accumulator, date) => - date > accumulator ? date : accumulator, - -Infinity); -} - -export function getEarliestDate(dates) { - const filtered = dates.filter(Boolean); - if (empty(filtered)) return null; - - return filtered - .reduce( - (accumulator, date) => - date < accumulator ? date : accumulator, - Infinity); -} - -// Funky sort which takes a data set and a corresponding list of "counts", -// which are really arbitrary numbers representing some property of each data -// object defined by the caller. It sorts and mutates *both* of these, so the -// sorted data will still correspond to the same indexed count. -export function sortByCount(data, counts, { - greatestFirst = false, -} = {}) { - sortMultipleArrays(data, counts, (data1, data2, count1, count2) => - (greatestFirst - ? count2 - count1 - : count1 - count2)); - - return data; -} - -export function sortByPositionInParent(data, { - getParent, - getChildren, -}) { - return data.sort((a, b) => { - const parentA = getParent(a); - const parentB = getParent(b); - - // Don't change the sort when the two items are from separate parents. - // This function doesn't change the order of parents or try to "merge" - // two separated chunks of items from the same parent together. - if (parentA !== parentB) { - return 0; - } - - // Don't change the sort when either (or both) of the items doesn't - // even have a parent (e.g. it's the passed data is a mixed array of - // children and parents). - if (!parentA || !parentB) { - return 0; - } - - const indexA = getChildren(parentA).indexOf(a); - const indexB = getChildren(parentB).indexOf(b); - - // If the getParent/getChildren relationship doesn't go both ways for - // some reason, don't change the sort. - if (indexA === -1 || indexB === -1) { - return 0; - } - - return indexA - indexB; - }); -} - -export function sortByPositionInAlbum(data) { - return sortByPositionInParent(data, { - getParent: track => track.album, - getChildren: album => album.tracks, - }); -} - -export function sortByPositionInFlashAct(data) { - return sortByPositionInParent(data, { - getParent: flash => flash.act, - getChildren: act => act.flashes, - }); -} - -// Sorts data so that items are grouped together according to whichever of a -// set of arbitrary given conditions is true first. If no conditions are met -// for a given item, it's moved over to the end! -export function sortByConditions(data, conditions) { - return data.sort((a, b) => { - const ai = conditions.findIndex((f) => f(a)); - const bi = conditions.findIndex((f) => f(b)); - - if (ai >= 0 && bi >= 0) { - return ai - bi; - } else if (ai >= 0) { - return -1; - } else if (bi >= 0) { - return 1; - } else { - return 0; - } - }); -} - -// Composite sorting functions - these consider multiple properties, generally -// always returning the same output regardless of how the input was originally -// sorted (or left unsorted). If you're working with arbitrarily sorted inputs -// (typically wiki data, either in full or unsorted filter), these make sure -// what gets put on the actual website (or wherever) is deterministic. Also -// they're just handy sorting utilities. -// -// Note that because these are each comprised of multiple component sorting -// functions, they expect more than just one property to be present for full -// sorting (listed above each function). If you're mapping thing objects to -// another representation, try to include all of these listed properties. - -// Expects thing properties: -// * directory (or override getDirectory) -// * name (or override getName) -export function sortAlphabetically(data, { - getDirectory, - getName, -} = {}) { - sortByDirectory(data, {getDirectory}); - sortByName(data, {getName}); - return data; -} - -// Expects thing properties: -// * directory (or override getDirectory) -// * name (or override getName) -// * date (or override getDate) -export function sortChronologically(data, { - latestFirst = false, - getDirectory, - getName, - getDate, -} = {}) { - sortAlphabetically(data, {getDirectory, getName}); - sortByDate(data, {latestFirst, getDate}); - return data; -} - -// This one's a little odd! Sorts an array of {entry, thing} pairs using -// the provided sortFunction, which will operate on each item's `thing`, not -// its entry (or the item as a whole). If multiple entries are associated -// with the same thing, they'll end up bunched together in the output, -// retaining their original relative positioning. -export function sortEntryThingPairs(data, sortFunction) { - const things = unique(data.map(item => item.thing)); - sortFunction(things); - - const outputArrays = []; - const thingToOutputArray = new Map(); - - for (const thing of things) { - const array = []; - thingToOutputArray.set(thing, array); - outputArrays.push(array); - } - - for (const item of data) { - thingToOutputArray.get(item.thing).push(item); - } - - data.splice(0, data.length, ...outputArrays.flat()); - - return data; -} - -/* -// Alternate draft version of sortEntryThingPairs. -// See: https://github.com/hsmusic/hsmusic-wiki/issues/90#issuecomment-1607412168 - -// Maps the provided "preparation" function across a list of arbitrary values, -// building up a list of sortable values; sorts these with the provided sorting -// function; and reorders the sources to match their corresponding prepared -// values. As usual, if multiple source items correspond to the same sorting -// data, this retains the source relative positioning. -export function prepareAndSort(sources, prepareForSort, sortFunction) { - const prepared = []; - const preparedToSource = new Map(); - - for (const original of originals) { - const prep = prepareForSort(source); - prepared.push(prep); - preparedToSource.set(prep, source); - } - - sortFunction(prepared); - - sources.splice(0, ...sources.length, prepared.map(prep => preparedToSource.get(prep))); - - return sources; -} -*/ - -// Highly contextual sort functions - these are only for very specific types -// of Things, and have appropriately hard-coded behavior. - -// Sorts so that tracks from the same album are generally grouped together in -// their original (album track list) order, while prioritizing date (by default -// release date but can be overridden) above all else. -// -// This function also works for data lists which contain only tracks. -export function sortAlbumsTracksChronologically(data, { - latestFirst = false, - getDate, -} = {}) { - // Sort albums before tracks... - sortByConditions(data, [(t) => t.album === undefined]); - - // Group tracks by album... - sortByDirectory(data, { - getDirectory: (t) => (t.album ? t.album.directory : t.directory), - }); - - // Sort tracks by position in album... - sortByPositionInAlbum(data); - - // ...and finally sort by date. If tracks from more than one album were - // released on the same date, they'll still be grouped together by album, - // and tracks within an album will retain their relative positioning (i.e. - // stay in the same order as part of the album's track listing). - sortByDate(data, {latestFirst, getDate}); - - return data; -} - -export function sortFlashesChronologically(data, { - latestFirst = false, - getDate, -} = {}) { - // Group flashes by act... - sortAlphabetically(data, { - getName: flash => flash.act.name, - getDirectory: flash => flash.act.directory, - }); - - // Sort flashes by position in act... - sortByPositionInFlashAct(data); - - // ...and finally sort by date. If flashes from more than one act were - // released on the same date, they'll still be grouped together by act, - // and flashes within an act will retain their relative positioning (i.e. - // stay in the same order as the act's flash listing). - sortByDate(data, {latestFirst, getDate}); - - 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 deleted file mode 100644 index 90d47b7c..00000000 --- a/src/util/sugar.js +++ /dev/null @@ -1,845 +0,0 @@ -// Syntactic sugar! (Mostly.) -// Generic functions - these are useful just a8out everywhere. -// -// Friendly(!) disclaimer: these utility functions haven't 8een tested all that -// much. Do not assume it will do exactly what you want it to do in all cases. -// It will likely only do exactly what I want it to, and only in the cases I -// decided were relevant enough to 8other handling. - -// 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 -// actually use this. 8ut it's still awesome, 8ecause I say so. -export function* splitArray(array, fn) { - let lastIndex = 0; - while (lastIndex < array.length) { - let nextIndex = array.findIndex((item, index) => index >= lastIndex && fn(item)); - if (nextIndex === -1) { - nextIndex = array.length; - } - yield array.slice(lastIndex, nextIndex); - // Plus one because we don't want to include the dividing line in the - // next array we yield. - lastIndex = nextIndex + 1; - } -} - -// Null-accepting function to check if an array or set is empty. Accepts null -// (which is treated as empty) as a shorthand for "hey, check if this property -// is an array with/without stuff in it" for objects where properties that are -// PRESENT but don't currently have a VALUE are null (rather than undefined). -export function empty(value) { - if (value === null) { - return true; - } - - if (Array.isArray(value)) { - return value.length === 0; - } - - if (value instanceof Set) { - return value.size === 0; - } - - throw new Error(`Expected array, set, or null`); -} - -// Repeats all the items of an array a number of times. -export function repeat(times, array) { - if (times === 0) return []; - if (array === null || array === undefined) return []; - if (Array.isArray(array) && empty(array)) return []; - - const out = []; - - for (let n = 1; n <= times; n++) { - const value = - (typeof array === 'function' - ? array() - : array); - - if (Array.isArray(value)) out.push(...value); - else out.push(value); - } - - return out; -} - -// Gets a random item from an array. -export function pick(array) { - return array[Math.floor(Math.random() * array.length)]; -} - -// Gets the item at an index relative to another index. -export function atOffset(array, index, offset, { - wrap = false, - valuePastEdge = null, -} = {}) { - if (index === -1) { - return valuePastEdge; - } - - if (offset === 0) { - return array[index]; - } - - if (wrap) { - return array[(index + offset) % array.length]; - } - - if (offset > 0 && index + offset > array.length - 1) { - return valuePastEdge; - } - - if (offset < 0 && index + offset < 0) { - return valuePastEdge; - } - - return array[index + offset]; -} - -// Gets the index of the first item that satisfies the provided function, -// or, if none does, returns the length of the array (the index just past the -// final item). -export function findIndexOrEnd(array, fn) { - const index = array.findIndex(fn); - if (index >= 0) { - return index; - } else { - return array.length; - } -} - -// Sums the values in an array, optionally taking a function which maps each -// item to a number (handy for accessing a certain property on an array of like -// objects). This also coalesces null values to zero, so if the mapping function -// returns null (or values in the array are nullish), they'll just be skipped in -// the sum. -export function accumulateSum(array, fn = x => x) { - return array.reduce( - (accumulator, value, index, array) => - accumulator + - fn(value, index, array) ?? 0, - 0); -} - -// Stitches together the items of separate arrays into one array of objects -// whose keys are the corresponding items from each array at that index. -// This is mostly useful for iterating over multiple arrays at once! -export function stitchArrays(keyToArray) { - const errors = []; - - for (const [key, value] of Object.entries(keyToArray)) { - if (value === null) continue; - if (Array.isArray(value)) continue; - errors.push(new TypeError(`(${key}) Expected array or null, got ${typeAppearance(value)}`)); - } - - if (!empty(errors)) { - throw new AggregateError(errors, `Expected arrays or null`); - } - - const keys = Object.keys(keyToArray); - const arrays = Object.values(keyToArray).filter(val => Array.isArray(val)); - const length = Math.max(...arrays.map(({length}) => length)); - const results = []; - - for (let i = 0; i < length; i++) { - const object = {}; - for (const key of keys) { - object[key] = - (Array.isArray(keyToArray[key]) - ? keyToArray[key][i] - : null); - } - results.push(object); - } - - 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: -// -// [ -// [123, 'orange', null], -// [456, 'apple', true], -// [789, 'banana', false], -// [1000, 'pear', undefined], -// ] -// -// Into this: -// -// [ -// [123, 456, 789, 1000], -// ['orange', 'apple', 'banana', 'pear'], -// [null, true, false, undefined], -// ] -// -// And back again, if you call it again on its results. -export function transposeArrays(arrays) { - if (empty(arrays)) { - return []; - } - - const length = arrays[0].length; - const results = new Array(length).fill(null).map(() => []); - - for (const array of arrays) { - for (let i = 0; i < length; i++) { - results[i].push(array[i]); - } - } - - return results; -} - -export const mapInPlace = (array, fn) => - array.splice(0, array.length, ...array.map(fn)); - -export const unique = (arr) => Array.from(new Set(arr)); - -export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) => - arr1.length === arr2.length && - (checkOrder - ? arr1.every((x, i) => arr2[i] === x) - : arr1.every((x) => arr2.includes(x))); - -export function compareObjects(obj1, obj2, { - checkOrder = false, - checkSymbols = true, -} = {}) { - const keys1 = Object.keys(obj1); - const keys2 = Object.keys(obj2); - if (!compareArrays(keys1, keys2, {checkOrder})) return false; - - let syms1, syms2; - if (checkSymbols) { - syms1 = Object.getOwnPropertySymbols(obj1); - syms2 = Object.getOwnPropertySymbols(obj2); - if (!compareArrays(syms1, syms2, {checkOrder})) return false; - } - - for (const key of keys1) { - if (obj2[key] !== obj1[key]) return false; - } - - if (checkSymbols) { - for (const sym of syms1) { - if (obj2[sym] !== obj1[sym]) return false; - } - } - - return true; -} - -// 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)); - } else { - return Object.fromEntries(result); - } -} - -export function setIntersection(set1, set2) { - const intersection = new Set(); - for (const item of set1) { - if (set2.has(item)) { - intersection.add(item); - } - } - return intersection; -} - -export function filterProperties(object, properties, { - preserveOriginalOrder = false, -} = {}) { - if (typeof object !== 'object' || object === null) { - throw new TypeError(`Expected object to be an object, got ${typeAppearance(object)}`); - } - - if (!Array.isArray(properties)) { - throw new TypeError(`Expected properties to be an array, got ${typeAppearance(properties)}`); - } - - const filteredObject = {}; - - if (preserveOriginalOrder) { - for (const property of Object.keys(object)) { - if (properties.includes(property)) { - filteredObject[property] = object[property]; - } - } - } else { - for (const property of properties) { - if (Object.hasOwn(object, property)) { - filteredObject[property] = object[property]; - } - } - } - - return filteredObject; -} - -export function queue(array, max = 50) { - if (max === 0) { - return array.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); - }); - }) - ); - - for (let i = 0; i < max && begin.length; i++) { - begin.shift()(); - } - - return ret; -} - -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 -// past stage ~~1~~ 2 yet: https://github.com/tc39/proposal-regex-escaping -export function escapeRegex(string) { - return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); -} - -export function splitKeys(key) { - return key.split(/(?<=(? - (k.length === 1 - ? o[k[0]] - : recursive(o[k[0]], k.slice(1))); - - return recursive(obj, splitKeys(key)); -} - -// Gets the "look" of some arbitrary value. It's like typeof, but smarter. -// Don't use this for actually validating types - it's only suitable for -// inclusion in error messages. -export function typeAppearance(value) { - if (value === null) return 'null'; - if (value === undefined) return 'undefined'; - if (Array.isArray(value)) return 'array'; - return typeof value; -} - -// Limits a string to the desired length, filling in an ellipsis at the end -// if it cuts any text off. -export function cut(text, length = 40) { - if (text.length >= length) { - const index = Math.max(1, length - 3); - return text.slice(0, index) + '...'; - } else { - return text; - } -} - -// Limits a string to the desired length, filling in an ellipsis at the start -// if it cuts any text off. -export function cutStart(text, length = 40) { - if (text.length >= length) { - const index = Math.min(text.length - 1, text.length - length + 3); - return '...' + text.slice(index); - } else { - return text; - } -} - -// 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: -// -// * its line and column numbers; -// * if `formatWhere` is true (the default), a pretty-formatted, -// human-readable indication of the match's placement in the string; -// * if `getContainingLine` is true, the entire line (or multiple lines) -// of text containing the match. -// -export function* iterateMultiline(content, iterator, { - formatWhere = true, - getContainingLine = false, -} = {}) { - const lineRegexp = /\n/g; - const isMultiline = content.includes('\n'); - - let lineNumber = 0; - let startOfLine = 0; - let previousIndex = 0; - - const countLineBreaks = (index, length) => { - const range = content.slice(index, index + length); - const lineBreaks = Array.from(range.matchAll(lineRegexp)); - if (!empty(lineBreaks)) { - lineNumber += lineBreaks.length; - startOfLine = index + lineBreaks.at(-1).index + 1; - } - }; - - for (const result of iterator) { - const {index, length} = result; - - countLineBreaks(previousIndex, index - previousIndex); - - const matchStartOfLine = startOfLine; - - previousIndex = index + length; - - const columnNumber = index - startOfLine; - - const where = - (formatWhere && isMultiline - ? `line: ${lineNumber + 1}, col: ${columnNumber + 1}` - : formatWhere - ? `pos: ${index + 1}` - : null); - - countLineBreaks(index, length); - - let containingLine = null; - if (getContainingLine) { - const nextLineResult = - content - .slice(previousIndex) - .matchAll(lineRegexp) - .next(); - - const nextStartOfLine = - (nextLineResult.done - ? content.length - : previousIndex + nextLineResult.value.index); - - containingLine = - content.slice(matchStartOfLine, nextStartOfLine); - } - - yield { - ...result, - lineNumber, - columnNumber, - where, - containingLine, - }; - } -} - -// Iterates over regular expression matches within a single- or multiline -// string, yielding each match as well as contextual details; this accepts -// the same options (and provides the same context) as iterateMultiline. -export function* matchMultiline(content, matchRegexp, options) { - const matchAllIterator = - content.matchAll(matchRegexp); - - const cleanMatchAllIterator = - (function*() { - for (const match of matchAllIterator) { - yield { - index: match.index, - length: match[0].length, - match, - }; - } - })(); - - const multilineIterator = - iterateMultiline(content, cleanMatchAllIterator, options); - - yield* multilineIterator; -} - -// Binds default values for arguments in a {key: value} type function argument -// (typically the second argument, but may be overridden by providing a -// [bindOpts.bindIndex] argument). Typically useful for preparing a function for -// reuse within one or multiple other contexts, which may not be aware of -// required or relevant values provided in the initial context. -// -// This function also passes the identity of `this` through (the returned value -// is not an arrow function), though note it's not a true bound function either -// (since Function.prototype.bind only supports positional arguments, not -// "options" specified via key/value). -// -export function bindOpts(fn, bind) { - const bindIndex = bind[bindOpts.bindIndex] ?? 1; - - const bound = function (...args) { - const opts = args[bindIndex] ?? {}; - return Reflect.apply(fn, this, [ - ...args.slice(0, bindIndex), - {...bind, ...opts} - ]); - }; - - annotateFunction(bound, { - name: fn, - trait: 'options-bound', - }); - - for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(fn))) { - if (key === 'length') continue; - if (key === 'name') continue; - if (key === 'arguments') continue; - if (key === 'caller') continue; - if (key === 'prototype') continue; - Object.defineProperty(bound, key, descriptor); - } - - return bound; -} - -bindOpts.bindIndex = Symbol(); - -// Sorts multiple arrays by an arbitrary function (which is the last argument). -// Paired values from each array are provided to the callback sequentially: -// -// (a_fromFirstArray, b_fromFirstArray, -// a_fromSecondArray, b_fromSecondArray, -// a_fromThirdArray, b_fromThirdArray) => -// relative positioning (negative, positive, or zero) -// -// Like native single-array sort, this is a mutating function. -export function sortMultipleArrays(...args) { - const arrays = args.slice(0, -1); - const fn = args.at(-1); - - const length = arrays[0].length; - const symbols = new Array(length).fill(null).map(() => Symbol()); - const indexes = Object.fromEntries(symbols.map((symbol, index) => [symbol, index])); - - symbols.sort((a, b) => { - const indexA = indexes[a]; - const indexB = indexes[b]; - - const args = []; - for (let i = 0; i < arrays.length; i++) { - args.push(arrays[i][indexA]); - args.push(arrays[i][indexB]); - } - - return fn(...args); - }); - - for (const array of arrays) { - // Note: We're mutating this array pulling values from itself, but only all - // at once after all those values have been pulled. - array.splice(0, array.length, ...symbols.map(symbol => array[indexes[symbol]])); - } - - return arrays; -} - -// Filters multiple arrays by an arbitrary function (which is the last argument). -// Values from each array are provided to the callback sequentially: -// -// (value_fromFirstArray, -// value_fromSecondArray, -// value_fromThirdArray, -// index, -// [firstArray, secondArray, thirdArray]) => -// true or false -// -// Please be aware that this is a mutating function, unlike native single-array -// filter. The mutated arrays are returned. Also attached under `.removed` are -// corresponding arrays of items filtered out. -export function filterMultipleArrays(...args) { - const arrays = args.slice(0, -1); - const fn = args.at(-1); - - const removed = new Array(arrays.length).fill(null).map(() => []); - - for (let i = arrays[0].length - 1; i >= 0; i--) { - const args = arrays.map(array => array[i]); - args.push(i, arrays); - - if (!fn(...args)) { - for (let j = 0; j < arrays.length; j++) { - const item = arrays[j][i]; - arrays[j].splice(i, 1); - removed[j].unshift(item); - } - } - } - - Object.assign(arrays, {removed}); - return arrays; -} - -// Corresponding filter function for sortByCount. By default, items whose -// corresponding count is zero will be removed. -export function filterByCount(data, counts, { - min = 1, - max = Infinity, -} = {}) { - filterMultipleArrays(data, counts, (data, count) => - count >= min && count <= max); -} - -// Reduces multiple arrays with an arbitrary function (which is the last -// argument). Note that this reduces into multiple accumulators, one for -// each input array, not just a single value. That's reflected in both the -// callback parameters: -// -// (accumulator1, -// accumulator2, -// value_fromFirstArray, -// value_fromSecondArray, -// index, -// [firstArray, secondArray]) => -// [newAccumulator1, newAccumulator2] -// -// As well as the final return value of reduceMultipleArrays: -// -// [finalAccumulator1, finalAccumulator2] -// -// This is not a mutating function. -export function reduceMultipleArrays(...args) { - const [arrays, fn, initialAccumulators] = - (typeof args.at(-1) === 'function' - ? [args.slice(0, -1), args.at(-1), null] - : [args.slice(0, -2), args.at(-2), args.at(-1)]); - - if (empty(arrays[0])) { - throw new TypeError(`Reduce of empty arrays with no initial value`); - } - - let [accumulators, i] = - (initialAccumulators - ? [initialAccumulators, 0] - : [arrays.map(array => array[0]), 1]); - - for (; i < arrays[0].length; i++) { - const args = [...accumulators, ...arrays.map(array => array[i])]; - args.push(i, arrays); - accumulators = fn(...args); - } - - return accumulators; -} - -export function chunkByConditions(array, conditions) { - if (empty(array)) { - return []; - } - - if (empty(conditions)) { - return [array]; - } - - const out = []; - let cur = [array[0]]; - for (let i = 1; i < array.length; i++) { - const item = array[i]; - const prev = array[i - 1]; - let chunk = false; - for (const condition of conditions) { - if (condition(item, prev)) { - chunk = true; - break; - } - } - if (chunk) { - out.push(cur); - cur = [item]; - } else { - cur.push(item); - } - } - out.push(cur); - return out; -} - -export function chunkByProperties(array, properties) { - return chunkByConditions( - array, - properties.map((p) => (a, b) => { - if (a[p] instanceof Date && b[p] instanceof Date) return +a[p] !== +b[p]; - - if (a[p] !== b[p]) return true; - - // Not sure if this line is still necessary with the specific check for - // d8tes a8ove, 8ut, uh, keeping it anyway, just in case....? - if (a[p] != b[p]) return true; - - return false; - }) - ).map((chunk) => ({ - ...Object.fromEntries(properties.map((p) => [p, chunk[0][p]])), - chunk, - })); -} - -export function chunkMultipleArrays(...args) { - const arrays = args.slice(0, -1); - const fn = args.at(-1); - - if (arrays[0].length === 0) { - return []; - } - - const newChunk = index => arrays.map(array => [array[index]]); - const results = [newChunk(0)]; - - for (let i = 1; i < arrays[0].length; i++) { - const current = results.at(-1); - - const args = []; - for (let j = 0; j < arrays.length; j++) { - const item = arrays[j][i]; - const previous = current[j].at(-1); - args.push(item, previous); - } - - if (fn(...args)) { - results.push(newChunk(i)); - continue; - } - - for (let j = 0; j < arrays.length; j++) { - current[j].push(arrays[j][i]); - } - } - - return results; -} - -// Delicious function annotations, such as: -// -// (*bound) soWeAreBackInTheMine -// (data *unfulfilled) generateShrekTwo -// -export function annotateFunction(fn, { - name: nameOrFunction = null, - description: newDescription, - trait: newTrait, -}) { - let name; - - if (typeof nameOrFunction === 'function') { - name = nameOrFunction.name; - } else if (typeof nameOrFunction === 'string') { - name = nameOrFunction; - } - - name ??= fn.name ?? 'anonymous'; - - const match = name.match(/^ *(?.*?) *\((?.*)( #(?.*))?\) *(?.*) *$/); - - let prefix, suffix, description, trait; - if (match) { - ({prefix, suffix, description, trait} = match.groups); - } - - prefix ??= ''; - suffix ??= name; - description ??= ''; - trait ??= ''; - - if (newDescription) { - if (description) { - description += '; ' + newDescription; - } else { - description = newDescription; - } - } - - if (newTrait) { - if (trait) { - trait += ' #' + newTrait; - } else { - trait = '#' + newTrait; - } - } - - let parenthesesPart; - - if (description && trait) { - parenthesesPart = `${description} ${trait}`; - } else if (description || trait) { - parenthesesPart = description || trait; - } else { - parenthesesPart = ''; - } - - let finalName; - - if (prefix && parenthesesPart) { - finalName = `${prefix} (${parenthesesPart}) ${suffix}`; - } else if (parenthesesPart) { - finalName = `(${parenthesesPart}) ${suffix}`; - } else { - finalName = suffix; - } - - Object.defineProperty(fn, 'name', {value: finalName}); -} diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js deleted file mode 100644 index f97ecd63..00000000 --- a/src/util/wiki-data.js +++ /dev/null @@ -1,475 +0,0 @@ -// Utility functions for interacting with wiki data. - -import {accumulateSum, empty, unique} from './sugar.js'; -import {sortByDate} from './sort.js'; - -// This is a duplicate binding of filterMultipleArrays that's included purely -// to leave wiki-data.js compatible with the release build of HSMusic. -// Sorry! This is really ridiculous!! If the next update after 10/25/2023 has -// released, this binding is no longer needed! -export {filterMultipleArrays} from './sugar.js'; - -// Generic value operations - -export function getKebabCase(name) { - return name - - // Spaces to dashes - .split(' ') - .join('-') - - // Punctuation as words - .replace(/&/g, '-and-') - .replace(/\+/g, '-plus-') - .replace(/%/g, '-percent-') - - // Punctuation which only divides words, not single characters - .replace(/(\b[^\s-.]{2,})\./g, '$1-') - .replace(/\.([^\s-.]{2,})\b/g, '-$1') - - // Punctuation which doesn't divide a number following a non-number - .replace(/(?<=[0-9])\^/g, '-') - .replace(/\^(?![0-9])/g, '-') - - // General punctuation which always separates surrounding words - .replace(/[/@#$%*()_=,[\]{}|\\;:<>?`~]/g, '-') - - // Accented characters - .replace(/[áâäàå]/gi, 'a') - .replace(/[çč]/gi, 'c') - .replace(/[éêëè]/gi, 'e') - .replace(/[íîïì]/gi, 'i') - .replace(/[óôöò]/gi, 'o') - .replace(/[úûüù]/gi, 'u') - - // Strip other characters - .replace(/[^a-z0-9-]/gi, '') - - // Combine consecutive dashes - .replace(/-{2,}/g, '-') - - // Trim dashes on boundaries - .replace(/^-+|-+$/g, '') - - // Always lowercase - .toLowerCase(); -} - -// Specific data utilities - -// Matches heading details from commentary data in roughly the formats: -// -// artistReference: (annotation, date) -// artistReference|artistDisplayText: (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 -// as a date, is the capturing group "date". "Parsing as a date" means matching -// one of these formats: -// -// * "25 December 2019" - one or two number digits, followed by any text, -// followed by four number digits -// * "December 25, 2019" - one all-letters word, a space, one or two number -// digits, a comma, and four number digits -// * "12/25/2019" etc - three sets of one to four number digits, separated -// by slashes or dashes (only valid orders are MM/DD/YYYY and YYYY/MM/DD) -// -// Note that the annotation and date are always wrapped by one opening and one -// closing parentheses. The whole heading does NOT need to match the entire -// line it occupies (though it does always start at the first position on that -// line), and if there is more than one closing parenthesis on the line, the -// annotation will always cut off only at the last parenthesis, or a comma -// preceding a date and then the last parenthesis. This is to ensure that -// parentheses can be part of the actual annotation content. -// -// Capturing group "artistReference" is all the characters between and -// (apart from the pipe and "artistDisplayText" text, if present), and is either -// the name of an artist or an "artist:directory"-style reference. -// -// 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. -// - -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>(?: \((?(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?:(?sometime|throughout|around) )?${dateRegex('date')}(?: ?- ?${dateRegex('secondDate')})?(?: (?captured|accessed) ${dateRegex('accessDate')})?)?\))?`; -export const commentaryRegexCaseInsensitive = - new RegExp(commentaryRegexRaw, 'gmi'); -export const commentaryRegexCaseSensitive = - new RegExp(commentaryRegexRaw, 'gm'); -export const commentaryRegexCaseSensitiveOneShot = - new RegExp(commentaryRegexRaw); - -export function filterAlbumsByCommentary(albums) { - return albums - .filter((album) => [album, ...album.tracks].some((x) => x.commentary)); -} - -export function getAlbumCover(album, {to}) { - // Some albums don't have art! This function returns null in that case. - if (album.hasCoverArt) { - return to('media.albumCover', album.directory, album.coverArtFileExtension); - } else { - return null; - } -} - -export function getAlbumListTag(album) { - return album.hasTrackNumbers ? 'ol' : 'ul'; -} - -// This gets all the track o8jects defined in every al8um, and sorts them 8y -// date released. Generally, albumData will pro8a8ly already 8e sorted 8efore -// you pass it to this function, 8ut individual tracks can have their own -// original release d8, distinct from the al8um's d8. I allowed that 8ecause -// in Homestuck, the first four Vol.'s were com8ined into one al8um really -// early in the history of the 8andcamp, and I still want to use that as the -// al8um listing (not the original four al8um listings), 8ut if I only did -// that, all the tracks would 8e sorted as though they were released at the -// same time as the compilation al8um - i.e, after some other al8ums (including -// Vol.'s 5 and 6!) were released. That would mess with chronological listings -// including tracks from multiple al8ums, like artist pages. So, to fix that, -// I gave tracks an Original Date field, defaulting to the release date of the -// al8um if not specified. Pretty reasona8le, I think! Oh, and this feature can -// 8e used for other projects too, like if you wanted to have an al8um listing -// compiling a 8unch of songs with radically different & interspersed release -// d8s, 8ut still keep the al8um listing in a specific order, since that isn't -// sorted 8y date. -export function getAllTracks(albumData) { - return sortByDate(albumData.flatMap((album) => album.tracks)); -} - -export function getArtistNumContributions(artist) { - return accumulateSum( - [ - unique( - ([ - artist.trackArtistContributions, - artist.trackContributorContributions, - artist.trackCoverArtistContributions, - ]).flat() - .map(({thing}) => thing)), - - artist.albumCoverArtistContributions, - artist.flashContributorContributions, - ], - ({length}) => length); -} - -export function getFlashCover(flash, {to}) { - return to('media.flashArt', flash.directory, flash.coverArtFileExtension); -} - -export function getFlashLink(flash) { - return `https://homestuck.com/story/${flash.page}`; -} - -export function getTotalDuration(tracks, { - originalReleasesOnly = false, -} = {}) { - if (originalReleasesOnly) { - tracks = tracks.filter(t => !t.originalReleaseTrack); - } - - return accumulateSum(tracks, track => track.duration); -} - -export function getTrackCover(track, {to}) { - // Some albums don't have any track art at all, and in those, every track - // just inherits the album's own cover art. Note that since cover art isn't - // guaranteed on albums either, it's possible that this function returns - // null! - if (!track.hasUniqueCoverArt) { - return getAlbumCover(track.album, {to}); - } else { - return to('media.trackCover', track.album.directory, track.directory, track.coverArtFileExtension); - } -} - -export function getArtistAvatar(artist, {to}) { - return to('media.artistAvatar', artist.directory, artist.avatarFileExtension); -} - -// Big-ass homepage row functions - -export function getNewAdditions(numAlbums, {albumData}) { - const sortedAlbums = albumData - .filter((album) => album.isListedOnHomepage) - .sort((a, b) => { - if (a.dateAddedToWiki > b.dateAddedToWiki) return -1; - if (a.dateAddedToWiki < b.dateAddedToWiki) return 1; - if (a.date > b.date) return -1; - if (a.date < b.date) return 1; - return 0; - }); - - // When multiple al8ums are added to the wiki at a time, we want to show - // all of them 8efore pulling al8ums from the next (earlier) date. We also - // want to show a diverse selection of al8ums - with limited space, we'd - // rather not show only the latest al8ums, if those happen to all 8e - // closely rel8ted! - // - // Specifically, we're concerned with avoiding too much overlap amongst - // the primary (first/top-most) group. We do this 8y collecting every - // primary group present amongst the al8ums for a given d8 into one - // (ordered) array, initially sorted (inherently) 8y latest al8um from - // the group. Then we cycle over the array, adding one al8um from each - // group until all the al8ums from that release d8 have 8een added (or - // we've met the total target num8er of al8ums). Once we've added all the - // al8ums for a given group, it's struck from the array (so the groups - // with the most additions on one d8 will have their oldest releases - // collected more towards the end of the list). - - const albums = []; - - let i = 0; - outerLoop: while (i < sortedAlbums.length) { - // 8uild up a list of groups and their al8ums 8y order of decending - // release, iter8ting until we're on a different d8. (We use a map for - // indexing so we don't have to iter8te through the entire array each - // time we access one of its entries. This is 8asically unnecessary - // since this will never 8e an expensive enough task for that to - // matter.... 8ut it's nicer code. BBBB) ) - const currentDate = sortedAlbums[i].dateAddedToWiki; - const groupMap = new Map(); - const groupArray = []; - for (let album; (album = sortedAlbums[i]) && +album.dateAddedToWiki === +currentDate; i++) { - const primaryGroup = album.groups[0]; - if (groupMap.has(primaryGroup)) { - groupMap.get(primaryGroup).push(album); - } else { - const entry = [album]; - groupMap.set(primaryGroup, entry); - groupArray.push(entry); - } - } - - // Then cycle over that sorted array, adding one al8um from each to - // the main array until we've run out or have met the target num8er - // of al8ums. - while (!empty(groupArray)) { - let j = 0; - while (j < groupArray.length) { - const entry = groupArray[j]; - const album = entry.shift(); - albums.push(album); - - // This is the only time we ever add anything to the main al8um - // list, so it's also the only place we need to check if we've - // met the target length. - if (albums.length === numAlbums) { - // If we've met it, 8r8k out of the outer loop - we're done - // here! - break outerLoop; - } - - if (empty(entry)) { - groupArray.splice(j, 1); - } else { - j++; - } - } - } - } - - return albums; -} - -export function getNewReleases(numReleases, {albumData}) { - return albumData - .filter((album) => album.isListedOnHomepage) - .reverse() - .slice(0, numReleases); -} - -// Carousel layout and utilities - -// Layout constants: -// -// Carousels support fitting 4-18 items, with a few "dead" zones to watch out -// for, namely when a multiple of 6, 5, or 4 columns would drop the last tiles. -// -// Carousels are limited to 1-3 rows and 4-6 columns. -// Lower edge case: 1-3 items are treated as 4 items (with blank space). -// Upper edge case: all items past 18 are dropped (treated as 18 items). -// -// This is all done through JS instead of CSS because it's just... ANNOYING... -// to write a mapping like this in CSS lol. -const carouselLayoutMap = [ - // 0-3 - null, null, null, null, - - // 4-6 - {rows: 1, columns: 4}, // 4: 1x4, drop 0 - {rows: 1, columns: 5}, // 5: 1x5, drop 0 - {rows: 1, columns: 6}, // 6: 1x6, drop 0 - - // 7-12 - {rows: 1, columns: 6}, // 7: 1x6, drop 1 - {rows: 2, columns: 4}, // 8: 2x4, drop 0 - {rows: 2, columns: 4}, // 9: 2x4, drop 1 - {rows: 2, columns: 5}, // 10: 2x5, drop 0 - {rows: 2, columns: 5}, // 11: 2x5, drop 1 - {rows: 2, columns: 6}, // 12: 2x6, drop 0 - - // 13-18 - {rows: 2, columns: 6}, // 13: 2x6, drop 1 - {rows: 2, columns: 6}, // 14: 2x6, drop 2 - {rows: 3, columns: 5}, // 15: 3x5, drop 0 - {rows: 3, columns: 5}, // 16: 3x5, drop 1 - {rows: 3, columns: 5}, // 17: 3x5, drop 2 - {rows: 3, columns: 6}, // 18: 3x6, drop 0 -]; - -const minCarouselLayoutItems = carouselLayoutMap.findIndex(x => x !== null); -const maxCarouselLayoutItems = carouselLayoutMap.length - 1; -const shortestCarouselLayout = carouselLayoutMap[minCarouselLayoutItems]; -const longestCarouselLayout = carouselLayoutMap[maxCarouselLayoutItems]; - -export function getCarouselLayoutForNumberOfItems(numItems) { - return ( - numItems < minCarouselLayoutItems ? shortestCarouselLayout : - numItems > maxCarouselLayoutItems ? longestCarouselLayout : - carouselLayoutMap[numItems]); -} - -export function filterItemsForCarousel(items) { - if (empty(items)) { - return []; - } - - return items - .filter(item => item.hasCoverArt) - .filter(item => item.artTags.every(tag => !tag.isContentWarning)) - .slice(0, maxCarouselLayoutItems + 1); -} - -// Ridiculous caching support nonsense - -export class TupleMap { - static maxNestedTupleLength = 25; - - #store = [undefined, null, null, null]; - - #lifetime(value) { - if (Array.isArray(value) && value.length <= TupleMap.maxNestedTupleLength) { - return 'tuple'; - } else if ( - typeof value === 'object' && value !== null || - typeof value === 'function' - ) { - return 'weak'; - } else { - return 'strong'; - } - } - - #getSubstoreShallow(value, store) { - const lifetime = this.#lifetime(value); - const mapIndex = {weak: 1, strong: 2, tuple: 3}[lifetime]; - - let map = store[mapIndex]; - if (map === null) { - map = store[mapIndex] = - (lifetime === 'weak' ? new WeakMap() - : lifetime === 'strong' ? new Map() - : lifetime === 'tuple' ? new TupleMap() - : null); - } - - if (map.has(value)) { - return map.get(value); - } else { - const substore = [undefined, null, null, null]; - map.set(value, substore); - return substore; - } - } - - #getSubstoreDeep(tuple, store = this.#store) { - if (tuple.length === 0) { - return store; - } else { - const [first, ...rest] = tuple; - return this.#getSubstoreDeep(rest, this.#getSubstoreShallow(first, store)); - } - } - - get(tuple) { - const store = this.#getSubstoreDeep(tuple); - return store[0]; - } - - has(tuple) { - const store = this.#getSubstoreDeep(tuple); - return store[0] !== undefined; - } - - set(tuple, value) { - const store = this.#getSubstoreDeep(tuple); - store[0] = value; - return value; - } -} - -export class TupleMapForBabies { - #here = new WeakMap(); - #next = new WeakMap(); - - set(...args) { - const first = args.at(0); - const last = args.at(-1); - const rest = args.slice(1, -1); - - if (empty(rest)) { - this.#here.set(first, last); - } else if (this.#next.has(first)) { - this.#next.get(first).set(...rest, last); - } else { - const tupleMap = new TupleMapForBabies(); - this.#next.set(first, tupleMap); - tupleMap.set(...rest, last); - } - } - - get(...args) { - const first = args.at(0); - const rest = args.slice(1); - - if (empty(rest)) { - return this.#here.get(first); - } else if (this.#next.has(first)) { - return this.#next.get(first).get(...rest); - } else { - return undefined; - } - } - - has(...args) { - const first = args.at(0); - const rest = args.slice(1); - - if (empty(rest)) { - return this.#here.has(first); - } else if (this.#next.has(first)) { - return this.#next.get(first).has(...rest); - } else { - return false; - } - } -} - -const combinedWikiDataTupleMap = new TupleMapForBabies(); - -export function combineWikiDataArrays(arrays) { - const map = combinedWikiDataTupleMap; - if (map.has(...arrays)) { - return map.get(...arrays); - } else { - const combined = arrays.flat(); - map.set(...arrays, combined); - return combined; - } -} diff --git a/src/web-routes.js b/src/web-routes.js index 762b26c3..b93607d6 100644 --- a/src/web-routes.js +++ b/src/web-routes.js @@ -34,7 +34,7 @@ export const stationaryCodeRoutes = [ }, { - from: path.join(codeSrcPath, 'util'), + from: path.join(codeSrcPath, 'common-util'), to: ['staticSharedUtil.root'], statically: 'copy', }, -- cgit 1.3.0-6-gf8a5