« 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.js47
-rw-r--r--src/data/things/additional-name.js21
-rw-r--r--src/data/things/album.js84
-rw-r--r--src/data/things/art-tag.js12
-rw-r--r--src/data/things/artist.js5
-rw-r--r--src/data/things/artwork.js91
-rw-r--r--src/data/things/content.js105
-rw-r--r--src/data/things/contribution.js82
-rw-r--r--src/data/things/flash.js16
-rw-r--r--src/data/things/group.js87
-rw-r--r--src/data/things/homepage-layout.js3
-rw-r--r--src/data/things/index.js4
-rw-r--r--src/data/things/language.js55
-rw-r--r--src/data/things/sorting-rule.js1
-rw-r--r--src/data/things/track.js105
-rw-r--r--src/data/things/wiki-info.js37
16 files changed, 621 insertions, 134 deletions
diff --git a/src/data/things/additional-file.js b/src/data/things/additional-file.js
new file mode 100644
index 00000000..398d0af5
--- /dev/null
+++ b/src/data/things/additional-file.js
@@ -0,0 +1,47 @@
+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';
+
+export class AdditionalFile extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    title: simpleString(),
+
+    description: contentString(),
+
+    filenames: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(validateArrayItems(isString)),
+      }),
+
+      exposeConstant({
+        value: input.value([]),
+      }),
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Title': {property: 'title'},
+      'Description': {property: 'description'},
+      'Files': {property: 'filenames'},
+    },
+  };
+
+  get paths() {
+    if (!this.thing) return null;
+    if (!this.thing.getOwnAdditionalFilePath) return null;
+
+    return (
+      this.filenames.map(filename =>
+        this.thing.getOwnAdditionalFilePath(this, filename)));
+  }
+}
diff --git a/src/data/things/additional-name.js b/src/data/things/additional-name.js
new file mode 100644
index 00000000..4c23f291
--- /dev/null
+++ b/src/data/things/additional-name.js
@@ -0,0 +1,21 @@
+import Thing from '#thing';
+
+import {contentString, thing} from '#composite/wiki-properties';
+
+export class AdditionalName extends Thing {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    thing: thing(),
+
+    name: contentString(),
+    annotation: contentString(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+      'Annotation': {property: 'annotation'},
+    },
+  };
+}
diff --git a/src/data/things/album.js b/src/data/things/album.js
index c71b9820..5132b962 100644
--- a/src/data/things/album.js
+++ b/src/data/things/album.js
@@ -8,7 +8,7 @@ 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';
 
@@ -33,8 +33,6 @@ import {exitWithoutContribs, withDirectory, withCoverArtDate}
   from '#composite/wiki-data';
 
 import {
-  additionalFiles,
-  additionalNameList,
   color,
   commentatorArtists,
   constitutibleArtwork,
@@ -49,7 +47,6 @@ import {
   name,
   referencedArtworkList,
   referenceList,
-  reverseReferenceList,
   simpleDate,
   simpleString,
   soupyFind,
@@ -69,12 +66,13 @@ export class Album extends Thing {
   static [Thing.referenceType] = 'album';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
     ArtTag,
     Artwork,
     CommentaryEntry,
     CreditingSourcesEntry,
     Group,
-    Track,
     TrackSection,
     WikiInfo,
   }) => ({
@@ -99,10 +97,14 @@ export class Album extends Thing {
     alwaysReferenceTracksByDirectory: flag(false),
     suffixTrackDirectories: flag(false),
 
+    countTracksInArtistTotals: flag(true),
+
     color: color(),
     urls: urls(),
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     bandcampAlbumIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
@@ -212,11 +214,13 @@ export class Album extends Thing {
       class: input.value(CommentaryEntry),
     }),
 
-    creditSources: thingList({
+    creditingSources: thingList({
       class: input.value(CreditingSourcesEntry),
     }),
 
-    additionalFiles: additionalFiles(),
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
     trackSections: thingList({
       class: input.value(TrackSection),
@@ -500,6 +504,8 @@ export class Album extends Thing {
         transform: String,
       },
 
+      'Count Tracks In Artist Totals': {property: 'countInArtistTotals'},
+
       'Date': {
         property: 'date',
         transform: parseDate,
@@ -588,9 +594,10 @@ export class Album extends Thing {
         transform: parseContributors,
       },
 
-      'Wallpaper Style': {property: 'wallpaperStyle'},
       'Wallpaper File Extension': {property: 'wallpaperFileExtension'},
 
+      'Wallpaper Style': {property: 'wallpaperStyle'},
+
       'Wallpaper Parts': {
         property: 'wallpaperParts',
         transform: parseWallpaperParts,
@@ -614,8 +621,8 @@ export class Album extends Thing {
         transform: parseCommentary,
       },
 
-      'Credit Sources': {
-        property: 'creditSources',
+      'Crediting Sources': {
+        property: 'creditingSources',
         transform: parseCreditingSources,
       },
 
@@ -692,6 +699,7 @@ export class Album extends Thing {
       const artworkData = [];
       const commentaryData = [];
       const creditingSourceData = [];
+      const referencingSourceData = [];
       const lyricsData = [];
 
       for (const {header: album, entries} of results) {
@@ -705,8 +713,6 @@ export class Album extends Thing {
           isDefaultTrackSection: true,
         });
 
-        const albumRef = Thing.getReference(album);
-
         const closeCurrentTrackSection = () => {
           if (
             currentTrackSection.isDefaultTrackSection &&
@@ -740,7 +746,8 @@ export class Album extends Thing {
 
           artworkData.push(...entry.trackArtworks);
           commentaryData.push(...entry.commentary);
-          creditingSourceData.push(...entry.creditSources);
+          creditingSourceData.push(...entry.creditingSources);
+          referencingSourceData.push(...entry.referencingSources);
 
           // TODO: As exposed, Track.lyrics tries to inherit from the main
           // release, which is impossible before the data's been linked.
@@ -763,7 +770,7 @@ export class Album extends Thing {
         }
 
         commentaryData.push(...album.commentary);
-        creditingSourceData.push(...album.creditSources);
+        creditingSourceData.push(...album.creditingSources);
 
         album.trackSections = trackSections;
       }
@@ -776,6 +783,7 @@ export class Album extends Thing {
         artworkData,
         commentaryData,
         creditingSourceData,
+        referencingSourceData,
         lyricsData,
       };
     },
@@ -786,6 +794,14 @@ export class Album extends Thing {
     },
   });
 
+  getOwnAdditionalFilePath(_file, filename) {
+    return [
+      'media.albumAdditionalFile',
+      this.directory,
+      filename,
+    ];
+  }
+
   getOwnArtworkPath(artwork) {
     if (artwork === this.bannerArtwork) {
       return [
@@ -823,13 +839,19 @@ export class Album extends Thing {
       artwork.fileExtension,
     ];
   }
+
+  // As of writing, albums don't even have a `duration` property...
+  // so this function will never be called... but the message stands...
+  countOwnContributionInDurationTotals(_contrib) {
+    return false;
+  }
 }
 
 export class TrackSection extends Thing {
   static [Thing.friendlyName] = `Track Section`;
   static [Thing.referenceType] = `track-section`;
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({Track}) => ({
     // Update & expose
 
     name: name('Unnamed Track Section'),
@@ -958,11 +980,13 @@ export class TrackSection extends Thing {
 
     parts.push(Thing.prototype[inspect.custom].apply(this));
 
-    if (depth >= 0) {
+    if (depth >= 0) showAlbum: {
       let album = null;
       try {
         album = this.album;
-      } catch {}
+      } catch {
+        break showAlbum;
+      }
 
       let first = null;
       try {
@@ -974,22 +998,20 @@ export class TrackSection extends Thing {
         last = this.tracks.at(-1).trackNumber;
       } catch {}
 
-      if (album) {
-        const albumName = album.name;
-        const albumIndex = album.trackSections.indexOf(this);
+      const albumName = album.name;
+      const albumIndex = album.trackSections.indexOf(this);
 
-        const num =
-          (albumIndex === -1
-            ? 'indeterminate position'
-            : `#${albumIndex + 1}`);
+      const num =
+        (albumIndex === -1
+          ? 'indeterminate position'
+          : `#${albumIndex + 1}`);
 
-        const range =
-          (albumIndex >= 0 && first !== null && last !== null
-            ? `: ${first}-${last}`
-            : '');
+      const range =
+        (albumIndex >= 0 && first !== null && last !== null
+          ? `: ${first}-${last}`
+          : '');
 
-        parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`);
-      }
+      parts.push(` (${colors.yellow(num + range)} in ${colors.green(`"${albumName}"`)})`);
     }
 
     return parts.join('');
diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js
index 57e156ee..518f616b 100644
--- a/src/data/things/art-tag.js
+++ b/src/data/things/art-tag.js
@@ -1,8 +1,7 @@
 export const ART_TAG_DATA_FILE = 'tags.yaml';
 
 import {input} from '#composite';
-import find from '#find';
-import {sortAlphabetically, sortAlbumsTracksChronologically} from '#sort';
+import {sortAlphabetically} from '#sort';
 import Thing from '#thing';
 import {unique} from '#sugar';
 import {isName} from '#validators';
@@ -12,7 +11,6 @@ import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue}
   from '#composite/control-flow';
 
 import {
-  additionalNameList,
   annotatedReferenceList,
   color,
   contentString,
@@ -23,8 +21,8 @@ import {
   name,
   soupyFind,
   soupyReverse,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {withAllDescendantArtTags, withAncestorArtTagBaobabTree}
@@ -34,7 +32,7 @@ export class ArtTag extends Thing {
   static [Thing.referenceType] = 'tag';
   static [Thing.friendlyName] = `Art Tag`;
 
-  static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({
+  static [Thing.getPropertyDescriptors] = ({AdditionalName}) => ({
     // Update & expose
 
     name: name('Unnamed Art Tag'),
@@ -55,7 +53,9 @@ export class ArtTag extends Thing {
       },
     ],
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     description: contentString(),
 
diff --git a/src/data/things/artist.js b/src/data/things/artist.js
index 9e329c74..5b67051c 100644
--- a/src/data/things/artist.js
+++ b/src/data/things/artist.js
@@ -26,7 +26,6 @@ import {
   soupyFind,
   soupyReverse,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {artistTotalDuration} from '#composite/things/artist';
@@ -35,7 +34,7 @@ export class Artist extends Thing {
   static [Thing.referenceType] = 'artist';
   static [Thing.wikiDataArray] = 'artistData';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Flash, Group, Track}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     name: name('Unnamed Artist'),
@@ -287,7 +286,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 8c88dea7..57c293ca 100644
--- a/src/data/things/artwork.js
+++ b/src/data/things/artwork.js
@@ -1,5 +1,6 @@
 import {inspect} from 'node:util';
 
+import {colors} from '#cli';
 import {input} from '#composite';
 import find from '#find';
 import Thing from '#thing';
@@ -24,7 +25,7 @@ import {
   parseDimensions,
 } from '#yaml';
 
-import {withIndexInList, withPropertyFromObject} from '#composite/data';
+import {withPropertyFromObject} from '#composite/data';
 
 import {
   exitWithoutDependency,
@@ -54,19 +55,17 @@ import {
 } from '#composite/wiki-properties';
 
 import {
+  withAttachedArtwork,
   withContainingArtworkList,
-  withContribsFromMainArtwork,
-  withPropertyFromMainArtwork,
+  withContribsFromAttachedArtwork,
+  withPropertyFromAttachedArtwork,
   withDate,
 } from '#composite/things/artwork';
 
 export class Artwork extends Thing {
   static [Thing.referenceType] = 'artwork';
 
-  static [Thing.getPropertyDescriptors] = ({
-    ArtTag,
-    Contribution,
-  }) => ({
+  static [Thing.getPropertyDescriptors] = ({ArtTag}) => ({
     // Update & expose
 
     unqualifiedDirectory: directory({
@@ -78,6 +77,7 @@ export class Artwork extends Thing {
 
     label: simpleString(),
     source: contentString(),
+    originDetails: contentString(),
 
     dateFromThingProperty: simpleString(),
 
@@ -134,7 +134,7 @@ export class Artwork extends Thing {
       }),
 
       exitWithoutDependency({
-        dependency: 'artistContribsFromThingProperty',
+        dependency: 'dimensionsFromThingProperty',
         value: input.value(null),
       }),
 
@@ -170,6 +170,7 @@ export class Artwork extends Thing {
       withResolvedContribs({
         from: input.updateValue({validate: isContributionList}),
         date: '#date',
+        thingProperty: input.thisProperty(),
         artistProperty: 'artistContribsArtistProperty',
       }),
 
@@ -178,10 +179,10 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
-      withContribsFromMainArtwork(),
+      withContribsFromAttachedArtwork(),
 
       exposeDependencyOrContinue({
-        dependency: '#mainArtwork.artistContribs',
+        dependency: '#attachedArtwork.artistContribs',
       }),
 
       exitWithoutDependency({
@@ -222,13 +223,12 @@ export class Artwork extends Thing {
         mode: input.value('empty'),
       }),
 
-      withPropertyFromMainArtwork({
+      withPropertyFromAttachedArtwork({
         property: input.value('artTags'),
-        onlyIfAttached: input.value(true),
       }),
 
       exposeDependencyOrContinue({
-        dependency: '#mainArtwork.artTags',
+        dependency: '#attachedArtwork.artTags',
       }),
 
       exitWithoutDependency({
@@ -360,35 +360,31 @@ export class Artwork extends Thing {
       },
     ],
 
-    siblingArtworks: [
-      withContainingArtworkList(),
+    attachedArtwork: [
+      withAttachedArtwork(),
 
-      exitWithoutDependency({
-        dependency: '#containingArtworkList',
-        value: input.value(null),
+      exposeDependency({
+        dependency: '#attachedArtwork',
       }),
+    ],
 
-      withIndexInList({
-        list: '#containingArtworkList',
-        item: input.myself(),
+    attachingArtworks: reverseReferenceList({
+      reverse: soupyReverse.input('artworksWhichAttach'),
+    }),
+
+    groups: [
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('groups'),
       }),
 
-      exitWithoutDependency({
-        dependency: '#index',
-        mode: input.value('index'),
-        value: input.value(null),
+      exposeDependencyOrContinue({
+        dependency: '#thing.groups',
       }),
 
-      {
-        dependencies: ['#containingArtworkList', '#index'],
-        compute: ({
-          ['#containingArtworkList']: list,
-          ['#index']: index,
-        }) => [
-          ...list.slice(0, index),
-          ...list.slice(index + 1),
-        ],
-      },
+      exposeConstant({
+        value: input.value([]),
+      }),
     ],
   });
 
@@ -404,6 +400,7 @@ export class Artwork extends Thing {
 
       'Label': {property: 'label'},
       'Source': {property: 'source'},
+      'Origin Details': {property: 'originDetails'},
 
       'Date': {
         property: 'date',
@@ -448,6 +445,18 @@ export class Artwork extends Thing {
       date: ({artwork}) => artwork.date,
     },
 
+    artworksWhichAttach: {
+      bindTo: 'artworkData',
+
+      referencing: referencingArtwork =>
+        (referencingArtwork.attachAbove
+          ? [referencingArtwork]
+          : []),
+
+      referenced: referencingArtwork =>
+        [referencingArtwork.attachedArtwork],
+    },
+
     artworksWhichFeature: {
       bindTo: 'artworkData',
 
@@ -463,6 +472,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 7f352795..ca41ccaa 100644
--- a/src/data/things/content.js
+++ b/src/data/things/content.js
@@ -1,31 +1,37 @@
 import {input} from '#composite';
-import find from '#find';
 import Thing from '#thing';
 import {is, isDate} from '#validators';
 import {parseDate} from '#yaml';
 
-import {contentString, referenceList, simpleDate, soupyFind, thing}
+import {contentString, simpleDate, soupyFind, thing}
   from '#composite/wiki-properties';
 
 import {
+  exitWithoutDependency,
   exposeConstant,
+  exposeDependency,
   exposeDependencyOrContinue,
   exposeUpdateValueOrContinue,
   withResultOfAvailabilityCheck,
 } from '#composite/control-flow';
 
-import {withWebArchiveDate} from '#composite/things/commentary-entry';
+import {
+  contentArtists,
+  hasAnnotationPart,
+  withAnnotationParts,
+  withHasAnnotationPart,
+  withSourceText,
+  withSourceURLs,
+  withWebArchiveDate,
+} from '#composite/things/content';
 
 export class ContentEntry extends Thing {
-  static [Thing.getPropertyDescriptors] = ({Artist}) => ({
+  static [Thing.getPropertyDescriptors] = () => ({
     // Update & expose
 
     thing: thing(),
 
-    artists: referenceList({
-      class: input.value(Artist),
-      find: soupyFind.input('artist'),
-    }),
+    artists: contentArtists(),
 
     artistText: contentString(),
 
@@ -44,6 +50,10 @@ export class ContentEntry extends Thing {
     },
 
     accessKind: [
+      exitWithoutDependency({
+        dependency: 'accessDate',
+      }),
+
       exposeUpdateValueOrContinue({
         validate: input.value(
           is(...[
@@ -67,7 +77,7 @@ export class ContentEntry extends Thing {
       },
 
       exposeConstant({
-        value: input.value(null),
+        value: input.value('accessed'),
       }),
     ],
 
@@ -96,6 +106,26 @@ export class ContentEntry extends Thing {
     // Update only
 
     find: soupyFind(),
+
+    // Expose only
+
+    annotationParts: [
+      withAnnotationParts({
+        mode: input.value('strings'),
+      }),
+
+      exposeDependency({dependency: '#annotationParts'}),
+    ],
+
+    sourceText: [
+      withSourceText(),
+      exposeDependency({dependency: '#sourceText'}),
+    ],
+
+    sourceURLs: [
+      withSourceURLs(),
+      exposeDependency({dependency: '#sourceURLs'}),
+    ],
   });
 
   static [Thing.yamlDocumentSpec] = {
@@ -117,6 +147,59 @@ export class ContentEntry extends Thing {
   };
 }
 
-export class CommentaryEntry extends ContentEntry {}
-export class LyricsEntry extends ContentEntry {}
+export class CommentaryEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Expose only
+
+    isWikiEditorCommentary: hasAnnotationPart({
+      part: input.value('wiki editor'),
+    }),
+  });
+}
+
+export class LyricsEntry extends ContentEntry {
+  static [Thing.getPropertyDescriptors] = () => ({
+    // Update & expose
+
+    originDetails: contentString(),
+
+    // Expose only
+
+    isWikiLyrics: hasAnnotationPart({
+      part: input.value('wiki lyrics'),
+    }),
+
+    hasSquareBracketAnnotations: [
+      withHasAnnotationPart({
+        part: input.value('wiki lyrics'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#hasAnnotationPart',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      exitWithoutDependency({
+        dependency: 'body',
+        value: input.value(false),
+      }),
+
+      {
+        dependencies: ['body'],
+        compute: ({body}) =>
+          /\[.*\]/m.test(body),
+      },
+    ],
+  });
+
+  static [Thing.yamlDocumentSpec] = Thing.extendDocumentSpec(ContentEntry, {
+    fields: {
+      'Origin Details': {property: 'originDetails'},
+    },
+  });
+}
+
 export class CreditingSourcesEntry extends ContentEntry {}
+
+export class ReferencingSourcesEntry extends ContentEntry {}
diff --git a/src/data/things/contribution.js b/src/data/things/contribution.js
index c92fafb4..90e8eb79 100644
--- a/src/data/things/contribution.js
+++ b/src/data/things/contribution.js
@@ -5,12 +5,20 @@ import {colors} from '#cli';
 import {input} from '#composite';
 import {empty} from '#sugar';
 import Thing from '#thing';
-import {isStringNonEmpty, isThing, validateReference} from '#validators';
+import {isBoolean, isStringNonEmpty, isThing, validateReference}
+  from '#validators';
 
-import {exitWithoutDependency, exposeDependency} from '#composite/control-flow';
 import {flag, simpleDate, soupyFind} from '#composite/wiki-properties';
 
 import {
+  exitWithoutDependency,
+  exposeConstant,
+  exposeDependency,
+  exposeDependencyOrContinue,
+  exposeUpdateValueOrContinue,
+} from '#composite/control-flow';
+
+import {
   withFilteredList,
   withNearbyItemFromList,
   withPropertyFromList,
@@ -70,7 +78,26 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInContributionTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInContributionTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     countInDurationTotals: [
@@ -78,7 +105,37 @@ export class Contribution extends Thing {
         property: input.thisProperty(),
       }),
 
-      flag(true),
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromObject({
+        object: 'thing',
+        property: input.value('duration'),
+      }),
+
+      exitWithoutDependency({
+        dependency: '#thing.duration',
+        mode: input.value('falsy'),
+        value: input.value(false),
+      }),
+
+      {
+        dependencies: ['thing', input.myself()],
+        compute: (continuation, {
+          ['thing']: thing,
+          [input.myself()]: contribution,
+        }) =>
+          (thing.countOwnContributionInDurationTotals?.(contribution)
+            ? true
+         : thing.countOwnContributionInDurationTotals
+            ? false
+            : continuation()),
+      },
+
+      exposeConstant({
+        value: input.value(true),
+      }),
     ],
 
     // Update only
@@ -238,6 +295,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 +331,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 a0bcb523..160221f0 100644
--- a/src/data/things/flash.js
+++ b/src/data/things/flash.js
@@ -27,7 +27,6 @@ import {
 } from '#composite/control-flow';
 
 import {
-  additionalNameList,
   color,
   commentatorArtists,
   constitutibleArtwork,
@@ -44,7 +43,6 @@ import {
   thing,
   thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 import {withFlashAct} from '#composite/things/flash';
@@ -54,10 +52,10 @@ export class Flash extends Thing {
   static [Thing.referenceType] = 'flash';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalName,
     CommentaryEntry,
     CreditingSourcesEntry,
     Track,
-    FlashAct,
     WikiInfo,
   }) => ({
     // Update & expose
@@ -127,13 +125,15 @@ export class Flash extends Thing {
 
     urls: urls(),
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     commentary: thingList({
       class: input.value(CommentaryEntry),
     }),
 
-    creditSources: thingList({
+    creditingSources: thingList({
       class: input.value(CreditingSourcesEntry),
     }),
 
@@ -255,8 +255,8 @@ export class Flash extends Thing {
         transform: parseCommentary,
       },
 
-      'Credit Sources': {
-        property: 'creditSources',
+      'Crediting Sources': {
+        property: 'creditingSources',
         transform: parseCreditingSources,
       },
 
@@ -459,7 +459,7 @@ export class FlashSide extends Thing {
 
       const artworkData = flashData.map(flash => flash.coverArtwork);
       const commentaryData = flashData.flatMap(flash => flash.commentary);
-      const creditingSourceData = flashData.flatMap(flash => flash.creditSources);
+      const creditingSourceData = flashData.flatMap(flash => flash.creditingSources);
 
       return {
         flashData,
diff --git a/src/data/things/group.js b/src/data/things/group.js
index b40d15b4..0262a3a5 100644
--- a/src/data/things/group.js
+++ b/src/data/things/group.js
@@ -1,7 +1,11 @@
 export const GROUP_DATA_FILE = 'groups.yaml';
 
+import {inspect} from 'node:util';
+
+import {colors} from '#cli';
 import {input} from '#composite';
 import Thing from '#thing';
+import {is} from '#validators';
 import {parseAnnotatedReferences, parseSerieses} from '#yaml';
 
 import {
@@ -11,16 +15,16 @@ import {
   directory,
   name,
   referenceList,
-  seriesList,
   soupyFind,
+  thing,
+  thingList,
   urls,
-  wikiData,
 } from '#composite/wiki-properties';
 
 export class Group extends Thing {
   static [Thing.referenceType] = 'group';
 
-  static [Thing.getPropertyDescriptors] = ({Album, Artist}) => ({
+  static [Thing.getPropertyDescriptors] = ({Album, Artist, Series}) => ({
     // Update & expose
 
     name: name('Unnamed Group'),
@@ -43,8 +47,8 @@ export class Group extends Thing {
       find: soupyFind.input('album'),
     }),
 
-    serieses: seriesList({
-      group: input.myself(),
+    serieses: thingList({
+      class: input.value(Series),
     }),
 
     // Update only
@@ -192,8 +196,9 @@ export class Group extends Thing {
 
       const groupData = results.filter(x => x instanceof Group);
       const groupCategoryData = results.filter(x => x instanceof GroupCategory);
+      const seriesData = groupData.flatMap(group => group.serieses);
 
-      return {groupData, groupCategoryData};
+      return {groupData, groupCategoryData, seriesData};
     },
 
     // Groups aren't sorted at all, always preserving the order in the data
@@ -240,3 +245,73 @@ export class GroupCategory extends Thing {
     },
   };
 }
+
+export class Series extends Thing {
+  static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({
+    // Update & expose
+
+    name: name('Unnamed Series'),
+
+    showAlbumArtists: {
+      flags: {update: true, expose: true},
+      update: {
+        validate:
+          is('all', 'differing', 'none'),
+      },
+    },
+
+    description: contentString(),
+
+    group: thing({
+      class: input.value(Group),
+    }),
+
+    albums: referenceList({
+      class: input.value(Album),
+      find: soupyFind.input('album'),
+    }),
+
+    // Update only
+
+    find: soupyFind(),
+  });
+
+  static [Thing.yamlDocumentSpec] = {
+    fields: {
+      'Name': {property: 'name'},
+
+      'Description': {property: 'description'},
+
+      'Show Album Artists': {property: 'showAlbumArtists'},
+
+      'Albums': {property: 'albums'},
+    },
+  };
+
+  [inspect.custom](depth, options, inspect) {
+    const parts = [];
+
+    parts.push(Thing.prototype[inspect.custom].apply(this));
+
+    if (depth >= 0) showGroup: {
+      let group = null;
+      try {
+        group = this.group;
+      } catch {
+        break showGroup;
+      }
+
+      const groupName = group.name;
+      const groupIndex = group.serieses.indexOf(this);
+
+      const num =
+        (groupIndex === -1
+          ? 'indeterminate position'
+          : `#${groupIndex + 1}`);
+
+      parts.push(` (${colors.yellow(num)} in ${colors.green(`"${groupName}"`)})`);
+    }
+
+    return parts.join('');
+  }
+}
diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js
index 82bad2d3..3a11c287 100644
--- a/src/data/things/homepage-layout.js
+++ b/src/data/things/homepage-layout.js
@@ -63,7 +63,6 @@ export class HomepageLayout extends Thing {
     thingConstructors: {
       HomepageLayout,
       HomepageLayoutSection,
-      HomepageLayoutAlbumsRow,
     },
   }) => ({
     title: `Process homepage layout file`,
@@ -250,7 +249,7 @@ export class HomepageLayoutActionsRow extends HomepageLayoutRow {
 export class HomepageLayoutAlbumCarouselRow extends HomepageLayoutRow {
   static [Thing.friendlyName] = `Homepage Album Carousel Row`;
 
-  static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({
+  static [Thing.getPropertyDescriptors] = (opts, {Album} = opts) => ({
     ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts),
 
     // Update & expose
diff --git a/src/data/things/index.js b/src/data/things/index.js
index b832ab75..11307b50 100644
--- a/src/data/things/index.js
+++ b/src/data/things/index.js
@@ -9,6 +9,8 @@ import * as serialize from '#serialize';
 import {withEntries} from '#sugar';
 import Thing from '#thing';
 
+import * as additionalFileClasses from './additional-file.js';
+import * as additionalNameClasses from './additional-name.js';
 import * as albumClasses from './album.js';
 import * as artTagClasses from './art-tag.js';
 import * as artistClasses from './artist.js';
@@ -26,6 +28,8 @@ import * as trackClasses from './track.js';
 import * as wikiInfoClasses from './wiki-info.js';
 
 const allClassLists = {
+  'additional-file.js': additionalFileClasses,
+  'additional-name.js': additionalNameClasses,
   'album.js': albumClasses,
   'art-tag.js': artTagClasses,
   'artist.js': artistClasses,
diff --git a/src/data/things/language.js b/src/data/things/language.js
index a3f861bd..e3689643 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -135,6 +135,7 @@ export class Language extends Thing {
     },
 
     intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}),
+    intl_dateYear: this.#intlHelper(Intl.DateTimeFormat, {year: 'numeric'}),
     intl_number: this.#intlHelper(Intl.NumberFormat),
     intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}),
     intl_listDisjunction: this.#intlHelper(Intl.ListFormat, {type: 'disjunction'}),
@@ -488,22 +489,44 @@ export class Language extends Thing {
     // 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`);
-      }
+    if (!hasStart && !hasEnd) {
+      return html.blank();
+    } else if (hasStart && !hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only start`);
+    } else if (!hasStart && hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only end`);
     }
 
     this.assertIntlAvailable('intl_date');
     return this.intl_date.formatRange(startDate, endDate);
   }
 
+  formatYear(date) {
+    if (date === null || date === undefined) {
+      return html.blank();
+    }
+
+    this.assertIntlAvailable('intl_dateYear');
+    return this.intl_dateYear.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.
+    const hasStart = startDate !== null && startDate !== undefined;
+    const hasEnd = endDate !== null && endDate !== undefined;
+    if (!hasStart && !hasEnd) {
+      return html.blank();
+    } else if (hasStart && !hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only start`);
+    } else if (!hasStart && hasEnd) {
+      throw new Error(`Expected both start and end of date range, got only end`);
+    }
+
+    this.assertIntlAvailable('intl_dateYear');
+    return this.intl_dateYear.formatRange(startDate, endDate);
+  }
+
   formatDateDuration({
     years: numYears = 0,
     months: numMonths = 0,
@@ -842,6 +865,18 @@ export class Language extends Thing {
     }
   }
 
+  typicallyLowerCase(string) {
+    // Utter nonsense implementation, so this only works on strings,
+    // not actual HTML content, and may rudely disrespect *intentful*
+    // capitalization of whatever goes into it.
+
+    if (typeof string !== 'string') return string;
+    if (string.length <= 1) return string;
+    if (/^\S+?[A-Z]/.test(string)) return string;
+
+    return string[0].toLowerCase() + string.slice(1);
+  }
+
   // Utility function to quickly provide a useful string key
   // (generally a prefix) to stuff nested beneath it.
   encapsulate(...args) {
diff --git a/src/data/things/sorting-rule.js b/src/data/things/sorting-rule.js
index b169a541..ccc4ad89 100644
--- a/src/data/things/sorting-rule.js
+++ b/src/data/things/sorting-rule.js
@@ -3,7 +3,6 @@ export const SORTING_RULE_DATA_FILE = 'sorting-rules.yaml';
 import {readFile, writeFile} from 'node:fs/promises';
 import * as path from 'node:path';
 
-import {input} from '#composite';
 import {chunkByProperties, compareArrays, unique} from '#sugar';
 import Thing from '#thing';
 import {isObject, isStringNonEmpty, anyOf, strictArrayOf} from '#validators';
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 57aaa90d..8b9420c7 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -15,6 +15,7 @@ import {
   parseCommentary,
   parseContributors,
   parseCreditingSources,
+  parseReferencingSources,
   parseDate,
   parseDimensions,
   parseDuration,
@@ -38,11 +39,8 @@ import {
 } from '#composite/wiki-data';
 
 import {
-  additionalFiles,
-  additionalNameList,
   commentatorArtists,
   constitutibleArtworkList,
-  contentString,
   contributionList,
   dimensions,
   directory,
@@ -86,14 +84,15 @@ export class Track extends Thing {
   static [Thing.referenceType] = 'track';
 
   static [Thing.getPropertyDescriptors] = ({
+    AdditionalFile,
+    AdditionalName,
     Album,
     ArtTag,
     Artwork,
     CommentaryEntry,
     CreditingSourcesEntry,
-    Flash,
     LyricsEntry,
-    TrackSection,
+    ReferencingSourcesEntry,
     WikiInfo,
   }) => ({
     // Update & expose
@@ -134,7 +133,9 @@ export class Track extends Thing {
       class: input.value(Album),
     }),
 
-    additionalNames: additionalNameList(),
+    additionalNames: thingList({
+      class: input.value(AdditionalName),
+    }),
 
     bandcampTrackIdentifier: simpleString(),
     bandcampArtworkIdentifier: simpleString(),
@@ -169,6 +170,18 @@ export class Track extends Thing {
       exposeDependency({dependency: '#alwaysReferenceByDirectory'}),
     ],
 
+    countInArtistTotals: [
+      exposeUpdateValueOrContinue({
+        validate: input.value(isBoolean),
+      }),
+
+      withPropertyFromAlbum({
+        property: input.value('countTracksInArtistTotals'),
+      }),
+
+      exposeDependency({dependency: '#album.countTracksInArtistTotals'}),
+    ],
+
     // 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
@@ -225,10 +238,14 @@ export class Track extends Thing {
       class: input.value(CommentaryEntry),
     }),
 
-    creditSources: thingList({
+    creditingSources: thingList({
       class: input.value(CreditingSourcesEntry),
     }),
 
+    referencingSources: thingList({
+      class: input.value(ReferencingSourcesEntry),
+    }),
+
     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
@@ -240,9 +257,17 @@ export class Track extends Thing {
       }),
     ],
 
-    additionalFiles: additionalFiles(),
-    sheetMusicFiles: additionalFiles(),
-    midiProjectFiles: additionalFiles(),
+    additionalFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    sheetMusicFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
+
+    midiProjectFiles: thingList({
+      class: input.value(AdditionalFile),
+    }),
 
     mainReleaseTrack: singleReference({
       class: input.value(Track),
@@ -428,6 +453,16 @@ export class Track extends Thing {
       exposeDependency({dependency: '#otherReleases'}),
     ],
 
+    groups: [
+      withPropertyFromAlbum({
+        property: input.value('groups'),
+      }),
+
+      exposeDependency({
+        dependency: '#album.groups',
+      }),
+    ],
+
     referencedByTracks: reverseReferenceList({
       reverse: soupyReverse.input('tracksWhichReference'),
     }),
@@ -462,6 +497,8 @@ export class Track extends Thing {
         transform: String,
       },
 
+      'Count In Artist Totals': {property: 'countInArtistTotals'},
+
       'Duration': {
         property: 'duration',
         transform: parseDuration,
@@ -507,11 +544,16 @@ export class Track extends Thing {
         transform: parseCommentary,
       },
 
-      'Credit Sources': {
-        property: 'creditSources',
+      'Crediting Sources': {
+        property: 'creditingSources',
         transform: parseCreditingSources,
       },
 
+      'Referencing Sources': {
+        property: 'referencingSources',
+        transform: parseReferencingSources,
+      },
+
       'Additional Files': {
         property: 'additionalFiles',
         transform: parseAdditionalFiles,
@@ -575,6 +617,11 @@ export class Track extends Thing {
     },
 
     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',
@@ -736,6 +783,16 @@ export class Track extends Thing {
   // Track YAML loading is handled in album.js.
   static [Thing.getYamlLoadingSpec] = null;
 
+  getOwnAdditionalFilePath(_file, filename) {
+    if (!this.album) return null;
+
+    return [
+      'media.albumAdditionalFile',
+      this.album.directory,
+      filename,
+    ];
+  }
+
   getOwnArtworkPath(artwork) {
     if (!this.album) return null;
 
@@ -751,6 +808,30 @@ export class Track extends Thing {
     ];
   }
 
+  countOwnContributionInContributionTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
+  countOwnContributionInDurationTotals(_contrib) {
+    if (!this.countInArtistTotals) {
+      return false;
+    }
+
+    if (this.isSecondaryRelease) {
+      return false;
+    }
+
+    return true;
+  }
+
   [inspect.custom](depth) {
     const parts = [];
 
diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js
index 590598be..f97f9027 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,
@@ -14,8 +14,17 @@ import {
 } from '#validators';
 
 import {exitWithoutDependency} from '#composite/control-flow';
-import {contentString, flag, name, referenceList, soupyFind}
-  from '#composite/wiki-properties';
+
+import {
+  contentString,
+  fileExtension,
+  flag,
+  name,
+  referenceList,
+  simpleString,
+  soupyFind,
+  wallpaperParts,
+} from '#composite/wiki-properties';
 
 export class WikiInfo extends Thing {
   static [Thing.friendlyName] = `Wiki Info`;
@@ -68,6 +77,10 @@ export class WikiInfo extends Thing {
       },
     },
 
+    wikiWallpaperFileExtension: fileExtension('jpg'),
+    wikiWallpaperStyle: simpleString(),
+    wikiWallpaperParts: wallpaperParts(),
+
     divideTrackListsByGroups: referenceList({
       class: input.value(Group),
       find: soupyFind.input('group'),
@@ -112,18 +125,34 @@ export class WikiInfo extends Thing {
     fields: {
       'Name': {property: 'name'},
       'Short Name': {property: 'nameShort'},
+
       'Color': {property: 'color'},
+
       'Description': {property: 'description'},
+
       'Footer Content': {property: 'footerContent'},
+
       'Default Language': {property: 'defaultLanguage'},
+
       'Canonical Base': {property: 'canonicalBase'},
-      'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'},
+
+      'Wiki Wallpaper File Extension': {property: 'wikiWallpaperFileExtension'},
+
+      'Wiki Wallpaper Style': {property: 'wikiWallpaperStyle'},
+
+      'Wiki Wallpaper Parts': {
+        property: 'wikiWallpaperParts',
+        transform: parseWallpaperParts,
+      },
+
       'Enable Flashes & Games': {property: 'enableFlashesAndGames'},
       'Enable Listings': {property: 'enableListings'},
       'Enable News': {property: 'enableNews'},
       'Enable Art Tag UI': {property: 'enableArtTagUI'},
       'Enable Group UI': {property: 'enableGroupUI'},
 
+      'Divide Track Lists By Groups': {property: 'divideTrackListsByGroups'},
+
       'Contribution Presets': {
         property: 'contributionPresets',
         transform: parseContributionPresets,