diff options
Diffstat (limited to 'src/util')
-rw-r--r-- | src/util/cli.js | 210 | ||||
-rw-r--r-- | src/util/colors.js | 47 | ||||
-rw-r--r-- | src/util/html.js | 92 | ||||
-rw-r--r-- | src/util/link.js | 67 | ||||
-rw-r--r-- | src/util/node-utils.js | 27 | ||||
-rw-r--r-- | src/util/sugar.js | 78 | ||||
-rw-r--r-- | src/util/urls.js | 102 | ||||
-rw-r--r-- | src/util/wiki-data.js | 126 |
8 files changed, 749 insertions, 0 deletions
diff --git a/src/util/cli.js b/src/util/cli.js new file mode 100644 index 00000000..77711566 --- /dev/null +++ b/src/util/cli.js @@ -0,0 +1,210 @@ +// Utility functions for CLI- and de8ugging-rel8ted stuff. +// +// A 8unch of these depend on process.stdout 8eing availa8le, so they won't +// work within the 8rowser. + +const logColor = color => (literals, ...values) => { + const w = s => process.stdout.write(s); + w(`\x1b[${color}m`); + for (let i = 0; i < literals.length; i++) { + w(literals[i]); + if (values[i] !== undefined) { + w(`\x1b[1m`); + w(String(values[i])); + w(`\x1b[0;${color}m`); + } + } + w(`\x1b[0m\n`); +}; + +export const logInfo = logColor(2); +export const logWarn = logColor(33); +export const logError = logColor(31); + +// Stolen as #@CK from mtui! +export async function parseOptions(options, optionDescriptorMap) { + // This function is sorely lacking in comments, but the basic usage is + // as such: + // + // options is the array of options you want to process; + // optionDescriptorMap is a mapping of option names to objects that describe + // the expected value for their corresponding options. + // Returned is a mapping of any specified option names to their values, or + // a process.exit(1) and error message if there were any issues. + // + // Here are examples of optionDescriptorMap to cover all the things you can + // do with it: + // + // optionDescriptorMap: { + // 'telnet-server': {type: 'flag'}, + // 't': {alias: 'telnet-server'} + // } + // + // options: ['t'] -> result: {'telnet-server': true} + // + // optionDescriptorMap: { + // 'directory': { + // type: 'value', + // validate(name) { + // // const whitelistedDirectories = ['apple', 'banana'] + // if (whitelistedDirectories.includes(name)) { + // return true + // } else { + // return 'a whitelisted directory' + // } + // } + // }, + // 'files': {type: 'series'} + // } + // + // ['--directory', 'apple'] -> {'directory': 'apple'} + // ['--directory', 'artichoke'] -> (error) + // ['--files', 'a', 'b', 'c', ';'] -> {'files': ['a', 'b', 'c']} + // + // TODO: Be able to validate the values in a series option. + + const handleDashless = optionDescriptorMap[parseOptions.handleDashless]; + const handleUnknown = optionDescriptorMap[parseOptions.handleUnknown]; + const result = Object.create(null); + for (let i = 0; i < options.length; i++) { + const option = options[i]; + if (option.startsWith('--')) { + // --x can be a flag or expect a value or series of values + let name = option.slice(2).split('=')[0]; // '--x'.split('=') = ['--x'] + let descriptor = optionDescriptorMap[name]; + if (!descriptor) { + if (handleUnknown) { + handleUnknown(option); + } else { + console.error(`Unknown option name: ${name}`); + process.exit(1); + } + continue; + } + if (descriptor.alias) { + name = descriptor.alias; + descriptor = optionDescriptorMap[name]; + } + if (descriptor.type === 'flag') { + result[name] = true; + } else if (descriptor.type === 'value') { + let value = option.slice(2).split('=')[1]; + if (!value) { + value = options[++i]; + if (!value || value.startsWith('-')) { + value = null; + } + } + if (!value) { + console.error(`Expected a value for --${name}`); + process.exit(1); + } + result[name] = value; + } else if (descriptor.type === 'series') { + if (!options.slice(i).includes(';')) { + console.error(`Expected a series of values concluding with ; (\\;) for --${name}`); + process.exit(1); + } + const endIndex = i + options.slice(i).indexOf(';'); + result[name] = options.slice(i + 1, endIndex); + i = endIndex; + } + if (descriptor.validate) { + const validation = await descriptor.validate(result[name]); + if (validation !== true) { + console.error(`Expected ${validation} for --${name}`); + process.exit(1); + } + } + } else if (option.startsWith('-')) { + // mtui doesn't use any -x=y or -x y format optionuments + // -x will always just be a flag + let name = option.slice(1); + let descriptor = optionDescriptorMap[name]; + if (!descriptor) { + if (handleUnknown) { + handleUnknown(option); + } else { + console.error(`Unknown option name: ${name}`); + process.exit(1); + } + continue; + } + if (descriptor.alias) { + name = descriptor.alias; + descriptor = optionDescriptorMap[name]; + } + if (descriptor.type === 'flag') { + result[name] = true; + } else { + console.error(`Use --${name} (value) to specify ${name}`); + process.exit(1); + } + } else if (handleDashless) { + handleDashless(option); + } + } + return result; +} + +export const handleDashless = Symbol(); +export const handleUnknown = Symbol(); + +export function decorateTime(functionToBeWrapped) { + const fn = function(...args) { + const start = Date.now(); + const ret = functionToBeWrapped(...args); + const end = Date.now(); + fn.timeSpent += end - start; + fn.timesCalled++; + return ret; + }; + + fn.wrappedName = functionToBeWrapped.name; + fn.timeSpent = 0; + fn.timesCalled = 0; + fn.displayTime = function() { + const averageTime = fn.timeSpent / fn.timesCalled; + console.log(`\x1b[1m${fn.wrappedName}(...):\x1b[0m ${fn.timeSpent} ms / ${fn.timesCalled} calls \x1b[2m(avg: ${averageTime} ms)\x1b[0m`); + }; + + decorateTime.decoratedFunctions.push(fn); + + return fn; +} + +decorateTime.decoratedFunctions = []; +decorateTime.displayTime = function() { + if (decorateTime.decoratedFunctions.length) { + console.log(`\x1b[1mdecorateTime results: ` + '-'.repeat(40) + '\x1b[0m'); + for (const fn of decorateTime.decoratedFunctions) { + fn.displayTime(); + } + } +}; + +export function progressPromiseAll(msgOrMsgFn, array) { + if (!array.length) { + return Promise.resolve([]); + } + + const msgFn = (typeof msgOrMsgFn === 'function' + ? msgOrMsgFn + : () => msgOrMsgFn); + + let done = 0, total = array.length; + process.stdout.write(`\r${msgFn()} [0/${total}]`); + const start = Date.now(); + return Promise.all(array.map(promise => promise.then(val => { + done++; + // const pc = `${done}/${total}`; + const pc = (Math.round(done / total * 1000) / 10 + '%').padEnd('99.9%'.length, ' '); + if (done === total) { + const time = Date.now() - start; + process.stdout.write(`\r\x1b[2m${msgFn()} [${pc}] \x1b[0;32mDone! \x1b[0;2m(${time} ms) \x1b[0m\n`) + } else { + process.stdout.write(`\r${msgFn()} [${pc}] `); + } + return val; + }))); +} diff --git a/src/util/colors.js b/src/util/colors.js new file mode 100644 index 00000000..1df591bf --- /dev/null +++ b/src/util/colors.js @@ -0,0 +1,47 @@ +// Color and theming utility functions! Handy. + +// Graciously stolen from https://stackoverflow.com/a/54071699! ::::) +// in: r,g,b in [0,1], out: h in [0,360) and s,l in [0,1] +export function rgb2hsl(r, g, b) { + let a=Math.max(r,g,b), n=a-Math.min(r,g,b), f=(1-Math.abs(a+a-n-1)); + let h= n && ((a==r) ? (g-b)/n : ((a==g) ? 2+(b-r)/n : 4+(r-g)/n)); + return [60*(h<0?h+6:h), f ? n/f : 0, (a+a-n)/2]; +} + +export function getColors(primary) { + const [ r, g, b ] = primary.slice(1) + .match(/[0-9a-fA-F]{2,2}/g) + .slice(0, 3) + .map(val => parseInt(val, 16) / 255); + const [ h, s, l ] = rgb2hsl(r, g, b); + const dim = `hsl(${Math.round(h)}deg, ${Math.round(s * 50)}%, ${Math.round(l * 80)}%)`; + + return {primary, dim}; +} + +export function getLinkThemeString(color) { + if (!color) return ''; + + const { primary, dim } = getColors(color); + return `--primary-color: ${primary}; --dim-color: ${dim}`; +} + +export function getThemeString(color, additionalVariables = []) { + if (!color) return ''; + + const { primary, dim } = getColors(color); + + const variables = [ + `--primary-color: ${primary}`, + `--dim-color: ${dim}`, + ...additionalVariables + ].filter(Boolean); + + if (!variables.length) return ''; + + return ( + `:root {\n` + + variables.map(line => ` ` + line + ';\n').join('') + + `}` + ); +} diff --git a/src/util/html.js b/src/util/html.js new file mode 100644 index 00000000..4895301b --- /dev/null +++ b/src/util/html.js @@ -0,0 +1,92 @@ +// Some really simple functions for formatting HTML content. + +// Non-comprehensive. ::::P +export const selfClosingTags = ['br', 'img']; + +// Pass to tag() as an attri8utes key to make tag() return a 8lank string +// if the provided content is empty. Useful for when you'll only 8e showing +// an element according to the presence of content that would 8elong there. +export const onlyIfContent = Symbol(); + +export function tag(tagName, ...args) { + const selfClosing = selfClosingTags.includes(tagName); + + let openTag; + let content; + let attrs; + + if (typeof args[0] === 'object' && !Array.isArray(args[0])) { + attrs = args[0]; + content = args[1]; + } else { + content = args[0]; + } + + if (selfClosing && content) { + throw new Error(`Tag <${tagName}> is self-closing but got content!`); + } + + if (attrs?.[onlyIfContent] && !content) { + return ''; + } + + if (attrs) { + const attrString = attributes(args[0]); + if (attrString) { + openTag = `${tagName} ${attrString}`; + } + } + + if (!openTag) { + openTag = tagName; + } + + if (Array.isArray(content)) { + content = content.filter(Boolean).join('\n'); + } + + if (content) { + if (content.includes('\n')) { + return ( + `<${openTag}>\n` + + content.split('\n').map(line => ' ' + line + '\n').join('') + + `</${tagName}>` + ); + } else { + return `<${openTag}>${content}</${tagName}>`; + } + } else { + if (selfClosing) { + return `<${openTag}>`; + } else { + return `<${openTag}></${tagName}>`; + } + } +} + +export function escapeAttributeValue(value) { + return value + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function attributes(attribs) { + return Object.entries(attribs) + .map(([ key, val ]) => { + if (!val) + return [key, val]; + else if (typeof val === 'string' || typeof val === 'boolean') + return [key, val]; + else if (typeof val === 'number') + return [key, val.toString()]; + else if (Array.isArray(val)) + return [key, val.join(' ')]; + else + throw new Error(`Attribute value for ${key} should be primitive or array, got ${typeof val}`); + }) + .filter(([ key, val ]) => val) + .map(([ key, val ]) => (typeof val === 'boolean' + ? `${key}` + : `${key}="${escapeAttributeValue(val)}"`)) + .join(' '); +} diff --git a/src/util/link.js b/src/util/link.js new file mode 100644 index 00000000..e5c3c596 --- /dev/null +++ b/src/util/link.js @@ -0,0 +1,67 @@ +// This file is essentially one level of a8straction a8ove urls.js (and the +// urlSpec it gets its paths from). It's a 8unch of utility functions which +// take certain types of wiki data o8jects (colloquially known as "things") +// and return actual <a href> HTML link tags. +// +// The functions we're cre8ting here (all factory-style) take a "to" argument, +// which is roughly a function which takes a urlSpec key and spits out a path +// to 8e stuck in an href or src or suchever. There are also a few other +// options availa8le in all the functions, making a common interface for +// gener8ting just a8out any link on the site. + +import * as html from './html.js' +import { getLinkThemeString } from './colors.js' + +const linkHelper = (hrefFn, {color = true, attr = null} = {}) => + (thing, { + strings, to, + text = '', + class: className = '', + hash = '' + }) => ( + html.tag('a', { + ...attr ? attr(thing) : {}, + href: hrefFn(thing, {to}) + (hash ? (hash.startsWith('#') ? '' : '#') + hash : ''), + style: color ? getLinkThemeString(thing.color) : '', + class: className + }, text || thing.name) + ); + +const linkDirectory = (key, {expose = null, attr = null, ...conf} = {}) => + linkHelper((thing, {to}) => to('localized.' + key, thing.directory), { + attr: thing => ({ + ...attr ? attr(thing) : {}, + ...expose ? {[expose]: thing.directory} : {} + }), + ...conf + }); + +const linkPathname = (key, conf) => linkHelper(({directory: pathname}, {to}) => to(key, pathname), conf); +const linkIndex = (key, conf) => linkHelper((_, {to}) => to('localized.' + key), conf); + +const link = { + album: linkDirectory('album'), + albumCommentary: linkDirectory('albumCommentary'), + artist: linkDirectory('artist', {color: false}), + artistGallery: linkDirectory('artistGallery', {color: false}), + commentaryIndex: linkIndex('commentaryIndex', {color: false}), + flashIndex: linkIndex('flashIndex', {color: false}), + flash: linkDirectory('flash'), + groupInfo: linkDirectory('groupInfo'), + groupGallery: linkDirectory('groupGallery'), + home: linkIndex('home', {color: false}), + listingIndex: linkIndex('listingIndex'), + listing: linkDirectory('listing'), + newsIndex: linkIndex('newsIndex', {color: false}), + newsEntry: linkDirectory('newsEntry', {color: false}), + staticPage: linkDirectory('staticPage', {color: false}), + tag: linkDirectory('tag'), + track: linkDirectory('track', {expose: 'data-track'}), + + media: linkPathname('media.path', {color: false}), + root: linkPathname('shared.path', {color: false}), + data: linkPathname('data.path', {color: false}), + site: linkPathname('localized.path', {color: false}) +}; + +export default link; diff --git a/src/util/node-utils.js b/src/util/node-utils.js new file mode 100644 index 00000000..d660612e --- /dev/null +++ b/src/util/node-utils.js @@ -0,0 +1,27 @@ +// Utility functions which are only relevant to particular Node.js constructs. + +// Very cool function origin8ting in... http-music pro8a8ly! +// Sorry if we happen to 8e violating past-us's copyright, lmao. +export function promisifyProcess(proc, showLogging = true) { + // Takes a process (from the child_process module) and returns a promise + // that resolves when the process exits (or rejects, if the exit code is + // non-zero). + // + // Ayy look, no alpha8etical second letter! Couldn't tell this was written + // like three years ago 8efore I was me. 8888) + + return new Promise((resolve, reject) => { + if (showLogging) { + proc.stdout.pipe(process.stdout); + proc.stderr.pipe(process.stderr); + } + + proc.on('exit', code => { + if (code === 0) { + resolve(); + } else { + reject(code); + } + }) + }) +} diff --git a/src/util/sugar.js b/src/util/sugar.js new file mode 100644 index 00000000..c24c617c --- /dev/null +++ b/src/util/sugar.js @@ -0,0 +1,78 @@ +// 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; + } +}; + +export const mapInPlace = (array, fn) => array.splice(0, array.length, ...array.map(fn)); + +export const filterEmptyLines = string => string.split('\n').filter(line => line.trim()).join('\n'); + +export const unique = arr => Array.from(new Set(arr)); + +// Stolen from jq! Which pro8a8ly stole the concept from other places. Nice. +export const withEntries = (obj, fn) => Object.fromEntries(fn(Object.entries(obj))); + +// Nothin' more to it than what it says. Runs a function in-place. Provides an +// altern8tive syntax to the usual IIFEs (e.g. (() => {})()) when you want to +// open a scope and run some statements while inside an existing expression. +export const call = fn => fn(); + +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)); +} + +// 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 yet: https://github.com/tc39/proposal-regex-escaping +export function escapeRegex(string) { + return string.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); +} diff --git a/src/util/urls.js b/src/util/urls.js new file mode 100644 index 00000000..f0f9cdb1 --- /dev/null +++ b/src/util/urls.js @@ -0,0 +1,102 @@ +// Code that deals with URLs (really the pathnames that get referenced all +// throughout the gener8ted HTML). Most nota8ly here is generateURLs, which +// is in charge of pre-gener8ting a complete network of template strings +// which can really quickly take su8stitute parameters to link from any one +// place to another; 8ut there are also a few other utilities, too. +// +// Nota8ly, everything here is string-8ased, for gener8ting and transforming +// actual path strings. More a8stract operations using wiki data o8jects is +// the domain of link.js. + +import * as path from 'path'; +import { withEntries } from './sugar.js'; + +export function generateURLs(urlSpec) { + const getValueForFullKey = (obj, fullKey, prop = null) => { + const [ groupKey, subKey ] = fullKey.split('.'); + if (!groupKey || !subKey) { + throw new Error(`Expected group key and subkey (got ${fullKey})`); + } + + if (!obj.hasOwnProperty(groupKey)) { + throw new Error(`Expected valid group key (got ${groupKey})`); + } + + const group = obj[groupKey]; + + if (!group.hasOwnProperty(subKey)) { + throw new Error(`Expected valid subkey (got ${subKey} for group ${groupKey})`); + } + + return { + value: group[subKey], + group + }; + }; + + const generateTo = (fromPath, fromGroup) => { + const rebasePrefix = '../'.repeat((fromGroup.prefix || '').split('/').filter(Boolean).length); + + const pathHelper = (toPath, toGroup) => { + let target = toPath; + + let argIndex = 0; + target = target.replaceAll('<>', () => `<${argIndex++}>`); + + if (toGroup.prefix !== fromGroup.prefix) { + // TODO: Handle differing domains in prefixes. + target = rebasePrefix + (toGroup.prefix || '') + target; + } + + return (path.relative(fromPath, target) + + (toPath.endsWith('/') ? '/' : '')); + }; + + const groupSymbol = Symbol(); + + const groupHelper = urlGroup => ({ + [groupSymbol]: urlGroup, + ...withEntries(urlGroup.paths, entries => entries + .map(([key, path]) => [key, pathHelper(path, urlGroup)])) + }); + + const relative = withEntries(urlSpec, entries => entries + .map(([key, urlGroup]) => [key, groupHelper(urlGroup)])); + + const to = (key, ...args) => { + const { value: template, group: {[groupSymbol]: toGroup} } = getValueForFullKey(relative, key) + let result = template.replaceAll(/<([0-9]+)>/g, (match, n) => args[n]); + + // Kinda hacky lol, 8ut it works. + const missing = result.match(/<([0-9]+)>/g); + if (missing) { + throw new Error(`Expected ${missing[missing.length - 1]} arguments, got ${args.length}`); + } + + return result; + }; + + return {to, relative}; + }; + + const generateFrom = () => { + const map = withEntries(urlSpec, entries => entries + .map(([key, group]) => [key, withEntries(group.paths, entries => entries + .map(([key, path]) => [key, generateTo(path, group)]) + )])); + + const from = key => getValueForFullKey(map, key).value; + + return {from, map}; + }; + + return generateFrom(); +} + +const thumbnailHelper = name => file => + file.replace(/\.(jpg|png)$/, name + '.jpg'); + +export const thumb = { + medium: thumbnailHelper('.medium'), + small: thumbnailHelper('.small') +}; diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js new file mode 100644 index 00000000..e76722bd --- /dev/null +++ b/src/util/wiki-data.js @@ -0,0 +1,126 @@ +// Utility functions for interacting with wiki data. + +// Generic value operations + +export function getKebabCase(name) { + return name + .split(' ') + .join('-') + .replace(/&/g, 'and') + .replace(/[^a-zA-Z0-9\-]/g, '') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); +} + +export function chunkByConditions(array, conditions) { + if (array.length === 0) { + return []; + } else if (conditions.length === 0) { + 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 + })); +} + +// Sorting functions + +export function sortByName(a, b) { + let an = a.name.toLowerCase(); + let bn = b.name.toLowerCase(); + if (an.startsWith('the ')) an = an.slice(4); + if (bn.startsWith('the ')) bn = bn.slice(4); + return an < bn ? -1 : an > bn ? 1 : 0; +} + +// This function was originally made to sort just al8um data, 8ut its exact +// code works fine for sorting tracks too, so I made the varia8les and names +// more general. +export function sortByDate(data) { + // Just to 8e clear: sort is a mutating function! I only return the array + // 8ecause then you don't have to define it as a separate varia8le 8efore + // passing it into this function. + return data.sort((a, b) => a.date - b.date); +} + +// Same details as the sortByDate, 8ut for covers~ +export function sortByArtDate(data) { + return data.sort((a, b) => (a.coverArtDate || a.date) - (b.coverArtDate || b.date)); +} + +// Specific data utilities + +// 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 ( + artist.tracks.asAny.length + + artist.albums.asCoverArtist.length + + (artist.flashes ? artist.flashes.asContributor.length : 0) + ); +} + +export function getArtistCommentary(artist, {justEverythingMan}) { + return justEverythingMan.filter(thing => + (thing?.commentary + .replace(/<\/?b>/g, '') + .includes('<i>' + artist.name + ':</i>'))); +} |