« 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/helpers/withDirectoryFromName.js (renamed from src/data/composite/wiki-data/withDirectoryFromName.js)3
-rw-r--r--src/data/composite/wiki-data/helpers/withReverseList-template.js189
-rw-r--r--src/data/composite/wiki-data/helpers/withSimpleDirectory.js52
-rw-r--r--src/data/composite/wiki-data/index.js4
-rw-r--r--src/data/composite/wiki-data/withDirectory.js41
-rw-r--r--src/data/composite/wiki-data/withParsedCommentaryEntries.js84
-rw-r--r--src/data/composite/wiki-data/withRecontextualizedContributionList.js28
-rw-r--r--src/data/composite/wiki-data/withResolvedArtworkReferenceList.js125
-rw-r--r--src/data/composite/wiki-data/withResolvedContribs.js8
-rw-r--r--src/data/composite/wiki-data/withResolvedSeriesList.js131
-rw-r--r--src/data/composite/wiki-data/withReverseContributionList.js139
-rw-r--r--src/data/composite/wiki-data/withReverseReferenceList.js140
-rw-r--r--src/data/composite/wiki-data/withReverseSingleReferenceList.js50
13 files changed, 702 insertions, 292 deletions
diff --git a/src/data/composite/wiki-data/withDirectoryFromName.js b/src/data/composite/wiki-data/helpers/withDirectoryFromName.js
index 034464e4..f85dae16 100644
--- a/src/data/composite/wiki-data/withDirectoryFromName.js
+++ b/src/data/composite/wiki-data/helpers/withDirectoryFromName.js
@@ -1,4 +1,4 @@
-// Compute a directory from a name - by default the current thing's own name.
+// Compute a directory from a name.
 
 import {input, templateCompositeFrom} from '#composite';
 
@@ -13,7 +13,6 @@ export default templateCompositeFrom({
   inputs: {
     name: input({
       validate: isName,
-      defaultDependency: 'name',
       acceptsNull: true,
     }),
   },
diff --git a/src/data/composite/wiki-data/helpers/withReverseList-template.js b/src/data/composite/wiki-data/helpers/withReverseList-template.js
new file mode 100644
index 00000000..5ed0e32e
--- /dev/null
+++ b/src/data/composite/wiki-data/helpers/withReverseList-template.js
@@ -0,0 +1,189 @@
+// Baseline implementation shared by or underlying reverse lists.
+//
+// This is a very rudimentary "these compositions have basically the same
+// shape but slightly different guts midway through" kind of solution,
+// and should use compositional subroutines instead, once those are ready.
+//
+// But, until then, this has the same effect of avoiding code duplication
+// and clearly identifying differences.
+//
+// ---
+//
+// This implementation uses a global cache (via WeakMap) to attempt to speed
+// up subsequent similar accesses.
+//
+// This has absolutely not been rigorously tested with altering properties of
+// data objects in a wiki data array which is reused. If a new wiki data array
+// is used, a fresh cache will always be created.
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {sortByDate} from '#sort';
+import {stitchArrays} from '#sugar';
+
+import {exitWithoutDependency, raiseOutputWithoutDependency}
+  from '#composite/control-flow';
+import {withFlattenedList, withMappedList} from '#composite/data';
+
+import inputWikiData from '../inputWikiData.js';
+
+export default function withReverseList_template({
+  annotation,
+
+  propertyInputName,
+  outputName,
+
+  customCompositionSteps,
+}) {
+  // Mapping of reference list property to WeakMap.
+  // Each WeakMap maps a wiki data array to another weak map,
+  // which in turn maps each referenced thing to an array of
+  // things referencing it.
+  const caches = new Map();
+
+  return templateCompositeFrom({
+    annotation,
+
+    inputs: {
+      data: inputWikiData({
+        allowMixedTypes: false,
+      }),
+
+      [propertyInputName]: input({
+        type: 'string',
+      }),
+    },
+
+    outputs: [outputName],
+
+    steps: () => [
+      // Early exit with an empty array if the data list isn't available.
+      exitWithoutDependency({
+        dependency: input('data'),
+        value: input.value([]),
+      }),
+
+      // Raise an empty array (don't early exit) if the data list is empty.
+      raiseOutputWithoutDependency({
+        dependency: input('data'),
+        mode: input.value('empty'),
+        output: input.value({[outputName]: []}),
+      }),
+
+      // Check for an existing cache record which corresponds to this
+      // property input and input('data'). If it exists, query it for the
+      // current thing, and raise that; if it doesn't, create it, put it
+      // where it needs to be, and provide it so the next steps can fill
+      // it in.
+      {
+        dependencies: [input(propertyInputName), input('data'), input.myself()],
+
+        compute: (continuation, {
+          [input(propertyInputName)]: property,
+          [input('data')]: data,
+          [input.myself()]: myself,
+        }) => {
+          if (!caches.has(property)) {
+            const cache = new WeakMap();
+            caches.set(property, cache);
+
+            const cacheRecord = new WeakMap();
+            cache.set(data, cacheRecord);
+
+            return continuation({
+              ['#cacheRecord']: cacheRecord,
+            });
+          }
+
+          const cache = caches.get(property);
+
+          if (!cache.has(data)) {
+            const cacheRecord = new WeakMap();
+            cache.set(data, cacheRecord);
+
+            return continuation({
+              ['#cacheRecord']: cacheRecord,
+            });
+          }
+
+          return continuation.raiseOutput({
+            [outputName]:
+              cache.get(data).get(myself) ?? [],
+          });
+        },
+      },
+
+      ...customCompositionSteps(),
+
+      // Actually fill in the cache record. Since we're building up a *reverse*
+      // reference list, track connections in terms of the referenced thing.
+      // Although we gather all referenced things into a set and provide that
+      // for sorting purposes in the next step, we *don't* reprovide the cache
+      // record, because we're mutating that in-place - we'll just reuse its
+      // existing '#cacheRecord' dependency.
+      {
+        dependencies: ['#cacheRecord', '#referencingThings', '#referencedThings'],
+        compute: (continuation, {
+          ['#cacheRecord']: cacheRecord,
+          ['#referencingThings']: referencingThings,
+          ['#referencedThings']: referencedThings,
+        }) => {
+          const allReferencedThings = new Set();
+
+          stitchArrays({
+            referencingThing: referencingThings,
+            referencedThings: referencedThings,
+          }).forEach(({referencingThing, referencedThings}) => {
+              for (const referencedThing of referencedThings) {
+                if (cacheRecord.has(referencedThing)) {
+                  cacheRecord.get(referencedThing).push(referencingThing);
+                } else {
+                  cacheRecord.set(referencedThing, [referencingThing]);
+                  allReferencedThings.add(referencedThing);
+                }
+              }
+            });
+
+          return continuation({
+            ['#allReferencedThings']:
+              allReferencedThings,
+          });
+        },
+      },
+
+      // Sort the entries in the cache records, too, just by date - the rest of
+      // sorting should be handled outside of this composition, either preceding
+      // (changing the 'data' input) or following (sorting the output).
+      // Again we're mutating in place, so no need to reprovide '#cacheRecord'
+      // here.
+      {
+        dependencies: ['#cacheRecord', '#allReferencedThings'],
+        compute: (continuation, {
+          ['#cacheRecord']: cacheRecord,
+          ['#allReferencedThings']: allReferencedThings,
+        }) => {
+          for (const referencedThing of allReferencedThings) {
+            if (cacheRecord.has(referencedThing)) {
+              const referencingThings = cacheRecord.get(referencedThing);
+              sortByDate(referencingThings);
+            }
+          }
+
+          return continuation();
+        },
+      },
+
+      // Then just pluck out the current object from the now-filled cache record!
+      {
+        dependencies: ['#cacheRecord', input.myself()],
+        compute: (continuation, {
+          ['#cacheRecord']: cacheRecord,
+          [input.myself()]: myself,
+        }) => continuation({
+          [outputName]:
+            cacheRecord.get(myself) ?? [],
+        }),
+      },
+    ],
+  });
+}
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
index 5f17ca3a..f99a1a14 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -10,14 +10,16 @@ export {default as withClonedThings} from './withClonedThings.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 withDirectoryFromName} from './withDirectoryFromName.js';
 export {default as withParsedCommentaryEntries} from './withParsedCommentaryEntries.js';
 export {default as withRecontextualizedContributionList} from './withRecontextualizedContributionList.js';
 export {default as withRedatedContributionList} from './withRedatedContributionList.js';
+export {default as withResolvedArtworkReferenceList} from './withResolvedArtworkReferenceList.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 withReverseContributionList} from './withReverseContributionList.js';
 export {default as withReverseReferenceList} from './withReverseReferenceList.js';
+export {default as withReverseSingleReferenceList} from './withReverseSingleReferenceList.js';
 export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
 export {default as withUniqueReferencingThing} from './withUniqueReferencingThing.js';
diff --git a/src/data/composite/wiki-data/withDirectory.js b/src/data/composite/wiki-data/withDirectory.js
index b08b6153..f3bedf2e 100644
--- a/src/data/composite/wiki-data/withDirectory.js
+++ b/src/data/composite/wiki-data/withDirectory.js
@@ -7,9 +7,9 @@ import {input, templateCompositeFrom} from '#composite';
 
 import {isDirectory, isName} from '#validators';
 
-import {withResultOfAvailabilityCheck} from '#composite/control-flow';
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
 
-import withDirectoryFromName from './withDirectoryFromName.js';
+import withSimpleDirectory from './helpers/withSimpleDirectory.js';
 
 export default templateCompositeFrom({
   annotation: `withDirectory`,
@@ -26,30 +26,37 @@ export default templateCompositeFrom({
       defaultDependency: 'name',
       acceptsNull: true,
     }),
+
+    suffix: input({
+      validate: isDirectory,
+      defaultValue: null,
+    }),
   },
 
   outputs: ['#directory'],
 
   steps: () => [
-    withResultOfAvailabilityCheck({
-      from: input('directory'),
+    withSimpleDirectory({
+      directory: input('directory'),
+      name: input('name'),
+    }),
+
+    raiseOutputWithoutDependency({
+      dependency: '#directory',
+      output: input.value({['#directory']: null}),
     }),
 
     {
-      dependencies: ['#availability', input('directory')],
+      dependencies: ['#directory', input('suffix')],
       compute: (continuation, {
-        ['#availability']: availability,
-        [input('directory')]: directory,
-      }) =>
-        (availability
-          ? continuation.raiseOutput({
-              ['#directory']: directory
-            })
-          : continuation()),
+        ['#directory']: directory,
+        [input('suffix')]: suffix,
+      }) => continuation({
+        ['#directory']:
+          (suffix
+            ? directory + '-' + suffix
+            : directory),
+      }),
     },
-
-    withDirectoryFromName({
-      name: input('name'),
-    }),
   ],
 });
diff --git a/src/data/composite/wiki-data/withParsedCommentaryEntries.js b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
index f0404a5d..144781a8 100644
--- a/src/data/composite/wiki-data/withParsedCommentaryEntries.js
+++ b/src/data/composite/wiki-data/withParsedCommentaryEntries.js
@@ -95,6 +95,10 @@ export default templateCompositeFrom({
         'artistDisplayText',
         'annotation',
         'date',
+        'secondDate',
+        'dateKind',
+        'accessDate',
+        'accessKind',
       ]),
     }),
 
@@ -140,12 +144,78 @@ export default templateCompositeFrom({
     }),
 
     {
+      dependencies: ['#entries.annotation'],
+      compute: (continuation, {
+        ['#entries.annotation']: annotation,
+      }) => continuation({
+        ['#entries.webArchiveDate']:
+          annotation
+            .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: ['#entries.date'],
       compute: (continuation, {
         ['#entries.date']: date,
       }) => continuation({
         ['#entries.date']:
-          date.map(date => date ? new Date(date) : null),
+          date
+            .map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    {
+      dependencies: ['#entries.secondDate'],
+      compute: (continuation, {
+        ['#entries.secondDate']: secondDate,
+      }) => continuation({
+        ['#entries.secondDate']:
+          secondDate
+            .map(date => date ? new Date(date) : null),
+      }),
+    },
+
+    fillMissingListItems({
+      list: '#entries.dateKind',
+      fill: input.value(null),
+    }),
+
+    {
+      dependencies: ['#entries.accessDate', '#entries.webArchiveDate'],
+      compute: (continuation, {
+        ['#entries.accessDate']: accessDate,
+        ['#entries.webArchiveDate']: webArchiveDate,
+      }) => continuation({
+        ['#entries.accessDate']:
+          stitchArrays({accessDate, webArchiveDate})
+            .map(({accessDate, webArchiveDate}) =>
+              accessDate ??
+              webArchiveDate ??
+              null)
+            .map(date => date ? new Date(date) : date),
+      }),
+    },
+
+    {
+      dependencies: ['#entries.accessKind', '#entries.webArchiveDate'],
+      compute: (continuation, {
+        ['#entries.accessKind']: accessKind,
+        ['#entries.webArchiveDate']: webArchiveDate,
+      }) => continuation({
+        ['#entries.accessKind']:
+          stitchArrays({accessKind, webArchiveDate})
+            .map(({accessKind, webArchiveDate}) =>
+              accessKind ??
+              (webArchiveDate && 'captured') ??
+              null),
       }),
     },
 
@@ -155,6 +225,10 @@ export default templateCompositeFrom({
         '#entries.artistDisplayText',
         '#entries.annotation',
         '#entries.date',
+        '#entries.secondDate',
+        '#entries.dateKind',
+        '#entries.accessDate',
+        '#entries.accessKind',
         '#entries.body',
       ],
 
@@ -163,6 +237,10 @@ export default templateCompositeFrom({
         ['#entries.artistDisplayText']: artistDisplayText,
         ['#entries.annotation']: annotation,
         ['#entries.date']: date,
+        ['#entries.secondDate']: secondDate,
+        ['#entries.dateKind']: dateKind,
+        ['#entries.accessDate']: accessDate,
+        ['#entries.accessKind']: accessKind,
         ['#entries.body']: body,
       }) => continuation({
         ['#parsedCommentaryEntries']:
@@ -171,6 +249,10 @@ export default templateCompositeFrom({
             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
index 06c997b5..d2401eac 100644
--- a/src/data/composite/wiki-data/withRecontextualizedContributionList.js
+++ b/src/data/composite/wiki-data/withRecontextualizedContributionList.js
@@ -1,12 +1,14 @@
 // Clones all the contributions in a list, with thing and thingProperty both
 // updated to match the current thing. Overwrites the provided dependency.
-// Doesn't do anything if the provided dependency is null.
+// 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 {raiseOutputWithoutDependency} from '#composite/control-flow';
 import {withClonedThings} from '#composite/wiki-data';
@@ -19,6 +21,11 @@ export default templateCompositeFrom({
       type: 'array',
       acceptsNull: true,
     }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
   },
 
   outputs: ({
@@ -47,16 +54,25 @@ export default templateCompositeFrom({
     },
 
     {
-      dependencies: [input.myself(), input.thisProperty()],
+      dependencies: [
+        input.myself(),
+        input.thisProperty(),
+        input('artistProperty'),
+      ],
 
       compute: (continuation, {
         [input.myself()]: myself,
         [input.thisProperty()]: thisProperty,
+        [input('artistProperty')]: artistProperty,
       }) => continuation({
-        ['#assignment']: {
-          thing: myself,
-          thingProperty: thisProperty,
-        },
+        ['#assignment']:
+          Object.assign(
+            {thing: myself},
+            {thingProperty: thisProperty},
+
+            (artistProperty
+              ? {artistProperty}
+              : {})),
       }),
     },
 
diff --git a/src/data/composite/wiki-data/withResolvedArtworkReferenceList.js b/src/data/composite/wiki-data/withResolvedArtworkReferenceList.js
new file mode 100644
index 00000000..e9c6a590
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedArtworkReferenceList.js
@@ -0,0 +1,125 @@
+import {input, templateCompositeFrom} from '#composite';
+import {stitchArrays} from '#sugar';
+import {is, isString, optional, validateArrayItems, validateProperties}
+  from '#validators';
+
+import {withFilteredList, withMappedList, withPropertiesFromList}
+  from '#composite/data';
+
+import inputWikiData from './inputWikiData.js';
+import withResolvedReferenceList from './withResolvedReferenceList.js';
+
+export default templateCompositeFrom({
+  annotation: `withResolvedArtworkReferenceList`,
+
+  inputs: {
+    list: input({
+      validate:
+        validateArrayItems(
+          validateProperties({
+            reference: isString,
+            annotation: optional(isString),
+          })),
+
+      acceptsNull: true,
+    }),
+
+    data: inputWikiData({allowMixedTypes: false}),
+    find: input({type: 'function'}),
+
+    notFoundMode: input({
+      validate: is('exit', 'filter', 'null'),
+      defaultValue: 'filter',
+    }),
+  },
+
+  steps: () => [
+    withPropertiesFromList({
+      list: input('list'),
+      properties: input.value([
+        'reference',
+        'annotation',
+      ]),
+    }),
+
+    withResolvedReferenceList({
+      list: '#list.reference',
+      data: input('data'),
+      find: input('find'),
+      notFoundMode: input.value('null'),
+    }),
+
+    {
+      dependencies: [
+        '#resolvedReferenceList',
+        '#list.annotation',
+      ],
+
+      compute: (continuation, {
+        ['#resolvedReferenceList']: thing,
+        ['#list.annotation']: annotation,
+      }) => continuation({
+        ['#matches']:
+          stitchArrays({
+            thing,
+            annotation,
+          }),
+      }),
+    },
+
+    {
+      dependencies: ['#matches'],
+      compute: (continuation, {'#matches': matches}) =>
+        (matches.every(match => match)
+          ? continuation.raiseOutput({
+              ['#resolvedArtworkReferenceList']:
+                matches,
+            })
+          : continuation()),
+    },
+
+    {
+      dependencies: [input('notFoundMode')],
+      compute: (continuation, {
+        [input('notFoundMode')]: notFoundMode,
+      }) =>
+        (notFoundMode === 'exit'
+          ? continuation.exit([])
+          : continuation()),
+    },
+
+    {
+      dependencies: ['#matches', input('notFoundMode')],
+      compute: (continuation, {
+        ['#matches']: matches,
+        [input('notFoundMode')]: notFoundMode,
+      }) =>
+        (notFoundMode === 'null'
+          ? continuation.raiseOutput({
+              ['#resolvedArtworkReferenceList']:
+                matches,
+            })
+          : continuation()),
+    },
+
+    withMappedList({
+      list: '#resolvedReferenceList',
+      map: input.value(thing => thing !== null),
+    }),
+
+    withFilteredList({
+      list: '#matches',
+      filter: '#mappedList',
+    }),
+
+    {
+      dependencies: ['#filteredList'],
+      compute: (continuation, {
+        ['#filteredList']: filteredList,
+      }) => continuation({
+        ['#resolvedArtworkReferenceList']:
+          filteredList,
+      }),
+    },
+  ],
+})
diff --git a/src/data/composite/wiki-data/withResolvedContribs.js b/src/data/composite/wiki-data/withResolvedContribs.js
index 23b91691..b5d7255b 100644
--- a/src/data/composite/wiki-data/withResolvedContribs.js
+++ b/src/data/composite/wiki-data/withResolvedContribs.js
@@ -36,6 +36,11 @@ export default templateCompositeFrom({
       validate: isStringNonEmpty,
       defaultValue: null,
     }),
+
+    artistProperty: input({
+      validate: isStringNonEmpty,
+      defaultValue: null,
+    }),
   },
 
   outputs: ['#resolvedContribs'],
@@ -103,12 +108,14 @@ export default templateCompositeFrom({
       dependencies: [
         '#details',
         '#thingProperty',
+        input('artistProperty'),
         input.myself(),
       ],
 
       compute: (continuation, {
         ['#details']: details,
         ['#thingProperty']: thingProperty,
+        [input('artistProperty')]: artistProperty,
         [input.myself()]: myself,
       }) => continuation({
         ['#contributions']:
@@ -119,6 +126,7 @@ export default templateCompositeFrom({
               ...details,
               thing: myself,
               thingProperty: thingProperty,
+              artistProperty: artistProperty,
             });
 
             return contrib;
diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js
new file mode 100644
index 00000000..4ac74cc3
--- /dev/null
+++ b/src/data/composite/wiki-data/withResolvedSeriesList.js
@@ -0,0 +1,131 @@
+import {input, templateCompositeFrom} from '#composite';
+import find from '#find';
+import {stitchArrays} from '#sugar';
+import {isSeriesList, validateThing} from '#validators';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+
+import {
+  fillMissingListItems,
+  withFlattenedList,
+  withUnflattenedList,
+  withPropertiesFromList,
+} from '#composite/data';
+
+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',
+      data: 'albumData',
+      find: input.value(find.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/withReverseContributionList.js b/src/data/composite/wiki-data/withReverseContributionList.js
index 63e712bb..dcf33c39 100644
--- a/src/data/composite/wiki-data/withReverseContributionList.js
+++ b/src/data/composite/wiki-data/withReverseContributionList.js
@@ -1,100 +1,18 @@
 // Analogous implementation for withReverseReferenceList, for contributions.
-// This is mostly duplicate code and both should be ported to the same
-// underlying data form later on.
-//
-// This implementation uses a global cache (via WeakMap) to attempt to speed
-// up subsequent similar accesses.
-//
-// This has absolutely not been rigorously tested with altering properties of
-// data objects in a wiki data array which is reused. If a new wiki data array
-// is used, a fresh cache will always be created.
 
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
+import withReverseList_template from './helpers/withReverseList-template.js';
 
-import {exitWithoutDependency, raiseOutputWithoutDependency}
-  from '#composite/control-flow';
-import {withFlattenedList, withMappedList} from '#composite/data';
-
-import inputWikiData from './inputWikiData.js';
+import {input} from '#composite';
 
-// Mapping of reference list property to WeakMap.
-// Each WeakMap maps a wiki data array to another weak map,
-// which in turn maps each referenced thing to an array of
-// things referencing it.
-const caches = new Map();
+import {withFlattenedList, withMappedList} from '#composite/data';
 
-export default templateCompositeFrom({
+export default withReverseList_template({
   annotation: `withReverseContributionList`,
 
-  inputs: {
-    data: inputWikiData({allowMixedTypes: false}),
-    list: input({type: 'string'}),
-  },
-
-  outputs: ['#reverseContributionList'],
-
-  steps: () => [
-    // Common behavior --
-
-    // Early exit with an empty array if the data list isn't available.
-    exitWithoutDependency({
-      dependency: input('data'),
-      value: input.value([]),
-    }),
-
-    // Raise an empty array (don't early exit) if the data list is empty.
-    raiseOutputWithoutDependency({
-      dependency: input('data'),
-      mode: input.value('empty'),
-      output: input.value({'#reverseContributionList': []}),
-    }),
-
-    // Check for an existing cache record which corresponds to this
-    // input('list') and input('data'). If it exists, query it for the
-    // current thing, and raise that; if it doesn't, create it, put it
-    // where it needs to be, and provide it so the next steps can fill
-    // it in.
-    {
-      dependencies: [input('list'), input('data'), input.myself()],
-
-      compute: (continuation, {
-        [input('list')]: list,
-        [input('data')]: data,
-        [input.myself()]: myself,
-      }) => {
-        if (!caches.has(list)) {
-          const cache = new WeakMap();
-          caches.set(list, cache);
-
-          const cacheRecord = new WeakMap();
-          cache.set(data, cacheRecord);
-
-          return continuation({
-            ['#cacheRecord']: cacheRecord,
-          });
-        }
-
-        const cache = caches.get(list);
-
-        if (!cache.has(data)) {
-          const cacheRecord = new WeakMap();
-          cache.set(data, cacheRecord);
-
-          return continuation({
-            ['#cacheRecord']: cacheRecord,
-          });
-        }
-
-        return continuation.raiseOutput({
-          ['#reverseContributionList']:
-            cache.get(data).get(myself) ?? [],
-        });
-      },
-    },
-
-    // Unique behavior for contribution lists --
+  propertyInputName: 'list',
+  outputName: '#reverseContributionList',
 
+  customCompositionSteps: () => [
     {
       dependencies: [input('list')],
       compute: (continuation, {
@@ -124,48 +42,5 @@ export default templateCompositeFrom({
     }).outputs({
       '#mappedList': '#referencedThings',
     }),
-
-    // Common behavior --
-
-    // Actually fill in the cache record. Since we're building up a *reverse*
-    // reference list, track connections in terms of the referenced thing.
-    // No newly-provided dependencies here since we're mutating the cache
-    // record, which is properly in store and will probably be reused in the
-    // future (and certainly in the next step).
-    {
-      dependencies: ['#cacheRecord', '#referencingThings', '#referencedThings'],
-      compute: (continuation, {
-        ['#cacheRecord']: cacheRecord,
-        ['#referencingThings']: referencingThings,
-        ['#referencedThings']: referencedThings,
-      }) => {
-        stitchArrays({
-          referencingThing: referencingThings,
-          referencedThings: referencedThings,
-        }).forEach(({referencingThing, referencedThings}) => {
-            for (const referencedThing of referencedThings) {
-              if (cacheRecord.has(referencedThing)) {
-                cacheRecord.get(referencedThing).push(referencingThing);
-              } else {
-                cacheRecord.set(referencedThing, [referencingThing]);
-              }
-            }
-          });
-
-        return continuation();
-      },
-    },
-
-    // Then just pluck out the current object from the now-filled cache record!
-    {
-      dependencies: ['#cacheRecord', input.myself()],
-      compute: (continuation, {
-        ['#cacheRecord']: cacheRecord,
-        [input.myself()]: myself,
-      }) => continuation({
-        ['#reverseContributionList']:
-          cacheRecord.get(myself) ?? [],
-      }),
-    },
   ],
 });
diff --git a/src/data/composite/wiki-data/withReverseReferenceList.js b/src/data/composite/wiki-data/withReverseReferenceList.js
index 1f8c082f..70d9a58d 100644
--- a/src/data/composite/wiki-data/withReverseReferenceList.js
+++ b/src/data/composite/wiki-data/withReverseReferenceList.js
@@ -1,102 +1,19 @@
 // Check out the info on reverseReferenceList!
 // This is its composable form.
-//
-// This implementation uses a global cache (via WeakMap) to attempt to speed
-// up subsequent similar accesses.
-//
-// This has absolutely not been rigorously tested with altering properties of
-// data objects in a wiki data array which is reused. If a new wiki data array
-// is used, a fresh cache will always be created.
-//
-// Note that this implementation is mirrored in withReverseContributionList,
-// so any changes should be reflected there (until these are combined).
 
-import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
+import withReverseList_template from './helpers/withReverseList-template.js';
 
-import {exitWithoutDependency, raiseOutputWithoutDependency}
-  from '#composite/control-flow';
-import {withMappedList} from '#composite/data';
-
-import inputWikiData from './inputWikiData.js';
+import {input} from '#composite';
 
-// Mapping of reference list property to WeakMap.
-// Each WeakMap maps a wiki data array to another weak map,
-// which in turn maps each referenced thing to an array of
-// things referencing it.
-const caches = new Map();
+import {withMappedList} from '#composite/data';
 
-export default templateCompositeFrom({
+export default withReverseList_template({
   annotation: `withReverseReferenceList`,
 
-  inputs: {
-    data: inputWikiData({allowMixedTypes: false}),
-    list: input({type: 'string'}),
-  },
-
-  outputs: ['#reverseReferenceList'],
-
-  steps: () => [
-    // Common behavior --
-
-    // Early exit with an empty array if the data list isn't available.
-    exitWithoutDependency({
-      dependency: input('data'),
-      value: input.value([]),
-    }),
-
-    // Raise an empty array (don't early exit) if the data list is empty.
-    raiseOutputWithoutDependency({
-      dependency: input('data'),
-      mode: input.value('empty'),
-      output: input.value({'#reverseReferenceList': []}),
-    }),
-
-    // Check for an existing cache record which corresponds to this
-    // input('list') and input('data'). If it exists, query it for the
-    // current thing, and raise that; if it doesn't, create it, put it
-    // where it needs to be, and provide it so the next steps can fill
-    // it in.
-    {
-      dependencies: [input('list'), input('data'), input.myself()],
-
-      compute: (continuation, {
-        [input('list')]: list,
-        [input('data')]: data,
-        [input.myself()]: myself,
-      }) => {
-        if (!caches.has(list)) {
-          const cache = new WeakMap();
-          caches.set(list, cache);
-
-          const cacheRecord = new WeakMap();
-          cache.set(data, cacheRecord);
-
-          return continuation({
-            ['#cacheRecord']: cacheRecord,
-          });
-        }
-
-        const cache = caches.get(list);
-
-        if (!cache.has(data)) {
-          const cacheRecord = new WeakMap();
-          cache.set(data, cacheRecord);
-
-          return continuation({
-            ['#cacheRecord']: cacheRecord,
-          });
-        }
-
-        return continuation.raiseOutput({
-          ['#reverseReferenceList']:
-            cache.get(data).get(myself) ?? [],
-        });
-      },
-    },
-
-    // Unique behavior for reference lists --
+  propertyInputName: 'list',
+  outputName: '#reverseReferenceList',
 
+  customCompositionSteps: () => [
     {
       dependencies: [input('list')],
       compute: (continuation, {
@@ -123,48 +40,5 @@ export default templateCompositeFrom({
           data,
       }),
     },
-
-    // Common behavior --
-
-    // Actually fill in the cache record. Since we're building up a *reverse*
-    // reference list, track connections in terms of the referenced thing.
-    // No newly-provided dependencies here since we're mutating the cache
-    // record, which is properly in store and will probably be reused in the
-    // future (and certainly in the next step).
-    {
-      dependencies: ['#cacheRecord', '#referencingThings', '#referencedThings'],
-      compute: (continuation, {
-        ['#cacheRecord']: cacheRecord,
-        ['#referencingThings']: referencingThings,
-        ['#referencedThings']: referencedThings,
-      }) => {
-        stitchArrays({
-          referencingThing: referencingThings,
-          referencedThings: referencedThings,
-        }).forEach(({referencingThing, referencedThings}) => {
-            for (const referencedThing of referencedThings) {
-              if (cacheRecord.has(referencedThing)) {
-                cacheRecord.get(referencedThing).push(referencingThing);
-              } else {
-                cacheRecord.set(referencedThing, [referencingThing]);
-              }
-            }
-          });
-
-        return continuation();
-      },
-    },
-
-    // Then just pluck out the current object from the now-filled cache record!
-    {
-      dependencies: ['#cacheRecord', input.myself()],
-      compute: (continuation, {
-        ['#cacheRecord']: cacheRecord,
-        [input.myself()]: myself,
-      }) => continuation({
-        ['#reverseReferenceList']:
-          cacheRecord.get(myself) ?? [],
-      }),
-    },
   ],
 });
diff --git a/src/data/composite/wiki-data/withReverseSingleReferenceList.js b/src/data/composite/wiki-data/withReverseSingleReferenceList.js
new file mode 100644
index 00000000..dd97dc66
--- /dev/null
+++ b/src/data/composite/wiki-data/withReverseSingleReferenceList.js
@@ -0,0 +1,50 @@
+// Like withReverseReferenceList, but for finding all things which reference
+// the current thing by a property that contains a single reference, rather
+// than within a reference list.
+
+import withReverseList_template from './helpers/withReverseList-template.js';
+
+import {input} from '#composite';
+
+import {withMappedList} from '#composite/data';
+
+export default withReverseList_template({
+  annotation: `withReverseSingleReferenceList`,
+
+  propertyInputName: 'ref',
+  outputName: '#reverseSingleReferenceList',
+
+  customCompositionSteps: () => [
+    {
+      dependencies: [input('data')],
+      compute: (continuation, {
+        [input('data')]: data,
+      }) => continuation({
+        ['#referencingThings']:
+          data,
+      }),
+    },
+
+    // This map wraps each referenced thing in a single-item array.
+    // Each referencing thing references exactly one thing, if any.
+    {
+      dependencies: [input('ref')],
+      compute: (continuation, {
+        [input('ref')]: ref,
+      }) => continuation({
+        ['#singleReferenceMap']:
+          thing =>
+            (thing[ref]
+              ? [thing[ref]]
+              : []),
+      }),
+    },
+
+    withMappedList({
+      list: '#referencingThings',
+      map: '#singleReferenceMap',
+    }).outputs({
+      '#mappedList': '#referencedThings',
+    }),
+  ],
+});