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 --- 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 +++++++++++++++++++++++ 6 files changed, 2138 insertions(+) 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 (limited to 'src/common-util') 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; + } +} -- cgit 1.3.0-6-gf8a5