« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/util
diff options
context:
space:
mode:
Diffstat (limited to 'src/util')
-rw-r--r--src/util/cli.js210
-rw-r--r--src/util/colors.js47
-rw-r--r--src/util/html.js92
-rw-r--r--src/util/link.js67
-rw-r--r--src/util/node-utils.js27
-rw-r--r--src/util/sugar.js78
-rw-r--r--src/util/urls.js102
-rw-r--r--src/util/wiki-data.js126
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('"', '&quot;')
+        .replaceAll("'", '&apos;');
+}
+
+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>')));
+}