« 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/things
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/things')
-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.js874
-rw-r--r--src/data/things/art-tag.js90
-rw-r--r--src/data/things/artist.js198
-rw-r--r--src/data/things/artwork.js265
-rw-r--r--src/data/things/content.js235
-rw-r--r--src/data/things/contribution.js198
-rw-r--r--src/data/things/flash.js175
-rw-r--r--src/data/things/group.js92
-rw-r--r--src/data/things/homepage-layout.js71
-rw-r--r--src/data/things/index.js96
-rw-r--r--src/data/things/language.js230
-rw-r--r--src/data/things/news-entry.js11
-rw-r--r--src/data/things/sorting-rule.js36
-rw-r--r--src/data/things/static-page.js13
-rw-r--r--src/data/things/track.js1274
-rw-r--r--src/data/things/wiki-info.js76
18 files changed, 2810 insertions, 1151 deletions
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 a4c2f6a5..e660a2b1 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -3,14 +3,22 @@ export const DATA_ALBUM_DIRECTORY = 'album';
 import * as path from 'node:path';
 import {inspect} from 'node:util';
 
-import CacheableObject from '#cacheable-object';
 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,
+  isBoolean,
+  isColor,
+  isContributionList,
+  isDate,
+  isDirectory,
+  isNumber,
+} from '#validators';
 
 import {
   parseAdditionalFiles,
@@ -25,12 +33,28 @@ import {
   parseWallpaperParts,
 } from '#yaml';
 
-import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
-  from '#composite/control-flow';
-import {withPropertyFromObject} from '#composite/data';
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
+  withFlattenedList,
+  withLengthOfList,
+  withNearbyItemFromList,
+  withPropertyFromList,
+  withPropertyFromObject,
+} from '#composite/data';
 
-import {exitWithoutContribs, withDirectory, withCoverArtDate}
-  from '#composite/wiki-data';
+import {
+  exitWithoutArtwork,
+  withDirectory,
+  withHasArtwork,
+  withResolvedContribs,
+} from '#composite/wiki-data';
 
 import {
   color,
@@ -47,7 +71,6 @@ import {
   name,
   referencedArtworkList,
   referenceList,
-  reverseReferenceList,
   simpleDate,
   simpleString,
   soupyFind,
@@ -59,12 +82,15 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {withHasCoverArt, withTracks} from '#composite/things/album';
-import {withAlbum, withContinueCountingFrom, withStartCountingFrom}
-  from '#composite/things/track-section';
-
 export class Album extends Thing {
   static [Thing.referenceType] = 'album';
+  static [Thing.wikiData] = 'albumData';
+
+  static [Thing.constitutibleProperties] = [
+    'coverArtworks',
+    'wallpaperArtwork',
+    'bannerArtwork',
+  ];
 
   static [Thing.getPropertyDescriptors] = ({
     AdditionalFile,
@@ -74,11 +100,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,81 +130,181 @@ 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(),
 
-    coverArtDate: [
-      withCoverArtDate({
-        from: input.updateValue({
-          validate: isDate,
-        }),
+    // > Update & expose - Credits and contributors
+
+    artistContribs: contributionList({
+      date: '_date',
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    trackArtistText: contentString(),
+
+    trackArtistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('albumTrackArtistContributions'),
+        date: '_date',
+      }).outputs({
+        '#resolvedContribs': '#trackArtistContribs',
       }),
 
-      exposeDependency({dependency: '#coverArtDate'}),
+      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'}),
     ],
 
-    coverArtFileExtension: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      fileExtension('jpg'),
+    // > Update & expose - General configuration
+
+    countTracksInArtistTotals: flag(true),
+
+    showAlbumInTracksWithoutArtists: flag(false),
+
+    hasTrackNumbers: flag(true),
+    isListedOnHomepage: flag(true),
+    isListedInGalleries: flag(true),
+
+    hideDuration: flag(false),
+
+    // > Update & expose - General metadata
+
+    color: color(),
+
+    urls: urls(),
+
+    // > Update & expose - Artworks
+
+    coverArtworks: [
+      exitWithoutArtwork({
+        contribs: '_coverArtistContribs',
+        artworks: '_coverArtworks',
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Cover Artwork'),
     ],
 
-    trackCoverArtFileExtension: fileExtension('jpg'),
+    coverArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumCoverArtistContributions'),
+    }),
 
-    wallpaperFileExtension: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      fileExtension('jpg'),
+    coverArtDate: [
+      withHasArtwork({
+        contribs: '_coverArtistContribs',
+        artworks: '_coverArtworks',
+      }),
+
+      exitWithoutDependency({
+        dependency: '#hasArtwork',
+        mode: input.value('falsy'),
+      }),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
+
+      exposeDependency({
+        dependency: 'date',
+      }),
     ],
 
-    bannerFileExtension: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+    coverArtFileExtension: [
+      exitWithoutArtwork({
+        contribs: '_coverArtistContribs',
+        artworks: '_coverArtworks',
+      }),
+
       fileExtension('jpg'),
     ],
 
-    wallpaperStyle: [
-      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
-      simpleString(),
+    coverArtDimensions: [
+      exitWithoutArtwork({
+        contribs: '_coverArtistContribs',
+        artworks: '_coverArtworks',
+      }),
+
+      dimensions(),
     ],
 
-    wallpaperParts: [
-      exitWithoutContribs({
-        contribs: 'wallpaperArtistContribs',
+    artTags: [
+      exitWithoutArtwork({
+        contribs: '_coverArtistContribs',
+        artworks: '_coverArtworks',
         value: input.value([]),
       }),
 
-      wallpaperParts(),
+      referenceList({
+        class: input.value(ArtTag),
+        find: soupyFind.input('artTag'),
+      }),
     ],
 
-    bannerStyle: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      simpleString(),
-    ],
+    referencedArtworks: [
+      exitWithoutArtwork({
+        contribs: '_coverArtistContribs',
+        artworks: '_coverArtworks',
+        value: input.value([]),
+      }),
 
-    coverArtDimensions: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
-      dimensions(),
+      referencedArtworkList(),
     ],
 
-    trackDimensions: dimensions(),
+    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',
 
-    bannerDimensions: [
-      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
-      dimensions(),
-    ],
+      // This is the "correct" value, but it gets overwritten - with the same
+      // value - regardless.
+      artistProperty: input.value('trackCoverArtistContributions'),
+    }),
+
+    trackArtDate: simpleDate(),
+
+    trackCoverArtFileExtension: fileExtension('jpg'),
+
+    trackDimensions: dimensions(),
 
     wallpaperArtwork: [
       exitWithoutDependency({
-        dependency: 'wallpaperArtistContribs',
+        dependency: '_wallpaperArtistContribs',
         mode: input.value('empty'),
         value: input.value(null),
       }),
@@ -182,119 +313,107 @@ export class Album extends Thing {
         .call(this, 'Wallpaper Artwork'),
     ],
 
-    bannerArtwork: [
-      exitWithoutDependency({
-        dependency: 'bannerArtistContribs',
-        mode: input.value('empty'),
-        value: input.value(null),
+    wallpaperArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumWallpaperArtistContributions'),
+    }),
+
+    wallpaperFileExtension: [
+      exitWithoutArtwork({
+        contribs: '_wallpaperArtistContribs',
+        artwork: '_wallpaperArtwork',
       }),
 
-      constitutibleArtwork.fromYAMLFieldSpec
-        .call(this, 'Banner Artwork'),
+      fileExtension('jpg'),
     ],
 
-    coverArtworks: [
-      withHasCoverArt(),
-
-      exitWithoutDependency({
-        dependency: '#hasCoverArt',
-        mode: input.value('falsy'),
-        value: input.value([]),
+    wallpaperStyle: [
+      exitWithoutArtwork({
+        contribs: '_wallpaperArtistContribs',
+        artwork: '_wallpaperArtwork',
       }),
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Cover Artwork'),
+      simpleString(),
     ],
 
-    hasTrackNumbers: flag(true),
-    isListedOnHomepage: flag(true),
-    isListedInGalleries: flag(true),
-
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
+    wallpaperParts: [
+      // kinda nonsensical or at least unlikely lol, but y'know
+      exitWithoutArtwork({
+        contribs: '_wallpaperArtistContribs',
+        artwork: '_wallpaperArtwork',
+        value: input.value([]),
+      }),
 
-    creditSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
+      wallpaperParts(),
+    ],
 
-    additionalFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
+    bannerArtwork: [
+      exitWithoutDependency({
+        dependency: '_bannerArtistContribs',
+        mode: input.value('empty'),
+        value: input.value(null),
+      }),
 
-    trackSections: thingList({
-      class: input.value(TrackSection),
-    }),
+      constitutibleArtwork.fromYAMLFieldSpec
+        .call(this, 'Banner Artwork'),
+    ],
 
-    artistContribs: contributionList({
-      date: 'date',
-      artistProperty: input.value('albumArtistContributions'),
+    bannerArtistContribs: contributionList({
+      date: 'coverArtDate',
+      artistProperty: input.value('albumBannerArtistContributions'),
     }),
 
-    coverArtistContribs: [
-      withCoverArtDate(),
-
-      contributionList({
-        date: '#coverArtDate',
-        artistProperty: input.value('albumCoverArtistContributions'),
+    bannerFileExtension: [
+      exitWithoutArtwork({
+        contribs: '_bannerArtistContribs',
+        artwork: '_bannerArtwork',
       }),
-    ],
-
-    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'),
-    }),
 
-    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,21 +428,46 @@ 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'}),
-    hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
+    hasWallpaperArt: contribsPresent({contribs: '_wallpaperArtistContribs'}),
+    hasBannerArt: contribsPresent({contribs: '_bannerArtistContribs'}),
 
     tracks: [
-      withTracks(),
-      exposeDependency({dependency: '#tracks'}),
+      exitWithoutDependency({
+        dependency: 'trackSections',
+        value: input.value([]),
+      }),
+
+      withPropertyFromList({
+        list: 'trackSections',
+        property: input.value('tracks'),
+      }),
+
+      withFlattenedList({
+        list: '#trackSections.tracks',
+      }),
+
+      exposeDependency({
+        dependency: '#flattenedList',
+      }),
     ],
   });
 
@@ -378,8 +522,22 @@ export class Album extends Thing {
       bindTo: 'albumData',
 
       getMatchableNames: album =>
-        (album.alwaysReferenceByDirectory 
-          ? [] 
+        (album.alwaysReferenceByDirectory
+          ? []
+          : [album.name]),
+    },
+
+    albumSinglesOnly: {
+      referencing: ['album'],
+
+      bindTo: 'albumData',
+
+      incldue: album =>
+        album.style === 'single',
+
+      getMatchableNames: album =>
+        (album.alwaysReferenceByDirectory
+          ? []
           : [album.name]),
     },
 
@@ -396,8 +554,8 @@ export class Album extends Thing {
         album.hasCoverArt,
 
       getMatchableNames: album =>
-        (album.alwaysReferenceByDirectory 
-          ? [] 
+        (album.alwaysReferenceByDirectory
+          ? []
           : [album.name]),
     },
 
@@ -428,20 +586,6 @@ export class Album extends Thing {
   };
 
   static [Thing.reverseSpecs] = {
-    albumsWhoseTracksInclude: {
-      bindTo: 'albumData',
-
-      referencing: album => [album],
-      referenced: album => album.tracks,
-    },
-
-    albumsWhoseTrackSectionsInclude: {
-      bindTo: 'albumData',
-
-      referencing: album => [album],
-      referenced: album => album.trackSections,
-    },
-
     albumsWhoseArtworksFeature: {
       bindTo: 'albumData',
 
@@ -459,6 +603,9 @@ export class Album extends Thing {
     albumArtistContributionsBy:
       soupyReverse.contributionsBy('albumData', 'artistContribs'),
 
+    albumTrackArtistContributionsBy:
+      soupyReverse.contributionsBy('albumData', 'trackArtistContribs'),
+
     albumCoverArtistContributionsBy:
       soupyReverse.artworkContributionsBy('albumData', 'coverArtworks'),
 
@@ -478,21 +625,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 +645,61 @@ 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 Artist Text': {
+        property: 'trackArtistText',
+      },
+
+      'Track Artists': {
+        property: 'trackArtistContribs',
+        transform: parseContributors,
+      },
+
+      // General configuration
+
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
+
+      'Show Album In Tracks Without Artists': {
+        property: 'showAlbumInTracksWithoutArtists',
+      },
 
       '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 +743,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 +779,6 @@ export class Album extends Thing {
       },
 
       'Wallpaper Style': {property: 'wallpaperStyle'},
-      'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
       'Wallpaper Parts': {
         property: 'wallpaperParts',
@@ -605,58 +790,70 @@ 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 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',
@@ -688,100 +885,48 @@ export class Album extends Thing {
         ? TrackSection
         : Track),
 
-    save(results) {
-      const albumData = [];
-      const trackSectionData = [];
-      const trackData = [];
-
-      const artworkData = [];
-      const commentaryData = [];
-      const creditingSourceData = [];
-      const lyricsData = [];
-
-      for (const {header: album, entries} of results) {
-        const trackSections = [];
-
-        let currentTrackSection = new TrackSection();
-        let currentTrackSectionTracks = [];
-
-        Object.assign(currentTrackSection, {
-          name: `Default Track Section`,
-          isDefaultTrackSection: true,
-        });
-
-        const albumRef = Thing.getReference(album);
-
-        const closeCurrentTrackSection = () => {
-          if (
-            currentTrackSection.isDefaultTrackSection &&
-            empty(currentTrackSectionTracks)
-          ) {
-            return;
-          }
-
-          currentTrackSection.tracks =
-            currentTrackSectionTracks;
-
-          trackSections.push(currentTrackSection);
-          trackSectionData.push(currentTrackSection);
-        };
-
-        for (const entry of entries) {
-          if (entry instanceof TrackSection) {
-            closeCurrentTrackSection();
-            currentTrackSection = entry;
-            currentTrackSectionTracks = [];
-            continue;
-          }
-
-          currentTrackSectionTracks.push(entry);
-          trackData.push(entry);
-
-          // Set the track's album before accessing its list of artworks.
-          // The existence of its artwork objects may depend on access to
-          // its album's 'Default Track Cover Artists'.
-          entry.album = album;
-
-          artworkData.push(...entry.trackArtworks);
-          commentaryData.push(...entry.commentary);
-          creditingSourceData.push(...entry.creditSources);
-
-          // TODO: As exposed, Track.lyrics tries to inherit from the main
-          // release, which is impossible before the data's been linked.
-          // We just use the update value here. But it's icky!
-          lyricsData.push(...CacheableObject.getUpdateValue(entry, 'lyrics') ?? []);
-        }
+    connect({header: album, entries}) {
+      const trackSections = [];
 
-        closeCurrentTrackSection();
+      let currentTrackSection = new TrackSection();
+      let currentTrackSectionTracks = [];
 
-        albumData.push(album);
+      Object.assign(currentTrackSection, {
+        name: `Default Track Section`,
+        isDefaultTrackSection: true,
+      });
 
-        artworkData.push(...album.coverArtworks);
-
-        if (album.bannerArtwork) {
-          artworkData.push(album.bannerArtwork);
+      const closeCurrentTrackSection = () => {
+        if (
+          currentTrackSection.isDefaultTrackSection &&
+          empty(currentTrackSectionTracks)
+        ) {
+          return;
         }
 
-        if (album.wallpaperArtwork) {
-          artworkData.push(album.wallpaperArtwork);
+        currentTrackSection.tracks = currentTrackSectionTracks;
+        currentTrackSection.album = album;
+
+        trackSections.push(currentTrackSection);
+      };
+
+      for (const entry of entries) {
+        if (entry instanceof TrackSection) {
+          closeCurrentTrackSection();
+          currentTrackSection = entry;
+          currentTrackSectionTracks = [];
+          continue;
         }
 
-        commentaryData.push(...album.commentary);
-        creditingSourceData.push(...album.creditSources);
+        entry.album = album;
+        entry.trackSection = currentTrackSection;
 
-        album.trackSections = trackSections;
+        currentTrackSectionTracks.push(entry);
       }
 
-      return {
-        albumData,
-        trackSectionData,
-        trackData,
+      closeCurrentTrackSection();
 
-        artworkData,
-        commentaryData,
-        creditingSourceData,
-        lyricsData,
-      };
+      album.trackSections = trackSections;
     },
 
     sort({albumData, trackData}) {
@@ -835,28 +980,63 @@ 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.wikiData] = 'trackSectionData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({Track}) => ({
     // Update & expose
 
+    album: thing({
+      class: input.value(Album),
+    }),
+
     name: name('Unnamed Track Section'),
 
     unqualifiedDirectory: directory(),
 
+    directorySuffix: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDirectory),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('directorySuffix'),
+      }),
+
+      exposeDependency({dependency: '#album.directorySuffix'}),
+    ],
+
+    suffixTrackDirectories: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('suffixTrackDirectories'),
+      }),
+
+      exposeDependency({dependency: '#album.suffixTrackDirectories'}),
+    ],
+
     color: [
       exposeUpdateValueOrContinue({
         validate: input.value(isColor),
       }),
 
-      withAlbum(),
-
       withPropertyFromObject({
-        object: '#album',
+        object: 'album',
         property: input.value('color'),
       }),
 
@@ -864,24 +1044,62 @@ export class TrackSection extends Thing {
     ],
 
     startCountingFrom: [
-      withStartCountingFrom({
-        from: input.updateValue({validate: isNumber}),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isNumber),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'album',
+        value: input.value(1),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('trackSections'),
+      }),
+
+      withNearbyItemFromList({
+        list: '#album.trackSections',
+        item: input.myself(),
+        offset: input.value(-1),
+      }).outputs({
+        '#nearbyItem': '#previousTrackSection',
       }),
 
-      exposeDependency({dependency: '#startCountingFrom'}),
+      exitWithoutDependency({
+        dependency: '#previousTrackSection',
+        value: input.value(1),
+      }),
+
+      withPropertyFromObject({
+        object: '#previousTrackSection',
+        property: input.value('continueCountingFrom'),
+      }),
+
+      exposeDependency({
+        dependency: '#previousTrackSection.continueCountingFrom',
+      }),
     ],
 
     dateOriginallyReleased: simpleDate(),
 
-    isDefaultTrackSection: flag(false),
+    countTracksInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
 
-    description: contentString(),
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('countTracksInArtistTotals'),
+      }),
 
-    album: [
-      withAlbum(),
-      exposeDependency({dependency: '#album'}),
+      exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
     ],
 
+    isDefaultTrackSection: flag(false),
+
+    description: contentString(),
+
     tracks: thingList({
       class: input.value(Track),
     }),
@@ -892,20 +1110,24 @@ export class TrackSection extends Thing {
 
     // Expose only
 
-    directory: [
-      withAlbum(),
+    isTrackSection: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
 
+    directory: [
       exitWithoutDependency({
-        dependency: '#album',
+        dependency: 'album',
       }),
 
       withPropertyFromObject({
-        object: '#album',
+        object: 'album',
         property: input.value('directory'),
       }),
 
       withDirectory({
-        directory: 'unqualifiedDirectory',
+        directory: '_unqualifiedDirectory',
       }).outputs({
         '#directory': '#unqualifiedDirectory',
       }),
@@ -921,9 +1143,15 @@ export class TrackSection extends Thing {
     ],
 
     continueCountingFrom: [
-      withContinueCountingFrom(),
+      withLengthOfList({
+        list: 'tracks',
+      }),
 
-      exposeDependency({dependency: '#continueCountingFrom'}),
+      {
+        dependencies: ['startCountingFrom', '#tracks.length'],
+        compute: ({startCountingFrom, '#tracks.length': tracks}) =>
+          startCountingFrom + tracks,
+      },
     ],
   });
 
@@ -941,18 +1169,12 @@ export class TrackSection extends Thing {
     },
   };
 
-  static [Thing.reverseSpecs] = {
-    trackSectionsWhichInclude: {
-      bindTo: 'trackSectionData',
-
-      referencing: trackSection => [trackSection],
-      referenced: trackSection => trackSection.tracks,
-    },
-  };
-
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Section': {property: 'name'},
+      'Directory Suffix': {property: 'directorySuffix'},
+      'Suffix Track Directories': {property: 'suffixTrackDirectories'},
+
       'Color': {property: 'color'},
       'Start Counting From': {property: 'startCountingFrom'},
 
@@ -961,6 +1183,8 @@ export class TrackSection extends Thing {
         transform: parseDate,
       },
 
+      'Count Tracks In Artist Totals': {property: 'countTracksInArtistTotals'},
+
       'Description': {property: 'description'},
     },
   };
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 0ec1ff31..0ae77434 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,15 +1,22 @@
+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,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
 
 import {
   annotatedReferenceList,
@@ -24,21 +31,14 @@ import {
   soupyReverse,
   thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
-import {withAllDescendantArtTags, withAncestorArtTagBaobabTree}
-  from '#composite/things/art-tag';
-
 export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
   static [Thing.friendlyName] = `Art Tag`;
+  static [Thing.wikiData] = 'artTagData';
 
-  static [Thing.getPropertyDescriptors] = ({
-    AdditionalName,
-    Album,
-    Track,
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({
     // Update & expose
 
     name: name('Unnamed Art Tag'),
@@ -85,6 +85,12 @@ export class ArtTag extends Thing {
 
     // Expose only
 
+    isArtTag: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     descriptionShort: [
       exitWithoutDependency({
         dependency: 'description',
@@ -103,29 +109,51 @@ export class ArtTag extends Thing {
     }),
 
     indirectlyFeaturedInArtworks: [
-      withAllDescendantArtTags(),
-
       {
-        dependencies: ['#allDescendantArtTags'],
-        compute: ({'#allDescendantArtTags': allDescendantArtTags}) =>
+        dependencies: ['allDescendantArtTags'],
+        compute: ({allDescendantArtTags}) =>
           unique(
             allDescendantArtTags
               .flatMap(artTag => artTag.directlyFeaturedInArtworks)),
       },
     ],
 
+    // All the art tags which descend from this one - that means its own direct
+    // descendants, plus all the direct and indirect descendants of each of those!
+    // The results aren't specially sorted, but they won't contain any duplicates
+    // (for example if two descendant tags both route deeper to end up including
+    // some of the same tags).
     allDescendantArtTags: [
-      withAllDescendantArtTags(),
-      exposeDependency({dependency: '#allDescendantArtTags'}),
+      {
+        dependencies: ['directDescendantArtTags'],
+        compute: ({directDescendantArtTags}) =>
+          unique([
+            ...directDescendantArtTags,
+            ...directDescendantArtTags.flatMap(artTag => artTag.allDescendantArtTags),
+          ]),
+      },
     ],
 
     directAncestorArtTags: reverseReferenceList({
       reverse: soupyReverse.input('artTagsWhichDirectlyAncestor'),
     }),
 
+    // All the art tags which are ancestors of this one as a "baobab tree" -
+    // what you'd typically think of as roots are all up in the air! Since this
+    // really is backwards from the way that the art tag tree is written in data,
+    // chances are pretty good that there will be many of the exact same "leaf"
+    // nodes - art tags which don't themselves have any ancestors. In the actual
+    // data structure, each node is a Map, with keys for each ancestor and values
+    // for each ancestor's own baobab (thus a branching structure, just like normal
+    // trees in this regard).
     ancestorArtTagBaobabTree: [
-      withAncestorArtTagBaobabTree(),
-      exposeDependency({dependency: '#ancestorArtTagBaobabTree'}),
+      {
+        dependencies: ['directAncestorArtTags'],
+        compute: ({directAncestorArtTags}) =>
+          new Map(
+            directAncestorArtTags
+              .map(artTag => [artTag, artTag.ancestorArtTagBaobabTree])),
+      },
     ],
   });
 
@@ -180,16 +208,26 @@ 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,
-    documentThing: ArtTag,
+    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)),
 
-    save: (results) => ({artTagData: results}),
+    documentMode: allTogether,
+    documentThing: ArtTag,
 
     sort({artTagData}) {
       sortAlphabetically(artTagData);
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 9e329c74..41d8504c 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -5,14 +5,20 @@ 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 {parseArtistAliases, parseArtwork} from '#yaml';
 
-import {exitWithoutDependency} from '#composite/control-flow';
+import {
+  sortAlbumsTracksChronologically,
+  sortArtworksChronologically,
+  sortAlphabetically,
+  sortContributionsChronologically,
+} from '#sort';
+
+import {exitWithoutDependency, exposeConstant, exposeDependency}
+  from '#composite/control-flow';
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+import {withContributionListSums} from '#composite/wiki-data';
 
 import {
   constitutibleArtwork,
@@ -22,20 +28,22 @@ import {
   flag,
   name,
   reverseReferenceList,
-  singleReference,
   soupyFind,
   soupyReverse,
+  thing,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
-import {artistTotalDuration} from '#composite/things/artist';
-
 export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
-  static [Thing.wikiDataArray] = 'artistData';
+  static [Thing.wikiData] = 'artistData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({
+  static [Thing.constitutibleProperties] = [
+    'avatarArtwork', // from inline fields
+  ];
+
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     name: name('Unnamed Artist'),
@@ -51,23 +59,21 @@ export class Artist extends Thing {
       exitWithoutDependency({
         dependency: 'hasAvatar',
         value: input.value(null),
+        mode: input.value('falsy'),
       }),
 
       constitutibleArtwork.fromYAMLFieldSpec
         .call(this, 'Avatar Artwork'),
     ],
 
-    aliasNames: {
-      flags: {update: true, expose: true},
-      update: {validate: validateArrayItems(isName)},
-      expose: {transform: (names) => names ?? []},
-    },
-
     isAlias: flag(),
 
-    aliasedArtist: singleReference({
+    artistAliases: thingList({
+      class: input.value(Artist),
+    }),
+
+    aliasedArtist: thing({
       class: input.value(Artist),
-      find: soupyFind.input('artist'),
     }),
 
     // Update only
@@ -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,7 +141,92 @@ export class Artist extends Thing {
       reverse: soupyReverse.input('groupsCloselyLinkedTo'),
     }),
 
-    totalDuration: artistTotalDuration(),
+    musicContributions: [
+      {
+        dependencies: [
+          'trackArtistContributions',
+          'trackContributorContributions',
+        ],
+
+        compute: (continuation, {
+          trackArtistContributions,
+          trackContributorContributions,
+        }) => continuation({
+          ['#contributions']: [
+            ...trackArtistContributions,
+            ...trackContributorContributions,
+          ],
+        }),
+      },
+
+      {
+        dependencies: ['#contributions'],
+        compute: ({'#contributions': contributions}) =>
+          sortContributionsChronologically(
+            contributions,
+            sortAlbumsTracksChronologically),
+      },
+    ],
+
+    artworkContributions: [
+      {
+        dependencies: [
+          'trackCoverArtistContributions',
+          'albumCoverArtistContributions',
+          'albumWallpaperArtistContributions',
+          'albumBannerArtistContributions',
+        ],
+
+        compute: (continuation, {
+          trackCoverArtistContributions,
+          albumCoverArtistContributions,
+          albumWallpaperArtistContributions,
+          albumBannerArtistContributions,
+        }) => continuation({
+          ['#contributions']: [
+            ...trackCoverArtistContributions,
+            ...albumCoverArtistContributions,
+            ...albumWallpaperArtistContributions,
+            ...albumBannerArtistContributions,
+          ],
+        }),
+      },
+
+      {
+        dependencies: ['#contributions'],
+        compute: ({'#contributions': contributions}) =>
+          sortContributionsChronologically(
+            contributions,
+            sortArtworksChronologically),
+      },
+    ],
+
+    totalDuration: [
+      withPropertyFromList({
+        list: 'musicContributions',
+        property: input.value('thing'),
+      }),
+
+      withPropertyFromList({
+        list: '#musicContributions.thing',
+        property: input.value('isMainRelease'),
+      }),
+
+      withFilteredList({
+        list: 'musicContributions',
+        filter: '#musicContributions.thing.isMainRelease',
+      }).outputs({
+        '#filteredList': '#mainReleaseContributions',
+      }),
+
+      withContributionListSums({
+        list: '#mainReleaseContributions',
+      }),
+
+      exposeDependency({
+        dependency: '#contributionListDuration',
+      }),
+    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -139,8 +240,6 @@ export class Artist extends Thing {
     hasAvatar: S.id,
     avatarFileExtension: S.id,
 
-    aliasNames: S.id,
-
     tracksAsCommentator: S.toRefs,
     albumsAsCommentator: S.toRefs,
   });
@@ -171,17 +270,9 @@ export class Artist extends Thing {
         // in the original's alias list. This is honestly a bit awkward, but it
         // avoids artist aliases conflicting with each other when checking for
         // duplicate directories.
-        for (const aliasName of originalArtist.aliasNames) {
-          // These are trouble. We should be accessing aliases' directories
-          // directly, but artists currently don't expose a reverse reference
-          // list for aliases. (This is pending a cleanup of "reverse reference"
-          // behavior in general.) It doesn't actually cause problems *here*
-          // because alias directories are computed from their names 100% of the
-          // time, but that *is* an assumption this code makes.
-          if (aliasName === artist.name) continue;
-          if (artist.directory === getKebabCase(aliasName)) {
-            return [];
-          }
+        for (const alias of originalArtist.artistAliases) {
+          if (alias === artist) break;
+          if (alias.directory === artist.directory) return [];
         }
 
         // And, aliases never return just a blank string. This part is pretty
@@ -221,7 +312,10 @@ export class Artist extends Thing {
       'Has Avatar': {property: 'hasAvatar'},
       'Avatar File Extension': {property: 'avatarFileExtension'},
 
-      'Aliases': {property: 'aliasNames'},
+      'Aliases': {
+        property: 'artistAliases',
+        transform: parseArtistAliases,
+      },
 
       'Dead URLs': {ignore: true},
 
@@ -239,38 +333,6 @@ export class Artist extends Thing {
     documentMode: allInOne,
     documentThing: Artist,
 
-    save(results) {
-      const artists = results;
-
-      const artistRefs =
-        artists.map(artist => Thing.getReference(artist));
-
-      const artistAliasNames =
-        artists.map(artist => artist.aliasNames);
-
-      const artistAliases =
-        stitchArrays({
-          originalArtistRef: artistRefs,
-          aliasNames: artistAliasNames,
-        }).flatMap(({originalArtistRef, aliasNames}) =>
-            aliasNames.map(name => {
-              const alias = new Artist();
-              alias.name = name;
-              alias.isAlias = true;
-              alias.aliasedArtist = originalArtistRef;
-              return alias;
-            }));
-
-      const artistData = [...artists, ...artistAliases];
-
-      const artworkData =
-        artistData
-          .filter(artist => artist.hasAvatar)
-          .map(artist => artist.avatarArtwork);
-
-      return {artistData, artworkData};
-    },
-
     sort({artistData}) {
       sortAlphabetically(artistData);
     },
@@ -287,7 +349,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..4aedd256 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,17 +25,25 @@ import {
   parseDimensions,
 } from '#yaml';
 
-import {withIndexInList, withPropertyFromObject} from '#composite/data';
-
 import {
   exitWithoutDependency,
   exposeConstant,
   exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
+  flipFilter,
 } from '#composite/control-flow';
 
 import {
+  withFilteredList,
+  withNearbyItemFromList,
+  withPropertyFromList,
+  withPropertyFromObject,
+} from '#composite/data';
+
+import {
+  constituteFrom,
+  constituteOrContinue,
   withRecontextualizedContributionList,
   withResolvedAnnotatedReferenceList,
   withResolvedContribs,
@@ -53,21 +62,18 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
-import {
-  withAttachedArtwork,
-  withContainingArtworkList,
-  withContribsFromAttachedArtwork,
-  withPropertyFromAttachedArtwork,
-  withDate,
-} from '#composite/things/artwork';
+import {withContainingArtworkList} from '#composite/things/artwork';
 
 export class Artwork extends Thing {
   static [Thing.referenceType] = 'artwork';
+  static [Thing.wikiData] = 'artworkData';
+
+  static [Thing.constitutibleProperties] = [
+    // Contributions currently aren't being observed for constitution.
+    // 'artistContribs', // from attached artwork or thing
+  ];
 
-  static [Thing.getPropertyDescriptors] = ({
-    ArtTag,
-    Contribution,
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({
     // Update & expose
 
     unqualifiedDirectory: directory({
@@ -79,51 +85,33 @@ export class Artwork extends Thing {
 
     label: simpleString(),
     source: contentString(),
+    originDetails: contentString(),
+    showFilename: simpleString(),
 
     dateFromThingProperty: simpleString(),
 
     date: [
-      withDate({
-        from: input.updateValue({validate: isDate}),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
       }),
 
-      exposeDependency({dependency: '#date'}),
+      constituteFrom({
+        property: 'dateFromThingProperty',
+        from: 'thing',
+      }),
     ],
 
     fileExtensionFromThingProperty: simpleString(),
 
     fileExtension: [
-      {
-        compute: (continuation) => continuation({
-          ['#default']: 'jpg',
-        }),
-      },
-
       exposeUpdateValueOrContinue({
         validate: input.value(isFileExtension),
       }),
 
-      exitWithoutDependency({
-        dependency: 'thing',
-        value: '#default',
-      }),
-
-      exitWithoutDependency({
-        dependency: 'fileExtensionFromThingProperty',
-        value: '#default',
-      }),
-
-      withPropertyFromObject({
-        object: 'thing',
+      constituteFrom({
         property: 'fileExtensionFromThingProperty',
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#value',
-      }),
-
-      exposeDependency({
-        dependency: '#default',
+        from: 'thing',
+        else: input.value('jpg'),
       }),
     ],
 
@@ -134,29 +122,9 @@ export class Artwork extends Thing {
         validate: input.value(isDimensions),
       }),
 
-      exitWithoutDependency({
-        dependency: 'dimensionsFromThingProperty',
-        value: input.value(null),
-      }),
-
-      withPropertyFromObject({
-        object: 'thing',
+      constituteFrom({
         property: 'dimensionsFromThingProperty',
-      }).outputs({
-        ['#value']: '#dimensionsFromThing',
-      }),
-
-      exitWithoutDependency({
-        dependency: 'dimensionsFromThingProperty',
-        value: input.value(null),
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#dimensionsFromThing',
-      }),
-
-      exposeConstant({
-        value: input.value(null),
+        from: 'thing',
       }),
     ],
 
@@ -166,11 +134,10 @@ export class Artwork extends Thing {
     artistContribsArtistProperty: simpleString(),
 
     artistContribs: [
-      withDate(),
-
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
-        date: '#date',
+        date: 'date',
+        thingProperty: input.thisProperty(),
         artistProperty: 'artistContribsArtistProperty',
       }),
 
@@ -179,33 +146,37 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
-      withContribsFromAttachedArtwork(),
+      withPropertyFromObject({
+        object: 'attachedArtwork',
+        property: input.value('artistContribs'),
+      }),
 
-      exposeDependencyOrContinue({
-        dependency: '#attachedArtwork.artistContribs',
+      withRecontextualizedContributionList({
+        list: '#attachedArtwork.artistContribs',
       }),
 
-      exitWithoutDependency({
-        dependency: 'artistContribsFromThingProperty',
-        value: input.value([]),
+      exposeDependencyOrContinue({
+        dependency: '#attachedArtwork.artistContribs',
       }),
 
       withPropertyFromObject({
         object: 'thing',
         property: 'artistContribsFromThingProperty',
       }).outputs({
-        ['#value']: '#artistContribs',
+        '#value': '#artistContribsFromThing',
       }),
 
       withRecontextualizedContributionList({
-        list: '#artistContribs',
+        list: '#artistContribsFromThing',
       }),
 
       exposeDependency({
-        dependency: '#artistContribs',
+        dependency: '#artistContribsFromThing',
       }),
     ],
 
+    style: simpleString(),
+
     artTagsFromThingProperty: simpleString(),
 
     artTags: [
@@ -214,7 +185,6 @@ export class Artwork extends Thing {
           validate:
             validateReferenceList(ArtTag[Thing.referenceType]),
         }),
-
         find: soupyFind.input('artTag'),
       }),
 
@@ -223,32 +193,16 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
-      withPropertyFromAttachedArtwork({
+      constituteOrContinue({
         property: input.value('artTags'),
+        from: 'attachedArtwork',
+        mode: input.value('empty'),
       }),
 
-      exposeDependencyOrContinue({
-        dependency: '#attachedArtwork.artTags',
-      }),
-
-      exitWithoutDependency({
-        dependency: 'artTagsFromThingProperty',
-        value: input.value([]),
-      }),
-
-      withPropertyFromObject({
-        object: 'thing',
+      constituteFrom({
         property: 'artTagsFromThingProperty',
-      }).outputs({
-        ['#value']: '#artTags',
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#artTags',
-      }),
-
-      exposeConstant({
-        value: input.value([]),
+        from: 'thing',
+        else: input.value([]),
       }),
     ],
 
@@ -279,7 +233,7 @@ export class Artwork extends Thing {
               })),
         }),
 
-        data: 'artworkData',
+        data: '_artworkData',
         find: '#find',
 
         thing: input.value('artwork'),
@@ -290,24 +244,10 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
-      exitWithoutDependency({
-        dependency: 'referencedArtworksFromThingProperty',
-        value: input.value([]),
-      }),
-
-      withPropertyFromObject({
-        object: 'thing',
+      constituteFrom({
         property: 'referencedArtworksFromThingProperty',
-      }).outputs({
-        ['#value']: '#referencedArtworks',
-      }),
-
-      exposeDependencyOrContinue({
-        dependency: '#referencedArtworks',
-      }),
-
-      exposeConstant({
-        value: input.value([]),
+        from: 'thing',
+        else: input.value([]),
       }),
     ],
 
@@ -323,6 +263,12 @@ export class Artwork extends Thing {
 
     // Expose only
 
+    isArtwork: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     referencedByArtworks: reverseReferenceList({
       reverse: soupyReverse.input('artworksWhichReference'),
     }),
@@ -361,16 +307,81 @@ export class Artwork extends Thing {
     ],
 
     attachedArtwork: [
-      withAttachedArtwork(),
+      exitWithoutDependency({
+        dependency: 'attachAbove',
+        mode: input.value('falsy'),
+      }),
+
+      withContainingArtworkList(),
+
+      withPropertyFromList({
+        list: '#containingArtworkList',
+        property: input.value('attachAbove'),
+      }),
+
+      flipFilter({
+        filter: '#containingArtworkList.attachAbove',
+      }).outputs({
+        '#containingArtworkList.attachAbove': '#filterNotAttached',
+      }),
+
+      withNearbyItemFromList({
+        list: '#containingArtworkList',
+        item: input.myself(),
+        offset: input.value(-1),
+        filter: '#filterNotAttached',
+      }),
 
       exposeDependency({
-        dependency: '#attachedArtwork',
+        dependency: '#nearbyItem',
       }),
     ],
 
     attachingArtworks: reverseReferenceList({
       reverse: soupyReverse.input('artworksWhichAttach'),
     }),
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+
+    contentWarningArtTags: [
+      withPropertyFromList({
+        list: 'artTags',
+        property: input.value('isContentWarning'),
+      }),
+
+      withFilteredList({
+        list: 'artTags',
+        filter: '#artTags.isContentWarning',
+      }),
+
+      exposeDependency({
+        dependency: '#filteredList',
+      }),
+    ],
+
+    contentWarnings: [
+      withPropertyFromList({
+        list: 'contentWarningArtTags',
+        property: input.value('name'),
+      }),
+
+      exposeDependency({
+        dependency: '#contentWarningArtTags.name',
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -385,6 +396,8 @@ export class Artwork extends Thing {
 
       'Label': {property: 'label'},
       'Source': {property: 'source'},
+      'Origin Details': {property: 'originDetails'},
+      'Show Filename': {property: 'showFilename'},
 
       'Date': {
         property: 'date',
@@ -398,6 +411,8 @@ export class Artwork extends Thing {
         transform: parseContributors,
       },
 
+      'Style': {property: 'style'},
+
       'Tags': {property: 'artTags'},
 
       'Referenced Artworks': {
@@ -456,6 +471,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..8a255ac3 100644
--- a/src/data/things/content.js
+++ b/src/data/things/content.js
@@ -1,10 +1,13 @@
 import {input} from '#composite';
-import find from '#find';
+import {transposeArrays} from '#sugar';
 import Thing from '#thing';
-import {is, isDate} from '#validators';
+import {is, isDate, validateReferenceList} from '#validators';
 import {parseDate} from '#yaml';
 
-import {contentString, referenceList, simpleDate, soupyFind, thing}
+import {withFilteredList, withMappedList, withPropertyFromList}
+  from '#composite/data';
+import {withResolvedReferenceList} from '#composite/wiki-data';
+import {contentString, simpleDate, soupyFind, thing}
   from '#composite/wiki-properties';
 
 import {
@@ -17,22 +20,39 @@ import {
 } from '#composite/control-flow';
 
 import {
-  contentArtists,
   hasAnnotationPart,
-  withAnnotationParts,
-  withHasAnnotationPart,
-  withSourceText,
-  withSourceURLs,
+  withAnnotationPartNodeLists,
+  withExpressedOrImplicitArtistReferences,
   withWebArchiveDate,
 } from '#composite/things/content';
 
 export class ContentEntry extends Thing {
-  static [Thing.getPropertyDescriptors] = ({Artist}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     thing: thing(),
 
-    artists: contentArtists(),
+    artists: [
+      withExpressedOrImplicitArtistReferences({
+        from: input.updateValue({
+          validate: validateReferenceList('artist'),
+        }),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#artistReferences',
+        value: input.value([]),
+      }),
+
+      withResolvedReferenceList({
+        list: '#artistReferences',
+        find: soupyFind.input('artist'),
+      }),
+
+      exposeDependency({
+        dependency: '#resolvedReferenceList',
+      }),
+    ],
 
     artistText: contentString(),
 
@@ -51,6 +71,10 @@ export class ContentEntry extends Thing {
     },
 
     accessKind: [
+      exitWithoutDependency({
+        dependency: '_accessDate',
+      }),
+
       exposeUpdateValueOrContinue({
         validate: input.value(
           is(...[
@@ -74,7 +98,7 @@ export class ContentEntry extends Thing {
       },
 
       exposeConstant({
-        value: input.value(null),
+        value: input.value('accessed'),
       }),
     ],
 
@@ -106,22 +130,133 @@ export class ContentEntry extends Thing {
 
     // Expose only
 
+    isContentEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     annotationParts: [
-      withAnnotationParts({
-        mode: input.value('strings'),
+      withAnnotationPartNodeLists(),
+
+      {
+        dependencies: ['#annotationPartNodeLists'],
+        compute: (continuation, {
+          ['#annotationPartNodeLists']: nodeLists,
+        }) => continuation({
+          ['#firstNodes']:
+            nodeLists.map(list => list.at(0)),
+
+          ['#lastNodes']:
+            nodeLists.map(list => list.at(-1)),
+        }),
+      },
+
+      withPropertyFromList({
+        list: '#firstNodes',
+        property: input.value('i'),
+      }).outputs({
+        '#firstNodes.i': '#startIndices',
       }),
 
-      exposeDependency({dependency: '#annotationParts'}),
+      withPropertyFromList({
+        list: '#lastNodes',
+        property: input.value('iEnd'),
+      }).outputs({
+        '#lastNodes.iEnd': '#endIndices',
+      }),
+
+      {
+        dependencies: [
+          'annotation',
+          '#startIndices',
+          '#endIndices',
+        ],
+
+        compute: ({
+          ['annotation']: annotation,
+          ['#startIndices']: startIndices,
+          ['#endIndices']: endIndices,
+        }) =>
+          transposeArrays([startIndices, endIndices])
+            .map(([start, end]) =>
+              annotation.slice(start, end)),
+      },
     ],
 
     sourceText: [
-      withSourceText(),
-      exposeDependency({dependency: '#sourceText'}),
+      withAnnotationPartNodeLists(),
+
+      {
+        dependencies: ['#annotationPartNodeLists'],
+        compute: (continuation, {
+          ['#annotationPartNodeLists']: nodeLists,
+        }) => continuation({
+          ['#firstPartWithExternalLink']:
+            nodeLists
+              .find(nodes => nodes
+                .some(node => node.type === 'external-link')) ??
+            null,
+        }),
+      },
+
+      exitWithoutDependency({
+        dependency: '#firstPartWithExternalLink',
+      }),
+
+      {
+        dependencies: ['annotation', '#firstPartWithExternalLink'],
+        compute: ({
+          ['annotation']: annotation,
+          ['#firstPartWithExternalLink']: nodes,
+        }) =>
+          annotation.slice(
+            nodes.at(0).i,
+            nodes.at(-1).iEnd),
+      },
     ],
 
     sourceURLs: [
-      withSourceURLs(),
-      exposeDependency({dependency: '#sourceURLs'}),
+      withAnnotationPartNodeLists(),
+
+      {
+        dependencies: ['#annotationPartNodeLists'],
+        compute: (continuation, {
+          ['#annotationPartNodeLists']: nodeLists,
+        }) => continuation({
+          ['#firstPartWithExternalLink']:
+            nodeLists
+              .find(nodes => nodes
+                .some(node => node.type === 'external-link')) ??
+            null,
+        }),
+      },
+
+      exitWithoutDependency({
+        dependency: '#firstPartWithExternalLink',
+        value: input.value([]),
+      }),
+
+      withMappedList({
+        list: '#firstPartWithExternalLink',
+        map: input.value(node => node.type === 'external-link'),
+      }).outputs({
+        '#mappedList': '#externalLinkFilter',
+      }),
+
+      withFilteredList({
+        list: '#firstPartWithExternalLink',
+        filter: '#externalLinkFilter',
+      }),
+
+      withMappedList({
+        list: '#filteredList',
+        map: input.value(node => node.data.href),
+      }),
+
+      exposeDependency({
+        dependency: '#mappedList',
+      }),
     ],
   });
 
@@ -145,9 +280,17 @@ export class ContentEntry extends Thing {
 }
 
 export class CommentaryEntry extends ContentEntry {
+  static [Thing.wikiData] = 'commentaryData';
+
   static [Thing.getPropertyDescriptors] = () => ({
     // Expose only
 
+    isCommentaryEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     isWikiEditorCommentary: hasAnnotationPart({
       part: input.value('wiki editor'),
     }),
@@ -155,20 +298,32 @@ export class CommentaryEntry extends ContentEntry {
 }
 
 export class LyricsEntry extends ContentEntry {
+  static [Thing.wikiData] = 'lyricsData';
+
   static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    originDetails: contentString(),
+
     // Expose only
 
+    isLyricsEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     isWikiLyrics: hasAnnotationPart({
       part: input.value('wiki lyrics'),
     }),
 
-    hasSquareBracketAnnotations: [
-      withHasAnnotationPart({
-        part: input.value('wiki lyrics'),
-      }),
+    helpNeeded: hasAnnotationPart({
+      part: input.value('help needed'),
+    }),
 
+    hasSquareBracketAnnotations: [
       exitWithoutDependency({
-        dependency: '#hasAnnotationPart',
+        dependency: 'isWikiLyrics',
         mode: input.value('falsy'),
         value: input.value(false),
       }),
@@ -185,6 +340,38 @@ export class LyricsEntry extends ContentEntry {
       },
     ],
   });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, {
+    fields: {
+      'Origin Details': {property: 'originDetails'},
+    },
+  });
+}
+
+export class CreditingSourcesEntry extends ContentEntry {
+  static [Thing.wikiData] = 'creditingSourceData';
+
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isCreditingSourcesEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+  });
 }
 
-export class CreditingSourcesEntry extends ContentEntry {}
+export class ReferencingSourcesEntry extends ContentEntry {
+  static [Thing.wikiData] = 'referencingSourceData';
+
+  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..41b57b7b 100644
--- a/src/data/things/contribution.js
+++ b/src/data/things/contribution.js
@@ -5,10 +5,19 @@ 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 {simpleDate, singleReference, soupyFind}
+  from '#composite/wiki-properties';
+
+import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
 
 import {
   withFilteredList,
@@ -19,12 +28,8 @@ import {
 
 import {
   inheritFromContributionPresets,
-  thingPropertyMatches,
-  thingReferenceTypeMatches,
   withContainingReverseContributionList,
-  withContributionArtist,
   withContributionContext,
-  withMatchingContributionPresets,
 } from '#composite/things/contribution';
 
 export class Contribution extends Thing {
@@ -48,17 +53,9 @@ export class Contribution extends Thing {
 
     date: simpleDate(),
 
-    artist: [
-      withContributionArtist({
-        ref: input.updateValue({
-          validate: validateReference('artist'),
-        }),
-      }),
-
-      exposeDependency({
-        dependency: '#artist',
-      }),
-    ],
+    artist: singleReference({
+      find: soupyFind.input('artist'),
+    }),
 
     annotation: {
       flags: {update: true, expose: true},
@@ -66,19 +63,64 @@ export class Contribution extends Thing {
     },
 
     countInContributionTotals: [
-      inheritFromContributionPresets({
-        property: input.thisProperty(),
+      inheritFromContributionPresets(),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
       }),
 
-      flag(true),
+      {
+        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: [
-      inheritFromContributionPresets({
-        property: input.thisProperty(),
+      inheritFromContributionPresets(),
+
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('duration'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#thing.duration',
+        mode: input.value('falsy'),
+        value: input.value(false),
       }),
 
-      flag(true),
+      {
+        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 +129,12 @@ export class Contribution extends Thing {
 
     // Expose only
 
+    isContribution: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     context: [
       withContributionContext(),
 
@@ -107,11 +155,58 @@ export class Contribution extends Thing {
     ],
 
     matchingPresets: [
-      withMatchingContributionPresets(),
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('wikiInfo'),
+        internal: input.value(true),
+      }),
 
-      exposeDependency({
-        dependency: '#matchingContributionPresets',
+      exitWithoutDependency({
+        dependency: '#thing.wikiInfo',
+        value: input.value([]),
       }),
+
+      withPropertyFromObject({
+        object: '#thing.wikiInfo',
+        property: input.value('contributionPresets'),
+      }).outputs({
+        '#thing.wikiInfo.contributionPresets': '#contributionPresets',
+      }),
+
+      exitWithoutDependency({
+        dependency: '#contributionPresets',
+        mode: input.value('empty'),
+        value: input.value([]),
+      }),
+
+      withContributionContext(),
+
+      {
+        dependencies: [
+          '#contributionPresets',
+          '#contributionTarget',
+          '#contributionProperty',
+          'annotation',
+        ],
+
+        compute: (continuation, {
+          ['#contributionPresets']: presets,
+          ['#contributionTarget']: target,
+          ['#contributionProperty']: property,
+          ['annotation']: annotation,
+        }) => continuation({
+          ['#matchingContributionPresets']:
+            presets
+              .filter(preset =>
+                preset.context[0] === target &&
+                preset.context.slice(1).includes(property) &&
+                // For now, only match if the annotation is a complete match.
+                // Partial matches (e.g. because the contribution includes "two"
+                // annotations, separated by commas) don't count.
+                preset.annotation === annotation),
+        })
+      },
+
     ],
 
     // All the contributions from the list which includes this contribution.
@@ -167,38 +262,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 +301,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 +337,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..efa99f36 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -1,7 +1,6 @@
 export const FLASH_DATA_FILE = 'flashes.yaml';
 
 import {input} from '#composite';
-import {empty} from '#sugar';
 import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
 import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
@@ -43,25 +42,30 @@ import {
   thing,
   thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
-import {withFlashAct} from '#composite/things/flash';
-import {withFlashSide} from '#composite/things/flash-act';
-
 export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
+  static [Thing.wikiData] = 'flashData';
+
+  static [Thing.constitutibleProperties] = [
+    'coverArtwork', // from inline fields
+  ];
 
   static [Thing.getPropertyDescriptors] = ({
     AdditionalName,
     CommentaryEntry,
     CreditingSourcesEntry,
-    Track,
     FlashAct,
+    Track,
     WikiInfo,
   }) => ({
     // Update & expose
 
+    act: thing({
+      class: input.value(FlashAct),
+    }),
+
     name: name('Unnamed Flash'),
 
     directory: {
@@ -95,14 +99,12 @@ export class Flash extends Thing {
         validate: input.value(isColor),
       }),
 
-      withFlashAct(),
-
       withPropertyFromObject({
-        object: '#flashAct',
+        object: 'act',
         property: input.value('color'),
       }),
 
-      exposeDependency({dependency: '#flashAct.color'}),
+      exposeDependency({dependency: '#act.color'}),
     ],
 
     date: simpleDate(),
@@ -135,7 +137,7 @@ export class Flash extends Thing {
       class: input.value(CommentaryEntry),
     }),
 
-    creditSources: thingList({
+    creditingSources: thingList({
       class: input.value(CreditingSourcesEntry),
     }),
 
@@ -151,22 +153,21 @@ export class Flash extends Thing {
 
     // Expose only
 
-    commentatorArtists: commentatorArtists(),
-
-    act: [
-      withFlashAct(),
-      exposeDependency({dependency: '#flashAct'}),
+    isFlash: [
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
-    side: [
-      withFlashAct(),
+    commentatorArtists: commentatorArtists(),
 
+    side: [
       withPropertyFromObject({
-        object: '#flashAct',
+        object: 'act',
         property: input.value('side'),
       }),
 
-      exposeDependency({dependency: '#flashAct.side'}),
+      exposeDependency({dependency: '#act.side'}),
     ],
   });
 
@@ -257,8 +258,8 @@ export class Flash extends Thing {
         transform: parseCommentary,
       },
 
-      'Credit Sources': {
-        property: 'creditSources',
+      'Crediting Sources': {
+        property: 'creditingSources',
         transform: parseCreditingSources,
       },
 
@@ -278,10 +279,15 @@ export class Flash extends Thing {
 export class FlashAct extends Thing {
   static [Thing.referenceType] = 'flash-act';
   static [Thing.friendlyName] = `Flash Act`;
+  static [Thing.wikiData] = 'flashActData';
 
-  static [Thing.getPropertyDescriptors] = () => ({
+  static [Thing.getPropertyDescriptors] = ({Flash, FlashSide}) => ({
     // Update & expose
 
+    side: thing({
+      class: input.value(FlashSide),
+    }),
+
     name: name('Unnamed Flash Act'),
     directory: directory(),
     color: color(),
@@ -291,15 +297,13 @@ export class FlashAct extends Thing {
         validate: input.value(isContentString),
       }),
 
-      withFlashSide(),
-
       withPropertyFromObject({
-        object: '#flashSide',
+        object: 'side',
         property: input.value('listTerminology'),
       }),
 
       exposeDependencyOrContinue({
-        dependency: '#flashSide.listTerminology',
+        dependency: '#side.listTerminology',
       }),
 
       exposeConstant({
@@ -307,9 +311,8 @@ export class FlashAct extends Thing {
       }),
     ],
 
-    flashes: referenceList({
+    flashes: thingList({
       class: input.value(Flash),
-      find: soupyFind.input('flash'),
     }),
 
     // Update only
@@ -319,9 +322,10 @@ export class FlashAct extends Thing {
 
     // Expose only
 
-    side: [
-      withFlashSide(),
-      exposeDependency({dependency: '#flashSide'}),
+    isFlashAct: [
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
   });
 
@@ -357,8 +361,9 @@ export class FlashAct extends Thing {
 export class FlashSide extends Thing {
   static [Thing.referenceType] = 'flash-side';
   static [Thing.friendlyName] = `Flash Side`;
+  static [Thing.wikiData] = 'flashSideData';
 
-  static [Thing.getPropertyDescriptors] = () => ({
+  static [Thing.getPropertyDescriptors] = ({FlashAct}) => ({
     // Update & expose
 
     name: name('Unnamed Flash Side'),
@@ -366,14 +371,21 @@ export class FlashSide extends Thing {
     color: color(),
     listTerminology: contentString(),
 
-    acts: referenceList({
+    acts: thingList({
       class: input.value(FlashAct),
-      find: soupyFind.input('flashAct'),
     }),
 
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    isFlashSide: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -416,62 +428,61 @@ export class FlashSide extends Thing {
         ? FlashAct
         : Flash),
 
-    save(results) {
-      // JavaScript likes you.
+    connect(results) {
+      let thing, i;
 
-      if (!empty(results) && !(results[0] instanceof FlashSide)) {
-        throw new Error(`Expected a side at top of flash data file`);
-      }
+      for (i = 0; thing = results[i]; i++) {
+        if (thing.isFlashSide) {
+          const side = thing;
+          const acts = [];
 
-      let index = 0;
-      let thing;
-      for (; thing = results[index]; index++) {
-        const flashSide = thing;
-        const flashActRefs = [];
+          for (i++; thing = results[i]; i++) {
+            if (thing.isFlashAct) {
+              const act = thing;
+              const flashes = [];
 
-        if (results[index + 1] instanceof Flash) {
-          throw new Error(`Expected an act to immediately follow a side`);
-        }
+              for (i++; thing = results[i]; i++) {
+                if (thing.isFlash) {
+                  const flash = thing;
+
+                  flash.act = act;
+                  flashes.push(flash);
+
+                  continue;
+                }
 
-        for (
-          index++;
-          (thing = results[index]) && thing instanceof FlashAct;
-          index++
-        ) {
-          const flashAct = thing;
-          const flashRefs = [];
-          for (
-            index++;
-            (thing = results[index]) && thing instanceof Flash;
-            index++
-          ) {
-            flashRefs.push(Thing.getReference(thing));
+                i--;
+                break;
+              }
+
+              act.side = side;
+              act.flashes = flashes;
+              acts.push(act);
+
+              continue;
+            }
+
+            if (thing.isFlash) {
+              throw new Error(`Flashes must be under an act`);
+            }
+
+            i--;
+            break;
           }
-          index--;
-          flashAct.flashes = flashRefs;
-          flashActRefs.push(Thing.getReference(flashAct));
-        }
-        index--;
-        flashSide.acts = flashActRefs;
-      }
 
-      const flashData = results.filter(x => x instanceof Flash);
-      const flashActData = results.filter(x => x instanceof FlashAct);
-      const flashSideData = results.filter(x => x instanceof FlashSide);
+          side.acts = acts;
 
-      const artworkData = flashData.map(flash => flash.coverArtwork);
-      const commentaryData = flashData.flatMap(flash => flash.commentary);
-      const creditingSourceData = flashData.flatMap(flash => flash.creditSources);
+          continue;
+        }
 
-      return {
-        flashData,
-        flashActData,
-        flashSideData,
+        if (thing.isFlashAct) {
+          throw new Error(`Acts must be under a side`);
+        }
 
-        artworkData,
-        commentaryData,
-        creditingSourceData,
-      };
+        if (thing.isFlash) {
+          throw new Error(`Flashes must be under a side and act`);
+        }
+      }
     },
 
     sort({flashData}) {
diff --git a/src/data/things/group.js b/src/data/things/group.js
index 4b4c306c..076f0c8f 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -5,25 +5,36 @@ import {inspect} from 'node:util';
 import {colors} from '#cli';
 import {input} from '#composite';
 import Thing from '#thing';
-import {is} from '#validators';
+import {is, isBoolean} from '#validators';
 import {parseAnnotatedReferences, parseSerieses} from '#yaml';
 
+import {withPropertyFromObject} from '#composite/data';
+import {withUniqueReferencingThing} from '#composite/wiki-data';
+
+import {
+  exposeConstant,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
 import {
   annotatedReferenceList,
   color,
   contentString,
   directory,
+  flag,
   name,
   referenceList,
   soupyFind,
+  soupyReverse,
   thing,
   thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
+  static [Thing.wikiData] = 'groupData';
 
   static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({
     // Update & expose
@@ -31,6 +42,33 @@ export class Group extends Thing {
     name: name('Unnamed Group'),
     directory: directory(),
 
+    excludeFromGalleryTabs: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withUniqueReferencingThing({
+        reverse: soupyReverse.input('groupCategoriesWhichInclude'),
+      }).outputs({
+        '#uniqueReferencingThing': '#category',
+      }),
+
+      withPropertyFromObject({
+        object: '#category',
+        property: input.value('excludeGroupsFromGalleryTabs'),
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#category.excludeGroupsFromGalleryTabs',
+      }),
+
+      exposeConstant({
+        value: input.value(false),
+      }),
+    ],
+
+    divideAlbumsByStyle: flag(false),
+
     description: contentString(),
 
     urls: urls(),
@@ -55,10 +93,16 @@ export class Group extends Thing {
     // Update only
 
     find: soupyFind(),
-    reverse: soupyFind(),
+    reverse: soupyReverse(),
 
     // Expose only
 
+    isGroup: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     descriptionShort: {
       flags: {expose: true},
 
@@ -75,8 +119,8 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['this', 'reverse'],
-        compute: ({this: group, reverse}) =>
+        dependencies: ['this', '_reverse'],
+        compute: ({this: group, _reverse: reverse}) =>
           reverse.albumsWhoseGroupsInclude(group),
       },
     },
@@ -85,8 +129,8 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['this', 'reverse'],
-        compute: ({this: group, reverse}) =>
+        dependencies: ['this', '_reverse'],
+        compute: ({this: group, _reverse: reverse}) =>
           reverse.groupCategoriesWhichInclude(group, {unique: true})
             ?.color,
       },
@@ -96,8 +140,8 @@ export class Group extends Thing {
       flags: {expose: true},
 
       expose: {
-        dependencies: ['this', 'reverse'],
-        compute: ({this: group, reverse}) =>
+        dependencies: ['this', '_reverse'],
+        compute: ({this: group, _reverse: reverse}) =>
           reverse.groupCategoriesWhichInclude(group, {unique: true}) ??
           null,
       },
@@ -134,6 +178,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'},
 
@@ -170,7 +218,7 @@ export class Group extends Thing {
         ? GroupCategory
         : Group),
 
-    save(results) {
+    connect(results) {
       let groupCategory;
       let groupRefs = [];
 
@@ -194,12 +242,6 @@ export class Group extends Thing {
       if (groupCategory) {
         Object.assign(groupCategory, {groups: groupRefs});
       }
-
-      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, seriesData};
     },
 
     // Groups aren't sorted at all, always preserving the order in the data
@@ -211,6 +253,7 @@ export class Group extends Thing {
 export class GroupCategory extends Thing {
   static [Thing.referenceType] = 'group-category';
   static [Thing.friendlyName] = `Group Category`;
+  static [Thing.wikiData] = 'groupCategoryData';
 
   static [Thing.getPropertyDescriptors] = ({Group}) => ({
     // Update & expose
@@ -218,6 +261,8 @@ export class GroupCategory extends Thing {
     name: name('Unnamed Group Category'),
     directory: directory(),
 
+    excludeGroupsFromGalleryTabs: flag(false),
+
     color: color(),
 
     groups: referenceList({
@@ -228,6 +273,14 @@ export class GroupCategory extends Thing {
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    isGroupCategory: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.reverseSpecs] = {
@@ -242,12 +295,19 @@ export class GroupCategory extends Thing {
   static [Thing.yamlDocumentSpec] = {
     fields: {
       'Category': {property: 'name'},
+
       'Color': {property: 'color'},
+
+      'Exclude Groups From Gallery Tabs': {
+        property: 'excludeGroupsFromGalleryTabs',
+      },
     },
   };
 }
 
 export class Series extends Thing {
+  static [Thing.wikiData] = 'seriesData';
+
   static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
     // Update & expose
 
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index 82bad2d3..e1b29362 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 {
@@ -32,6 +32,8 @@ import {
 
 export class HomepageLayout extends Thing {
   static [Thing.friendlyName] = `Homepage Layout`;
+  static [Thing.wikiData] = 'homepageLayout';
+  static [Thing.oneInstancePerWiki] = true;
 
   static [Thing.getPropertyDescriptors] = ({HomepageLayoutSection}) => ({
     // Update & expose
@@ -47,6 +49,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 +73,6 @@ export class HomepageLayout extends Thing {
     thingConstructors: {
       HomepageLayout,
       HomepageLayoutSection,
-      HomepageLayoutAlbumsRow,
     },
   }) => ({
     title: `Process homepage layout file`,
@@ -95,7 +104,7 @@ export class HomepageLayout extends Thing {
       return null;
     },
 
-    save(results) {
+    connect(results) {
       if (!empty(results) && !(results[0] instanceof HomepageLayout)) {
         throw new Error(`Expected 'Homepage' document at top of homepage layout file`);
       }
@@ -138,8 +147,6 @@ export class HomepageLayout extends Thing {
       closeCurrentSection();
 
       homepageLayout.sections = sections;
-
-      return {homepageLayout};
     },
   });
 }
@@ -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},
 
@@ -222,9 +243,7 @@ export class HomepageLayoutRow extends Thing {
 export class HomepageLayoutActionsRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Actions Row`;
 
-  static [Thing.getPropertyDescriptors] = (opts) => ({
-    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
-
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     actionLinks: {
@@ -234,25 +253,29 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow {
 
     // Expose only
 
+    isHomepageLayoutActionsRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
       expose: {compute: () => 'actions'},
     },
   });
 
-  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+  static [Thing.yamlDocumentSpec] = {
     fields: {
       'Actions': {property: 'actionLinks'},
     },
-  });
+  };
 }
 
 export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Album Carousel Row`;
 
-  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
-    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
-
+  static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({
     // Update & expose
 
     albums: referenceList({
@@ -262,25 +285,29 @@ export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
 
     // Expose only
 
+    isHomepageLayoutAlbumCarouselRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
       expose: {compute: () => 'album carousel'},
     },
   });
 
-  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+  static [Thing.yamlDocumentSpec] = {
     fields: {
       'Albums': {property: 'albums'},
     },
-  });
+  };
 }
 
 export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Album Grid Row`;
 
   static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
-    ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
-
     // Update & expose
 
     sourceGroup: [
@@ -322,17 +349,23 @@ export class HomepageLayoutAlbumGridRow extends HomepageLayoutRow {
 
     // Expose only
 
+    isHomepageLayoutAlbumGridRow: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     type: {
       flags: {expose: true},
       expose: {compute: () => 'album grid'},
     },
   });
 
-  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(HomepageLayoutRow, {
+  static [Thing.yamlDocumentSpec] = {
     fields: {
       'Group': {property: 'sourceGroup'},
       'Count': {property: 'countAlbumsFromGroup'},
       'Albums': {property: 'sourceAlbums'},
     },
-  });
+  };
 }
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 11307b50..09765fd2 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -6,7 +6,7 @@ import CacheableObject from '#cacheable-object';
 import {logError} from '#cli';
 import {compositeFrom} from '#composite';
 import * as serialize from '#serialize';
-import {withEntries} from '#sugar';
+import {empty} from '#sugar';
 import Thing from '#thing';
 
 import * as additionalFileClasses from './additional-file.js';
@@ -58,6 +58,7 @@ const __dirname = path.dirname(
 function niceShowAggregate(error, ...opts) {
   showAggregate(error, {
     pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)),
+    showClasses: false,
     ...opts,
   });
 }
@@ -90,25 +91,39 @@ function errorDuplicateClassNames() {
 }
 
 function flattenClassLists() {
-  let allClassesUnsorted = Object.create(null);
-
+  let remaining = [];
   for (const classes of Object.values(allClassLists)) {
-    for (const [name, constructor] of Object.entries(classes)) {
+    for (const constructor of Object.values(classes)) {
       if (typeof constructor !== 'function') continue;
       if (!(constructor.prototype instanceof Thing)) continue;
-      allClassesUnsorted[name] = constructor;
+      remaining.push(constructor);
+    }
+  }
+
+  let sorted = [];
+  while (true) {
+    if (sorted[0]) {
+      const superclass = Object.getPrototypeOf(sorted[0]);
+      if (superclass !== Thing) {
+        if (sorted.includes(superclass)) {
+          sorted.unshift(...sorted.splice(sorted.indexOf(superclass), 1));
+        } else {
+          sorted.unshift(superclass);
+        }
+        continue;
+      }
+    }
+
+    if (!empty(remaining)) {
+      sorted.unshift(remaining.shift());
+    } else {
+      break;
     }
   }
 
-  // Sort subclasses after their superclasses.
-  Object.assign(allClasses,
-    withEntries(allClassesUnsorted, entries =>
-      entries.sort(({[1]: A}, {[1]: B}) =>
-        (A.prototype instanceof B
-          ? +1
-       : B.prototype instanceof A
-          ? -1
-          :  0))));
+  for (const constructor of sorted) {
+    allClasses[constructor.name] = constructor;
+  }
 }
 
 function descriptorAggregateHelper({
@@ -138,6 +153,15 @@ function descriptorAggregateHelper({
   } catch (error) {
     niceShowAggregate(error);
     showFailedClasses(failedClasses);
+
+    /*
+    if (error.errors) {
+      for (const sub of error.errors) {
+        console.error(sub);
+      }
+    }
+    */
+
     return false;
   }
 }
@@ -167,10 +191,10 @@ function evaluatePropertyDescriptors() {
         }
       }
 
-      constructor[CacheableObject.propertyDescriptors] = {
-        ...constructor[CacheableObject.propertyDescriptors] ?? {},
-        ...results,
-      };
+      constructor[CacheableObject.propertyDescriptors] =
+        Object.create(constructor[CacheableObject.propertyDescriptors] ?? null);
+
+      Object.assign(constructor[CacheableObject.propertyDescriptors], results);
     },
 
     showFailedClasses(failedClasses) {
@@ -200,6 +224,27 @@ function evaluateSerializeDescriptors() {
   });
 }
 
+function finalizeYamlDocumentSpecs() {
+  return descriptorAggregateHelper({
+    message: `Errors finalizing Thing YAML document specs`,
+
+    op(constructor) {
+      const superclass = Object.getPrototypeOf(constructor);
+      if (
+        constructor[Thing.yamlDocumentSpec] &&
+        superclass[Thing.yamlDocumentSpec]
+      ) {
+        constructor[Thing.yamlDocumentSpec] =
+          Thing.extendDocumentSpec(superclass, constructor[Thing.yamlDocumentSpec]);
+      }
+    },
+
+    showFailedClasses(failedClasses) {
+      logError`Failed to finalize YAML document specs for classes: ${failedClasses.join(', ')}`;
+    },
+  });
+}
+
 function finalizeCacheableObjectPrototypes() {
   return descriptorAggregateHelper({
     message: `Errors finalizing Thing class prototypes`,
@@ -214,19 +259,14 @@ function finalizeCacheableObjectPrototypes() {
   });
 }
 
-if (!errorDuplicateClassNames())
-  process.exit(1);
+if (!errorDuplicateClassNames()) process.exit(1);
 
 flattenClassLists();
 
-if (!evaluatePropertyDescriptors())
-  process.exit(1);
-
-if (!evaluateSerializeDescriptors())
-  process.exit(1);
-
-if (!finalizeCacheableObjectPrototypes())
-  process.exit(1);
+if (!evaluatePropertyDescriptors()) process.exit(1);
+if (!evaluateSerializeDescriptors()) process.exit(1);
+if (!finalizeYamlDocumentSpecs()) process.exit(1);
+if (!finalizeCacheableObjectPrototypes()) process.exit(1);
 
 Object.assign(allClasses, {Thing});
 
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 4e23cf7f..afda258c 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,24 +1,24 @@
-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';
+import {accumulateSum, empty, withEntries} from '#sugar';
+import {isLanguageCode, isObject} from '#validators';
 import Thing from '#thing';
+import {languageOptionRegex} from '#wiki-data';
 
 import {
+  externalLinkSpec,
   getExternalLinkStringOfStyleFromDescriptors,
   getExternalLinkStringsFromDescriptors,
   isExternalLinkContext,
-  isExternalLinkSpec,
   isExternalLinkStyle,
 } from '#external-links';
 
-import {externalFunction, flag, name} from '#composite/wiki-properties';
-
-export const languageOptionRegex = /{(?<name>[A-Z0-9_]+)}/g;
+import {exitWithoutDependency, exposeConstant, exposeDependency}
+  from '#composite/control-flow';
+import {flag, name} from '#composite/wiki-properties';
 
 export class Language extends Thing {
   static [Thing.getPropertyDescriptors] = () => ({
@@ -60,16 +60,25 @@ export class Language extends Thing {
 
     // Mapping of translation keys to values (strings). Generally, don't
     // access this object directly - use methods instead.
-    strings: {
-      flags: {update: true, expose: true},
-      update: {validate: (t) => typeof t === 'object'},
+    strings: [
+      {
+        dependencies: [
+          input.updateValue({validate: isObject}),
+          'inheritedStrings',
+        ],
+
+        compute: (continuation, {
+          [input.updateValue()]: strings,
+          ['inheritedStrings']: inheritedStrings,
+        }) =>
+          (strings && inheritedStrings
+            ? continuation()
+            : strings ?? inheritedStrings),
+      },
 
-      expose: {
+      {
         dependencies: ['inheritedStrings', 'code'],
         transform(strings, {inheritedStrings, code}) {
-          if (!strings && !inheritedStrings) return null;
-          if (!inheritedStrings) return strings;
-
           const validStrings = {
             ...inheritedStrings,
             ...strings,
@@ -98,6 +107,7 @@ export class Language extends Thing {
                 logWarn`- Missing options: ${missingOptionNames.join(', ')}`;
               if (!empty(misplacedOptionNames))
                 logWarn`- Unexpected options: ${misplacedOptionNames.join(', ')}`;
+
               validStrings[key] = inheritedStrings[key];
             }
           }
@@ -105,7 +115,7 @@ export class Language extends Thing {
           return validStrings;
         },
       },
-    },
+    ],
 
     // May be provided to specify "default" strings, generally (but not
     // necessarily) inherited from another Language object.
@@ -114,19 +124,14 @@ export class Language extends Thing {
       update: {validate: (t) => typeof t === 'object'},
     },
 
-    // List of descriptors for providing to external link utilities when using
-    // language.formatExternalLink - refer to #external-links for info.
-    externalLinkSpec: {
-      flags: {update: true, expose: true},
-      update: {validate: isExternalLinkSpec},
-    },
-
-    // Update only
-
-    escapeHTML: externalFunction(),
-
     // Expose only
 
+    isLanguage: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     onlyIfOptions: {
       flags: {expose: true},
       expose: {
@@ -136,12 +141,14 @@ export class Language extends Thing {
 
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
     intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}),
+    intl_dateMonthDay: this.#intlHelper(Intl.DateTimeFormat, {month: 'numeric', day: 'numeric'}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
     intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}),
     intl_listUnit: this.#intlHelper(Intl.ListFormat, {type: 'unit'}),
     intl_pluralCardinal: this.#intlHelper(Intl.PluralRules, {type: 'cardinal'}),
     intl_pluralOrdinal: this.#intlHelper(Intl.PluralRules, {type: 'ordinal'}),
+    intl_wordSegmenter: this.#intlHelper(Intl.Segmenter, {granularity: 'word'}),
 
     validKeys: {
       flags: {expose: true},
@@ -159,19 +166,18 @@ export class Language extends Thing {
     },
 
     // TODO: This currently isn't used. Is it still needed?
-    strings_htmlEscaped: {
-      flags: {expose: true},
-      expose: {
-        dependencies: ['strings', 'inheritedStrings', 'escapeHTML'],
-        compute({strings, inheritedStrings, escapeHTML}) {
-          if (!(strings || inheritedStrings) || !escapeHTML) return null;
-          const allStrings = {...inheritedStrings, ...strings};
-          return Object.fromEntries(
-            Object.entries(allStrings).map(([k, v]) => [k, escapeHTML(v)])
-          );
-        },
+    strings_htmlEscaped: [
+      exitWithoutDependency({
+        dependency: 'strings',
+      }),
+
+      {
+        dependencies: ['strings'],
+        compute: ({strings}) =>
+          withEntries(strings, entries => entries
+            .map(([key, value]) => [key, html.escape(value)])),
       },
-    },
+    ],
   });
 
   static #intlHelper (constructor, opts) {
@@ -192,18 +198,35 @@ export class Language extends Thing {
     return this.formatString(...args);
   }
 
+  $order(...args) {
+    return this.orderStringOptions(...args);
+  }
+
   assertIntlAvailable(property) {
     if (!this[property]) {
       throw new Error(`Intl API ${property} unavailable`);
     }
   }
 
+  countWords(text) {
+    this.assertIntlAvailable('intl_wordSegmenter');
+
+    const string = html.resolve(text, {normalize: 'plain'});
+    const segments = this.intl_wordSegmenter.segment(string);
+
+    return accumulateSum(segments, segment => segment.isWordLike ? 1 : 0);
+  }
+
   getUnitForm(value) {
     this.assertIntlAvailable('intl_pluralCardinal');
     return this.intl_pluralCardinal.select(value);
   }
 
   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;
@@ -211,19 +234,14 @@ export class Language extends Thing {
     const key =
       this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args);
 
+    const template =
+      this.#getStringTemplateFromFormedKey(key);
+
     const options =
       (hasOptions
         ? args.at(-1)
         : {});
 
-    if (!this.strings) {
-      throw new Error(`Strings unavailable`);
-    }
-
-    if (!this.validKeys.includes(key)) {
-      throw new Error(`Invalid key ${key} accessed`);
-    }
-
     const constantCasify = name =>
       name
         .replace(/[A-Z]/g, '_$&')
@@ -264,8 +282,7 @@ export class Language extends Thing {
         ]));
 
     const output = this.#iterateOverTemplate({
-      template: this.strings[key],
-
+      template,
       match: languageOptionRegex,
 
       insert: ({name: optionName}, canceledForming) => {
@@ -310,7 +327,7 @@ export class Language extends Thing {
           return undefined;
         }
 
-        return optionValue;
+        return this.sanitize(optionValue);
       },
     });
 
@@ -345,6 +362,46 @@ export class Language extends Thing {
     return output;
   }
 
+  orderStringOptions(...args) {
+    let slice = null, at = null, parts = null;
+    if (args.length >= 2 && typeof args.at(-1) === 'number') {
+      if (args.length >= 3 && typeof args.at(-2) === 'number') {
+        slice = [args.at(-2), args.at(-1)];
+        parts = args.slice(0, -2);
+      } else {
+        at = args.at(-1);
+        parts = args.slice(0, -1);
+      }
+    } else {
+      parts = args;
+    }
+
+    const template = this.getStringTemplate(...parts);
+    const matches = Array.from(template.matchAll(languageOptionRegex));
+    const options = matches.map(({groups}) => groups.name);
+
+    if (slice !== null) return options.slice(...slice);
+    if (at !== null) return options.at(at);
+    return options;
+  }
+
+  getStringTemplate(...args) {
+    const key = this.#joinKeyParts(args);
+    return this.#getStringTemplateFromFormedKey(key);
+  }
+
+  #getStringTemplateFromFormedKey(key) {
+    if (!this.strings) {
+      throw new Error(`Strings unavailable`);
+    }
+
+    if (!this.validKeys.includes(key)) {
+      throw new Error(`Invalid key ${key} accessed`);
+    }
+
+    return this.strings[key];
+  }
+
   #iterateOverTemplate({
     template,
     match: regexp,
@@ -375,26 +432,22 @@ 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);
+      const insertionItems = html.smush(insertion).content;
+      if (insertionItems.length === 1 && typeof insertionItems[0] !== 'string') {
+        // Push the insertion exactly as it is, rather than manipulating.
+        if (partInProgress) outputParts.push(partInProgress);
+        outputParts.push(insertion);
         partInProgress = '';
+      } else for (const insertionItem of insertionItems) {
+        if (typeof insertionItem === 'string') {
+          // Join consecutive strings together.
+          partInProgress += insertionItem;
+        } else {
+          // Push the string part in progress, then the insertion as-is.
+          if (partInProgress) outputParts.push(partInProgress);
+          outputParts.push(insertionItem);
+          partInProgress = '';
+        }
       }
 
       lastIndex = match.index + match[0].length;
@@ -426,14 +479,9 @@ export class Language extends Thing {
   // html.Tag objects - gets left as-is, preserving the value exactly as it's
   // provided.
   #sanitizeValueForInsertion(value) {
-    const escapeHTML = CacheableObject.getUpdateValue(this, 'escapeHTML');
-    if (!escapeHTML) {
-      throw new Error(`escapeHTML unavailable`);
-    }
-
     switch (typeof value) {
       case 'string':
-        return escapeHTML(value);
+        return html.escape(value);
 
       case 'number':
       case 'boolean':
@@ -510,6 +558,15 @@ export class Language extends Thing {
     return this.intl_dateYear.format(date);
   }
 
+  formatMonthDay(date) {
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_dateMonthDay');
+    return this.intl_dateMonthDay.format(date);
+  }
+
   formatYearRange(startDate, endDate) {
     // formatYearRange expects both values to be present, but if both are null
     // or both are undefined, that's just blank content.
@@ -688,10 +745,6 @@ export class Language extends Thing {
     style = 'platform',
     context = 'generic',
   } = {}) {
-    if (!this.externalLinkSpec) {
-      throw new TypeError(`externalLinkSpec unavailable`);
-    }
-
     // Null or undefined url is blank content.
     if (url === null || url === undefined) {
       return html.blank();
@@ -700,7 +753,7 @@ export class Language extends Thing {
     isExternalLinkContext(context);
 
     if (style === 'all') {
-      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+      return getExternalLinkStringsFromDescriptors(url, externalLinkSpec, {
         language: this,
         context,
       });
@@ -709,7 +762,7 @@ export class Language extends Thing {
     isExternalLinkStyle(style);
 
     const result =
-      getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+      getExternalLinkStringOfStyleFromDescriptors(url, style, externalLinkSpec, {
         language: this,
         context,
       });
@@ -865,6 +918,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) {
@@ -923,7 +988,6 @@ Object.assign(Language.prototype, {
   countArtworks: countHelper('artworks'),
   countCommentaryEntries: countHelper('commentaryEntries', 'entries'),
   countContributions: countHelper('contributions'),
-  countCoverArts: countHelper('coverArts'),
   countDays: countHelper('days'),
   countFlashes: countHelper('flashes'),
   countMonths: countHelper('months'),
diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js
index 43d1638e..e5467a46 100644
--- a/src/data/things/news-entry.js
+++ b/src/data/things/news-entry.js
@@ -1,15 +1,18 @@
 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';
 
 export class NewsEntry extends Thing {
   static [Thing.referenceType] = 'news-entry';
   static [Thing.friendlyName] = `News Entry`;
+  static [Thing.wikiData] = 'newsData';
 
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
@@ -22,6 +25,12 @@ export class NewsEntry extends Thing {
 
     // Expose only
 
+    isNewsEntry: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
+
     contentShort: {
       flags: {expose: true},
 
@@ -64,8 +73,6 @@ export class NewsEntry extends Thing {
     documentMode: allInOne,
     documentThing: NewsEntry,
 
-    save: (results) => ({newsData: results}),
-
     sort({newsData}) {
       sortChronologically(newsData, {latestFirst: true});
     },
diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js
index b169a541..e113955f 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) {
@@ -37,6 +38,7 @@ function isSelectFollowingEntry(value) {
 
 export class SortingRule extends Thing {
   static [Thing.friendlyName] = `Sorting Rule`;
+  static [Thing.wikiData] = 'sortingRules';
 
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
@@ -47,6 +49,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] = {
@@ -68,8 +78,6 @@ export class SortingRule extends Thing {
       (document['Sort Documents']
         ? DocumentSortingRule
         : null),
-
-    save: (results) => ({sortingRules: results}),
   });
 
   check(opts) {
@@ -119,6 +127,14 @@ export class ThingSortingRule extends SortingRule {
         validate: strictArrayOf(isStringNonEmpty),
       },
     },
+
+    // Expose only
+
+    isThingSortingRule: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(SortingRule, {
@@ -129,7 +145,7 @@ export class ThingSortingRule extends SortingRule {
 
   sort(sortable) {
     if (this.properties) {
-      for (const property of this.properties.slice().reverse()) {
+      for (const property of this.properties.toReversed()) {
         const get = thing => thing[property];
         const lc = property.toLowerCase();
 
@@ -218,6 +234,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, {
@@ -261,10 +285,8 @@ export class DocumentSortingRule extends ThingSortingRule {
   }
 
   static async* applyAll(rules, {wikiData, dataPath, dry}) {
-    rules =
-      rules
-        .slice()
-        .sort((a, b) => a.filename.localeCompare(b.filename, 'en'));
+    rules = rules
+      .toSorted((a, b) => a.filename.localeCompare(b.filename, 'en'));
 
     for (const {chunk, filename} of chunkByProperties(rules, ['filename'])) {
       const initialLayout = getThingLayoutForFilename(filename, wikiData);
diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js
index 52a09c31..617bc940 100644
--- a/src/data/things/static-page.js
+++ b/src/data/things/static-page.js
@@ -2,17 +2,20 @@ 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';
 
 export class StaticPage extends Thing {
   static [Thing.referenceType] = 'static';
   static [Thing.friendlyName] = `Static Page`;
+  static [Thing.wikiData] = 'staticPageData';
 
   static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
@@ -36,6 +39,14 @@ export class StaticPage extends Thing {
     content: contentString(),
 
     absoluteLinks: flag(),
+
+    // Expose only
+
+    isStaticPage: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
   });
 
   static [Thing.findSpecs] = {
@@ -76,8 +87,6 @@ export class StaticPage extends Thing {
     documentMode: onePerFile,
     documentThing: StaticPage,
 
-    save: (results) => ({staticPageData: results}),
-
     sort({staticPageData}) {
       sortAlphabetically(staticPageData);
     },
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 557ba2a7..39a1804f 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -3,9 +3,20 @@ import {inspect} from 'node:util';
 import CacheableObject from '#cacheable-object';
 import {colors} from '#cli';
 import {input} from '#composite';
+import {onlyItem} from '#sugar';
+import {sortByDate} from '#sort';
 import Thing from '#thing';
-import {isBoolean, isColor, isContributionList, isDate, isFileExtension}
-  from '#validators';
+import {getKebabCase} from '#wiki-data';
+
+import {
+  isBoolean,
+  isColor,
+  isContentString,
+  isContributionList,
+  isDate,
+  isFileExtension,
+  validateReference,
+} from '#validators';
 
 import {
   parseAdditionalFiles,
@@ -15,26 +26,41 @@ import {
   parseCommentary,
   parseContributors,
   parseCreditingSources,
+  parseReferencingSources,
   parseDate,
   parseDimensions,
   parseDuration,
   parseLyrics,
 } from '#yaml';
 
-import {withPropertyFromObject} from '#composite/data';
-
 import {
+  exitWithoutDependency,
+  exitWithoutUpdateValue,
   exposeConstant,
   exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
   exposeWhetherDependencyAvailable,
+  withAvailabilityFilter,
+  withResultOfAvailabilityCheck,
 } from '#composite/control-flow';
 
 import {
+  fillMissingListItems,
+  withFilteredList,
+  withFlattenedList,
+  withIndexInList,
+  withMappedList,
+  withPropertiesFromObject,
+  withPropertyFromList,
+  withPropertyFromObject,
+} from '#composite/data';
+
+import {
   withRecontextualizedContributionList,
   withRedatedContributionList,
   withResolvedContribs,
+  withResolvedReference,
 } from '#composite/wiki-data';
 
 import {
@@ -52,7 +78,6 @@ import {
   reverseReferenceList,
   simpleDate,
   simpleString,
-  singleReference,
   soupyFind,
   soupyReverse,
   thing,
@@ -62,26 +87,22 @@ import {
 } from '#composite/wiki-properties';
 
 import {
-  exitWithoutUniqueCoverArt,
   inheritContributionListFromMainRelease,
   inheritFromMainRelease,
-  withAllReleases,
-  withAlwaysReferenceByDirectory,
-  withContainingTrackSection,
-  withCoverArtistContribs,
-  withDate,
-  withDirectorySuffix,
-  withHasUniqueCoverArt,
-  withMainRelease,
-  withOtherReleases,
-  withPropertyFromAlbum,
-  withSuffixDirectoryFromAlbum,
-  withTrackArtDate,
-  withTrackNumber,
 } from '#composite/things/track';
 
 export class Track extends Thing {
   static [Thing.referenceType] = 'track';
+  static [Thing.wikiData] = 'trackData';
+
+  static [Thing.constitutibleProperties] = [
+    // Contributions currently aren't being observed for constitution.
+    // 'artistContribs', // from main release or album
+    // 'contributorContribs', // from main release
+    // 'coverArtistContribs', // from main release
+
+    'trackArtworks', // from inline fields
+  ];
 
   static [Thing.getPropertyDescriptors] = ({
     AdditionalFile,
@@ -91,264 +112,465 @@ export class Track extends Thing {
     Artwork,
     CommentaryEntry,
     CreditingSourcesEntry,
-    Flash,
     LyricsEntry,
+    ReferencingSourcesEntry,
     TrackSection,
     WikiInfo,
   }) => ({
-    // Update & expose
+    // > Update & expose - Internal relationships
+
+    album: thing({
+      class: input.value(Album),
+    }),
+
+    trackSection: thing({
+      class: input.value(TrackSection),
+    }),
+
+    // > Update & expose - Identifying metadata
 
     name: name('Unnamed Track'),
+    nameText: contentString(),
+
+    directory: directory({
+      suffix: 'directorySuffix',
+    }),
 
-    directory: [
-      withDirectorySuffix(),
+    suffixDirectoryFromAlbum: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
 
-      directory({
-        suffix: '#directorySuffix',
+      withPropertyFromObject({
+        object: 'trackSection',
+        property: input.value('suffixTrackDirectories'),
+      }),
+
+      exposeDependency({
+        dependency: '#trackSection.suffixTrackDirectories',
       }),
     ],
 
-    suffixDirectoryFromAlbum: [
+    // Controls how find.track works - it'll never be matched by
+    // a reference just to the track's name, which means you don't
+    // have to always reference some *other* (much more commonly
+    // referenced) track by directory instead of more naturally by name.
+    alwaysReferenceByDirectory: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('alwaysReferenceTracksByDirectory'),
+      }),
+
+      // Falsy mode means this exposes true if the album's property is true,
+      // but continues if the property is false (which is also the default).
+      exposeDependencyOrContinue({
+        dependency: '#album.alwaysReferenceTracksByDirectory',
+        mode: input.value('falsy'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '_mainRelease',
+        value: input.value(false),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'mainReleaseTrack',
+        value: input.value(false),
+      }),
+
+      withPropertyFromObject({
+        object: 'mainReleaseTrack',
+        property: input.value('name'),
+      }),
+
+      {
+        dependencies: ['name', '#mainReleaseTrack.name'],
+        compute: ({
+          ['name']: name,
+          ['#mainReleaseTrack.name']: mainReleaseName,
+        }) =>
+          getKebabCase(name) ===
+          getKebabCase(mainReleaseName),
+      },
+    ],
+
+    // Album or track. The exposed value is really just what's provided here,
+    // whether or not a matching track is found on a provided album, for
+    // example. When presenting or processing, read `mainReleaseTrack`.
+    mainRelease: [
+      exitWithoutUpdateValue({
+        validate: input.value(
+          validateReference(['album', 'track'])),
+      }),
+
+      {
+        dependencies: ['name'],
+        transform: (ref, continuation, {name: ownName}) =>
+          (ref === 'same name single'
+            ? continuation(ref, {
+                ['#albumOrTrackReference']: null,
+                ['#sameNameSingleReference']: ownName,
+              })
+            : continuation(ref, {
+                ['#albumOrTrackReference']: ref,
+                ['#sameNameSingleReference']: null,
+              })),
+      },
+
+      withResolvedReference({
+        ref: '#albumOrTrackReference',
+        find: soupyFind.input('trackMainReleasesOnly'),
+      }).outputs({
+        '#resolvedReference': '#matchingTrack',
+      }),
+
+      withResolvedReference({
+        ref: '#albumOrTrackReference',
+        find: soupyFind.input('album'),
+      }).outputs({
+        '#resolvedReference': '#matchingAlbum',
+      }),
+
+      withResolvedReference({
+        ref: '#sameNameSingleReference',
+        find: soupyFind.input('albumSinglesOnly'),
+        findOptions: input.value({
+          fuzz: {
+            capitalization: true,
+            kebab: true,
+          },
+        }),
+      }).outputs({
+        '#resolvedReference': '#sameNameSingle',
+      }),
+
+      exposeDependencyOrContinue({
+        dependency: '#sameNameSingle',
+      }),
+
       {
         dependencies: [
-          input.updateValue({validate: isBoolean}),
+          '#matchingTrack',
+          '#matchingAlbum',
         ],
 
         compute: (continuation, {
-          [input.updateValue()]: value,
-        }) => continuation({
-          ['#flagValue']: value ?? false,
-        }),
+          ['#matchingTrack']: matchingTrack,
+          ['#matchingAlbum']: matchingAlbum,
+        }) =>
+          (matchingTrack && matchingAlbum
+            ? continuation()
+         : matchingTrack ?? matchingAlbum
+            ? matchingTrack ?? matchingAlbum
+            : null),
       },
 
-      withSuffixDirectoryFromAlbum({
-        flagValue: '#flagValue',
+      withPropertyFromObject({
+        object: '#matchingAlbum',
+        property: input.value('tracks'),
       }),
 
-      exposeDependency({
-        dependency: '#suffixDirectoryFromAlbum',
-      })
+      {
+        dependencies: [
+          '#matchingAlbum.tracks',
+          '#matchingTrack',
+        ],
+
+        compute: ({
+          ['#matchingAlbum.tracks']: matchingAlbumTracks,
+          ['#matchingTrack']: matchingTrack,
+        }) =>
+          (matchingAlbumTracks.includes(matchingTrack)
+            ? matchingTrack
+            : null),
+      },
     ],
 
-    album: thing({
-      class: input.value(Album),
-    }),
+    bandcampTrackIdentifier: simpleString(),
+    bandcampArtworkIdentifier: simpleString(),
 
     additionalNames: thingList({
       class: input.value(AdditionalName),
     }),
 
-    bandcampTrackIdentifier: simpleString(),
-    bandcampArtworkIdentifier: simpleString(),
-
-    duration: duration(),
-    urls: urls(),
     dateFirstReleased: simpleDate(),
 
-    color: [
+    // > Update & expose - Credits and contributors
+
+    artistText: [
       exposeUpdateValueOrContinue({
-        validate: input.value(isColor),
+        validate: input.value(isContentString),
       }),
 
-      withContainingTrackSection(),
-
       withPropertyFromObject({
-        object: '#trackSection',
-        property: input.value('color'),
+        object: 'album',
+        property: input.value('trackArtistText'),
       }),
 
-      exposeDependencyOrContinue({dependency: '#trackSection.color'}),
+      exposeDependency({
+        dependency: '#album.trackArtistText',
+      }),
+    ],
 
-      withPropertyFromAlbum({
-        property: input.value('color'),
+    artistTextInLists: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isContentString),
       }),
 
-      exposeDependency({dependency: '#album.color'}),
-    ],
+      exposeDependencyOrContinue({
+        dependency: '_artistText',
+      }),
 
-    alwaysReferenceByDirectory: [
-      withAlwaysReferenceByDirectory(),
-      exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('trackArtistText'),
+      }),
+
+      exposeDependency({
+        dependency: '#album.trackArtistText',
+      }),
     ],
 
-    // 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(),
+    artistContribs: [
+      withResolvedContribs({
+        from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+        date: 'date',
+      }).outputs({
+        '#resolvedContribs': '#artistContribs',
+      }),
 
-    // 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(),
+      exposeDependencyOrContinue({
+        dependency: '#artistContribs',
+        mode: input.value('empty'),
+      }),
 
-      exposeUpdateValueOrContinue({
-        validate: input.value(isFileExtension),
+      // Specifically inherit artist contributions later than artist contribs.
+      // Secondary releases' artists may differ from the main release.
+      inheritContributionListFromMainRelease(),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('trackArtistContribs'),
       }),
 
-      withPropertyFromAlbum({
-        property: input.value('trackCoverArtFileExtension'),
+      withRecontextualizedContributionList({
+        list: '#album.trackArtistContribs',
+        artistProperty: input.value('trackArtistContributions'),
       }),
 
-      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
+      withRedatedContributionList({
+        list: '#album.trackArtistContribs',
+        date: 'date',
+      }),
 
-      exposeConstant({
-        value: input.value('jpg'),
+      exposeDependency({dependency: '#album.trackArtistContribs'}),
+    ],
+
+    contributorContribs: [
+      inheritContributionListFromMainRelease(),
+
+      contributionList({
+        date: 'date',
+        artistProperty: input.value('trackContributorContributions'),
       }),
     ],
 
-    coverArtDate: [
-      withTrackArtDate({
-        from: input.updateValue({
-          validate: isDate,
-        }),
+    // > Update & expose - General configuration
+
+    countInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'trackSection',
+        property: input.value('countTracksInArtistTotals'),
       }),
 
-      exposeDependency({dependency: '#trackArtDate'}),
+      exposeDependency({dependency: '#trackSection.countTracksInArtistTotals'}),
     ],
 
-    coverArtDimensions: [
-      exitWithoutUniqueCoverArt(),
+    disableUniqueCoverArt: flag(),
+    disableDate: flag(),
 
-      exposeUpdateValueOrContinue(),
+    // > Update & expose - General metadata
 
-      withPropertyFromAlbum({
-        property: input.value('trackDimensions'),
+    duration: duration(),
+
+    color: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isColor),
       }),
 
-      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
+      withPropertyFromObject({
+        object: 'trackSection',
+        property: input.value('color'),
+      }),
 
-      dimensions(),
-    ],
+      exposeDependencyOrContinue({dependency: '#trackSection.color'}),
 
-    commentary: thingList({
-      class: input.value(CommentaryEntry),
-    }),
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('color'),
+      }),
 
-    creditSources: thingList({
-      class: input.value(CreditingSourcesEntry),
-    }),
+      exposeDependency({dependency: '#album.color'}),
+    ],
 
-    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(),
+    needsLyrics: [
+      exposeUpdateValueOrContinue({
+        mode: input.value('falsy'),
+        validate: input.value(isBoolean),
+      }),
 
-      thingList({
-        class: input.value(LyricsEntry),
+      exitWithoutDependency({
+        dependency: '_lyrics',
+        mode: input.value('empty'),
+        value: input.value(false),
       }),
-    ],
 
-    additionalFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
+      withPropertyFromList({
+        list: '_lyrics',
+        property: input.value('helpNeeded'),
+      }),
 
-    sheetMusicFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
+      {
+        dependencies: ['#lyrics.helpNeeded'],
+        compute: ({
+          ['#lyrics.helpNeeded']: helpNeeded,
+        }) =>
+          helpNeeded.includes(true)
+      },
+    ],
 
-    midiProjectFiles: thingList({
-      class: input.value(AdditionalFile),
-    }),
+    urls: urls(),
 
-    mainReleaseTrack: singleReference({
-      class: input.value(Track),
-      find: soupyFind.input('track'),
-    }),
+    // > Update & expose - Artworks
 
-    artistContribs: [
-      inheritContributionListFromMainRelease(),
+    trackArtworks: [
+      exitWithoutDependency({
+        dependency: 'hasUniqueCoverArt',
+        mode: input.value('falsy'),
+        value: input.value([]),
+      }),
+
+      constitutibleArtworkList.fromYAMLFieldSpec
+        .call(this, 'Track Artwork'),
+    ],
 
-      withDate(),
+    coverArtistContribs: [
+      exitWithoutDependency({
+        dependency: 'hasUniqueCoverArt',
+        mode: input.value('falsy'),
+        value: input.value([]),
+      }),
 
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
-        thingProperty: input.thisProperty(),
-        artistProperty: input.value('trackArtistContributions'),
-        date: '#date',
-      }).outputs({
-        '#resolvedContribs': '#artistContribs',
+        thingProperty: input.value('coverArtistContribs'),
+        artistProperty: input.value('trackCoverArtistContributions'),
+        date: 'coverArtDate',
       }),
 
       exposeDependencyOrContinue({
-        dependency: '#artistContribs',
+        dependency: '#resolvedContribs',
         mode: input.value('empty'),
       }),
 
-      withPropertyFromAlbum({
-        property: input.value('artistContribs'),
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('trackCoverArtistContribs'),
       }),
 
       withRecontextualizedContributionList({
-        list: '#album.artistContribs',
-        artistProperty: input.value('trackArtistContributions'),
+        list: '#album.trackCoverArtistContribs',
+        artistProperty: input.value('trackCoverArtistContributions'),
       }),
 
       withRedatedContributionList({
-        list: '#album.artistContribs',
-        date: '#date',
+        list: '#album.trackCoverArtistContribs',
+        date: 'coverArtDate',
       }),
 
-      exposeDependency({dependency: '#album.artistContribs'}),
+      exposeDependency({
+        dependency: '#album.trackCoverArtistContribs',
+      }),
     ],
 
-    contributorContribs: [
-      inheritContributionListFromMainRelease(),
+    coverArtDate: [
+      exitWithoutDependency({
+        dependency: 'hasUniqueCoverArt',
+        mode: input.value('falsy'),
+      }),
 
-      withDate(),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isDate),
+      }),
 
-      contributionList({
-        date: '#date',
-        artistProperty: input.value('trackContributorContributions'),
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('trackArtDate'),
       }),
-    ],
 
-    coverArtistContribs: [
-      withCoverArtistContribs({
-        from: input.updateValue({
-          validate: isContributionList,
-        }),
+      exposeDependencyOrContinue({
+        dependency: '#album.trackArtDate',
       }),
 
-      exposeDependency({dependency: '#coverArtistContribs'}),
+      exposeDependency({
+        dependency: 'date',
+      }),
     ],
 
-    referencedTracks: [
-      inheritFromMainRelease({
-        notFoundValue: input.value([]),
+    coverArtFileExtension: [
+      exitWithoutDependency({
+        dependency: 'hasUniqueCoverArt',
+        mode: input.value('falsy'),
       }),
 
-      referenceList({
-        class: input.value(Track),
-        find: soupyFind.input('track'),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isFileExtension),
       }),
-    ],
 
-    sampledTracks: [
-      inheritFromMainRelease({
-        notFoundValue: input.value([]),
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('trackCoverArtFileExtension'),
       }),
 
-      referenceList({
-        class: input.value(Track),
-        find: soupyFind.input('track'),
+      exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
+
+      exposeConstant({
+        value: input.value('jpg'),
       }),
     ],
 
-    trackArtworks: [
-      exitWithoutUniqueCoverArt({
-        value: input.value([]),
+    coverArtDimensions: [
+      exitWithoutDependency({
+        dependency: 'hasUniqueCoverArt',
+        mode: input.value('falsy'),
       }),
 
-      constitutibleArtworkList.fromYAMLFieldSpec
-        .call(this, 'Track Artwork'),
+      exposeUpdateValueOrContinue(),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('trackDimensions'),
+      }),
+
+      exposeDependencyOrContinue({dependency: '#album.trackDimensions'}),
+
+      dimensions(),
     ],
 
     artTags: [
-      exitWithoutUniqueCoverArt({
+      exitWithoutDependency({
+        dependency: 'hasUniqueCoverArt',
+        mode: input.value('falsy'),
         value: input.value([]),
       }),
 
@@ -359,14 +581,84 @@ export class Track extends Thing {
     ],
 
     referencedArtworks: [
-      exitWithoutUniqueCoverArt({
+      exitWithoutDependency({
+        dependency: 'hasUniqueCoverArt',
+        mode: input.value('falsy'),
         value: input.value([]),
       }),
 
       referencedArtworkList(),
     ],
 
-    // Update only
+    // > Update & expose - Referenced tracks
+
+    previousProductionTracks: [
+      inheritFromMainRelease(),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('trackMainReleasesOnly'),
+      }),
+    ],
+
+    referencedTracks: [
+      inheritFromMainRelease(),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('trackMainReleasesOnly'),
+      }),
+    ],
+
+    sampledTracks: [
+      inheritFromMainRelease(),
+
+      referenceList({
+        class: input.value(Track),
+        find: soupyFind.input('trackMainReleasesOnly'),
+      }),
+    ],
+
+    // > Update & expose - Additional files
+
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    sheetMusicFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    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),
+      }),
+    ],
+
+    commentary: thingList({
+      class: input.value(CommentaryEntry),
+    }),
+
+    creditingSources: thingList({
+      class: input.value(CreditingSourcesEntry),
+    }),
+
+    referencingSources: thingList({
+      class: input.value(ReferencingSourcesEntry),
+    }),
+
+    // > Update only
 
     find: soupyFind(),
     reverse: soupyReverse(),
@@ -376,50 +668,374 @@ export class Track extends Thing {
       class: input.value(Artwork),
     }),
 
-    // used for withAlwaysReferenceByDirectory (for some reason)
-    trackData: wikiData({
-      class: input.value(Track),
-    }),
-
     // used for withMatchingContributionPresets (indirectly by Contribution)
     wikiInfo: thing({
       class: input.value(WikiInfo),
     }),
 
-    // Expose only
+    // > Expose only
+
+    isTrack: [
+      exposeConstant({
+        value: input.value(true),
+      }),
+    ],
 
     commentatorArtists: commentatorArtists(),
 
+    directorySuffix: [
+      exitWithoutDependency({
+        dependency: 'suffixDirectoryFromAlbum',
+        mode: input.value('falsy'),
+      }),
+
+      withPropertyFromObject({
+        object: 'trackSection',
+        property: input.value('directorySuffix'),
+      }),
+
+      exposeDependency({
+        dependency: '#trackSection.directorySuffix',
+      }),
+    ],
+
     date: [
-      withDate(),
-      exposeDependency({dependency: '#date'}),
+      {
+        dependencies: ['disableDate'],
+        compute: (continuation, {disableDate}) =>
+          (disableDate
+            ? null
+            : continuation()),
+      },
+
+      exposeDependencyOrContinue({
+        dependency: 'dateFirstReleased',
+      }),
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('date'),
+      }),
+
+      exposeDependency({
+        dependency: '#album.date',
+      }),
     ],
 
     trackNumber: [
-      withTrackNumber(),
-      exposeDependency({dependency: '#trackNumber'}),
+      // Zero is the fallback, not one, but in most albums the first track
+      // (and its intended output by this composition) will be one.
+      exitWithoutDependency({
+        dependency: 'trackSection',
+        value: input.value(0),
+      }),
+
+      withPropertiesFromObject({
+        object: 'trackSection',
+        properties: input.value(['tracks', 'startCountingFrom']),
+      }),
+
+      withIndexInList({
+        list: '#trackSection.tracks',
+        item: input.myself(),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#index',
+        value: input.value(0),
+        mode: input.value('index'),
+      }),
+
+      {
+        dependencies: ['#trackSection.startCountingFrom', '#index'],
+        compute: ({
+          ['#trackSection.startCountingFrom']: startCountingFrom,
+          ['#index']: index,
+        }) => startCountingFrom + index,
+      },
     ],
 
+    // Whether or not the track has "unique" cover artwork - a cover which is
+    // specifically associated with this track in particular, rather than with
+    // the track's album as a whole. This is typically used to select between
+    // displaying the track artwork and a fallback, such as the album artwork
+    // or a placeholder. (This property is named hasUniqueCoverArt instead of
+    // the usual hasCoverArt to emphasize that it does not inherit from the
+    // album.)
+    //
+    // hasUniqueCoverArt is based only around the presence of *specified*
+    // cover artist contributions, not whether the references to artists on those
+    // contributions actually resolve to anything. It completely evades interacting
+    // with find/replace.
     hasUniqueCoverArt: [
-      withHasUniqueCoverArt(),
-      exposeDependency({dependency: '#hasUniqueCoverArt'}),
+      {
+        dependencies: ['disableUniqueCoverArt'],
+        compute: (continuation, {disableUniqueCoverArt}) =>
+          (disableUniqueCoverArt
+            ? false
+            : continuation()),
+      },
+
+      withResultOfAvailabilityCheck({
+        from: '_coverArtistContribs',
+        mode: input.value('empty'),
+      }),
+
+      {
+        dependencies: ['#availability'],
+        compute: (continuation, {
+          ['#availability']: availability,
+        }) =>
+          (availability
+            ? true
+            : continuation()),
+      },
+
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('trackCoverArtistContribs'),
+        internal: input.value(true),
+      }),
+
+      withResultOfAvailabilityCheck({
+        from: '#album.trackCoverArtistContribs',
+        mode: input.value('empty'),
+      }),
+
+      {
+        dependencies: ['#availability'],
+        compute: (continuation, {
+          ['#availability']: availability,
+        }) =>
+          (availability
+            ? true
+            : continuation()),
+      },
+
+      exitWithoutDependency({
+        dependency: '_trackArtworks',
+        mode: input.value('empty'),
+        value: input.value(false),
+      }),
+
+      withPropertyFromList({
+        list: '_trackArtworks',
+        property: input.value('artistContribs'),
+        internal: input.value(true),
+      }),
+
+      // 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: '#trackArtworks.artistContribs',
+        fill: input.value([]),
+      }),
+
+      withFlattenedList({
+        list: '#trackArtworks.artistContribs',
+      }),
+
+      withResultOfAvailabilityCheck({
+        from: '#flattenedList',
+        mode: input.value('empty'),
+      }),
+
+      exposeDependency({
+        dependency: '#availability',
+      }),
     ],
 
     isMainRelease: [
-      withMainRelease(),
-
       exposeWhetherDependencyAvailable({
-        dependency: '#mainRelease',
+        dependency: 'mainReleaseTrack',
         negate: input.value(true),
       }),
     ],
 
     isSecondaryRelease: [
-      withMainRelease(),
-
       exposeWhetherDependencyAvailable({
-        dependency: '#mainRelease',
+        dependency: 'mainReleaseTrack',
+      }),
+    ],
+
+    mainReleaseTrack: [
+      exitWithoutDependency({
+        dependency: 'mainRelease',
+      }),
+
+      withPropertyFromObject({
+        object: 'mainRelease',
+        property: input.value('isTrack'),
+      }),
+
+      {
+        dependencies: ['mainRelease', '#mainRelease.isTrack'],
+        compute: (continuation, {
+          ['mainRelease']: mainRelease,
+          ['#mainRelease.isTrack']: mainReleaseIsTrack,
+        }) =>
+          (mainReleaseIsTrack
+            ? mainRelease
+            : continuation()),
+      },
+
+      {
+        dependencies: ['name', '_directory'],
+        compute: (continuation, {
+          ['name']: ownName,
+          ['_directory']: ownDirectory,
+        }) => {
+          const ownNameKebabed = getKebabCase(ownName);
+
+          return continuation({
+            ['#mapItsNameLikeName']:
+              name => getKebabCase(name) === ownNameKebabed,
+
+            ['#mapItsDirectoryLikeDirectory']:
+              (ownDirectory
+                ? directory => directory === ownDirectory
+                : () => false),
+
+            ['#mapItsNameLikeDirectory']:
+              (ownDirectory
+                ? name => getKebabCase(name) === ownDirectory
+                : () => false),
+
+            ['#mapItsDirectoryLikeName']:
+              directory => directory === ownNameKebabed,
+          });
+        },
+      },
+
+      withPropertyFromObject({
+        object: 'mainRelease',
+        property: input.value('tracks'),
+      }),
+
+      withPropertyFromList({
+        list: '#mainRelease.tracks',
+        property: input.value('mainRelease'),
+        internal: input.value(true),
+      }),
+
+      withAvailabilityFilter({
+        from: '#mainRelease.tracks.mainRelease',
       }),
+
+      withMappedList({
+        list: '#availabilityFilter',
+        map: input.value(item => !item),
+      }).outputs({
+        '#mappedList': '#availabilityFilter',
+      }),
+
+      withFilteredList({
+        list: '#mainRelease.tracks',
+        filter: '#availabilityFilter',
+      }).outputs({
+        '#filteredList': '#mainRelease.tracks',
+      }),
+
+      withPropertyFromList({
+        list: '#mainRelease.tracks',
+        property: input.value('name'),
+      }),
+
+      withPropertyFromList({
+        list: '#mainRelease.tracks',
+        property: input.value('directory'),
+        internal: input.value(true),
+      }),
+
+      withMappedList({
+        list: '#mainRelease.tracks.name',
+        map: '#mapItsNameLikeName',
+      }).outputs({
+        '#mappedList': '#filterItsNameLikeName',
+      }),
+
+      withMappedList({
+        list: '#mainRelease.tracks.directory',
+        map: '#mapItsDirectoryLikeDirectory',
+      }).outputs({
+        '#mappedList': '#filterItsDirectoryLikeDirectory',
+      }),
+
+      withMappedList({
+        list: '#mainRelease.tracks.name',
+        map: '#mapItsNameLikeDirectory',
+      }).outputs({
+        '#mappedList': '#filterItsNameLikeDirectory',
+      }),
+
+      withMappedList({
+        list: '#mainRelease.tracks.directory',
+        map: '#mapItsDirectoryLikeName',
+      }).outputs({
+        '#mappedList': '#filterItsDirectoryLikeName',
+      }),
+
+      withFilteredList({
+        list: '#mainRelease.tracks',
+        filter: '#filterItsNameLikeName',
+      }).outputs({
+        '#filteredList': '#matchingItsNameLikeName',
+      }),
+
+      withFilteredList({
+        list: '#mainRelease.tracks',
+        filter: '#filterItsDirectoryLikeDirectory',
+      }).outputs({
+        '#filteredList': '#matchingItsDirectoryLikeDirectory',
+      }),
+
+      withFilteredList({
+        list: '#mainRelease.tracks',
+        filter: '#filterItsNameLikeDirectory',
+      }).outputs({
+        '#filteredList': '#matchingItsNameLikeDirectory',
+      }),
+
+      withFilteredList({
+        list: '#mainRelease.tracks',
+        filter: '#filterItsDirectoryLikeName',
+      }).outputs({
+        '#filteredList': '#matchingItsDirectoryLikeName',
+      }),
+
+      {
+        dependencies: [
+          '#matchingItsNameLikeName',
+          '#matchingItsDirectoryLikeDirectory',
+          '#matchingItsNameLikeDirectory',
+          '#matchingItsDirectoryLikeName',
+        ],
+
+        compute: (continuation, {
+          ['#matchingItsNameLikeName']:           NLN,
+          ['#matchingItsDirectoryLikeDirectory']: DLD,
+          ['#matchingItsNameLikeDirectory']:      NLD,
+          ['#matchingItsDirectoryLikeName']:      DLN,
+        }) => continuation({
+          ['#mainReleaseTrack']:
+            onlyItem(DLD) ??
+            onlyItem(NLN) ??
+            onlyItem(DLN) ??
+            onlyItem(NLD) ??
+            null,
+        }),
+      },
+
+      {
+        dependencies: ['#mainReleaseTrack', input.myself()],
+        compute: ({
+          ['#mainReleaseTrack']: mainReleaseTrack,
+          [input.myself()]: thisTrack,
+        }) =>
+          (mainReleaseTrack === thisTrack
+            ? null
+            : mainReleaseTrack),
+      },
     ],
 
     // Only has any value for main releases, because secondary releases
@@ -429,15 +1045,85 @@ export class Track extends Thing {
     }),
 
     allReleases: [
-      withAllReleases(),
-      exposeDependency({dependency: '#allReleases'}),
+      {
+        dependencies: [
+          'mainReleaseTrack',
+          'secondaryReleases',
+          input.myself(),
+        ],
+
+        compute: (continuation, {
+          mainReleaseTrack,
+          secondaryReleases,
+          [input.myself()]: thisTrack,
+        }) =>
+          (mainReleaseTrack
+            ? continuation({
+                ['#mainReleaseTrack']: mainReleaseTrack,
+                ['#secondaryReleaseTracks']: mainReleaseTrack.secondaryReleases,
+              })
+            : continuation({
+                ['#mainReleaseTrack']: thisTrack,
+                ['#secondaryReleaseTracks']: secondaryReleases,
+              })),
+      },
+
+      {
+        dependencies: [
+          '#mainReleaseTrack',
+          '#secondaryReleaseTracks',
+        ],
+
+        compute: ({
+          ['#mainReleaseTrack']: mainReleaseTrack,
+          ['#secondaryReleaseTracks']: secondaryReleaseTracks,
+        }) =>
+          sortByDate([mainReleaseTrack, ...secondaryReleaseTracks]),
+      },
     ],
 
     otherReleases: [
-      withOtherReleases(),
-      exposeDependency({dependency: '#otherReleases'}),
+      {
+        dependencies: [input.myself(), 'allReleases'],
+        compute: ({
+          [input.myself()]: thisTrack,
+          ['allReleases']: allReleases,
+        }) =>
+          allReleases.filter(track => track !== thisTrack),
+      },
+    ],
+
+    commentaryFromMainRelease: [
+      exitWithoutDependency({
+        dependency: 'mainReleaseTrack',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'mainReleaseTrack',
+        property: input.value('commentary'),
+      }),
+
+      exposeDependency({
+        dependency: '#mainReleaseTrack.commentary',
+      }),
     ],
 
+    groups: [
+      withPropertyFromObject({
+        object: 'album',
+        property: input.value('groups'),
+      }),
+
+      exposeDependency({
+        dependency: '#album.groups',
+      }),
+    ],
+
+    followingProductionTracks: reverseReferenceList({
+      reverse: soupyReverse.input('tracksWhichAreFollowingProductionsOf'),
+    }),
+
     referencedByTracks: reverseReferenceList({
       reverse: soupyReverse.input('tracksWhichReference'),
     }),
@@ -453,14 +1139,14 @@ export class Track extends Thing {
 
   static [Thing.yamlDocumentSpec] = {
     fields: {
+      // Identifying metadata
+
       'Track': {property: 'name'},
+      'Track Text': {property: 'nameText'},
       'Directory': {property: 'directory'},
       'Suffix Directory': {property: 'suffixDirectoryFromAlbum'},
-
-      'Additional Names': {
-        property: 'additionalNames',
-        transform: parseAdditionalNames,
-      },
+      'Always Reference By Directory': {property: 'alwaysReferenceByDirectory'},
+      'Main Release': {property: 'mainRelease'},
 
       'Bandcamp Track ID': {
         property: 'bandcampTrackIdentifier',
@@ -472,17 +1158,86 @@ export class Track extends Thing {
         transform: String,
       },
 
+      'Additional Names': {
+        property: 'additionalNames',
+        transform: parseAdditionalNames,
+      },
+
+      'Date First Released': {
+        property: 'dateFirstReleased',
+        transform: parseDate,
+      },
+
+      // Credits and contributors
+
+      'Artist Text': {property: 'artistText'},
+      'Artist Text In Lists': {property: 'artistTextInLists'},
+
+      '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),
+      },
+
+      'Has Date': {
+        property: 'disableDate',
+        transform: value =>
+          (typeof value === 'boolean'
+            ? !value
+            : value),
+      },
+
+      // General metadata
+
       'Duration': {
         property: 'duration',
         transform: parseDuration,
       },
 
       'Color': {property: 'color'},
+
+      'Needs Lyrics': {
+        property: 'needsLyrics',
+      },
+
       '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 +1252,20 @@ 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,
-      },
+      'Previous Productions': {property: 'previousProductionTracks'},
+      'Referenced Tracks': {property: 'referencedTracks'},
+      'Sampled Tracks': {property: 'sampledTracks'},
+
+      // Additional files
 
       'Additional Files': {
         property: 'additionalFiles',
@@ -537,54 +1282,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,
-      },
-
-      'Franchises': {ignore: true},
-      'Inherit Franchises': {ignore: true},
-
-      'Artists': {
-        property: 'artistContribs',
-        transform: parseContributors,
+      'Lyrics': {
+        property: 'lyrics',
+        transform: parseLyrics,
       },
 
-      'Contributors': {
-        property: 'contributorContribs',
-        transform: parseContributors,
+      'Commentary': {
+        property: 'commentary',
+        transform: parseCommentary,
       },
 
-      'Cover Artists': {
-        property: 'coverArtistContribs',
-        transform: parseContributors,
+      'Crediting Sources': {
+        property: 'creditingSources',
+        transform: parseCreditingSources,
       },
 
-      'Track Artwork': {
-        property: 'trackArtworks',
-        transform:
-          parseArtwork({
-            thingProperty: 'trackArtworks',
-            dimensionsFromThingProperty: 'coverArtDimensions',
-            fileExtensionFromThingProperty: 'coverArtFileExtension',
-            dateFromThingProperty: 'coverArtDate',
-            artTagsFromThingProperty: 'artTags',
-            referencedArtworksFromThingProperty: 'referencedArtworks',
-            artistContribsFromThingProperty: 'coverArtistContribs',
-            artistContribsArtistProperty: 'trackCoverArtistContributions',
-          }),
+      'Referencing Sources': {
+        property: 'referencingSources',
+        transform: parseReferencingSources,
       },
 
-      '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',
@@ -595,11 +1327,6 @@ export class Track extends Thing {
         'Sampled Tracks',
       ]},
 
-      {message: `Secondary releases inherit artists from the main one`, fields: [
-        'Main Release',
-        'Artists',
-      ]},
-
       {message: `Secondary releases inherit contributors from the main one`, fields: [
         'Main Release',
         'Contributors',
@@ -641,7 +1368,7 @@ export class Track extends Thing {
       bindTo: 'trackData',
 
       include: track =>
-        !CacheableObject.getUpdateValue(track, 'mainReleaseTrack'),
+        !CacheableObject.getUpdateValue(track, 'mainRelease'),
 
       // It's still necessary to check alwaysReferenceByDirectory here, since
       // it may be set manually (with `Always Reference By Directory: true`),
@@ -741,6 +1468,13 @@ export class Track extends Thing {
       referencing: track => track.isSecondaryRelease ? [track] : [],
       referenced: track => [track.mainReleaseTrack],
     },
+
+    tracksWhichAreFollowingProductionsOf: {
+      bindTo: 'trackData',
+
+      referencing: track => track,
+      referenced: track => track.previousProductionTracks,
+    },
   };
 
   // Track YAML loading is handled in album.js.
@@ -771,12 +1505,36 @@ 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 = [];
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (CacheableObject.getUpdateValue(this, 'mainReleaseTrack')) {
+    if (CacheableObject.getUpdateValue(this, 'mainRelease')) {
       parts.unshift(`${colors.yellow('[secrelease]')} `);
     }
 
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 590598be..73470b7d 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,
@@ -10,15 +10,26 @@ import {
   isContributionPresetList,
   isLanguageCode,
   isName,
-  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 {
+  canonicalBase,
+  contentString,
+  fileExtension,
+  flag,
+  name,
+  referenceList,
+  simpleString,
+  soupyFind,
+  wallpaperParts,
+} from '#composite/wiki-properties';
 
 export class WikiInfo extends Thing {
   static [Thing.friendlyName] = `Wiki Info`;
+  static [Thing.wikiData] = 'wikiInfo';
+  static [Thing.oneInstancePerWiki] = true;
 
   static [Thing.getPropertyDescriptors] = ({Group}) => ({
     // Update & expose
@@ -55,18 +66,12 @@ export class WikiInfo extends Thing {
       update: {validate: isLanguageCode},
     },
 
-    canonicalBase: {
-      flags: {update: true, expose: true},
-      update: {validate: isURL},
-      expose: {
-        transform: (value) =>
-          (value === null
-            ? null
-         : value.endsWith('/')
-            ? value
-            : value + '/'),
-      },
-    },
+    canonicalBase: canonicalBase(),
+    canonicalMediaBase: canonicalBase(),
+
+    wikiWallpaperFileExtension: fileExtension('jpg'),
+    wikiWallpaperStyle: simpleString(),
+    wikiWallpaperParts: wallpaperParts(),
 
     divideTrackListsByGroups: referenceList({
       class: input.value(Group),
@@ -87,7 +92,7 @@ export class WikiInfo extends Thing {
 
     enableSearch: [
       exitWithoutDependency({
-        dependency: 'searchDataAvailable',
+        dependency: '_searchDataAvailable',
         mode: input.value('falsy'),
         value: input.value(false),
       }),
@@ -106,24 +111,49 @@ 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'},
+      'Canonical Media Base': {property: 'canonicalMediaBase'},
+
+      '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,
@@ -140,13 +170,5 @@ export class WikiInfo extends Thing {
 
     documentMode: oneDocumentTotal,
     documentThing: WikiInfo,
-
-    save(wikiInfo) {
-      if (!wikiInfo) {
-        return;
-      }
-
-      return {wikiInfo};
-    },
   });
 }