« 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/aggregate.js110
-rw-r--r--src/util/cli.js73
-rw-r--r--src/util/colors.js2
-rw-r--r--src/util/external-links.js12
-rw-r--r--src/util/html.js98
-rw-r--r--src/util/search-spec.js256
-rw-r--r--src/util/serialize.js6
-rw-r--r--src/util/sort.js3
-rw-r--r--src/util/sugar.js58
9 files changed, 580 insertions, 38 deletions
diff --git a/src/util/aggregate.js b/src/util/aggregate.js
index f0023359..e8f45f3b 100644
--- a/src/util/aggregate.js
+++ b/src/util/aggregate.js
@@ -91,6 +91,46 @@ export function openAggregate({
     return aggregate.callAsync(() => withAggregateAsync(...args));
   };
 
+  aggregate.receive = (results) => {
+    if (!Array.isArray(results)) {
+      if (typeof results === 'object' && results.aggregate) {
+        const {aggregate, result} = results;
+
+        try {
+          aggregate.close();
+        } catch (error) {
+          errors.push(error);
+        }
+
+        return result;
+      }
+
+      throw new Error(`Expected an array or {aggregate, result} object`);
+    }
+
+    return results.map(({aggregate, result}) => {
+      if (!aggregate) {
+        console.log('nope:', results);
+        throw new Error(`Expected an array of {aggregate, result} objects`);
+      }
+
+      try {
+        aggregate.close();
+      } catch (error) {
+        errors.push(error);
+      }
+
+      return result;
+    });
+  };
+
+  aggregate.contain = (results) => {
+    return {
+      aggregate,
+      result: aggregate.receive(results),
+    };
+  };
+
   aggregate.map = (...args) => {
     const parent = aggregate;
     const {result, aggregate: child} = mapAggregate(...args);
@@ -136,18 +176,33 @@ export function aggregateThrows(errorClass) {
   return {[openAggregate.errorClassSymbol]: errorClass};
 }
 
-// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn)
-// in aggregate utilities.
-function _reorganizeAggregateArguments(arg1, arg2) {
-  if (typeof arg1 === 'function') {
-    return {fn: arg1, opts: arg2 ?? {}};
-  } else if (typeof arg2 === 'function') {
-    return {fn: arg2, opts: arg1 ?? {}};
+// Helper function for allowing both (fn, opts) and (opts, fn) in aggregate
+// utilities (or other shapes besides functions).
+function _reorganizeAggregateArguments(arg1, arg2, desire = v => typeof v === 'function') {
+  if (desire(arg1)) {
+    return [arg1, arg2 ?? {}];
+  } else if (desire(arg2)) {
+    return [arg2, arg1];
   } else {
-    throw new Error(`Expected a function`);
+    return [undefined, undefined];
   }
 }
 
+// Takes a list of {aggregate, result} objects, puts all the aggregates into
+// a new aggregate, and puts all the results into an array, returning both on
+// a new {aggregate, result} object. This is essentailly the generalized
+// composable version of functions like mapAggregate or filterAggregate.
+export function receiveAggregate(arg1, arg2) {
+  const [array, opts] = _reorganizeAggregateArguments(arg1, arg2, Array.isArray);
+  if (!array) {
+    throw new Error(`Expected an array`);
+  }
+
+  const aggregate = openAggregate(opts);
+  const result = aggregate.receive(array);
+  return {aggregate, result};
+}
+
 // Performs an ordinary array map with the given function, collating into a
 // results array (with errored inputs filtered out) and an error aggregate.
 //
@@ -158,12 +213,20 @@ function _reorganizeAggregateArguments(arg1, arg2) {
 // use aggregate.close() to throw the error. (This aggregate may be passed to a
 // parent aggregate: `parent.call(aggregate.close)`!)
 export function mapAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _mapAggregate('sync', null, array, fn, opts);
 }
 
 export function mapAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _mapAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -200,12 +263,20 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 //
 // As with mapAggregate, the returned aggregate property is not yet closed.
 export function filterAggregate(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _filterAggregate('sync', null, array, fn, opts);
 }
 
 export async function filterAggregateAsync(array, arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts;
   return _filterAggregate('async', promiseAll, array, fn, remainingOpts);
 }
@@ -268,12 +339,20 @@ function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) {
 // function with it, then closing the function and returning the result (if
 // there's no throw).
 export function withAggregate(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('sync', opts, fn);
 }
 
 export function withAggregateAsync(arg1, arg2) {
-  const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2);
+  const [fn, opts] = _reorganizeAggregateArguments(arg1, arg2);
+  if (!fn) {
+    throw new Error(`Expected a function`);
+  }
+
   return _withAggregate('async', opts, fn);
 }
 
@@ -294,7 +373,10 @@ export function _withAggregate(mode, aggregateOpts, fn) {
 
 export const unhelpfulTraceLines = [
   /sugar/,
+  /sort/,
   /aggregate/,
+  /composite/,
+  /cacheable-object/,
   /node:/,
   /<anonymous>/,
 ];
diff --git a/src/util/cli.js b/src/util/cli.js
index ce513f08..72979d3f 100644
--- a/src/util/cli.js
+++ b/src/util/cli.js
@@ -201,6 +201,79 @@ export async function parseOptions(options, optionDescriptorMap) {
   return result;
 }
 
+// Takes precisely the same sort of structure as `parseOptions` above,
+// and displays associated help messages. Radical!
+//
+// 'indentWrap' should be the function from '#sugar', with its wrap option
+//   already bound.
+//
+// 'sort' should take care of sorting a list of {name, descriptor} entries.
+export function showHelpForOptions({
+  heading,
+  options,
+  indentWrap,
+  sort = entries => entries,
+}) {
+  if (heading) {
+    console.log(colors.bright(heading));
+  }
+
+  const sortedOptions =
+    sort(
+      Object.entries(options)
+        .map(([name, descriptor]) => ({name, descriptor})));
+
+  if (!sortedOptions.length) {
+    console.log(`(No options available)`)
+  }
+
+  let justInsertedPaddingLine = false;
+
+  for (const {name, descriptor} of sortedOptions) {
+    if (descriptor.alias) {
+      continue;
+    }
+
+    const aliases =
+      Object.entries(options)
+        .filter(([_name, {alias}]) => alias === name)
+        .map(([name]) => name);
+
+    let wrappedHelp, wrappedHelpLines = 0;
+    if (descriptor.help) {
+      wrappedHelp = indentWrap(descriptor.help, {spaces: 4});
+      wrappedHelpLines = wrappedHelp.split('\n').length;
+    }
+
+    if (wrappedHelpLines > 0 && !justInsertedPaddingLine) {
+      console.log('');
+    }
+
+    console.log(colors.bright(` --` + name) +
+      (aliases.length
+        ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})`
+        : '') +
+      (descriptor.help
+        ? ''
+        : colors.dim('  (no help provided)')));
+
+    if (wrappedHelp) {
+      console.log(wrappedHelp);
+    }
+
+    if (wrappedHelpLines > 1) {
+      console.log('');
+      justInsertedPaddingLine = true;
+    } else {
+      justInsertedPaddingLine = false;
+    }
+  }
+
+  if (!justInsertedPaddingLine) {
+    console.log(``);
+  }
+}
+
 export const handleDashless = Symbol();
 export const handleUnknown = Symbol();
 
diff --git a/src/util/colors.js b/src/util/colors.js
index 50339cd3..7298c46a 100644
--- a/src/util/colors.js
+++ b/src/util/colors.js
@@ -15,6 +15,7 @@ export function getColors(themeColor, {
   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);
@@ -31,6 +32,7 @@ export function getColors(themeColor, {
     deep: deep.hex(),
     deepGhost: deepGhost.hex(),
     light: light.hex(),
+    lightGhost: lightGhost.hex(),
 
     bg: bg.hex(),
     bgBlack: bgBlack.hex(),
diff --git a/src/util/external-links.js b/src/util/external-links.js
index 3b779afc..a616efb3 100644
--- a/src/util/external-links.js
+++ b/src/util/external-links.js
@@ -211,6 +211,18 @@ export const externalLinkSpec = [
   // Generic domains, sorted alphabetically (by string)
 
   {
+    match: {
+      domains: [
+        'music.amazon.co.jp',
+        'music.amazon.com',
+      ],
+    },
+
+    platform: 'amazonMusic',
+    icon: 'globe',
+  },
+
+  {
     match: {domain: 'music.apple.com'},
     platform: 'appleMusic',
     icon: 'appleMusic',
diff --git a/src/util/html.js b/src/util/html.js
index d1d509e2..6e892031 100644
--- a/src/util/html.js
+++ b/src/util/html.js
@@ -53,11 +53,18 @@ export const attributeSpec = {
   },
 };
 
-// Pass to tag() as an attributes key to make tag() return a 8lank string if the
+// Pass to tag() as an attributes key to make tag() return a 8lank tag 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();
 
+// Pass to tag() as an attributes key to make tag() return a blank tag if
+// this tag doesn't get shown beside any siblings! (I.e, siblings who don't
+// also have the [html.onlyIfSiblings] attribute.) Since they'd just be blank,
+// tags with [html.onlyIfSiblings] never make the difference in counting as
+// content for [html.onlyIfContent]. Useful for <summary> and such.
+export const onlyIfSiblings = Symbol();
+
 // Pass to tag() as an attributes key to make children be joined together by the
 // provided string. This is handy, for example, for joining lines by <br> tags,
 // or putting some other divider between each child. Note this will only have an
@@ -124,13 +131,18 @@ function isBlankArrayHelper(content) {
   // content of tags marked onlyIfContent) into one array,
   // and templates into another. And if there's anything
   // else, that's a non-blank condition we'll detect now.
+  // We'll flat-out skip items marked onlyIfSiblings,
+  // since they could never count as content alone
+  // (some other item will have to count).
 
   const arrayContent = [];
   const templateContent = [];
 
   for (const item of nonStringContent) {
     if (item instanceof Tag) {
-      if (item.onlyIfContent || item.contentOnly) {
+      if (item.onlyIfSiblings) {
+        continue;
+      } else if (item.onlyIfContent || item.contentOnly) {
         arrayContent.push(item.content);
       } else {
         return false;
@@ -416,6 +428,10 @@ export class Tag {
   }
 
   get blank() {
+    // Tags don't have a reference to their parent, so this only evinces
+    // something about this tag's own content or attributes. It does *not*
+    // account for [html.onlyIfSiblings]!
+
     if (this.onlyIfContent && isBlank(this.content)) {
       return true;
     }
@@ -477,6 +493,14 @@ export class Tag {
     return this.#getAttributeFlag(onlyIfContent);
   }
 
+  set onlyIfSiblings(value) {
+    this.#setAttributeFlag(onlyIfSiblings, value);
+  }
+
+  get onlyIfSiblings() {
+    return this.#getAttributeFlag(onlyIfSiblings);
+  }
+
   set joinChildren(value) {
     this.#setAttributeString(joinChildren, value);
   }
@@ -593,6 +617,8 @@ export class Tag {
     let content = '';
     let blockwrapClosers = '';
 
+    let seenSiblingIndependentContent = false;
+
     const chunkwrapSplitter =
       (this.chunkwrap
         ? this.#getAttributeString('split')
@@ -647,6 +673,10 @@ export class Tag {
         continue;
       }
 
+      if (!(item instanceof Tag && item.onlyIfSiblings)) {
+        seenSiblingIndependentContent = true;
+      }
+
       const chunkwrapChunks =
         (typeof item === 'string' && chunkwrapSplitter
           ? itemContent.split(chunkwrapSplitter)
@@ -658,28 +688,25 @@ export class Tag {
           : null);
 
       if (content) {
-        if (itemIncludesChunkwrapSplit) {
-          if (!seenChunkwrapSplitter) {
-            // The first time we see a chunkwrap splitter, backtrack and wrap
-            // the content *so far* in a chunk.
-            content = `<span class="chunkwrap">` + content;
-          }
-
-          // Close the existing chunk. We'll add the new chunks after the
-          // (normal) joiner.
-          content += `</span>`;
+        if (itemIncludesChunkwrapSplit && !seenChunkwrapSplitter) {
+          // The first time we see a chunkwrap splitter, backtrack and wrap
+          // the content *so far* in a chunk. This will be treated just like
+          // any other open chunkwrap, and closed after the first chunk of
+          // this item! (That means the existing content is part of the same
+          // chunk as the first chunk included in this content, which makes
+          // sense, because that first chink is really just more text that
+          // precedes the first split.)
+          content = `<span class="chunkwrap">` + content;
         }
 
         content += joiner;
-      } else {
+      } else if (itemIncludesChunkwrapSplit) {
         // We've encountered a chunkwrap split before any other content.
         // This means there's no content to wrap, no existing chunkwrap
         // to close, and no reason to add a joiner, but we *do* need to
         // enter a chunkwrap wrapper *now*, so the first chunk of this
         // item will be properly wrapped.
-        if (itemIncludesChunkwrapSplit) {
-          content = `<span class="chunkwrap">`;
-        }
+        content = `<span class="chunkwrap">`;
       }
 
       if (itemIncludesChunkwrapSplit) {
@@ -700,6 +727,10 @@ export class Tag {
         if (itemIncludesChunkwrapSplit) {
           for (const [index, chunk] of chunkwrapChunks.entries()) {
             if (index === 0) {
+              // The first chunk isn't actually a chunk all on its own, it's
+              // text that should be appended to the previous chunk. We will
+              // close this chunk as the first appended content as we process
+              // the next chunk.
               content += chunk;
             } else {
               const whitespace = chunk.match(/^\s+/) ?? '';
@@ -718,6 +749,12 @@ export class Tag {
       }
     }
 
+    // If we've only seen sibling-dependent content (or just no content),
+    // then the content in total is blank.
+    if (!seenSiblingIndependentContent) {
+      return '';
+    }
+
     if (chunkwrapSplitter) {
       if (seenChunkwrapSplitter) {
         content += '</span>';
@@ -1101,8 +1138,17 @@ export class Attributes {
     return this.#attributes[attribute];
   }
 
-  has(attribute) {
-    return attribute in this.#attributes;
+  has(attribute, pattern) {
+    if (typeof pattern === 'undefined') {
+      return attribute in this.#attributes;
+    } else if (this.has(attribute)) {
+      const value = this.get(attribute);
+      if (Array.isArray(value)) {
+        return value.includes(pattern);
+      } else {
+        return value === pattern;
+      }
+    }
   }
 
   remove(attribute) {
@@ -1338,6 +1384,22 @@ export function smush(smushee) {
   return smush(Tag.normalize(smushee));
 }
 
+// Much gentler version of smush - this only flattens nested html.tags(), and
+// guarantees the result is itself an html.tags(). It doesn't manipulate text
+// content, and it doesn't resolve templates.
+export function smooth(smoothie) {
+  // Helper function to avoid intermediate html.tags() calls.
+  function helper(tag) {
+    if (tag instanceof Tag && tag.contentOnly) {
+      return tag.content.flatMap(helper);
+    } else {
+      return tag;
+    }
+  }
+
+  return tags(helper(smoothie));
+}
+
 export function template(description) {
   return new Template(description);
 }
diff --git a/src/util/search-spec.js b/src/util/search-spec.js
new file mode 100644
index 00000000..79e8ee95
--- /dev/null
+++ b/src/util/search-spec.js
@@ -0,0 +1,256 @@
+// 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;
+
+      fields.parentName =
+        (fields.kind === 'track'
+          ? thing.album.name
+       : fields.kind === 'group'
+          ? thing.category.name
+       : fields.kind === 'flash'
+          ? thing.act.name
+          : null);
+
+      fields.color =
+        thing.color;
+
+      fields.artTags =
+        (Object.hasOwn(thing, 'artTags')
+          ? thing.artTags.map(artTag => artTag.nameShort)
+          : []);
+
+      fields.additionalNames =
+        (Object.hasOwn(thing, 'additionalNames')
+          ? thing.additionalNames.map(entry => entry.name)
+       : Object.hasOwn(thing, 'aliasNames')
+          ? thing.aliasNames
+          : []);
+
+      const contribKeys = [
+        'artistContribs',
+        'bannerArtistContribs',
+        'contributorContribs',
+        'coverArtistContribs',
+        'wallpaperArtistContribs',
+      ];
+
+      const contributions =
+        contribKeys
+          .filter(key => Object.hasOwn(thing, key))
+          .flatMap(key => thing[key]);
+
+      fields.contributors =
+        contributions
+          .flatMap(({artist}) => [
+            artist.name,
+            ...artist.aliasNames,
+          ]);
+
+      const groups =
+         (Object.hasOwn(thing, 'groups')
+           ? thing.groups
+        : Object.hasOwn(thing, 'album')
+           ? thing.album.groups
+           : []);
+
+      const mainContributorNames =
+        contributions
+          .map(({artist}) => artist.name);
+
+      fields.groups =
+        groups
+          .filter(group => !mainContributorNames.includes(group.name))
+          .map(group => group.name);
+
+      fields.artwork =
+        prepareArtwork(thing, opts);
+
+      return fields;
+    },
+
+    index: [
+      'primaryName',
+      'parentName',
+      'artTags',
+      'additionalNames',
+      'contributors',
+      'groups',
+    ],
+
+    store: [
+      'primaryName',
+      'artwork',
+      'color',
+    ],
+  },
+};
+
+export function makeSearchIndex(descriptor, {FlexSearch}) {
+  return new FlexSearch.Document({
+    id: 'reference',
+    index: descriptor.index,
+    store: descriptor.store,
+  });
+}
+
+// TODO: This function basically mirrors bind-utilities.js, which isn't
+// exactly robust, but... binding might need some more thought across the
+// codebase in *general.*
+function bindSearchUtilities({
+  checkIfImagePathHasCachedThumbnails,
+  getThumbnailEqualOrSmaller,
+  thumbsCache,
+  urls,
+}) {
+  const bound = {
+    urls,
+  };
+
+  bound.checkIfImagePathHasCachedThumbnails =
+    (imagePath) =>
+      checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache);
+
+  bound.getThumbnailEqualOrSmaller =
+    (preferred, imagePath) =>
+      getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache);
+
+  return bound;
+}
+
+export function populateSearchIndex(index, descriptor, opts) {
+  const {wikiData} = opts;
+  const bound = bindSearchUtilities(opts);
+
+  const collection = descriptor.query(wikiData);
+
+  for (const thing of collection) {
+    const reference = thing.constructor.getReference(thing);
+
+    let processed;
+    try {
+      processed = descriptor.process(thing, bound);
+    } catch (caughtError) {
+      throw new Error(
+        `Failed to process searchable thing ${reference}`,
+        {cause: caughtError});
+    }
+
+    index.add({reference, ...processed});
+  }
+}
diff --git a/src/util/serialize.js b/src/util/serialize.js
index 4992e2bf..eb18a759 100644
--- a/src/util/serialize.js
+++ b/src/util/serialize.js
@@ -14,10 +14,10 @@ export function serializeLink(thing) {
 }
 
 export function serializeContribs(contribs) {
-  return contribs.map(({who, what}) => {
+  return contribs.map(({artist, annotation}) => {
     const ret = {};
-    ret.artist = serializeLink(who);
-    if (what) ret.contribution = what;
+    ret.artist = serializeLink(artist);
+    if (annotation) ret.contribution = annotation;
     return ret;
   });
 }
diff --git a/src/util/sort.js b/src/util/sort.js
index b3a90812..9e9de641 100644
--- a/src/util/sort.js
+++ b/src/util/sort.js
@@ -388,7 +388,8 @@ export function sortFlashesChronologically(data, {
   getDate,
 } = {}) {
   // Group flashes by act...
-  sortByDirectory(data, {
+  sortAlphabetically(data, {
+    getName: flash => flash.act.name,
     getDirectory: flash => flash.act.directory,
   });
 
diff --git a/src/util/sugar.js b/src/util/sugar.js
index e060f458..3fa3fb46 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -136,6 +136,23 @@ export function stitchArrays(keyToArray) {
   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:
 //
 //   [
@@ -183,8 +200,14 @@ export const compareArrays = (arr1, arr2, {checkOrder = true} = {}) =>
     : arr1.every((x) => arr2.includes(x)));
 
 // Stolen from jq! Which pro8a8ly stole the concept from other places. Nice.
-export const withEntries = (obj, fn) =>
-  Object.fromEntries(fn(Object.entries(obj)));
+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();
@@ -260,6 +283,16 @@ 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
@@ -315,6 +348,27 @@ export function cutStart(text, length = 40) {
   }
 }
 
+// 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:
 //