From 36c25f298c5dfb9478157e61aacff53227a0ed1e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 5 Jul 2023 21:53:05 -0300 Subject: content: listArtistsbyLatestContribution: stuck in the array mines --- .../listArtistsByLatestContribution.js | 241 +++++++++++++++++---- src/strings-default.json | 4 +- src/util/sugar.js | 35 +++ src/util/wiki-data.js | 104 ++++++++- 4 files changed, 325 insertions(+), 59 deletions(-) (limited to 'src') 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. -- cgit 1.3.0-6-gf8a5