« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data/composite/wiki-data
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/composite/wiki-data')
-rw-r--r--src/data/composite/wiki-data/exitWithoutContribs.js48
-rw-r--r--src/data/composite/wiki-data/gobbleSoupyFind.js39
-rw-r--r--src/data/composite/wiki-data/gobbleSoupyReverse.js39
-rw-r--r--src/data/composite/wiki-data/helpers/withDirectoryFromName.js41
-rw-r--r--src/data/composite/wiki-data/helpers/withResolvedReverse.js40
-rw-r--r--src/data/composite/wiki-data/helpers/withSimpleDirectory.js52
-rw-r--r--src/data/composite/wiki-data/index.js32
-rw-r--r--src/data/composite/wiki-data/inputNotFoundMode.js9
-rw-r--r--src/data/composite/wiki-data/inputSoupyFind.js28
-rw-r--r--src/data/composite/wiki-data/inputSoupyReverse.js32
-rw-r--r--src/data/composite/wiki-data/inputWikiData.js17
-rw-r--r--src/data/composite/wiki-data/processContentEntryDates.js181
-rw-r--r--src/data/composite/wiki-data/raiseResolvedReferenceList.js96
-rw-r--r--src/data/composite/wiki-data/withClonedThings.js68
-rw-r--r--src/data/composite/wiki-data/withConstitutedArtwork.js57
-rw-r--r--src/data/composite/wiki-data/withContributionListSums.js95
-rw-r--r--src/data/composite/wiki-data/withCoverArtDate.js51
-rw-r--r--src/data/composite/wiki-data/withDirectory.js62
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js129
-rw-r--r--src/data/composite/wiki-data/withParsedContentEntries.js111
-rw-r--r--src/data/composite/wiki-data/withParsedLyricsEntries.js157
-rw-r--r--src/data/composite/wiki-data/withRecontextualizedContributionList.js100
-rw-r--r--src/data/composite/wiki-data/withRedatedContributionList.js127
-rw-r--r--src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js100
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js156
-rw-r--r--src/data/composite/wiki-data/withResolvedReference.js57
-rw-r--r--src/data/composite/wiki-data/withResolvedReferenceList.js80
-rw-r--r--src/data/composite/wiki-data/withResolvedSeriesList.js130
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js36
-rw-r--r--src/data/composite/wiki-data/withThingsSortedAlphabetically.js122
-rw-r--r--src/data/composite/wiki-data/withUniqueReferencingThing.js36
31 files changed, 2328 insertions, 0 deletions
diff --git a/src/data/composite/wiki-data/exitWithoutContribs.js b/src/data/composite/wiki-data/exitWithoutContribs.js
new file mode 100644
index 00000000..cf52950d
--- /dev/null
+++ b/src/data/composite/wiki-data/exitWithoutContribs.js
@@ -0,0 +1,48 @@
+// Shorthand for exiting if the contribution list (usually a property's update
+// value) resolves to empty - ensuring that the later computed results are only
+// returned if these contributions are present.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList} from '#validators';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withResolvedContribs from './withResolvedContribs.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutContribs`,
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+
+    value: input({defaultValue: null}),
+  },
+
+  steps: () => [
+    withResolvedContribs({
+      from: input('contribs'),
+      date: input.value(null),
+    }),
+
+    // TODO: Fairly certain exitWithoutDependency would be sufficient here.
+
+    withResultOfAvailabilityCheck({
+      from: '#resolvedContribs',
+      mode: input.value('empty'),
+    }),
+
+    {
+      dependencies: ['#availability', input('value')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('value')]: value,
+      }) =>
+        (availability
+          ? continuation()
+          : continuation.exit(value)),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/gobbleSoupyFind.js b/src/data/composite/wiki-data/gobbleSoupyFind.js
new file mode 100644
index 00000000..aec3f5b1
--- /dev/null
+++ b/src/data/composite/wiki-data/gobbleSoupyFind.js
@@ -0,0 +1,39 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import inputSoupyFind, {getSoupyFindInputKey} from './inputSoupyFind.js';
+
+export default templateCompositeFrom({
+  annotation: `gobbleSoupyFind`,
+
+  inputs: {
+    find: inputSoupyFind(),
+  },
+
+  outputs: ['#find'],
+
+  steps: () => [
+    {
+      dependencies: [input('find')],
+      compute: (continuation, {
+        [input('find')]: find,
+      }) =>
+        (typeof find === 'function'
+          ? continuation.raiseOutput({
+              ['#find']: find,
+            })
+          : continuation({
+              ['#key']:
+                getSoupyFindInputKey(find),
+            })),
+    },
+
+    withPropertyFromObject({
+      object: 'find',
+      property: '#key',
+    }).outputs({
+      '#value': '#find',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/gobbleSoupyReverse.js b/src/data/composite/wiki-data/gobbleSoupyReverse.js
new file mode 100644
index 00000000..86a1061c
--- /dev/null
+++ b/src/data/composite/wiki-data/gobbleSoupyReverse.js
@@ -0,0 +1,39 @@
+import {input, templateCompositeFrom} from '#composite';
+
+import {withPropertyFromObject} from '#composite/data';
+
+import inputSoupyReverse, {getSoupyReverseInputKey} from './inputSoupyReverse.js';
+
+export default templateCompositeFrom({
+  annotation: `gobbleSoupyReverse`,
+
+  inputs: {
+    reverse: inputSoupyReverse(),
+  },
+
+  outputs: ['#reverse'],
+
+  steps: () => [
+    {
+      dependencies: [input('reverse')],
+      compute: (continuation, {
+        [input('reverse')]: reverse,
+      }) =>
+        (typeof reverse === 'function'
+          ? continuation.raiseOutput({
+              ['#reverse']: reverse,
+            })
+          : continuation({
+              ['#key']:
+                getSoupyReverseInputKey(reverse),
+            })),
+    },
+
+    withPropertyFromObject({
+      object: 'reverse',
+      property: '#key',
+    }).outputs({
+      '#value': '#reverse',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/helpers/withDirectoryFromName.js b/src/data/composite/wiki-data/helpers/withDirectoryFromName.js
new file mode 100644
index 00000000..f85dae16
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withDirectoryFromName.js
@@ -0,0 +1,41 @@
+// Compute a directory from a name.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isName} from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+export default templateCompositeFrom({
+  annotation: `withDirectoryFromName`,
+
+  inputs: {
+    name: input({
+      validate: isName,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('name'),
+      mode: input.value('falsy'),
+      output: input.value({
+        ['#directory']: null,
+      }),
+    }),
+
+    {
+      dependencies: [input('name')],
+      compute: (continuation, {
+        [input('name')]: name,
+      }) => continuation({
+        ['#directory']:
+          getKebabCase(name),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/helpers/withResolvedReverse.js b/src/data/composite/wiki-data/helpers/withResolvedReverse.js
new file mode 100644
index 00000000..818f60b7
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withResolvedReverse.js
@@ -0,0 +1,40 @@
+// Actually execute a reverse function.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import inputWikiData from '../inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withReverseReferenceList`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: input({type: 'function'}),
+    options: input({type: 'object', defaultValue: null}),
+  },
+
+  outputs: ['#resolvedReverse'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.myself(),
+        input('data'),
+        input('reverse'),
+        input('options'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('data')]: data,
+        [input('reverse')]: reverseFunction,
+        [input('options')]: opts,
+      }) => continuation({
+        ['#resolvedReverse']:
+          (data
+            ? reverseFunction(myself, data, opts)
+            : reverseFunction(myself, opts)),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/helpers/withSimpleDirectory.js b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js
new file mode 100644
index 00000000..08ca3bfc
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withSimpleDirectory.js
@@ -0,0 +1,52 @@
+// A "simple" directory, based only on the already-provided directory, if
+// available, or the provided name.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+
+import withDirectoryFromName from './withDirectoryFromName.js';
+
+export default templateCompositeFrom({
+  annotation: `withSimpleDirectory`,
+
+  inputs: {
+    directory: input({
+      validate: isDirectory,
+      defaultDependency: 'directory',
+      acceptsNull: true,
+    }),
+
+    name: input({
+      validate: isName,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    withResultOfAvailabilityCheck({
+      from: input('directory'),
+    }),
+
+    {
+      dependencies: ['#availability', input('directory')],
+      compute: (continuation, {
+        ['#availability']: availability,
+        [input('directory')]: directory,
+      }) =>
+        (availability
+          ? continuation.raiseOutput({
+              ['#directory']: directory
+            })
+          : continuation()),
+    },
+
+    withDirectoryFromName({
+      name: input('name'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
new file mode 100644
index 00000000..1d94f74b
--- /dev/null
+++ b/src/data/composite/wiki-data/index.js
@@ -0,0 +1,32 @@
+// #composite/wiki-data
+//
+// Entries here may depend on entries in #composite/control-flow and in
+// #composite/data.
+//
+
+export {default as exitWithoutContribs} from './exitWithoutContribs.js';
+export {default as gobbleSoupyFind} from './gobbleSoupyFind.js';
+export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js';
+export {default as inputNotFoundMode} from './inputNotFoundMode.js';
+export {default as inputSoupyFind} from './inputSoupyFind.js';
+export {default as inputSoupyReverse} from './inputSoupyReverse.js';
+export {default as inputWikiData} from './inputWikiData.js';
+export {default as processContentEntryDates} from './processContentEntryDates.js';
+export {default as withClonedThings} from './withClonedThings.js';
+export {default as withConstitutedArtwork} from './withConstitutedArtwork.js';
+export {default as withContributionListSums} from './withContributionListSums.js';
+export {default as withCoverArtDate} from './withCoverArtDate.js';
+export {default as withDirectory} from './withDirectory.js';
+export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
+export {default as withParsedContentEntries} from './withParsedContentEntries.js';
+export {default as withParsedLyricsEntries} from './withParsedLyricsEntries.js';
+export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
+export {default as withRedatedContributionList} from './withRedatedContributionList.js';
+export {default as withResolvedAnnotatedReferenceList} from './withResolvedAnnotatedReferenceList.js';
+export {default as withResolvedContribs} from './withResolvedContribs.js';
+export {default as withResolvedReference} from './withResolvedReference.js';
+export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
+export {default as withResolvedSeriesList} from './withResolvedSeriesList.js';
+export {default as withReverseReferenceList} from './withReverseReferenceList.js';
+export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
+export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js';
diff --git a/src/data/composite/wiki-data/inputNotFoundMode.js b/src/data/composite/wiki-data/inputNotFoundMode.js
new file mode 100644
index 00000000..d16b2472
--- /dev/null
+++ b/src/data/composite/wiki-data/inputNotFoundMode.js
@@ -0,0 +1,9 @@
+import {input} from '#composite';
+import {is} from '#validators';
+
+export default function inputNotFoundMode() {
+  return input({
+    validate: is('exit', 'filter', 'null'),
+    defaultValue: 'filter',
+  });
+}
diff --git a/src/data/composite/wiki-data/inputSoupyFind.js b/src/data/composite/wiki-data/inputSoupyFind.js
new file mode 100644
index 00000000..020f4990
--- /dev/null
+++ b/src/data/composite/wiki-data/inputSoupyFind.js
@@ -0,0 +1,28 @@
+import {input} from '#composite';
+import {anyOf, isFunction, isString} from '#validators';
+
+function inputSoupyFind() {
+  return input({
+    validate:
+      anyOf(
+        isFunction,
+        val => {
+          isString(val);
+
+          if (!val.startsWith('_soupyFind:')) {
+            throw new Error(`Expected soupyFind.input() token`);
+          }
+
+          return true;
+        }),
+  });
+}
+
+inputSoupyFind.input = key =>
+  input.value('_soupyFind:' + key);
+
+export default inputSoupyFind;
+
+export function getSoupyFindInputKey(value) {
+  return value.slice('_soupyFind:'.length);
+}
diff --git a/src/data/composite/wiki-data/inputSoupyReverse.js b/src/data/composite/wiki-data/inputSoupyReverse.js
new file mode 100644
index 00000000..0b0a23fe
--- /dev/null
+++ b/src/data/composite/wiki-data/inputSoupyReverse.js
@@ -0,0 +1,32 @@
+import {input} from '#composite';
+import {anyOf, isFunction, isString} from '#validators';
+
+function inputSoupyReverse() {
+  return input({
+    validate:
+      anyOf(
+        isFunction,
+        val => {
+          isString(val);
+
+          if (!val.startsWith('_soupyReverse:')) {
+            throw new Error(`Expected soupyReverse.input() token`);
+          }
+
+          return true;
+        }),
+  });
+}
+
+inputSoupyReverse.input = key =>
+  input.value('_soupyReverse:' + key);
+
+export default inputSoupyReverse;
+
+export function getSoupyReverseInputKey(value) {
+  return value.slice('_soupyReverse:'.length).replace(/\.unique$/, '');
+}
+
+export function doesSoupyReverseInputWantUnique(value) {
+  return value.endsWith('.unique');
+}
diff --git a/src/data/composite/wiki-data/inputWikiData.js b/src/data/composite/wiki-data/inputWikiData.js
new file mode 100644
index 00000000..b9021986
--- /dev/null
+++ b/src/data/composite/wiki-data/inputWikiData.js
@@ -0,0 +1,17 @@
+import {input} from '#composite';
+import {validateWikiData} from '#validators';
+
+// TODO: This doesn't access a class's own ThingSubclass[Thing.referenceType]
+// value because classes aren't initialized by when templateCompositeFrom gets
+// called (see: circular imports). So the reference types have to be hard-coded,
+// which somewhat defeats the point of storing them on the class in the first
+// place...
+export default function inputWikiData({
+  referenceType = '',
+  allowMixedTypes = false,
+} = {}) {
+  return input({
+    validate: validateWikiData({referenceType, allowMixedTypes}),
+    defaultValue: null,
+  });
+}
diff --git a/src/data/composite/wiki-data/processContentEntryDates.js b/src/data/composite/wiki-data/processContentEntryDates.js
new file mode 100644
index 00000000..e418a121
--- /dev/null
+++ b/src/data/composite/wiki-data/processContentEntryDates.js
@@ -0,0 +1,181 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isContentString, isString, looseArrayOf} from '#validators';
+
+import {fillMissingListItems} from '#composite/data';
+
+// Important note: These two kinds of inputs have the exact same shape!!
+// This isn't on purpose (besides that they *are* both supposed to be strings).
+// They just don't have any more particular validation, yet.
+
+const inputDateList = defaultDependency =>
+  input({
+    validate: looseArrayOf(isString),
+    defaultDependency,
+  });
+
+const inputKindList = defaultDependency =>
+  input.staticDependency({
+    validate: looseArrayOf(isString),
+    defaultDependency: defaultDependency,
+  });
+
+export default templateCompositeFrom({
+  annotation: `processContentEntryDates`,
+
+  inputs: {
+    annotations: input({
+      validate: looseArrayOf(isContentString),
+      defaultDependency: '#entries.annotation',
+    }),
+
+    dates: inputDateList('#entries.date'),
+    secondDates: inputDateList('#entries.secondDate'),
+    accessDates: inputDateList('#entries.accessDate'),
+
+    dateKinds: inputKindList('#entries.dateKind'),
+    accessKinds: inputKindList('#entries.accessKind'),
+  },
+
+  outputs: ({
+    [input.staticDependency('dates')]: dates,
+    [input.staticDependency('secondDates')]: secondDates,
+    [input.staticDependency('accessDates')]: accessDates,
+    [input.staticDependency('dateKinds')]: dateKinds,
+    [input.staticDependency('accessKinds')]: accessKinds,
+  }) => [
+    dates ?? '#processedContentEntryDates',
+    secondDates ?? '#processedContentEntrySecondDates',
+    accessDates ?? '#processedContentEntryAccessDates',
+    dateKinds ?? '#processedContentEntryDateKinds',
+    accessKinds ?? '#processedContentEntryAccessKinds',
+  ],
+
+  steps: () => [
+    {
+      dependencies: [input('annotations')],
+      compute: (continuation, {
+        [input('annotations')]: annotations,
+      }) => continuation({
+        ['#webArchiveDates']:
+          annotations
+            .map(text => text?.match(/https?:\/\/web.archive.org\/web\/([0-9]{8,8})[0-9]*\//))
+            .map(match => match?.[1])
+            .map(dateText =>
+              (dateText
+                ? dateText.slice(0, 4) + '/' +
+                  dateText.slice(4, 6) + '/' +
+                  dateText.slice(6, 8)
+                : null)),
+      }),
+    },
+
+    {
+      dependencies: [input('dates')],
+      compute: (continuation, {
+        [input('dates')]: dates,
+      }) => continuation({
+        ['#processedContentEntryDates']:
+          dates
+            .map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    {
+      dependencies: [input('secondDates')],
+      compute: (continuation, {
+        [input('secondDates')]: secondDates,
+      }) => continuation({
+        ['#processedContentEntrySecondDates']:
+          secondDates
+            .map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    fillMissingListItems({
+      list: input('dateKinds'),
+      fill: input.value(null),
+    }).outputs({
+      '#list': '#processedContentEntryDateKinds',
+    }),
+
+    {
+      dependencies: [input('accessDates'), '#webArchiveDates'],
+      compute: (continuation, {
+        [input('accessDates')]: accessDates,
+        ['#webArchiveDates']: webArchiveDates,
+      }) => continuation({
+        ['#processedContentEntryAccessDates']:
+          stitchArrays({
+            accessDate: accessDates,
+            webArchiveDate: webArchiveDates
+          }).map(({accessDate, webArchiveDate}) =>
+              accessDate ??
+              webArchiveDate ??
+              null)
+            .map(date => date ? new Date(date) : date),
+      }),
+    },
+
+    {
+      dependencies: [input('accessKinds'), '#webArchiveDates'],
+      compute: (continuation, {
+        [input('accessKinds')]: accessKinds,
+        ['#webArchiveDates']: webArchiveDates,
+      }) => continuation({
+        ['#processedContentEntryAccessKinds']:
+          stitchArrays({
+            accessKind: accessKinds,
+            webArchiveDate: webArchiveDates,
+          }).map(({accessKind, webArchiveDate}) =>
+              accessKind ??
+              (webArchiveDate && 'captured') ??
+              null),
+      }),
+    },
+
+    // TODO: Annoying conversion step for outputs, would be nice to avoid.
+    {
+      dependencies: [
+        '#processedContentEntryDates',
+        '#processedContentEntrySecondDates',
+        '#processedContentEntryAccessDates',
+        '#processedContentEntryDateKinds',
+        '#processedContentEntryAccessKinds',
+        input.staticDependency('dates'),
+        input.staticDependency('secondDates'),
+        input.staticDependency('accessDates'),
+        input.staticDependency('dateKinds'),
+        input.staticDependency('accessKinds'),
+      ],
+
+      compute: (continuation, {
+        ['#processedContentEntryDates']: processedContentEntryDates,
+        ['#processedContentEntrySecondDates']: processedContentEntrySecondDates,
+        ['#processedContentEntryAccessDates']: processedContentEntryAccessDates,
+        ['#processedContentEntryDateKinds']: processedContentEntryDateKinds,
+        ['#processedContentEntryAccessKinds']: processedContentEntryAccessKinds,
+        [input.staticDependency('dates')]: dates,
+        [input.staticDependency('secondDates')]: secondDates,
+        [input.staticDependency('accessDates')]: accessDates,
+        [input.staticDependency('dateKinds')]: dateKinds,
+        [input.staticDependency('accessKinds')]: accessKinds,
+      }) => continuation({
+        [dates ?? '#processedContentEntryDates']:
+          processedContentEntryDates,
+
+        [secondDates ?? '#processedContentEntrySecondDates']:
+          processedContentEntrySecondDates,
+
+        [accessDates ?? '#processedContentEntryAccessDates']:
+          processedContentEntryAccessDates,
+
+        [dateKinds ?? '#processedContentEntryDateKinds']:
+          processedContentEntryDateKinds,
+
+        [accessKinds ?? '#processedContentEntryAccessKinds']:
+          processedContentEntryAccessKinds,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/raiseResolvedReferenceList.js b/src/data/composite/wiki-data/raiseResolvedReferenceList.js
new file mode 100644
index 00000000..613b002b
--- /dev/null
+++ b/src/data/composite/wiki-data/raiseResolvedReferenceList.js
@@ -0,0 +1,96 @@
+// Concludes compositions like withResolvedReferenceList, which share behavior
+// in processing the resolved results before continuing further.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {withFilteredList} from '#composite/data';
+
+import inputNotFoundMode from './inputNotFoundMode.js';
+
+export default templateCompositeFrom({
+  inputs: {
+    notFoundMode: inputNotFoundMode(),
+
+    results: input({type: 'array'}),
+    filter: input({type: 'array'}),
+
+    exitValue: input({defaultValue: []}),
+
+    outputs: input.staticValue({type: 'string'}),
+  },
+
+  outputs: ({
+    [input.staticValue('outputs')]: outputs,
+  }) => [outputs],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('results'),
+        input('filter'),
+        input('outputs'),
+      ],
+
+      compute: (continuation, {
+        [input('results')]: results,
+        [input('filter')]: filter,
+        [input('outputs')]: outputs,
+      }) =>
+        (filter.every(keep => keep)
+          ? continuation.raiseOutput({[outputs]: results})
+          : continuation()),
+    },
+
+    {
+      dependencies: [
+        input('notFoundMode'),
+        input('exitValue'),
+      ],
+
+      compute: (continuation, {
+        [input('notFoundMode')]: notFoundMode,
+        [input('exitValue')]: exitValue,
+      }) =>
+        (notFoundMode === 'exit'
+          ? continuation.exit(exitValue)
+          : continuation()),
+    },
+
+    {
+      dependencies: [
+        input('results'),
+        input('notFoundMode'),
+        input('outputs'),
+      ],
+
+      compute: (continuation, {
+        [input('results')]: results,
+        [input('notFoundMode')]: notFoundMode,
+        [input('outputs')]: outputs,
+      }) =>
+        (notFoundMode === 'null'
+          ? continuation.raiseOutput({[outputs]: results})
+          : continuation()),
+    },
+
+    withFilteredList({
+      list: input('results'),
+      filter: input('filter'),
+    }),
+
+    {
+      dependencies: [
+        '#filteredList',
+        input('outputs'),
+      ],
+
+      compute: (continuation, {
+        ['#filteredList']: filteredList,
+        [input('outputs')]: outputs,
+      }) => continuation({
+        [outputs]:
+          filteredList,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withClonedThings.js b/src/data/composite/wiki-data/withClonedThings.js
new file mode 100644
index 00000000..9af6aa84
--- /dev/null
+++ b/src/data/composite/wiki-data/withClonedThings.js
@@ -0,0 +1,68 @@
+// Clones all the things in a list. If the 'assign' input is provided,
+// all new things are assigned the same specified properties. If the
+// 'assignEach' input is provided, each new thing is assigned the
+// corresponding properties.
+
+import CacheableObject from '#cacheable-object';
+import {input, templateCompositeFrom} from '#composite';
+import {isObject, sparseArrayOf} from '#validators';
+
+import {withMappedList} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withClonedThings`,
+
+  inputs: {
+    things: input({type: 'array'}),
+
+    assign: input({
+      type: 'object',
+      defaultValue: null,
+    }),
+
+    assignEach: input({
+      validate: sparseArrayOf(isObject),
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#clonedThings'],
+
+  steps: () => [
+    {
+      dependencies: [input('assign'), input('assignEach')],
+      compute: (continuation, {
+        [input('assign')]: assign,
+        [input('assignEach')]: assignEach,
+      }) => continuation({
+        ['#assignmentMap']:
+          (index) =>
+            (assign && assignEach
+              ? {...assignEach[index] ?? {}, ...assign}
+           : assignEach
+              ? assignEach[index] ?? {}
+              : assign ?? {}),
+      }),
+    },
+
+    {
+      dependencies: ['#assignmentMap'],
+      compute: (continuation, {
+        ['#assignmentMap']: assignmentMap,
+      }) => continuation({
+        ['#cloningMap']:
+          (thing, index) =>
+            Object.assign(
+              CacheableObject.clone(thing),
+              assignmentMap(index)),
+      }),
+    },
+
+    withMappedList({
+      list: input('things'),
+      map: '#cloningMap',
+    }).outputs({
+      '#mappedList': '#clonedThings',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js
new file mode 100644
index 00000000..9e260abf
--- /dev/null
+++ b/src/data/composite/wiki-data/withConstitutedArtwork.js
@@ -0,0 +1,57 @@
+import {input, templateCompositeFrom} from '#composite';
+import thingConstructors from '#things';
+import {isContributionList} from '#validators';
+
+export default templateCompositeFrom({
+  annotation: `withConstitutedArtwork`,
+
+  inputs: {
+    dimensionsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    fileExtensionFromThingProperty: input({type: 'string', acceptsNull: true}),
+    dateFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    artistContribsArtistProperty: input({type: 'string', acceptsNull: true}),
+    artTagsFromThingProperty: input({type: 'string', acceptsNull: true}),
+    referencedArtworksFromThingProperty: input({type: 'string', acceptsNull: true}),
+  },
+
+  outputs: ['#constitutedArtwork'],
+
+  steps: () => [
+    {
+      dependencies: [
+        input.myself(),
+        input('dimensionsFromThingProperty'),
+        input('fileExtensionFromThingProperty'),
+        input('dateFromThingProperty'),
+        input('artistContribsFromThingProperty'),
+        input('artistContribsArtistProperty'),
+        input('artTagsFromThingProperty'),
+        input('referencedArtworksFromThingProperty'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input('dimensionsFromThingProperty')]: dimensionsFromThingProperty,
+        [input('fileExtensionFromThingProperty')]: fileExtensionFromThingProperty,
+        [input('dateFromThingProperty')]: dateFromThingProperty,
+        [input('artistContribsFromThingProperty')]: artistContribsFromThingProperty,
+        [input('artistContribsArtistProperty')]: artistContribsArtistProperty,
+        [input('artTagsFromThingProperty')]: artTagsFromThingProperty,
+        [input('referencedArtworksFromThingProperty')]: referencedArtworksFromThingProperty,
+      }) => continuation({
+        ['#constitutedArtwork']:
+          Object.assign(new thingConstructors.Artwork, {
+            thing: myself,
+            dimensionsFromThingProperty,
+            fileExtensionFromThingProperty,
+            artistContribsFromThingProperty,
+            artistContribsArtistProperty,
+            artTagsFromThingProperty,
+            dateFromThingProperty,
+            referencedArtworksFromThingProperty,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withContributionListSums.js b/src/data/composite/wiki-data/withContributionListSums.js
new file mode 100644
index 00000000..b4f36361
--- /dev/null
+++ b/src/data/composite/wiki-data/withContributionListSums.js
@@ -0,0 +1,95 @@
+// Gets the total duration and contribution count from a list of contributions,
+// respecting their `countInContributionTotals` and `countInDurationTotals`
+// flags.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {
+  withFilteredList,
+  withPropertiesFromList,
+  withPropertyFromList,
+  withSum,
+  withUniqueItemsOnly,
+} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withContributionListSums`,
+
+  inputs: {
+    list: input({type: 'array'}),
+  },
+
+  outputs: [
+    '#contributionListCount',
+    '#contributionListDuration',
+  ],
+
+  steps: () => [
+    withPropertiesFromList({
+      list: input('list'),
+      properties: input.value([
+        'countInContributionTotals',
+        'countInDurationTotals',
+      ]),
+    }),
+
+    withFilteredList({
+      list: input('list'),
+      filter: '#list.countInContributionTotals',
+    }).outputs({
+      '#filteredList': '#contributionsForCounting',
+    }),
+
+    withFilteredList({
+      list: input('list'),
+      filter: '#list.countInDurationTotals',
+    }).outputs({
+      '#filteredList': '#contributionsForDuration',
+    }),
+
+    {
+      dependencies: ['#contributionsForCounting'],
+      compute: (continuation, {
+        ['#contributionsForCounting']: contributionsForCounting,
+      }) => continuation({
+        ['#count']:
+          contributionsForCounting.length,
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#contributionsForDuration',
+      property: input.value('thing'),
+    }),
+
+    // Don't double-up the durations for a track where the artist has multiple
+    // contributions.
+    withUniqueItemsOnly({
+      list: '#contributionsForDuration.thing',
+    }),
+
+    withPropertyFromList({
+      list: '#contributionsForDuration.thing',
+      property: input.value('duration'),
+    }).outputs({
+      '#contributionsForDuration.thing.duration': '#durationValues',
+    }),
+
+    withSum({
+      values: '#durationValues',
+    }).outputs({
+      '#sum': '#duration',
+    }),
+
+    {
+      dependencies: ['#count', '#duration'],
+      compute: (continuation, {
+        ['#count']: count,
+        ['#duration']: duration,
+      }) => continuation({
+        ['#contributionListCount']: count,
+        ['#contributionListDuration']: duration,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/wiki-data/withCoverArtDate.js
new file mode 100644
index 00000000..a114d5ff
--- /dev/null
+++ b/src/data/composite/wiki-data/withCoverArtDate.js
@@ -0,0 +1,51 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isDate} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withResolvedContribs from './withResolvedContribs.js';
+
+export default templateCompositeFrom({
+  annotation: `withCoverArtDate`,
+
+  inputs: {
+    from: input({
+      validate: isDate,
+      defaultDependency: 'coverArtDate',
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#coverArtDate'],
+
+  steps: () => [
+    withResolvedContribs({
+      from: 'coverArtistContribs',
+      date: input.value(null),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#resolvedContribs',
+      mode: input.value('empty'),
+      output: input.value({'#coverArtDate': null}),
+    }),
+
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: from,
+      }) =>
+        (from
+          ? continuation.raiseOutput({'#coverArtDate': from})
+          : continuation()),
+    },
+
+    {
+      dependencies: ['date'],
+      compute: (continuation, {date}) =>
+        (date
+          ? continuation({'#coverArtDate': date})
+          : continuation({'#coverArtDate': null})),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withDirectory.js b/src/data/composite/wiki-data/withDirectory.js
new file mode 100644
index 00000000..f3bedf2e
--- /dev/null
+++ b/src/data/composite/wiki-data/withDirectory.js
@@ -0,0 +1,62 @@
+// Select a directory, either using a manually specified directory, or
+// computing it from a name. By default these values are the current thing's
+// 'directory' and 'name' properties, so it can be used without any options
+// to get the current thing's effective directory (assuming no custom rules).
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {isDirectory, isName} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import withSimpleDirectory from './helpers/withSimpleDirectory.js';
+
+export default templateCompositeFrom({
+  annotation: `withDirectory`,
+
+  inputs: {
+    directory: input({
+      validate: isDirectory,
+      defaultDependency: 'directory',
+      acceptsNull: true,
+    }),
+
+    name: input({
+      validate: isName,
+      defaultDependency: 'name',
+      acceptsNull: true,
+    }),
+
+    suffix: input({
+      validate: isDirectory,
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#directory'],
+
+  steps: () => [
+    withSimpleDirectory({
+      directory: input('directory'),
+      name: input('name'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#directory',
+      output: input.value({['#directory']: null}),
+    }),
+
+    {
+      dependencies: ['#directory', input('suffix')],
+      compute: (continuation, {
+        ['#directory']: directory,
+        [input('suffix')]: suffix,
+      }) => continuation({
+        ['#directory']:
+          (suffix
+            ? directory + '-' + suffix
+            : directory),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
new file mode 100644
index 00000000..6794c479
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -0,0 +1,129 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isCommentary} from '#validators';
+import {commentaryRegexCaseSensitive} from '#wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import processContentEntryDates from './processContentEntryDates.js';
+import withParsedContentEntries from './withParsedContentEntries.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withParsedCommentaryEntries`,
+
+  inputs: {
+    from: input({validate: isCommentary}),
+  },
+
+  outputs: ['#parsedCommentaryEntries'],
+
+  steps: () => [
+    withParsedContentEntries({
+      from: input('from'),
+      caseSensitiveRegex: input.value(commentaryRegexCaseSensitive),
+    }),
+
+    withPropertiesFromList({
+      list: '#parsedContentEntryHeadings',
+      prefix: input.value('#entries'),
+      properties: input.value([
+        'artistReferences',
+        'artistDisplayText',
+        'annotation',
+        'date',
+        'secondDate',
+        'dateKind',
+        'accessDate',
+        'accessKind',
+      ]),
+    }),
+
+    // The artistReferences group will always have a value, since it's required
+    // for the line to match in the first place.
+
+    {
+      dependencies: ['#entries.artistReferences'],
+      compute: (continuation, {
+        ['#entries.artistReferences']: artistReferenceTexts,
+      }) => continuation({
+        ['#entries.artistReferences']:
+          artistReferenceTexts
+            .map(text => text.split(',').map(ref => ref.trim())),
+      }),
+    },
+
+    withFlattenedList({
+      list: '#entries.artistReferences',
+    }),
+
+    withResolvedReferenceList({
+      list: '#flattenedList',
+      find: inputSoupyFind.input('artist'),
+      notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
+    }).outputs({
+      '#unflattenedList': '#entries.artists',
+    }),
+
+    fillMissingListItems({
+      list: '#entries.artistDisplayText',
+      fill: input.value(null),
+    }),
+
+    fillMissingListItems({
+      list: '#entries.annotation',
+      fill: input.value(null),
+    }),
+
+    processContentEntryDates(),
+
+    {
+      dependencies: [
+        '#entries.artists',
+        '#entries.artistDisplayText',
+        '#entries.annotation',
+        '#entries.date',
+        '#entries.secondDate',
+        '#entries.dateKind',
+        '#entries.accessDate',
+        '#entries.accessKind',
+        '#parsedContentEntryBodies',
+      ],
+
+      compute: (continuation, {
+        ['#entries.artists']: artists,
+        ['#entries.artistDisplayText']: artistDisplayText,
+        ['#entries.annotation']: annotation,
+        ['#entries.date']: date,
+        ['#entries.secondDate']: secondDate,
+        ['#entries.dateKind']: dateKind,
+        ['#entries.accessDate']: accessDate,
+        ['#entries.accessKind']: accessKind,
+        ['#parsedContentEntryBodies']: body,
+      }) => continuation({
+        ['#parsedCommentaryEntries']:
+          stitchArrays({
+            artists,
+            artistDisplayText,
+            annotation,
+            date,
+            secondDate,
+            dateKind,
+            accessDate,
+            accessKind,
+            body,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withParsedContentEntries.js b/src/data/composite/wiki-data/withParsedContentEntries.js
new file mode 100644
index 00000000..2a9b3f6a
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedContentEntries.js
@@ -0,0 +1,111 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isContentString, validateInstanceOf} from '#validators';
+
+import {withPropertiesFromList} from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withParsedContentEntries`,
+
+  inputs: {
+    // TODO: Is there any way to validate this input based on the *other*
+    // inputs proivded, i.e. regexes? This kind of just assumes the string
+    // has already been validated according to the form the regex expects,
+    // which *is* always the case (as used), but it seems a bit awkward.
+    from: input({validate: isContentString}),
+
+    caseSensitiveRegex: input({
+      validate: validateInstanceOf(RegExp),
+    }),
+  },
+
+  outputs: [
+    '#parsedContentEntryHeadings',
+    '#parsedContentEntryBodies',
+  ],
+
+  steps: () => [
+    {
+      dependencies: [
+        input('from'),
+        input('caseSensitiveRegex'),
+      ],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+        [input('caseSensitiveRegex')]: caseSensitiveRegex,
+      }) => continuation({
+        ['#rawMatches']:
+          Array.from(commentaryText.matchAll(caseSensitiveRegex)),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: '#rawMatches',
+      properties: input.value([
+        '0', // The entire match as a string.
+        'groups',
+        'index',
+      ]),
+    }).outputs({
+      '#rawMatches.0': '#rawMatches.text',
+      '#rawMatches.groups': '#parsedContentEntryHeadings',
+      '#rawMatches.index': '#rawMatches.startIndex',
+    }),
+
+    {
+      dependencies: [
+        '#rawMatches.text',
+        '#rawMatches.startIndex',
+      ],
+
+      compute: (continuation, {
+        ['#rawMatches.text']: text,
+        ['#rawMatches.startIndex']: startIndex,
+      }) => continuation({
+        ['#rawMatches.endIndex']:
+          stitchArrays({text, startIndex})
+            .map(({text, startIndex}) => startIndex + text.length),
+      }),
+    },
+
+    {
+      dependencies: [
+        input('from'),
+        '#rawMatches.startIndex',
+        '#rawMatches.endIndex',
+      ],
+
+      compute: (continuation, {
+        [input('from')]: commentaryText,
+        ['#rawMatches.startIndex']: startIndex,
+        ['#rawMatches.endIndex']: endIndex,
+      }) => continuation({
+        ['#parsedContentEntryBodies']:
+          stitchArrays({startIndex, endIndex})
+            .map(({endIndex}, index, stitched) =>
+              (index === stitched.length - 1
+                ? commentaryText.slice(endIndex)
+                : commentaryText.slice(
+                    endIndex,
+                    stitched[index + 1].startIndex)))
+            .map(body => body.trim()),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#parsedContentEntryHeadings',
+        '#parsedContentEntryBodies',
+      ],
+
+      compute: (continuation, {
+        ['#parsedContentEntryHeadings']: parsedContentEntryHeadings,
+        ['#parsedContentEntryBodies']: parsedContentEntryBodies,
+      }) => continuation({
+        ['#parsedContentEntryHeadings']: parsedContentEntryHeadings,
+        ['#parsedContentEntryBodies']: parsedContentEntryBodies,
+      })
+    }
+  ],
+});
diff --git a/src/data/composite/wiki-data/withParsedLyricsEntries.js b/src/data/composite/wiki-data/withParsedLyricsEntries.js
new file mode 100644
index 00000000..d13bfbaa
--- /dev/null
+++ b/src/data/composite/wiki-data/withParsedLyricsEntries.js
@@ -0,0 +1,157 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isLyrics} from '#validators';
+import {commentaryRegexCaseSensitive, oldStyleLyricsDetectionRegex}
+  from '#wiki-data';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withPropertiesFromList,
+  withUnflattenedList,
+} from '#composite/data';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import processContentEntryDates from './processContentEntryDates.js';
+import withParsedContentEntries from './withParsedContentEntries.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+function constituteLyricsEntry(text) {
+  return {
+    artists: [],
+    artistDisplayText: null,
+    annotation: null,
+    date: null,
+    secondDate: null,
+    dateKind: null,
+    accessDate: null,
+    accessKind: null,
+    body: text,
+  };
+}
+
+export default templateCompositeFrom({
+  annotation: `withParsedLyricsEntries`,
+
+  inputs: {
+    from: input({validate: isLyrics}),
+  },
+
+  outputs: ['#parsedLyricsEntries'],
+
+  steps: () => [
+    {
+      dependencies: [input('from')],
+      compute: (continuation, {
+        [input('from')]: lyrics,
+      }) =>
+        (oldStyleLyricsDetectionRegex.test(lyrics)
+          ? continuation()
+          : continuation.raiseOutput({
+              ['#parsedLyricsEntries']:
+                [constituteLyricsEntry(lyrics)],
+            })),
+    },
+
+    withParsedContentEntries({
+      from: input('from'),
+      caseSensitiveRegex: input.value(commentaryRegexCaseSensitive),
+    }),
+
+    withPropertiesFromList({
+      list: '#parsedContentEntryHeadings',
+      prefix: input.value('#entries'),
+      properties: input.value([
+        'artistReferences',
+        'artistDisplayText',
+        'annotation',
+        'date',
+        'secondDate',
+        'dateKind',
+        'accessDate',
+        'accessKind',
+      ]),
+    }),
+
+    // The artistReferences group will always have a value, since it's required
+    // for the line to match in the first place.
+
+    {
+      dependencies: ['#entries.artistReferences'],
+      compute: (continuation, {
+        ['#entries.artistReferences']: artistReferenceTexts,
+      }) => continuation({
+        ['#entries.artistReferences']:
+          artistReferenceTexts
+            .map(text => text.split(',').map(ref => ref.trim())),
+      }),
+    },
+
+    withFlattenedList({
+      list: '#entries.artistReferences',
+    }),
+
+    withResolvedReferenceList({
+      list: '#flattenedList',
+      find: inputSoupyFind.input('artist'),
+      notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
+    }).outputs({
+      '#unflattenedList': '#entries.artists',
+    }),
+
+    fillMissingListItems({
+      list: '#entries.artistDisplayText',
+      fill: input.value(null),
+    }),
+
+    fillMissingListItems({
+      list: '#entries.annotation',
+      fill: input.value(null),
+    }),
+
+    processContentEntryDates(),
+
+    {
+      dependencies: [
+        '#entries.artists',
+        '#entries.artistDisplayText',
+        '#entries.annotation',
+        '#entries.date',
+        '#entries.secondDate',
+        '#entries.dateKind',
+        '#entries.accessDate',
+        '#entries.accessKind',
+        '#parsedContentEntryBodies',
+      ],
+
+      compute: (continuation, {
+        ['#entries.artists']: artists,
+        ['#entries.artistDisplayText']: artistDisplayText,
+        ['#entries.annotation']: annotation,
+        ['#entries.date']: date,
+        ['#entries.secondDate']: secondDate,
+        ['#entries.dateKind']: dateKind,
+        ['#entries.accessDate']: accessDate,
+        ['#entries.accessKind']: accessKind,
+        ['#parsedContentEntryBodies']: body,
+      }) => continuation({
+        ['#parsedLyricsEntries']:
+          stitchArrays({
+            artists,
+            artistDisplayText,
+            annotation,
+            date,
+            secondDate,
+            dateKind,
+            accessDate,
+            accessKind,
+            body,
+          }),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withRecontextualizedContributionList.js b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
new file mode 100644
index 00000000..bcc6e486
--- /dev/null
+++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
@@ -0,0 +1,100 @@
+// Clones all the contributions in a list, with thing and thingProperty both
+// updated to match the current thing. Overwrites the provided dependency.
+// Optionally updates artistProperty as well. Doesn't do anything if
+// the provided dependency is null.
+//
+// See also:
+//  - withRedatedContributionList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isStringNonEmpty} from '#validators';
+
+import {withClonedThings} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withRecontextualizedContributionList`,
+
+  inputs: {
+    list: input.staticDependency({
+      type: 'array',
+      acceptsNull: true,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list],
+
+  steps: () => [
+    // TODO: Is raiseOutputWithoutDependency workable here?
+    // Is it true that not specifying any output wouldn't overwrite
+    // the provided dependency?
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input('list'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: dependency,
+        [input('list')]: list,
+      }) =>
+        (list
+          ? continuation()
+          : continuation.raiseOutput({
+              [dependency]: list,
+            })),
+    },
+
+    {
+      dependencies: [
+        input.myself(),
+        input.thisProperty(),
+        input('artistProperty'),
+      ],
+
+      compute: (continuation, {
+        [input.myself()]: myself,
+        [input.thisProperty()]: thisProperty,
+        [input('artistProperty')]: artistProperty,
+      }) => continuation({
+        ['#assignment']:
+          Object.assign(
+            {thing: myself},
+            {thingProperty: thisProperty},
+
+            (artistProperty
+              ? {artistProperty}
+              : {})),
+      }),
+    },
+
+    withClonedThings({
+      things: input('list'),
+      assign: '#assignment',
+    }).outputs({
+      '#clonedThings': '#newContributions',
+    }),
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        '#newContributions',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: listDependency,
+        ['#newContributions']: newContributions,
+      }) => continuation({
+        [listDependency]:
+          newContributions,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withRedatedContributionList.js b/src/data/composite/wiki-data/withRedatedContributionList.js
new file mode 100644
index 00000000..12f3e16b
--- /dev/null
+++ b/src/data/composite/wiki-data/withRedatedContributionList.js
@@ -0,0 +1,127 @@
+// Clones all the contributions in a list, with date updated to the provided
+// value. Overwrites the provided dependency. Doesn't do anything if the
+// provided dependency is null, or the provided date is null.
+//
+// If 'override' is true (the default), then so long as the provided date has
+// a value at all, it's always written onto the (cloned) contributions.
+//
+// If 'override' is false, and any of the contributions were already dated,
+// those will keep their existing dates.
+//
+// See also:
+//  - withRecontextualizedContributionList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {isDate} from '#validators';
+
+import {withMappedList, withPropertyFromList} from '#composite/data';
+import {withClonedThings} from '#composite/wiki-data';
+
+export default templateCompositeFrom({
+  annotation: `withRedatedContributionList`,
+
+  inputs: {
+    list: input.staticDependency({
+      type: 'array',
+      acceptsNull: true,
+    }),
+
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    override: input({
+      type: 'boolean',
+      defaultValue: true,
+    }),
+  },
+
+  outputs: ({
+    [input.staticDependency('list')]: list,
+  }) => [list],
+
+  steps: () => [
+    // TODO: Is raiseOutputWithoutDependency workable here?
+    // Is it true that not specifying any output wouldn't overwrite
+    // the provided dependency?
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        input('list'),
+        input('date'),
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: dependency,
+        [input('list')]: list,
+        [input('date')]: date,
+      }) =>
+        (list && date
+          ? continuation()
+          : continuation.raiseOutput({
+              [dependency]: list,
+            })),
+    },
+
+    withPropertyFromList({
+      list: input('list'),
+      property: input.value('date'),
+    }).outputs({
+      '#list.date': '#existingDates',
+    }),
+
+    {
+      dependencies: [
+        input('date'),
+        input('override'),
+        '#existingDates',
+      ],
+
+      compute: (continuation, {
+        [input('date')]: date,
+        [input('override')]: override,
+        '#existingDates': existingDates,
+      }) => continuation({
+        ['#assignmentMap']:
+          // TODO: Should be mapping over withIndicesFromList
+          (_, index) =>
+            (!override && existingDates[index]
+              ? {date: existingDates[index]}
+           : date
+              ? {date}
+              : {}),
+      }),
+    },
+
+    withMappedList({
+      list: input('list'),
+      map: '#assignmentMap',
+    }).outputs({
+      '#mappedList': '#assignment',
+    }),
+
+    withClonedThings({
+      things: input('list'),
+      assignEach: '#assignment',
+    }).outputs({
+      '#clonedThings': '#newContributions',
+    }),
+
+    {
+      dependencies: [
+        input.staticDependency('list'),
+        '#newContributions',
+      ],
+
+      compute: (continuation, {
+        [input.staticDependency('list')]: listDependency,
+        ['#newContributions']: newContributions,
+      }) => continuation({
+        [listDependency]:
+          newContributions,
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
new file mode 100644
index 00000000..9cc52f29
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedAnnotatedReferenceList.js
@@ -0,0 +1,100 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isObject, validateArrayItems} from '#validators';
+
+import {withPropertyFromList} from '#composite/data';
+
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import inputNotFoundMode from './inputNotFoundMode.js';
+import inputWikiData from './inputWikiData.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedAnnotatedReferenceList`,
+
+  inputs: {
+    list: input({
+      validate: validateArrayItems(isObject),
+      acceptsNull: true,
+    }),
+
+    reference: input({type: 'string', defaultValue: 'reference'}),
+    annotation: input({type: 'string', defaultValue: 'annotation'}),
+    thing: input({type: 'string', defaultValue: 'thing'}),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+
+    notFoundMode: inputNotFoundMode(),
+  },
+
+  outputs: ['#resolvedAnnotatedReferenceList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedAnnotatedReferenceList']: [],
+      }),
+    }),
+
+    withPropertyFromList({
+      list: input('list'),
+      property: input('reference'),
+    }).outputs({
+      ['#values']: '#references',
+    }),
+
+    withPropertyFromList({
+      list: input('list'),
+      property: input('annotation'),
+    }).outputs({
+      ['#values']: '#annotations',
+    }),
+
+    withResolvedReferenceList({
+      list: '#references',
+      data: input('data'),
+      find: input('find'),
+      notFoundMode: input.value('null'),
+    }),
+
+    {
+      dependencies: [
+        input('thing'),
+        input('annotation'),
+        '#resolvedReferenceList',
+        '#annotations',
+      ],
+
+      compute: (continuation, {
+        [input('thing')]: thingProperty,
+        [input('annotation')]: annotationProperty,
+        ['#resolvedReferenceList']: things,
+        ['#annotations']: annotations,
+      }) => continuation({
+        ['#matches']:
+          stitchArrays({
+            [thingProperty]: things,
+            [annotationProperty]: annotations,
+          }),
+      }),
+    },
+
+    withAvailabilityFilter({
+      from: '#resolvedReferenceList',
+    }),
+
+    raiseResolvedReferenceList({
+      notFoundMode: input('notFoundMode'),
+      results: '#matches',
+      filter: '#availabilityFilter',
+      outputs: input.value('#resolvedAnnotatedReferenceList'),
+    }),
+  ],
+})
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
new file mode 100644
index 00000000..838c991f
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -0,0 +1,156 @@
+// Resolves the contribsByRef contained in the provided dependency,
+// providing (named by the second argument) the result. "Resolving"
+// means mapping the artist reference of each contribution to an artist
+// object, and filtering out those whose artist reference doesn't match
+// any artist.
+
+import {input, templateCompositeFrom} from '#composite';
+import {filterMultipleArrays, stitchArrays} from '#sugar';
+import thingConstructors from '#things';
+import {isContributionList, isDate, isStringNonEmpty} from '#validators';
+
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+import {withPropertyFromList, withPropertiesFromList} from '#composite/data';
+
+import inputNotFoundMode from './inputNotFoundMode.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedContribs`,
+
+  inputs: {
+    from: input({
+      validate: isContributionList,
+      acceptsNull: true,
+    }),
+
+    date: input({
+      validate: isDate,
+      acceptsNull: true,
+    }),
+
+    notFoundMode: inputNotFoundMode(),
+
+    thingProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
+  },
+
+  outputs: ['#resolvedContribs'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('from'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedContribs']: [],
+      }),
+    }),
+
+    {
+      dependencies: [
+        input('thingProperty'),
+        input.staticDependency('from'),
+      ],
+
+      compute: (continuation, {
+        [input('thingProperty')]: thingProperty,
+        [input.staticDependency('from')]: fromDependency,
+      }) => continuation({
+        ['#thingProperty']:
+          (thingProperty
+            ? thingProperty
+         : !fromDependency?.startsWith('#')
+            ? fromDependency
+            : null),
+      }),
+    },
+
+    withPropertiesFromList({
+      list: input('from'),
+      properties: input.value(['artist', 'annotation']),
+      prefix: input.value('#contribs'),
+    }),
+
+    {
+      dependencies: [
+        '#contribs.artist',
+        '#contribs.annotation',
+        input('date'),
+      ],
+
+      compute(continuation, {
+        ['#contribs.artist']: artist,
+        ['#contribs.annotation']: annotation,
+        [input('date')]: date,
+      }) {
+        filterMultipleArrays(artist, annotation, (artist, _annotation) => artist);
+
+        return continuation({
+          ['#details']:
+            stitchArrays({artist, annotation})
+              .map(details => ({
+                ...details,
+                date: date ?? null,
+              })),
+        });
+      },
+    },
+
+    {
+      dependencies: [
+        '#details',
+        '#thingProperty',
+        input('artistProperty'),
+        input.myself(),
+        'find',
+      ],
+
+      compute: (continuation, {
+        ['#details']: details,
+        ['#thingProperty']: thingProperty,
+        [input('artistProperty')]: artistProperty,
+        [input.myself()]: myself,
+        ['find']: find,
+      }) => continuation({
+        ['#contributions']:
+          details.map(details => {
+            const contrib = new thingConstructors.Contribution();
+
+            Object.assign(contrib, {
+              ...details,
+              thing: myself,
+              thingProperty: thingProperty,
+              artistProperty: artistProperty,
+              find: find,
+            });
+
+            return contrib;
+          }),
+      }),
+    },
+
+    withPropertyFromList({
+      list: '#contributions',
+      property: input.value('artist'),
+    }),
+
+    withAvailabilityFilter({
+      from: '#contributions.artist',
+    }),
+
+    raiseResolvedReferenceList({
+      notFoundMode: input('notFoundMode'),
+      results: '#contributions',
+      filter: '#availabilityFilter',
+      outputs: input.value('#resolvedContribs'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedReference.js b/src/data/composite/wiki-data/withResolvedReference.js
new file mode 100644
index 00000000..6f422194
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedReference.js
@@ -0,0 +1,57 @@
+// Resolves a reference by using the provided find function to match it
+// within the provided thingData dependency. The data object is provided on
+// the output dependency, or null, if the reference doesn't match anything or
+// itself was null to begin with.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import gobbleSoupyFind from './gobbleSoupyFind.js';
+import inputSoupyFind from './inputSoupyFind.js';
+import inputWikiData from './inputWikiData.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedReference`,
+
+  inputs: {
+    ref: input({type: 'string', acceptsNull: true}),
+
+    data: inputWikiData({allowMixedTypes: false}),
+    find: inputSoupyFind(),
+  },
+
+  outputs: ['#resolvedReference'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('ref'),
+      output: input.value({
+        ['#resolvedReference']: null,
+      }),
+    }),
+
+    gobbleSoupyFind({
+      find: input('find'),
+    }),
+
+    {
+      dependencies: [
+        input('ref'),
+        input('data'),
+        '#find',
+      ],
+
+      compute: (continuation, {
+        [input('ref')]: ref,
+        [input('data')]: data,
+        ['#find']: findFunction,
+      }) => continuation({
+        ['#resolvedReference']:
+          (data
+            ? findFunction(ref, data, {mode: 'quiet'}) ?? null
+            : findFunction(ref, {mode: 'quiet'}) ?? null),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedReferenceList.js b/src/data/composite/wiki-data/withResolvedReferenceList.js
new file mode 100644
index 00000000..9dc960dd
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedReferenceList.js
@@ -0,0 +1,80 @@
+// Resolves a list of references, with each reference matched with provided
+// data in the same way as withResolvedReference. By default it will filter
+// out references which don't match, but this can be changed to early exit
+// ({notFoundMode: 'exit'}) or leave null in place ('null').
+
+import {input, templateCompositeFrom} from '#composite';
+import {isString, validateArrayItems} from '#validators';
+
+import {raiseOutputWithoutDependency, withAvailabilityFilter}
+  from '#composite/control-flow';
+import {withMappedList} from '#composite/data';
+
+import gobbleSoupyFind from './gobbleSoupyFind.js';
+import inputNotFoundMode from './inputNotFoundMode.js';
+import inputSoupyFind from './inputSoupyFind.js';
+import inputWikiData from './inputWikiData.js';
+import raiseResolvedReferenceList from './raiseResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedReferenceList`,
+
+  inputs: {
+    list: input({
+      validate: validateArrayItems(isString),
+      acceptsNull: true,
+    }),
+
+    data: inputWikiData({allowMixedTypes: true}),
+    find: inputSoupyFind(),
+
+    notFoundMode: inputNotFoundMode(),
+  },
+
+  outputs: ['#resolvedReferenceList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedReferenceList']: [],
+      }),
+    }),
+
+    gobbleSoupyFind({
+      find: input('find'),
+    }),
+
+    {
+      dependencies: [input('data'), '#find'],
+      compute: (continuation, {
+        [input('data')]: data,
+        ['#find']: findFunction,
+      }) => continuation({
+        ['#map']:
+          (data
+            ? ref => findFunction(ref, data, {mode: 'quiet'})
+            : ref => findFunction(ref, {mode: 'quiet'})),
+      }),
+    },
+
+    withMappedList({
+      list: input('list'),
+      map: '#map',
+    }).outputs({
+      '#mappedList': '#matches',
+    }),
+
+    withAvailabilityFilter({
+      from: '#matches',
+    }),
+
+    raiseResolvedReferenceList({
+      notFoundMode: input('notFoundMode'),
+      results: '#matches',
+      filter: '#availabilityFilter',
+      outputs: input.value('#resolvedReferenceList'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js
new file mode 100644
index 00000000..deaab466
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedSeriesList.js
@@ -0,0 +1,130 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {isSeriesList, validateThing} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withUnflattenedList,
+  withPropertiesFromList,
+} from '#composite/data';
+
+import inputSoupyFind from './inputSoupyFind.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedSeriesList`,
+
+  inputs: {
+    group: input({
+      validate: validateThing({referenceType: 'group'}),
+    }),
+
+    list: input({
+      validate: isSeriesList,
+      acceptsNull: true,
+    }),
+  },
+
+  outputs: ['#resolvedSeriesList'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('list'),
+      mode: input.value('empty'),
+      output: input.value({
+        ['#resolvedSeriesList']: [],
+      }),
+    }),
+
+    withPropertiesFromList({
+      list: input('list'),
+      prefix: input.value('#serieses'),
+      properties: input.value([
+        'name',
+        'description',
+        'albums',
+
+        'showAlbumArtists',
+      ]),
+    }),
+
+    fillMissingListItems({
+      list: '#serieses.albums',
+      fill: input.value([]),
+    }),
+
+    withFlattenedList({
+      list: '#serieses.albums',
+    }),
+
+    withResolvedReferenceList({
+      list: '#flattenedList',
+      find: inputSoupyFind.input('album'),
+      notFoundMode: input.value('null'),
+    }),
+
+    withUnflattenedList({
+      list: '#resolvedReferenceList',
+    }).outputs({
+      '#unflattenedList': '#serieses.albums',
+    }),
+
+    fillMissingListItems({
+      list: '#serieses.description',
+      fill: input.value(null),
+    }),
+
+    fillMissingListItems({
+      list: '#serieses.showAlbumArtists',
+      fill: input.value(null),
+    }),
+
+    {
+      dependencies: [
+        '#serieses.name',
+        '#serieses.description',
+        '#serieses.albums',
+
+        '#serieses.showAlbumArtists',
+      ],
+
+      compute: (continuation, {
+        ['#serieses.name']: name,
+        ['#serieses.description']: description,
+        ['#serieses.albums']: albums,
+
+        ['#serieses.showAlbumArtists']: showAlbumArtists,
+      }) => continuation({
+        ['#seriesProperties']:
+          stitchArrays({
+            name,
+            description,
+            albums,
+
+            showAlbumArtists,
+          }).map(properties => ({
+              ...properties,
+              group: input
+            }))
+      }),
+    },
+
+    {
+      dependencies: ['#seriesProperties', input('group')],
+      compute: (continuation, {
+        ['#seriesProperties']: seriesProperties,
+        [input('group')]: group,
+      }) => continuation({
+        ['#resolvedSeriesList']:
+          seriesProperties
+            .map(properties => ({
+              ...properties,
+              group,
+            })),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
new file mode 100644
index 00000000..906f5bc5
--- /dev/null
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -0,0 +1,36 @@
+// Check out the info on reverseReferenceList!
+// This is its composable form.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import gobbleSoupyReverse from './gobbleSoupyReverse.js';
+import inputSoupyReverse from './inputSoupyReverse.js';
+import inputWikiData from './inputWikiData.js';
+
+import withResolvedReverse from './helpers/withResolvedReverse.js';
+
+export default templateCompositeFrom({
+  annotation: `withReverseReferenceList`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
+  },
+
+  outputs: ['#reverseReferenceList'],
+
+  steps: () => [
+    gobbleSoupyReverse({
+      reverse: input('reverse'),
+    }),
+
+    // TODO: Check that the reverse spec returns a list.
+
+    withResolvedReverse({
+      data: input('data'),
+      reverse: '#reverse',
+    }).outputs({
+      '#resolvedReverse': '#reverseReferenceList',
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/withThingsSortedAlphabetically.js b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
new file mode 100644
index 00000000..5e85fa6a
--- /dev/null
+++ b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
@@ -0,0 +1,122 @@
+// Sorts a list of live, generic wiki data objects alphabetically.
+// Note that this uses localeCompare but isn't specialized to a particular
+// language; where localization is concerned (in content), a follow-up, locale-
+// specific sort should be performed. But this function does serve to organize
+// a list so same-name entries are beside each other.
+
+import {input, templateCompositeFrom} from '#composite';
+import {compareCaseLessSensitive, normalizeName} from '#sort';
+import {validateWikiData} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withMappedList, withSortedList, withPropertiesFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withThingsSortedAlphabetically`,
+
+  inputs: {
+    things: input({validate: validateWikiData}),
+  },
+
+  outputs: ['#sortedThings'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('things'),
+      mode: input.value('empty'),
+      output: input.value({'#sortedThings': []}),
+    }),
+
+    withPropertiesFromList({
+      list: input('things'),
+      properties: input.value(['name', 'directory']),
+    }).outputs({
+      '#list.name': '#names',
+      '#list.directory': '#directories',
+    }),
+
+    withMappedList({
+      list: '#names',
+      map: input.value(normalizeName),
+    }).outputs({
+      '#mappedList': '#normalizedNames',
+    }),
+
+    withSortedList({
+      list: '#normalizedNames',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#normalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#names',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#nonNormalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#directories',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#directorySortIndices',
+    }),
+
+    // TODO: No primitive for the next two-three steps, yet...
+
+    {
+      dependencies: [input('things')],
+      compute: (continuation, {
+        [input('things')]: things,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          Array.from(
+            {length: things.length},
+            (_item, index) => index),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#combinedSortIndices',
+        '#normalizedNameSortIndices',
+        '#nonNormalizedNameSortIndices',
+        '#directorySortIndices',
+      ],
+
+      compute: (continuation, {
+        ['#combinedSortIndices']: combined,
+        ['#normalizedNameSortIndices']: normalized,
+        ['#nonNormalizedNameSortIndices']: nonNormalized,
+        ['#directorySortIndices']: directory,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          combined.sort((index1, index2) => {
+            if (normalized[index1] !== normalized[index2])
+              return normalized[index1] - normalized[index2];
+
+            if (nonNormalized[index1] !== nonNormalized[index2])
+              return nonNormalized[index1] - nonNormalized[index2];
+
+            if (directory[index1] !== directory[index2])
+              return directory[index1] - directory[index2];
+
+            return 0;
+          }),
+      }),
+    },
+
+    {
+      dependencies: [input('things'), '#combinedSortIndices'],
+      compute: (continuation, {
+        [input('things')]: things,
+        ['#combinedSortIndices']: combined,
+      }) => continuation({
+        ['#sortedThings']:
+          combined.map(index => things[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/withUniqueReferencingThing.js b/src/data/composite/wiki-data/withUniqueReferencingThing.js
new file mode 100644
index 00000000..7c267038
--- /dev/null
+++ b/src/data/composite/wiki-data/withUniqueReferencingThing.js
@@ -0,0 +1,36 @@
+// Like withReverseReferenceList, but this is specifically for special "unique"
+// references, meaning this thing is referenced by exactly one or zero things
+// in the data list.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import gobbleSoupyReverse from './gobbleSoupyReverse.js';
+import inputSoupyReverse from './inputSoupyReverse.js';
+import inputWikiData from './inputWikiData.js';
+
+import withResolvedReverse from './helpers/withResolvedReverse.js';
+
+export default templateCompositeFrom({
+  annotation: `withUniqueReferencingThing`,
+
+  inputs: {
+    data: inputWikiData({allowMixedTypes: true}),
+    reverse: inputSoupyReverse(),
+  },
+
+  outputs: ['#uniqueReferencingThing'],
+
+  steps: () => [
+    gobbleSoupyReverse({
+      reverse: input('reverse'),
+    }),
+
+    withResolvedReverse({
+      data: input('data'),
+      reverse: '#reverse',
+      options: input.value({unique: true}),
+    }).outputs({
+      '#resolvedReverse': '#uniqueReferencingThing',
+    }),
+  ],
+});