« 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/listArtistsByLatestContribution.js241
-rw-r--r--src/strings-default.json4
-rw-r--r--src/util/sugar.js35
-rw-r--r--src/util/wiki-data.js104
4 files changed, 325 insertions, 59 deletions
diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js
index 3ebb084d..450c3d9f 100644
--- a/src/content/dependencies/listArtistsByLatestContribution.js
+++ b/src/content/dependencies/listArtistsByLatestContribution.js
@@ -1,15 +1,23 @@
-import {empty, stitchArrays} from '../../util/sugar.js';
+import {transposeArrays, empty, stitchArrays} from '../../util/sugar.js';
 
 import {
+  chunkMultipleArrays,
+  compareCaseLessSensitive,
   compareDates,
   filterMultipleArrays,
-  getLatestDate,
+  reduceMultipleArrays,
   sortAlphabetically,
   sortMultipleArrays,
 } from '../../util/wiki-data.js';
 
 export default {
-  contentDependencies: ['generateListingPage', 'linkArtist'],
+  contentDependencies: [
+    'generateListingPage',
+    'linkAlbum',
+    'linkArtist',
+    'linkFlash',
+  ],
+
   extraDependencies: ['html', 'language', 'wikiData'],
 
   sprawl({artistData, wikiInfo}) {
@@ -25,66 +33,136 @@ export default {
       enableFlashesAndGames: sprawl.enableFlashesAndGames,
     };
 
-    const queryContributionInfo = (artistsKey, datesKey, datelessArtistsKey, fn) => {
+    const queryContributionInfo = (
+      artistsKey,
+      chunkThingsKey,
+      datesKey,
+      datelessArtistsKey,
+      fn,
+    ) => {
       const artists = sortAlphabetically(sprawl.artistData.slice());
 
-      // Each value stored in this list, corresponding to each artist,
+      // Each value stored in dateLists, corresponding to each artist,
       // is going to be a list of dates and nulls. Any nulls represent
       // a contribution which isn't associated with a particular date.
-      const dateLists = artists.map(artist => fn(artist));
+      const [chunkThingLists, dateLists] =
+        transposeArrays(artists.map(artist => fn(artist)));
 
       // Scrap artists who don't even have any relevant contributions.
       // These artists may still have other contributions across the wiki, but
       // they weren't returned by the callback and so aren't relevant to this
       // list.
-      filterMultipleArrays(artists, dateLists, (artist, dates) => !empty(dates));
-
-      const dates = dateLists.map(dates => getLatestDate(dates));
-
-      // Also exclude artists whose remaining contributions are all dateless -
-      // in this case getLatestDate above will have returned null. But keep
-      // track of the artists removed here, since they'll be displayed in an
-      // additional list in the final listing page.
+      filterMultipleArrays(
+        artists,
+        chunkThingLists,
+        dateLists,
+        (artists, chunkThings, dates) => !empty(dates));
+
+      // Also exclude artists whose remaining contributions are all dateless.
+      // But keep track of the artists removed here, since they'll be displayed
+      // in an additional list in the final listing page.
       const {removed: [datelessArtists]} =
-        filterMultipleArrays(artists, dates, (artist, date) => date);
+        filterMultipleArrays(
+          artists,
+          chunkThingLists,
+          dateLists,
+          (artist, chunkThings, dates) => !empty(dates.filter(Boolean)));
+
+      // Cut out dateless contributions. They're not relevant to finding the
+      // latest date.
+      for (const [chunkThings, dates] of transposeArrays([chunkThingLists, dateLists])) {
+        filterMultipleArrays(chunkThings, dates, (chunkThing, date) => date);
+      }
+
+      const [chunkThings, dates] =
+        transposeArrays(
+          transposeArrays([chunkThingLists, dateLists])
+            .map(([chunkThings, dates]) =>
+              reduceMultipleArrays(
+                chunkThings, dates,
+                (accChunkThing, accDate, chunkThing, date) =>
+                  (date && date < accDate
+                    ? [chunkThing, date]
+                    : [accChunkThing, accDate]))));
+
+      sortMultipleArrays(artists, dates, chunkThings,
+        (artistA, artistB, dateA, dateB, chunkThingA, chunkThingB) => {
+          const dateComparison = compareDates(dateA, dateB, {latestFirst: true});
+          if (dateComparison !== 0) {
+            return dateComparison;
+          }
+
+          // TODO: Compare alphabetically, not just by directory.
+          return compareCaseLessSensitive(chunkThingA.directory, chunkThingB.directory);
+        });
+
+      const chunks =
+        chunkMultipleArrays(artists, dates, chunkThings,
+          (artist, lastArtist, date, lastDate, chunkThing, lastChunkThing) =>
+            +date !== +lastDate || chunkThing !== lastChunkThing);
+
+      query[chunkThingsKey] =
+        chunks.map(([artists, dates, chunkThings]) => chunkThings[0]);
+
+      query[datesKey] =
+        chunks.map(([artists, dates, chunkThings]) => dates[0]);
+
+      query[artistsKey] =
+        chunks.map(([artists, dates, chunkThings]) => artists);
 
-      sortMultipleArrays(artists, dates,
-        (a, b, dateA, dateB) =>
-          compareDates(dateA, dateB, {latestFirst: true}));
-
-      query[artistsKey] = artists;
-      query[datesKey] = dates.map(dateNumber => new Date(dateNumber));
       query[datelessArtistsKey] = datelessArtists;
     };
 
     queryContributionInfo(
       'artistsByTrackContributions',
+      'albumsByTrackContributions',
       'datesByTrackContributions',
       'datelessArtistsByTrackContributions',
       artist => [
-        ...artist.tracksAsContributor.map(track => +track.date),
-        ...artist.tracksAsArtist.map(track => +track.date),
+        [
+          ...artist.tracksAsArtist.map(track => track.album),
+          ...artist.tracksAsContributor.map(track => track.album),
+        ],
+        [
+          ...artist.tracksAsArtist.map(track => track.date),
+          ...artist.tracksAsContributor.map(track => track.date),
+        ],
       ]);
 
     queryContributionInfo(
       'artistsByArtworkContributions',
+      'albumsByArtworkContributions',
       'datesByArtworkContributions',
       'datelessArtistsByArtworkContributions',
       artist => [
-        // TODO: Per-artwork dates, see #90.
-        ...artist.tracksAsCoverArtist.map(track => +track.coverArtDate),
-        ...artist.albumsAsCoverArtist.map(album => +album.coverArtDate),
-        ...artist.albumsAsWallpaperArtist.map(album => +album.coverArtDate),
-        ...artist.albumsAsBannerArtist.map(album => +album.coverArtDate),
+        [
+          ...artist.tracksAsCoverArtist.map(track => track.album),
+          ...artist.albumsAsCoverArtist,
+          ...artist.albumsAsWallpaperArtist,
+          ...artist.albumsAsBannerArtist,
+        ],
+        [
+          // TODO: Per-artwork dates, see #90.
+          ...artist.tracksAsCoverArtist.map(track => track.coverArtDate),
+          ...artist.albumsAsCoverArtist.map(album => album.coverArtDate),
+          ...artist.albumsAsWallpaperArtist.map(album => album.coverArtDate),
+          ...artist.albumsAsBannerArtist.map(album => album.coverArtDate),
+        ],
       ]);
 
     if (sprawl.enableFlashesAndGames) {
       queryContributionInfo(
         'artistsByFlashContributions',
+        'flashesByFlashContributions',
         'datesByFlashContributions',
         'datelessArtistsByFlashContributions',
         artist => [
-          ...artist.flashesAsContributor.map(flash => +flash.date),
+          [
+            ...artist.flashesAsContributor,
+          ],
+          [
+            ...artist.flashesAsContributor.map(flash => flash.date),
+          ],
         ]);
     }
 
@@ -97,26 +175,47 @@ export default {
     relations.page =
       relation('generateListingPage', query.spec);
 
+    // Track contributors
+
+    relations.albumLinksByTrackContributions =
+      query.albumsByTrackContributions
+        .map(album => relation('linkAlbum', album));
+
     relations.artistLinksByTrackContributions =
       query.artistsByTrackContributions
-        .map(artist => relation('linkArtist', artist));
+        .map(artists =>
+          artists.map(artist => relation('linkArtist', artist)));
 
     relations.datelessArtistLinksByTrackContributions =
       query.datelessArtistsByTrackContributions
         .map(artist => relation('linkArtist', artist));
 
+    // Artwork contributors
+
+    relations.albumLinksByArtworkContributions =
+      query.albumsByArtworkContributions
+        .map(album => relation('linkAlbum', album));
+
     relations.artistLinksByArtworkContributions =
       query.artistsByArtworkContributions
-        .map(artist => relation('linkArtist', artist));
+        .map(artists =>
+          artists.map(artist => relation('linkArtist', artist)));
 
     relations.datelessArtistLinksByArtworkContributions =
       query.datelessArtistsByArtworkContributions
         .map(artist => relation('linkArtist', artist));
 
+    // Flash contributors
+
     if (query.enableFlashesAndGames) {
+      relations.flashLinksByFlashContributions =
+        query.flashesByFlashContributions
+          .map(flash => relation('linkFlash', flash));
+
       relations.artistLinksByFlashContributions =
         query.artistsByFlashContributions
-          .map(artist => relation('linkArtist', artist));
+          .map(artists =>
+            artists.map(artist => relation('linkArtist', artist)));
 
       relations.datelessArtistLinksByFlashContributions =
         query.datelessArtistsByFlashContributions
@@ -142,40 +241,86 @@ export default {
   },
 
   generate(data, relations, {html, language}) {
+    const chunkTitles = Object.fromEntries(
+      ([
+        ['tracks', [
+          'album',
+          relations.albumLinksByTrackContributions,
+          data.datesByTrackContributions,
+        ]],
+
+        ['artworks', [
+          'album',
+          relations.albumLinksByArtworkContributions,
+          data.datesByArtworkContributions,
+        ]],
+
+        data.enableFlashesAndGames &&
+          ['flashes', [
+            'flash',
+            relations.flashLinksByFlashContributions,
+            data.datesByFlashContributions,
+          ]],
+      ]).filter(Boolean)
+        .map(([key, [stringsKey, links, dates]]) => [
+          key,
+          stitchArrays({link: links, date: dates})
+            .map(({link, date}) =>
+              html.tag('dt',
+                language.$(`listingPage.listArtists.byLatest.chunk.title.${stringsKey}`, {
+                  [stringsKey]: link,
+                  date: language.formatDate(date),
+                }))),
+        ]));
+
+    const chunkItems = Object.fromEntries(
+      ([
+        ['tracks', relations.artistLinksByTrackContributions],
+        ['artworks', relations.artistLinksByArtworkContributions],
+        data.enableFlashesAndGames &&
+          ['flashes', relations.artistLinksByFlashContributions],
+      ]).filter(Boolean)
+        .map(([key, artistLinkLists]) => [
+          key,
+          artistLinkLists.map(artistLinks =>
+            html.tag('dd',
+              html.tag('ul',
+                artistLinks.map(artistLink =>
+                  html.tag('li',
+                    language.$('listingPage.listArtists.byLatest.chunk.item', {
+                      artist: artistLink,
+                    })))))),
+        ]));
+
     const lists = Object.fromEntries(
       ([
         ['tracks', [
-          relations.artistLinksByTrackContributions,
+          chunkTitles.tracks,
+          chunkItems.tracks,
           relations.datelessArtistLinksByTrackContributions,
-          data.datesByTrackContributions,
         ]],
 
         ['artworks', [
-          relations.artistLinksByArtworkContributions,
+          chunkTitles.artworks,
+          chunkItems.artworks,
           relations.datelessArtistLinksByArtworkContributions,
-          data.datesByArtworkContributions,
         ]],
 
         data.enableFlashesAndGames &&
           ['flashes', [
-            relations.artistLinksByFlashContributions,
+            chunkTitles.flashes,
+            chunkItems.flashes,
             relations.datelessArtistLinksByFlashContributions,
-            data.datesByFlashContributions,
           ]],
       ]).filter(Boolean)
-        .map(([key, [artistLinks, datelessArtistLinks, dates]]) => [
+        .map(([key, [titles, items, datelessArtistLinks]]) => [
           key,
           html.tags([
-            html.tag('ul',
+            html.tag('dl',
               stitchArrays({
-                artistLink: artistLinks,
-                date: dates,
-              }).map(({artistLink, date}) =>
-                  html.tag('li',
-                    language.$('listingPage.listArtists.byLatest.item', {
-                      artist: artistLink,
-                      date: language.formatDate(date),
-                    })))),
+                title: titles,
+                items: items,
+              }).map(({title, items}) => [title, items])),
 
             !empty(datelessArtistLinks) && [
               html.tag('p',
diff --git a/src/strings-default.json b/src/strings-default.json
index 0938af85..4deeebfa 100644
--- a/src/strings-default.json
+++ b/src/strings-default.json
@@ -375,7 +375,9 @@
   "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})",
   "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution",
   "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution",
-  "listingPage.listArtists.byLatest.item": "{ARTIST} ({DATE})",
+  "listingPage.listArtists.byLatest.chunk.title.album": "{ALBUM} ({DATE})",
+  "listingPage.listArtists.byLatest.chunk.title.flash": "{FLASH} ({DATE})",
+  "listingPage.listArtists.byLatest.chunk.item": "{ARTIST}",
   "listingPage.listArtists.byLatest.dateless.title": "These artists' contributions aren't dated:",
   "listingPage.listArtists.byLatest.dateless.item": "{ARTIST}",
   "listingPage.listGroups.byName.title": "Groups - by Name",
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 11ff7f01..da21d6d0 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -108,6 +108,41 @@ export function stitchArrays(keyToArray) {
   return results;
 }
 
+// Turns this:
+//
+//   [
+//     [123, 'orange', null],
+//     [456, 'apple', true],
+//     [789, 'banana', false],
+//     [1000, 'pear', undefined],
+//   ]
+//
+// Into this:
+//
+//   [
+//     [123, 456, 789, 1000],
+//     ['orange', 'apple', 'banana', 'pear'],
+//     [null, true, false, undefined],
+//   ]
+//
+// And back again, if you call it again on its results.
+export function transposeArrays(arrays) {
+  if (empty(arrays)) {
+    return [];
+  }
+
+  const length = arrays[0].length;
+  const results = new Array(length).fill(null).map(() => []);
+
+  for (const array of arrays) {
+    for (let i = 0; i < length; i++) {
+      results[i].push(array[i]);
+    }
+  }
+
+  return results;
+}
+
 export const mapInPlace = (array, fn) =>
   array.splice(0, array.length, ...array.map(fn));
 
diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js
index 7408593b..a3133748 100644
--- a/src/util/wiki-data.js
+++ b/src/util/wiki-data.js
@@ -72,6 +72,36 @@ export function chunkByProperties(array, properties) {
   }));
 }
 
+export function chunkMultipleArrays(...args) {
+  const arrays = args.slice(0, -1);
+  const fn = args.at(-1);
+
+  const newChunk = index => arrays.map(array => [array[index]]);
+  const results = [newChunk(0)];
+
+  for (let i = 1; i < arrays[0].length; i++) {
+    const current = results.at(-1);
+
+    const args = [];
+    for (let j = 0; j < arrays.length; j++) {
+      const item = arrays[j][i];
+      const previous = current[j].at(-1);
+      args.push(item, previous);
+    }
+
+    if (fn(...args)) {
+      results.push(newChunk(i));
+      continue;
+    }
+
+    for (let j = 0; j < arrays.length; j++) {
+      current[j].push(arrays[j][i]);
+    }
+  }
+
+  return results;
+}
+
 // Sorting functions - all utils here are mutating, so make sure to initially
 // slice/filter/somehow generate a new array from input data if retaining the
 // initial sort matters! (Spoilers: If what you're doing involves any kind of
@@ -120,9 +150,14 @@ export function normalizeName(s) {
 }
 
 // Sorts multiple arrays by an arbitrary function (which is the last argument).
-// Values from each array are paired: (a_fromFirstArray, b_fromFirstArray,
-// a_fromSecondArray, b_fromSecondArray), etc. This definitely only works if
-// all arrays are of the same length.
+// Paired values from each array are provided to the callback sequentially:
+//
+//   (a_fromFirstArray, b_fromFirstArray,
+//    a_fromSecondArray, b_fromSecondArray,
+//    a_fromThirdArray, b_fromThirdArray) =>
+//     relative positioning (negative, positive, or zero)
+//
+// Like native single-array sort, this is a mutating function.
 export function sortMultipleArrays(...args) {
   const arrays = args.slice(0, -1);
   const fn = args.at(-1);
@@ -154,18 +189,25 @@ export function sortMultipleArrays(...args) {
 }
 
 // Filters multiple arrays by an arbitrary function (which is the last argument).
-// Values from each array are sequential: (value_fromFirstArray,
-// value_fromSecondArray, value_fromThirdArray, index, [firstArray, secondArray,
-// thirdArray]), etc. This definitely only works if all arrays are of the same
-// length.
+// Values from each array are provided to the callback sequentially:
+//
+//   (value_fromFirstArray,
+//    value_fromSecondArray,
+//    value_fromThirdArray,
+//    index,
+//    [firstArray, secondArray, thirdArray]) =>
+//      true or false
+//
+// Please be aware that this is a mutating function, unlike native single-array
+// filter. The mutated arrays are returned. Also attached under `.removed` are
+// corresponding arrays of items filtered out.
 export function filterMultipleArrays(...args) {
   const arrays = args.slice(0, -1);
   const fn = args.at(-1);
 
-  const length = arrays[0].length;
-  const removed = new Array(length).fill(null).map(() => []);
+  const removed = new Array(arrays.length).fill(null).map(() => []);
 
-  for (let i = length - 1; i >= 0; i--) {
+  for (let i = arrays[0].length - 1; i >= 0; i--) {
     const args = arrays.map(array => array[i]);
     args.push(i, arrays);
 
@@ -182,6 +224,48 @@ export function filterMultipleArrays(...args) {
   return arrays;
 }
 
+// Reduces multiple arrays with an arbitrary function (which is the last
+// argument). Note that this reduces into multiple accumulators, one for
+// each input array, not just a single value. That's reflected in both the
+// callback parameters:
+//
+//   (accumulator1,
+//    accumulator2,
+//    value_fromFirstArray,
+//    value_fromSecondArray,
+//    index,
+//    [firstArray, secondArray]) =>
+//      [newAccumulator1, newAccumulator2]
+//
+// As well as the final return value of reduceMultipleArrays:
+//
+//   [finalAccumulator1, finalAccumulator2]
+//
+// This is not a mutating function.
+export function reduceMultipleArrays(...args) {
+  const [arrays, fn, initialAccumulators] =
+    (typeof args.at(-1) === 'function'
+      ? [args.slice(0, -1), args.at(-1), null]
+      : [args.slice(0, -2), args.at(-2), args.at(-1)]);
+
+  if (empty(arrays[0])) {
+    throw new TypeError(`Reduce of empty arrays with no initial value`);
+  }
+
+  let [accumulators, i] =
+    (initialAccumulators
+      ? [initialAccumulators, 0]
+      : [arrays.map(array => array[0]), 1]);
+
+  for (; i < arrays[0].length; i++) {
+    const args = [...accumulators, ...arrays.map(array => array[i])];
+    args.push(i, arrays);
+    accumulators = fn(...args);
+  }
+
+  return accumulators;
+}
+
 // Component sort functions - these sort by one particular property, applying
 // unique particulars where appropriate. Usually you don't want to use these
 // directly, but if you're making a custom sort they can come in handy.