« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--src/content/dependencies/generateAdditionalNamesBox.js46
-rw-r--r--src/content/dependencies/generateAdditionalNamesBoxItem.js68
-rw-r--r--src/content/dependencies/generateTrackAdditionalNamesBox.js53
-rw-r--r--src/content/dependencies/generateTrackInfoPage.js11
-rw-r--r--src/data/composite/data/excludeFromList.js7
-rw-r--r--src/data/composite/data/fillMissingListItems.js7
-rw-r--r--src/data/composite/data/index.js3
-rw-r--r--src/data/composite/data/withFilteredList.js50
-rw-r--r--src/data/composite/data/withFlattenedList.js6
-rw-r--r--src/data/composite/data/withMappedList.js39
-rw-r--r--src/data/composite/data/withPropertiesFromList.js4
-rw-r--r--src/data/composite/data/withPropertyFromList.js4
-rw-r--r--src/data/composite/data/withSortedList.js126
-rw-r--r--src/data/composite/data/withUnflattenedList.js10
-rw-r--r--src/data/composite/things/track/index.js2
-rw-r--r--src/data/composite/things/track/inferredAdditionalNameList.js67
-rw-r--r--src/data/composite/things/track/sharedAdditionalNameList.js38
-rw-r--r--src/data/composite/things/track/trackAdditionalNameList.js38
-rw-r--r--src/data/composite/wiki-data/index.js1
-rw-r--r--src/data/composite/wiki-data/withThingsSortedAlphabetically.js122
-rw-r--r--src/data/composite/wiki-properties/additionalNameList.js1
-rw-r--r--src/data/things/track.js5
-rw-r--r--src/data/things/validators.js25
-rw-r--r--src/static/site6.css2
-rw-r--r--src/strings-default.yaml8
-rw-r--r--tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs99
-rw-r--r--test/snapshot/generateTrackAdditionalNamesBox.js107
27 files changed, 881 insertions, 68 deletions
diff --git a/src/content/dependencies/generateAdditionalNamesBox.js b/src/content/dependencies/generateAdditionalNamesBox.js
index f7fa3b00..63427c58 100644
--- a/src/content/dependencies/generateAdditionalNamesBox.js
+++ b/src/content/dependencies/generateAdditionalNamesBox.js
@@ -1,48 +1,20 @@
-import {stitchArrays} from '#sugar';
-
 export default {
-  contentDependencies: ['transformContent'],
+  contentDependencies: ['generateAdditionalNamesBoxItem'],
   extraDependencies: ['html', 'language'],
 
   relations: (relation, additionalNames) => ({
-    names:
-      additionalNames.map(({name}) =>
-        relation('transformContent', name)),
-
-    annotations:
-      additionalNames.map(({annotation}) =>
-        (annotation
-          ? relation('transformContent', annotation)
-          : null)),
+    items:
+      additionalNames
+        .map(entry => relation('generateAdditionalNamesBoxItem', entry)),
   }),
 
-  generate: (relations, {html, language}) => {
-    const names =
-      relations.names.map(name =>
-        html.tag('span', {class: 'additional-name'},
-          name.slot('mode', 'inline')));
-
-    const annotations =
-      relations.annotations.map(annotation =>
-        (annotation
-          ? html.tag('span', {class: 'annotation'},
-              language.$('misc.additionalNames.item.annotation', {
-                annotation:
-                  annotation.slot('mode', 'inline'),
-              }))
-          : null));
-
-    return html.tag('div', {id: 'additional-names-box'}, [
+  generate: (relations, {html, language}) =>
+    html.tag('div', {id: 'additional-names-box'}, [
       html.tag('p',
         language.$('misc.additionalNames.title')),
 
       html.tag('ul',
-        stitchArrays({name: names, annotation: annotations})
-          .map(({name, annotation}) =>
-            html.tag('li',
-              (annotation
-                ? language.$('misc.additionalNames.item.withAnnotation', {name, annotation})
-                : language.$('misc.additionalNames.item', {name}))))),
-    ]);
-  },
+        relations.items
+          .map(item => html.tag('li', item))),
+    ]),
 };
diff --git a/src/content/dependencies/generateAdditionalNamesBoxItem.js b/src/content/dependencies/generateAdditionalNamesBoxItem.js
new file mode 100644
index 00000000..bb4c8477
--- /dev/null
+++ b/src/content/dependencies/generateAdditionalNamesBoxItem.js
@@ -0,0 +1,68 @@
+import {stitchArrays} from '#sugar';
+
+export default {
+  contentDependencies: ['linkTrack', 'transformContent'],
+  extraDependencies: ['html', 'language'],
+
+  relations: (relation, entry) => ({
+    nameContent:
+      relation('transformContent', entry.name),
+
+    annotationContent:
+      (entry.annotation
+        ? relation('transformContent', entry.annotation)
+        : null),
+
+    trackLinks:
+      (entry.from
+        ? entry.from.map(track => relation('linkTrack', track))
+        : null),
+  }),
+
+  data: (entry) => ({
+    albumNames:
+      (entry.from
+        ? entry.from.map(track => track.album.name)
+        : null),
+  }),
+
+  generate: (data, relations, {html, language}) => {
+    const prefix = 'misc.additionalNames.item';
+
+    const itemParts = [prefix];
+    const itemOptions = {};
+
+    itemOptions.name =
+      html.tag('span', {class: 'additional-name'},
+        relations.nameContent.slot('mode', 'inline'));
+
+    const accentParts = [prefix, 'accent'];
+    const accentOptions = {};
+
+    if (relations.annotationContent) {
+      accentParts.push('withAnnotation');
+      accentOptions.annotation =
+        relations.annotationContent.slot('mode', 'inline');
+    }
+
+    if (relations.trackLinks) {
+      accentParts.push('withAlbums');
+      accentOptions.albums =
+        language.formatConjunctionList(
+          stitchArrays({
+            trackLink: relations.trackLinks,
+            albumName: data.albumNames,
+          }).map(({trackLink, albumName}) =>
+              trackLink.slot('content', albumName)));
+    }
+
+    if (accentParts.length > 2) {
+      itemParts.push('withAccent');
+      itemOptions.accent =
+        html.tag('span', {class: 'accent'},
+          language.$(...accentParts, accentOptions));
+    }
+
+    return language.$(...itemParts, itemOptions);
+  },
+};
diff --git a/src/content/dependencies/generateTrackAdditionalNamesBox.js b/src/content/dependencies/generateTrackAdditionalNamesBox.js
new file mode 100644
index 00000000..bad04b74
--- /dev/null
+++ b/src/content/dependencies/generateTrackAdditionalNamesBox.js
@@ -0,0 +1,53 @@
+import {empty} from '#sugar';
+
+export default {
+  contentDependencies: ['generateAdditionalNamesBox'],
+  extraDependencies: ['html'],
+
+  query: (track) => {
+    const {
+      additionalNames: own,
+      sharedAdditionalNames: shared,
+      inferredAdditionalNames: inferred,
+    } = track;
+
+    if (empty(own) && empty(shared) && empty(inferred)) {
+      return {combinedList: []};
+    }
+
+    const firstFilter =
+      (empty(own)
+        ? new Set()
+        : new Set(own.map(({name}) => name)));
+
+    const sharedFiltered =
+      shared.filter(({name}) => !firstFilter.has(name))
+
+    const secondFilter =
+      new Set([
+        ...firstFilter,
+        ...sharedFiltered.map(({name}) => name),
+      ]);
+
+    const inferredFiltered =
+      inferred.filter(({name}) => !secondFilter.has(name));
+
+    return {
+      combinedList: [
+        ...own,
+        ...sharedFiltered,
+        ...inferredFiltered,
+      ],
+    };
+  },
+
+  relations: (relation, query) => ({
+    box:
+      (empty(query.combinedList)
+        ? null
+        : relation('generateAdditionalNamesBox', query.combinedList)),
+  }),
+
+  generate: (relations, {html}) =>
+    relations.box ?? html.blank(),
+};
diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js
index 11fb2b9c..041f6bbc 100644
--- a/src/content/dependencies/generateTrackInfoPage.js
+++ b/src/content/dependencies/generateTrackInfoPage.js
@@ -7,7 +7,6 @@ export default {
   contentDependencies: [
     'generateAbsoluteDatetimestamp',
     'generateAdditionalFilesShortcut',
-    'generateAdditionalNamesBox',
     'generateAlbumAdditionalFilesList',
     'generateAlbumNavAccent',
     'generateAlbumSidebar',
@@ -19,6 +18,7 @@ export default {
     'generateContributionList',
     'generatePageLayout',
     'generateRelativeDatetimestamp',
+    'generateTrackAdditionalNamesBox',
     'generateTrackCoverArtwork',
     'generateTrackList',
     'generateTrackListDividedByGroups',
@@ -111,10 +111,9 @@ export default {
       list: relation('generateAlbumAdditionalFilesList', album, additionalFiles),
     });
 
-    if (!empty(track.additionalNames)) {
-      relations.additionalNamesBox =
-        relation('generateAdditionalNamesBox', track.additionalNames);
-    }
+    // This'll take care of itself being blank if there's nothing to show here.
+    relations.additionalNamesBox =
+      relation('generateTrackAdditionalNamesBox', track);
 
     if (track.hasUniqueCoverArt || album.hasCoverArt) {
       relations.cover =
@@ -331,7 +330,7 @@ export default {
         title: language.$('trackPage.title', {track: data.name}),
         headingMode: 'sticky',
 
-        additionalNames: relations.additionalNamesBox ?? null,
+        additionalNames: relations.additionalNamesBox,
 
         color: data.color,
         styleRules: [relations.albumStyleRules],
diff --git a/src/data/composite/data/excludeFromList.js b/src/data/composite/data/excludeFromList.js
index 718f2294..d798dcdc 100644
--- a/src/data/composite/data/excludeFromList.js
+++ b/src/data/composite/data/excludeFromList.js
@@ -6,10 +6,9 @@
 //  - fillMissingListItems
 //
 // More list utilities:
-//  - withFlattenedList
-//  - withPropertyFromList
-//  - withPropertiesFromList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/fillMissingListItems.js b/src/data/composite/data/fillMissingListItems.js
index c06eceda..4f818a79 100644
--- a/src/data/composite/data/fillMissingListItems.js
+++ b/src/data/composite/data/fillMissingListItems.js
@@ -5,10 +5,9 @@
 //  - excludeFromList
 //
 // More list utilities:
-//  - withFlattenedList
-//  - withPropertyFromList
-//  - withPropertiesFromList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/index.js b/src/data/composite/data/index.js
index e2927afd..256c0490 100644
--- a/src/data/composite/data/index.js
+++ b/src/data/composite/data/index.js
@@ -5,10 +5,13 @@
 
 export {default as excludeFromList} from './excludeFromList.js';
 export {default as fillMissingListItems} from './fillMissingListItems.js';
+export {default as withFilteredList} from './withFilteredList.js';
 export {default as withFlattenedList} from './withFlattenedList.js';
+export {default as withMappedList} from './withMappedList.js';
 export {default as withPropertiesFromList} from './withPropertiesFromList.js';
 export {default as withPropertiesFromObject} from './withPropertiesFromObject.js';
 export {default as withPropertyFromList} from './withPropertyFromList.js';
 export {default as withPropertyFromObject} from './withPropertyFromObject.js';
+export {default as withSortedList} from './withSortedList.js';
 export {default as withUnflattenedList} from './withUnflattenedList.js';
 export {default as withUniqueItemsOnly} from './withUniqueItemsOnly.js';
diff --git a/src/data/composite/data/withFilteredList.js b/src/data/composite/data/withFilteredList.js
new file mode 100644
index 00000000..82e56903
--- /dev/null
+++ b/src/data/composite/data/withFilteredList.js
@@ -0,0 +1,50 @@
+// Applies a filter - an array of truthy and falsy values - to the index-
+// corresponding items in a list. Items which correspond to a truthy value
+// are kept, and the rest are excluded from the output list.
+//
+// TODO: It would be neat to apply an availability check here, e.g. to allow
+// not providing a filter at all and performing the check on the contents of
+// the list (though on the filter, if present, is fine too). But that's best
+// done by some shmancy-fancy mapping support in composite.js, so a bit out
+// of reach for now (apart from proving uses built on top of a more boring
+// implementation).
+//
+// TODO: There should be two outputs - one for the items included according to
+// the filter, and one for the items excluded.
+//
+// See also:
+//  - withMappedList
+//  - withSortedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withFilteredList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    filter: input({type: 'array'}),
+  },
+
+  outputs: ['#filteredList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('filter')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('filter')]: filter,
+      }) => continuation({
+        '#filteredList':
+          list.filter((item, index) => filter[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withFlattenedList.js b/src/data/composite/data/withFlattenedList.js
index b08edb4e..edfa3403 100644
--- a/src/data/composite/data/withFlattenedList.js
+++ b/src/data/composite/data/withFlattenedList.js
@@ -3,13 +3,13 @@
 // successive source array.
 //
 // See also:
-//  - withFlattenedList
+//  - withUnflattenedList
 //
 // More list utilities:
 //  - excludeFromList
 //  - fillMissingListItems
-//  - withPropertyFromList
-//  - withPropertiesFromList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withPropertyFromList, withPropertiesFromList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/withMappedList.js b/src/data/composite/data/withMappedList.js
new file mode 100644
index 00000000..e0a700b2
--- /dev/null
+++ b/src/data/composite/data/withMappedList.js
@@ -0,0 +1,39 @@
+// Applies a map function to each item in a list, just like a normal JavaScript
+// map.
+//
+// See also:
+//  - withFilteredList
+//  - withSortedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+
+export default templateCompositeFrom({
+  annotation: `withMappedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    map: input({type: 'function'}),
+  },
+
+  outputs: ['#mappedList'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('map')],
+      compute: (continuation, {
+        [input('list')]: list,
+        [input('map')]: mapFn,
+      }) => continuation({
+        ['#mappedList']:
+          list.map(mapFn),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/data/withPropertiesFromList.js b/src/data/composite/data/withPropertiesFromList.js
index 76ba696c..08907bab 100644
--- a/src/data/composite/data/withPropertiesFromList.js
+++ b/src/data/composite/data/withPropertiesFromList.js
@@ -11,8 +11,8 @@
 // More list utilities:
 //  - excludeFromList
 //  - fillMissingListItems
-//  - withFlattenedList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/withPropertyFromList.js b/src/data/composite/data/withPropertyFromList.js
index 1983ebbc..a2c66d77 100644
--- a/src/data/composite/data/withPropertyFromList.js
+++ b/src/data/composite/data/withPropertyFromList.js
@@ -12,8 +12,8 @@
 // More list utilities:
 //  - excludeFromList
 //  - fillMissingListItems
-//  - withFlattenedList
-//  - withUnflattenedList
+//  - withFilteredList, withMappedList, withSortedList
+//  - withFlattenedList, withUnflattenedList
 //
 
 import {input, templateCompositeFrom} from '#composite';
diff --git a/src/data/composite/data/withSortedList.js b/src/data/composite/data/withSortedList.js
new file mode 100644
index 00000000..882907f5
--- /dev/null
+++ b/src/data/composite/data/withSortedList.js
@@ -0,0 +1,126 @@
+// Applies a sort function across pairs of items in a list, just like a normal
+// JavaScript sort. Alongside the sorted results, so are outputted the indices
+// which each item in the unsorted list corresponds to in the sorted one,
+// allowing for the results of this sort to be composed in some more involved
+// operation. For example, using an alphabetical sort, the list ['banana',
+// 'apple', 'pterodactyl'] will output the expected alphabetical items, as well
+// as the indices list [1, 0, 2].
+//
+// If two items are equal (in the eyes of the sort operation), their placement
+// in the sorted list is arbitrary, though every input index will be present in
+// '#sortIndices' exactly once (and equal items will be bunched together).
+//
+// The '#sortIndices' output refers to the "true" index which each source item
+// occupies in the sorted list. This sacrifices information about equal items,
+// which can be obtained through '#unstableSortIndices' instead: each mapped
+// index may appear more than once, and rather than represent exact positions
+// in the sorted list, they represent relational values: if items A and B are
+// mapped to indices 3 and 5, then A certainly is positioned before B (and vice
+// versa); but there may be more than one item in-between. If items C and D are
+// both mapped to index 4, then their position relative to each other is
+// arbitrary - they are equal - but they both certainly appear after item A and
+// before item B.
+//
+// This implementation is based on the one used for sortMultipleArrays.
+//
+// See also:
+//  - withFilteredList
+//  - withMappedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFlattenedList, withUnflattenedList
+//  - withPropertyFromList, withPropertiesFromList
+//
+
+import {input, templateCompositeFrom} from '#composite';
+import {empty} from '#sugar';
+
+export default templateCompositeFrom({
+  annotation: `withSortedList`,
+
+  inputs: {
+    list: input({type: 'array'}),
+    sort: input({type: 'function'}),
+  },
+
+  outputs: ['#sortedList', '#sortIndices', '#unstableSortIndices'],
+
+  steps: () => [
+    {
+      dependencies: [input('list'), input('sort')],
+      compute(continuation, {
+        [input('list')]: list,
+        [input('sort')]: sortFn,
+      }) {
+        const symbols =
+          Array.from({length: list.length}, () => Symbol());
+
+        const equalSymbols =
+          new Map();
+
+        const indexMap =
+          new Map(Array.from(symbols,
+            (symbol, index) => [symbol, index]));
+
+        symbols.sort((symbol1, symbol2) => {
+          const comparison =
+            sortFn(
+              list[indexMap.get(symbol1)],
+              list[indexMap.get(symbol2)]);
+
+          if (comparison === 0) {
+            if (equalSymbols.has(symbol1)) {
+              equalSymbols.get(symbol1).add(symbol2);
+            } else {
+              equalSymbols.set(symbol1, new Set([symbol2]));
+            }
+
+            if (equalSymbols.has(symbol2)) {
+              equalSymbols.get(symbol2).add(symbol1);
+            } else {
+              equalSymbols.set(symbol2, new Set([symbol1]));
+            }
+          }
+
+          return comparison;
+        });
+
+        const sortIndices =
+          symbols.map(symbol => indexMap.get(symbol));
+
+        const sortedList =
+          sortIndices.map(index => list[index]);
+
+        const stableToUnstable =
+          symbols
+            .map((symbol, index) =>
+              index > 0 &&
+              equalSymbols.get(symbols[index - 1])?.has(symbol))
+            .reduce((accumulator, collapseEqual) => {
+              if (empty(accumulator)) {
+                accumulator.push(0);
+              } else {
+                const last = accumulator[accumulator.length - 1];
+                if (collapseEqual) {
+                  accumulator.push(last);
+                } else {
+                  accumulator.push(last + 1);
+                }
+              }
+              return accumulator;
+            }, []);
+
+        const unstableSortIndices =
+          sortIndices.map(stable => stableToUnstable[stable]);
+
+        return continuation({
+          ['#sortedList']: sortedList,
+          ['#sortIndices']: sortIndices,
+          ['#unstableSortIndices']: unstableSortIndices,
+        });
+      },
+    },
+  ],
+});
diff --git a/src/data/composite/data/withUnflattenedList.js b/src/data/composite/data/withUnflattenedList.js
index 3cfc247b..39a666dc 100644
--- a/src/data/composite/data/withUnflattenedList.js
+++ b/src/data/composite/data/withUnflattenedList.js
@@ -3,6 +3,16 @@
 // of filtering them out), this function allows for recombining them. It will
 // filter out null and undefined items by default (pass {filter: false} to
 // disable this).
+//
+// See also:
+//  - withFlattenedList
+//
+// More list utilities:
+//  - excludeFromList
+//  - fillMissingListItems
+//  - withFilteredList, withMappedList, withSortedList
+//  - withPropertyFromList, withPropertiesFromList
+//
 
 import {input, templateCompositeFrom} from '#composite';
 import {isWholeNumber, validateArrayItems} from '#validators';
diff --git a/src/data/composite/things/track/index.js b/src/data/composite/things/track/index.js
index 3354b1c4..cc723a24 100644
--- a/src/data/composite/things/track/index.js
+++ b/src/data/composite/things/track/index.js
@@ -1,5 +1,7 @@
 export {default as exitWithoutUniqueCoverArt} from './exitWithoutUniqueCoverArt.js';
+export {default as inferredAdditionalNameList} from './inferredAdditionalNameList.js';
 export {default as inheritFromOriginalRelease} from './inheritFromOriginalRelease.js';
+export {default as sharedAdditionalNameList} from './sharedAdditionalNameList.js';
 export {default as trackReverseReferenceList} from './trackReverseReferenceList.js';
 export {default as withAlbum} from './withAlbum.js';
 export {default as withAlwaysReferenceByDirectory} from './withAlwaysReferenceByDirectory.js';
diff --git a/src/data/composite/things/track/inferredAdditionalNameList.js b/src/data/composite/things/track/inferredAdditionalNameList.js
new file mode 100644
index 00000000..9cf158c6
--- /dev/null
+++ b/src/data/composite/things/track/inferredAdditionalNameList.js
@@ -0,0 +1,67 @@
+// Infers additional name entries from other releases that were titled
+// differently; the corresponding releases are stored in eacn entry's "from"
+// array, which will include multiple items, if more than one other release
+// shares the same name differing from this one's.
+
+import {input, templateCompositeFrom} from '#composite';
+import {chunkByProperties} from '#wiki-data';
+
+import {exitWithoutDependency} from '#composite/control-flow';
+import {withFilteredList, withPropertyFromList} from '#composite/data';
+import {withThingsSortedAlphabetically} from '#composite/wiki-data';
+
+import withOtherReleases from './withOtherReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `inferredAdditionalNameList`,
+
+  compose: false,
+
+  steps: () => [
+    withOtherReleases(),
+
+    exitWithoutDependency({
+      dependency: '#otherReleases',
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    withPropertyFromList({
+      list: '#otherReleases',
+      property: input.value('name'),
+    }),
+
+    {
+      dependencies: ['#otherReleases.name', 'name'],
+      compute: (continuation, {
+        ['#otherReleases.name']: releaseNames,
+        ['name']: ownName,
+      }) => continuation({
+        ['#nameFilter']:
+          releaseNames.map(name => name !== ownName),
+      }),
+    },
+
+    withFilteredList({
+      list: '#otherReleases',
+      filter: '#nameFilter',
+    }).outputs({
+      '#filteredList': '#differentlyNamedReleases',
+    }),
+
+    withThingsSortedAlphabetically({
+      things: '#differentlyNamedReleases',
+    }).outputs({
+      '#sortedThings': '#differentlyNamedReleases',
+    }),
+
+    {
+      dependencies: ['#differentlyNamedReleases'],
+      compute: ({
+        ['#differentlyNamedReleases']: releases,
+      }) =>
+        chunkByProperties(releases, ['name'])
+          .map(({name, chunk}) => ({name, from: chunk})),
+    },
+  ],
+});
diff --git a/src/data/composite/things/track/sharedAdditionalNameList.js b/src/data/composite/things/track/sharedAdditionalNameList.js
new file mode 100644
index 00000000..1806ec80
--- /dev/null
+++ b/src/data/composite/things/track/sharedAdditionalNameList.js
@@ -0,0 +1,38 @@
+// Compiles additional names directly provided by other releases.
+
+import {input, templateCompositeFrom} from '#composite';
+
+import {exitWithoutDependency, exposeDependency}
+  from '#composite/control-flow';
+import {withFlattenedList, withPropertyFromList} from '#composite/data';
+
+import withOtherReleases from './withOtherReleases.js';
+
+export default templateCompositeFrom({
+  annotation: `sharedAdditionalNameList`,
+
+  compose: false,
+
+  steps: () => [
+    withOtherReleases(),
+
+    exitWithoutDependency({
+      dependency: '#otherReleases',
+      mode: input.value('empty'),
+      value: input.value([]),
+    }),
+
+    withPropertyFromList({
+      list: '#otherReleases',
+      property: input.value('additionalNames'),
+    }),
+
+    withFlattenedList({
+      list: '#otherReleases.additionalNames',
+    }),
+
+    exposeDependency({
+      dependency: '#flattenedList',
+    }),
+  ],
+});
diff --git a/src/data/composite/things/track/trackAdditionalNameList.js b/src/data/composite/things/track/trackAdditionalNameList.js
new file mode 100644
index 00000000..65a2263d
--- /dev/null
+++ b/src/data/composite/things/track/trackAdditionalNameList.js
@@ -0,0 +1,38 @@
+// Compiles additional names from various sources.
+
+import {input, templateCompositeFrom} from '#composite';
+import {isAdditionalNameList} from '#validators';
+
+import withInferredAdditionalNames from './withInferredAdditionalNames.js';
+import withSharedAdditionalNames from './withSharedAdditionalNames.js';
+
+export default templateCompositeFrom({
+  annotation: `trackAdditionalNameList`,
+
+  compose: false,
+
+  update: {validate: isAdditionalNameList},
+
+  steps: () => [
+    withInferredAdditionalNames(),
+    withSharedAdditionalNames(),
+
+    {
+      dependencies: [
+        '#inferredAdditionalNames',
+        '#sharedAdditionalNames',
+        input.updateValue(),
+      ],
+
+      compute: ({
+        ['#inferredAdditionalNames']: inferredAdditionalNames,
+        ['#sharedAdditionalNames']: sharedAdditionalNames,
+        [input.updateValue()]: providedAdditionalNames,
+      }) => [
+        ...providedAdditionalNames ?? [],
+        ...sharedAdditionalNames,
+        ...inferredAdditionalNames,
+      ],
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-data/index.js b/src/data/composite/wiki-data/index.js
index df50a2db..a2ff09d8 100644
--- a/src/data/composite/wiki-data/index.js
+++ b/src/data/composite/wiki-data/index.js
@@ -12,3 +12,4 @@ export {default as withResolvedContribs} from './withResolvedContribs.js';
 export {default as withResolvedReference} from './withResolvedReference.js';
 export {default as withResolvedReferenceList} from './withResolvedReferenceList.js';
 export {default as withReverseReferenceList} from './withReverseReferenceList.js';
+export {default as withThingsSortedAlphabetically} from './withThingsSortedAlphabetically.js';
diff --git a/src/data/composite/wiki-data/withThingsSortedAlphabetically.js b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
new file mode 100644
index 00000000..d2487e42
--- /dev/null
+++ b/src/data/composite/wiki-data/withThingsSortedAlphabetically.js
@@ -0,0 +1,122 @@
+// Sorts a list of live, generic wiki data objects alphabetically.
+// Note that this uses localeCompare but isn't specialized to a particular
+// language; where localization is concerned (in content), a follow-up, locale-
+// specific sort should be performed. But this function does serve to organize
+// a list so same-name entries are beside each other.
+
+import {input, templateCompositeFrom} from '#composite';
+import {validateWikiData} from '#validators';
+import {compareCaseLessSensitive, normalizeName} from '#wiki-data';
+
+import {raiseOutputWithoutDependency} from '#composite/control-flow';
+import {withMappedList, withSortedList, withPropertiesFromList}
+  from '#composite/data';
+
+export default templateCompositeFrom({
+  annotation: `withThingsSortedAlphabetically`,
+
+  inputs: {
+    things: input({validate: validateWikiData}),
+  },
+
+  outputs: ['#sortedThings'],
+
+  steps: () => [
+    raiseOutputWithoutDependency({
+      dependency: input('things'),
+      mode: input.value('empty'),
+      output: input.value({'#sortedThings': []}),
+    }),
+
+    withPropertiesFromList({
+      list: input('things'),
+      properties: input.value(['name', 'directory']),
+    }).outputs({
+      '#list.name': '#names',
+      '#list.directory': '#directories',
+    }),
+
+    withMappedList({
+      list: '#names',
+      map: input.value(normalizeName),
+    }).outputs({
+      '#mappedList': '#normalizedNames',
+    }),
+
+    withSortedList({
+      list: '#normalizedNames',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#normalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#names',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#nonNormalizedNameSortIndices',
+    }),
+
+    withSortedList({
+      list: '#directories',
+      sort: input.value(compareCaseLessSensitive),
+    }).outputs({
+      '#unstableSortIndices': '#directorySortIndices',
+    }),
+
+    // TODO: No primitive for the next two-three steps, yet...
+
+    {
+      dependencies: [input('things')],
+      compute: (continuation, {
+        [input('things')]: things,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          Array.from(
+            {length: things.length},
+            (_item, index) => index),
+      }),
+    },
+
+    {
+      dependencies: [
+        '#combinedSortIndices',
+        '#normalizedNameSortIndices',
+        '#nonNormalizedNameSortIndices',
+        '#directorySortIndices',
+      ],
+
+      compute: (continuation, {
+        ['#combinedSortIndices']: combined,
+        ['#normalizedNameSortIndices']: normalized,
+        ['#nonNormalizedNameSortIndices']: nonNormalized,
+        ['#directorySortIndices']: directory,
+      }) => continuation({
+        ['#combinedSortIndices']:
+          combined.sort((index1, index2) => {
+            if (normalized[index1] !== normalized[index2])
+              return normalized[index1] - normalized[index2];
+
+            if (nonNormalized[index1] !== nonNormalized[index2])
+              return nonNormalized[index1] - nonNormalized[index2];
+
+            if (directory[index1] !== directory[index2])
+              return directory[index1] - directory[index2];
+
+            return 0;
+          }),
+      }),
+    },
+
+    {
+      dependencies: [input('things'), '#combinedSortIndices'],
+      compute: (continuation, {
+        [input('things')]: things,
+        ['#combinedSortIndices']: combined,
+      }) => continuation({
+        ['#sortedThings']:
+          combined.map(index => things[index]),
+      }),
+    },
+  ],
+});
diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js
index d1302224..c5971d4a 100644
--- a/src/data/composite/wiki-properties/additionalNameList.js
+++ b/src/data/composite/wiki-properties/additionalNameList.js
@@ -9,5 +9,6 @@ export default function() {
   return {
     flags: {update: true, expose: true},
     update: {validate: isAdditionalNameList},
+    expose: {transform: value => value ?? []},
   };
 }
diff --git a/src/data/things/track.js b/src/data/things/track.js
index d25213c2..e3fe0804 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -43,7 +43,9 @@ import {
 
 import {
   exitWithoutUniqueCoverArt,
+  inferredAdditionalNameList,
   inheritFromOriginalRelease,
+  sharedAdditionalNameList,
   trackReverseReferenceList,
   withAlbum,
   withAlwaysReferenceByDirectory,
@@ -64,7 +66,10 @@ export class Track extends Thing {
 
     name: name('Unnamed Track'),
     directory: directory(),
+
     additionalNames: additionalNameList(),
+    sharedAdditionalNames: sharedAdditionalNameList(),
+    inferredAdditionalNames: inferredAdditionalNameList(),
 
     duration: duration(),
     urls: urls(),
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index 55eedbcf..ac91b456 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -429,13 +429,6 @@ export function isURL(string) {
   return true;
 }
 
-export const isAdditionalName = validateProperties({
-  name: isName,
-  annotation: optional(isStringNonEmpty),
-});
-
-export const isAdditionalNameList = validateArrayItems(isAdditionalName);
-
 export function validateReference(type = 'track') {
   return (ref) => {
     isStringNonEmpty(ref);
@@ -557,6 +550,24 @@ export function validateWikiData({
   };
 }
 
+export const isAdditionalName = validateProperties({
+  name: isName,
+  annotation: optional(isStringNonEmpty),
+
+  // TODO: This only allows indicating sourcing from a track.
+  // That's okay for the current limited use of "from", but
+  // could be expanded later.
+  from:
+    // Double TODO: Explicitly allowing both references and
+    // live objects to co-exist is definitely weird, and
+    // altogether questions the way we define validators...
+    optional(oneOf(
+      validateReferenceList('track'),
+      validateWikiData({referenceType: 'track'}))),
+});
+
+export const isAdditionalNameList = validateArrayItems(isAdditionalName);
+
 // Compositional utilities
 
 export function oneOf(...checks) {
diff --git a/src/static/site6.css b/src/static/site6.css
index e30992f5..b2130222 100644
--- a/src/static/site6.css
+++ b/src/static/site6.css
@@ -992,7 +992,7 @@ h1 a[href="#additional-names-box"]:hover {
   margin-top: 0.5em;
 }
 
-#additional-names-box li .annotation {
+#additional-names-box li .accent {
   opacity: 0.8;
   display: inline-block;
 }
diff --git a/src/strings-default.yaml b/src/strings-default.yaml
index 61b376c0..c15a8f5d 100644
--- a/src/strings-default.yaml
+++ b/src/strings-default.yaml
@@ -397,8 +397,12 @@ misc:
 
     item:
       _: "{NAME}"
-      withAnnotation: "{NAME} {ANNOTATION}"
-      annotation: "({ANNOTATION})"
+      withAccent: "{NAME} {ACCENT}"
+
+      accent:
+        withAnnotation: "({ANNOTATION})"
+        withAlbums: "(on {ALBUMS})"
+        withAnnotations.withAlbums: "({ANNOTATION}; on {ALBUMS})"
 
   # alt:
   #   Fallback text for the alt text of images and artworks - these
diff --git a/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs
new file mode 100644
index 00000000..3a22266e
--- /dev/null
+++ b/tap-snapshots/test/snapshot/generateTrackAdditionalNamesBox.js.test.cjs
@@ -0,0 +1,99 @@
+/* IMPORTANT
+ * This snapshot file is auto-generated, but designed for humans.
+ * It should be checked into source control and tracked carefully.
+ * Re-generate by setting TAP_SNAPSHOT=1 and running tests.
+ * Make sure to inspect the output below.  Do not ignore changes!
+ */
+'use strict'
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > inferred additional names only 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Baz Baz', from: [ { directory: 'the-pyrenees' } ] }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > multiple own 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Apple Time!' },
+     { name: 'Pterodactyl Time!' },
+     { name: 'Banana Time!' }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > no additional names 1`] = `
+
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own additional names only 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [ [ { name: 'Foo Bar', annotation: 'the Alps' } ] ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and inferred, some overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Ke$halo Strike Back', annotation: 'own annotation' },
+     { name: 'Ironic Mania', annotation: 'own annotation' },
+     {
+       name: 'ANARCHY::MEGASTRIFE',
+       from: [ { directory: 'inferred-from' } ]
+     }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared and inferred, various overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Own!', annotation: 'own annotation' },
+     { name: 'Own! Shared!', annotation: 'own annotation' },
+     { name: 'Own! Inferred!', annotation: 'own annotation' },
+     { name: 'Own! Shared! Inferred!', annotation: 'own annotation' },
+     { name: 'Shared!', annotation: 'shared annotation' },
+     { name: 'Shared! Inferred!', annotation: 'shared annotation' },
+     { name: 'Inferred!', from: [ { directory: 'inferred-from' } ] }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > own and shared, some overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'weed dreams..', annotation: 'own annotation' },
+     { name: '夜間のMOON汗', annotation: 'own annotation' },
+     { name: 'GAMINGブラザー96', annotation: 'shared annotation' }
+   ]
+ ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared additional names only 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [ [ { name: 'Bar Foo', annotation: 'the Rockies' } ] ]
+ slots: {}]
+`
+
+exports[`test/snapshot/generateTrackAdditionalNamesBox.js > TAP > generateTrackAdditionalNamesBox (snapshot) > shared and inferred, some overlap 1`] = `
+[mocked: generateAdditionalNamesBox
+ args: [
+   [
+     { name: 'Coruscate', annotation: 'shared annotation' },
+     { name: 'Arbroath', annotation: 'shared annotation' },
+     { name: 'Prana Ferox', from: [ { directory: 'inferred-from' } ] }
+   ]
+ ]
+ slots: {}]
+`
diff --git a/test/snapshot/generateTrackAdditionalNamesBox.js b/test/snapshot/generateTrackAdditionalNamesBox.js
new file mode 100644
index 00000000..9c1e3598
--- /dev/null
+++ b/test/snapshot/generateTrackAdditionalNamesBox.js
@@ -0,0 +1,107 @@
+import t from 'tap';
+
+import contentFunction from '#content-function';
+import {testContentFunctions} from '#test-lib';
+
+testContentFunctions(t, 'generateTrackAdditionalNamesBox (snapshot)', async (t, evaluate) => {
+  await evaluate.load({
+    mock: {
+      generateAdditionalNamesBox:
+        evaluate.stubContentFunction('generateAdditionalNamesBox'),
+    },
+  });
+
+  const stubTrack = {
+    additionalNames: [],
+    sharedAdditionalNames: [],
+    inferredAdditionalNames: [],
+  };
+
+  const quickSnapshot = (message, trackProperties) =>
+    evaluate.snapshot(message, {
+      name: 'generateTrackAdditionalNamesBox',
+      args: [{...stubTrack, ...trackProperties}],
+    });
+
+  quickSnapshot(`no additional names`, {});
+
+  quickSnapshot(`own additional names only`, {
+    additionalNames: [
+      {name: `Foo Bar`, annotation: `the Alps`},
+    ],
+  });
+
+  quickSnapshot(`shared additional names only`, {
+    sharedAdditionalNames: [
+      {name: `Bar Foo`, annotation: `the Rockies`},
+    ],
+  });
+
+  quickSnapshot(`inferred additional names only`, {
+    inferredAdditionalNames: [
+      {name: `Baz Baz`, from: [{directory: `the-pyrenees`}]},
+    ],
+  });
+
+  quickSnapshot(`multiple own`, {
+    additionalNames: [
+      {name: `Apple Time!`},
+      {name: `Pterodactyl Time!`},
+      {name: `Banana Time!`},
+    ],
+  });
+
+  quickSnapshot(`own and shared, some overlap`, {
+    additionalNames: [
+      {name: `weed dreams..`, annotation: `own annotation`},
+      {name: `夜間のMOON汗`, annotation: `own annotation`},
+    ],
+    sharedAdditionalNames: [
+      {name: `weed dreams..`, annotation: `shared annotation`},
+      {name: `GAMINGブラザー96`, annotation: `shared annotation`},
+    ],
+  });
+
+  quickSnapshot(`shared and inferred, some overlap`, {
+    sharedAdditionalNames: [
+      {name: `Coruscate`, annotation: `shared annotation`},
+      {name: `Arbroath`, annotation: `shared annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Arbroath`, from: [{directory: `inferred-from`}]},
+      {name: `Prana Ferox`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+
+  quickSnapshot(`own and inferred, some overlap`, {
+    additionalNames: [
+      {name: `Ke$halo Strike Back`, annotation: `own annotation`},
+      {name: `Ironic Mania`, annotation: `own annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Ironic Mania`, from: [{directory: `inferred-from`}]},
+      {name: `ANARCHY::MEGASTRIFE`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+
+  quickSnapshot(`own and shared and inferred, various overlap`, {
+    additionalNames: [
+      {name: `Own!`, annotation: `own annotation`},
+      {name: `Own! Shared!`, annotation: `own annotation`},
+      {name: `Own! Inferred!`, annotation: `own annotation`},
+      {name: `Own! Shared! Inferred!`, annotation: `own annotation`},
+    ],
+    sharedAdditionalNames: [
+      {name: `Shared!`, annotation: `shared annotation`},
+      {name: `Own! Shared!`, annotation: `shared annotation`},
+      {name: `Shared! Inferred!`, annotation: `shared annotation`},
+      {name: `Own! Shared! Inferred!`, annotation: `shared annotation`},
+    ],
+    inferredAdditionalNames: [
+      {name: `Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Own! Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Shared! Inferred!`, from: [{directory: `inferred-from`}]},
+      {name: `Own! Shared! Inferred!`, from: [{directory: `inferred-from`}]},
+    ],
+  });
+});