« 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/album.js78
-rw-r--r--src/data/things/artist.js125
-rw-r--r--src/data/things/contribution.js265
-rw-r--r--src/data/things/flash.js27
-rw-r--r--src/data/things/index.js2
-rw-r--r--src/data/things/language.js237
-rw-r--r--src/data/things/track.js123
-rw-r--r--src/data/things/wiki-info.js42
8 files changed, 704 insertions, 195 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
index e9f55b2c..a0021946 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -18,8 +18,13 @@ import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions}
 import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
 import {withPropertyFromObject} from '#composite/data';
-import {exitWithoutContribs, withDirectory, withResolvedReference}
-  from '#composite/wiki-data';
+
+import {
+  exitWithoutContribs,
+  withDirectory,
+  withResolvedReference,
+  withCoverArtDate,
+} from '#composite/wiki-data';
 
 import {
   additionalFiles,
@@ -37,6 +42,7 @@ import {
   simpleDate,
   simpleString,
   singleReference,
+  thing,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -53,6 +59,7 @@ export class Album extends Thing {
     Group,
     Track,
     TrackSection,
+    WikiInfo,
   }) => ({
     // Update & expose
 
@@ -71,13 +78,16 @@ export class Album extends Thing {
     dateAddedToWiki: simpleDate(),
 
     coverArtDate: [
-      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      // TODO: Why does this fall back, but Track.coverArtDate doesn't?
+      withCoverArtDate({
+        from: input.updateValue({
+          validate: isDate,
+        }),
 
-      exposeUpdateValueOrContinue({
-        validate: input.value(isDate),
+        fallback: input.value(true),
       }),
 
-      exposeDependency({dependency: 'date'}),
+      exposeDependency({dependency: '#coverArtDate'}),
     ],
 
     coverArtFileExtension: [
@@ -130,11 +140,53 @@ export class Album extends Thing {
       find: input.value(find.unqualifiedTrackSection),
     }),
 
-    artistContribs: contributionList(),
-    coverArtistContribs: contributionList(),
-    trackCoverArtistContribs: contributionList(),
-    wallpaperArtistContribs: contributionList(),
-    bannerArtistContribs: contributionList(),
+    artistContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('albumArtistContributions'),
+    }),
+
+    coverArtistContribs: [
+      withCoverArtDate({
+        fallback: input.value(true),
+      }),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumCoverArtistContributions'),
+      }),
+    ],
+
+    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({
+        fallback: input.value(true),
+      }),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumWallpaperArtistContributions'),
+      }),
+    ],
+
+    bannerArtistContribs: [
+      withCoverArtDate({
+        fallback: input.value(true),
+      }),
+
+      contributionList({
+        date: '#coverArtDate',
+        artistProperty: input.value('albumBannerArtistContributions'),
+      }),
+    ],
 
     groups: referenceList({
       class: input.value(Group),
@@ -173,6 +225,10 @@ export class Album extends Thing {
       class: input.value(TrackSection),
     }),
 
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
     // Expose only
 
     commentatorArtists: commentatorArtists(),
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 841d652f..6d5e33c0 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -12,6 +12,7 @@ import Thing from '#thing';
 import {isName, validateArrayItems} from '#validators';
 import {getKebabCase} from '#wiki-data';
 
+import {exposeDependency} from '#composite/control-flow';
 import {withReverseContributionList} from '#composite/wiki-data';
 
 import {
@@ -27,6 +28,8 @@ import {
   wikiData,
 } from '#composite/wiki-properties';
 
+import {artistTotalDuration} from '#composite/things/artist';
+
 export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
   static [Thing.wikiDataArray] = 'artistData';
@@ -77,146 +80,52 @@ export class Artist extends Thing {
 
     // Expose only
 
-    tracksAsArtist: reverseContributionList({
+    trackArtistContributions: reverseContributionList({
       data: 'trackData',
       list: input.value('artistContribs'),
     }),
 
-    tracksAsContributor: reverseContributionList({
+    trackContributorContributions: reverseContributionList({
       data: 'trackData',
       list: input.value('contributorContribs'),
     }),
 
-    tracksAsCoverArtist: reverseContributionList({
+    trackCoverArtistContributions: reverseContributionList({
       data: 'trackData',
       list: input.value('coverArtistContribs'),
     }),
 
-    tracksAsAny: [
-      withReverseContributionList({
-        data: 'trackData',
-        list: input.value('artistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#tracksAsArtist',
-      }),
-
-      withReverseContributionList({
-        data: 'trackData',
-        list: input.value('contributorContribs'),
-      }).outputs({
-        '#reverseContributionList': '#tracksAsContributor',
-      }),
-
-      withReverseContributionList({
-        data: 'trackData',
-        list: input.value('coverArtistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#tracksAsCoverArtist',
-      }),
-
-      {
-        dependencies: [
-          '#tracksAsArtist',
-          '#tracksAsContributor',
-          '#tracksAsCoverArtist',
-        ],
-
-        compute: ({
-          ['#tracksAsArtist']: tracksAsArtist,
-          ['#tracksAsContributor']: tracksAsContributor,
-          ['#tracksAsCoverArtist']: tracksAsCoverArtist,
-        }) =>
-          unique([
-            ...tracksAsArtist,
-            ...tracksAsContributor,
-            ...tracksAsCoverArtist,
-          ]),
-      },
-    ],
-
     tracksAsCommentator: reverseReferenceList({
       data: 'trackData',
       list: input.value('commentatorArtists'),
     }),
 
-    albumsAsAlbumArtist: reverseContributionList({
+    albumArtistContributions: reverseContributionList({
       data: 'albumData',
       list: input.value('artistContribs'),
     }),
 
-    albumsAsCoverArtist: reverseContributionList({
+    albumCoverArtistContributions: reverseContributionList({
       data: 'albumData',
       list: input.value('coverArtistContribs'),
     }),
 
-    albumsAsWallpaperArtist: reverseContributionList({
+    albumWallpaperArtistContributions: reverseContributionList({
       data: 'albumData',
       list: input.value('wallpaperArtistContribs'),
     }),
 
-    albumsAsBannerArtist: reverseContributionList({
+    albumBannerArtistContributions: reverseContributionList({
       data: 'albumData',
       list: input.value('bannerArtistContribs'),
     }),
 
-    albumsAsAny: [
-      withReverseContributionList({
-        data: 'albumData',
-        list: input.value('artistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#albumsAsArtist',
-      }),
-
-      withReverseContributionList({
-        data: 'albumData',
-        list: input.value('coverArtistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#albumsAsCoverArtist',
-      }),
-
-      withReverseContributionList({
-        data: 'albumData',
-        list: input.value('wallpaperArtistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#albumsAsWallpaperArtist',
-      }),
-
-      withReverseContributionList({
-        data: 'albumData',
-        list: input.value('bannerArtistContribs'),
-      }).outputs({
-        '#reverseContributionList': '#albumsAsBannerArtist',
-      }),
-
-      {
-        dependencies: [
-          '#albumsAsArtist',
-          '#albumsAsCoverArtist',
-          '#albumsAsWallpaperArtist',
-          '#albumsAsBannerArtist',
-        ],
-
-        compute: ({
-          ['#albumsAsArtist']: albumsAsArtist,
-          ['#albumsAsCoverArtist']: albumsAsCoverArtist,
-          ['#albumsAsWallpaperArtist']: albumsAsWallpaperArtist,
-          ['#albumsAsBannerArtist']: albumsAsBannerArtist,
-        }) =>
-          unique([
-            ...albumsAsArtist,
-            ...albumsAsCoverArtist,
-            ...albumsAsWallpaperArtist,
-            ...albumsAsBannerArtist,
-          ]),
-      },
-    ],
-
     albumsAsCommentator: reverseReferenceList({
       data: 'albumData',
       list: input.value('commentatorArtists'),
     }),
 
-    flashesAsContributor: reverseContributionList({
+    flashContributorContributions: reverseContributionList({
       data: 'flashData',
       list: input.value('contributorContribs'),
     }),
@@ -225,6 +134,8 @@ export class Artist extends Thing {
       data: 'flashData',
       list: input.value('commentatorArtists'),
     }),
+
+    totalDuration: artistTotalDuration(),
   });
 
   static [Thing.getSerializeDescriptors] = ({
@@ -240,18 +151,8 @@ export class Artist extends Thing {
 
     aliasNames: S.id,
 
-    tracksAsArtist: S.toRefs,
-    tracksAsContributor: S.toRefs,
-    tracksAsCoverArtist: S.toRefs,
     tracksAsCommentator: S.toRefs,
-
-    albumsAsAlbumArtist: S.toRefs,
-    albumsAsCoverArtist: S.toRefs,
-    albumsAsWallpaperArtist: S.toRefs,
-    albumsAsBannerArtist: S.toRefs,
     albumsAsCommentator: S.toRefs,
-
-    flashesAsContributor: S.toRefs,
   });
 
   static [Thing.findSpecs] = {
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
new file mode 100644
index 00000000..79acf1e1
--- /dev/null
+++ b/src/data/things/contribution.js
@@ -0,0 +1,265 @@
+import {inspect} from 'node:util';
+
+import CacheableObject from '#cacheable-object';
+import {colors} from '#cli';
+import {input} from '#composite';
+import {empty} from '#sugar';
+import Thing from '#thing';
+import {isStringNonEmpty, isThing, validateReference} from '#validators';
+
+import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
+import {withNearbyItemFromList, withPropertyFromObject} from '#composite/data';
+import {withResolvedReference} from '#composite/wiki-data';
+import {flag, simpleDate} from '#composite/wiki-properties';
+
+import {
+  inheritFromContributionPresets,
+  thingPropertyMatches,
+  thingReferenceTypeMatches,
+  withContainingReverseContributionList,
+  withContributionArtist,
+  withContributionContext,
+  withMatchingContributionPresets,
+} from '#composite/things/contribution';
+
+export class Contribution extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: {
+      flags: {update: true, expose: true},
+      update: {validate: isThing},
+    },
+
+    thingProperty: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    artistProperty: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    date: simpleDate(),
+
+    artist: [
+      withContributionArtist({
+        ref: input.updateValue({
+          validate: validateReference('artist'),
+        }),
+      }),
+
+      exposeDependency({
+        dependency: '#artist',
+      }),
+    ],
+
+    annotation: {
+      flags: {update: true, expose: true},
+      update: {validate: isStringNonEmpty},
+    },
+
+    countInContributionTotals: [
+      inheritFromContributionPresets({
+        property: input.thisProperty(),
+      }),
+
+      flag(true),
+    ],
+
+    countInDurationTotals: [
+      inheritFromContributionPresets({
+        property: input.thisProperty(),
+      }),
+
+      flag(true),
+    ],
+
+    // Expose only
+
+    context: [
+      withContributionContext(),
+
+      {
+        dependencies: [
+          '#contributionTarget',
+          '#contributionProperty',
+        ],
+
+        compute: ({
+          ['#contributionTarget']: target,
+          ['#contributionProperty']: property,
+        }) => ({
+          target,
+          property,
+        }),
+      },
+    ],
+
+    matchingPresets: [
+      withMatchingContributionPresets(),
+
+      exposeDependency({
+        dependency: '#matchingContributionPresets',
+      }),
+    ],
+
+    // All the contributions from the list which includes this contribution.
+    // Note that this list contains not only other contributions by the same
+    // artist, but also this very contribution. It doesn't mix contributions
+    // exposed on different properties.
+    associatedContributions: [
+      exitWithoutDependency({
+        dependency: 'thing',
+        value: input.value([]),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'thingProperty',
+        value: input.value([]),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: 'thingProperty',
+      }),
+
+      exposeDependency({
+        dependency: '#value',
+      }),
+    ],
+
+    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',
+      }),
+
+      exitWithoutDependency({
+        dependency: '#list',
+      }),
+
+      withNearbyItemFromList({
+        list: '#list',
+        item: input.myself(),
+        offset: input.value(-1),
+      }),
+
+      exposeDependency({
+        dependency: '#nearbyItem',
+      }),
+    ],
+
+    nextBySameArtist: [
+      withContainingReverseContributionList().outputs({
+        '#containingReverseContributionList': '#list',
+      }),
+
+      exitWithoutDependency({
+        dependency: '#list',
+      }),
+
+      withNearbyItemFromList({
+        list: '#list',
+        item: input.myself(),
+        offset: input.value(+1),
+      }),
+
+      exposeDependency({
+        dependency: '#nearbyItem',
+      }),
+    ],
+  });
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+    const accentParts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (this.annotation) {
+      accentParts.push(colors.green(`"${this.annotation}"`));
+    }
+
+    if (this.date) {
+      accentParts.push(colors.yellow(this.date.toLocaleDateString()));
+    }
+
+    let artistRef;
+    if (depth >= 0) {
+      let artist;
+      try {
+        artist = this.artist;
+      } catch (_error) {
+        // Computing artist might crash for any reason - don't distract from
+        // other errors as a result of inspecting this contribution.
+      }
+
+      if (artist) {
+        artistRef =
+          colors.blue(Thing.getReference(artist));
+      }
+    } else {
+      artistRef =
+        colors.green(CacheableObject.getUpdateValue(this, 'artist'));
+    }
+
+    if (artistRef) {
+      accentParts.push(`by ${artistRef}`);
+    }
+
+    if (this.thing) {
+      if (depth >= 0) {
+        const newOptions = {
+          ...options,
+          depth:
+            (options.depth === null
+              ? null
+              : options.depth - 1),
+        };
+
+        accentParts.push(`to ${inspect(this.thing, newOptions)}`);
+      } else {
+        accentParts.push(`to ${colors.blue(Thing.getReference(this.thing))}`);
+      }
+    }
+
+    if (!empty(accentParts)) {
+      parts.push(` (${accentParts.join(', ')})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/flash.js b/src/data/things/flash.js
index ceed79f7..89e59fe7 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -7,7 +7,7 @@ import {sortFlashesChronologically} from '#sort';
 import Thing from '#thing';
 import {anyOf, isColor, isContentString, isDirectory, isNumber, isString}
   from '#validators';
-import {parseDate, parseContributors} from '#yaml';
+import {parseContributors, parseDate, parseDimensions} from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
 
@@ -24,11 +24,13 @@ import {
   commentatorArtists,
   contentString,
   contributionList,
+  dimensions,
   directory,
   fileExtension,
   name,
   referenceList,
   simpleDate,
+  thing,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -39,7 +41,12 @@ import {withFlashSide} from '#composite/things/flash-act';
 export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
 
-  static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({
+  static [Thing.getPropertyDescriptors] = ({
+    Artist,
+    Track,
+    FlashAct,
+    WikiInfo,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Flash'),
@@ -89,7 +96,12 @@ export class Flash extends Thing {
 
     coverArtFileExtension: fileExtension('jpg'),
 
-    contributorContribs: contributionList(),
+    coverArtDimensions: dimensions(),
+
+    contributorContribs: contributionList({
+      date: 'date',
+      artistProperty: input.value('flashContributorContributions'),
+    }),
 
     featuredTracks: referenceList({
       class: input.value(Track),
@@ -115,6 +127,10 @@ export class Flash extends Thing {
       class: input.value(FlashAct),
     }),
 
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
     // Expose only
 
     commentatorArtists: commentatorArtists(),
@@ -171,6 +187,11 @@ export class Flash extends Thing {
 
       'Cover Art File Extension': {property: 'coverArtFileExtension'},
 
+      'Cover Art Dimensions': {
+        property: 'coverArtDimensions',
+        transform: parseDimensions,
+      },
+
       'Featured Tracks': {property: 'featuredTracks'},
 
       'Contributors': {
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 4f87f492..f18e283a 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -11,6 +11,7 @@ import Thing from '#thing';
 import * as albumClasses from './album.js';
 import * as artTagClasses from './art-tag.js';
 import * as artistClasses from './artist.js';
+import * as contributionClasses from './contribution.js';
 import * as flashClasses from './flash.js';
 import * as groupClasses from './group.js';
 import * as homepageLayoutClasses from './homepage-layout.js';
@@ -24,6 +25,7 @@ const allClassLists = {
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
   'artist.js': artistClasses,
+  'contribution.js': contributionClasses,
   'flash.js': flashClasses,
   'group.js': groupClasses,
   'homepage-layout.js': homepageLayoutClasses,
diff --git a/src/data/things/language.js b/src/data/things/language.js
index dbe1ff3d..88f16ecb 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -127,6 +127,13 @@ export class Language extends Thing {
 
     // Expose only
 
+    onlyIfOptions: {
+      flags: {expose: true},
+      expose: {
+        compute: () => Symbol.for(`language.onlyIfOptions`),
+      },
+    },
+
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
@@ -201,9 +208,7 @@ export class Language extends Thing {
       args.at(-1) !== null;
 
     const key =
-      (hasOptions ? args.slice(0, -1) : args)
-        .filter(Boolean)
-        .join('.');
+      this.#joinKeyParts(hasOptions ? args.slice(0, -1) : args);
 
     const options =
       (hasOptions
@@ -218,18 +223,42 @@ export class Language extends Thing {
       throw new Error(`Invalid key ${key} accessed`);
     }
 
+    const constantCasify = name =>
+      name
+        .replace(/[A-Z]/g, '_$&')
+        .toUpperCase();
+
     // These will be filled up as we iterate over the template, slotting in
     // each option (if it's present).
     const missingOptionNames = new Set();
 
+    // These will also be filled. It's a bit different of an error, indicating
+    // a provided option was *expected,* but its value was null, undefined, or
+    // blank HTML content.
+    const valuelessOptionNames = new Set();
+
+    // These *might* be missing, and if they are, that's OK!! Instead of adding
+    // to the valueless set above, we'll just mark to return a blank for the
+    // whole string.
+    const expectedValuelessOptionNames =
+      new Set(
+        (options[this.onlyIfOptions] ?? [])
+          .map(constantCasify));
+
+    let seenExpectedValuelessOption = false;
+
+    const isValueless =
+      value =>
+        value === null ||
+        value === undefined ||
+        html.isBlank(value);
+
     // And this will have entries deleted as they're encountered in the
     // template. Leftover entries are misplaced.
     const optionsMap =
       new Map(
         Object.entries(options).map(([name, value]) => [
-          name
-            .replace(/[A-Z]/g, '_$&')
-            .toUpperCase(),
+          constantCasify(name),
           value,
         ]));
 
@@ -239,32 +268,48 @@ export class Language extends Thing {
       match: languageOptionRegex,
 
       insert: ({name: optionName}, canceledForming) => {
-        if (optionsMap.has(optionName)) {
-          let optionValue;
-
-          // We'll only need the option's value if we're going to use it as
-          // part of the formed output (see below).
-          if (!canceledForming) {
-            optionValue = optionsMap.get(optionName);
-          }
-
-          // But we always have to delete expected options off the provided
-          // option map, since the leftovers are what will be used to tell
-          // which are misplaced.
-          optionsMap.delete(optionName);
+        if (!optionsMap.has(optionName)) {
+          missingOptionNames.add(optionName);
 
-          if (canceledForming) {
-            return undefined;
-          } else {
-            return optionValue;
-          }
-        } else {
           // We don't need to continue forming the output if we've hit a
           // missing option name, since the end result of this formatString
           // call will be a thrown error, and formed output won't be needed.
-          missingOptionNames.add(optionName);
+          // Return undefined to mark canceledForming for the following
+          // iterations (and exit early out of this iteration).
+          return undefined;
+        }
+
+        // Even if we're not actually forming the output anymore, we'll still
+        // have to access this option's value to check if it is invalid.
+        const optionValue = optionsMap.get(optionName);
+
+        // We always have to delete expected options off the provided option
+        // map, since the leftovers are what will be used to tell which are
+        // misplaced - information you want even (or doubly so) if we've
+        // already stopped forming the output thanks to missing options.
+        optionsMap.delete(optionName);
+
+        // Just like if an option is missing, a valueless option cancels
+        // forming the rest of the output.
+        if (isValueless(optionValue)) {
+          // It's also an error, *except* if this option is one of the ones
+          // that we're indicated to *expect* might be valueless! In that case,
+          // we still need to stop forming the string (and mark a separate flag
+          // so that we return a blank), but it's not an error.
+          if (expectedValuelessOptionNames.has(optionName)) {
+            seenExpectedValuelessOption = true;
+          } else {
+            valuelessOptionNames.add(optionName);
+          }
+
           return undefined;
         }
+
+        if (canceledForming) {
+          return undefined;
+        }
+
+        return optionValue;
       },
     });
 
@@ -272,17 +317,30 @@ export class Language extends Thing {
       Array.from(optionsMap.keys());
 
     withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => {
+      const names = set => Array.from(set).join(', ');
+
       if (!empty(missingOptionNames)) {
-        const names = Array.from(missingOptionNames).join(`, `);
-        push(new Error(`Missing options: ${names}`));
+        push(new Error(
+          `Missing options: ${names(missingOptionNames)}`));
+      }
+
+      if (!empty(valuelessOptionNames)) {
+        push(new Error(
+          `Valueless options: ${names(valuelessOptionNames)}`));
       }
 
       if (!empty(misplacedOptionNames)) {
-        const names = Array.from(misplacedOptionNames).join(`, `);
-        push(new Error(`Unexpected options: ${names}`));
+        push(new Error(
+          `Unexpected options: ${names(misplacedOptionNames)}`));
       }
     });
 
+    // If an option was valueless as marked to expect, then that indicates
+    // the whole string should be treated as blank content.
+    if (seenExpectedValuelessOption) {
+      return html.blank();
+    }
+
     return output;
   }
 
@@ -416,11 +474,32 @@ export class Language extends Thing {
   }
 
   formatDate(date) {
+    // Null or undefined date is blank content.
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
     this.assertIntlAvailable('intl_date');
     return this.intl_date.format(date);
   }
 
   formatDateRange(startDate, endDate) {
+    // formatDateRange expects both values to be present, but if both are null
+    // or both are undefined, that's just blank content.
+    const hasStart = startDate !== null && startDate !== undefined;
+    const hasEnd = endDate !== null && endDate !== undefined;
+    if (!hasStart || !hasEnd) {
+      if (startDate === endDate) {
+        return html.blank();
+      } else if (hasStart) {
+        throw new Error(`Expected both start and end of date range, got only start`);
+      } else if (hasEnd) {
+        throw new Error(`Expected both start and end of date range, got only end`);
+      } else {
+        throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`);
+      }
+    }
+
     this.assertIntlAvailable('intl_date');
     return this.intl_date.formatRange(startDate, endDate);
   }
@@ -431,6 +510,17 @@ export class Language extends Thing {
     days: numDays = 0,
     approximate = false,
   }) {
+    // Give up if any of years, months, or days is null or undefined.
+    // These default to zero, so something's gone pretty badly wrong to
+    // pass in all or partial missing values.
+    if (
+      numYears === undefined || numYears === null ||
+      numMonths === undefined || numMonths === null ||
+      numDays === undefined || numDays === null
+    ) {
+      throw new Error(`Expected values or default zero for years, months, and days`);
+    }
+
     let basis;
 
     const years = this.countYears(numYears, {unit: true});
@@ -468,6 +558,14 @@ export class Language extends Thing {
     approximate = true,
     absolute = true,
   } = {}) {
+    // Give up if current and/or reference date is null or undefined.
+    if (
+      currentDate === undefined || currentDate === null ||
+      referenceDate === undefined || referenceDate === null
+    ) {
+      throw new Error(`Expected values for currentDate and referenceDate`);
+    }
+
     const currentInstant = toTemporalInstant.apply(currentDate);
     const referenceInstant = toTemporalInstant.apply(referenceDate);
 
@@ -528,6 +626,12 @@ export class Language extends Thing {
   }
 
   formatDuration(secTotal, {approximate = false, unit = false} = {}) {
+    // Null or undefined duration is blank content.
+    if (secTotal === null || secTotal === undefined) {
+      return html.blank();
+    }
+
+    // Zero duration is a "missing" string.
     if (secTotal === 0) {
       return this.formatString('count.duration.missing');
     }
@@ -565,6 +669,11 @@ export class Language extends Thing {
       throw new TypeError(`externalLinkSpec unavailable`);
     }
 
+    // Null or undefined url is blank content.
+    if (url === null || url === undefined) {
+      return html.blank();
+    }
+
     isExternalLinkContext(context);
 
     if (style === 'all') {
@@ -589,16 +698,31 @@ export class Language extends Thing {
   }
 
   formatIndex(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     this.assertIntlAvailable('intl_pluralOrdinal');
     return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
   }
 
   formatNumber(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     this.assertIntlAvailable('intl_number');
     return this.intl_number.format(value);
   }
 
   formatWordCount(value) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     const num = this.formatNumber(
       value > 1000 ? Math.floor(value / 100) / 10 : value
     );
@@ -612,6 +736,11 @@ export class Language extends Thing {
   }
 
   #formatListHelper(array, processFn) {
+    // Empty lists, null, and undefined are blank content.
+    if (empty(array) || array === null || array === undefined) {
+      return html.blank();
+    }
+
     // Operate on "insertion markers" instead of the actual contents of the
     // array, because the process function (likely an Intl operation) is taken
     // to only operate on strings. We'll insert the contents of the array back
@@ -673,10 +802,22 @@ export class Language extends Thing {
 
   // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB
   formatFileSize(bytes) {
-    if (!bytes) return '';
+    // Null or undefined bytes is blank content.
+    if (bytes === null || bytes === undefined) {
+      return html.blank();
+    }
+
+    // Zero bytes is blank content.
+    if (bytes === 0) {
+      return html.blank();
+    }
 
     bytes = parseInt(bytes);
-    if (isNaN(bytes)) return '';
+
+    // Non-number bytes is blank content! Wow.
+    if (isNaN(bytes)) {
+      return html.blank();
+    }
 
     const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10;
 
@@ -700,10 +841,42 @@ export class Language extends Thing {
       return this.formatString('count.fileSize.bytes', {bytes});
     }
   }
+
+  // Utility function to quickly provide a useful string key
+  // (generally a prefix) to stuff nested beneath it.
+  encapsulate(...args) {
+    const fn =
+      (typeof args.at(-1) === 'function'
+        ? args.at(-1)
+        : null);
+
+    const parts =
+      (fn
+        ? args.slice(0, -1)
+        : args);
+
+    const capsule =
+      this.#joinKeyParts(parts);
+
+    if (fn) {
+      return fn(capsule);
+    } else {
+      return capsule;
+    }
+  }
+
+  #joinKeyParts(parts) {
+    return parts.filter(Boolean).join('.');
+  }
 }
 
 const countHelper = (stringKey, optionName = stringKey) =>
   function(value, {unit = false} = {}) {
+    // Null or undefined value is blank content.
+    if (value === null || value === undefined) {
+      return html.blank();
+    }
+
     return this.formatString(
       unit
         ? `count.${stringKey}.withUnit.` + this.getUnitForm(value)
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 725b1bb7..4aaf364c 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -18,7 +18,6 @@ import {
 } from '#yaml';
 
 import {withPropertyFromObject} from '#composite/data';
-import {withResolvedContribs} from '#composite/wiki-data';
 
 import {
   exitWithoutDependency,
@@ -26,9 +25,16 @@ import {
   exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
+  exposeWhetherDependencyAvailable,
 } from '#composite/control-flow';
 
 import {
+  withRecontextualizedContributionList,
+  withRedatedContributionList,
+  withResolvedContribs,
+} from '#composite/wiki-data';
+
+import {
   additionalFiles,
   additionalNameList,
   commentary,
@@ -43,8 +49,9 @@ import {
   referenceList,
   reverseReferenceList,
   simpleDate,
-  singleReference,
   simpleString,
+  singleReference,
+  thing,
   urls,
   wikiData,
 } from '#composite/wiki-properties';
@@ -52,21 +59,31 @@ import {
 import {
   exitWithoutUniqueCoverArt,
   inferredAdditionalNameList,
+  inheritContributionListFromOriginalRelease,
   inheritFromOriginalRelease,
   sharedAdditionalNameList,
   trackReverseReferenceList,
   withAlbum,
   withAlwaysReferenceByDirectory,
   withContainingTrackSection,
+  withDate,
   withHasUniqueCoverArt,
+  withOriginalRelease,
   withOtherReleases,
   withPropertyFromAlbum,
+  withTrackArtDate,
 } from '#composite/things/track';
 
 export class Track extends Thing {
   static [Thing.referenceType] = 'track';
 
-  static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({
+  static [Thing.getPropertyDescriptors] = ({
+    Album,
+    ArtTag,
+    Artist,
+    Flash,
+    WikiInfo,
+  }) => ({
     // Update & expose
 
     name: name('Unnamed Track'),
@@ -137,27 +154,14 @@ export class Track extends Thing {
       }),
     ],
 
-    // Date of cover art release. Like coverArtFileExtension, this represents
-    // only the track's own unique cover artwork, if any. This exposes only as
-    // the track's own coverArtDate or its album's trackArtDate, so if neither
-    // is specified, this value is null.
     coverArtDate: [
-      withHasUniqueCoverArt(),
-
-      exitWithoutDependency({
-        dependency: '#hasUniqueCoverArt',
-        mode: input.value('falsy'),
-      }),
-
-      exposeUpdateValueOrContinue({
-        validate: input.value(isDate),
-      }),
-
-      withPropertyFromAlbum({
-        property: input.value('trackArtDate'),
+      withTrackArtDate({
+        from: input.updateValue({
+          validate: isDate,
+        }),
       }),
 
-      exposeDependency({dependency: '#album.trackArtDate'}),
+      exposeDependency({dependency: '#trackArtDate'}),
     ],
 
     coverArtDimensions: [
@@ -192,12 +196,15 @@ export class Track extends Thing {
     }),
 
     artistContribs: [
-      inheritFromOriginalRelease({
-        notFoundValue: input.value([]),
-      }),
+      inheritContributionListFromOriginalRelease(),
+
+      withDate(),
 
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackArtistContributions'),
+        date: '#date',
       }).outputs({
         '#resolvedContribs': '#artistContribs',
       }),
@@ -211,15 +218,28 @@ export class Track extends Thing {
         property: input.value('artistContribs'),
       }),
 
+      withRecontextualizedContributionList({
+        list: '#album.artistContribs',
+        artistProperty: input.value('trackArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.artistContribs',
+        date: '#date',
+      }),
+
       exposeDependency({dependency: '#album.artistContribs'}),
     ],
 
     contributorContribs: [
-      inheritFromOriginalRelease({
-        notFoundValue: input.value([]),
-      }),
+      inheritContributionListFromOriginalRelease(),
 
-      contributionList(),
+      withDate(),
+
+      contributionList({
+        date: '#date',
+        artistProperty: input.value('trackContributorContributions'),
+      }),
     ],
 
     // Cover artists aren't inherited from the original release, since it
@@ -230,8 +250,15 @@ export class Track extends Thing {
         value: input.value([]),
       }),
 
+      withTrackArtDate({
+        fallback: input.value(true),
+      }),
+
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
+        thingProperty: input.thisProperty(),
+        artistProperty: input.value('trackCoverArtistContributions'),
+        date: '#trackArtDate',
       }).outputs({
         '#resolvedContribs': '#coverArtistContribs',
       }),
@@ -245,6 +272,16 @@ export class Track extends Thing {
         property: input.value('trackCoverArtistContribs'),
       }),
 
+      withRecontextualizedContributionList({
+        list: '#album.trackCoverArtistContribs',
+        artistProperty: input.value('trackCoverArtistContributions'),
+      }),
+
+      withRedatedContributionList({
+        list: '#album.trackCoverArtistContribs',
+        date: '#trackArtDate',
+      }),
+
       exposeDependency({dependency: '#album.trackCoverArtistContribs'}),
     ],
 
@@ -306,6 +343,10 @@ export class Track extends Thing {
       class: input.value(Track),
     }),
 
+    wikiInfo: thing({
+      class: input.value(WikiInfo),
+    }),
+
     // Expose only
 
     commentatorArtists: commentatorArtists(),
@@ -316,13 +357,8 @@ export class Track extends Thing {
     ],
 
     date: [
-      exposeDependencyOrContinue({dependency: 'dateFirstReleased'}),
-
-      withPropertyFromAlbum({
-        property: input.value('date'),
-      }),
-
-      exposeDependency({dependency: '#album.date'}),
+      withDate(),
+      exposeDependency({dependency: '#date'}),
     ],
 
     hasUniqueCoverArt: [
@@ -330,6 +366,23 @@ export class Track extends Thing {
       exposeDependency({dependency: '#hasUniqueCoverArt'}),
     ],
 
+    isOriginalRelease: [
+      withOriginalRelease(),
+
+      exposeWhetherDependencyAvailable({
+        dependency: '#originalRelease',
+        negate: input.value(true),
+      }),
+    ],
+
+    isRerelease: [
+      withOriginalRelease(),
+
+      exposeWhetherDependencyAvailable({
+        dependency: '#originalRelease',
+      }),
+    ],
+
     otherReleases: [
       withOtherReleases(),
       exposeDependency({dependency: '#otherReleases'}),
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 316bd3bb..b7b7b01c 100644
--- a/src/data/things/wiki-info.js
+++ b/src/data/things/wiki-info.js
@@ -3,8 +3,18 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml';
 import {input} from '#composite';
 import find from '#find';
 import Thing from '#thing';
-import {isColor, isLanguageCode, isName, isURL} from '#validators';
-
+import {parseContributionPresets} from '#yaml';
+
+import {
+  isBoolean,
+  isColor,
+  isContributionPresetList,
+  isLanguageCode,
+  isName,
+  isURL,
+} from '#validators';
+
+import {exitWithoutDependency} from '#composite/control-flow';
 import {contentString, flag, name, referenceList, wikiData}
   from '#composite/wiki-properties';
 
@@ -57,6 +67,11 @@ export class WikiInfo extends Thing {
       data: 'groupData',
     }),
 
+    contributionPresets: {
+      flags: {update: true, expose: true},
+      update: {validate: isContributionPresetList},
+    },
+
     // Feature toggles
     enableFlashesAndGames: flag(false),
     enableListings: flag(false),
@@ -64,8 +79,26 @@ export class WikiInfo extends Thing {
     enableArtTagUI: flag(false),
     enableGroupUI: flag(false),
 
+    enableSearch: [
+      exitWithoutDependency({
+        dependency: 'searchDataAvailable',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      flag(true),
+    ],
+
     // Update only
 
+    searchDataAvailable: {
+      flags: {update: true},
+      update: {
+        validate: isBoolean,
+        default: false,
+      },
+    },
+
     groupData: wikiData({
       class: input.value(Group),
     }),
@@ -86,6 +119,11 @@ export class WikiInfo extends Thing {
       'Enable News': {property: 'enableNews'},
       'Enable Art Tag UI': {property: 'enableArtTagUI'},
       'Enable Group UI': {property: 'enableGroupUI'},
+
+      'Contribution Presets': {
+        property: 'contributionPresets',
+        transform: parseContributionPresets,
+      },
     },
   };