« 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/external-links.js679
-rw-r--r--src/util/html.js4
-rw-r--r--src/util/sugar.js175
-rw-r--r--src/util/wiki-data.js35
4 files changed, 860 insertions, 33 deletions
diff --git a/src/util/external-links.js b/src/util/external-links.js
new file mode 100644
index 00000000..0a4a77cf
--- /dev/null
+++ b/src/util/external-links.js
@@ -0,0 +1,679 @@
+import {empty, stitchArrays} from '#sugar';
+
+import {
+  is,
+  isObject,
+  isStringNonEmpty,
+  oneOf,
+  optional,
+  validateArrayItems,
+  validateInstanceOf,
+  validateProperties,
+} from '#validators';
+
+export const externalLinkStyles = [
+  'normal',
+  'compact',
+  'platform',
+  'icon-id',
+];
+
+export const isExternalLinkStyle = is(...externalLinkStyles);
+
+export const externalLinkContexts = [
+  'album',
+  'artist',
+  'flash',
+  'generic',
+  'group',
+  'track',
+];
+
+export const isExternalLinkContext = is(...externalLinkContexts);
+
+// This might need to be adjusted for YAML importing...
+const isRegExp =
+  validateInstanceOf(RegExp);
+
+export const isExternalLinkExtractSpec =
+  validateProperties({
+    prefix: optional(isStringNonEmpty),
+
+    url: optional(isRegExp),
+    domain: optional(isRegExp),
+    pathname: optional(isRegExp),
+    query: optional(isRegExp),
+  });
+
+export const isExternalLinkSpec =
+  validateArrayItems(
+    validateProperties({
+      match: validateProperties({
+        // TODO: Don't allow providing both of these, and require providing one
+        domain: optional(isStringNonEmpty),
+        domains: optional(validateArrayItems(isStringNonEmpty)),
+
+        // TODO: Don't allow providing both of these
+        pathname: optional(isRegExp),
+        pathnames: optional(validateArrayItems(isRegExp)),
+
+        // TODO: Don't allow providing both of these
+        query: optional(isRegExp),
+        queries: optional(validateArrayItems(isRegExp)),
+
+        context:
+          optional(oneOf(
+            isExternalLinkContext,
+            validateArrayItems(isExternalLinkContext))),
+      }),
+
+      platform: isStringNonEmpty,
+      substring: optional(isStringNonEmpty),
+
+      // TODO: Don't allow 'handle' or 'custom' options if the corresponding
+      // properties aren't provided
+      normal: optional(is('domain', 'handle', 'custom')),
+      compact: optional(is('domain', 'handle', 'custom')),
+      icon: optional(isStringNonEmpty),
+
+      handle: optional(isExternalLinkExtractSpec),
+
+      // TODO: This should validate each value with isExternalLinkExtractSpec.
+      custom: optional(isObject),
+    }));
+
+export const fallbackDescriptor = {
+  platform: 'external',
+
+  normal: 'domain',
+  compact: 'domain',
+  icon: 'globe',
+};
+
+// TODO: Define all this stuff in data as YAML!
+export const externalLinkSpec = [
+  // Special handling for album links
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtube.com',
+      pathname: /^playlist/,
+    },
+
+    platform: 'youtube',
+    substring: 'playlist',
+
+    icon: 'youtube',
+  },
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtube.com',
+      pathname: /^watch/,
+    },
+
+    platform: 'youtube',
+    substring: 'fullAlbum',
+
+    icon: 'youtube',
+  },
+
+  {
+    match: {
+      context: 'album',
+      domain: 'youtu.be',
+    },
+
+    platform: 'youtube',
+    substring: 'fullAlbum',
+
+    icon: 'youtube',
+  },
+
+  // Special handling for artist links
+
+  {
+    match: {
+      domain: 'patreon.com',
+      context: 'artist',
+    },
+
+    platform: 'patreon',
+
+    normal: 'handle',
+    compact: 'handle',
+    icon: 'globe',
+
+    handle: /([^/]*)\/?$/,
+  },
+
+  {
+    match: {
+      context: 'artist',
+      domain: 'youtube.com',
+    },
+
+    platform: 'youtube',
+
+    normal: 'handle',
+    compact: 'handle',
+    icon: 'youtube',
+
+    handle: {
+      pathname: /^(@.*?)\/?$/,
+    },
+  },
+
+  // Special handling for flash links
+
+  {
+    match: {
+      context: 'flash',
+      domain: 'bgreco.net',
+    },
+
+    platform: 'bgreco',
+    substring: 'flash',
+
+    icon: 'globe',
+  },
+
+  // This takes precedence over the secretPage match below.
+  {
+    match: {
+      context: 'flash',
+      domain: 'homestuck.com',
+      pathname: /^story\/[0-9]+\/?$/,
+    },
+
+    platform: 'homestuck',
+    substring: 'page',
+
+    normal: 'custom',
+    icon: 'globe',
+
+    custom: {
+      page: {
+        pathname: /[0-9]+/,
+      },
+    },
+  },
+
+  {
+    match: {
+      context: 'flash',
+      domain: 'homestuck.com',
+      pathname: /^story\/.+\/?$/,
+    },
+
+    platform: 'homestuck',
+    substring: 'secretPage',
+
+    icon: 'globe',
+  },
+
+  {
+    match: {
+      context: 'flash',
+      domains: ['youtube.com', 'youtu.be'],
+    },
+
+    platform: 'youtube',
+    substring: 'flash',
+
+    icon: 'youtube',
+  },
+
+  // Generic domains, sorted alphabetically (by string)
+
+  {
+    match: {domains: ['bc.s3m.us', 'music.solatrus.com']},
+
+    platform: 'bandcamp',
+
+    normal: 'domain',
+    compact: 'domain',
+    icon: 'bandcamp',
+  },
+
+  {
+    match: {domain: '.bandcamp.com'},
+
+    platform: 'bandcamp',
+
+    compact: 'handle',
+    icon: 'bandcamp',
+
+    handle: {domain: /^[^.]*/},
+  },
+
+  {
+    match: {domain: 'deviantart.com'},
+    platform: 'deviantart',
+    icon: 'deviantart',
+  },
+
+  {
+    match: {domain: 'homestuck.com'},
+    platform: 'homestuck',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'hsmusic.wiki'},
+    platform: 'local',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'instagram.com'},
+    platform: 'instagram',
+    icon: 'instagram',
+  },
+
+  {
+    match: {domains: ['types.pl']},
+
+    platform: 'mastodon',
+
+    normal: 'domain',
+    compact: 'domain',
+    icon: 'mastodon',
+  },
+
+  {
+    match: {domain: 'newgrounds.com'},
+    platform: 'newgrounds',
+    icon: 'newgrounds',
+  },
+
+  {
+    match: {domain: 'patreon.com'},
+    platform: 'patreon',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'poetryfoundation.org'},
+    platform: 'poetryFoundation',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: 'soundcloud.com'},
+
+    platform: 'soundcloud',
+
+    compact: 'handle',
+    icon: 'soundcloud',
+
+    handle: /([^/]*)\/?$/,
+  },
+
+  {
+    match: {domain: 'spotify.com'},
+    platform: 'spotify',
+    icon: 'globe',
+  },
+
+  {
+    match: {domain: '.tumblr.com'},
+
+    platform: 'tumblr',
+
+    compact: 'handle',
+    icon: 'tumblr',
+
+    handle: {domain: /^[^.]*/},
+  },
+
+  {
+    match: {domain: 'twitter.com'},
+
+    platform: 'twitter',
+
+    compact: 'handle',
+    icon: 'twitter',
+
+    handle: {
+      prefix: '@',
+      pathname: /^@?([a-zA-Z0-9_]*)\/?$/,
+    },
+  },
+
+  {
+    match: {domain: 'wikipedia.org'},
+    platform: 'wikipedia',
+    icon: 'misc',
+  },
+
+  {
+    match: {domains: ['youtube.com', 'youtu.be']},
+    platform: 'youtube',
+    icon: 'youtube',
+  },
+];
+
+function urlParts(url) {
+  const {
+    hostname: domain,
+    pathname,
+    search: query,
+  } = new URL(url);
+
+  return {domain, pathname, query};
+}
+
+function createEmptyResults() {
+  return Object.fromEntries(externalLinkStyles.map(style => [style, null]));
+}
+
+export function getMatchingDescriptorsForExternalLink(url, descriptors, {
+  context = 'generic',
+} = {}) {
+  const {domain, pathname, query} = urlParts(url);
+
+  const compareDomain = string => domain.includes(string);
+  const comparePathname = regex => regex.test(pathname.slice(1));
+  const compareQuery = regex => regex.test(query.slice(1));
+
+  const matchingDescriptors =
+    descriptors
+      .filter(({match}) => {
+        if (match.domain) return compareDomain(match.domain);
+        if (match.domains) return match.domains.some(compareDomain);
+        return false;
+      })
+      .filter(({match}) => {
+        if (Array.isArray(match.context)) return match.context.includes(context);
+        if (match.context) return context === match.context;
+        return true;
+      })
+      .filter(({match}) => {
+        if (match.pathname) return comparePathname(match.pathname);
+        if (match.pathnames) return match.pathnames.some(comparePathname);
+        return true;
+      })
+      .filter(({match}) => {
+        if (match.query) return compareQuery(match.query);
+        if (match.queries) return match.quieries.some(compareQuery);
+        return true;
+      });
+
+  return [...matchingDescriptors, fallbackDescriptor];
+}
+
+export function extractPartFromExternalLink(url, extract) {
+  const {domain, pathname, query} = urlParts(url);
+
+  let regexen = [];
+  let tests = [];
+  let prefix = '';
+
+  if (extract instanceof RegExp) {
+    regexen.push(extract);
+    tests.push(url);
+  } else {
+    for (const [key, value] of Object.entries(extract)) {
+      switch (key) {
+        case 'prefix':
+          prefix = value;
+          continue;
+
+        case 'url':
+          tests.push(url);
+          break;
+
+        case 'domain':
+          tests.push(domain);
+          break;
+
+        case 'pathname':
+          tests.push(pathname.slice(1));
+          break;
+
+        case 'query':
+          tests.push(query.slice(1));
+          break;
+
+        default:
+          tests.push('');
+          break;
+      }
+
+      regexen.push(value);
+    }
+  }
+
+  for (const {regex, test} of stitchArrays({
+    regex: regexen,
+    test: tests,
+  })) {
+    const match = test.match(regex);
+    if (match) {
+      return prefix + (match[1] ?? match[0]);
+    }
+  }
+
+  return null;
+}
+
+export function extractAllCustomPartsFromExternalLink(url, custom) {
+  const customParts = {};
+
+  // All or nothing: if one part doesn't match, all results are scrapped.
+  for (const [key, value] of Object.entries(custom)) {
+    customParts[key] = extractPartFromExternalLink(url, value);
+    if (!customParts[key]) return null;
+  }
+
+  return customParts;
+}
+
+export function getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language}) {
+  const prefix = 'misc.external';
+
+  function getPlatform() {
+    return language.$(prefix, descriptor.platform);
+  }
+
+  function getDomain() {
+    return urlParts(url).domain;
+  }
+
+  function getCustom() {
+    if (!descriptor.custom) {
+      return null;
+    }
+
+    const customParts =
+      extractAllCustomPartsFromExternalLink(url, descriptor.custom);
+
+    if (!customParts) {
+      return null;
+    }
+
+    return language.$(prefix, descriptor.platform, descriptor.substring, customParts);
+  }
+
+  function getHandle() {
+    if (!descriptor.handle) {
+      return null;
+    }
+
+    return extractPartFromExternalLink(url, descriptor.handle);
+  }
+
+  function getNormal() {
+    if (descriptor.custom) {
+      if (descriptor.normal === 'custom') {
+        return getCustom();
+      } else {
+        return null;
+      }
+    }
+
+    if (descriptor.normal === 'domain') {
+      const platform = getPlatform();
+      const domain = getDomain();
+
+      if (!platform || !domain) {
+        return null;
+      }
+
+      return language.$(prefix, 'withDomain', {platform, domain});
+    }
+
+    if (descriptor.normal === 'handle') {
+      const platform = getPlatform();
+      const handle = getHandle();
+
+      if (!platform || !handle) {
+        return null;
+      }
+
+      return language.$(prefix, 'withHandle', {platform, handle});
+    }
+
+    return language.$(prefix, descriptor.platform, descriptor.substring);
+  }
+
+  function getCompact() {
+    if (descriptor.custom) {
+      if (descriptor.compact === 'custom') {
+        return getCustom();
+      } else {
+        return null;
+      }
+    }
+
+    if (descriptor.compact === 'domain') {
+      const domain = getDomain();
+
+      if (!domain) {
+        return null;
+      }
+
+      return language.sanitize(domain.replace(/^www\./, ''));
+    }
+
+    if (descriptor.compact === 'handle') {
+      const handle = getHandle();
+
+      if (!handle) {
+        return null;
+      }
+
+      return language.sanitize(handle);
+    }
+  }
+
+  function getIconId() {
+    return descriptor.icon ?? null;
+  }
+
+  switch (style) {
+    case 'normal': return getNormal();
+    case 'compact': return getCompact();
+    case 'platform': return getPlatform();
+    case 'icon-id': return getIconId();
+  }
+}
+
+export function couldDescriptorSupportStyle(descriptor, style) {
+  if (style === 'normal') {
+    if (descriptor.custom) {
+      return descriptor.normal === 'custom';
+    } else {
+      return true;
+    }
+  }
+
+  if (style === 'compact') {
+    if (descriptor.custom) {
+      return descriptor.compact === 'custom';
+    } else {
+      return !!descriptor.compact;
+    }
+  }
+
+  if (style === 'platform') {
+    return true;
+  }
+
+  if (style === 'icon-id') {
+    return !!descriptor.icon;
+  }
+}
+
+export function getExternalLinkStringOfStyleFromDescriptors(url, style, descriptors, {
+  language,
+  context = 'generic',
+}) {
+  const matchingDescriptors =
+    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
+
+  const styleFilteredDescriptors =
+    matchingDescriptors.filter(descriptor =>
+      couldDescriptorSupportStyle(descriptor, style));
+
+  for (const descriptor of styleFilteredDescriptors) {
+    const descriptorResult =
+      getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language});
+
+    if (descriptorResult) {
+      return descriptorResult;
+    }
+  }
+
+  return null;
+}
+
+export function getExternalLinkStringsFromDescriptor(url, descriptor, {language}) {
+  const getStyle = style =>
+    getExternalLinkStringOfStyleFromDescriptor(url, style, descriptor, {language});
+
+  return {
+    'normal': getStyle('normal'),
+    'compact': getStyle('compact'),
+    'platform': getStyle('platform'),
+    'icon-id': getStyle('icon-id'),
+  };
+}
+
+export function getExternalLinkStringsFromDescriptors(url, descriptors, {
+  language,
+  context = 'generic',
+}) {
+  const results = createEmptyResults();
+  const remainingKeys = new Set(Object.keys(results));
+
+  const matchingDescriptors =
+    getMatchingDescriptorsForExternalLink(url, descriptors, {context});
+
+  for (const descriptor of matchingDescriptors) {
+    const descriptorResults =
+      getExternalLinkStringsFromDescriptor(url, descriptor, {language});
+
+    const descriptorKeys =
+      new Set(
+        Object.entries(descriptorResults)
+          .filter(entry => entry[1])
+          .map(entry => entry[0]));
+
+    for (const key of remainingKeys) {
+      if (descriptorKeys.has(key)) {
+        results[key] = descriptorResults[key];
+        remainingKeys.delete(key);
+      }
+    }
+
+    if (empty(remainingKeys)) {
+      return results;
+    }
+  }
+
+  return results;
+}
diff --git a/src/util/html.js b/src/util/html.js
index 282a52da..5b6743e0 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -181,6 +181,10 @@ export function tags(content, attributes = null) {
   return new Tag(null, attributes, content);
 }
 
+export function normalize(content) {
+  return Tag.normalize(content);
+}
+
 export class Tag {
   #tagName = '';
   #content = null;
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 9646be37..cee3df12 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -250,6 +250,16 @@ export function typeAppearance(value) {
   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) {
+    return text.slice(0, Math.max(1, length - 3)) + '...';
+  } else {
+    return text;
+  }
+}
+
 // 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
@@ -315,6 +325,12 @@ export function openAggregate({
   // constructed.
   message = '',
 
+  // Optional flag to indicate that this layer of the aggregate error isn't
+  // generally useful outside of developer debugging purposes - it will be
+  // skipped by default when using showAggregate, showing contained errors
+  // inline with other children of this aggregate's parent.
+  translucent = false,
+
   // Value to return when a provided function throws an error. If this is a
   // function, it will be called with the arguments given to the function.
   // (This is primarily useful when wrapping a function and then providing it
@@ -397,7 +413,13 @@ export function openAggregate({
 
   aggregate.close = () => {
     if (errors.length) {
-      throw Reflect.construct(errorClass, [errors, message]);
+      const error = Reflect.construct(errorClass, [errors, message]);
+
+      if (translucent) {
+        error[Symbol.for(`hsmusic.aggregate.translucent`)] = true;
+      }
+
+      throw error;
     }
   };
 
@@ -567,37 +589,123 @@ export function _withAggregate(mode, aggregateOpts, fn) {
   }
 }
 
+export const unhelpfulStackLines = [
+  /sugar/,
+  /node:/,
+  /<anonymous>/,
+];
+
+export function getUsefulStackLine(stack) {
+  if (!stack) return '';
+
+  function isUseful(stackLine) {
+    const trimmed = stackLine.trim();
+
+    if (!trimmed.startsWith('at'))
+      return false;
+
+    if (unhelpfulStackLines.some(regex => regex.test(trimmed)))
+      return false;
+
+    return true;
+  }
+
+  const stackLines = stack.split('\n');
+  const usefulStackLine = stackLines.find(isUseful);
+  return usefulStackLine ?? '';
+}
+
 export function showAggregate(topError, {
   pathToFileURL = f => f,
   showTraces = true,
+  showTranslucent = showTraces,
   print = true,
 } = {}) {
-  const recursive = (error, {level}) => {
-    let headerPart = showTraces
-      ? `[${error.constructor.name || 'unnamed'}] ${
-          error.message || '(no message)'
-        }`
-      : error instanceof AggregateError
-      ? `[${error.message || '(no message)'}]`
-      : error.message || '(no message)';
+  const translucentSymbol = Symbol.for('hsmusic.aggregate.translucent');
+
+  const determineCause = error => {
+    let cause = error.cause;
+    if (showTranslucent) return cause ?? null;
+
+    while (cause) {
+      if (!cause[translucentSymbol]) return cause;
+      cause = cause.cause;
+    }
+
+    return null;
+  };
+
+  const determineErrors = parentError => {
+    if (!parentError.errors) return null;
+    if (showTranslucent) return parentError.errors;
+
+    const errors = [];
+    for (const error of parentError.errors) {
+      if (!error[translucentSymbol]) {
+        errors.push(error);
+        continue;
+      }
+
+      if (error.cause) {
+        errors.push(determineCause(error));
+      }
+
+      if (error.errors) {
+        errors.push(...determineErrors(error));
+      }
+    }
+
+    return errors;
+  };
+
+  const flattenErrorStructure = (error, level = 0) => {
+    const cause = determineCause(error);
+    const errors = determineErrors(error);
+
+    return {
+      level,
+
+      kind: error.constructor.name,
+      message: error.message,
+      stack: error.stack,
+
+      cause:
+        (cause
+          ? flattenErrorStructure(cause, level + 1)
+          : null),
+
+      errors:
+        (errors
+          ? errors.map(error => flattenErrorStructure(error, level + 1))
+          : null),
+    };
+  };
+
+  const recursive = ({level, kind, message, stack, cause, errors}) => {
+    const messagePart =
+      message || `(no message)`;
+
+    const kindPart =
+      kind || `unnamed kind`;
+
+    let headerPart =
+      (showTraces
+        ? `[${kindPart}] ${messagePart}`
+     : errors
+        ? `[${messagePart}]`
+        : messagePart);
 
     if (showTraces) {
-      const stackLines = error.stack?.split('\n');
-
-      const stackLine = stackLines?.find(
-        (line) =>
-          line.trim().startsWith('at') &&
-          !line.includes('sugar') &&
-          !line.includes('node:') &&
-          !line.includes('<anonymous>')
-      );
+      const stackLine =
+        getUsefulStackLine(stack);
 
-      const tracePart = stackLine
-        ? '- ' +
-          stackLine
-            .trim()
-            .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match))
-        : '(no stack trace)';
+      const tracePart =
+        (stackLine
+          ? '- ' +
+            stackLine
+              .trim()
+              .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match))
+          : '(no stack trace)');
 
       headerPart += ` ${colors.dim(tracePart)}`;
     }
@@ -606,8 +714,8 @@ export function showAggregate(topError, {
     const bar1 = ' ';
 
     const causePart =
-      (error.cause
-        ? recursive(error.cause, {level: level + 1})
+      (cause
+        ? recursive(cause)
             .split('\n')
             .map((line, i) => i === 0 ? ` ${head1} ${line}` : ` ${bar1} ${line}`)
             .join('\n')
@@ -616,19 +724,20 @@ export function showAggregate(topError, {
     const head2 = level % 2 === 0 ? '\u257f' : colors.dim('\u257f');
     const bar2 = level % 2 === 0 ? '\u2502' : colors.dim('\u254e');
 
-    const aggregatePart =
-      (error instanceof AggregateError
-        ? error.errors
-            .map(error => recursive(error, {level: level + 1}))
+    const errorsPart =
+      (errors
+        ? errors
+            .map(error => recursive(error))
             .flatMap(str => str.split('\n'))
             .map((line, i) => i === 0 ? ` ${head2} ${line}` : ` ${bar2} ${line}`)
             .join('\n')
         : '');
 
-    return [headerPart, causePart, aggregatePart].filter(Boolean).join('\n');
+    return [headerPart, causePart, errorsPart].filter(Boolean).join('\n');
   };
 
-  const message = recursive(topError, {level: 0});
+  const structure = flattenErrorStructure(topError);
+  const message = recursive(structure);
 
   if (print) {
     console.error(message);
@@ -685,7 +794,7 @@ export function asyncAdaptiveDecorateError(fn, callback) {
     try {
       return await fn(...args);
     } catch (caughtError) {
-      throw callback(caughtError);
+      throw callback(caughtError, ...args);
     }
   };
 
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index 0790ae91..b5813c7a 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -629,6 +629,41 @@ export function sortFlashesChronologically(data, {
 
 // Specific data utilities
 
+// Matches heading details from commentary data in roughly the formats:
+//
+//    <i>artistReference:</i> (annotation, date)
+//    <i>artistReference|artistDisplayText:</i> (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 <i> and </i>
+// (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.
+//
+export const commentaryRegex =
+  /^<i>(?<artistReferences>.+?)(?:\|(?<artistDisplayText>.+))?:<\/i>(?: \((?<annotation>(?:.*?(?=,|\)[^)]*$))*?)(?:,? ?(?<date>[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}))?\))?/gm;
+
 export function filterAlbumsByCommentary(albums) {
   return albums
     .filter((album) => [album, ...album.tracks].some((x) => x.commentary));