« 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
diff options
context:
space:
mode:
Diffstat (limited to 'src/data')
-rw-r--r--src/data/cacheable-object.js4
-rw-r--r--src/data/checks.js94
-rw-r--r--src/data/composite/data/withLengthOfList.js1
-rw-r--r--src/data/composite/things/album/index.js2
-rw-r--r--src/data/composite/things/album/withCoverArtDate.js (renamed from src/data/composite/wiki-data/withCoverArtDate.js)13
-rw-r--r--src/data/composite/things/artwork/withContribsFromAttachedArtwork.js1
-rw-r--r--src/data/composite/things/content/withAnnotationParts.js1
-rw-r--r--src/data/composite/things/content/withSourceText.js1
-rw-r--r--src/data/composite/things/content/withSourceURLs.js1
-rw-r--r--src/data/composite/things/contribution/index.js2
-rw-r--r--src/data/composite/things/contribution/thingPropertyMatches.js46
-rw-r--r--src/data/composite/things/contribution/thingReferenceTypeMatches.js66
-rw-r--r--src/data/composite/things/track-section/withContinueCountingFrom.js2
-rw-r--r--src/data/composite/things/track/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/withAllReleases.js1
-rw-r--r--src/data/composite/things/track/withAlwaysReferenceByDirectory.js1
-rw-r--r--src/data/composite/things/track/withOtherReleases.js3
-rw-r--r--src/data/composite/wiki-data/exitWithoutArtwork.js45
-rw-r--r--src/data/composite/wiki-data/index.js4
-rw-r--r--src/data/composite/wiki-data/withConstitutedArtwork.js1
-rw-r--r--src/data/composite/wiki-data/withHasArtwork.js (renamed from src/data/composite/things/album/withHasCoverArt.js)59
-rw-r--r--src/data/composite/wiki-data/withResolvedSeriesList.js130
-rw-r--r--src/data/composite/wiki-properties/index.js1
-rw-r--r--src/data/composite/wiki-properties/referencedArtworkList.js1
-rw-r--r--src/data/composite/wiki-properties/seriesList.js31
-rw-r--r--src/data/thing.js4
-rw-r--r--src/data/things/additional-file.js13
-rw-r--r--src/data/things/additional-name.js14
-rw-r--r--src/data/things/album.js570
-rw-r--r--src/data/things/art-tag.js47
-rw-r--r--src/data/things/artist.js122
-rw-r--r--src/data/things/artwork.js44
-rw-r--r--src/data/things/content.js63
-rw-r--r--src/data/things/contribution.js122
-rw-r--r--src/data/things/flash.js30
-rw-r--r--src/data/things/group.js111
-rw-r--r--src/data/things/homepage-layout.js45
-rw-r--r--src/data/things/language.js58
-rw-r--r--src/data/things/news-entry.js8
-rw-r--r--src/data/things/sorting-rule.js25
-rw-r--r--src/data/things/static-page.js10
-rw-r--r--src/data/things/track.js513
-rw-r--r--src/data/things/wiki-info.js47
-rw-r--r--src/data/yaml.js85
44 files changed, 1560 insertions, 920 deletions
diff --git a/src/data/cacheable-object.js b/src/data/cacheable-object.js
index a089e325..651a61cf 100644
--- a/src/data/cacheable-object.js
+++ b/src/data/cacheable-object.js
@@ -243,13 +243,13 @@ export class CacheableObjectPropertyValueError extends Error {
 
     try {
       inspectOldValue = inspect(oldValue);
-    } catch (error) {
+    } catch {
       inspectOldValue = colors.red(`(couldn't inspect)`);
     }
 
     try {
       inspectNewValue = inspect(newValue);
-    } catch (error) {
+    } catch {
       inspectNewValue = colors.red(`(couldn't inspect)`);
     }
 
diff --git a/src/data/checks.js b/src/data/checks.js
index 0bbd044e..3fcb6d3b 100644
--- a/src/data/checks.js
+++ b/src/data/checks.js
@@ -185,16 +185,20 @@ export function filterReferenceErrors(wikiData, {
       artTags: '_artTag',
       referencedArtworks: '_artwork',
       commentary: '_content',
-      creditSources: '_content',
+      creditingSources: '_content',
     }],
 
     ['artTagData', {
       directDescendantArtTags: 'artTag',
     }],
 
+    ['artworkData', {
+      referencedArtworks: '_artwork',
+    }],
+
     ['flashData', {
       commentary: '_content',
-      creditSources: '_content',
+      creditingSources: '_content',
     }],
 
     ['groupCategoryData', {
@@ -221,8 +225,8 @@ export function filterReferenceErrors(wikiData, {
       flashes: 'flash',
     }],
 
-    ['groupData', {
-      serieses: '_serieses',
+    ['seriesData', {
+      albums: 'album',
     }],
 
     ['trackData', {
@@ -235,7 +239,8 @@ export function filterReferenceErrors(wikiData, {
       referencedArtworks: '_artwork',
       mainReleaseTrack: '_trackMainReleasesOnly',
       commentary: '_content',
-      creditSources: '_content',
+      creditingSources: '_content',
+      referencingSources: '_content',
       lyrics: '_content',
     }],
 
@@ -290,15 +295,6 @@ export function filterReferenceErrors(wikiData, {
                 // need writing, humm...)
                 writeProperty = false;
                 break;
-
-              case '_serieses':
-                if (value) {
-                  // Doesn't report on which series has the error, but...
-                  value = value.flatMap(series => series.albums);
-                }
-
-                writeProperty = false;
-                break;
             }
 
             if (value === undefined) {
@@ -350,10 +346,6 @@ export function filterReferenceErrors(wikiData, {
                 };
                 break;
 
-              case '_serieses':
-                findFn = boundFind.album;
-                break;
-
               case '_trackArtwork':
                 findFn = ref => boundFind.track(ref.reference);
                 break;
@@ -393,7 +385,7 @@ export function filterReferenceErrors(wikiData, {
                 break;
             }
 
-            const suppress = fn => conditionallySuppressError(error => {
+            const suppress = fn => conditionallySuppressError(_error => {
               // We're not suppressing any errors at the moment.
               // An old suppression is kept below for reference.
 
@@ -565,6 +557,11 @@ export function reportContentTextErrors(wikiData, {
     description: 'description',
   };
 
+  const artworkShape = {
+    source: 'artwork source',
+    originDetails: 'artwork origin details',
+  };
+
   const commentaryShape = {
     body: 'commentary body',
     artistText: 'commentary artist text',
@@ -581,6 +578,8 @@ export function reportContentTextErrors(wikiData, {
     ['albumData', {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
+      creditingSources: commentaryShape,
+      coverArtworks: artworkShape,
     }],
 
     ['artTagData', {
@@ -593,6 +592,8 @@ export function reportContentTextErrors(wikiData, {
 
     ['flashData', {
       commentary: commentaryShape,
+      creditingSources: commentaryShape,
+      coverArtwork: artworkShape,
     }],
 
     ['flashActData', {
@@ -622,10 +623,12 @@ export function reportContentTextErrors(wikiData, {
     ['trackData', {
       additionalFiles: additionalFileShape,
       commentary: commentaryShape,
-      creditSources: commentaryShape,
+      creditingSources: commentaryShape,
+      referencingSources: commentaryShape,
       lyrics: lyricsShape,
       midiProjectFiles: additionalFileShape,
       sheetMusicFiles: additionalFileShape,
+      trackArtworks: artworkShape,
     }],
 
     ['wikiInfo', {
@@ -695,7 +698,7 @@ export function reportContentTextErrors(wikiData, {
       } else if (node.type === 'external-link') {
         try {
           new URL(node.data.href);
-        } catch (error) {
+        } catch {
           yield {
             index, length,
             message:
@@ -766,6 +769,31 @@ export function reportContentTextErrors(wikiData, {
               const topMessage =
                 `Content text errors` + fieldPropertyMessage;
 
+              const checkShapeEntries = (entry, callProcessContentOpts) => {
+                for (const [key, annotation] of Object.entries(shape)) {
+                  const value = entry[key];
+
+                  // TODO: This should be an undefined/null check, like above,
+                  // but it's not, because sometimes the stuff we're checking
+                  // here isn't actually coded as a Thing - so the properties
+                  // might really be undefined instead of null. Terrifying and
+                  // awful. And most of all, citation needed.
+                  if (!value) continue;
+
+                  callProcessContent({
+                    ...callProcessContentOpts,
+
+                    // TODO: `nest` isn't provided by `callProcessContentOpts`
+                    //`but `push` is - this is to match the old code, but
+                    // what's the deal here?
+                    nest,
+
+                    value,
+                    message: `Error in ${colors.green(annotation)}`,
+                  });
+                }
+              };
+
               if (shape === '_content') {
                 callProcessContent({
                   nest,
@@ -773,26 +801,18 @@ export function reportContentTextErrors(wikiData, {
                   value,
                   message: topMessage,
                 });
-              } else {
+              } else if (Array.isArray(value)) {
                 nest({message: topMessage}, ({push}) => {
                   for (const [index, entry] of value.entries()) {
-                    for (const [key, annotation] of Object.entries(shape)) {
-                      const value = entry[key];
-
-                      // TODO: Should this check undefined/null similar to above?
-                      if (!value) continue;
-
-                      callProcessContent({
-                        nest,
-                        push,
-                        value,
-                        message: `Error in ${colors.green(annotation)}`,
-                        annotateError: error =>
-                          annotateErrorWithIndex(error, index),
-                      });
-                    }
+                    checkShapeEntries(entry, {
+                      push,
+                      annotateError: error =>
+                        annotateErrorWithIndex(error, index),
+                    });
                   }
                 });
+              } else {
+                checkShapeEntries(value, {push});
               }
             }
           });
diff --git a/src/data/composite/data/withLengthOfList.js b/src/data/composite/data/withLengthOfList.js
index efc3ecae..e67aa887 100644
--- a/src/data/composite/data/withLengthOfList.js
+++ b/src/data/composite/data/withLengthOfList.js
@@ -1,5 +1,4 @@
 import {input, templateCompositeFrom} from '#composite';
-import {stitchArrays} from '#sugar';
 
 function getOutputName({
   [input.staticDependency('list')]: list,
diff --git a/src/data/composite/things/album/index.js b/src/data/composite/things/album/index.js
index dfc6864f..de1d37c3 100644
--- a/src/data/composite/things/album/index.js
+++ b/src/data/composite/things/album/index.js
@@ -1,2 +1,2 @@
-export {default as withHasCoverArt} from './withHasCoverArt.js';
+export {default as withCoverArtDate} from './withCoverArtDate.js';
 export {default as withTracks} from './withTracks.js';
diff --git a/src/data/composite/wiki-data/withCoverArtDate.js b/src/data/composite/things/album/withCoverArtDate.js
index a114d5ff..978f566a 100644
--- a/src/data/composite/wiki-data/withCoverArtDate.js
+++ b/src/data/composite/things/album/withCoverArtDate.js
@@ -2,8 +2,7 @@ import {input, templateCompositeFrom} from '#composite';
 import {isDate} from '#validators';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
-
-import withResolvedContribs from './withResolvedContribs.js';
+import {withHasArtwork} from '#composite/wiki-data';
 
 export default templateCompositeFrom({
   annotation: `withCoverArtDate`,
@@ -19,14 +18,14 @@ export default templateCompositeFrom({
   outputs: ['#coverArtDate'],
 
   steps: () => [
-    withResolvedContribs({
-      from: 'coverArtistContribs',
-      date: input.value(null),
+    withHasArtwork({
+      contribs: 'coverArtistContribs',
+      artworks: 'coverArtworks',
     }),
 
     raiseOutputWithoutDependency({
-      dependency: '#resolvedContribs',
-      mode: input.value('empty'),
+      dependency: '#hasArtwork',
+      mode: input.value('falsy'),
       output: input.value({'#coverArtDate': null}),
     }),
 
diff --git a/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js
index 36abb3fe..e9425c95 100644
--- a/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js
+++ b/src/data/composite/things/artwork/withContribsFromAttachedArtwork.js
@@ -1,7 +1,6 @@
 import {input, templateCompositeFrom} from '#composite';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
 import {withRecontextualizedContributionList} from '#composite/wiki-data';
 
 import withPropertyFromAttachedArtwork from './withPropertyFromAttachedArtwork.js';
diff --git a/src/data/composite/things/content/withAnnotationParts.js b/src/data/composite/things/content/withAnnotationParts.js
index 5eb8e3d5..6311b57a 100644
--- a/src/data/composite/things/content/withAnnotationParts.js
+++ b/src/data/composite/things/content/withAnnotationParts.js
@@ -1,5 +1,4 @@
 import {input, templateCompositeFrom} from '#composite';
-import {parseContentNodes} from '#replacer';
 import {transposeArrays} from '#sugar';
 import {is} from '#validators';
 
diff --git a/src/data/composite/things/content/withSourceText.js b/src/data/composite/things/content/withSourceText.js
index d310e8ea..292306b7 100644
--- a/src/data/composite/things/content/withSourceText.js
+++ b/src/data/composite/things/content/withSourceText.js
@@ -1,5 +1,4 @@
 import {input, templateCompositeFrom} from '#composite';
-import {parseContentNodes} from '#replacer';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
 
diff --git a/src/data/composite/things/content/withSourceURLs.js b/src/data/composite/things/content/withSourceURLs.js
index f1e8dbc0..f85ff9ea 100644
--- a/src/data/composite/things/content/withSourceURLs.js
+++ b/src/data/composite/things/content/withSourceURLs.js
@@ -1,5 +1,4 @@
 import {input, templateCompositeFrom} from '#composite';
-import {parseContentNodes} from '#replacer';
 
 import {raiseOutputWithoutDependency} from '#composite/control-flow';
 import {withFilteredList, withMappedList} from '#composite/data';
diff --git a/src/data/composite/things/contribution/index.js b/src/data/composite/things/contribution/index.js
index 9b22be2e..31d86b8b 100644
--- a/src/data/composite/things/contribution/index.js
+++ b/src/data/composite/things/contribution/index.js
@@ -1,6 +1,4 @@
 export {default as inheritFromContributionPresets} from './inheritFromContributionPresets.js';
-export {default as thingPropertyMatches} from './thingPropertyMatches.js';
-export {default as thingReferenceTypeMatches} from './thingReferenceTypeMatches.js';
 export {default as withContainingReverseContributionList} from './withContainingReverseContributionList.js';
 export {default as withContributionArtist} from './withContributionArtist.js';
 export {default as withContributionContext} from './withContributionContext.js';
diff --git a/src/data/composite/things/contribution/thingPropertyMatches.js b/src/data/composite/things/contribution/thingPropertyMatches.js
deleted file mode 100644
index 1e9019b8..00000000
--- a/src/data/composite/things/contribution/thingPropertyMatches.js
+++ /dev/null
@@ -1,46 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: `thingPropertyMatches`,
-
-  compose: false,
-
-  inputs: {
-    value: input({type: 'string'}),
-  },
-
-  steps: () => [
-    {
-      dependencies: ['thing', 'thingProperty'],
-
-      compute: (continuation, {thing, thingProperty}) =>
-        continuation({
-          ['#thingProperty']:
-            (thing.constructor[Symbol.for('Thing.referenceType')] === 'artwork'
-              ? thing.artistContribsFromThingProperty
-              : thingProperty),
-        }),
-    },
-
-    exitWithoutDependency({
-      dependency: '#thingProperty',
-      value: input.value(false),
-    }),
-
-    {
-      dependencies: [
-        '#thingProperty',
-        input('value'),
-      ],
-
-      compute: ({
-        ['#thingProperty']: thingProperty,
-        [input('value')]: value,
-      }) =>
-        thingProperty === value,
-    },
-  ],
-});
diff --git a/src/data/composite/things/contribution/thingReferenceTypeMatches.js b/src/data/composite/things/contribution/thingReferenceTypeMatches.js
deleted file mode 100644
index 4042e78f..00000000
--- a/src/data/composite/things/contribution/thingReferenceTypeMatches.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
-export default templateCompositeFrom({
-  annotation: `thingReferenceTypeMatches`,
-
-  compose: false,
-
-  inputs: {
-    value: input({type: 'string'}),
-  },
-
-  steps: () => [
-    exitWithoutDependency({
-      dependency: 'thing',
-      value: input.value(false),
-    }),
-
-    withPropertyFromObject({
-      object: 'thing',
-      property: input.value('constructor'),
-    }),
-
-    {
-      dependencies: [
-        '#thing.constructor',
-        input('value'),
-      ],
-
-      compute: (continuation, {
-        ['#thing.constructor']: constructor,
-        [input('value')]: value,
-      }) =>
-        (constructor[Symbol.for('Thing.referenceType')] === value
-          ? continuation.exit(true)
-       : constructor[Symbol.for('Thing.referenceType')] === 'artwork'
-          ? continuation()
-          : continuation.exit(false)),
-    },
-
-    withPropertyFromObject({
-      object: 'thing',
-      property: input.value('thing'),
-    }),
-
-    withPropertyFromObject({
-      object: '#thing.thing',
-      property: input.value('constructor'),
-    }),
-
-    {
-      dependencies: [
-        '#thing.thing.constructor',
-        input('value'),
-      ],
-
-      compute: ({
-        ['#thing.thing.constructor']: constructor,
-        [input('value')]: value,
-      }) =>
-        constructor[Symbol.for('Thing.referenceType')] === value,
-    },
-  ],
-});
diff --git a/src/data/composite/things/track-section/withContinueCountingFrom.js b/src/data/composite/things/track-section/withContinueCountingFrom.js
index e034b7a5..0ca52b6c 100644
--- a/src/data/composite/things/track-section/withContinueCountingFrom.js
+++ b/src/data/composite/things/track-section/withContinueCountingFrom.js
@@ -1,4 +1,4 @@
-import {input, templateCompositeFrom} from '#composite';
+import {templateCompositeFrom} from '#composite';
 
 import withStartCountingFrom from './withStartCountingFrom.js';
 
diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js
deleted file mode 100644
index 65a2263d..00000000
--- a/src/data/composite/things/track/trackAdditionalNameList.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// Compiles additional names from various sources.
-
-import {input, templateCompositeFrom} from '#composite';
-import {isAdditionalNameList} from '#validators';
-
-import withInferredAdditionalNames from './withInferredAdditionalNames.js';
-import withSharedAdditionalNames from './withSharedAdditionalNames.js';
-
-export default templateCompositeFrom({
-  annotation: `trackAdditionalNameList`,
-
-  compose: false,
-
-  update: {validate: isAdditionalNameList},
-
-  steps: () => [
-    withInferredAdditionalNames(),
-    withSharedAdditionalNames(),
-
-    {
-      dependencies: [
-        '#inferredAdditionalNames',
-        '#sharedAdditionalNames',
-        input.updateValue(),
-      ],
-
-      compute: ({
-        ['#inferredAdditionalNames']: inferredAdditionalNames,
-        ['#sharedAdditionalNames']: sharedAdditionalNames,
-        [input.updateValue()]: providedAdditionalNames,
-      }) => [
-        ...providedAdditionalNames ?? [],
-        ...sharedAdditionalNames,
-        ...inferredAdditionalNames,
-      ],
-    },
-  ],
-});
diff --git a/src/data/composite/things/track/withAllReleases.js b/src/data/composite/things/track/withAllReleases.js
index b93bf753..891db102 100644
--- a/src/data/composite/things/track/withAllReleases.js
+++ b/src/data/composite/things/track/withAllReleases.js
@@ -8,7 +8,6 @@
 import {input, templateCompositeFrom} from '#composite';
 import {sortByDate} from '#sort';
 
-import {exitWithoutDependency} from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 
 import withMainRelease from './withMainRelease.js';
diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
index 60faeaf4..87edf21e 100644
--- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
+++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js
@@ -9,7 +9,6 @@ import {isBoolean} from '#validators';
 
 import {withPropertyFromObject} from '#composite/data';
 import {withResolvedReference} from '#composite/wiki-data';
-import {soupyFind} from '#composite/wiki-properties';
 
 import {
   exitWithoutDependency,
diff --git a/src/data/composite/things/track/withOtherReleases.js b/src/data/composite/things/track/withOtherReleases.js
index 0639742f..bb3e8983 100644
--- a/src/data/composite/things/track/withOtherReleases.js
+++ b/src/data/composite/things/track/withOtherReleases.js
@@ -3,9 +3,6 @@
 
 import {input, templateCompositeFrom} from '#composite';
 
-import {exitWithoutDependency} from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
-
 import withAllReleases from './withAllReleases.js';
 
 export default templateCompositeFrom({
diff --git a/src/data/composite/wiki-data/exitWithoutArtwork.js b/src/data/composite/wiki-data/exitWithoutArtwork.js
new file mode 100644
index 00000000..8e799fda
--- /dev/null
+++ b/src/data/composite/wiki-data/exitWithoutArtwork.js
@@ -0,0 +1,45 @@
+import {input, templateCompositeFrom} from '#composite';
+import {isContributionList, isThing, strictArrayOf} from '#validators';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+
+import withHasArtwork from './withHasArtwork.js';
+
+export default templateCompositeFrom({
+  annotation: `exitWithoutArtwork`,
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      defaultValue: null,
+    }),
+
+    artwork: input({
+      validate: isThing,
+      defaultValue: null,
+    }),
+
+    artworks: input({
+      validate: strictArrayOf(isThing),
+      defaultValue: null,
+    }),
+
+    value: input({
+      defaultValue: null,
+    }),
+  },
+
+  steps: () => [
+    withHasArtwork({
+      contribs: input('contribs'),
+      artwork: input('artwork'),
+      artworks: input('artworks'),
+    }),
+
+    exitWithoutDependency({
+      dependency: '#hasArtwork',
+      mode: input.value('falsy'),
+      value: input('value'),
+    }),
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index 44a43db0..3206575b 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -5,6 +5,7 @@
 //
 
 export {default as exitWithoutContribs} from './exitWithoutContribs.js';
+export {default as exitWithoutArtwork} from './exitWithoutArtwork.js';
 export {default as gobbleSoupyFind} from './gobbleSoupyFind.js';
 export {default as gobbleSoupyReverse} from './gobbleSoupyReverse.js';
 export {default as inputNotFoundMode} from './inputNotFoundMode.js';
@@ -16,15 +17,14 @@ export {default as withClonedThings} from './withClonedThings.js';
 export {default as withConstitutedArtwork} from './withConstitutedArtwork.js';
 export {default as withContentNodes} from './withContentNodes.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 withHasArtwork} from './withHasArtwork.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/withConstitutedArtwork.js b/src/data/composite/wiki-data/withConstitutedArtwork.js
index 6187d55b..28d719e2 100644
--- a/src/data/composite/wiki-data/withConstitutedArtwork.js
+++ b/src/data/composite/wiki-data/withConstitutedArtwork.js
@@ -1,6 +1,5 @@
 import {input, templateCompositeFrom} from '#composite';
 import thingConstructors from '#things';
-import {isContributionList} from '#validators';
 
 export default templateCompositeFrom({
   annotation: `withConstitutedArtwork`,
diff --git a/src/data/composite/things/album/withHasCoverArt.js b/src/data/composite/wiki-data/withHasArtwork.js
index fd3f2894..9c22f439 100644
--- a/src/data/composite/things/album/withHasCoverArt.js
+++ b/src/data/composite/wiki-data/withHasArtwork.js
@@ -1,7 +1,5 @@
-// TODO: This shouldn't be coded as an Album-specific thing,
-// or even really to do with cover artworks in particular, either.
-
 import {input, templateCompositeFrom} from '#composite';
+import {isContributionList, isThing, strictArrayOf} from '#validators';
 
 import {raiseOutputWithoutDependency, withResultOfAvailabilityCheck}
   from '#composite/control-flow';
@@ -9,13 +7,30 @@ import {fillMissingListItems, withFlattenedList, withPropertyFromList}
   from '#composite/data';
 
 export default templateCompositeFrom({
-  annotation: 'withHasCoverArt',
+  annotation: 'withHasArtwork',
+
+  inputs: {
+    contribs: input({
+      validate: isContributionList,
+      defaultValue: null,
+    }),
+
+    artwork: input({
+      validate: isThing,
+      defaultValue: null,
+    }),
+
+    artworks: input({
+      validate: strictArrayOf(isThing),
+      defaultValue: null,
+    }),
+  },
 
-  outputs: ['#hasCoverArt'],
+  outputs: ['#hasArtwork'],
 
   steps: () => [
     withResultOfAvailabilityCheck({
-      from: 'coverArtistContribs',
+      from: input('contribs'),
       mode: input.value('empty'),
     }),
 
@@ -26,19 +41,37 @@ export default templateCompositeFrom({
       }) =>
         (availability
           ? continuation.raiseOutput({
-              ['#hasCoverArt']: true,
+              ['#hasArtwork']: true,
             })
           : continuation()),
     },
 
+    {
+      dependencies: [input('artwork'), input('artworks')],
+      compute: (continuation, {
+        [input('artwork')]: artwork,
+        [input('artworks')]: artworks,
+      }) =>
+        continuation({
+          ['#artworks']:
+            (artwork && artworks
+              ? [artwork, ...artworks]
+           : artwork
+              ? [artwork]
+           : artworks
+              ? artworks
+              : []),
+        }),
+    },
+
     raiseOutputWithoutDependency({
-      dependency: 'coverArtworks',
+      dependency: '#artworks',
       mode: input.value('empty'),
-      output: input.value({'#hasCoverArt': false}),
+      output: input.value({'#hasArtwork': false}),
     }),
 
     withPropertyFromList({
-      list: 'coverArtworks',
+      list: '#artworks',
       property: input.value('artistContribs'),
       internal: input.value(true),
     }),
@@ -46,19 +79,19 @@ export default templateCompositeFrom({
     // Since we're getting the update value for each artwork's artistContribs,
     // it may not be set at all, and in that case won't be exposing as [].
     fillMissingListItems({
-      list: '#coverArtworks.artistContribs',
+      list: '#artworks.artistContribs',
       fill: input.value([]),
     }),
 
     withFlattenedList({
-      list: '#coverArtworks.artistContribs',
+      list: '#artworks.artistContribs',
     }),
 
     withResultOfAvailabilityCheck({
       from: '#flattenedList',
       mode: input.value('empty'),
     }).outputs({
-      '#availability': '#hasCoverArt',
+      '#availability': '#hasArtwork',
     }),
   ],
 });
diff --git a/src/data/composite/wiki-data/withResolvedSeriesList.js b/src/data/composite/wiki-data/withResolvedSeriesList.js
deleted file mode 100644
index deaab466..00000000
--- a/src/data/composite/wiki-data/withResolvedSeriesList.js
+++ /dev/null
@@ -1,130 +0,0 @@
-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-properties/index.js b/src/data/composite/wiki-properties/index.js
index 9d7f1aed..e8f109d3 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -21,7 +21,6 @@ export {default as name} from './name.js';
 export {default as referenceList} from './referenceList.js';
 export {default as referencedArtworkList} from './referencedArtworkList.js';
 export {default as reverseReferenceList} from './reverseReferenceList.js';
-export {default as seriesList} from './seriesList.js';
 export {default as simpleDate} from './simpleDate.js';
 export {default as simpleString} from './simpleString.js';
 export {default as singleReference} from './singleReference.js';
diff --git a/src/data/composite/wiki-properties/referencedArtworkList.js b/src/data/composite/wiki-properties/referencedArtworkList.js
index 9ba2e393..4f243493 100644
--- a/src/data/composite/wiki-properties/referencedArtworkList.js
+++ b/src/data/composite/wiki-properties/referencedArtworkList.js
@@ -1,6 +1,5 @@
 import {input, templateCompositeFrom} from '#composite';
 import find from '#find';
-import {isDate} from '#validators';
 
 import annotatedReferenceList from './annotatedReferenceList.js';
 
diff --git a/src/data/composite/wiki-properties/seriesList.js b/src/data/composite/wiki-properties/seriesList.js
deleted file mode 100644
index 2a101b45..00000000
--- a/src/data/composite/wiki-properties/seriesList.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import {input, templateCompositeFrom} from '#composite';
-import {isSeriesList, validateThing} from '#validators';
-
-import {exposeDependency} from '#composite/control-flow';
-import {withResolvedSeriesList} from '#composite/wiki-data';
-
-export default templateCompositeFrom({
-  annotation: `seriesList`,
-
-  compose: false,
-
-  inputs: {
-    group: input({
-      validate: validateThing({referenceType: 'group'}),
-    }),
-  },
-
-  steps: () => [
-    withResolvedSeriesList({
-      group: input('group'),
-
-      list: input.updateValue({
-        validate: isSeriesList,
-      }),
-    }),
-
-    exposeDependency({
-      dependency: '#resolvedSeriesList',
-    }),
-  ],
-});
diff --git a/src/data/thing.js b/src/data/thing.js
index 66f73de5..f719224d 100644
--- a/src/data/thing.js
+++ b/src/data/thing.js
@@ -60,7 +60,7 @@ export default class Thing extends CacheableObject {
       if (this.name) {
         name = colors.green(`"${this.name}"`);
       }
-    } catch (error) {
+    } catch {
       name = colors.yellow(`couldn't get name`);
     }
 
@@ -69,7 +69,7 @@ export default class Thing extends CacheableObject {
       if (this.directory) {
         reference = colors.blue(Thing.getReference(this));
       }
-    } catch (error) {
+    } catch {
       reference = colors.yellow(`couldn't get reference`);
     }
 
diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js
index 2ddc688a..b15f62e0 100644
--- a/src/data/things/additional-file.js
+++ b/src/data/things/additional-file.js
@@ -2,13 +2,12 @@ import {input} from '#composite';
 import Thing from '#thing';
 import {isString, validateArrayItems} from '#validators';
 
-import {contentString, simpleString, thing} from '#composite/wiki-properties';
-
 import {exposeConstant, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
+import {contentString, simpleString, thing} from '#composite/wiki-properties';
 
 export class AdditionalFile extends Thing {
-  static [Thing.getPropertyDescriptors] = ({}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     thing: thing(),
@@ -26,6 +25,14 @@ export class AdditionalFile extends Thing {
         value: input.value([]),
       }),
     ],
+
+    // Expose only
+
+    isAdditionalFile: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js
index b96fcd50..99f3ee46 100644
--- a/src/data/things/additional-name.js
+++ b/src/data/things/additional-name.js
@@ -1,15 +1,25 @@
+import {input} from '#composite';
 import Thing from '#thing';
 
-import {contentString, simpleString, thing} from '#composite/wiki-properties';
+import {exposeConstant} from '#composite/control-flow';
+import {contentString, thing} from '#composite/wiki-properties';
 
 export class AdditionalName extends Thing {
-  static [Thing.getPropertyDescriptors] = ({}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     thing: thing(),
 
     name: contentString(),
     annotation: contentString(),
+
+    // Expose only
+
+    isAdditionalName: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
diff --git a/src/data/things/album.js b/src/data/things/album.js
index 7a7b387d..427c5d7f 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -8,9 +8,10 @@ import {colors} from '#cli';
 import {input} from '#composite';
 import {traverse} from '#node-utils';
 import {sortAlbumsTracksChronologically, sortChronologically} from '#sort';
-import {accumulateSum, empty} from '#sugar';
+import {empty} from '#sugar';
 import Thing from '#thing';
-import {isColor, isDate, isDirectory, isNumber} from '#validators';
+import {is, isColor, isContributionList, isDate, isDirectory, isNumber}
+  from '#validators';
 
 import {
   parseAdditionalFiles,
@@ -25,12 +26,22 @@ import {
   parseWallpaperParts,
 } from '#yaml';
 
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
 
-import {exitWithoutContribs, withDirectory, withCoverArtDate}
-  from '#composite/wiki-data';
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  exitWithoutArtwork,
+  withDirectory,
+  withHasArtwork,
+  withResolvedContribs,
+} from '#composite/wiki-data';
 
 import {
   color,
@@ -47,7 +58,6 @@ import {
   name,
   referencedArtworkList,
   referenceList,
-  reverseReferenceList,
   simpleDate,
   simpleString,
   soupyFind,
@@ -59,7 +69,7 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withHasCoverArt, withTracks} from '#composite/things/album';
+import {withCoverArtDate, withTracks} from '#composite/things/album';
 import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
   from '#composite/things/track-section';
 
@@ -74,11 +84,16 @@ export class Album extends Thing {
     CommentaryEntry,
     CreditingSourcesEntry,
     Group,
-    Track,
     TrackSection,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    trackSections: thingList({
+      class: input.value(TrackSection),
+    }),
+
+    // > Update & expose - Identifying metadata
 
     name: name('Unnamed Album'),
     directory: directory(),
@@ -99,20 +114,105 @@ export class Album extends Thing {
     alwaysReferenceTracksByDirectory: flag(false),
     suffixTrackDirectories: flag(false),
 
-    color: color(),
-    urls: urls(),
+    style: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(is(...[
+          'album',
+          'single',
+        ])),
+      }),
 
-    additionalNames: thingList({
-      class: input.value(AdditionalName),
-    }),
+      exposeConstant({
+        value: input.value('album'),
+      }),
+    ],
 
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
 
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
+
     date: simpleDate(),
-    trackArtDate: simpleDate(),
     dateAddedToWiki: simpleDate(),
 
+    // > Update & expose - Credits and contributors
+
+    artistContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    trackArtistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('albumTrackArtistContributions'),
+        date: 'date',
+      }).outputs({
+        '#resolvedContribs': '#trackArtistContribs',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#trackArtistContribs',
+        mode: input.value('empty'),
+      }),
+
+      withResolvedContribs({
+        from: 'artistContribs',
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('albumTrackArtistContributions'),
+        date: 'date',
+      }).outputs({
+        '#resolvedContribs': '#trackArtistContribs',
+      }),
+
+      exposeDependency({dependency: '#trackArtistContribs'}),
+    ],
+
+    // > Update & expose - General configuration
+
+    countTracksInArtistTotals: flag(true),
+
+    hasTrackNumbers: flag(true),
+    isListedOnHomepage: flag(true),
+    isListedInGalleries: flag(true),
+
+    hideDuration: flag(false),
+
+    // > Update & expose - General metadata
+
+    color: color(),
+
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
+    coverArtworks: [
+      // This works, lol, because this array describes `expose.transform` for
+      // the coverArtworks property, and compositions generally access the
+      // update value, not what's exposed by property access out in the open.
+      // There's no recursion going on here.
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
+    ],
+
+    coverArtistContribs: [
+      withCoverArtDate(),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumCoverArtistContributions'),
+      }),
+    ],
+
     coverArtDate: [
       withCoverArtDate({
         from: input.updateValue({
@@ -124,52 +224,61 @@ export class Album extends Thing {
     ],
 
     coverArtFileExtension: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
+
       fileExtension('jpg'),
     ],
 
-    trackCoverArtFileExtension: fileExtension('jpg'),
+    coverArtDimensions: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
 
-    wallpaperFileExtension: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      fileExtension('jpg'),
+      dimensions(),
     ],
 
-    bannerFileExtension: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      fileExtension('jpg'),
-    ],
+    artTags: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+        value: input.value([]),
+      }),
 
-    wallpaperStyle: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      simpleString(),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
     ],
 
-    wallpaperParts: [
-      exitWithoutContribs({
-        contribs: 'wallpaperArtistContribs',
+    referencedArtworks: [
+      exitWithoutArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
         value: input.value([]),
       }),
 
-      wallpaperParts(),
+      referencedArtworkList(),
     ],
 
-    bannerStyle: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      simpleString(),
-    ],
+    trackCoverArtistContribs: contributionList({
+      // May be null, indicating cover art was added for tracks on the date
+      // each track specifies, or else the track's own release date.
+      date: 'trackArtDate',
 
-    coverArtDimensions: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      dimensions(),
-    ],
+      // This is the "correct" value, but it gets overwritten - with the same
+      // value - regardless.
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
 
-    trackDimensions: dimensions(),
+    trackArtDate: simpleDate(),
 
-    bannerDimensions: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      dimensions(),
-    ],
+    trackCoverArtFileExtension: fileExtension('jpg'),
+
+    trackDimensions: dimensions(),
 
     wallpaperArtwork: [
       exitWithoutDependency({
@@ -182,119 +291,115 @@ export class Album extends Thing {
         .call(this, 'Wallpaper Artwork'),
     ],
 
-    bannerArtwork: [
-      exitWithoutDependency({
-        dependency: 'bannerArtistContribs',
-        mode: input.value('empty'),
-        value: input.value(null),
-      }),
+    wallpaperArtistContribs: [
+      withCoverArtDate(),
 
-      constitutibleArtwork.fromYAMLFieldSpec
-        .call(this, 'Banner Artwork'),
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumWallpaperArtistContributions'),
+      }),
     ],
 
-    coverArtworks: [
-      withHasCoverArt(),
-
-      exitWithoutDependency({
-        dependency: '#hasCoverArt',
-        mode: input.value('falsy'),
-        value: input.value([]),
+    wallpaperFileExtension: [
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
       }),
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Cover Artwork'),
+      fileExtension('jpg'),
     ],
 
-    hasTrackNumbers: flag(true),
-    isListedOnHomepage: flag(true),
-    isListedInGalleries: flag(true),
+    wallpaperStyle: [
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
+      }),
 
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
+      simpleString(),
+    ],
 
-    creditSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
+    wallpaperParts: [
+      // kinda nonsensical or at least unlikely lol, but y'know
+      exitWithoutArtwork({
+        contribs: 'wallpaperArtistContribs',
+        artwork: 'wallpaperArtwork',
+        value: input.value([]),
+      }),
 
-    additionalFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
+      wallpaperParts(),
+    ],
 
-    trackSections: thingList({
-      class: input.value(TrackSection),
-    }),
+    bannerArtwork: [
+      exitWithoutDependency({
+        dependency: 'bannerArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
 
-    artistContribs: contributionList({
-      date: 'date',
-      artistProperty: input.value('albumArtistContributions'),
-    }),
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
 
-    coverArtistContribs: [
+    bannerArtistContribs: [
       withCoverArtDate(),
 
       contributionList({
         date: '#coverArtDate',
-        artistProperty: input.value('albumCoverArtistContributions'),
+        artistProperty: input.value('albumBannerArtistContributions'),
       }),
     ],
 
-    trackCoverArtistContribs: contributionList({
-      // May be null, indicating cover art was added for tracks on the date
-      // each track specifies, or else the track's own release date.
-      date: 'trackArtDate',
-
-      // This is the "correct" value, but it gets overwritten - with the same
-      // value - regardless.
-      artistProperty: input.value('trackCoverArtistContributions'),
-    }),
+    bannerFileExtension: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
+      }),
 
-    wallpaperArtistContribs: [
-      withCoverArtDate(),
+      fileExtension('jpg'),
+    ],
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumWallpaperArtistContributions'),
+    bannerDimensions: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
       }),
-    ],
 
-    bannerArtistContribs: [
-      withCoverArtDate(),
+      dimensions(),
+    ],
 
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumBannerArtistContributions'),
+    bannerStyle: [
+      exitWithoutArtwork({
+        contribs: 'bannerArtistContribs',
+        artwork: 'bannerArtwork',
       }),
+
+      simpleString(),
     ],
 
+    // > Update & expose - Groups
+
     groups: referenceList({
       class: input.value(Group),
       find: soupyFind.input('group'),
     }),
 
-    artTags: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
+    // > Update & expose - Content entries
 
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
-      }),
-    ],
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
 
-    referencedArtworks: [
-      exitWithoutContribs({
-        contribs: 'coverArtistContribs',
-        value: input.value([]),
-      }),
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
 
-      referencedArtworkList(),
-    ],
+    // > Update & expose - Additional files
 
-    // Update only
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    // > Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
@@ -309,13 +414,23 @@ export class Album extends Thing {
       class: input.value(WikiInfo),
     }),
 
-    // Expose only
+    // > Expose only
+
+    isAlbum: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
 
     commentatorArtists: commentatorArtists(),
 
     hasCoverArt: [
-      withHasCoverArt(),
-      exposeDependency({dependency: '#hasCoverArt'}),
+      withHasArtwork({
+        contribs: 'coverArtistContribs',
+        artworks: 'coverArtworks',
+      }),
+
+      exposeDependency({dependency: '#hasArtwork'}),
     ],
 
     hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
@@ -459,6 +574,9 @@ export class Album extends Thing {
     albumArtistContributionsBy:
       soupyReverse.contributionsBy('albumData', 'artistContribs'),
 
+    albumTrackArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'trackArtistContribs'),
+
     albumCoverArtistContributionsBy:
       soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
 
@@ -478,21 +596,15 @@ export class Album extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
-      'Album': {property: 'name'},
+      // Identifying metadata
 
+      'Album': {property: 'name'},
       'Directory': {property: 'directory'},
       'Directory Suffix': {property: 'directorySuffix'},
       'Suffix Track Directories': {property: 'suffixTrackDirectories'},
-
       'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
-      'Always Reference Tracks By Directory': {
-        property: 'alwaysReferenceTracksByDirectory',
-      },
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference Tracks By Directory': {property: 'alwaysReferenceTracksByDirectory'},
+      'Style': {property: 'style'},
 
       'Bandcamp Album ID': {
         property: 'bandcampAlbumIdentifier',
@@ -504,18 +616,53 @@ export class Album extends Thing {
         transform: String,
       },
 
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
       'Date': {
         property: 'date',
         transform: parseDate,
       },
 
-      'Color': {property: 'color'},
-      'URLs': {property: 'urls'},
+      'Date Added': {
+        property: 'dateAddedToWiki',
+        transform: parseDate,
+      },
+
+      // Credits and contributors
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Track Artists': {
+        property: 'trackArtistContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
 
       'Has Track Numbers': {property: 'hasTrackNumbers'},
       'Listed on Homepage': {property: 'isListedOnHomepage'},
       'Listed in Galleries': {property: 'isListedInGalleries'},
 
+      'Hide Duration': {property: 'hideDuration'},
+
+      // General metadata
+
+      'Color': {property: 'color'},
+
+      'URLs': {property: 'urls'},
+
+      // Artworks
+      //  (Note - this YAML section is deliberately ordered differently
+      //   than the corresponding property descriptors.)
+
       'Cover Artwork': {
         property: 'coverArtworks',
         transform:
@@ -559,27 +706,29 @@ export class Album extends Thing {
           }),
       },
 
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
+      },
+
       'Cover Art Date': {
         property: 'coverArtDate',
         transform: parseDate,
       },
 
-      'Default Track Cover Art Date': {
-        property: 'trackArtDate',
-        transform: parseDate,
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
       },
 
-      'Date Added': {
-        property: 'dateAddedToWiki',
-        transform: parseDate,
+      'Default Track Cover Artists': {
+        property: 'trackCoverArtistContribs',
+        transform: parseContributors,
       },
 
-      'Cover Art File Extension': {property: 'coverArtFileExtension'},
-      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
-
-      'Cover Art Dimensions': {
-        property: 'coverArtDimensions',
-        transform: parseDimensions,
+      'Default Track Cover Art Date': {
+        property: 'trackArtDate',
+        transform: parseDate,
       },
 
       'Default Track Dimensions': {
@@ -593,7 +742,6 @@ export class Album extends Thing {
       },
 
       'Wallpaper Style': {property: 'wallpaperStyle'},
-      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
       'Wallpaper Parts': {
         property: 'wallpaperParts',
@@ -605,58 +753,75 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Banner Style': {property: 'bannerStyle'},
-      'Banner File Extension': {property: 'bannerFileExtension'},
-
       'Banner Dimensions': {
         property: 'bannerDimensions',
         transform: parseDimensions,
       },
 
-      'Commentary': {
-        property: 'commentary',
-        transform: parseCommentary,
-      },
+      'Banner Style': {property: 'bannerStyle'},
 
-      'Credit Sources': {
-        property: 'creditSources',
-        transform: parseCreditingSources,
-      },
+      'Cover Art File Extension': {property: 'coverArtFileExtension'},
+      'Track Art File Extension': {property: 'trackCoverArtFileExtension'},
+      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
+      'Banner File Extension': {property: 'bannerFileExtension'},
 
-      'Additional Files': {
-        property: 'additionalFiles',
-        transform: parseAdditionalFiles,
-      },
+      'Art Tags': {property: 'artTags'},
 
       'Referenced Artworks': {
         property: 'referencedArtworks',
         transform: parseAnnotatedReferences,
       },
 
-      'Franchises': {ignore: true},
+      // Groups
 
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
+      'Groups': {property: 'groups'},
+
+      // Content entries
+
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
       },
 
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
       },
 
-      'Default Track Cover Artists': {
-        property: 'trackCoverArtistContribs',
-        transform: parseContributors,
+      // Additional files
+
+      'Additional Files': {
+        property: 'additionalFiles',
+        transform: parseAdditionalFiles,
       },
 
-      'Groups': {property: 'groups'},
-      'Art Tags': {property: 'artTags'},
+      // Shenanigans
 
+      'Franchises': {ignore: true},
       'Review Points': {ignore: true},
     },
 
     invalidFieldCombinations: [
+      {message: `Move commentary on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Commentary',
+      ]},
+
+      {message: `Move crediting sources on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Crediting Sources',
+      ]},
+
+      {message: `Move referencing sources on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Referencing Sources',
+      ]},
+
+      {message: `Move additional names on singles to the track`, fields: [
+        ['Style', 'single'],
+        'Additional Names',
+      ]},
+
       {message: `Specify one wallpaper style or multiple wallpaper parts, not both`, fields: [
         'Wallpaper Parts',
         'Wallpaper Style',
@@ -696,6 +861,7 @@ export class Album extends Thing {
       const artworkData = [];
       const commentaryData = [];
       const creditingSourceData = [];
+      const referencingSourceData = [];
       const lyricsData = [];
 
       for (const {header: album, entries} of results) {
@@ -709,8 +875,6 @@ export class Album extends Thing {
           isDefaultTrackSection: true,
         });
 
-        const albumRef = Thing.getReference(album);
-
         const closeCurrentTrackSection = () => {
           if (
             currentTrackSection.isDefaultTrackSection &&
@@ -744,7 +908,8 @@ export class Album extends Thing {
 
           artworkData.push(...entry.trackArtworks);
           commentaryData.push(...entry.commentary);
-          creditingSourceData.push(...entry.creditSources);
+          creditingSourceData.push(...entry.creditingSources);
+          referencingSourceData.push(...entry.referencingSources);
 
           // TODO: As exposed, Track.lyrics tries to inherit from the main
           // release, which is impossible before the data's been linked.
@@ -767,7 +932,7 @@ export class Album extends Thing {
         }
 
         commentaryData.push(...album.commentary);
-        creditingSourceData.push(...album.creditSources);
+        creditingSourceData.push(...album.creditingSources);
 
         album.trackSections = trackSections;
       }
@@ -780,6 +945,7 @@ export class Album extends Thing {
         artworkData,
         commentaryData,
         creditingSourceData,
+        referencingSourceData,
         lyricsData,
       };
     },
@@ -835,13 +1001,19 @@ export class Album extends Thing {
       artwork.fileExtension,
     ];
   }
+
+  // As of writing, albums don't even have a `duration` property...
+  // so this function will never be called... but the message stands...
+  countOwnContributionInDurationTotals(_contrib) {
+    return false;
+  }
 }
 
 export class TrackSection extends Thing {
   static [Thing.friendlyName] = `Track Section`;
   static [Thing.referenceType] = `track-section`;
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({Track}) => ({
     // Update & expose
 
     name: name('Unnamed Track Section'),
@@ -892,6 +1064,12 @@ export class TrackSection extends Thing {
 
     // Expose only
 
+    isTrackSection: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     directory: [
       withAlbum(),
 
@@ -970,11 +1148,13 @@ export class TrackSection extends Thing {
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (depth >= 0) {
+    if (depth >= 0) showAlbum: {
       let album = null;
       try {
         album = this.album;
-      } catch {}
+      } catch {
+        break showAlbum;
+      }
 
       let first = null;
       try {
@@ -986,22 +1166,20 @@ export class TrackSection extends Thing {
         last = this.tracks.at(-1).trackNumber;
       } catch {}
 
-      if (album) {
-        const albumName = album.name;
-        const albumIndex = album.trackSections.indexOf(this);
+      const albumName = album.name;
+      const albumIndex = album.trackSections.indexOf(this);
 
-        const num =
-          (albumIndex === -1
-            ? 'indeterminate position'
-            : `#${albumIndex + 1}`);
+      const num =
+        (albumIndex === -1
+          ? 'indeterminate position'
+          : `#${albumIndex + 1}`);
 
-        const range =
-          (albumIndex >= 0 && first !== null && last !== null
-            ? `: ${first}-${last}`
-            : '');
+      const range =
+        (albumIndex >= 0 && first !== null && last !== null
+          ? `: ${first}-${last}`
+          : '');
 
-        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
-      }
+      parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`);
     }
 
     return parts.join('');
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 0ec1ff31..fff724cb 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,15 +1,23 @@
+export const DATA_ART_TAGS_DIRECTORY = 'art-tags';
 export const ART_TAG_DATA_FILE = 'tags.yaml';
 
+import {readFile} from 'node:fs/promises';
+import * as path from 'node:path';
+
 import {input} from '#composite';
-import find from '#find';
-import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort';
+import {traverse} from '#node-utils';
+import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
 import {unique} from '#sugar';
 import {isName} from '#validators';
 import {parseAdditionalNames, parseAnnotatedReferences} from '#yaml';
 
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
 
 import {
   annotatedReferenceList,
@@ -24,7 +32,6 @@ import {
   soupyReverse,
   thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {withAllDescendantArtTags, withAncestorArtTagBaobabTree}
@@ -34,11 +41,7 @@ export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
   static [Thing.friendlyName] = `Art Tag`;
 
-  static [Thing.getPropertyDescriptors] = ({
-    AdditionalName,
-    Album,
-    Track,
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({
     // Update & expose
 
     name: name('Unnamed Art Tag'),
@@ -85,6 +88,12 @@ export class ArtTag extends Thing {
 
     // Expose only
 
+    isArtTag: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     descriptionShort: [
       exitWithoutDependency({
         dependency: 'description',
@@ -180,13 +189,25 @@ export class ArtTag extends Thing {
   };
 
   static [Thing.getYamlLoadingSpec] = ({
-    documentModes: {allInOne},
+    documentModes: {allTogether},
     thingConstructors: {ArtTag},
   }) => ({
     title: `Process art tags file`,
-    file: ART_TAG_DATA_FILE,
 
-    documentMode: allInOne,
+    files: dataPath =>
+      Promise.allSettled([
+        readFile(path.join(dataPath, ART_TAG_DATA_FILE))
+          .then(() => [ART_TAG_DATA_FILE]),
+
+        traverse(path.join(dataPath, DATA_ART_TAGS_DIRECTORY), {
+          filterFile: name => path.extname(name) === '.yaml',
+          prefixPath: DATA_ART_TAGS_DIRECTORY,
+        }),
+      ]).then(results => results
+          .filter(({status}) => status === 'fulfilled')
+          .flatMap(({value}) => value)),
+
+    documentMode: allTogether,
     documentThing: ArtTag,
 
     save: (results) => ({artTagData: results}),
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 9e329c74..2905d893 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -5,14 +5,21 @@ import {inspect} from 'node:util';
 import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
-import {sortAlphabetically} from '#sort';
 import {stitchArrays} from '#sugar';
 import Thing from '#thing';
 import {isName, validateArrayItems} from '#validators';
 import {getKebabCase} from '#wiki-data';
 import {parseArtwork} from '#yaml';
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {
+  sortAlbumsTracksChronologically,
+  sortArtworksChronologically,
+  sortAlphabetically,
+  sortContributionsChronologically,
+} from '#sort';
+
+import {exitWithoutDependency, exposeConstant} from '#composite/control-flow';
+import {withReverseReferenceList} from '#composite/wiki-data';
 
 import {
   constitutibleArtwork,
@@ -26,7 +33,6 @@ import {
   soupyFind,
   soupyReverse,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {artistTotalDuration} from '#composite/things/artist';
@@ -35,7 +41,7 @@ export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
   static [Thing.wikiDataArray] = 'artistData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     name: name('Unnamed Artist'),
@@ -77,6 +83,12 @@ export class Artist extends Thing {
 
     // Expose only
 
+    isArtist: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     trackArtistContributions: reverseReferenceList({
       reverse: soupyReverse.input('trackArtistContributionsBy'),
     }),
@@ -97,6 +109,10 @@ export class Artist extends Thing {
       reverse: soupyReverse.input('albumArtistContributionsBy'),
     }),
 
+    albumTrackArtistContributions: reverseReferenceList({
+      reverse: soupyReverse.input('albumTrackArtistContributionsBy'),
+    }),
+
     albumCoverArtistContributions: reverseReferenceList({
       reverse: soupyReverse.input('albumCoverArtistContributionsBy'),
     }),
@@ -125,6 +141,102 @@ export class Artist extends Thing {
       reverse: soupyReverse.input('groupsCloselyLinkedTo'),
     }),
 
+    musicContributions: [
+      withReverseReferenceList({
+        reverse: soupyReverse.input('trackArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#trackArtistContribs',
+      }),
+
+      withReverseReferenceList({
+        reverse: soupyReverse.input('trackContributorContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#trackContributorContribs',
+      }),
+
+      {
+        dependencies: [
+          '#trackArtistContribs',
+          '#trackContributorContribs',
+        ],
+
+        compute: (continuation, {
+          ['#trackArtistContribs']: trackArtistContribs,
+          ['#trackContributorContribs']: trackContributorContribs,
+        }) => continuation({
+          ['#contributions']: [
+            ...trackArtistContribs,
+            ...trackContributorContribs,
+          ],
+        }),
+      },
+
+      {
+        dependencies: ['#contributions'],
+        compute: ({'#contributions': contributions}) =>
+          sortContributionsChronologically(
+            contributions,
+            sortAlbumsTracksChronologically),
+      },
+    ],
+
+    artworkContributions: [
+      withReverseReferenceList({
+        reverse: soupyReverse.input('trackCoverArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#trackCoverArtistContribs',
+      }),
+
+      withReverseReferenceList({
+        reverse: soupyReverse.input('albumCoverArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#albumCoverArtistContribs',
+      }),
+
+      withReverseReferenceList({
+        reverse: soupyReverse.input('albumWallpaperArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#albumWallpaperArtistContribs',
+      }),
+
+      withReverseReferenceList({
+        reverse: soupyReverse.input('albumBannerArtistContributionsBy'),
+      }).outputs({
+        '#reverseReferenceList': '#albumBannerArtistContribs',
+      }),
+
+      {
+        dependencies: [
+          '#trackCoverArtistContribs',
+          '#albumCoverArtistContribs',
+          '#albumWallpaperArtistContribs',
+          '#albumBannerArtistContribs',
+        ],
+
+        compute: (continuation, {
+          ['#trackCoverArtistContribs']: trackCoverArtistContribs,
+          ['#albumCoverArtistContribs']: albumCoverArtistContribs,
+          ['#albumWallpaperArtistContribs']: albumWallpaperArtistContribs,
+          ['#albumBannerArtistContribs']: albumBannerArtistContribs,
+        }) => continuation({
+          ['#contributions']: [
+            ...trackCoverArtistContribs,
+            ...albumCoverArtistContribs,
+            ...albumWallpaperArtistContribs,
+            ...albumBannerArtistContribs,
+          ],
+        }),
+      },
+
+      {
+        dependencies: ['#contributions'],
+        compute: ({'#contributions': contributions}) =>
+          sortContributionsChronologically(
+            contributions,
+            sortArtworksChronologically),
+      },
+    ],
+
     totalDuration: artistTotalDuration(),
   });
 
@@ -287,7 +399,7 @@ export class Artist extends Thing {
       let aliasedArtist;
       try {
         aliasedArtist = this.aliasedArtist.name;
-      } catch (_error) {
+      } catch {
         aliasedArtist = CacheableObject.getUpdateValue(this, 'aliasedArtist');
       }
 
diff --git a/src/data/things/artwork.js b/src/data/things/artwork.js
index ac70159c..4aced874 100644
--- a/src/data/things/artwork.js
+++ b/src/data/things/artwork.js
@@ -1,5 +1,6 @@
 import {inspect} from 'node:util';
 
+import {colors} from '#cli';
 import {input} from '#composite';
 import find from '#find';
 import Thing from '#thing';
@@ -24,7 +25,7 @@ import {
   parseDimensions,
 } from '#yaml';
 
-import {withIndexInList, withPropertyFromObject} from '#composite/data';
+import {withPropertyFromObject} from '#composite/data';
 
 import {
   exitWithoutDependency,
@@ -64,10 +65,7 @@ import {
 export class Artwork extends Thing {
   static [Thing.referenceType] = 'artwork';
 
-  static [Thing.getPropertyDescriptors] = ({
-    ArtTag,
-    Contribution,
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({
     // Update & expose
 
     unqualifiedDirectory: directory({
@@ -79,6 +77,7 @@ export class Artwork extends Thing {
 
     label: simpleString(),
     source: contentString(),
+    originDetails: contentString(),
 
     dateFromThingProperty: simpleString(),
 
@@ -171,6 +170,7 @@ export class Artwork extends Thing {
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
         date: '#date',
+        thingProperty: input.thisProperty(),
         artistProperty: 'artistContribsArtistProperty',
       }),
 
@@ -323,6 +323,12 @@ export class Artwork extends Thing {
 
     // Expose only
 
+    isArtwork: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     referencedByArtworks: reverseReferenceList({
       reverse: soupyReverse.input('artworksWhichReference'),
     }),
@@ -371,6 +377,21 @@ export class Artwork extends Thing {
     attachingArtworks: reverseReferenceList({
       reverse: soupyReverse.input('artworksWhichAttach'),
     }),
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -385,6 +406,7 @@ export class Artwork extends Thing {
 
       'Label': {property: 'label'},
       'Source': {property: 'source'},
+      'Origin Details': {property: 'originDetails'},
 
       'Date': {
         property: 'date',
@@ -456,6 +478,18 @@ export class Artwork extends Thing {
     return this.thing.getOwnArtworkPath(this);
   }
 
+  countOwnContributionInContributionTotals(contrib) {
+    if (this.attachAbove) {
+      return false;
+    }
+
+    if (contrib.annotation?.startsWith('edits for wiki')) {
+      return false;
+    }
+
+    return true;
+  }
+
   [inspect.custom](depth, options, inspect) {
     const parts = [];
 
diff --git a/src/data/things/content.js b/src/data/things/content.js
index cf8fa1f4..d2cf32dc 100644
--- a/src/data/things/content.js
+++ b/src/data/things/content.js
@@ -1,10 +1,9 @@
 import {input} from '#composite';
-import find from '#find';
 import Thing from '#thing';
 import {is, isDate} from '#validators';
 import {parseDate} from '#yaml';
 
-import {contentString, referenceList, simpleDate, soupyFind, thing}
+import {contentString, simpleDate, soupyFind, thing}
   from '#composite/wiki-properties';
 
 import {
@@ -27,7 +26,7 @@ import {
 } from '#composite/things/content';
 
 export class ContentEntry extends Thing {
-  static [Thing.getPropertyDescriptors] = ({Artist}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     thing: thing(),
@@ -51,6 +50,10 @@ export class ContentEntry extends Thing {
     },
 
     accessKind: [
+      exitWithoutDependency({
+        dependency: 'accessDate',
+      }),
+
       exposeUpdateValueOrContinue({
         validate: input.value(
           is(...[
@@ -74,7 +77,7 @@ export class ContentEntry extends Thing {
       },
 
       exposeConstant({
-        value: input.value(null),
+        value: input.value('accessed'),
       }),
     ],
 
@@ -106,6 +109,12 @@ export class ContentEntry extends Thing {
 
     // Expose only
 
+    isContentEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     annotationParts: [
       withAnnotationParts({
         mode: input.value('strings'),
@@ -148,6 +157,12 @@ export class CommentaryEntry extends ContentEntry {
   static [Thing.getPropertyDescriptors] = () => ({
     // Expose only
 
+    isCommentaryEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     isWikiEditorCommentary: hasAnnotationPart({
       part: input.value('wiki editor'),
     }),
@@ -156,8 +171,18 @@ export class CommentaryEntry extends ContentEntry {
 
 export class LyricsEntry extends ContentEntry {
   static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    originDetails: contentString(),
+
     // Expose only
 
+    isLyricsEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     isWikiLyrics: hasAnnotationPart({
       part: input.value('wiki lyrics'),
     }),
@@ -185,6 +210,34 @@ export class LyricsEntry extends ContentEntry {
       },
     ],
   });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, {
+    fields: {
+      'Origin Details': {property: 'originDetails'},
+    },
+  });
+}
+
+export class CreditingSourcesEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isCreditingSourcesEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+  });
 }
 
-export class CreditingSourcesEntry extends ContentEntry {}
+export class ReferencingSourcesEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isReferencingSourceEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+  });
+}
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
index c92fafb4..006aeec0 100644
--- a/src/data/things/contribution.js
+++ b/src/data/things/contribution.js
@@ -5,12 +5,20 @@ import {colors} from '#cli';
 import {input} from '#composite';
 import {empty} from '#sugar';
 import Thing from '#thing';
-import {isStringNonEmpty, isThing, validateReference} from '#validators';
+import {isBoolean, isStringNonEmpty, isThing, validateReference}
+  from '#validators';
 
-import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
 import {flag, simpleDate, soupyFind} from '#composite/wiki-properties';
 
 import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
   withFilteredList,
   withNearbyItemFromList,
   withPropertyFromList,
@@ -19,8 +27,6 @@ import {
 
 import {
   inheritFromContributionPresets,
-  thingPropertyMatches,
-  thingReferenceTypeMatches,
   withContainingReverseContributionList,
   withContributionArtist,
   withContributionContext,
@@ -70,7 +76,26 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInContributionTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInContributionTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     countInDurationTotals: [
@@ -78,7 +103,37 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('duration'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#thing.duration',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInDurationTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInDurationTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     // Update only
@@ -87,6 +142,12 @@ export class Contribution extends Thing {
 
     // Expose only
 
+    isContribution: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     context: [
       withContributionContext(),
 
@@ -167,38 +228,6 @@ export class Contribution extends Thing {
       }),
     ],
 
-    isArtistContribution: thingPropertyMatches({
-      value: input.value('artistContribs'),
-    }),
-
-    isContributorContribution: thingPropertyMatches({
-      value: input.value('contributorContribs'),
-    }),
-
-    isCoverArtistContribution: thingPropertyMatches({
-      value: input.value('coverArtistContribs'),
-    }),
-
-    isBannerArtistContribution: thingPropertyMatches({
-      value: input.value('bannerArtistContribs'),
-    }),
-
-    isWallpaperArtistContribution: thingPropertyMatches({
-      value: input.value('wallpaperArtistContribs'),
-    }),
-
-    isForTrack: thingReferenceTypeMatches({
-      value: input.value('track'),
-    }),
-
-    isForAlbum: thingReferenceTypeMatches({
-      value: input.value('album'),
-    }),
-
-    isForFlash: thingReferenceTypeMatches({
-      value: input.value('flash'),
-    }),
-
     previousBySameArtist: [
       withContainingReverseContributionList().outputs({
         '#containingReverseContributionList': '#list',
@@ -238,6 +267,21 @@ export class Contribution extends Thing {
         dependency: '#nearbyItem',
       }),
     ],
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
   });
 
   [inspect.custom](depth, options, inspect) {
@@ -259,7 +303,7 @@ export class Contribution extends Thing {
       let artist;
       try {
         artist = this.artist;
-      } catch (_error) {
+      } catch {
         // Computing artist might crash for any reason - don't distract from
         // other errors as a result of inspecting this contribution.
       }
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index 11b19ebc..73b22746 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -43,7 +43,6 @@ import {
   thing,
   thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {withFlashAct} from '#composite/things/flash';
@@ -57,7 +56,6 @@ export class Flash extends Thing {
     CommentaryEntry,
     CreditingSourcesEntry,
     Track,
-    FlashAct,
     WikiInfo,
   }) => ({
     // Update & expose
@@ -135,7 +133,7 @@ export class Flash extends Thing {
       class: input.value(CommentaryEntry),
     }),
 
-    creditSources: thingList({
+    creditingSources: thingList({
       class: input.value(CreditingSourcesEntry),
     }),
 
@@ -151,6 +149,12 @@ export class Flash extends Thing {
 
     // Expose only
 
+    isFlash: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     commentatorArtists: commentatorArtists(),
 
     act: [
@@ -257,8 +261,8 @@ export class Flash extends Thing {
         transform: parseCommentary,
       },
 
-      'Credit Sources': {
-        property: 'creditSources',
+      'Crediting Sources': {
+        property: 'creditingSources',
         transform: parseCreditingSources,
       },
 
@@ -319,6 +323,12 @@ export class FlashAct extends Thing {
 
     // Expose only
 
+    isFlashAct: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     side: [
       withFlashSide(),
       exposeDependency({dependency: '#flashSide'}),
@@ -374,6 +384,14 @@ export class FlashSide extends Thing {
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    isFlashSide: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -461,7 +479,7 @@ export class FlashSide extends Thing {
 
       const artworkData = flashData.map(flash => flash.coverArtwork);
       const commentaryData = flashData.flatMap(flash => flash.commentary);
-      const creditingSourceData = flashData.flatMap(flash => flash.creditSources);
+      const creditingSourceData = flashData.flatMap(flash => flash.creditingSources);
 
       return {
         flashData,
diff --git a/src/data/things/group.js b/src/data/things/group.js
index b40d15b4..71d1ea69 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,31 +1,41 @@
 export const GROUP_DATA_FILE = 'groups.yaml';
 
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
 import {input} from '#composite';
 import Thing from '#thing';
+import {is} from '#validators';
 import {parseAnnotatedReferences, parseSerieses} from '#yaml';
 
+import {exposeConstant} from '#composite/control-flow';
+
 import {
   annotatedReferenceList,
   color,
   contentString,
   directory,
+  flag,
   name,
   referenceList,
-  seriesList,
   soupyFind,
+  thing,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({
     // Update & expose
 
     name: name('Unnamed Group'),
     directory: directory(),
 
+    excludeFromGalleryTabs: flag(false),
+    divideAlbumsByStyle: flag(false),
+
     description: contentString(),
 
     urls: urls(),
@@ -43,8 +53,8 @@ export class Group extends Thing {
       find: soupyFind.input('album'),
     }),
 
-    serieses: seriesList({
-      group: input.myself(),
+    serieses: thingList({
+      class: input.value(Series),
     }),
 
     // Update only
@@ -54,6 +64,12 @@ export class Group extends Thing {
 
     // Expose only
 
+    isGroup: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     descriptionShort: {
       flags: {expose: true},
 
@@ -129,6 +145,10 @@ export class Group extends Thing {
     fields: {
       'Group': {property: 'name'},
       'Directory': {property: 'directory'},
+
+      'Exclude From Gallery Tabs': {property: 'excludeFromGalleryTabs'},
+      'Divide Albums By Style': {property: 'divideAlbumsByStyle'},
+
       'Description': {property: 'description'},
       'URLs': {property: 'urls'},
 
@@ -192,8 +212,9 @@ export class Group extends Thing {
 
       const groupData = results.filter(x => x instanceof Group);
       const groupCategoryData = results.filter(x => x instanceof GroupCategory);
+      const seriesData = groupData.flatMap(group => group.serieses);
 
-      return {groupData, groupCategoryData};
+      return {groupData, groupCategoryData, seriesData};
     },
 
     // Groups aren't sorted at all, always preserving the order in the data
@@ -222,6 +243,14 @@ export class GroupCategory extends Thing {
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    isGroupCategory: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.reverseSpecs] = {
@@ -240,3 +269,73 @@ export class GroupCategory extends Thing {
     },
   };
 }
+
+export class Series extends Thing {
+  static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
+    // Update & expose
+
+    name: name('Unnamed Series'),
+
+    showAlbumArtists: {
+      flags: {update: true, expose: true},
+      update: {
+        validate:
+          is('all', 'differing', 'none'),
+      },
+    },
+
+    description: contentString(),
+
+    group: thing({
+      class: input.value(Group),
+    }),
+
+    albums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+
+      'Description': {property: 'description'},
+
+      'Show Album Artists': {property: 'showAlbumArtists'},
+
+      'Albums': {property: 'albums'},
+    },
+  };
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) showGroup: {
+      let group = null;
+      try {
+        group = this.group;
+      } catch {
+        break showGroup;
+      }
+
+      const groupName = group.name;
+      const groupIndex = group.serieses.indexOf(this);
+
+      const num =
+        (groupIndex === -1
+          ? 'indeterminate position'
+          : `#${groupIndex + 1}`);
+
+      parts.push(` (${colors.yellow(num)} in ${colors.green(`"${groupName}"`)})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index 82bad2d3..2456ca95 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -17,7 +17,7 @@ import {
   validateReference,
 } from '#validators';
 
-import {exposeDependency} from '#composite/control-flow';
+import {exposeConstant, exposeDependency} from '#composite/control-flow';
 import {withResolvedReference} from '#composite/wiki-data';
 
 import {
@@ -47,6 +47,14 @@ export class HomepageLayout extends Thing {
     sections: thingList({
       class: input.value(HomepageLayoutSection),
     }),
+
+    // Expose only
+
+    isHomepageLayout: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -63,7 +71,6 @@ export class HomepageLayout extends Thing {
     thingConstructors: {
       HomepageLayout,
       HomepageLayoutSection,
-      HomepageLayoutAlbumsRow,
     },
   }) => ({
     title: `Process homepage layout file`,
@@ -157,6 +164,14 @@ export class HomepageLayoutSection extends Thing {
     rows: thingList({
       class: input.value(HomepageLayoutRow),
     }),
+
+    // Expose only
+
+    isHomepageLayoutSection: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -183,6 +198,12 @@ export class HomepageLayoutRow extends Thing {
 
     // Expose only
 
+    isHomepageLayoutRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
 
@@ -234,6 +255,12 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow {
 
     // Expose only
 
+    isHomepageLayoutActionsRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
       expose: {compute: () => 'actions'},
@@ -250,7 +277,7 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow {
 export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Album Carousel Row`;
 
-  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+  static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({
     ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
 
     // Update & expose
@@ -262,6 +289,12 @@ export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
 
     // Expose only
 
+    isHomepageLayoutAlbumCarouselRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
       expose: {compute: () => 'album carousel'},
@@ -322,6 +355,12 @@ export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
 
     // Expose only
 
+    isHomepageLayoutAlbumGridRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
       expose: {compute: () => 'album grid'},
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 4e23cf7f..88e8d996 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,8 +1,9 @@
-import { Temporal, toTemporalInstant } from '@js-temporal/polyfill';
+import {Temporal, toTemporalInstant} from '@js-temporal/polyfill';
 
 import {withAggregate} from '#aggregate';
 import CacheableObject from '#cacheable-object';
 import {logWarn} from '#cli';
+import {input} from '#composite';
 import * as html from '#html';
 import {empty} from '#sugar';
 import {isLanguageCode} from '#validators';
@@ -16,6 +17,7 @@ import {
   isExternalLinkStyle,
 } from '#external-links';
 
+import {exposeConstant} from '#composite/control-flow';
 import {externalFunction, flag, name} from '#composite/wiki-properties';
 
 export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g;
@@ -127,6 +129,12 @@ export class Language extends Thing {
 
     // Expose only
 
+    isLanguage: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     onlyIfOptions: {
       flags: {expose: true},
       expose: {
@@ -204,6 +212,10 @@ export class Language extends Thing {
   }
 
   formatString(...args) {
+    if (typeof args.at(-1) === 'function') {
+      throw new Error(`Passed function - did you mean language.encapsulate() instead?`);
+    }
+
     const hasOptions =
       typeof args.at(-1) === 'object' &&
       args.at(-1) !== null;
@@ -310,7 +322,7 @@ export class Language extends Thing {
           return undefined;
         }
 
-        return optionValue;
+        return this.sanitize(optionValue);
       },
     });
 
@@ -375,26 +387,16 @@ export class Language extends Thing {
 
       partInProgress += template.slice(lastIndex, match.index);
 
-      // Sanitize string arguments in particular. These are taken to come from
-      // (raw) data and may include special characters that aren't meant to be
-      // rendered as HTML markup.
-      const sanitizedInsertion =
-        this.#sanitizeValueForInsertion(insertion);
-
-      if (typeof sanitizedInsertion === 'string') {
-        // Join consecutive strings together.
-        partInProgress += sanitizedInsertion;
-      } else if (
-        sanitizedInsertion instanceof html.Tag &&
-        sanitizedInsertion.contentOnly
-      ) {
-        // Collapse string-only tag contents onto the current string part.
-        partInProgress += sanitizedInsertion.toString();
-      } else {
-        // Push the string part in progress, then the insertion as-is.
-        outputParts.push(partInProgress);
-        outputParts.push(sanitizedInsertion);
-        partInProgress = '';
+      for (const insertionItem of html.smush(insertion).content) {
+        if (typeof insertionItem === 'string') {
+          // Join consecutive strings together.
+          partInProgress += insertionItem;
+        } else {
+          // Push the string part in progress, then the insertion as-is.
+          outputParts.push(partInProgress);
+          outputParts.push(insertionItem);
+          partInProgress = '';
+        }
       }
 
       lastIndex = match.index + match[0].length;
@@ -865,6 +867,18 @@ export class Language extends Thing {
     }
   }
 
+  typicallyLowerCase(string) {
+    // Utter nonsense implementation, so this only works on strings,
+    // not actual HTML content, and may rudely disrespect *intentful*
+    // capitalization of whatever goes into it.
+
+    if (typeof string !== 'string') return string;
+    if (string.length <= 1) return string;
+    if (/^\S+?[A-Z]/.test(string)) return string;
+
+    return string[0].toLowerCase() + string.slice(1);
+  }
+
   // Utility function to quickly provide a useful string key
   // (generally a prefix) to stuff nested beneath it.
   encapsulate(...args) {
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
index 43d1638e..28289f53 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/news-entry.js
@@ -1,9 +1,11 @@
 export const NEWS_DATA_FILE = 'news.yaml';
 
+import {input} from '#composite';
 import {sortChronologically} from '#sort';
 import Thing from '#thing';
 import {parseDate} from '#yaml';
 
+import {exposeConstant} from '#composite/control-flow';
 import {contentString, directory, name, simpleDate}
   from '#composite/wiki-properties';
 
@@ -22,6 +24,12 @@ export class NewsEntry extends Thing {
 
     // Expose only
 
+    isNewsEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     contentShort: {
       flags: {expose: true},
 
diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js
index b169a541..808a0085 100644
--- a/src/data/things/sorting-rule.js
+++ b/src/data/things/sorting-rule.js
@@ -22,6 +22,7 @@ import {
   reorderDocumentsInYAMLSourceText,
 } from '#yaml';
 
+import {exposeConstant} from '#composite/control-flow';
 import {flag} from '#composite/wiki-properties';
 
 function isSelectFollowingEntry(value) {
@@ -47,6 +48,14 @@ export class SortingRule extends Thing {
       flags: {update: true, expose: true},
       update: {validate: isStringNonEmpty},
     },
+
+    // Expose only
+
+    isSortingRule: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -119,6 +128,14 @@ export class ThingSortingRule extends SortingRule {
         validate: strictArrayOf(isStringNonEmpty),
       },
     },
+
+    // Expose only
+
+    isThingSortingRule: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, {
@@ -218,6 +235,14 @@ export class DocumentSortingRule extends ThingSortingRule {
       flags: {update: true, expose: true},
       update: {validate: isStringNonEmpty},
     },
+
+    // Expose only
+
+    isDocumentSortingRule: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ThingSortingRule, {
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index 52a09c31..28167df2 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -2,11 +2,13 @@ export const DATA_STATIC_PAGE_DIRECTORY = 'static-page';
 
 import * as path from 'node:path';
 
+import {input} from '#composite';
 import {traverse} from '#node-utils';
 import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
 import {isName} from '#validators';
 
+import {exposeConstant} from '#composite/control-flow';
 import {contentString, directory, flag, name, simpleString}
   from '#composite/wiki-properties';
 
@@ -36,6 +38,14 @@ export class StaticPage extends Thing {
     content: contentString(),
 
     absoluteLinks: flag(),
+
+    // Expose only
+
+    isStaticPage: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.findSpecs] = {
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 557ba2a7..18faebc3 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -15,6 +15,7 @@ import {
   parseCommentary,
   parseContributors,
   parseCreditingSources,
+  parseReferencingSources,
   parseDate,
   parseDimensions,
   parseDuration,
@@ -24,6 +25,7 @@ import {
 import {withPropertyFromObject} from '#composite/data';
 
 import {
+  exitWithoutDependency,
   exposeConstant,
   exposeDependency,
   exposeDependencyOrContinue,
@@ -40,7 +42,6 @@ import {
 import {
   commentatorArtists,
   constitutibleArtworkList,
-  contentString,
   contributionList,
   dimensions,
   directory,
@@ -91,12 +92,17 @@ export class Track extends Thing {
     Artwork,
     CommentaryEntry,
     CreditingSourcesEntry,
-    Flash,
     LyricsEntry,
-    TrackSection,
+    ReferencingSourcesEntry,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    album: thing({
+      class: input.value(Album),
+    }),
+
+    // > Update & expose - Identifying metadata
 
     name: name('Unnamed Track'),
 
@@ -130,20 +136,93 @@ export class Track extends Thing {
       })
     ],
 
-    album: thing({
-      class: input.value(Album),
+    alwaysReferenceByDirectory: [
+      withAlwaysReferenceByDirectory(),
+      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
+    ],
+
+    mainReleaseTrack: singleReference({
+      class: input.value(Track),
+      find: soupyFind.input('track'),
     }),
 
+    bandcampTrackIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
+
     additionalNames: thingList({
       class: input.value(AdditionalName),
     }),
 
-    bandcampTrackIdentifier: simpleString(),
-    bandcampArtworkIdentifier: simpleString(),
+    dateFirstReleased: simpleDate(),
+
+    // > Update & expose - Credits and contributors
+
+    artistContribs: [
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
+
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+        date: '#date',
+      }).outputs({
+        '#resolvedContribs': '#artistContribs',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#artistContribs',
+        mode: input.value('empty'),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('trackArtistContribs'),
+      }),
+
+      withRecontextualizedContributionList({
+        list: '#album.trackArtistContribs',
+        artistProperty: input.value('trackArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.trackArtistContribs',
+        date: '#date',
+      }),
+
+      exposeDependency({dependency: '#album.trackArtistContribs'}),
+    ],
+
+    contributorContribs: [
+      inheritContributionListFromMainRelease(),
+
+      withDate(),
+
+      contributionList({
+        date: '#date',
+        artistProperty: input.value('trackContributorContributions'),
+      }),
+    ],
+
+    // > Update & expose - General configuration
+
+    countInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('countTracksInArtistTotals'),
+      }),
+
+      exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
+    ],
+
+    disableUniqueCoverArt: flag(),
+
+    // > Update & expose - General metadata
 
     duration: duration(),
-    urls: urls(),
-    dateFirstReleased: simpleDate(),
 
     color: [
       exposeUpdateValueOrContinue({
@@ -166,37 +245,27 @@ export class Track extends Thing {
       exposeDependency({dependency: '#album.color'}),
     ],
 
-    alwaysReferenceByDirectory: [
-      withAlwaysReferenceByDirectory(),
-      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
-    ],
-
-    // Disables presenting the track as though it has its own unique artwork.
-    // This flag should only be used in select circumstances, i.e. to override
-    // an album's trackCoverArtists. This flag supercedes that property, as well
-    // as the track's own coverArtists.
-    disableUniqueCoverArt: flag(),
-
-    // File extension for track's corresponding media file. This represents the
-    // track's unique cover artwork, if any, and does not inherit the extension
-    // of the album's main artwork. It does inherit trackCoverArtFileExtension,
-    // if present on the album.
-    coverArtFileExtension: [
-      exitWithoutUniqueCoverArt(),
+    urls: urls(),
 
-      exposeUpdateValueOrContinue({
-        validate: input.value(isFileExtension),
-      }),
+    // > Update & expose - Artworks
 
-      withPropertyFromAlbum({
-        property: input.value('trackCoverArtFileExtension'),
+    trackArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
       }),
 
-      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Track Artwork'),
+    ],
 
-      exposeConstant({
-        value: input.value('jpg'),
+    coverArtistContribs: [
+      withCoverArtistContribs({
+        from: input.updateValue({
+          validate: isContributionList,
+        }),
       }),
+
+      exposeDependency({dependency: '#coverArtistContribs'}),
     ],
 
     coverArtDate: [
@@ -209,113 +278,59 @@ export class Track extends Thing {
       exposeDependency({dependency: '#trackArtDate'}),
     ],
 
-    coverArtDimensions: [
+    coverArtFileExtension: [
       exitWithoutUniqueCoverArt(),
 
-      exposeUpdateValueOrContinue(),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
+      }),
 
       withPropertyFromAlbum({
-        property: input.value('trackDimensions'),
+        property: input.value('trackCoverArtFileExtension'),
       }),
 
-      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
-
-      dimensions(),
-    ],
-
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
-
-    creditSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
-
-    lyrics: [
-      // TODO: Inherited lyrics are literally the same objects, so of course
-      // their .thing properties aren't going to point back to this one, and
-      // certainly couldn't be recontextualized...
-      inheritFromMainRelease(),
+      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
 
-      thingList({
-        class: input.value(LyricsEntry),
+      exposeConstant({
+        value: input.value('jpg'),
       }),
     ],
 
-    additionalFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    sheetMusicFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    midiProjectFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
-
-    mainReleaseTrack: singleReference({
-      class: input.value(Track),
-      find: soupyFind.input('track'),
-    }),
-
-    artistContribs: [
-      inheritContributionListFromMainRelease(),
-
-      withDate(),
-
-      withResolvedContribs({
-        from: input.updateValue({validate: isContributionList}),
-        thingProperty: input.thisProperty(),
-        artistProperty: input.value('trackArtistContributions'),
-        date: '#date',
-      }).outputs({
-        '#resolvedContribs': '#artistContribs',
-      }),
+    coverArtDimensions: [
+      exitWithoutUniqueCoverArt(),
 
-      exposeDependencyOrContinue({
-        dependency: '#artistContribs',
-        mode: input.value('empty'),
-      }),
+      exposeUpdateValueOrContinue(),
 
       withPropertyFromAlbum({
-        property: input.value('artistContribs'),
-      }),
-
-      withRecontextualizedContributionList({
-        list: '#album.artistContribs',
-        artistProperty: input.value('trackArtistContributions'),
+        property: input.value('trackDimensions'),
       }),
 
-      withRedatedContributionList({
-        list: '#album.artistContribs',
-        date: '#date',
-      }),
+      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
 
-      exposeDependency({dependency: '#album.artistContribs'}),
+      dimensions(),
     ],
 
-    contributorContribs: [
-      inheritContributionListFromMainRelease(),
-
-      withDate(),
+    artTags: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
+      }),
 
-      contributionList({
-        date: '#date',
-        artistProperty: input.value('trackContributorContributions'),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
       }),
     ],
 
-    coverArtistContribs: [
-      withCoverArtistContribs({
-        from: input.updateValue({
-          validate: isContributionList,
-        }),
+    referencedArtworks: [
+      exitWithoutUniqueCoverArt({
+        value: input.value([]),
       }),
 
-      exposeDependency({dependency: '#coverArtistContribs'}),
+      referencedArtworkList(),
     ],
 
+    // > Update & expose - Referenced tracks
+
     referencedTracks: [
       inheritFromMainRelease({
         notFoundValue: input.value([]),
@@ -338,35 +353,46 @@ export class Track extends Thing {
       }),
     ],
 
-    trackArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    // > Update & expose - Additional files
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Track Artwork'),
-    ],
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
-    artTags: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    sheetMusicFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
-      referenceList({
-        class: input.value(ArtTag),
-        find: soupyFind.input('artTag'),
+    midiProjectFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    // > Update & expose - Content entries
+
+    lyrics: [
+      // TODO: Inherited lyrics are literally the same objects, so of course
+      // their .thing properties aren't going to point back to this one, and
+      // certainly couldn't be recontextualized...
+      inheritFromMainRelease(),
+
+      thingList({
+        class: input.value(LyricsEntry),
       }),
     ],
 
-    referencedArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
-      }),
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
 
-      referencedArtworkList(),
-    ],
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
+
+    referencingSources: thingList({
+      class: input.value(ReferencingSourcesEntry),
+    }),
 
-    // Update only
+    // > Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
@@ -386,7 +412,13 @@ export class Track extends Thing {
       class: input.value(WikiInfo),
     }),
 
-    // Expose only
+    // > Expose only
+
+    isTrack: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
 
     commentatorArtists: commentatorArtists(),
 
@@ -438,6 +470,34 @@ export class Track extends Thing {
       exposeDependency({dependency: '#otherReleases'}),
     ],
 
+    commentaryFromMainRelease: [
+      withMainRelease(),
+
+      exitWithoutDependency({
+        dependency: '#mainRelease',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: '#mainRelease',
+        property: input.value('commentary'),
+      }),
+
+      exposeDependency({
+        dependency: '#mainRelease.commentary',
+      }),
+    ],
+
+    groups: [
+      withPropertyFromAlbum({
+        property: input.value('groups'),
+      }),
+
+      exposeDependency({
+        dependency: '#album.groups',
+      }),
+    ],
+
     referencedByTracks: reverseReferenceList({
       reverse: soupyReverse.input('tracksWhichReference'),
     }),
@@ -453,14 +513,13 @@ export class Track extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
+      // Identifying metadata
+
       'Track': {property: 'name'},
       'Directory': {property: 'directory'},
       'Suffix Directory': {property: 'suffixDirectoryFromAlbum'},
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Main Release': {property: 'mainReleaseTrack'},
 
       'Bandcamp Track ID': {
         property: 'bandcampTrackIdentifier',
@@ -472,17 +531,71 @@ export class Track extends Thing {
         transform: String,
       },
 
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Date First Released': {
+        property: 'dateFirstReleased',
+        transform: parseDate,
+      },
+
+      // Credits and contributors
+
+      'Artists': {
+        property: 'artistContribs',
+        transform: parseContributors,
+      },
+
+      'Contributors': {
+        property: 'contributorContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
+      'Count In Artist Totals': {property: 'countInArtistTotals'},
+
+      'Has Cover Art': {
+        property: 'disableUniqueCoverArt',
+        transform: value =>
+          (typeof value === 'boolean'
+            ? !value
+            : value),
+      },
+
+      // General metadata
+
       'Duration': {
         property: 'duration',
         transform: parseDuration,
       },
 
       'Color': {property: 'color'},
+
       'URLs': {property: 'urls'},
 
-      'Date First Released': {
-        property: 'dateFirstReleased',
-        transform: parseDate,
+      // Artworks
+
+      'Track Artwork': {
+        property: 'trackArtworks',
+        transform:
+          parseArtwork({
+            thingProperty: 'trackArtworks',
+            dimensionsFromThingProperty: 'coverArtDimensions',
+            fileExtensionFromThingProperty: 'coverArtFileExtension',
+            dateFromThingProperty: 'coverArtDate',
+            artTagsFromThingProperty: 'artTags',
+            referencedArtworksFromThingProperty: 'referencedArtworks',
+            artistContribsFromThingProperty: 'coverArtistContribs',
+            artistContribsArtistProperty: 'trackCoverArtistContributions',
+          }),
+      },
+
+      'Cover Artists': {
+        property: 'coverArtistContribs',
+        transform: parseContributors,
       },
 
       'Cover Art Date': {
@@ -497,30 +610,19 @@ export class Track extends Thing {
         transform: parseDimensions,
       },
 
-      'Has Cover Art': {
-        property: 'disableUniqueCoverArt',
-        transform: value =>
-          (typeof value === 'boolean'
-            ? !value
-            : value),
-      },
-
-      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Art Tags': {property: 'artTags'},
 
-      'Lyrics': {
-        property: 'lyrics',
-        transform: parseLyrics,
+      'Referenced Artworks': {
+        property: 'referencedArtworks',
+        transform: parseAnnotatedReferences,
       },
 
-      'Commentary': {
-        property: 'commentary',
-        transform: parseCommentary,
-      },
+      // Referenced tracks
 
-      'Credit Sources': {
-        property: 'creditSources',
-        transform: parseCreditingSources,
-      },
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
+
+      // Additional files
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -537,54 +639,41 @@ export class Track extends Thing {
         transform: parseAdditionalFiles,
       },
 
-      'Main Release': {property: 'mainReleaseTrack'},
-      'Referenced Tracks': {property: 'referencedTracks'},
-      'Sampled Tracks': {property: 'sampledTracks'},
+      // Content entries
 
-      'Referenced Artworks': {
-        property: 'referencedArtworks',
-        transform: parseAnnotatedReferences,
+      'Lyrics': {
+        property: 'lyrics',
+        transform: parseLyrics,
       },
 
-      'Franchises': {ignore: true},
-      'Inherit Franchises': {ignore: true},
-
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
       },
 
-      'Contributors': {
-        property: 'contributorContribs',
-        transform: parseContributors,
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
       },
 
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
+      'Referencing Sources': {
+        property: 'referencingSources',
+        transform: parseReferencingSources,
       },
 
-      'Track Artwork': {
-        property: 'trackArtworks',
-        transform:
-          parseArtwork({
-            thingProperty: 'trackArtworks',
-            dimensionsFromThingProperty: 'coverArtDimensions',
-            fileExtensionFromThingProperty: 'coverArtFileExtension',
-            dateFromThingProperty: 'coverArtDate',
-            artTagsFromThingProperty: 'artTags',
-            referencedArtworksFromThingProperty: 'referencedArtworks',
-            artistContribsFromThingProperty: 'coverArtistContribs',
-            artistContribsArtistProperty: 'trackCoverArtistContributions',
-          }),
-      },
-
-      'Art Tags': {property: 'artTags'},
+      // Shenanigans
 
+      'Franchises': {ignore: true},
+      'Inherit Franchises': {ignore: true},
       'Review Points': {ignore: true},
     },
 
     invalidFieldCombinations: [
+      {message: `Secondary releases never count in artist totals`, fields: [
+        'Main Release',
+        'Count In Artist Totals',
+      ]},
+
       {message: `Secondary releases inherit references from the main one`, fields: [
         'Main Release',
         'Referenced Tracks',
@@ -771,6 +860,30 @@ export class Track extends Thing {
     ];
   }
 
+  countOwnContributionInContributionTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
+  countOwnContributionInDurationTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
   [inspect.custom](depth) {
     const parts = [];
 
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 590598be..b6057735 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -2,7 +2,7 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml';
 
 import {input} from '#composite';
 import Thing from '#thing';
-import {parseContributionPresets} from '#yaml';
+import {parseContributionPresets, parseWallpaperParts} from '#yaml';
 
 import {
   isBoolean,
@@ -13,9 +13,18 @@ import {
   isURL,
 } from '#validators';
 
-import {exitWithoutDependency} from '#composite/control-flow';
-import {contentString, flag, name, referenceList, soupyFind}
-  from '#composite/wiki-properties';
+import {exitWithoutDependency, exposeConstant} from '#composite/control-flow';
+
+import {
+  contentString,
+  fileExtension,
+  flag,
+  name,
+  referenceList,
+  simpleString,
+  soupyFind,
+  wallpaperParts,
+} from '#composite/wiki-properties';
 
 export class WikiInfo extends Thing {
   static [Thing.friendlyName] = `Wiki Info`;
@@ -68,6 +77,10 @@ export class WikiInfo extends Thing {
       },
     },
 
+    wikiWallpaperFileExtension: fileExtension('jpg'),
+    wikiWallpaperStyle: simpleString(),
+    wikiWallpaperParts: wallpaperParts(),
+
     divideTrackListsByGroups: referenceList({
       class: input.value(Group),
       find: soupyFind.input('group'),
@@ -106,24 +119,48 @@ export class WikiInfo extends Thing {
         default: false,
       },
     },
+
+    // Expose only
+
+    isWikiInfo: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Name': {property: 'name'},
       'Short Name': {property: 'nameShort'},
+
       'Color': {property: 'color'},
+
       'Description': {property: 'description'},
+
       'Footer Content': {property: 'footerContent'},
+
       'Default Language': {property: 'defaultLanguage'},
+
       'Canonical Base': {property: 'canonicalBase'},
-      'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'},
+
+      'Wiki Wallpaper File Extension': {property: 'wikiWallpaperFileExtension'},
+
+      'Wiki Wallpaper Style': {property: 'wikiWallpaperStyle'},
+
+      'Wiki Wallpaper Parts': {
+        property: 'wikiWallpaperParts',
+        transform: parseWallpaperParts,
+      },
+
       'Enable Flashes & Games': {property: 'enableFlashesAndGames'},
       'Enable Listings': {property: 'enableListings'},
       'Enable News': {property: 'enableNews'},
       'Enable Art Tag UI': {property: 'enableArtTagUI'},
       'Enable Group UI': {property: 'enableGroupUI'},
 
+      'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'},
+
       'Contribution Presets': {
         property: 'contributionPresets',
         transform: parseContributionPresets,
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 45694237..71887fc1 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -87,7 +87,7 @@ function makeProcessDocument(thingConstructor, {
   //   ]
   //
   // ...means A can't coexist with B or C, B can't coexist with A or C, and
-  // C can't coexist iwth A, B, or D - but it's okay for D to coexist with
+  // C can't coexist with A, B, or D - but it's okay for D to coexist with
   // A or B.
   //
   invalidFieldCombinations = [],
@@ -182,9 +182,22 @@ function makeProcessDocument(thingConstructor, {
 
     const fieldCombinationErrors = [];
 
-    for (const {message, fields} of invalidFieldCombinations) {
+    for (const {message, fields: fieldsSpec} of invalidFieldCombinations) {
       const fieldsPresent =
-        presentFields.filter(field => fields.includes(field));
+        fieldsSpec.flatMap(fieldSpec => {
+          if (Array.isArray(fieldSpec)) {
+            const [field, match] = fieldSpec;
+            if (!presentFields.includes(field)) return [];
+            if (typeof match === 'function') {
+              return match(document[field]) ? [field] : [];
+            } else {
+              return document[field] === match ? [field] : [];
+            }
+          }
+
+          const field = fieldSpec;
+          return presentFields.includes(field) ? [field] : [];
+        });
 
       if (fieldsPresent.length >= 2) {
         const filteredDocument =
@@ -194,7 +207,10 @@ function makeProcessDocument(thingConstructor, {
             {preserveOriginalOrder: true});
 
         fieldCombinationErrors.push(
-          new FieldCombinationError(filteredDocument, message));
+          new FieldCombinationError(
+            filteredDocument,
+            fieldsSpec,
+            message));
 
         for (const field of Object.keys(filteredDocument)) {
           skippedFields.add(field);
@@ -416,19 +432,36 @@ export class FieldCombinationAggregateError extends AggregateError {
 }
 
 export class FieldCombinationError extends Error {
-  constructor(fields, message) {
-    const fieldNames = Object.keys(fields);
+  constructor(filteredDocument, fieldsSpec, message) {
+    const fieldNames = Object.keys(filteredDocument);
 
     const fieldNamesText =
       fieldNames
-        .map(field => colors.red(field))
+        .map(field => {
+          if (fieldsSpec.includes(field)) {
+            return colors.red(field);
+          }
+
+          const match =
+            fieldsSpec
+              .find(fieldSpec =>
+                Array.isArray(fieldSpec) &&
+                fieldSpec[0] === field)
+              .at(1);
+
+          if (typeof match === 'function') {
+            return colors.red(`${field}: ${filteredDocument[field]}`);
+          } else {
+            return colors.red(`${field}: ${match}`);
+          }
+        })
         .join(', ');
 
     const mainMessage = `Don't combine ${fieldNamesText}`;
 
     const causeMessage =
       (typeof message === 'function'
-        ? message(fields)
+        ? message(filteredFields)
      : typeof message === 'string'
         ? message
         : null);
@@ -440,7 +473,7 @@ export class FieldCombinationError extends Error {
           : null),
     });
 
-    this.fields = fields;
+    this.fields = fieldNames;
   }
 }
 
@@ -639,17 +672,11 @@ export function parseAdditionalNames(entries, {subdoc, AdditionalName}) {
   });
 }
 
-export function parseSerieses(entries) {
+export function parseSerieses(entries, {subdoc, Series}) {
   return parseArrayEntries(entries, item => {
     if (typeof item !== 'object') return item;
 
-    return {
-      name: item['Name'],
-      description: item['Description'] ?? null,
-      albums: item['Albums'] ?? null,
-
-      showAlbumArtists: item['Show Album Artists'] ?? null,
-    };
+    return subdoc(Series, item, {bindInto: 'group'});
   });
 }
 
@@ -936,6 +963,10 @@ export function parseCreditingSources(value, {subdoc, CreditingSourcesEntry}) {
   return parseContentEntries(CreditingSourcesEntry, value, {subdoc});
 }
 
+export function parseReferencingSources(value, {subdoc, ReferencingSourcesEntry}) {
+  return parseContentEntries(ReferencingSourcesEntry, value, {subdoc});
+}
+
 export function parseLyrics(value, {subdoc, LyricsEntry}) {
   if (
     typeof value === 'string' &&
@@ -978,6 +1009,12 @@ export const documentModes = {
   // array of processed documents (wiki objects).
   allInOne: Symbol('Document mode: allInOne'),
 
+  // allTogether: One or more documens, spread across any number of files.
+  // Expects files array (or function) and processDocument function.
+  // Calls save with an array of processed documents (wiki objects) - this is
+  // a flat array, *not* an array of the documents processed from *each* file.
+  allTogether: Symbol('Document mode: allTogether'),
+
   // oneDocumentTotal: Just a single document, represented in one file.
   // Expects file string (or function) and processDocument function. Calls
   // save with the single processed wiki document (data object).
@@ -1024,7 +1061,7 @@ export const documentModes = {
 export function getAllDataSteps() {
   try {
     thingConstructors;
-  } catch (error) {
+  } catch {
     throw new Error(`Thing constructors aren't ready yet, can't get all data steps`);
   }
 
@@ -1088,6 +1125,7 @@ export async function getFilesFromDataStep(dataStep, {dataPath}) {
       }
     }
 
+    case documentModes.allTogether:
     case documentModes.headerAndEntries:
     case documentModes.onePerFile: {
       if (!dataStep.files) {
@@ -1243,7 +1281,8 @@ export function processThingsFromDataStep(documents, dataStep) {
   const {documentMode} = dataStep;
 
   switch (documentMode) {
-    case documentModes.allInOne: {
+    case documentModes.allInOne:
+    case documentModes.allTogether: {
       const result = [];
       const aggregate = openAggregate({message: `Errors processing documents`});
 
@@ -1518,6 +1557,10 @@ export function saveThingsFromDataStep(thingLists, dataStep) {
       return dataStep.save(thing);
     }
 
+    case documentModes.allTogether: {
+      return dataStep.save(thingLists.flat());
+    }
+
     case documentModes.headerAndEntries:
     case documentModes.onePerFile: {
       return dataStep.save(thingLists);
@@ -1644,6 +1687,10 @@ export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
 
     ['lyricsData', [/* find */]],
 
+    ['referencingSourceData', [/* find */]],
+
+    ['seriesData', [/* find */]],
+
     ['trackData', [
       'artworkData',
       'trackData',