« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data
diff options
context:
space:
mode:
Diffstat (limited to 'src/data')
-rw-r--r--src/data/things/album.js181
-rw-r--r--src/data/things/composite.js223
-rw-r--r--src/data/things/index.js13
-rw-r--r--src/data/things/thing.js156
-rw-r--r--src/data/things/track.js293
5 files changed, 531 insertions, 335 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js
index fb0c3427..9ca662a0 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -1,15 +1,17 @@
 import find from '#find';
 import {stitchArrays} from '#sugar';
-import {isDate, isDimensions, isTrackSectionList} from '#validators';
+import {isDate, isTrackSectionList} from '#validators';
 
 import {
-  compositeFrom,
   exitWithoutDependency,
   exitWithoutUpdateValue,
   exposeDependency,
   exposeUpdateValueOrContinue,
+  fillMissingListItems,
   withFlattenedArray,
+  withPropertiesFromList,
   withUnflattenedArray,
+  withUpdateValueAsDependency,
 } from '#composite';
 
 import Thing, {
@@ -19,7 +21,9 @@ import Thing, {
   commentatorArtists,
   contribsPresent,
   contributionList,
+  dimensions,
   directory,
+  exitWithoutContribs,
   fileExtension,
   flag,
   name,
@@ -28,7 +32,6 @@ import Thing, {
   simpleString,
   urls,
   wikiData,
-  withResolvedContribs,
   withResolvedReferenceList,
 } from './thing.js';
 
@@ -47,83 +50,92 @@ export class Album extends Thing {
     trackArtDate: simpleDate(),
     dateAddedToWiki: simpleDate(),
 
-    coverArtDate: compositeFrom(`Album.coverArtDate`, [
-      withResolvedContribs({from: 'coverArtistContribs'}),
-      exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}),
-
+    coverArtDate: [
+      exitWithoutContribs({contribs: 'coverArtistContribs'}),
       exposeUpdateValueOrContinue(),
       exposeDependency({
         dependency: 'date',
         update: {validate: isDate},
       }),
-    ]),
+    ],
 
-    artistContribs: contributionList(),
-    coverArtistContribs: contributionList(),
-    trackCoverArtistContribs: contributionList(),
-    wallpaperArtistContribs: contributionList(),
-    bannerArtistContribs: contributionList(),
+    coverArtFileExtension: [
+      exitWithoutContribs({contribs: 'coverArtistContribs'}),
+      fileExtension('jpg'),
+    ],
 
-    groups: referenceList({
-      class: Group,
-      find: find.group,
-      data: 'groupData',
-    }),
+    trackCoverArtFileExtension: fileExtension('jpg'),
 
-    artTags: referenceList({
-      class: ArtTag,
-      find: find.artTag,
-      data: 'artTagData',
-    }),
+    wallpaperFileExtension: [
+      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
+      fileExtension('jpg'),
+    ],
+
+    bannerFileExtension: [
+      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+      fileExtension('jpg'),
+    ],
+
+    wallpaperStyle: [
+      exitWithoutContribs({contribs: 'wallpaperArtistContribs'}),
+      simpleString(),
+    ],
+
+    bannerStyle: [
+      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+      simpleString(),
+    ],
+
+    bannerDimensions: [
+      exitWithoutContribs({contribs: 'bannerArtistContribs'}),
+      dimensions(),
+    ],
 
-    trackSections: compositeFrom(`Album.trackSections`, [
+    hasTrackNumbers: flag(true),
+    isListedOnHomepage: flag(true),
+    isListedInGalleries: flag(true),
+
+    commentary: commentary(),
+    additionalFiles: additionalFiles(),
+
+    trackSections: [
       exitWithoutDependency({dependency: 'trackData', value: []}),
       exitWithoutUpdateValue({value: [], mode: 'empty'}),
 
-      {
-        transform: (trackSections, continuation) =>
-          continuation(trackSections, {
-            '#sectionTrackRefs':
-              trackSections.map(section => section.tracks),
-
-            '#sectionDateOriginallyReleased':
-              trackSections
-                .map(({dateOriginallyReleased}) => dateOriginallyReleased ?? null),
-
-            '#sectionIsDefaultTrackSection':
-              trackSections
-                .map(({isDefaultTrackSection}) => isDefaultTrackSection ?? false),
-          }),
-      },
+      withUpdateValueAsDependency({into: '#sections'}),
 
-      {
-        dependencies: ['color'],
-        transform: (trackSections, {color: albumColor}, continuation) =>
-          continuation(trackSections, {
-            '#sectionColor':
-              trackSections
-                .map(({color: sectionColor}) => sectionColor ?? albumColor),
-          }),
-      },
+      withPropertiesFromList({
+        list: '#sections',
+        properties: [
+          'tracks',
+          'dateOriginallyReleased',
+          'isDefaultTrackSection',
+          'color',
+        ],
+      }),
+
+      fillMissingListItems({list: '#sections.tracks', value: []}),
+      fillMissingListItems({list: '#sections.isDefaultTrackSection', value: false}),
+      fillMissingListItems({list: '#sections.color', dependency: 'color'}),
 
       withFlattenedArray({
-        from: '#sectionTrackRefs',
+        from: '#sections.tracks',
         into: '#trackRefs',
-        intoIndices: '#sectionStartIndex',
+        intoIndices: '#sections.startIndex',
       }),
 
       withResolvedReferenceList({
         list: '#trackRefs',
         data: 'trackData',
-        mode: 'null',
+        notFoundMode: 'null',
         find: find.track,
         into: '#tracks',
       }),
 
       withUnflattenedArray({
         from: '#tracks',
-        fromIndices: '#sectionStartIndex',
-        into: '#sectionTracks',
+        fromIndices: '#sections.startIndex',
+        into: '#sections.tracks',
       }),
 
       {
@@ -133,19 +145,19 @@ export class Album extends Thing {
 
         expose: {
           dependencies: [
-            '#sectionTracks',
-            '#sectionColor',
-            '#sectionDateOriginallyReleased',
-            '#sectionIsDefaultTrackSection',
-            '#sectionStartIndex',
+            '#sections.tracks',
+            '#sections.color',
+            '#sections.dateOriginallyReleased',
+            '#sections.isDefaultTrackSection',
+            '#sections.startIndex',
           ],
 
           transform: (trackSections, {
-            '#sectionTracks': tracks,
-            '#sectionColor': color,
-            '#sectionDateOriginallyReleased': dateOriginallyReleased,
-            '#sectionIsDefaultTrackSection': isDefaultTrackSection,
-            '#sectionStartIndex': startIndex,
+            '#sections.tracks': tracks,
+            '#sections.color': color,
+            '#sections.dateOriginallyReleased': dateOriginallyReleased,
+            '#sections.isDefaultTrackSection': isDefaultTrackSection,
+            '#sections.startIndex': startIndex,
           }) =>
             stitchArrays({
               tracks,
@@ -156,32 +168,25 @@ export class Album extends Thing {
             }),
         },
       },
-    ]),
-
-    coverArtFileExtension: compositeFrom(`Album.coverArtFileExtension`, [
-      withResolvedContribs({from: 'coverArtistContribs'}),
-      exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}),
-      fileExtension('jpg'),
-    ]),
-
-    trackCoverArtFileExtension: fileExtension('jpg'),
-
-    wallpaperStyle: simpleString(),
-    wallpaperFileExtension: fileExtension('jpg'),
+    ],
 
-    bannerStyle: simpleString(),
-    bannerFileExtension: fileExtension('jpg'),
-    bannerDimensions: {
-      flags: {update: true, expose: true},
-      update: {validate: isDimensions},
-    },
+    artistContribs: contributionList(),
+    coverArtistContribs: contributionList(),
+    trackCoverArtistContribs: contributionList(),
+    wallpaperArtistContribs: contributionList(),
+    bannerArtistContribs: contributionList(),
 
-    hasTrackNumbers: flag(true),
-    isListedOnHomepage: flag(true),
-    isListedInGalleries: flag(true),
+    groups: referenceList({
+      class: Group,
+      find: find.group,
+      data: 'groupData',
+    }),
 
-    commentary: commentary(),
-    additionalFiles: additionalFiles(),
+    artTags: referenceList({
+      class: ArtTag,
+      find: find.artTag,
+      data: 'artTagData',
+    }),
 
     // Update only
 
@@ -198,7 +203,7 @@ export class Album extends Thing {
     hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}),
     hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}),
 
-    tracks: compositeFrom(`Album.tracks`, [
+    tracks: [
       exitWithoutDependency({dependency: 'trackData', value: []}),
       exitWithoutDependency({dependency: 'trackSections', mode: 'empty', value: []}),
 
@@ -218,7 +223,7 @@ export class Album extends Thing {
       }),
 
       exposeDependency({dependency: '#resolvedReferenceList'}),
-    ]),
+    ],
   });
 
   static [Thing.getSerializeDescriptors] = ({
diff --git a/src/data/things/composite.js b/src/data/things/composite.js
index 1f6482f6..2dd92f17 100644
--- a/src/data/things/composite.js
+++ b/src/data/things/composite.js
@@ -432,13 +432,8 @@ export function compositeFrom(firstArg, secondArg) {
           ? step.expose
           : step);
 
-      const stepComputes = !!expose.compute;
-      const stepTransforms = !!expose.transform;
-
-      if (!stepComputes && !stepTransforms) {
-        push(new TypeError(`Steps must provide compute or transform (or both)`));
-        return;
-      }
+      const stepComputes = !!expose?.compute;
+      const stepTransforms = !!expose?.transform;
 
       if (
         stepTransforms && !stepComputes &&
@@ -459,7 +454,7 @@ export function compositeFrom(firstArg, secondArg) {
       // Unmapped dependencies are exposed on the final composition only if
       // they're "public", i.e. pointing to update values of other properties
       // on the CacheableObject.
-      for (const dependency of expose.dependencies ?? []) {
+      for (const dependency of expose?.dependencies ?? []) {
         if (typeof dependency === 'string' && dependency.startsWith('#')) {
           continue;
         }
@@ -470,22 +465,14 @@ export function compositeFrom(firstArg, secondArg) {
       // Mapped dependencies are always exposed on the final composition.
       // These are explicitly for reading values which are named outside of
       // the current compositional step.
-      for (const dependency of Object.values(expose.mapDependencies ?? {})) {
+      for (const dependency of Object.values(expose?.mapDependencies ?? {})) {
         exposeDependencies.add(dependency);
       }
     });
   }
 
-  if (!baseComposes) {
-    if (baseUpdates) {
-      if (!anyStepsTransform) {
-        aggregate.push(new TypeError(`Expected at least one step to transform`));
-      }
-    } else {
-      if (!anyStepsCompute) {
-        aggregate.push(new TypeError(`Expected at least one step to compute`));
-      }
-    }
+  if (!baseComposes && !baseUpdates && !anyStepsCompute) {
+    aggregate.push(new TypeError(`Expected at least one step to compute`));
   }
 
   aggregate.close();
@@ -615,6 +602,11 @@ export function compositeFrom(firstArg, secondArg) {
           ? step.expose
           : step);
 
+      if (!expose) {
+        debug(() => `step #${i+1} - no expose description, nothing to do for this step`);
+        continue;
+      }
+
       const callingTransformForThisStep =
         expectingTransform && expose.transform;
 
@@ -1089,6 +1081,199 @@ export function withUpdateValueAsDependency({
   };
 }
 
+// Gets a property of some object (in a dependency) and provides that value.
+// If the object itself is null, or the object doesn't have the listed property,
+// the provided dependency will also be null.
+export function withPropertyFromObject({
+  object,
+  property,
+  into = null,
+}) {
+  into ??=
+    (object.startsWith('#')
+      ? `${object}.${property}`
+      : `#${object}.${property}`);
+
+  return {
+    annotation: `withPropertyFromObject`,
+    flags: {expose: true, compose: true},
+
+    expose: {
+      mapDependencies: {object},
+      mapContinuation: {into},
+      options: {property},
+
+      compute: ({object, '#options': {property}}, continuation) =>
+        (object === null || object === undefined
+          ? continuation({into: null})
+          : continuation({into: object[property] ?? null})),
+    },
+  };
+}
+
+// Gets the listed properties from some object, providing each property's value
+// as a dependency prefixed with the same name as the object (by default).
+// If the object itself is null, all provided dependencies will be null;
+// if it's missing only select properties, those will be provided as null.
+export function withPropertiesFromObject({
+  object,
+  properties,
+  prefix =
+    (object.startsWith('#')
+      ? object
+      : `#${object}`),
+}) {
+  return {
+    annotation: `withPropertiesFromObject`,
+    flags: {expose: true, compose: true},
+
+    expose: {
+      mapDependencies: {object},
+      options: {prefix, properties},
+
+      compute: ({object, '#options': {prefix, properties}}, continuation) =>
+        continuation(
+          Object.fromEntries(
+            properties.map(property => [
+              `${prefix}.${property}`,
+              (object === null || object === undefined
+                ? null
+                : object[property] ?? null),
+            ]))),
+    },
+  };
+}
+
+// Gets a property from each of a list of objects (in a dependency) and
+// provides the results. This doesn't alter any list indices, so positions
+// which were null in the original list are kept null here. Objects which don't
+// have the specified property are retained in-place as null.
+export function withPropertyFromList({
+  list,
+  property,
+  into = null,
+}) {
+  into ??=
+    (list.startsWith('#')
+      ? `${list}.${property}`
+      : `#${list}.${property}`);
+
+  return {
+    annotation: `withPropertyFromList`,
+    flags: {expose: true, compose: true},
+
+    expose: {
+      mapDependencies: {list},
+      mapContinuation: {into},
+      options: {property},
+
+      compute({list, '#options': {property}}, continuation) {
+        if (list === undefined || empty(list)) {
+          return continuation({into: []});
+        }
+
+        return continuation({
+          into:
+            list.map(item =>
+              (item === null || item === undefined
+                ? null
+                : item[property] ?? null)),
+        });
+      },
+    },
+  };
+}
+
+// Gets the listed properties from each of a list of objects, providing lists
+// of property values each into a dependency prefixed with the same name as the
+// list (by default). Like withPropertyFromList, this doesn't alter indices.
+export function withPropertiesFromList({
+  list,
+  properties,
+  prefix =
+    (list.startsWith('#')
+      ? list
+      : `#${list}`),
+}) {
+  return {
+    annotation: `withPropertiesFromList`,
+    flags: {expose: true, compose: true},
+
+    expose: {
+      mapDependencies: {list},
+      options: {prefix, properties},
+
+      compute({list, '#options': {prefix, properties}}, continuation) {
+        const lists =
+          Object.fromEntries(
+            properties.map(property => [`${prefix}.${property}`, []]));
+
+        for (const item of list) {
+          for (const property of properties) {
+            lists[`${prefix}.${property}`].push(
+              (item === null || item === undefined
+                ? null
+                : item[property] ?? null));
+          }
+        }
+
+        return continuation(lists);
+      }
+    }
+  }
+}
+
+// Replaces items of a list, which are null or undefined, with some fallback
+// value, either a constant (set {value}) or from a dependency ({dependency}).
+// By default, this replaces the passed dependency.
+export function fillMissingListItems({
+  list,
+  value,
+  dependency,
+  into = list,
+}) {
+  if (value !== undefined && dependency !== undefined) {
+    throw new TypeError(`Don't provide both value and dependency`);
+  }
+
+  if (value === undefined && dependency === undefined) {
+    throw new TypeError(`Missing value or dependency`);
+  }
+
+  if (dependency) {
+    return {
+      annotation: `fillMissingListItems.fromDependency`,
+      flags: {expose: true, compose: true},
+
+      expose: {
+        mapDependencies: {list, dependency},
+        mapContinuation: {into},
+
+        compute: ({list, dependency}, continuation) =>
+          continuation({
+            into: list.map(item => item ?? dependency),
+          }),
+      },
+    };
+  } else {
+    return {
+      annotation: `fillMissingListItems.fromValue`,
+      flags: {expose: true, compose: true},
+
+      expose: {
+        mapDependencies: {list},
+        mapContinuation: {into},
+        options: {value},
+
+        compute: ({list, '#options': {value}}, continuation) =>
+          continuation({
+            into: list.map(item => item ?? value),
+          }),
+      },
+    };
+  }
+}
+
 // Flattens an array with one level of nested arrays, providing as dependencies
 // both the flattened array as well as the original starting indices of each
 // successive source array.
diff --git a/src/data/things/index.js b/src/data/things/index.js
index 3b73a772..4d8d9d1f 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -2,6 +2,7 @@ import * as path from 'node:path';
 import {fileURLToPath} from 'node:url';
 
 import {logError} from '#cli';
+import {compositeFrom} from '#composite';
 import * as serialize from '#serialize';
 import {openAggregate, showAggregate} from '#sugar';
 
@@ -130,8 +131,16 @@ function evaluatePropertyDescriptors() {
         throw new Error(`Missing [Thing.getPropertyDescriptors] function`);
       }
 
-      constructor.propertyDescriptors =
-        constructor[Thing.getPropertyDescriptors](opts);
+      const results = constructor[Thing.getPropertyDescriptors](opts);
+
+      for (const [key, value] of Object.entries(results)) {
+        if (Array.isArray(value)) {
+          results[key] = compositeFrom(`${constructor.name}.${key}`, value);
+          continue;
+        }
+      }
+
+      constructor.propertyDescriptors = results;
     },
 
     showFailedClasses(failedClasses) {
diff --git a/src/data/things/thing.js b/src/data/things/thing.js
index 0f47dc90..b1a9a802 100644
--- a/src/data/things/thing.js
+++ b/src/data/things/thing.js
@@ -5,7 +5,7 @@ import {inspect} from 'node:util';
 
 import {colors} from '#cli';
 import find from '#find';
-import {empty, stitchArrays} from '#sugar';
+import {empty, stitchArrays, unique} from '#sugar';
 import {filterMultipleArrays, getKebabCase} from '#wiki-data';
 
 import {
@@ -16,6 +16,7 @@ import {
   exposeDependencyOrContinue,
   raiseWithoutDependency,
   withResultOfAvailabilityCheck,
+  withPropertiesFromList,
   withUpdateValueAsDependency,
 } from '#composite';
 
@@ -26,7 +27,9 @@ import {
   isColor,
   isContributionList,
   isDate,
+  isDimensions,
   isDirectory,
+  isDuration,
   isFileExtension,
   isName,
   isString,
@@ -123,6 +126,24 @@ export function fileExtension(defaultFileExtension = null) {
   };
 }
 
+// Plain ol' image dimensions. This is a two-item array of positive integers,
+// corresponding to width and height respectively.
+export function dimensions() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDimensions},
+  };
+}
+
+// Duration! This is a number of seconds, possibly floating point, always
+// at minimum zero.
+export function duration() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isDuration},
+  };
+}
+
 // Straightforward flag descriptor for a variety of property purposes.
 // Provide a default value, true or false!
 export function flag(defaultValue = false) {
@@ -331,29 +352,40 @@ export function wikiData(thingClass) {
 // This one's kinda tricky: it parses artist "references" from the
 // commentary content, and finds the matching artist for each reference.
 // This is mostly useful for credits and listings on artist pages.
-export function commentatorArtists(){
-  return {
-    flags: {expose: true},
+export function commentatorArtists() {
+  return compositeFrom(`commentatorArtists`, [
+    exitWithoutDependency({dependency: 'commentary', mode: 'falsy', value: []}),
 
-    expose: {
-      dependencies: ['artistData', 'commentary'],
-
-      compute: ({artistData, commentary}) =>
-        artistData && commentary
-          ? Array.from(
-              new Set(
-                Array.from(
-                  commentary
-                    .replace(/<\/?b>/g, '')
-                    .matchAll(/<i>(?<who>.*?):<\/i>/g)
-                ).map(({groups: {who}}) =>
-                  find.artist(who, artistData, {mode: 'quiet'})
-                )
-              )
-            )
-          : [],
+    {
+      dependencies: ['commentary'],
+      compute: ({commentary}, continuation) =>
+        continuation({
+          '#artistRefs':
+            Array.from(
+              commentary
+                .replace(/<\/?b>/g, '')
+                .matchAll(/<i>(?<who>.*?):<\/i>/g))
+              .map(({groups: {who}}) => who),
+        }),
     },
-  };
+
+    withResolvedReferenceList({
+      list: '#artistRefs',
+      data: 'artistData',
+      into: '#artists',
+      find: find.artist,
+    }),
+
+    {
+      flags: {expose: true},
+
+      expose: {
+        dependencies: ['#artists'],
+        compute: ({'#artists': artists}) =>
+          unique(artists),
+      },
+    },
+  ]);
 }
 
 // Compositional utilities
@@ -374,27 +406,24 @@ export function withResolvedContribs({
       raise: {into: []},
     }),
 
-    {
-      mapDependencies: {from},
-      compute: ({from}, continuation) =>
-        continuation({
-          '#artistRefs': from.map(({who}) => who),
-          '#what': from.map(({what}) => what),
-        }),
-    },
+    withPropertiesFromList({
+      list: from,
+      properties: ['who', 'what'],
+      prefix: '#contribs',
+    }),
 
     withResolvedReferenceList({
-      list: '#artistRefs',
+      list: '#contribs.who',
       data: 'artistData',
-      into: '#who',
+      into: '#contribs.who',
       find: find.artist,
       notFoundMode: 'null',
     }),
 
     {
-      dependencies: ['#who', '#what'],
+      dependencies: ['#contribs.who', '#contribs.what'],
       mapContinuation: {into},
-      compute({'#who': who, '#what': what}, continuation) {
+      compute({'#contribs.who': who, '#contribs.what': what}, continuation) {
         filterMultipleArrays(who, what, (who, _what) => who);
         return continuation({
           into: stitchArrays({who, what}),
@@ -404,6 +433,23 @@ export function withResolvedContribs({
   ]);
 }
 
+// Shorthand for exiting if the contribution list (usually a property's update
+// value) resolves to empty - ensuring that the later computed results are only
+// returned if these contributions are present.
+export function exitWithoutContribs({
+  contribs,
+  value = null,
+}) {
+  return compositeFrom(`exitWithoutContribs`, [
+    withResolvedContribs({from: contribs}),
+    exitWithoutDependency({
+      dependency: '#resolvedContribs',
+      mode: 'empty',
+      value,
+    }),
+  ]);
+}
+
 // Resolves a reference by using the provided find function to match it
 // within the provided thingData dependency. This will early exit if the
 // data dependency is null, or, if notFoundMode is set to 'exit', if the find
@@ -480,29 +526,45 @@ export function withResolvedReferenceList({
     }),
 
     {
-      options: {findFunction, notFoundMode},
       mapDependencies: {list, data},
-      mapContinuation: {matches: into},
+      options: {findFunction},
 
-      compute({list, data, '#options': {findFunction, notFoundMode}}, continuation) {
-        let matches =
-          list.map(ref => findFunction(ref, data, {mode: 'quiet'}));
+      compute: ({list, data, '#options': {findFunction}}, continuation) =>
+        continuation({
+          '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})),
+        }),
+    },
 
-        if (matches.every(match => match)) {
-          return continuation.raise({matches});
-        }
+    {
+      dependencies: ['#matches'],
+      mapContinuation: {into},
 
-        switch (notFoundMode) {
-          case 'filter':
-            matches = matches.filter(match => match);
-            return continuation.raise({matches});
+      compute: ({'#matches': matches}, continuation) =>
+        (matches.every(match => match)
+          ? continuation.raise({into: matches})
+          : continuation()),
+    },
 
+    {
+      dependencies: ['#matches'],
+      options: {notFoundMode},
+      mapContinuation: {into},
+
+      compute({
+        '#matches': matches,
+        '#options': {notFoundMode},
+      }, continuation) {
+        switch (notFoundMode) {
           case 'exit':
             return continuation.exit([]);
 
+          case 'filter':
+            matches = matches.filter(match => match);
+            return continuation.raise({into: matches});
+
           case 'null':
             matches = matches.map(match => match ?? null);
-            return continuation.raise({matches});
+            return continuation.raise({into: matches});
         }
       },
     },
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 8263d399..a8d59023 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -11,6 +11,7 @@ import {
   exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
+  withPropertyFromObject,
   withResultOfAvailabilityCheck,
   withUpdateValueAsDependency,
 } from '#composite';
@@ -19,7 +20,6 @@ import {
   isColor,
   isContributionList,
   isDate,
-  isDuration,
   isFileExtension,
 } from '#validators';
 
@@ -31,6 +31,7 @@ import Thing, {
   commentatorArtists,
   contributionList,
   directory,
+  duration,
   flag,
   name,
   referenceList,
@@ -54,43 +55,23 @@ export class Track extends Thing {
     name: name('Unnamed Track'),
     directory: directory(),
 
-    duration: {
-      flags: {update: true, expose: true},
-      update: {validate: isDuration},
-    },
-
+    duration: duration(),
     urls: urls(),
     dateFirstReleased: simpleDate(),
 
-    artTags: referenceList({
-      class: ArtTag,
-      find: find.artTag,
-      data: 'artTagData',
-    }),
-
-    color: compositeFrom(`Track.color`, [
+    color: [
       exposeUpdateValueOrContinue(),
-      withContainingTrackSection(),
 
-      {
-        dependencies: ['#trackSection'],
-        compute: ({'#trackSection': trackSection}, continuation) =>
-          // Album.trackSections guarantees the track section will have a
-          // color property (inheriting from the album's own color), but only
-          // if it's actually present! Color will be inherited directly from
-          // album otherwise.
-          (trackSection
-            ? trackSection.color
-            : continuation()),
-      },
-
-      withAlbumProperty({property: 'color'}),
+      withContainingTrackSection(),
+      withPropertyFromObject({object: '#trackSection', property: 'color'}),
+      exposeDependencyOrContinue({dependency: '#trackSection.color'}),
 
+      withPropertyFromAlbum({property: 'color'}),
       exposeDependency({
         dependency: '#album.color',
         update: {validate: isColor},
       }),
-    ]),
+    ],
 
     // 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
@@ -102,42 +83,43 @@ export class Track extends Thing {
     // 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: compositeFrom(`Track.coverArtFileExtension`, [
-      // No cover art file extension if the track doesn't have unique artwork
-      // in the first place.
-      withHasUniqueCoverArt(),
-      exitWithoutDependency({dependency: '#hasUniqueCoverArt', mode: 'falsy'}),
+    coverArtFileExtension: [
+      exitWithoutUniqueCoverArt(),
 
-      // Expose custom coverArtFileExtension update value first.
       exposeUpdateValueOrContinue(),
 
-      // Expose album's trackCoverArtFileExtension if no update value set.
-      withAlbumProperty({property: 'trackCoverArtFileExtension'}),
+      withPropertyFromAlbum({property: 'trackCoverArtFileExtension'}),
       exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}),
 
-      // Fallback to 'jpg'.
       exposeConstant({
         value: 'jpg',
         update: {validate: isFileExtension},
       }),
-    ]),
+    ],
 
     // 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: compositeFrom(`Track.coverArtDate`, [
+    coverArtDate: [
       withHasUniqueCoverArt(),
       exitWithoutDependency({dependency: '#hasUniqueCoverArt', mode: 'falsy'}),
 
       exposeUpdateValueOrContinue(),
 
-      withAlbumProperty({property: 'trackArtDate'}),
+      withPropertyFromAlbum({property: 'trackArtDate'}),
       exposeDependency({
         dependency: '#album.trackArtDate',
         update: {validate: isDate},
       }),
-    ]),
+    ],
+
+    commentary: commentary(),
+    lyrics: simpleString(),
+
+    additionalFiles: additionalFiles(),
+    sheetMusicFiles: additionalFiles(),
+    midiProjectFiles: additionalFiles(),
 
     originalReleaseTrack: singleReference({
       class: Track,
@@ -145,24 +127,74 @@ export class Track extends Thing {
       data: 'trackData',
     }),
 
-    // Note - this is an internal property used only to help identify a track.
-    // It should not be assumed in general that the album and dataSourceAlbum match
-    // (i.e. a track may dynamically be moved from one album to another, at
-    // which point dataSourceAlbum refers to where it was originally from, and is
-    // not generally relevant information). It's also not guaranteed that
-    // dataSourceAlbum is available (depending on the Track creator to optionally
-    // provide this property's update value).
+    // Internal use only - for directly identifying an album inside a track's
+    // util.inspect display, if it isn't indirectly available (by way of being
+    // included in an album's track list).
     dataSourceAlbum: singleReference({
       class: Album,
       find: find.album,
       data: 'albumData',
     }),
 
-    commentary: commentary(),
-    lyrics: simpleString(),
-    additionalFiles: additionalFiles(),
-    sheetMusicFiles: additionalFiles(),
-    midiProjectFiles: additionalFiles(),
+    artistContribs: [
+      inheritFromOriginalRelease({property: 'artistContribs'}),
+
+      withUpdateValueAsDependency(),
+      withResolvedContribs({from: '#updateValue', into: '#artistContribs'}),
+      exposeDependencyOrContinue({dependency: '#artistContribs'}),
+
+      withPropertyFromAlbum({property: 'artistContribs'}),
+      exposeDependency({
+        dependency: '#album.artistContribs',
+        update: {validate: isContributionList},
+      }),
+    ],
+
+    contributorContribs: [
+      inheritFromOriginalRelease({property: 'contributorContribs'}),
+      contributionList(),
+    ],
+
+    // Cover artists aren't inherited from the original release, since it
+    // typically varies by release and isn't defined by the musical qualities
+    // of the track.
+    coverArtistContribs: [
+      exitWithoutUniqueCoverArt(),
+
+      withUpdateValueAsDependency(),
+      withResolvedContribs({from: '#updateValue', into: '#coverArtistContribs'}),
+      exposeDependencyOrContinue({dependency: '#coverArtistContribs'}),
+
+      withPropertyFromAlbum({property: 'trackCoverArtistContribs'}),
+      exposeDependency({
+        dependency: '#album.trackCoverArtistContribs',
+        update: {validate: isContributionList},
+      }),
+    ],
+
+    referencedTracks: [
+      inheritFromOriginalRelease({property: 'referencedTracks'}),
+      referenceList({
+        class: Track,
+        find: find.track,
+        data: 'trackData',
+      }),
+    ],
+
+    sampledTracks: [
+      inheritFromOriginalRelease({property: 'sampledTracks'}),
+      referenceList({
+        class: Track,
+        find: find.track,
+        data: 'trackData',
+      }),
+    ],
+
+    artTags: referenceList({
+      class: ArtTag,
+      find: find.artTag,
+      data: 'artTagData',
+    }),
 
     // Update only
 
@@ -176,16 +208,16 @@ export class Track extends Thing {
 
     commentatorArtists: commentatorArtists(),
 
-    album: compositeFrom(`Track.album`, [
+    album: [
       withAlbum(),
       exposeDependency({dependency: '#album'}),
-    ]),
+    ],
 
-    date: compositeFrom(`Track.date`, [
+    date: [
       exposeDependencyOrContinue({dependency: 'dateFirstReleased'}),
-      withAlbumProperty({property: 'date'}),
+      withPropertyFromAlbum({property: 'date'}),
       exposeDependency({dependency: '#album.date'}),
-    ]),
+    ],
 
     // Whether or not the track has "unique" cover artwork - a cover which is
     // specifically associated with this track in particular, rather than with
@@ -194,12 +226,12 @@ export class Track extends Thing {
     // or a placeholder. (This property is named hasUniqueCoverArt instead of
     // the usual hasCoverArt to emphasize that it does not inherit from the
     // album.)
-    hasUniqueCoverArt: compositeFrom(`Track.hasUniqueCoverArt`, [
+    hasUniqueCoverArt: [
       withHasUniqueCoverArt(),
       exposeDependency({dependency: '#hasUniqueCoverArt'}),
-    ]),
+    ],
 
-    otherReleases: compositeFrom(`Track.otherReleases`, [
+    otherReleases: [
       exitWithoutDependency({dependency: 'trackData', mode: 'empty'}),
       withOriginalRelease({selfIfOriginal: true}),
 
@@ -221,67 +253,7 @@ export class Track extends Thing {
                 track.originalReleaseTrack === originalRelease)),
         },
       },
-    ]),
-
-    artistContribs: compositeFrom(`Track.artistContribs`, [
-      inheritFromOriginalRelease({property: 'artistContribs'}),
-
-      withUpdateValueAsDependency(),
-      withResolvedContribs({from: '#updateValue', into: '#artistContribs'}),
-      exposeDependencyOrContinue({dependency: '#artistContribs'}),
-
-      withAlbumProperty({property: 'artistContribs'}),
-      exposeDependency({
-        dependency: '#album.artistContribs',
-        update: {validate: isContributionList},
-      }),
-    ]),
-
-    contributorContribs: compositeFrom(`Track.contributorContribs`, [
-      inheritFromOriginalRelease({property: 'contributorContribs'}),
-      contributionList(),
-    ]),
-
-    // Cover artists aren't inherited from the original release, since it
-    // typically varies by release and isn't defined by the musical qualities
-    // of the track.
-    coverArtistContribs: compositeFrom(`Track.coverArtistContribs`, [
-      {
-        dependencies: ['disableUniqueCoverArt'],
-        compute: ({disableUniqueCoverArt}, continuation) =>
-          (disableUniqueCoverArt
-            ? null
-            : continuation()),
-      },
-
-      withUpdateValueAsDependency(),
-      withResolvedContribs({from: '#updateValue', into: '#coverArtistContribs'}),
-      exposeDependencyOrContinue({dependency: '#coverArtistContribs'}),
-
-      withAlbumProperty({property: 'trackCoverArtistContribs'}),
-      exposeDependency({
-        dependency: '#album.trackCoverArtistContribs',
-        update: {validate: isContributionList},
-      }),
-    ]),
-
-    referencedTracks: compositeFrom(`Track.referencedTracks`, [
-      inheritFromOriginalRelease({property: 'referencedTracks'}),
-      referenceList({
-        class: Track,
-        find: find.track,
-        data: 'trackData',
-      }),
-    ]),
-
-    sampledTracks: compositeFrom(`Track.sampledTracks`, [
-      inheritFromOriginalRelease({property: 'sampledTracks'}),
-      referenceList({
-        class: Track,
-        find: find.track,
-        data: 'trackData',
-      }),
-    ]),
+    ],
 
     // Specifically exclude re-releases from this list - while it's useful to
     // get from a re-release to the tracks it references, re-releases aren't
@@ -430,66 +402,14 @@ function withAlbum({
 // property name prefixed with '#album.' (by default). If the track's album
 // isn't available, then by default, the property will be provided as null;
 // set {notFoundMode: 'exit'} to early exit instead.
-function withAlbumProperty({
+function withPropertyFromAlbum({
   property,
   into = '#album.' + property,
   notFoundMode = 'null',
 }) {
-  return compositeFrom(`withAlbumProperty`, [
-    withAlbum({notFoundMode}),
-
-    {
-      dependencies: ['#album'],
-      options: {property},
-      mapContinuation: {into},
-
-      compute: ({
-        '#album': album,
-        '#options': {property},
-      }, continuation) =>
-        (album
-          ? continuation.raise({into: album[property]})
-          : continuation.raise({into: null})),
-    },
-  ]);
-}
-
-// Gets the listed properties from this track's album, providing them as
-// dependencies (by default) with '#album.' prefixed before each property
-// name. If the track's album isn't available, then by default, the same
-// dependency names will be provided as null; set {notFoundMode: 'exit'}
-// to early exit instead.
-function withAlbumProperties({
-  properties,
-  prefix = '#album',
-  notFoundMode = 'null',
-}) {
-  return compositeFrom(`withAlbumProperties`, [
+  return compositeFrom(`withPropertyFromAlbum`, [
     withAlbum({notFoundMode}),
-
-    {
-      dependencies: ['#album'],
-      options: {properties, prefix},
-
-      compute({
-        '#album': album,
-        '#options': {properties, prefix},
-      }, continuation) {
-        const raise = {};
-
-        if (album) {
-          for (const property of properties) {
-            raise[prefix + '.' + property] = album[property];
-          }
-        } else {
-          for (const property of properties) {
-            raise[prefix + '.' + property] = null;
-          }
-        }
-
-        return continuation.raise(raise);
-      },
-    },
+    withPropertyFromObject({object: '#album', property, into}),
   ]);
 }
 
@@ -505,7 +425,7 @@ function withContainingTrackSection({
   }
 
   return compositeFrom(`withContainingTrackSection`, [
-    withAlbumProperty({property: 'trackSections', notFoundMode}),
+    withPropertyFromAlbum({property: 'trackSections', notFoundMode}),
 
     {
       dependencies: ['this', '#album.trackSections'],
@@ -604,7 +524,7 @@ function withHasUniqueCoverArt({
           : continuation.raise({into: true})),
     },
 
-    withAlbumProperty({property: 'trackCoverArtistContribs'}),
+    withPropertyFromAlbum({property: 'trackCoverArtistContribs'}),
 
     {
       dependencies: ['#album.trackCoverArtistContribs'],
@@ -617,6 +537,21 @@ function withHasUniqueCoverArt({
   ]);
 }
 
+// Shorthand for checking if the track has unique cover art and exposing a
+// fallback value if it isn't.
+function exitWithoutUniqueCoverArt({
+  value = null,
+} = {}) {
+  return compositeFrom(`exitWithoutUniqueCoverArt`, [
+    withHasUniqueCoverArt(),
+    exitWithoutDependency({
+      dependency: '#hasUniqueCoverArt',
+      mode: 'falsy',
+      value,
+    }),
+  ]);
+}
+
 function trackReverseReferenceList({
   property: refListProperty,
 }) {