diff options
37 files changed, 3505 insertions, 1021 deletions
diff --git a/src/content/dependencies/generateArtTagGalleryPage.js b/src/content/dependencies/generateArtTagGalleryPage.js index c04bfb68..e28b54cb 100644 --- a/src/content/dependencies/generateArtTagGalleryPage.js +++ b/src/content/dependencies/generateArtTagGalleryPage.js @@ -23,7 +23,7 @@ export default { const things = tag.taggedInThings.slice(); sortAlbumsTracksChronologically(things, { - getDate: thing => thing.coverArtDate, + getDate: thing => thing.coverArtDate ?? thing.date, latestFirst: true, }); diff --git a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js index aa6efe5e..a3bcf687 100644 --- a/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js +++ b/src/content/dependencies/generateArtistInfoPageArtworksChunkedList.js @@ -30,7 +30,7 @@ export default { entry: { type: 'albumCover', album: album, - date: album.coverArtDate, + date: album.coverArtDate ?? album.date, contribs: album.coverArtistContribs, }, })), @@ -40,7 +40,7 @@ export default { entry: { type: 'albumWallpaper', album: album, - date: album.coverArtDate, + date: album.coverArtDate ?? album.date, contribs: album.wallpaperArtistContribs, }, })), @@ -50,7 +50,7 @@ export default { entry: { type: 'albumBanner', album: album, - date: album.coverArtDate, + date: album.coverArtDate ?? album.date, contribs: album.bannerArtistContribs, }, })), @@ -60,7 +60,7 @@ export default { entry: { type: 'trackCover', album: track.album, - date: track.coverArtDate, + date: track.coverArtDate ?? track.date, track: track, contribs: track.coverArtistContribs, }, @@ -69,7 +69,7 @@ export default { sortEntryThingPairs(entries, things => sortAlbumsTracksChronologically(things, { - getDate: thing => thing.coverArtDate, + getDate: thing => thing.coverArtDate ?? thing.date, })); const chunks = diff --git a/src/content/dependencies/generateListingPage.js b/src/content/dependencies/generateListingPage.js index f527f16f..45b7dc1b 100644 --- a/src/content/dependencies/generateListingPage.js +++ b/src/content/dependencies/generateListingPage.js @@ -156,8 +156,10 @@ export default { .slots({ hash: id, content: - formatListingString('chunk.title', title) - .replace(/:$/, ''), + html.normalize( + formatListingString('chunk.title', title) + .toString() + .replace(/:$/, '')), }))))), ], diff --git a/src/content/dependencies/generatePageLayout.js b/src/content/dependencies/generatePageLayout.js index cd831ba7..72dfbae5 100644 --- a/src/content/dependencies/generatePageLayout.js +++ b/src/content/dependencies/generatePageLayout.js @@ -449,7 +449,8 @@ export default { {[html.onlyIfContent]: true, class: 'skipper-list'}, processSkippers([ {id: 'tracks', string: 'tracks'}, - {id: 'art', string: 'flashes'}, + {id: 'art', string: 'artworks'}, + {id: 'flashes', string: 'flashes'}, {id: 'contributors', string: 'contributors'}, {id: 'references', string: 'references'}, {id: 'referenced-by', string: 'referencedBy'}, diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 1083d863..93334948 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -82,7 +82,7 @@ export default { ...artist.albumsAsCoverArtist, ...artist.tracksAsCoverArtist, ], { - getDate: albumOrTrack => albumOrTrack.coverArtDate, + getDate: thing => thing.coverArtDate ?? thing.date, }), }), diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js index 6c0aeecd..8aa9753b 100644 --- a/src/content/dependencies/image.js +++ b/src/content/dependencies/image.js @@ -77,6 +77,11 @@ export default { originalSrc = ''; } + // TODO: This feels janky. It's necessary to deal with static content that + // includes strings like <img src="media/misc/foo.png">, but processing the + // src string directly when a parts-formed path *is* available seems wrong. + // It should be possible to do urls.from(slots.path[0]).to(...slots.path), + // for example, but will require reworking the control flow here a little. let mediaSrc = null; if (originalSrc.startsWith(to('media.root'))) { mediaSrc = @@ -160,7 +165,7 @@ export default { // which is the HTML output-appropriate path including `../../` or // another alternate base path. const selectedSize = getThumbnailEqualOrSmaller(slots.thumb, mediaSrc); - thumbSrc = originalSrc.replace(/\.(jpg|png)$/, `.${selectedSize}.jpg`); + thumbSrc = to('thumb.path', mediaSrc.replace(/\.(png|jpg)$/, `.${selectedSize}.jpg`)); const dimensions = getDimensionsOfImagePath(mediaSrc); availableThumbs = getThumbnailsAvailableForDimensions(dimensions); diff --git a/src/content/dependencies/linkExternal.js b/src/content/dependencies/linkExternal.js index 73c656e3..5de612e2 100644 --- a/src/content/dependencies/linkExternal.js +++ b/src/content/dependencies/linkExternal.js @@ -3,10 +3,20 @@ const BANDCAMP_DOMAINS = ['bc.s3m.us', 'music.solatrux.com']; const MASTODON_DOMAINS = ['types.pl']; export default { - extraDependencies: ['html', 'language'], + extraDependencies: ['html', 'language', 'wikiData'], - data(url) { - return {url}; + sprawl: ({wikiInfo}) => ({wikiInfo}), + + data(sprawl, url) { + const data = {url}; + + const {canonicalBase} = sprawl.wikiInfo; + if (canonicalBase) { + const {hostname: canonicalDomain} = new URL(canonicalBase); + Object.assign(data, {canonicalDomain}); + } + + return data; }, slots: { @@ -20,6 +30,7 @@ export default { let isLocal; let domain; let pathname; + try { const url = new URL(data.url); domain = url.hostname; @@ -28,6 +39,14 @@ export default { // No support for relative local URLs yet, sorry! (I.e, local URLs must // be absolute relative to the domain name in order to work.) isLocal = true; + domain = null; + pathname = null; + } + + // isLocal also applies for URLs which match the 'Canonical Base' under + // wiki-info.yaml, if present. + if (data.canonicalDomain && domain === data.canonicalDomain) { + isLocal = true; } const link = html.tag('a', diff --git a/src/content/dependencies/listArtistsByLatestContribution.js b/src/content/dependencies/listArtistsByLatestContribution.js index edb02e0d..45f8390f 100644 --- a/src/content/dependencies/listArtistsByLatestContribution.js +++ b/src/content/dependencies/listArtistsByLatestContribution.js @@ -97,14 +97,14 @@ export default { ...getArtists(album, 'bannerArtistContribs'), ])) { // Might combine later with 'track' of the same album and date. - considerDate(artist, album.coverArtDate, album, 'artwork'); + considerDate(artist, album.coverArtDate ?? album.date, album, 'artwork'); } } for (const track of tracksLatestFirst) { for (const artist of getArtists(track, 'coverArtistContribs')) { // No special effect if artist already has 'artwork' for the same album and date. - considerDate(artist, track.coverArtDate, track.album, 'artwork'); + considerDate(artist, track.coverArtDate ?? track.date, track.album, 'artwork'); } for (const artist of new Set([ @@ -199,10 +199,6 @@ export default { } }); - // Last off, turn the flat sorted list into a proper chunked list, now that - // entries going in the same chunk are sorted correctly next to each other. - // Then extract the parts that are useful for displaying on the listing! - const chunks = chunkMultipleArrays(artistThings, artistDates, artistContributions, artists, (thing, lastThing, date, lastDate) => diff --git a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js index d27f7b23..fac8e213 100644 --- a/src/data/composite/things/track/withAlwaysReferenceByDirectory.js +++ b/src/data/composite/things/track/withAlwaysReferenceByDirectory.js @@ -2,22 +2,15 @@ // just to the track's name, which means you don't have to always reference // some *other* (much more commonly referenced) track by directory instead // of more naturally by name. -// -// See the implementation for an important caveat about matching the original -// track against other tracks, which uses a custom implementation pulling (and -// duplicating) details from #find instead of using withOriginalRelease and the -// usual withResolvedReference / find.track() utilities. -// import {input, templateCompositeFrom} from '#composite'; +import find from '#find'; import {isBoolean} from '#validators'; import {exitWithoutDependency, exposeUpdateValueOrContinue} from '#composite/control-flow'; import {withPropertyFromObject} from '#composite/data'; - -// TODO: Kludge. (The usage of this, not so much the import.) -import CacheableObject from '../../../things/cacheable-object.js'; +import {withResolvedReference} from '#composite/wiki-data'; export default templateCompositeFrom({ annotation: `withAlwaysReferenceByDirectory`, @@ -44,29 +37,23 @@ export default templateCompositeFrom({ value: input.value(false), }), - // "Slow" / uncached, manual search from trackData (with this track - // excluded). Otherwise there end up being pretty bad recursion issues - // (track1.alwaysReferencedByDirectory depends on searching through data - // including track2, which depends on evaluating track2.alwaysReferenced- - // ByDirectory, which depends on searcing through data including track1...) - // That said, this is 100% a kludge, since it involves duplicating find - // logic on a completely unrelated context. - { - dependencies: [input.myself(), 'trackData', 'originalReleaseTrack'], - compute: (continuation, { - [input.myself()]: thisTrack, - ['trackData']: trackData, - ['originalReleaseTrack']: ref, - }) => continuation({ - ['#originalRelease']: - (ref.startsWith('track:') - ? trackData.find(track => track.directory === ref.slice('track:'.length)) - : trackData.find(track => - track !== thisTrack && - !CacheableObject.getUpdateValue(track, 'originalReleaseTrack') && - track.name.toLowerCase() === ref.toLowerCase())), - }) - }, + // It's necessary to use the custom trackOriginalReleasesOnly find function + // here, so as to avoid recursion issues - the find.track() function depends + // on accessing each track's alwaysReferenceByDirectory, which means it'll + // hit *this track* - and thus this step - and end up recursing infinitely. + // By definition, find.trackOriginalReleasesOnly excludes tracks which have + // an originalReleaseTrack update value set, which means even though it does + // still access each of tracks' `alwaysReferenceByDirectory` property, it + // won't access that of *this* track - it will never proceed past the + // `exitWithoutDependency` step directly above, so there's no opportunity + // for recursion. + withResolvedReference({ + ref: 'originalReleaseTrack', + data: 'trackData', + find: input.value(find.trackOriginalReleasesOnly), + }).outputs({ + '#resolvedReference': '#originalRelease', + }), exitWithoutDependency({ dependency: '#originalRelease', diff --git a/src/data/composite/wiki-properties/wikiData.js b/src/data/composite/wiki-properties/wikiData.js index 4ea47785..5cea49a0 100644 --- a/src/data/composite/wiki-properties/wikiData.js +++ b/src/data/composite/wiki-properties/wikiData.js @@ -1,17 +1,29 @@ // General purpose wiki data constructor, for properties like artistData, // trackData, etc. -import {validateArrayItems, validateInstanceOf} from '#validators'; +import {input, templateCompositeFrom} from '#composite'; +import {validateWikiData} from '#validators'; -// TODO: Not templateCompositeFrom. +import {inputThingClass} from '#composite/wiki-data'; -// TODO: This should validate with validateWikiData. +// TODO: Kludge. +import Thing from '../../things/thing.js'; -export default function(thingClass) { - return { - flags: {update: true}, - update: { - validate: validateArrayItems(validateInstanceOf(thingClass)), - }, - }; -} +export default templateCompositeFrom({ + annotation: `wikiData`, + + compose: false, + + inputs: { + class: inputThingClass(), + }, + + update: ({ + [input.staticValue('class')]: thingClass, + }) => { + const referenceType = thingClass[Thing.referenceType]; + return {validate: validateWikiData({referenceType})}; + }, + + steps: () => [], +}); diff --git a/src/data/language.js b/src/data/language.js index 09466907..6ffc31e0 100644 --- a/src/data/language.js +++ b/src/data/language.js @@ -1,39 +1,190 @@ +import EventEmitter from 'node:events'; import {readFile} from 'node:fs/promises'; +import path from 'node:path'; -// It stands for "HTML Entities", apparently. Cursed. -import he from 'he'; +import chokidar from 'chokidar'; +import he from 'he'; // It stands for "HTML Entities", apparently. Cursed. +import yaml from 'js-yaml'; import T from '#things'; +import {colors, logWarn} from '#cli'; -export async function processLanguageFile(file) { - const contents = await readFile(file, 'utf-8'); - const json = JSON.parse(contents); +import { + annotateError, + annotateErrorWithFile, + showAggregate, + withAggregate, +} from '#sugar'; + +const {Language} = T; + +export function processLanguageSpec(spec, {existingCode = null} = {}) { + const { + 'meta.languageCode': code, + 'meta.languageName': name, + + 'meta.languageIntlCode': intlCode = null, + 'meta.hidden': hidden = false, + + ...strings + } = spec; + + withAggregate({message: `Errors validating language spec`}, ({push}) => { + if (!code) { + push(new Error(`Missing language code`)); + } + + if (!name) { + push(new Error(`Missing language name`)); + } + + if (code && existingCode && code !== existingCode) { + push(new Error(`Language code (${code}) doesn't match previous value\n(You'll have to reload hsmusic to load this)`)); + } + }); + + return {code, intlCode, name, hidden, strings}; +} + +function flattenLanguageSpec(spec) { + const recursive = (keyPath, value) => + (typeof value === 'object' + ? Object.assign({}, ... + Object.entries(value) + .map(([key, value]) => + (key === '_' + ? {[keyPath]: value} + : recursive( + (keyPath ? `${keyPath}.${key}` : key), + value)))) + : {[keyPath]: value}); - const code = json['meta.languageCode']; - if (!code) { - throw new Error(`Missing language code (file: ${file})`); + return recursive('', spec); +} + +async function processLanguageSpecFromFile(file, processLanguageSpecOpts) { + let contents; + + try { + contents = await readFile(file, 'utf-8'); + } catch (caughtError) { + throw annotateError( + new Error(`Failed to read language file`, {cause: caughtError}), + error => annotateErrorWithFile(error, file)); } - delete json['meta.languageCode']; - const intlCode = json['meta.languageIntlCode'] ?? null; - delete json['meta.languageIntlCode']; + let rawSpec; + let parseLanguage; - const name = json['meta.languageName']; - if (!name) { - throw new Error(`Missing language name (${code})`); + try { + if (path.extname(file) === '.yaml') { + parseLanguage = 'YAML'; + rawSpec = yaml.load(contents); + } else { + parseLanguage = 'JSON'; + rawSpec = JSON.parse(contents); + } + } catch (caughtError) { + throw annotateError( + new Error(`Failed to parse language file as valid ${parseLanguage}`, {cause: caughtError}), + error => annotateErrorWithFile(error, file)); } - delete json['meta.languageName']; - const hidden = json['meta.hidden'] ?? false; - delete json['meta.hidden']; + const flattenedSpec = flattenLanguageSpec(rawSpec); + + try { + return processLanguageSpec(flattenedSpec, processLanguageSpecOpts); + } catch (caughtError) { + throw annotateErrorWithFile(caughtError, file); + } +} - const language = new T.Language(); - language.code = code; - language.intlCode = intlCode; - language.name = name; - language.hidden = hidden; - language.escapeHTML = (string) => +export function initializeLanguageObject() { + const language = new Language(); + + language.escapeHTML = string => he.encode(string, {useNamedReferences: true}); - language.strings = json; + return language; } + +export async function processLanguageFile(file) { + const language = initializeLanguageObject(); + const properties = await processLanguageSpecFromFile(file); + return Object.assign(language, properties); +} + +export function watchLanguageFile(file, { + logging = true, +} = {}) { + const basename = path.basename(file); + + const events = new EventEmitter(); + const language = initializeLanguageObject(); + + let emittedReady = false; + let successfullyAppliedLanguage = false; + + Object.assign(events, {language, close}); + + const watcher = chokidar.watch(file); + watcher.on('change', () => handleFileUpdated()); + + setImmediate(handleFileUpdated); + + return events; + + async function close() { + return watcher.close(); + } + + function checkReadyConditions() { + if (emittedReady) return; + if (!successfullyAppliedLanguage) return; + + events.emit('ready'); + emittedReady = true; + } + + async function handleFileUpdated() { + let properties; + + try { + properties = await processLanguageSpecFromFile(file, { + existingCode: + (successfullyAppliedLanguage + ? language.code + : null), + }); + } catch (error) { + events.emit('error', error); + + if (logging) { + const label = + (successfullyAppliedLanguage + ? `${language.name} (${language.code})` + : basename); + + if (successfullyAppliedLanguage) { + logWarn`Failed to load language ${label} - using existing version`; + } else { + logWarn`Failed to load language ${label} - no prior version loaded`; + } + showAggregate(error, {showTraces: false}); + } + + return; + } + + Object.assign(language, properties); + successfullyAppliedLanguage = true; + + if (logging && emittedReady) { + const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'}); + console.log(colors.green(`[${timestamp}] Updated language ${language.name} (${language.code})`)); + } + + events.emit('update'); + checkReadyConditions(); + } +} diff --git a/src/data/things/album.js b/src/data/things/album.js index 546fda3b..af3eb042 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -121,10 +121,21 @@ export class Album extends Thing { // Update only - artistData: wikiData(Artist), - artTagData: wikiData(ArtTag), - groupData: wikiData(Group), - trackData: wikiData(Track), + artistData: wikiData({ + class: input.value(Artist), + }), + + artTagData: wikiData({ + class: input.value(ArtTag), + }), + + groupData: wikiData({ + class: input.value(Group), + }), + + trackData: wikiData({ + class: input.value(Track), + }), // Expose only diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index 6503beec..f9e5f0f3 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -40,8 +40,13 @@ export class ArtTag extends Thing { // Update only - albumData: wikiData(Album), - trackData: wikiData(Track), + albumData: wikiData({ + class: input.value(Album), + }), + + trackData: wikiData({ + class: input.value(Track), + }), // Expose only @@ -54,7 +59,7 @@ export class ArtTag extends Thing { sortAlbumsTracksChronologically( [...albumData, ...trackData] .filter(({artTags}) => artTags.includes(artTag)), - {getDate: o => o.coverArtDate}), + {getDate: thing => thing.coverArtDate ?? thing.date}), }, }, }); diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 1b313db6..a51723c4 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -45,10 +45,21 @@ export class Artist extends Thing { // Update only - albumData: wikiData(Album), - artistData: wikiData(Artist), - flashData: wikiData(Flash), - trackData: wikiData(Track), + albumData: wikiData({ + class: input.value(Album), + }), + + artistData: wikiData({ + class: input.value(Artist), + }), + + flashData: wikiData({ + class: input.value(Flash), + }), + + trackData: wikiData({ + class: input.value(Track), + }), // Expose only diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 51525bc1..113f0a4f 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -637,6 +637,10 @@ export function compositeFrom(description) { const compositionNests = description.compose ?? true; + if (compositionNests && empty(steps)) { + aggregate.push(new TypeError(`Expected at least one step`)); + } + // Steps default to exposing if using a shorthand syntax where flags aren't // specified at all. const stepsExpose = @@ -802,8 +806,8 @@ export function compositeFrom(description) { }); } - if (!compositionNests && !anyStepsCompute && !anyStepsTransform) { - aggregate.push(new TypeError(`Expected at least one step to compute or transform`)); + if (!compositionNests && !compositionUpdates && !anyStepsCompute) { + aggregate.push(new TypeError(`Expected at least one step to compute`)); } aggregate.close(); @@ -1241,8 +1245,10 @@ export function compositeFrom(description) { expose.cache = base.cacheComposition; } } else if (compositionUpdates) { - expose.transform = (value, dependencies) => - _wrapper(value, null, dependencies); + if (!empty(steps)) { + expose.transform = (value, dependencies) => + _wrapper(value, null, dependencies); + } } else { expose.compute = (dependencies) => _wrapper(noTransformSymbol, null, dependencies); diff --git a/src/data/things/flash.js b/src/data/things/flash.js index e2afcef4..1bdda6c8 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -95,9 +95,17 @@ export class Flash extends Thing { // Update only - artistData: wikiData(Artist), - trackData: wikiData(Track), - flashActData: wikiData(FlashAct), + artistData: wikiData({ + class: input.value(Artist), + }), + + trackData: wikiData({ + class: input.value(Track), + }), + + flashActData: wikiData({ + class: input.value(FlashAct), + }), // Expose only @@ -159,6 +167,8 @@ export class FlashAct extends Thing { // Update only - flashData: wikiData(Flash), + flashData: wikiData({ + class: input.value(Flash), + }), }) } diff --git a/src/data/things/group.js b/src/data/things/group.js index 8764a9db..75469bbd 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -34,8 +34,13 @@ export class Group extends Thing { // Update only - albumData: wikiData(Album), - groupCategoryData: wikiData(GroupCategory), + albumData: wikiData({ + class: input.value(Album), + }), + + groupCategoryData: wikiData({ + class: input.value(GroupCategory), + }), // Expose only @@ -83,12 +88,15 @@ export class Group extends Thing { } export class GroupCategory extends Thing { + static [Thing.referenceType] = 'group-category'; static [Thing.friendlyName] = `Group Category`; static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose name: name('Unnamed Group Category'), + directory: directory(), + color: color(), groups: referenceList({ @@ -99,6 +107,8 @@ export class GroupCategory extends Thing { // Update only - groupData: wikiData(Group), + groupData: wikiData({ + class: input.value(Group), + }), }); } diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index bfa971ca..59c069bd 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -70,11 +70,17 @@ export class HomepageLayoutRow extends Thing { // Update only - // These aren't necessarily used by every HomepageLayoutRow subclass, but - // for convenience of providing this data, every row accepts all wiki data - // arrays depended upon by any subclass's behavior. - albumData: wikiData(Album), - groupData: wikiData(Group), + // These wiki data arrays aren't necessarily used by every subclass, but + // to the convenience of providing these, the superclass accepts all wiki + // data arrays depended upon by any subclass. + + albumData: wikiData({ + class: input.value(Album), + }), + + groupData: wikiData({ + class: input.value(Group), + }), }); } diff --git a/src/data/things/track.js b/src/data/things/track.js index db325a17..8d310611 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -256,11 +256,25 @@ export class Track extends Thing { // Update only - albumData: wikiData(Album), - artistData: wikiData(Artist), - artTagData: wikiData(ArtTag), - flashData: wikiData(Flash), - trackData: wikiData(Track), + albumData: wikiData({ + class: input.value(Album), + }), + + artistData: wikiData({ + class: input.value(Artist), + }), + + artTagData: wikiData({ + class: input.value(ArtTag), + }), + + flashData: wikiData({ + class: input.value(Flash), + }), + + trackData: wikiData({ + class: input.value(Track), + }), // Expose only diff --git a/src/data/things/validators.js b/src/data/things/validators.js index ee301f15..f60c363c 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -433,18 +433,38 @@ export function validateWikiData({ OK = true; return true; } - const allRefTypes = - new Set(array.map(object => - object.constructor[Symbol.for('Thing.referenceType')])); + const allRefTypes = new Set(); - if (allRefTypes.has(undefined)) { - if (allRefTypes.size === 1) { - throw new TypeError(`Expected array of wiki data objects, got array of other objects`); + let foundThing = false; + let foundOtherObject = false; + + for (const object of array) { + const {[Symbol.for('Thing.referenceType')]: referenceType} = object.constructor; + + if (referenceType === undefined) { + foundOtherObject = true; + + // Early-exit if a Thing has been found - nothing more can be learned. + if (foundThing) { + throw new TypeError(`Expected array of wiki data objects, got mixed items`); + } } else { - throw new TypeError(`Expected array of wiki data objects, got mixed items`); + foundThing = true; + + // Early-exit if a non-Thing object has been found - nothing more can + // be learned. + if (foundOtherObject) { + throw new TypeError(`Expected array of wiki data objects, got mixed items`); + } + + allRefTypes.add(referenceType); } } + if (foundOtherObject && !foundThing) { + throw new TypeError(`Expected array of wiki data objects, got array of other objects`); + } + if (allRefTypes.size > 1) { if (allowMixedTypes) { OK = true; return true; @@ -464,8 +484,10 @@ export function validateWikiData({ throw new TypeError(`Expected array of unmixed reference types, got multiple: ${types()}`); } - if (referenceType && !allRefTypes.has(referenceType)) { - throw new TypeError(`Expected array of ${referenceType}, got array of ${allRefTypes[0]}`) + const onlyRefType = Array.from(allRefTypes)[0]; + + if (referenceType && onlyRefType !== referenceType) { + throw new TypeError(`Expected array of ${referenceType}, got array of ${onlyRefType}`) } OK = true; return true; diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 6286a267..89053d62 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -64,6 +64,8 @@ export class WikiInfo extends Thing { // Update only - groupData: wikiData(Group), + groupData: wikiData({ + class: input.value(Group), + }), }); } diff --git a/src/data/yaml.js b/src/data/yaml.js index f7856cb7..1d35bae8 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -18,11 +18,12 @@ import T, { } from '#things'; import { + annotateErrorWithFile, conditionallySuppressError, decorateErrorWithIndex, + decorateErrorWithAnnotation, empty, filterProperties, - mapAggregate, openAggregate, showAggregate, withAggregate, @@ -1120,22 +1121,25 @@ export async function loadAndProcessDataDocuments({dataPath}) { const wikiDataResult = {}; function decorateErrorWithFile(fn) { - return (x, index, array) => { - try { - return fn(x, index, array); - } catch (error) { - error.message += - (error.message.includes('\n') ? '\n' : ' ') + - `(file: ${colors.bright(colors.blue(path.relative(dataPath, x.file)))})`; - throw error; - } - }; + return decorateErrorWithAnnotation(fn, + (caughtError, firstArg) => + annotateErrorWithFile( + caughtError, + path.relative( + dataPath, + (typeof firstArg === 'object' + ? firstArg.file + : firstArg)))); + } + + function asyncDecorateErrorWithFile(fn) { + return decorateErrorWithFile(fn).async; } for (const dataStep of dataSteps) { await processDataAggregate.nestAsync( {message: `Errors during data step: ${colors.bright(dataStep.title)}`}, - async ({call, callAsync, map, mapAsync, push, nest}) => { + async ({call, callAsync, map, mapAsync, push}) => { const {documentMode} = dataStep; if (!Object.values(documentModes).includes(documentMode)) { @@ -1323,8 +1327,8 @@ export async function loadAndProcessDataDocuments({dataPath}) { throw new Error(`Expected 'files' property for ${documentMode.toString()}`); } - let files = ( - typeof dataStep.files === 'function' + const filesFromDataStep = + (typeof dataStep.files === 'function' ? await callAsync(() => dataStep.files(dataPath).then( files => files, @@ -1335,101 +1339,110 @@ export async function loadAndProcessDataDocuments({dataPath}) { throw error; } })) - : dataStep.files - ); + : dataStep.files); - if (!files) { - return; - } + const filesUnderDataPath = + filesFromDataStep + .map(file => path.join(dataPath, file)); - files = files.map((file) => path.join(dataPath, file)); - - const readResults = await mapAsync( - files, - (file) => readFile(file, 'utf-8').then((contents) => ({file, contents})), - {message: `Errors reading data files`}); - - let yamlResults = map( - readResults, - decorateErrorWithFile(({file, contents}) => ({ - file, - documents: yaml.loadAll(contents), - })), - {message: `Errors parsing data files as valid YAML`}); - - yamlResults = yamlResults.map(({file, documents}) => { - const {documents: filteredDocuments, aggregate} = filterBlankDocuments(documents); - call(decorateErrorWithFile(aggregate.close), {file}); - return {file, documents: filteredDocuments}; - }); + const yamlResults = []; + + await mapAsync(filesUnderDataPath, {message: `Errors loading data files`}, + asyncDecorateErrorWithFile(async file => { + let contents; + try { + contents = await readFile(file, 'utf-8'); + } catch (caughtError) { + throw new Error(`Failed to read data file`, {cause: caughtError}); + } + + let documents; + try { + documents = yaml.loadAll(contents); + } catch (caughtError) { + throw new Error(`Failed to parse valid YAML`, {cause: caughtError}); + } + + const {documents: filteredDocuments, aggregate: filterAggregate} = + filterBlankDocuments(documents); + + try { + filterAggregate.close(); + } catch (caughtError) { + // Blank documents aren't a critical error, they're just something + // that should be noted - the (filtered) documents still get pushed. + const pathToFile = path.relative(dataPath, file); + annotateErrorWithFile(caughtError, pathToFile); + push(caughtError); + } + + yamlResults.push({file, documents: filteredDocuments}); + })); const processResults = []; switch (documentMode) { case documentModes.headerAndEntries: - map(yamlResults, decorateErrorWithFile(({documents}) => { - const headerDocument = documents[0]; - const entryDocuments = documents.slice(1).filter(Boolean); - - if (!headerDocument) - throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`); - - // This'll be decorated with the file, and groups together any - // errors from processing the header and entry documents. - const fileAggregate = - openAggregate({message: `Errors processing documents`}); - - const {thing: headerObject, aggregate: headerAggregate} = - dataStep.processHeaderDocument(headerDocument); - - try { - headerAggregate.close() - } catch (caughtError) { - caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`; - fileAggregate.push(caughtError); - } + map(yamlResults, {message: `Errors processing documents in data files`}, + decorateErrorWithFile(({documents}) => { + const headerDocument = documents[0]; + const entryDocuments = documents.slice(1).filter(Boolean); + + if (!headerDocument) + throw new Error(`Missing header document (empty file or erroneously starting with "---"?)`); + + withAggregate({message: `Errors processing documents`}, ({push}) => { + const {thing: headerObject, aggregate: headerAggregate} = + dataStep.processHeaderDocument(headerDocument); - const entryObjects = []; + try { + headerAggregate.close(); + } catch (caughtError) { + caughtError.message = `(${colors.yellow(`header`)}) ${caughtError.message}`; + push(caughtError); + } - for (let index = 0; index < entryDocuments.length; index++) { - const entryDocument = entryDocuments[index]; + const entryObjects = []; - const {thing: entryObject, aggregate: entryAggregate} = - dataStep.processEntryDocument(entryDocument); + for (let index = 0; index < entryDocuments.length; index++) { + const entryDocument = entryDocuments[index]; - entryObjects.push(entryObject); + const {thing: entryObject, aggregate: entryAggregate} = + dataStep.processEntryDocument(entryDocument); - try { - entryAggregate.close(); - } catch (caughtError) { - caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`; - fileAggregate.push(caughtError); - } - } + entryObjects.push(entryObject); - processResults.push({ - header: headerObject, - entries: entryObjects, - }); + try { + entryAggregate.close(); + } catch (caughtError) { + caughtError.message = `(${colors.yellow(`entry #${index + 1}`)}) ${caughtError.message}`; + push(caughtError); + } + } - fileAggregate.close(); - }), {message: `Errors processing documents in data files`}); + processResults.push({ + header: headerObject, + entries: entryObjects, + }); + }); + })); break; case documentModes.onePerFile: - map(yamlResults, decorateErrorWithFile(({documents}) => { - if (documents.length > 1) - throw new Error(`Only expected one document to be present per file, got ${documents.length} here`); + map(yamlResults, {message: `Errors processing data files as valid documents`}, + decorateErrorWithFile(({documents}) => { + if (documents.length > 1) + throw new Error(`Only expected one document to be present per file, got ${documents.length} here`); - if (empty(documents) || !documents[0]) - throw new Error(`Expected a document, this file is empty`); + if (empty(documents) || !documents[0]) + throw new Error(`Expected a document, this file is empty`); - const {thing, aggregate} = - dataStep.processDocument(documents[0]); + const {thing, aggregate} = + dataStep.processDocument(documents[0]); - processResults.push(thing); - aggregate.close(); - }), {message: `Errors processing data files as valid documents`}); + processResults.push(thing); + aggregate.close(); + })); break; } @@ -1662,7 +1675,7 @@ export function filterReferenceErrors(wikiData) { } } - nest({message: `Reference errors in ${inspect(thing)}`}, ({push, filter}) => { + nest({message: `Reference errors in ${inspect(thing)}`}, ({nest, push, filter}) => { for (const [property, findFnKey] of Object.entries(propSpec)) { const value = CacheableObject.getUpdateValue(thing, property); diff --git a/src/find.js b/src/find.js index 8c9413b7..dfcaa9aa 100644 --- a/src/find.js +++ b/src/find.js @@ -2,6 +2,7 @@ import {inspect} from 'node:util'; import {colors, logWarn} from '#cli'; import {typeAppearance} from '#sugar'; +import {CacheableObject} from '#things'; function warnOrThrow(mode, message) { if (mode === 'error') { @@ -16,6 +17,8 @@ function warnOrThrow(mode, message) { } export function processAllAvailableMatches(data, { + include = thing => true, + getMatchableNames = thing => (Object.hasOwn(thing, 'name') ? [thing.name] @@ -26,6 +29,10 @@ export function processAllAvailableMatches(data, { const multipleNameMatches = Object.create(null); for (const thing of data) { + if (!include(thing)) continue; + + byDirectory[thing.directory] = thing; + for (const name of getMatchableNames(thing)) { if (typeof name !== 'string') { logWarn`Unexpected ${typeAppearance(name)} returned in names for ${inspect(thing)}`; @@ -33,6 +40,7 @@ export function processAllAvailableMatches(data, { } const normalizedName = name.toLowerCase(); + if (normalizedName in byName) { const alreadyMatchesByName = byName[normalizedName]; byName[normalizedName] = null; @@ -45,8 +53,6 @@ export function processAllAvailableMatches(data, { byName[normalizedName] = thing; } } - - byDirectory[thing.directory] = thing; } return {byName, byDirectory, multipleNameMatches}; @@ -55,6 +61,7 @@ export function processAllAvailableMatches(data, { function findHelper({ referenceTypes, + include = undefined, getMatchableNames = undefined, }) { const keyRefRegex = @@ -84,6 +91,7 @@ function findHelper({ if (!subcache) { subcache = processAllAvailableMatches(data, { + include, getMatchableNames, }); @@ -178,6 +186,22 @@ const find = { ? [] : [track.name]), }), + + trackOriginalReleasesOnly: findHelper({ + referenceTypes: ['track'], + + include: track => + !CacheableObject.getUpdateValue(track, 'originalReleaseTrack'), + + // It's still necessary to check alwaysReferenceByDirectory here, since it + // may be set manually (with the `Always Reference By Directory` field), and + // these shouldn't be matched by name (as per usual). See the definition for + // that property for more information. + getMatchableNames: track => + (track.alwaysReferenceByDirectory + ? [] + : [track.name]), + }), }; export default find; @@ -200,6 +224,7 @@ export function bindFind(wikiData, opts1) { newsEntry: 'newsData', staticPage: 'staticPageData', track: 'trackData', + trackOriginalReleasesOnly: 'trackData', }).map(([key, value]) => { const findFn = find[key]; const thingData = wikiData[value]; diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 3d441bc9..1bbcb9c1 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -88,13 +88,22 @@ const thumbnailSpec = { import {spawn} from 'node:child_process'; import {createHash} from 'node:crypto'; import {createReadStream} from 'node:fs'; -import {readFile, stat, unlink, writeFile} from 'node:fs/promises'; import * as path from 'node:path'; +import { + mkdir, + readdir, + readFile, + rename, + stat, + writeFile, +} from 'node:fs/promises'; + import dimensionsOf from 'image-size'; -import {delay, empty, queue} from '#sugar'; +import {delay, empty, queue, unique} from '#sugar'; import {CacheableObject} from '#things'; +import {sortByName} from '#wiki-data'; import { colors, @@ -102,6 +111,7 @@ import { logError, logInfo, logWarn, + logicalPathTo, parseOptions, progressPromiseAll, } from '#cli'; @@ -304,18 +314,30 @@ async function getSpawnMagick(tool) { // Note: This returns an array of no-argument functions, suitable for passing // to queue(). function generateImageThumbnails({ + mediaPath, + mediaCachePath, filePath, dimensions, spawnConvert, }) { - const dirname = path.dirname(filePath); - const extname = path.extname(filePath); - const basename = path.basename(filePath, extname); - const output = (name) => path.join(dirname, basename + name + '.jpg'); - - const convert = (name, {size, quality}) => - spawnConvert([ - filePath, + const filePathInMedia = path.join(mediaPath, filePath); + + function getOutputPath(thumbtack) { + return path.join( + mediaCachePath, + path.dirname(filePath), + [ + path.basename(filePath, path.extname(filePath)), + thumbtack, + 'jpg' + ].join('.')); + } + + function startConvertProcess(outputPathInCache, details) { + const {size, quality} = details; + + return spawnConvert([ + filePathInMedia, '-strip', '-resize', `${size}x${size}>`, @@ -323,24 +345,115 @@ function generateImageThumbnails({ 'Plane', '-quality', `${quality}%`, - output(name), + outputPathInCache, ]); + } return ( getThumbnailsAvailableForDimensions(dimensions) - .map(([name]) => [name, thumbnailSpec[name]]) - .map(([name, details]) => () => - promisifyProcess(convert('.' + name, details), false))); + .map(([thumbtack]) => [thumbtack, thumbnailSpec[thumbtack]]) + .map(([thumbtack, details]) => async () => { + const outputPathInCache = getOutputPath(thumbtack); + await mkdir(path.dirname(outputPathInCache), {recursive: true}); + + const convertProcess = startConvertProcess(outputPathInCache, details); + await promisifyProcess(convertProcess, false); + })); } -export async function clearThumbs(mediaPath, { +export async function determineMediaCachePath({ + mediaPath, + providedMediaCachePath, + disallowDoubling = false, +}) { + if (!mediaPath) { + return { + annotation: 'media path not provided', + mediaCachePath: null, + }; + } + + if (providedMediaCachePath) { + return { + annotation: 'custom path provided', + mediaCachePath: providedMediaCachePath, + }; + } + + let mediaIncludesThumbnailCache; + + try { + const files = await readdir(mediaPath); + mediaIncludesThumbnailCache = files.includes(CACHE_FILE); + } catch (error) { + mediaIncludesThumbnailCache = false; + } + + if (mediaIncludesThumbnailCache === true && !disallowDoubling) { + return { + annotation: 'media path doubles as cache', + mediaCachePath: mediaPath, + }; + } + + const inferredPath = + path.join( + path.dirname(mediaPath), + path.basename(mediaPath) + '-cache'); + + let inferredIncludesThumbnailCache; + + try { + const files = await readdir(inferredPath); + inferredIncludesThumbnailCache = files.includes(CACHE_FILE); + } catch (error) { + if (error.code === 'ENOENT') { + inferredIncludesThumbnailCache = null; + } else { + inferredIncludesThumbnailCache = undefined; + } + } + + if (inferredIncludesThumbnailCache === true) { + return { + annotation: 'inferred path has cache', + mediaCachePath: inferredPath, + }; + } else if (inferredIncludesThumbnailCache === false) { + return { + annotation: 'inferred path does not have cache', + mediaCachePath: null, + }; + } else if (inferredIncludesThumbnailCache === null) { + return { + annotation: 'inferred path will be created', + mediaCachePath: inferredPath, + }; + } else { + return { + annotation: 'inferred path not readable', + mediaCachePath: null, + }; + } +} + +export async function migrateThumbsIntoDedicatedCacheDirectory({ + mediaPath, + mediaCachePath, + queueSize = 0, -} = {}) { +}) { if (!mediaPath) { - throw new Error('Expected mediaPath to be passed'); + throw new Error('Expected mediaPath'); } - logInfo`Looking for thumbnails to clear out...`; + if (!mediaCachePath) { + throw new Error(`Expected mediaCachePath`); + } + + logInfo`Migrating thumbnail files into dedicated directory.`; + logInfo`Moving thumbs from: ${mediaPath}`; + logInfo`Moving thumbs into: ${mediaCachePath}`; const thumbFiles = await traverse(mediaPath, { pathStyle: 'device', @@ -349,8 +462,7 @@ export async function clearThumbs(mediaPath, { }); if (thumbFiles.length) { - // Double-check files. Since we're unlinking (deleting) files, - // we're better off safe than sorry! + // Double-check files. const thumbtacks = Object.keys(thumbnailSpec); const unsafeFiles = thumbFiles.filter(file => { if (path.extname(file) !== '.jpg') return true; @@ -369,14 +481,20 @@ export async function clearThumbs(mediaPath, { return {success: false}; } - logInfo`Clearing out ${thumbFiles.length} thumbs.`; + logInfo`Moving ${thumbFiles.length} thumbs.`; + + await mkdir(mediaCachePath, {recursive: true}); const errored = []; - await progressPromiseAll(`Removing thumbnail files`, queue( + await progressPromiseAll(`Moving thumbnail files`, queue( thumbFiles.map(file => async () => { try { - await unlink(file); + const filePathInMedia = file; + const filePath = path.relative(mediaPath, filePathInMedia); + const filePathInCache = path.join(mediaCachePath, filePath); + await mkdir(path.dirname(filePathInCache), {recursive: true}); + await rename(filePathInMedia, filePathInCache); } catch (error) { if (error.code !== 'ENOENT') { errored.push(file); @@ -386,18 +504,18 @@ export async function clearThumbs(mediaPath, { queueSize)); if (errored.length) { - logError`Couldn't remove these paths (${errored.length}):`; + logError`Couldn't move these paths (${errored.length}):`; for (const file of errored) { console.error(file); } - logError`Check for permission errors?`; + logError`It's possible there were permission errors. After you've`; + logError`investigated, running again should work to move these.`; return {success: false}; } else { - logInfo`Successfully deleted all ${thumbFiles.length} thumbnail files!`; + logInfo`Successfully moved all ${thumbFiles.length} thumbnail files!`; } } else { - logInfo`Didn't find any thumbs in media directory.`; - logInfo`${mediaPath}`; + logInfo`Didn't find any thumbnails to move.`; } let cacheExists = false; @@ -406,7 +524,7 @@ export async function clearThumbs(mediaPath, { cacheExists = true; } catch (error) { if (error.code === 'ENOENT') { - logInfo`Cache file already missing, nothing to remove there.`; + logInfo`No cache file present here. (${CACHE_FILE})`; } else { logWarn`Failed to access cache file. Check its permissions?`; } @@ -414,21 +532,27 @@ export async function clearThumbs(mediaPath, { if (cacheExists) { try { - unlink(path.join(mediaPath, CACHE_FILE)); - logInfo`Removed thumbnail cache file.`; + await rename( + path.join(mediaPath, CACHE_FILE), + path.join(mediaCachePath, CACHE_FILE)); + logInfo`Moved thumbnail cache file.`; } catch (error) { - logWarn`Failed to remove cache file. Check its permissions?`; + logWarn`Failed to move cache file. (${CACHE_FILE})`; + logWarn`Check its permissions, or try copying/pasting.`; } } return {success: true}; } -export default async function genThumbs(mediaPath, { +export default async function genThumbs({ + mediaPath, + mediaCachePath, + queueSize = 0, magickThreads = defaultMagickThreads, quiet = false, -} = {}) { +}) { if (!mediaPath) { throw new Error('Expected mediaPath to be passed'); } @@ -454,13 +578,13 @@ export default async function genThumbs(mediaPath, { quietInfo`Running up to ${magickThreads + ' magick threads'} simultaneously.`; - let cache, - firstRun = false; + let cache = null; + let firstRun = false; + try { - cache = JSON.parse(await readFile(path.join(mediaPath, CACHE_FILE))); + cache = JSON.parse(await readFile(path.join(mediaCachePath, CACHE_FILE))); quietInfo`Cache file successfully read.`; } catch (error) { - cache = {}; if (error.code === 'ENOENT') { firstRun = true; } else { @@ -472,7 +596,20 @@ export default async function genThumbs(mediaPath, { } try { - await writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache)); + await mkdir(mediaCachePath, {recursive: true}); + } catch (error) { + logError`Couldn't create the media cache directory: ${error.code}`; + logError`That's where the media files are going to go, so you'll`; + logError`have to investigate this - it's likely a permissions error.`; + return {success: false}; + } + + try { + await writeFile( + path.join(mediaCachePath, CACHE_FILE), + (firstRun + ? JSON.stringify({}) + : JSON.stringify(cache))); quietInfo`Writing to cache file appears to be working.`; } catch (error) { logWarn`Test of cache file writing failed: ${error}`; @@ -480,6 +617,7 @@ export default async function genThumbs(mediaPath, { logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`; } else if (firstRun) { logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`; + logWarn`You may also have to provide ${'--media-cache-path'} ${mediaCachePath} next run.`; } else { logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`; } @@ -487,6 +625,10 @@ export default async function genThumbs(mediaPath, { await delay(WARNING_DELAY_TIME); } + if (firstRun) { + cache = {}; + } + const imagePaths = await traverseSourceImagePaths(mediaPath, {target: 'generate'}); const imageToMD5Entries = @@ -574,7 +716,9 @@ export default async function genThumbs(mediaPath, { const generateCalls = entriesToGenerate.flatMap(([filePath, md5]) => generateImageThumbnails({ - filePath: path.join(mediaPath, filePath), + mediaPath, + mediaCachePath, + filePath, dimensions: imageToDimensions[filePath], spawnConvert, }).map(call => async () => { @@ -610,7 +754,7 @@ export default async function genThumbs(mediaPath, { try { await writeFile( - path.join(mediaPath, CACHE_FILE), + path.join(mediaCachePath, CACHE_FILE), JSON.stringify(updatedCache) ); quietInfo`Updated cache file successfully written!`; @@ -626,7 +770,7 @@ export default async function genThumbs(mediaPath, { export function getExpectedImagePaths(mediaPath, {urls, wikiData}) { const fromRoot = urls.from('media.root'); - return [ + const paths = [ wikiData.albumData .flatMap(album => [ album.hasCoverArt && fromRoot.to('media.albumCover', album.directory, album.coverArtFileExtension), @@ -646,6 +790,10 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) { wikiData.flashData .map(flash => fromRoot.to('media.flashArt', flash.directory, flash.coverArtFileExtension)), ].flat(); + + sortByName(paths, {getName: path => path}); + + return paths; } export function checkMissingMisplacedMediaFiles(expectedImagePaths, extantImagePaths) { @@ -674,28 +822,114 @@ export function checkMissingMisplacedMediaFiles(expectedImagePaths, extantImageP export async function verifyImagePaths(mediaPath, {urls, wikiData}) { const expectedPaths = getExpectedImagePaths(mediaPath, {urls, wikiData}); const extantPaths = await traverseSourceImagePaths(mediaPath, {target: 'verify'}); - const {missing, misplaced} = checkMissingMisplacedMediaFiles(expectedPaths, extantPaths); - if (empty(missing) && empty(misplaced)) { + const {missing: missingPaths, misplaced: misplacedPaths} = + checkMissingMisplacedMediaFiles(expectedPaths, extantPaths); + + if (empty(missingPaths) && empty(misplacedPaths)) { logInfo`All image paths are good - nice! None are missing or misplaced.`; - return {missing, misplaced}; + return {missing: [], misplaced: []}; + } + + const relativeMediaPath = await logicalPathTo(mediaPath); + + const dirnamesOfExpectedPaths = + unique(expectedPaths.map(file => path.dirname(file))); + + const dirnamesOfExtantPaths = + unique(extantPaths.map(file => path.dirname(file))); + + const dirnamesOfMisplacedPaths = + unique(misplacedPaths.map(file => path.dirname(file))); + + const completelyMisplacedDirnames = + dirnamesOfMisplacedPaths + .filter(dirname => !dirnamesOfExpectedPaths.includes(dirname)); + + const completelyMissingDirnames = + dirnamesOfExpectedPaths + .filter(dirname => !dirnamesOfExtantPaths.includes(dirname)); + + const individuallyMisplacedPaths = + misplacedPaths + .filter(file => !completelyMisplacedDirnames.includes(path.dirname(file))); + + const individuallyMissingPaths = + missingPaths + .filter(file => !completelyMissingDirnames.includes(path.dirname(file))); + + const wrongExtensionPaths = + misplacedPaths + .map(file => { + const stripExtension = file => + path.join( + path.dirname(file), + path.basename(file, path.extname(file))); + + const extantExtension = path.extname(file); + const basename = stripExtension(file); + + const expectedPath = + missingPaths + .find(file => stripExtension(file) === basename); + + if (!expectedPath) return null; + + const expectedExtension = path.extname(expectedPath); + return {basename, extantExtension, expectedExtension}; + }) + .filter(Boolean); + + if (!empty(missingPaths)) { + if (missingPaths.length === 1) { + logWarn`${1} expected image file is missing from ${relativeMediaPath}:`; + } else { + logWarn`${missingPaths.length} expected image files are missing:`; + } + + for (const dirname of completelyMissingDirnames) { + console.log(` - (missing) All files under ${colors.bright(dirname)}`); + } + + for (const file of individuallyMissingPaths) { + console.log(` - (missing) ${file}`); + } } - if (!empty(missing)) { - logWarn`** Some image files are missing! (${missing.length + ' files'}) **`; - for (const file of missing) { - console.warn(colors.yellow(` - `) + file); + if (!empty(misplacedPaths)) { + if (misplacedPaths.length === 1) { + logWarn`${1} image file, present in ${relativeMediaPath}, wasn't expected:`; + } else { + logWarn`${misplacedPaths.length} image files, present in ${relativeMediaPath}, weren't expected:`; + } + + for (const dirname of completelyMisplacedDirnames) { + console.log(` - (misplaced) All files under ${colors.bright(dirname)}`); + } + + for (const file of individuallyMisplacedPaths) { + console.log(` - (misplaced) ${file}`); } } - if (!empty(misplaced)) { - logWarn`** Some image files are misplaced! (${misplaced.length + ' files'}) **`; - for (const file of misplaced) { - console.warn(colors.yellow(` - `) + file); + if (!empty(wrongExtensionPaths)) { + if (wrongExtensionPaths.length === 1) { + logWarn`Of these, ${1} has an unexpected file extension:`; + } else { + logWarn`Of these, ${wrongExtensionPaths.length} have an unexpected file extension:`; } + + for (const {basename, extantExtension, expectedExtension} of wrongExtensionPaths) { + console.log(` - (expected ${colors.green(expectedExtension)}) ${basename + colors.red(extantExtension)}`); + } + + logWarn`To handle unexpected file extensions:`; + logWarn` * Source and ${`replace`} with the correct file, or`; + logWarn` * Add ${`"Cover Art File Extension"`} field (or similar)`; + logWarn` to the respective document in YAML data files.`; } - return {missing, misplaced}; + return {missing: missingPaths, misplaced: misplacedPaths}; } // Recursively traverses the provided (extant) media path, filtering so only @@ -725,7 +959,7 @@ export async function traverseSourceImagePaths(mediaPath, {target}) { throw new Error(`Expected target to be 'verify' or 'generate', got ${target}`); } - return await traverse(mediaPath, { + const paths = await traverse(mediaPath, { pathStyle: (target === 'verify' ? 'posix' : 'device'), prefixPath: '', @@ -755,6 +989,10 @@ export async function traverseSourceImagePaths(mediaPath, {target}) { return true; }, }); + + sortByName(paths, {getName: path => path}); + + return paths; } export function isThumb(file) { diff --git a/src/listing-spec.js b/src/listing-spec.js index f57762b0..9433ee68 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -66,6 +66,7 @@ listingSpec.push({ contentFunction: 'listArtistsByDuration', }); +// TODO: hide if no groups... listingSpec.push({ directory: 'artists/by-group', stringsKey: 'listArtists.byGroup', diff --git a/src/repl.js b/src/repl.js index ead01567..7a6f5c45 100644 --- a/src/repl.js +++ b/src/repl.js @@ -16,6 +16,8 @@ import * as serialize from '#serialize'; import * as sugar from '#sugar'; import * as wikiDataUtils from '#wiki-data'; +import {DEFAULT_STRINGS_FILE} from './upd8.js'; + const __dirname = path.dirname(fileURLToPath(import.meta.url)); export async function getContextAssignments({ @@ -46,7 +48,7 @@ export async function getContextAssignments({ language = await processLanguageFile( path.join( path.dirname(fileURLToPath(import.meta.url)), - 'strings-default.json')); + DEFAULT_STRINGS_FILE)); } catch (error) { console.error(error); logWarn`Failed to create Language object`; diff --git a/src/static/client2.js b/src/static/client2.js index 3a5f9c37..28882a88 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -993,10 +993,14 @@ function handleImageLinkClicked(evt) { const thumbImage = document.getElementById('image-overlay-image-thumb'); const {href: originalSrc} = evt.target.closest('a'); - const {dataset: { - originalSize: originalFileSize, - thumbs: availableThumbList, - }} = evt.target.closest('a').querySelector('img'); + + const { + src: embeddedSrc, + dataset: { + originalSize: originalFileSize, + thumbs: availableThumbList, + }, + } = evt.target.closest('a').querySelector('img'); updateFileSizeInformation(originalFileSize); @@ -1006,8 +1010,8 @@ function handleImageLinkClicked(evt) { if (availableThumbList) { const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); - mainSrc = originalSrc.replace(/\.(jpg|png)$/, `.${mainThumb}.jpg`); - thumbSrc = originalSrc.replace(/\.(jpg|png)$/, `.${smallThumb}.jpg`); + mainSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${mainThumb}.jpg`); + thumbSrc = embeddedSrc.replace(/\.[a-z]+\.(jpg|png)$/, `.${smallThumb}.jpg`); // Show the thumbnail size on each <img> element's data attributes. // Y'know, just for debugging convenience. mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; diff --git a/src/strings-default.json b/src/strings-default.json deleted file mode 100644 index b0b68a57..00000000 --- a/src/strings-default.json +++ /dev/null @@ -1,528 +0,0 @@ -{ - "meta.languageCode": "en", - "meta.languageName": "English", - "count.tracks": "{TRACKS}", - "count.tracks.withUnit.zero": "", - "count.tracks.withUnit.one": "{TRACKS} track", - "count.tracks.withUnit.two": "", - "count.tracks.withUnit.few": "", - "count.tracks.withUnit.many": "", - "count.tracks.withUnit.other": "{TRACKS} tracks", - "count.additionalFiles": "{FILES}", - "count.additionalFiles.withUnit.zero": "", - "count.additionalFiles.withUnit.one": "{FILES} file", - "count.additionalFiles.withUnit.two": "", - "count.additionalFiles.withUnit.few": "", - "count.additionalFiles.withUnit.many": "", - "count.additionalFiles.withUnit.other": "{FILES} files", - "count.albums": "{ALBUMS}", - "count.albums.withUnit.zero": "", - "count.albums.withUnit.one": "{ALBUMS} album", - "count.albums.withUnit.two": "", - "count.albums.withUnit.few": "", - "count.albums.withUnit.many": "", - "count.albums.withUnit.other": "{ALBUMS} albums", - "count.artworks": "{ARTWORKS}", - "count.artworks.withUnit.zero": "", - "count.artworks.withUnit.one": "{ARTWORKS} artwork", - "count.artworks.withUnit.two": "", - "count.artworks.withUnit.few": "", - "count.artworks.withUnit.many": "", - "count.artworks.withUnit.other": "{ARTWORKS} artworks", - "count.commentaryEntries": "{ENTRIES}", - "count.commentaryEntries.withUnit.zero": "", - "count.commentaryEntries.withUnit.one": "{ENTRIES} entry", - "count.commentaryEntries.withUnit.two": "", - "count.commentaryEntries.withUnit.few": "", - "count.commentaryEntries.withUnit.many": "", - "count.commentaryEntries.withUnit.other": "{ENTRIES} entries", - "count.contributions": "{CONTRIBUTIONS}", - "count.contributions.withUnit.zero": "", - "count.contributions.withUnit.one": "{CONTRIBUTIONS} contribution", - "count.contributions.withUnit.two": "", - "count.contributions.withUnit.few": "", - "count.contributions.withUnit.many": "", - "count.contributions.withUnit.other": "{CONTRIBUTIONS} contributions", - "count.coverArts": "{COVER_ARTS}", - "count.coverArts.withUnit.zero": "", - "count.coverArts.withUnit.one": "{COVER_ARTS} cover art", - "count.coverArts.withUnit.two": "", - "count.coverArts.withUnit.few": "", - "count.coverArts.withUnit.many": "", - "count.coverArts.withUnit.other": "{COVER_ARTS} cover arts", - "count.flashes": "{FLASHES}", - "count.flashes.withUnit.zero": "", - "count.flashes.withUnit.one": "{FLASHES} flashes & games", - "count.flashes.withUnit.two": "", - "count.flashes.withUnit.few": "", - "count.flashes.withUnit.many": "", - "count.flashes.withUnit.other": "{FLASHES} flashes & games", - "count.timesReferenced": "{TIMES_REFERENCED}", - "count.timesReferenced.withUnit.zero": "", - "count.timesReferenced.withUnit.one": "{TIMES_REFERENCED} time referenced", - "count.timesReferenced.withUnit.two": "", - "count.timesReferenced.withUnit.few": "", - "count.timesReferenced.withUnit.many": "", - "count.timesReferenced.withUnit.other": "{TIMES_REFERENCED} times referenced", - "count.words": "{WORDS}", - "count.words.thousand": "{WORDS}k", - "count.words.withUnit.zero": "", - "count.words.withUnit.one": "{WORDS} word", - "count.words.withUnit.two": "", - "count.words.withUnit.few": "", - "count.words.withUnit.many": "", - "count.words.withUnit.other": "{WORDS} words", - "count.timesUsed": "{TIMES_USED}", - "count.timesUsed.withUnit.zero": "", - "count.timesUsed.withUnit.one": "used {TIMES_USED} time", - "count.timesUsed.withUnit.two": "", - "count.timesUsed.withUnit.few": "", - "count.timesUsed.withUnit.many": "", - "count.timesUsed.withUnit.other": "used {TIMES_USED} times", - "count.index.zero": "", - "count.index.one": "{INDEX}st", - "count.index.two": "{INDEX}nd", - "count.index.few": "{INDEX}rd", - "count.index.many": "", - "count.index.other": "{INDEX}th", - "count.duration.hours": "{HOURS}:{MINUTES}:{SECONDS}", - "count.duration.hours.withUnit": "{HOURS}:{MINUTES}:{SECONDS} hours", - "count.duration.minutes": "{MINUTES}:{SECONDS}", - "count.duration.minutes.withUnit": "{MINUTES}:{SECONDS} minutes", - "count.duration.approximate": "~{DURATION}", - "count.duration.missing": "_:__", - "count.fileSize.terabytes": "{TERABYTES} TB", - "count.fileSize.gigabytes": "{GIGABYTES} GB", - "count.fileSize.megabytes": "{MEGABYTES} MB", - "count.fileSize.kilobytes": "{KILOBYTES} kB", - "count.fileSize.bytes": "{BYTES} bytes", - "releaseInfo.by": "By {ARTISTS}.", - "releaseInfo.from": "From {ALBUM}.", - "releaseInfo.coverArtBy": "Cover art by {ARTISTS}.", - "releaseInfo.wallpaperArtBy": "Wallpaper art by {ARTISTS}.", - "releaseInfo.bannerArtBy": "Banner art by {ARTISTS}.", - "releaseInfo.released": "Released {DATE}.", - "releaseInfo.artReleased": "Art released {DATE}.", - "releaseInfo.addedToWiki": "Added to wiki {DATE}.", - "releaseInfo.duration": "Duration: {DURATION}.", - "releaseInfo.viewCommentary": "View {LINK}!", - "releaseInfo.viewCommentary.link": "commentary page", - "releaseInfo.viewGallery": "View {LINK}!", - "releaseInfo.viewGallery.link": "gallery page", - "releaseInfo.viewGalleryOrCommentary": "View {GALLERY} or {COMMENTARY}!", - "releaseInfo.viewGalleryOrCommentary.gallery": "gallery page", - "releaseInfo.viewGalleryOrCommentary.commentary": "commentary page", - "releaseInfo.viewOriginalFile": "View {LINK}.", - "releaseInfo.viewOriginalFile.withSize": "View {LINK} ({SIZE}).", - "releaseInfo.viewOriginalFile.link": "original file", - "releaseInfo.viewOriginalFile.sizeWarning": "(Heads up! If you're on a mobile plan, this is a large download.)", - "releaseInfo.listenOn": "Listen on {LINKS}.", - "releaseInfo.listenOn.noLinks": "This wiki doesn't have any listening links for {NAME}.", - "releaseInfo.visitOn": "Visit on {LINKS}.", - "releaseInfo.playOn": "Play on {LINKS}.", - "releaseInfo.readCommentary": "Read {LINK}.", - "releaseInfo.readCommentary.link": "artist commentary", - "releaseInfo.alsoReleasedAs": "Also released as:", - "releaseInfo.alsoReleasedAs.item": "{TRACK} (on {ALBUM})", - "releaseInfo.contributors": "Contributors:", - "releaseInfo.tracksReferenced": "Tracks that {TRACK} references:", - "releaseInfo.tracksThatReference": "Tracks that reference {TRACK}:", - "releaseInfo.tracksSampled": "Tracks that {TRACK} samples:", - "releaseInfo.tracksThatSample": "Tracks that sample {TRACK}:", - "releaseInfo.flashesThatFeature": "Flashes & games that feature {TRACK}:", - "releaseInfo.flashesThatFeature.item": "{FLASH}", - "releaseInfo.flashesThatFeature.item.asDifferentRelease": "{FLASH} (as {TRACK})", - "releaseInfo.tracksFeatured": "Tracks that {FLASH} features:", - "releaseInfo.lyrics": "Lyrics:", - "releaseInfo.artistCommentary": "Artist commentary:", - "releaseInfo.artistCommentary.seeOriginalRelease": "See {ORIGINAL}!", - "releaseInfo.artTags": "Tags:", - "releaseInfo.artTags.inline": "Tags: {TAGS}", - "releaseInfo.additionalFiles.shortcut": "View {ANCHOR_LINK}: {TITLES}", - "releaseInfo.additionalFiles.shortcut.anchorLink": "additional files", - "releaseInfo.additionalFiles.heading": "View or download {ADDITIONAL_FILES}:", - "releaseInfo.additionalFiles.entry": "{TITLE}", - "releaseInfo.additionalFiles.entry.withDescription": "{TITLE}: {DESCRIPTION}", - "releaseInfo.additionalFiles.file": "{FILE}", - "releaseInfo.additionalFiles.file.withSize": "{FILE} ({SIZE})", - "releaseInfo.sheetMusicFiles.shortcut": "Download {LINK}.", - "releaseInfo.sheetMusicFiles.shortcut.link": "sheet music files", - "releaseInfo.sheetMusicFiles.heading": "Print or download sheet music files:", - "releaseInfo.midiProjectFiles.shortcut": "Download {LINK}.", - "releaseInfo.midiProjectFiles.shortcut.link": "MIDI/project files", - "releaseInfo.midiProjectFiles.heading": "Download MIDI/project files:", - "releaseInfo.note": "Context notes:", - "trackList.section.withDuration": "{SECTION} ({DURATION}):", - "trackList.group": "From {GROUP}:", - "trackList.group.fromOther": "From somewhere else:", - "trackList.item.withDuration": "({DURATION}) {TRACK}", - "trackList.item.withDuration.withArtists": "({DURATION}) {TRACK} {BY}", - "trackList.item.withArtists": "{TRACK} {BY}", - "trackList.item.withArtists.by": "by {ARTISTS}", - "trackList.item.rerelease": "{TRACK} (re-release)", - "misc.alt.albumCover": "album cover", - "misc.alt.albumBanner": "album banner", - "misc.alt.trackCover": "track cover", - "misc.alt.artistAvatar": "artist avatar", - "misc.alt.flashArt": "flash art", - "misc.artistLink": "{ARTIST}", - "misc.artistLink.withContribution": "{ARTIST} ({CONTRIB})", - "misc.artistLink.withExternalLinks": "{ARTIST} ({LINKS})", - "misc.artistLink.withContribution.withExternalLinks": "{ARTIST} ({CONTRIB}) ({LINKS})", - "misc.chronology.seeArtistPages": "(See artist pages for chronology info!)", - "misc.chronology.heading.coverArt": "{INDEX} cover art by {ARTIST}", - "misc.chronology.heading.flash": "{INDEX} flash/game by {ARTIST}", - "misc.chronology.heading.track": "{INDEX} track by {ARTIST}", - "misc.chronology.withNavigation": "{HEADING} ({NAVIGATION})", - "misc.external.domain": "External ({DOMAIN})", - "misc.external.local": "Wiki Archive (local upload)", - "misc.external.bandcamp": "Bandcamp", - "misc.external.bandcamp.domain": "Bandcamp ({DOMAIN})", - "misc.external.deviantart": "DeviantArt", - "misc.external.instagram": "Instagram", - "misc.external.mastodon": "Mastodon", - "misc.external.mastodon.domain": "Mastodon ({DOMAIN})", - "misc.external.newgrounds": "Newgrounds", - "misc.external.patreon": "Patreon", - "misc.external.poetryFoundation": "Poetry Foundation", - "misc.external.soundcloud": "SoundCloud", - "misc.external.spotify": "Spotify", - "misc.external.tumblr": "Tumblr", - "misc.external.twitter": "Twitter", - "misc.external.wikipedia": "Wikipedia", - "misc.external.youtube": "YouTube", - "misc.external.youtube.playlist": "YouTube (playlist)", - "misc.external.youtube.fullAlbum": "YouTube (full album)", - "misc.external.flash.bgreco": "{LINK} (HQ Audio)", - "misc.external.flash.homestuck.page": "{LINK} (page {PAGE})", - "misc.external.flash.homestuck.secret": "{LINK} (secret page)", - "misc.external.flash.youtube": "{LINK} (on any device)", - "misc.missingImage": "(This image file is missing)", - "misc.missingLinkContent": "(Missing link content)", - "misc.nav.previous": "Previous", - "misc.nav.next": "Next", - "misc.nav.info": "Info", - "misc.nav.gallery": "Gallery", - "misc.pageTitle": "{TITLE}", - "misc.pageTitle.withWikiName": "{TITLE} | {WIKI_NAME}", - "misc.skippers.skipTo": "Skip to:", - "misc.skippers.content": "Content", - "misc.skippers.sidebar": "Sidebar", - "misc.skippers.sidebar.left": "Sidebar (left)", - "misc.skippers.sidebar.right": "Sidebar (right)", - "misc.skippers.header": "Header", - "misc.skippers.footer": "Footer", - "misc.skippers.tracks": "Tracks", - "misc.skippers.art": "Artworks", - "misc.skippers.flashes": "Flashes & Games", - "misc.skippers.contributors": "Contributors", - "misc.skippers.references": "References...", - "misc.skippers.referencedBy": "Referenced by...", - "misc.skippers.samples": "Samples...", - "misc.skippers.sampledBy": "Sampled by...", - "misc.skippers.features": "Features...", - "misc.skippers.featuredIn": "Featured in...", - "misc.skippers.lyrics": "Lyrics", - "misc.skippers.sheetMusicFiles": "Sheet music files", - "misc.skippers.midiProjectFiles": "MIDI/project files", - "misc.skippers.additionalFiles": "Additional files", - "misc.skippers.commentary": "Commentary", - "misc.skippers.artistCommentary": "Commentary", - "misc.socialEmbed.heading": "{WIKI_NAME} | {HEADING}", - "misc.jumpTo": "Jump to:", - "misc.jumpTo.withLinks": "Jump to: {LINKS}.", - "misc.contentWarnings": "cw: {WARNINGS}", - "misc.contentWarnings.reveal": "click to show", - "misc.albumGrid.details": "({TRACKS}, {TIME})", - "misc.albumGrid.details.coverArtists": "(Illust. {ARTISTS})", - "misc.albumGrid.details.otherCoverArtists": "(With {ARTISTS})", - "misc.albumGrid.noCoverArt": "{ALBUM}", - "misc.albumGalleryGrid.noCoverArt": "{NAME}", - "misc.uiLanguage": "UI Language: {LANGUAGES}", - "homepage.title": "{TITLE}", - "homepage.news.title": "News", - "homepage.news.entry.viewRest": "(View rest of entry!)", - "albumSidebar.trackList.fallbackSectionName": "Track list", - "albumSidebar.trackList.group": "{GROUP}", - "albumSidebar.trackList.group.withRange": "{GROUP} ({RANGE})", - "albumSidebar.trackList.item": "{TRACK}", - "albumSidebar.groupBox.title": "{GROUP}", - "albumSidebar.groupBox.next": "Next: {ALBUM}", - "albumSidebar.groupBox.previous": "Previous: {ALBUM}", - "albumPage.title": "{ALBUM}", - "albumPage.nav.album": "{ALBUM}", - "albumPage.nav.randomTrack": "Random Track", - "albumPage.nav.gallery": "Gallery", - "albumPage.nav.commentary": "Commentary", - "albumPage.socialEmbed.heading": "{GROUP}", - "albumPage.socialEmbed.title": "{ALBUM}", - "albumPage.socialEmbed.body.withDuration": "{DURATION}.", - "albumPage.socialEmbed.body.withTracks": "{TRACKS}.", - "albumPage.socialEmbed.body.withReleaseDate": "Released {DATE}.", - "albumPage.socialEmbed.body.withDuration.withTracks": "{DURATION}, {TRACKS}.", - "albumPage.socialEmbed.body.withDuration.withReleaseDate": "{DURATION}. Released {DATE}.", - "albumPage.socialEmbed.body.withTracks.withReleaseDate": "{TRACKS}. Released {DATE}.", - "albumPage.socialEmbed.body.withDuration.withTracks.withReleaseDate": "{DURATION}, {TRACKS}. Released {DATE}.", - "albumGalleryPage.title": "{ALBUM} - Gallery", - "albumGalleryPage.statsLine": "{TRACKS} totaling {DURATION}.", - "albumGalleryPage.statsLine.withDate": "{TRACKS} totaling {DURATION}. Released {DATE}.", - "albumGalleryPage.coverArtistsLine": "All track artwork by {ARTISTS}.", - "albumGalleryPage.noTrackArtworksLine": "This album doesn't have any track artwork.", - "albumCommentaryPage.title": "{ALBUM} - Commentary", - "albumCommentaryPage.infoLine": "{WORDS} across {ENTRIES}.", - "albumCommentaryPage.nav.album": "Album: {ALBUM}", - "albumCommentaryPage.entry.title.albumCommentary": "Album commentary", - "albumCommentaryPage.entry.title.trackCommentary": "{TRACK}", - "artistPage.title": "{ARTIST}", - "artistPage.creditList.album": "{ALBUM}", - "artistPage.creditList.album.withDate": "{ALBUM} ({DATE})", - "artistPage.creditList.album.withDuration": "{ALBUM} ({DURATION})", - "artistPage.creditList.album.withDate.withDuration": "{ALBUM} ({DATE}; {DURATION})", - "artistPage.creditList.flashAct": "{ACT}", - "artistPage.creditList.flashAct.withDate": "{ACT} ({DATE})", - "artistPage.creditList.flashAct.withDateRange": "{ACT} ({DATE_RANGE})", - "artistPage.creditList.entry.track": "{TRACK}", - "artistPage.creditList.entry.track.withDuration": "({DURATION}) {TRACK}", - "artistPage.creditList.entry.album.coverArt": "(cover art)", - "artistPage.creditList.entry.album.wallpaperArt": "(wallpaper art)", - "artistPage.creditList.entry.album.bannerArt": "(banner art)", - "artistPage.creditList.entry.album.commentary": "(album commentary)", - "artistPage.creditList.entry.flash": "{FLASH}", - "artistPage.creditList.entry.rerelease": "{ENTRY} (re-release)", - "artistPage.creditList.entry.withContribution": "{ENTRY} ({CONTRIBUTION})", - "artistPage.creditList.entry.withArtists": "{ENTRY} (with {ARTISTS})", - "artistPage.creditList.entry.withArtists.withContribution": "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})", - "artistPage.contributedDurationLine": "{ARTIST} has contributed {DURATION} of music shared on this wiki.", - "artistPage.musicGroupsLine": "Contributed music to groups: {GROUPS}", - "artistPage.artGroupsLine": "Contributed art to groups: {GROUPS}", - "artistPage.groupsLine.item.withCount": "{GROUP} ({COUNT})", - "artistPage.groupsLine.item.withDuration": "{GROUP} ({DURATION})", - "artistPage.groupContributions.title.music": "Contributed music to groups:", - "artistPage.groupContributions.title.artworks": "Contributed artworks to groups:", - "artistPage.groupContributions.title.withSortButton": "{TITLE} ({SORT})", - "artistPage.groupContributions.title.sorting.count": "Sorting by count.", - "artistPage.groupContributions.title.sorting.duration": "Sorting by duration.", - "artistPage.groupContributions.item.countAccent": "({COUNT})", - "artistPage.groupContributions.item.durationAccent": "({DURATION})", - "artistPage.groupContributions.item.countDurationAccent": "({COUNT} — {DURATION})", - "artistPage.groupContributions.item.durationCountAccent": "({DURATION} — {COUNT})", - "artistPage.trackList.title": "Tracks", - "artistPage.artList.title": "Artworks", - "artistPage.flashList.title": "Flashes & Games", - "artistPage.commentaryList.title": "Commentary", - "artistPage.viewArtGallery": "View {LINK}!", - "artistPage.viewArtGallery.orBrowseList": "View {LINK}! Or browse the list:", - "artistPage.viewArtGallery.link": "art gallery", - "artistPage.nav.artist": "Artist: {ARTIST}", - "artistGalleryPage.title": "{ARTIST} - Gallery", - "artistGalleryPage.infoLine": "Contributed to {COVER_ARTS}.", - "commentaryIndex.title": "Commentary", - "commentaryIndex.infoLine": "{WORDS} across {ENTRIES}, in all.", - "commentaryIndex.albumList.title": "Choose an album:", - "commentaryIndex.albumList.item": "{ALBUM} ({WORDS} across {ENTRIES})", - "flashIndex.title": "Flashes & Games", - "flashPage.title": "{FLASH}", - "flashPage.nav.flash": "{FLASH}", - "flashSidebar.flashList.flashesInThisAct": "Flashes in this act", - "flashSidebar.flashList.entriesInThisSection": "Entries in this section", - "groupSidebar.title": "Groups", - "groupSidebar.groupList.category": "{CATEGORY}", - "groupSidebar.groupList.item": "{GROUP}", - "groupPage.nav.group": "Group: {GROUP}", - "groupInfoPage.title": "{GROUP}", - "groupInfoPage.viewAlbumGallery": "View {LINK}! Or browse the list:", - "groupInfoPage.viewAlbumGallery.link": "album gallery", - "groupInfoPage.albumList.title": "Albums", - "groupInfoPage.albumList.item": "({YEAR}) {ALBUM}", - "groupInfoPage.albumList.item.withoutYear": "{ALBUM}", - "groupInfoPage.albumList.item.withAccent": "{ITEM} {ACCENT}", - "groupInfoPage.albumList.item.otherGroupAccent": "(from {GROUP})", - "groupGalleryPage.title": "{GROUP} - Gallery", - "groupGalleryPage.infoLine": "{TRACKS} across {ALBUMS}, totaling {TIME}.", - "listingIndex.title": "Listings", - "listingIndex.infoLine": "{WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}.", - "listingIndex.exploreList": "Feel free to explore any of the listings linked below and in the sidebar!", - "listingPage.target.album": "Albums", - "listingPage.target.artist": "Artists", - "listingPage.target.group": "Groups", - "listingPage.target.track": "Tracks", - "listingPage.target.tag": "Tags", - "listingPage.target.other": "Other", - "listingPage.listingsFor": "Listings for {TARGET}: {LISTINGS}", - "listingPage.seeAlso": "Also check out: {LISTINGS}", - "listingPage.skipToSection": "Skip to a section:", - "listingPage.listAlbums.byName.title": "Albums - by Name", - "listingPage.listAlbums.byName.title.short": "...by Name", - "listingPage.listAlbums.byName.item": "{ALBUM} ({TRACKS})", - "listingPage.listAlbums.byTracks.title": "Albums - by Tracks", - "listingPage.listAlbums.byTracks.title.short": "...by Tracks", - "listingPage.listAlbums.byTracks.item": "{ALBUM} ({TRACKS})", - "listingPage.listAlbums.byDuration.title": "Albums - by Duration", - "listingPage.listAlbums.byDuration.title.short": "...by Duration", - "listingPage.listAlbums.byDuration.item": "{ALBUM} ({DURATION})", - "listingPage.listAlbums.byDate.title": "Albums - by Date", - "listingPage.listAlbums.byDate.title.short": "...by Date", - "listingPage.listAlbums.byDate.item": "{ALBUM} ({DATE})", - "listingPage.listAlbums.byDateAdded.title.short": "...by Date Added to Wiki", - "listingPage.listAlbums.byDateAdded.title": "Albums - by Date Added to Wiki", - "listingPage.listAlbums.byDateAdded.chunk.title": "{DATE}", - "listingPage.listAlbums.byDateAdded.chunk.item": "{ALBUM}", - "listingPage.listArtists.byName.title": "Artists - by Name", - "listingPage.listArtists.byName.title.short": "...by Name", - "listingPage.listArtists.byName.item": "{ARTIST} ({CONTRIBUTIONS})", - "listingPage.listArtists.byContribs.title": "Artists - by Contributions", - "listingPage.listArtists.byContribs.title.short": "...by Contributions", - "listingPage.listArtists.byContribs.chunk.title.trackContributors": "Contributed tracks:", - "listingPage.listArtists.byContribs.chunk.title.artContributors": "Contributed artworks:", - "listingPage.listArtists.byContribs.chunk.title.flashContributors": "Contributed to flashes & games:", - "listingPage.listArtists.byContribs.chunk.item": "{ARTIST} ({CONTRIBUTIONS})", - "listingPage.listArtists.byCommentary.title": "Artists - by Commentary Entries", - "listingPage.listArtists.byCommentary.title.short": "...by Commentary Entries", - "listingPage.listArtists.byCommentary.item": "{ARTIST} ({ENTRIES})", - "listingPage.listArtists.byDuration.title": "Artists - by Duration", - "listingPage.listArtists.byDuration.title.short": "...by Duration", - "listingPage.listArtists.byDuration.item": "{ARTIST} ({DURATION})", - "listingPage.listArtists.byGroup.title": "Artists - by Group", - "listingPage.listArtists.byGroup.title.short": "...by Group", - "listingPage.listArtists.byGroup.item": "{ARTIST} ({CONTRIBUTIONS})", - "listingPage.listArtists.byGroup.chunk.title": "Contributed to {GROUP}:", - "listingPage.listArtists.byGroup.chunk.item": "{ARTIST} ({CONTRIBUTIONS})", - "listingPage.listArtists.byLatest.title": "Artists - by Latest Contribution", - "listingPage.listArtists.byLatest.title.short": "...by Latest Contribution", - "listingPage.listArtists.byLatest.chunk.title.album": "{ALBUM} ({DATE})", - "listingPage.listArtists.byLatest.chunk.title.flash": "{FLASH} ({DATE})", - "listingPage.listArtists.byLatest.chunk.title.dateless": "These artists' contributions aren't dated:", - "listingPage.listArtists.byLatest.chunk.item": "{ARTIST}", - "listingPage.listArtists.byLatest.chunk.item.tracks": "{ARTIST} (tracks)", - "listingPage.listArtists.byLatest.chunk.item.tracksAndArt": "{ARTIST} (tracks, art)", - "listingPage.listArtists.byLatest.chunk.item.art": "{ARTIST} (art)", - "listingPage.listGroups.byName.title": "Groups - by Name", - "listingPage.listGroups.byName.title.short": "...by Name", - "listingPage.listGroups.byName.item": "{GROUP} ({GALLERY})", - "listingPage.listGroups.byName.item.gallery": "Gallery", - "listingPage.listGroups.byCategory.title": "Groups - by Category", - "listingPage.listGroups.byCategory.title.short": "...by Category", - "listingPage.listGroups.byCategory.chunk.title": "{CATEGORY}", - "listingPage.listGroups.byCategory.chunk.item": "{GROUP} ({GALLERY})", - "listingPage.listGroups.byCategory.chunk.item.gallery": "Gallery", - "listingPage.listGroups.byAlbums.title": "Groups - by Albums", - "listingPage.listGroups.byAlbums.title.short": "...by Albums", - "listingPage.listGroups.byAlbums.item": "{GROUP} ({ALBUMS})", - "listingPage.listGroups.byTracks.title": "Groups - by Tracks", - "listingPage.listGroups.byTracks.title.short": "...by Tracks", - "listingPage.listGroups.byTracks.item": "{GROUP} ({TRACKS})", - "listingPage.listGroups.byDuration.title": "Groups - by Duration", - "listingPage.listGroups.byDuration.title.short": "...by Duration", - "listingPage.listGroups.byDuration.item": "{GROUP} ({DURATION})", - "listingPage.listGroups.byLatest.title": "Groups - by Latest Album", - "listingPage.listGroups.byLatest.title.short": "...by Latest Album", - "listingPage.listGroups.byLatest.item": "{GROUP} ({DATE})", - "listingPage.listTracks.byName.title": "Tracks - by Name", - "listingPage.listTracks.byName.title.short": "...by Name", - "listingPage.listTracks.byName.item": "{TRACK}", - "listingPage.listTracks.byAlbum.title": "Tracks - by Album", - "listingPage.listTracks.byAlbum.title.short": "...by Album", - "listingPage.listTracks.byAlbum.chunk.title": "{ALBUM}", - "listingPage.listTracks.byAlbum.chunk.item": "{TRACK}", - "listingPage.listTracks.byDate.title": "Tracks - by Date", - "listingPage.listTracks.byDate.title.short": "...by Date", - "listingPage.listTracks.byDate.chunk.title": "{ALBUM} ({DATE})", - "listingPage.listTracks.byDate.chunk.item": "{TRACK}", - "listingPage.listTracks.byDate.chunk.item.rerelease": "{TRACK} (re-release)", - "listingPage.listTracks.byDuration.title": "Tracks - by Duration", - "listingPage.listTracks.byDuration.title.short": "...by Duration", - "listingPage.listTracks.byDuration.item": "{TRACK} ({DURATION})", - "listingPage.listTracks.byDurationInAlbum.title": "Tracks - by Duration (in Album)", - "listingPage.listTracks.byDurationInAlbum.title.short": "...by Duration (in Album)", - "listingPage.listTracks.byDurationInAlbum.chunk.title": "{ALBUM}", - "listingPage.listTracks.byDurationInAlbum.chunk.item": "{TRACK} ({DURATION})", - "listingPage.listTracks.byTimesReferenced.title": "Tracks - by Times Referenced", - "listingPage.listTracks.byTimesReferenced.title.short": "...by Times Referenced", - "listingPage.listTracks.byTimesReferenced.item": "{TRACK} ({TIMES_REFERENCED})", - "listingPage.listTracks.inFlashes.byAlbum.title": "Tracks - in Flashes & Games (by Album)", - "listingPage.listTracks.inFlashes.byAlbum.title.short": "...in Flashes & Games (by Album)", - "listingPage.listTracks.inFlashes.byAlbum.chunk.title": "{ALBUM}", - "listingPage.listTracks.inFlashes.byAlbum.chunk.item": "{TRACK} (in {FLASHES})", - "listingPage.listTracks.inFlashes.byFlash.title": "Tracks - in Flashes & Games (by Flash)", - "listingPage.listTracks.inFlashes.byFlash.title.short": "...in Flashes & Games (by Flash)", - "listingPage.listTracks.inFlashes.byFlash.chunk.title": "{FLASH}", - "listingPage.listTracks.inFlashes.byFlash.chunk.item": "{TRACK} (from {ALBUM})", - "listingPage.listTracks.withLyrics.title": "Tracks - with Lyrics", - "listingPage.listTracks.withLyrics.title.short": "...with Lyrics", - "listingPage.listTracks.withLyrics.chunk.title": "{ALBUM}", - "listingPage.listTracks.withLyrics.chunk.title.withDate": "{ALBUM} ({DATE})", - "listingPage.listTracks.withLyrics.chunk.item": "{TRACK}", - "listingPage.listTracks.withSheetMusicFiles.title": "Tracks - with Sheet Music Files", - "listingPage.listTracks.withSheetMusicFiles.title.short": "...with Sheet Music Files", - "listingPage.listTracks.withSheetMusicFiles.chunk.title": "{ALBUM}", - "listingPage.listTracks.withSheetMusicFiles.chunk.title.withDate": "{ALBUM} ({DATE})", - "listingPage.listTracks.withSheetMusicFiles.chunk.item": "{TRACK}", - "listingPage.listTracks.withMidiProjectFiles.title": "Tracks - with MIDI & Project Files", - "listingPage.listTracks.withMidiProjectFiles.title.short": "...with MIDI & Project Files", - "listingPage.listTracks.withMidiProjectFiles.chunk.title": "{ALBUM}", - "listingPage.listTracks.withMidiProjectFiles.chunk.title.withDate": "{ALBUM} ({DATE})", - "listingPage.listTracks.withMidiProjectFiles.chunk.item": "{TRACK}", - "listingPage.listTags.byName.title": "Tags - by Name", - "listingPage.listTags.byName.title.short": "...by Name", - "listingPage.listTags.byName.item": "{TAG} ({TIMES_USED})", - "listingPage.listTags.byUses.title": "Tags - by Uses", - "listingPage.listTags.byUses.title.short": "...by Uses", - "listingPage.listTags.byUses.item": "{TAG} ({TIMES_USED})", - "listingPage.other.allSheetMusic.title": "All Sheet Music", - "listingPage.other.allSheetMusic.title.short": "All Sheet Music", - "listingPage.other.allSheetMusic.albumFiles": "Album sheet music:", - "listingPage.other.allSheetMusic.file": "{TITLE}", - "listingPage.other.allSheetMusic.file.withMultipleFiles": "{TITLE} ({FILES})", - "listingPage.other.allMidiProjectFiles.title": "All MIDI/Project Files", - "listingPage.other.allMidiProjectFiles.title.short": "All MIDI/Project Files", - "listingPage.other.allMidiProjectFiles.albumFiles": "Album MIDI/project files:", - "listingPage.other.allMidiProjectFiles.file": "{TITLE}", - "listingPage.other.allMidiProjectFiles.file.withMultipleFiles": "{TITLE} ({FILES})", - "listingPage.other.allAdditionalFiles.title": "All Additional Files", - "listingPage.other.allAdditionalFiles.title.short": "All Additional Files", - "listingPage.other.allAdditionalFiles.albumFiles": "Album additional files:", - "listingPage.other.allAdditionalFiles.file": "{TITLE}", - "listingPage.other.allAdditionalFiles.file.withMultipleFiles": "{TITLE} ({FILES})", - "listingPage.other.randomPages.title": "Random Pages", - "listingPage.other.randomPages.title.short": "Random Pages", - "listingPage.other.randomPages.chooseLinkLine": "{FROM_PART} {BROWSER_SUPPORT_PART}", - "listingPage.other.randomPages.chooseLinkLine.fromPart.dividedByGroups": "Choose a link to go to a random page in that group or album!", - "listingPage.other.randomPages.chooseLinkLine.fromPart.notDividedByGroups": "Choose a link to go to a random page in that album!", - "listingPage.other.randomPages.chooseLinkLine.browserSupportPart": "If your browser doesn't support relatively modern JavaScript or you've disabled it, these links won't work - sorry.", - "listingPage.other.randomPages.dataLoadingLine": "(Data files are downloading in the background! Please wait for data to load.)", - "listingPage.other.randomPages.dataLoadedLine": "(Data files have finished being downloaded. The links should work!)", - "listingPage.other.randomPages.misc": "Miscellaneous:", - "listingPage.other.randomPages.misc.randomArtist": "Random Artist", - "listingPage.other.randomPages.misc.atLeastTwoContributions": "at least 2 contributions", - "listingPage.other.randomPages.misc.randomAlbumWholeSite": "Random Album (whole site)", - "listingPage.other.randomPages.misc.randomTrackWholeSite": "Random Track (whole site)", - "listingPage.other.randomPages.fromAlbum": "From an album:", - "listingPage.other.randomPages.fromGroup": "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})", - "listingPage.other.randomPages.fromGroup.randomAlbum": "Random Album", - "listingPage.other.randomPages.fromGroup.randomTrack": "Random Track", - "listingPage.other.randomPages.album": "{ALBUM}", - "listingPage.misc.trackContributors": "Track Contributors", - "listingPage.misc.artContributors": "Art Contributors", - "listingPage.misc.flashContributors": "Flash & Game Contributors", - "listingPage.misc.artAndFlashContributors": "Art & Flash Contributors", - "newsIndex.title": "News", - "newsIndex.entry.viewRest": "(View rest of entry!)", - "newsEntryPage.title": "{ENTRY}", - "newsEntryPage.published": "(Published {DATE}.)", - "redirectPage.title": "Moved to {TITLE}", - "redirectPage.infoLine": "This page has been moved to {TARGET}.", - "tagPage.title": "{TAG}", - "tagPage.infoLine": "Appears in {COVER_ARTS}.", - "tagPage.nav.tag": "Tag: {TAG}", - "trackPage.title": "{TRACK}", - "trackPage.referenceList.fandom": "Fandom:", - "trackPage.referenceList.official": "Official:", - "trackPage.nav.track": "{TRACK}", - "trackPage.nav.track.withNumber": "{NUMBER}. {TRACK}", - "trackPage.nav.random": "Random", - "trackPage.socialEmbed.heading": "{ALBUM}", - "trackPage.socialEmbed.title": "{TRACK}", - "trackPage.socialEmbed.body.withArtists.withCoverArtists": "By {ARTISTS}; art by {COVER_ARTISTS}.", - "trackPage.socialEmbed.body.withArtists": "By {ARTISTS}.", - "trackPage.socialEmbed.body.withCoverArtists": "Art by {COVER_ARTISTS}." -} diff --git a/src/strings-default.yaml b/src/strings-default.yaml new file mode 100644 index 00000000..6e975de7 --- /dev/null +++ b/src/strings-default.yaml @@ -0,0 +1,1690 @@ +meta.languageCode: en +meta.languageName: English + +# +# count: +# +# This covers pretty much any time that a specific number of things +# is represented! It's sectioned... like an alignment chart meme... +# +# First counting specific wiki objects, then more abstract stuff, +# and finally numerical representations of kinds of quantities that +# aren't really "counting", per se. +# +# These must be filled out according to the Unicode Common Locale +# Data Repository (Unicode CLDR). Check out info on their site: +# https://cldr.unicode.org +# +# Specifically, you'll want to look into the Plural Rules for your +# language. Here's a summary on what those even are: +# https://cldr.unicode.org/index/cldr-spec/plural-rules +# +# CLDR's charts are available online! This should bring you to the +# most recent table of plural rules: +# https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html +# +# Counting is generally done with the "Type: cardinal" section on +# that chart - for example, if the chart lists "one", "many", and +# "other" under the cardinal plural rules for your language, then +# your job is to fill in the correct pluralizations of the specific +# term for each of those. +# +# If you adore technical details or want to better understand the +# "Rules" column, you'll want to check out the syntax outline here: +# https://unicode.org/reports/tr35/tr35-numbers.html#Language_Plural_Rules +# +count: + + # Count things and objects + + additionalFiles: + _: "{FILES}" + withUnit: + zero: "" + one: "{FILES} file" + two: "" + few: "" + many: "" + other: "{FILES} files" + + albums: + _: "{ALBUMS}" + withUnit: + zero: "" + one: "{ALBUMS} album" + two: "" + few: "" + many: "" + other: "{ALBUMS} albums" + + artworks: + _: "{ARTWORKS}" + withUnit: + zero: "" + one: "{ARTWORKS} artwork" + two: "" + few: "" + many: "" + other: "{ARTWORKS} artworks" + + commentaryEntries: + _: "{ENTRIES}" + withUnit: + zero: "" + one: "{ENTRIES} entry" + two: "" + few: "" + many: "" + other: "{ENTRIES} entries" + + contributions: + _: "{CONTRIBUTIONS}" + withUnit: + zero: "" + one: "{CONTRIBUTIONS} contribution" + two: "" + few: "" + many: "" + other: "{CONTRIBUTIONS} contributions" + + coverArts: + _: "{COVER_ARTS}" + withUnit: + zero: "" + one: "{COVER_ARTS} cover art" + two: "" + few: "" + many: "" + other: "{COVER_ARTS} cover arts" + + flashes: + _: "{FLASHES}" + withUnit: + zero: "" + one: "{FLASHES} flashes & games" + two: "" + few: "" + many: "" + other: "{FLASHES} flashes & games" + + tracks: + _: "{TRACKS}" + withUnit: + zero: "" + one: "{TRACKS} track" + two: "" + few: "" + many: "" + other: "{TRACKS} tracks" + + # Count more abstract stuff + + days: + _: "{DAYS}" + withUnit: + zero: "" + one: "{DAYS} day" + two: "" + few: "" + many: "" + other: "{DAYS} days" + + timesReferenced: + _: "{TIMES_REFERENCED}" + withUnit: + zero: "" + one: "{TIMES_REFERENCED} time referenced" + two: "" + few: "" + many: "" + other: "{TIMES_REFERENCED} times referenced" + + timesUsed: + _: "{TIMES_USED}" + withUnit: + zero: "" + one: "used {TIMES_USED} time" + two: "" + few: "" + many: "" + other: "used {TIMES_USED} times" + + words: + _: "{WORDS}" + thousand: "{WORDS}k" + withUnit: + zero: "" + one: "{WORDS} word" + two: "" + few: "" + many: "" + other: "{WORDS} words" + + # Numerical things that aren't exactly counting, per se + + duration: + missing: "_:__" + approximate: "~{DURATION}" + hours: + _: "{HOURS}:{MINUTES}:{SECONDS}" + withUnit: "{HOURS}:{MINUTES}:{SECONDS} hours" + minutes: + _: "{MINUTES}:{SECONDS}" + withUnit: "{MINUTES}:{SECONDS} minutes" + + fileSize: + terabytes: "{TERABYTES} TB" + gigabytes: "{GIGABYTES} GB" + megabytes: "{MEGABYTES} MB" + kilobytes: "{KILOBYTES} kB" + bytes: "{BYTES} bytes" + + # Indexes in a list + # These use "Type: ordinal" on CLDR's chart of plural rules. + + index: + zero: "" + one: "{INDEX}st" + two: "{INDEX}nd" + few: "{INDEX}rd" + many: "" + other: "{INDEX}th" + +# +# releaseInfo: +# +# This covers a lot of generic strings - they're used in a variety +# of contexts. They're sorted below with descriptions first, then +# actions further down. +# +releaseInfo: + + # Descriptions + + by: "By {ARTISTS}." + from: "From {ALBUM}." + + coverArtBy: "Cover art by {ARTISTS}." + wallpaperArtBy: "Wallpaper art by {ARTISTS}." + bannerArtBy: "Banner art by {ARTISTS}." + + released: "Released {DATE}." + artReleased: "Art released {DATE}." + addedToWiki: "Added to wiki {DATE}." + + duration: "Duration: {DURATION}." + + contributors: "Contributors:" + lyrics: "Lyrics:" + note: "Context notes:" + + alsoReleasedAs: + _: "Also released as:" + item: "{TRACK} (on {ALBUM})" + + tracksReferenced: "Tracks that {TRACK} references:" + tracksThatReference: "Tracks that reference {TRACK}:" + tracksSampled: "Tracks that {TRACK} samples:" + tracksThatSample: "Tracks that sample {TRACK}:" + + flashesThatFeature: + _: "Flashes & games that feature {TRACK}:" + item: + _: "{FLASH}" + asDifferentRelease: "{FLASH} (as {TRACK})" + + tracksFeatured: "Tracks that {FLASH} features:" + + artTags: + _: "Tags:" + inline: "Tags: {TAGS}" + + # Actions + + viewCommentary: + _: "View {LINK}!" + link: "commentary page" + + viewGallery: + _: "View {LINK}!" + link: "gallery page" + + viewGalleryOrCommentary: + _: "View {GALLERY} or {COMMENTARY}!" + gallery: "gallery page" + commentary: "commentary page" + + viewOriginalFile: + _: "View {LINK}." + withSize: "View {LINK} ({SIZE})." + link: "original file" + sizeWarning: >- + (Heads up! If you're on a mobile plan, this is a large download.) + + listenOn: + _: "Listen on {LINKS}." + noLinks: >- + This wiki doesn't have any listening links for {NAME}. + + visitOn: "Visit on {LINKS}." + playOn: "Play on {LINKS}." + + readCommentary: + _: "Read {LINK}." + link: "artist commentary" + + artistCommentary: + _: "Artist commentary:" + seeOriginalRelease: "See {ORIGINAL}!" + + additionalFiles: + heading: "View or download {ADDITIONAL_FILES}:" + + entry: + _: "{TITLE}" + withDescription: "{TITLE}: {DESCRIPTION}" + + file: + _: "{FILE}" + withSize: "{FILE} ({SIZE})" + + shortcut: + _: "View {ANCHOR_LINK}: {TITLES}" + anchorLink: "additional files" + + sheetMusicFiles: + heading: "Print or download sheet music files:" + + shortcut: + _: "Download {LINK}." + link: "sheet music files" + + midiProjectFiles: + heading: "Download MIDI/project files:" + + shortcut: + _: "Download {LINK}." + link: "MIDI/project files" + +# +# trackList: +# +# A list of tracks! These are used pretty much across the wiki. +# Track lists can be split into sections, groups, or not split at +# all. "Track sections" are divisions in the list which suit the +# album as a whole, like if it has multiple discs or bonus tracks. +# "Groups" are actual group objects (see ex. groupInfoPage). +# +trackList: + section: + withDuration: "{SECTION} ({DURATION}):" + + group: + _: "From {GROUP}:" + fromOther: "From somewhere else:" + + item: + withDuration: "({DURATION}) {TRACK}" + withDuration.withArtists: "({DURATION}) {TRACK} {BY}" + withArtists: "{TRACK} {BY}" + withArtists.by: "by {ARTISTS}" + rerelease: "{TRACK} (re-release)" + +# +# misc: +# +# These cover a whole host of general things across the wiki, and +# aren't specially organized. Sorry! See each entry for details. +# +misc: + + # alt: + # Fallback text for the alt text of images and artworks - these + # are read aloud by screen readers. + + alt: + albumCover: "album cover" + albumBanner: "album banner" + trackCover: "track cover" + artistAvatar: "artist avatar" + flashArt: "flash art" + + # artistLink: + # Artist links have special accents which are made conditionally + # present in a variety of places across the wiki. + + artistLink: + _: "{ARTIST}" + + # Contribution to a track, artwork, or other thing. + withContribution: "{ARTIST} ({CONTRIB})" + + # External links to visit the artist's own websites or profiles. + withExternalLinks: "{ARTIST} ({LINKS})" + + # Combination of above. + withContribution.withExternalLinks: "{ARTIST} ({CONTRIB}) ({LINKS})" + + # chronology: + # + # "Chronology links" are a section that appear in the nav bar for + # most things with individual contributors across the wiki! These + # allow for quick navigation between older and newer releases of + # a given artist, or seeing at a glance how many contributions an + # artist made before the one you're currently viewing. + # + # Chronology information is described for each artist and shows + # the kind of thing which is being contributed to, since all of + # the entries are displayed together in one list. + # + + chronology: + + # seeArtistPages: + # If the thing you're viewing has a lot of contributors, their + # chronology info will be exempt from the nav bar, which'll + # show this message instead. + + seeArtistPages: "(See artist pages for chronology info!)" + + # withNavigation: + # Navigation refers to previous/next links. + + withNavigation: "{HEADING} ({NAVIGATION})" + + heading: + coverArt: "{INDEX} cover art by {ARTIST}" + flash: "{INDEX} flash/game by {ARTIST}" + track: "{INDEX} track by {ARTIST}" + + # external: + # Links which will generally bring you somewhere off of the wiki. + # The list of sites is hard-coded into the wiki software, so it + # may be out of date or missing ones that are relevant to another + # wiki - sorry! + + external: + + # domain: + # General domain when one the URL doesn't match one of the + # sites below. + + domain: "External ({DOMAIN})" + + # local: + # Files which are locally available on the wiki (under its media + # directory). + + local: "Wiki Archive (local upload)" + + deviantart: "DeviantArt" + instagram: "Instagram" + newgrounds: "Newgrounds" + patreon: "Patreon" + poetryFoundation: "Poetry Foundation" + soundcloud: "SoundCloud" + spotify: "Spotify" + tumblr: "Tumblr" + twitter: "Twitter" + wikipedia: "Wikipedia" + + bandcamp: + _: "Bandcamp" + domain: "Bandcamp ({DOMAIN})" + + mastodon: + _: "Mastodon" + domain: "Mastodon ({DOMAIN})" + + youtube: + _: "YouTube" + playlist: "YouTube (playlist)" + fullAlbum: "YouTube (full album)" + + flash: + bgreco: "{LINK} (HQ Audio)" + youtube: "{LINK} (on any device)" + homestuck: + page: "{LINK} (page {PAGE})" + secret: "{LINK} (secret page)" + + # missingImage: + # Fallback text displayed in an image when it's sourced to a file + # that isn't available under the wiki's media directory. While it + # shouldn't display on a correct build of the site, it may be + # displayed when working on data locally (for example adding a + # track before you've brought in its cover art). + + missingImage: "(This image file is missing)" + + # misingLinkContent: + # Generic fallback when a link is completely missing its content. + # This is only to make those links visible in the first place - + # it should never appear on the website and is only intended for + # debugging. + + missingLinkContent: "(Missing link content)" + + # nav: + # Generic navigational elements. These usually only appear in the + # wiki's nav bar, at the top of the page. + + nav: + previous: "Previous" + next: "Next" + info: "Info" + gallery: "Gallery" + + # pageTitle: + # Title set under the page's <title> HTML element, which is + # displayed in the browser tab bar, bookmarks list, etc. + + pageTitle: + _: "{TITLE}" + withWikiName: "{TITLE} | {WIKI_NAME}" + + # skippers: + # + # These are navigational links that only show up when you're + # navigating the wiki using the Tab key (or some other method of + # "tabbing" between links and interactive elements). They move + # the browser's nav focus to the selected element when pressed. + # + # There are a lot of definitions here, and they're mostly shown + # conditionally, based on the elements that are actually apparent + # on the current page. + # + + skippers: + skipTo: "Skip to:" + + content: "Content" + header: "Header" + footer: "Footer" + + sidebar: + _: "Sidebar" + left: "Sidebar (left)" + right: "Sidebar (right)" + + # Displayed on artist info page. + + tracks: "Tracks" + artworks: "Artworks" + flashes: "Flashes & Games" + + # Displayed on track and flash info pages. + + contributors: "Contributors" + + # Displayed on track info page. + + references: "References..." + referencedBy: "Referenced by..." + samples: "Samples..." + sampledBy: "Sampled by..." + features: "Features..." + featuredIn: "Featured in..." + + lyrics: "Lyrics" + + sheetMusicFiles: "Sheet music files" + midiProjectFiles: "MIDI/project files" + + # Displayed on track and album info pages. + + commentary: "Commentary" + + artistCommentary: "Commentary" + additionalFiles: "Additional files" + + # socialEmbed: + # Social embeds describe how the page should be represented on + # social platforms, chat messaging apps, and so on. + + socialEmbed: + heading: "{WIKI_NAME} | {HEADING}" + + # jumpTo: + # Generic action displayed at the top of some longer pages, for + # quickly scrolling down to a particular section. + + jumpTo: + _: "Jump to:" + withLinks: "Jump to: {LINKS}." + + # contentWarnings: + # Displayed for some artworks, informing of possibly sensitive + # content and giving the viewer a chance to consider before + # clicking through. + + contentWarnings: + _: "cw: {WARNINGS}" + reveal: "click to show" + + # albumGrid: + # Generic strings for various sorts of gallery grids, displayed + # on the homepage, album galleries, artist artwork galleries, and + # so on. These get the name of the thing being represented and, + # often, a bit of text providing pertinent extra details about + # that thing. + + albumGrid: + noCoverArt: "{ALBUM}" + + details: + _: "({TRACKS}, {TIME})" + coverArtists: "(Illust. {ARTISTS})" + otherCoverArtists: "(With {ARTISTS})" + + albumGalleryGrid: + noCoverArt: "{NAME}" + + # uiLanguage: + # Displayed in the footer, for switching between languages. + + uiLanguage: "UI Language: {LANGUAGES}" + +# +# homepage: +# This is the main index and home for the whole wiki! There isn't +# much for strings here as the layout is very customizable and +# includes mostly wiki-provided content. +# +homepage: + title: "{TITLE}" + + # news: + # If the wiki has news entries enabled, then there's a box in the + # homepage's sidebar (beneath custom sidebar content, if any) + # which displays the bodies the latest few entries up to a split. + + news: + title: "News" + + entry: + viewRest: "(View rest of entry!)" + +# +# albumSidebar: +# This sidebar is displayed on both the album and track info pages! +# It displays the groups that the album is from (each getting its +# own box on the album page, all conjoined in one box on the track +# page) and the list of tracks in the album, which can be sectioned +# similarly to normal track lists, but displays the range of tracks +# in each section rather than the section's duration. +# +albumSidebar: + trackList: + item: "{TRACK}" + + # fallbackSectionName: + # If an album's track list isn't sectioned, the track list here + # will still have all the tracks grouped under a list that can + # be toggled open and closed. This controls how that list gets + # titled. + + fallbackSectionName: "Track list" + + # group: + # "Group" is a misnomer - these are track sections. Some albums + # don't use track numbers at all, and for these, the default + # string will be used instead of group.withRange. + + group: + _: "{GROUP}" + withRange: "{GROUP} ({RANGE})" + + # groupBox: + # This is the box for groups. Apart from the next and previous + # links, it also gets "visit on" and the group's descripton + # (up to a split). + + groupBox: + title: "{GROUP}" + next: "Next: {ALBUM}" + previous: "Previous: {ALBUM}" + +# +# albumPage: +# +# Albums group together tracks and provide quick access to each of +# their pages, have release data (and sometimes credits) that are +# generally inherited by the album's tracks plus commentary and +# other goodies of their own, and are generally the main object on +# the wiki! +# +# Most of the strings on the album info page are tracked under +# releaseInfo, so there isn't a lot here. +# +albumPage: + title: "{ALBUM}" + + nav: + album: "{ALBUM}" + randomTrack: "Random Track" + gallery: "Gallery" + commentary: "Commentary" + + socialEmbed: + heading: "{GROUP}" + title: "{ALBUM}" + + # body: + # These permutations are a bit awkward. "Tracks" is a counted + # string, ex. "63 tracks". + + body: + withDuration: "{DURATION}." + withTracks: "{TRACKS}." + withReleaseDate: Released {DATE}. + withDuration.withTracks: "{DURATION}, {TRACKS}." + withDuration.withReleaseDate: "{DURATION}. Released {DATE}." + withTracks.withReleaseDate: "{TRACKS}. Released {DATE}." + withDuration.withTracks.withReleaseDate: "{DURATION}, {TRACKS}. Released {DATE}." + +# +# albumGalleryPage: +# Album galleries provide an alternative way to navigate the album, +# and put all its artwork - including for each track - into the +# spotlight. Apart from the main gallery grid (which usually lists +# each artwork's illustrators), this page also has a quick stats +# line about the album, and may display a message about all of the +# artworks if one applies. +# +albumGalleryPage: + title: "{ALBUM} - Gallery" + + # statsLine: + # Most albums have release dates, but not all. These strings + # react accordingly. + + statsLine: >- + {TRACKS} totaling {DURATION}. + + statsLine.withDate: >- + {TRACKS} totaling {DURATION}. Released {DATE}. + + # coverArtistsLine: + # This is displayed if every track (which has artwork at all) + # has the same illustration credits. + + coverArtistsLine: >- + All track artwork by {ARTISTS}. + + # noTrackArtworksLine: + # This is displayed if none of the tracks on the album have any + # artwork at all. Generally, this means the album gallery won't + # be linked from the album's other pages, but it is possible to + # end up on "stub galleries" using nav links on another gallery. + + noTrackArtworksLine: >- + This album doesn't have any track artwork. + +# +# albumCommentaryPage: +# The album commentary page is a more minimal layout that brings +# the commentary for the album, and each of its tracks, to the +# front. It's basically inspired by reading in a library, or by +# following along with an album's booklet or liner notes while +# playing it back on a treasured dinky CD player late at night. +# +albumCommentaryPage: + title: "{ALBUM} - Commentary" + + nav: + album: "Album: {ALBUM}" + + infoLine: >- + {WORDS} across {ENTRIES}. + + entry: + title: + albumCommentary: "Album commentary" + trackCommentary: "{TRACK}" + +# +# artistInfoPage: +# The artist info page is an artist's main home on the wiki, and +# automatically includes a full list of all the things they've +# contributed to and been credited on. It's split into a section +# for each of the kinds of things the artist is credited for, +# including tracks, artworks, flashes/games, and commentary. +# +artistPage: + title: "{ARTIST}" + + nav: + artist: "Artist: {ARTIST}" + + creditList: + + # album: + # Tracks are chunked by albums, as long as the tracks are all + # of the same date (if applicable). + + album: + _: "{ALBUM}" + withDate: "{ALBUM} ({DATE})" + withDuration: "{ALBUM} ({DURATION})" + withDate.withDuration: "{ALBUM} ({DATE}; {DURATION})" + + # flashAct: + # Flashes are chunked by flash act, though a single flash act + # might be split into multiple chunks if it spans a long range + # and the artist contributed to a flash from some other act + # between. A date range will be shown if an act has at least + # two differently dated flashes. + + flashAct: + _: "{ACT}" + withDate: "{ACT} ({DATE})" + withDateRange: "{ACT} ({DATE_RANGE})" + + # entry: + # This section covers strings for all kinds of individual + # things which an artist has contributed to, and refers to the + # items in each of the chunks described above. + + entry: + + # withContribution: + # The specific contribution that an artist made to a given + # thing may be described with a word or two, and that's shown + # in the list. + + withContribution: "{ENTRY} ({CONTRIBUTION})" + + # withArtists: + # This lists co-artists or co-contributors, depending on how + # the artist themselves was credited. + + withArtists: "{ENTRY} (with {ARTISTS})" + + withArtists.withContribution: "{ENTRY} ({CONTRIBUTION}; with {ARTISTS})" + + # rerelease: + # Tracks which aren't the original release don't display co- + # artists or contributors, and get dimmed a little compared + # to original release track entries. + + rerelease: "{ENTRY} (re-release)" + + # track: + # The string without duration is used in both the artist's + # track credits list as well as their commentary list. + + track: + _: "{TRACK}" + withDuration: "({DURATION}) {TRACK}" + + # album: + # The artist info page doesn't display if the artist is + # musically credited outright for the album as a whole, + # opting to show each of the tracks from that album instead. + # But other parts belonging specifically to the album have + # credits too, and those entreis get the strings below. + + album: + coverArt: "(cover art)" + wallpaperArt: "(wallpaper art)" + bannerArt: "(banner art)" + commentary: "(album commentary)" + + flash: + _: "{FLASH}" + + # contributedDurationLine: + # This is shown at the top of the artist's track list, provided + # any of their tracks have durations at all. + + contributedDurationLine: >- + {ARTIST} has contributed {DURATION} of music shared on this wiki. + + # groupContributions: + # This is a special "chunk" shown at the top of an artist's + # track and artwork lists. It lists which groups an artist has + # contributed the most (and least) to, and is interactive - + # it can be sorted by count or, for tracks, by duration. + + groupContributions: + title: + music: "Contributed music to groups:" + artworks: "Contributed artworks to groups:" + withSortButton: "{TITLE} ({SORT})" + + sorting: + count: "Sorting by count." + duration: "Sorting by duration." + + item: + countAccent: "({COUNT})" + durationAccent: "({DURATION})" + countDurationAccent: "({COUNT} — {DURATION})" + durationCountAccent: "({DURATION} — {COUNT})" + + trackList: + title: "Tracks" + + artList: + title: "Artworks" + + flashList: + title: "Flashes & Games" + + commentaryList: + title: "Commentary" + + # viewArtGallery: + # This is shown twice on the page - once at almost the very top + # of the page, just beneath visiting links, and once above the + # list of credited artworks, where it gets the longer + # orBrowseList form. + + viewArtGallery: + _: "View {LINK}!" + orBrowseList: "View {LINK}! Or browse the list:" + link: "art gallery" + +# +# artistGalleryPage: +# The artist gallery page shows a neat grid of all of the album and +# track artworks an artist has contributed to! Co-illustrators are +# also displayed when applicable. +# +artistGalleryPage: + title: "{ARTIST} - Gallery" + + infoLine: >- + Contributed to {COVER_ARTS}. + +# +# commentaryIndex: +# The commentary index page shows a summary of all the commentary +# across the entire wiki, with a list linking to each album's +# dedicated commentary page. +# +commentaryIndex: + title: "Commentary" + + infoLine: >- + {WORDS} across {ENTRIES}, in all. + + albumList: + title: "Choose an album:" + item: "{ALBUM} ({WORDS} across {ENTRIES})" + +# +# flashIndex: +# The flash index page shows a very long grid including every flash +# on the wiki, sectioned with big headings for each act. It's also +# got jump links at the top to skip to a specific overarching +# section ("side") of flash acts. +# +flashIndex: + title: "Flashes & Games" + +# +# flashSidebar: +# The flash sidebar is used on both the flash info and flash act +# gallery pages, and has two boxes - one showing all the flashes in +# the current flash act, and one showing all the flash acts on the +# wiki, sectioned by "side". +# +flashSidebar: + flashList: + + # These two strings are the default ones used when a flash act + # doesn't specify a custom phrasing. + flashesInThisAct: "Flashes in this act" + entriesInThisSection: "Entries in this section" + +# +# flashPage: +# The flash info page shows release information, links to check the +# flash out, and lists of contributors and featured tracks. Most of +# those strings are under releaseInfo, so there aren't a lot of +# strings here. +# +flashPage: + title: "{FLASH}" + + nav: + flash: "{FLASH}" + +# +# groupSidebar: +# The group sidebar is used on both the group info and group +# gallery pages, and is formed of just one box, showing all the +# groups on the wiki, sectioned by "category". +# +groupSidebar: + title: "Groups" + + groupList: + category: "{CATEGORY}" + item: "{GROUP}" + +# +# groupPage: +# This section represents strings common to multiple group pages. +# +groupPage: + nav: + group: "Group: {GROUP}" + +# +# groupInfoPage: +# The group info page shows visiting links, the group's full +# description, and a list of albums from the group. +# +groupInfoPage: + title: "{GROUP}" + + viewAlbumGallery: + _: "View {LINK}! Or browse the list:" + link: "album gallery" + + # albumList: + # Many albums are present under multiple groups, and these get an + # accent indicating what other group is highest on the album's + # list of groups. + + albumList: + title: "Albums" + + item: + _: "({YEAR}) {ALBUM}" + withoutYear: "{ALBUM}" + withAccent: "{ITEM} {ACCENT}" + otherGroupAccent: "(from {GROUP})" + +# +# groupGalleryPage: +# The group gallery page shows a grid of all the albums from that +# group, each including the number of tracks and duration, as well +# as a stats line for the group as a whole, and a neat carousel, if +# pre-configured! +# +groupGalleryPage: + title: "{GROUP} - Gallery" + + infoLine: >- + {TRACKS} across {ALBUMS}, totaling {TIME}. + +# +# listingIndex: +# The listing index page shows all available listings on the wiki, +# and a very exciting stats line for the wiki as a whole. +# +listingIndex: + title: "Listings" + + infoLine: >- + {WIKI}: {TRACKS} across {ALBUMS}, totaling {DURATION}. + + exploreList: >- + Feel free to explore any of the listings linked below and in the sidebar! + +# +# listingPage: +# +# There are a lot of listings! Each is automatically generated and +# sorts or organizes the data on the wiki in some way that provides +# useful or interesting information. Most listings work primarily +# with one kind of data and are sectioned accordingly, for example +# "listAlbums.byDuration" or "listTracks.byDate". +# +# There are also some miscellaneous strings here, most of which are +# common to a variety of listings, and are often navigational in +# nature. +# +listingPage: + + # target: + # Just the names for each of the sections - each chunk on the + # listing index (and in the sidebar) gets is titled with one of + # these. + + target: + album: "Albums" + artist: "Artists" + group: "Groups" + track: "Tracks" + tag: "Tags" + other: "Other" + + # misc: + # Common, generic terminology across multiple listings. + + misc: + trackContributors: "Track Contributors" + artContributors: "Art Contributors" + flashContributors: "Flash & Game Contributors" + artAndFlashContributors: "Art & Flash Contributors" + + # listingFor: + # Displays quick links to navigate to other listings for the + # current target. + + listingsFor: "Listings for {TARGET}: {LISTINGS}" + + # seeAlso: + # Displays directly related listings, which might be from other + # targets besides the current one. + + seeAlso: "Also check out: {LISTINGS}" + + # skipToSection: + # Some listings which use a chunked-list layout also show links + # to scroll down to each of these sections - this is the title + # for the list of those links. + + skipToSection: "Skip to a section:" + + listAlbums: + + # listAlbums.byName: + # Lists albums alphabetically without sorting or chunking by + # any other criteria. Also displays the number of tracks for + # each album. + + byName: + title: "Albums - by Name" + title.short: "...by Name" + item: "{ALBUM} ({TRACKS})" + + # listAlbums.byTracks: + # Lists albums by number of tracks, most to least, or by name + # alphabetically, if two albums have the same track count. + # Albums without any tracks are totally excluded. + + byTracks: + title: "Albums - by Tracks" + title.short: "...by Tracks" + item: "{ALBUM} ({TRACKS})" + + # listAlbums.byDuration: + # Lists albums by total duration of all tracks, longest to + # shortest, falling back to an alphabetical sort if two albums + # are the same duration. Albums with zero duration are totally + # excluded. + + byDuration: + title: "Albums - by Duration" + title.short: "...by Duration" + item: "{ALBUM} ({DURATION})" + + # listAlbums.byDate: + # Lists albums by release date, oldest to newest, falling back + # to an alphabetical sort if two albums were released on the + # same date. Dateless albums are totally excluded. + + byDate: + title: "Albums - by Date" + title.short: "...by Date" + item: "{ALBUM} ({DATE})" + + # listAlbums.byDateAdded: + # Lists albums by the date they were added to the wiki, oldest + # to newest, and chunks these by date, since albums are usually + # added in bunches at a time. The albums in each chunk are + # sorted alphabetically, and albums which are missing the + # "Date Added" field are totally excluded. + + byDateAdded: + title: "Albums - by Date Added to Wiki" + title.short: "...by Date Added to Wiki" + chunk: + title: "{DATE}" + item: "{ALBUM}" + + listArtists: + + # listArtists.byName: + # Lists artists alphabetically without sorting or chunking by + # any other criteria. Also displays the number of contributions + # from each artist. + + byName: + title: "Artists - by Name" + title.short: "...by Name" + item: "{ARTIST} ({CONTRIBUTIONS})" + + # listArtists.byContribs: + # Lists artists by number of contributions, most to least, + # with separate lists for contributions to tracks, artworks, + # and flashes. Falls back alphabetically if two artists have + # the same number of contributions. Artists who aren't credited + # for any contributions to each of these categories are + # excluded from the respective list. + + byContribs: + title: "Artists - by Contributions" + title.short: "...by Contributions" + chunk: + item: "{ARTIST} ({CONTRIBUTIONS})" + title: + trackContributors: "Contributed tracks:" + artContributors: "Contributed artworks:" + flashContributors: "Contributed to flashes & games:" + + # listArtists.byCommentary: + # Lists artists by number of commentary entries, most to least, + # falling back to an alphabetical sort if two artists have the + # same count. Artists who don't have any commentary entries are + # totally excluded. + + byCommentary: + title: "Artists - by Commentary Entries" + title.short: "...by Commentary Entries" + item: "{ARTIST} ({ENTRIES})" + + # listArtists.byDuration: + # Lists artists by total duration of the tracks which they're + # credited on (as either artist or contributor), longest sum to + # shortest, falling back alphabetically if two artists have + # the same duration. Artists who haven't contributed any music, + # or whose tracks all lack durations, are totally excluded. + + byDuration: + title: "Artists - by Duration" + title.short: "...by Duration" + item: "{ARTIST} ({DURATION})" + + # listArtists.byGroup: + # Lists artists who have contributed to each of the main groups + # of a wiki (its "Divide Track Lists By Groups" field), sorted + # alphabetically. Artists who aren't credited for contributions + # under each of the groups are exlcuded from the respective + # list. + + byGroup: + title: "Artists - by Group" + title.short: "...by Group" + item: "{ARTIST} ({CONTRIBUTIONS})" + chunk: + title: "Contributed to {GROUP}:" + item: "{ARTIST} ({CONTRIBUTIONS})" + + # listArtists.byLatest: + # Lists artists by the date of their latest contribution + # overall, and chunks artists together by the album or flash + # which that contribution belongs to. Within albums, each + # artist is accented with the kind of contribution they made - + # tracks, artworks, or both - and sorted so those of the same + # sort of contribution are bunched together, then by name. + # Artists who aren't credited for any dated contributions are + # included at the bottom under a separate chunk. + + byLatest: + title: "Artists - by Latest Contribution" + title.short: "...by Latest Contribution" + chunk: + title: + album: "{ALBUM} ({DATE})" + flash: "{FLASH} ({DATE})" + dateless: "These artists' contributions aren't dated:" + item: + _: "{ARTIST}" + tracks: "{ARTIST} (tracks)" + tracksAndArt: "{ARTIST} (tracks, art)" + art: "{ARTIST} (art)" + + listGroups: + + # listGroups.byName: + # Lists groups alphabetically without sorting or chunking by + # any other criteria. Also displays a link to each group's + # gallery page. + + byName: + title: "Groups - by Name" + title.short: "...by Name" + item: "{GROUP} ({GALLERY})" + item.gallery: "Gallery" + + # listGroups.byCategory: + # Lists groups directly reflecting the way they're sorted in + # the wiki's groups.yaml data file, with no automatic sorting, + # chunked (as sectioned in groups.yaml) by category. Also shows + # a link to each group's gallery page. + + byCategory: + title: "Groups - by Category" + title.short: "...by Category" + + chunk: + title: "{CATEGORY}" + item: "{GROUP} ({GALLERY})" + item.gallery: "Gallery" + + # listGroups.byAlbums: + # Lists groups by number of belonging albums, most to least, + # falling back alphabetically if two groups have the same + # number of albums. Groups without any albums are totally + # excluded. + + byAlbums: + title: "Groups - by Albums" + title.short: "...by Albums" + item: "{GROUP} ({ALBUMS})" + + # listGroups.byTracks: + # Lists groups by number of tracks under each group's albums, + # most to least, falling back to an alphabetical sort if two + # groups have the same track counts. Groups without any tracks + # are totally excluded. + + byTracks: + title: "Groups - by Tracks" + title.short: "...by Tracks" + item: "{GROUP} ({TRACKS})" + + # listGroups.byDuration: + # Lists groups by sum of durations of all the tracks under each + # of the group's albums, longest to shortest, falling back to + # an alphabetical sort if two groups have the same duration. + # Groups whose total duration is zero are totally excluded. + + byDuration: + title: "Groups - by Duration" + title.short: "...by Duration" + item: "{GROUP} ({DURATION})" + + # listGroups.byLatest: + # List groups by release date of each group's most recent + # album, most recent to longest ago, falling back to sorting + # alphabetically if two groups' latest albums were released + # on the same date. Groups which don't have any albums, or + # whose albums are all dateless, are totally excluded. + + byLatest: + title: "Groups - by Latest Album" + title.short: "...by Latest Album" + item: "{GROUP} ({DATE})" + + listTracks: + + # listTracks.byName: + # List tracks alphabetically without sorting or chunking by + # any other criteria. + + byName: + title: "Tracks - by Name" + title.short: "...by Name" + item: "{TRACK}" + + # listTracks.byAlbum: + # List tracks chunked by the album they're from, retaining the + # position each track occupies in its album, and sorting albums + # from oldest to newest (or alphabetically, if two albums were + # released on the same date). Dateless albums are included at + # the bottom of the list. Custom "Date First Released" fields + # on individual tracks are totally ignored. + + byAlbum: + title: "Tracks - by Album" + title.short: "...by Album" + + chunk: + title: "{ALBUM}" + item: "{TRACK}" + + # listTracks.byDate: + # List tracks according to their own release dates, which may + # differ from that of the album via the "Date First Released" + # field, oldest to newest, and chunked by album when multiple + # tracks from one album were released on the same date. Track + # order within a given album is preserved where possible. + # Dateless albums are excluded, except for contained tracks + # which have custom "Date First Released" fields. + + byDate: + title: "Tracks - by Date" + title.short: "...by Date" + + chunk: + title: "{ALBUM} ({DATE})" + item: "{TRACK}" + item.rerelease: "{TRACK} (re-release)" + + # listTracks.byDuration: + # List tracks by duration, longest to shortest, falling back to + # an alphabetical sort if two tracks have the same duration. + # Tracks which don't have any duration are totally excluded. + + byDuration: + title: "Tracks - by Duration" + title.short: "...by Duration" + item: "{TRACK} ({DURATION})" + + # listTracks.byDurationInAlbum: + # List tracks chunked by the album they're from, then sorted + # by duration, longest to shortest; albums are sorted by date, + # oldest to newest, and both sorts fall back alphabetically. + # Dateless albums are included at the bottom of the list. + + byDurationInAlbum: + title: "Tracks - by Duration (in Album)" + title.short: "...by Duration (in Album)" + + chunk: + title: "{ALBUM}" + item: "{TRACK} ({DURATION})" + + # listTracks.byTimesReferenced: + # List tracks by how many other tracks' reference lists each + # appears in, most times referenced to fewest, falling back + # alphabetically if two tracks have been referenced the same + # number of times. Tracks that aren't referenced by any other + # tracks are totally excluded from the list. + + byTimesReferenced: + title: "Tracks - by Times Referenced" + title.short: "...by Times Referenced" + item: "{TRACK} ({TIMES_REFERENCED})" + + # listTracks.inFlashes.byAlbum: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # and display the list of flashes that eack track is featured + # in. Tracks which aren't featured in any flashes are totally + # excluded from the list. + + inFlashes.byAlbum: + title: "Tracks - in Flashes & Games (by Album)" + title.short: "...in Flashes & Games (by Album)" + + chunk: + title: "{ALBUM}" + item: "{TRACK} (in {FLASHES})" + + # listTracks.inFlashes.byFlash: + # List tracks, chunked by flash (which are sorted by date, + # retaining their positions in a common act where applicable, + # or else by the two acts' names) and sorted according to the + # featured list of the flash, and display a link to the album + # each track is contained in. Tracks which aren't featured in + # any flashes are totally excluded from the list. + + inFlashes.byFlash: + title: "Tracks - in Flashes & Games (by Flash)" + title.short: "...in Flashes & Games (by Flash)" + + chunk: + title: "{FLASH}" + item: "{TRACK} (from {ALBUM})" + + # listTracks.withLyrics: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # displaying only tracks which have lyrics. The chunk titles + # also display the date each album was released, and tracks' + # own custom "Date First Released" fields are totally ignored. + + withLyrics: + title: "Tracks - with Lyrics" + title.short: "...with Lyrics" + + chunk: + title: "{ALBUM}" + title.withDate: "{ALBUM} ({DATE})" + item: "{TRACK}" + + # listTracks.withSheetMusicFiles: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # displaying only tracks which have sheet music files. The + # chunk titles also display the date each album was released, + # and tracks' own custom "Date First Released" fields are + # totally ignored. + + withSheetMusicFiles: + title: "Tracks - with Sheet Music Files" + title.short: "...with Sheet Music Files" + + chunk: + title: "{ALBUM}" + title.withDate: "{ALBUM} ({DATE})" + item: "{TRACK}" + + # listTracks.withMidiProjectFiles: + # List tracks, chunked by album (which are sorted by date, + # falling back alphabetically) and in their usual track order, + # displaying only tracks which have MIDI & project files. The + # chunk titles also display the date each album was released, + # and tracks' own custom "Date First Released" fields are + # totally ignored. + + withMidiProjectFiles: + title: "Tracks - with MIDI & Project Files" + title.short: "...with MIDI & Project Files" + + chunk: + title: "{ALBUM}" + title.withDate: "{ALBUM} ({DATE})" + item: "{TRACK}" + + listTags: + + # listTags.byName: + # List art tags alphabetically without sorting or chunking by + # any other criteria. Also displays the number of times each + # art tag has been featured. + + byName: + title: "Tags - by Name" + title.short: "...by Name" + item: "{TAG} ({TIMES_USED})" + + # listTags.byUses: + # List art tags by number of times used, falling back to an + # alphabetical sort if two art tags have been featured the same + # number of times. Art tags which haven't haven't been featured + # at all yet are totally excluded from the list. + + byUses: + title: "Tags - by Uses" + title.short: "...by Uses" + item: "{TAG} ({TIMES_USED})" + + other: + + # other.allSheetMusic: + # List all sheet music files, sectioned by album (which are + # sorted by date, falling back alphabetically) and then by + # track (which retain album ordering). If one "file" entry + # contains multiple files, then it's displayed as an expandable + # list, collapsed by default, accented with the number of + # downloadable files. + + allSheetMusic: + title: "All Sheet Music" + title.short: "All Sheet Music" + albumFiles: "Album sheet music:" + + file: + _: "{TITLE}" + withMultipleFiles: "{TITLE} ({FILES})" + + # other.midiProjectFiles: + # Same as other.allSheetMusic, but for MIDI & project files. + + allMidiProjectFiles: + title: "All MIDI/Project Files" + title.short: "All MIDI/Project Files" + albumFiles: "Album MIDI/project files:" + + file: + _: "{TITLE}" + withMultipleFiles: "{TITLE} ({FILES})" + + # other.additionalFiles: + # Same as other.allSheetMusic, but for additional files. + + allAdditionalFiles: + title: "All Additional Files" + title.short: "All Additional Files" + albumFiles: "Album additional files:" + + file: + _: "{TITLE}" + withMultipleFiles: "{TITLE} ({FILES})" + + # other.randomPages: + # Special listing which shows a bunch of buttons that each + # link to a random page on the wiki under a particular scope. + + randomPages: + title: "Random Pages" + title.short: "Random Pages" + + # chooseLinkLine: + # Introductory line explaining the links on this listing. + + chooseLinkLine: + _: "{FROM_PART} {BROWSER_SUPPORT_PART}" + + fromPart: + dividedByGroups: >- + Choose a link to go to a random page in that group or album! + notDividedByGroups: >- + Choose a link to go to a random page in that album! + + browserSupportPart: >- + If your browser doesn't support relatively modern JavaScript + or you've disabled it, these links won't work - sorry. + + # dataLoadingLine, dataLoadedLine: + # Since the links on this page depend on access to a fairly + # large data file that is downloaded separately and in the + # background, these messages indicate the status of that + # download and whether or not the links will work yet. + + dataLoadingLine: >- + (Data files are downloading in the background! Please wait for data to load.) + + dataLoadedLine: >- + (Data files have finished being downloaded. The links should work!) + + # misc: + # The first chunk in the list includes general links which + # bring you to some random page across the whole site! + + misc: + _: "Miscellaneous:" + randomArtist: "Random Artist" + atLeastTwoContributions: "at least 2 contributions" + randomAlbumWholeSite: "Random Album (whole site)" + randomTrackWholeSite: "Random Track (whole site)" + + # fromGroup: + # Provided the wiki has "Divide Track Lists By Groups" set, + # the remaining chunks are one for each of those groups, each + # with a list of links for albums from the group that bring + # you to a random track from the chosen album. + + fromGroup: + _: "From {GROUP}: ({RANDOM_ALBUM}, {RANDOM_TRACK})" + randomAlbum: "Random Album" + randomTrack: "Random Track" + + # fromAlbum: + # If the wiki doesn't have "Divide Track Lists By Groups", + # all albums across the wiki are grouped in one list. + # (There aren't "random album" and "random track" links like + # for groups because those are already included at the top, + # under the "miscellaneous" chunk.) + + fromAlbum: "From an album:" + + # album: + # Album entries under each group. + + album: "{ALBUM}" + +# +# newsIndex: +# The news index page shows a list of every news entry on the wiki! +# (If it's got news entries enabled.) Each entry gets a stylized +# heading with its name of and date, sorted newest to oldest, as +# well as its body (up to a split) and a link to view the rest of +# the entry on its dedicated news entry page. +# +newsIndex: + title: "News" + + entry: + viewRest: "(View rest of entry!)" + +# +# newsEntryPage: +# The news entry page displays all the content of a news entry, +# as well as its date published, in one big list, and has nav links +# to go to the previous and next news entry. +# +newsEntryPage: + title: "{ENTRY}" + published: "(Published {DATE}.)" + +# +# redirectPage: +# Static "placeholder" pages when redirecting a visitor from one +# page to another - this generally happens automatically, before +# you have a chance to read the page, so content is concise. +# +redirectPage: + title: "Moved to {TITLE}" + + infoLine: >- + This page has been moved to {TARGET}. + +# +# tagPage: +# The tag gallery page displays all the artworks that a tag has +# been featured in, in one neat grid, with each artwork displaying +# its illustrators, as well as a short info line that indicates +# how many artworks the tag's part of. +# +tagPage: + title: "{TAG}" + + nav: + tag: "Tag: {TAG}" + + infoLine: >- + Appears in {COVER_ARTS}. + +# +# trackPage: +# +# The track info page is pretty much the most discrete and common +# chunk of information across the whole site, displaying info about +# the track like its release date, artists, cover illustrators, +# commentary, and more, as well as relational info, like the tracks +# it references and tracks which reference it, and flashes which +# it's been featured in. Tracks can also have extra related files, +# like sheet music and MIDI/project files. +# +# Most of the details about tracks use strings that are defined +# under releaseInfo, so this section is a little sparse. +# +trackPage: + title: "{TRACK}" + + nav: + random: "Random" + + track: + _: "{TRACK}" + withNumber: "{NUMBER}. {TRACK}" + + socialEmbed: + heading: "{ALBUM}" + title: "{TRACK}" + + body: + withArtists.withCoverArtists: "By {ARTISTS}; art by {COVER_ARTISTS}." + withArtists: "By {ARTISTS}." + withCoverArtists: "Art by {COVER_ARTISTS}." diff --git a/src/upd8.js b/src/upd8.js index 27445a8e..24d0b92b 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -39,7 +39,7 @@ import {fileURLToPath} from 'node:url'; import wrap from 'word-wrap'; import {displayCompositeCacheAnalysis} from '#composite'; -import {processLanguageFile} from '#language'; +import {processLanguageFile, watchLanguageFile} from '#language'; import {isMain, traverse} from '#node-utils'; import bootRepl from '#repl'; import {empty, showAggregate, withEntries} from '#sugar'; @@ -56,14 +56,14 @@ import { logError, parseOptions, progressCallAll, - progressPromiseAll, } from '#cli'; import genThumbs, { CACHE_FILE as thumbsCacheFile, - clearThumbs, defaultMagickThreads, + determineMediaCachePath, isThumb, + migrateThumbsIntoDedicatedCacheDirectory, verifyImagePaths, } from '#thumbs'; @@ -93,7 +93,7 @@ try { const BUILD_TIME = new Date(); -const DEFAULT_STRINGS_FILE = 'strings-default.json'; +export const DEFAULT_STRINGS_FILE = 'strings-default.yaml'; const STATUS_NOT_STARTED = `not started`; const STATUS_NOT_APPLICABLE = `not applicable`; @@ -113,6 +113,12 @@ async function main() { Error.stackTraceLimit = Infinity; stepStatusSummary = { + determineMediaCachePath: + {...defaultStepStatus, name: `determine media cache path`}, + + migrateThumbnails: + {...defaultStepStatus, name: `migrate thumbnails`}, + loadThumbnailCache: {...defaultStepStatus, name: `load thumbnail cache file`}, @@ -125,6 +131,9 @@ async function main() { linkWikiDataArrays: {...defaultStepStatus, name: `link wiki data arrays`}, + precacheCommonData: + {...defaultStepStatus, name: `precache common data`}, + filterDuplicateDirectories: {...defaultStepStatus, name: `filter duplicate directories`}, @@ -134,8 +143,8 @@ async function main() { sortWikiDataArrays: {...defaultStepStatus, name: `sort wiki data arrays`}, - precacheData: - {...defaultStepStatus, name: `precache data`}, + precacheAllData: + {...defaultStepStatus, name: `precache nearly all data`}, loadInternalDefaultLanguage: {...defaultStepStatus, name: `load internal default language`}, @@ -146,6 +155,9 @@ async function main() { initializeDefaultLanguage: {...defaultStepStatus, name: `initialize default language`}, + verifyImagePaths: + {...defaultStepStatus, name: `verify missing/misplaced image paths`}, + preloadFileSizes: {...defaultStepStatus, name: `preload file sizes`}, @@ -215,6 +227,11 @@ async function main() { type: 'value', }, + 'media-cache-path': { + help: `Specify path to media cache directory, including automatically generated thumbnails\n\nThis usually doesn't need to be provided, and will be inferred by adding "-cache" to the end of the media directory`, + type: 'value', + }, + // String files! For the most part, this is used for translating the // site to different languages, though you can also customize strings // for your own 8uild of the site if you'd like. Files here should all @@ -240,6 +257,11 @@ async function main() { type: 'flag', }, + 'skip-reference-validation': { + help: `Skips checking and reporting reference errors, which speeds up the build but may silently allow erroneous data to pass through`, + type: 'flag', + }, + // Thum8nail gener8tion is *usually* something you want, 8ut it can 8e // kinda a pain to run every time, since it does necessit8te reading // every media file at run time. Pass this to skip it. @@ -255,8 +277,8 @@ async function main() { type: 'flag', }, - 'clear-thumbs': { - help: `Clear the thumbnail cache and remove generated thumbnail files from media directory\n\n(This skips building. Run again without --clear-thumbs to build the site.)`, + 'migrate-thumbs': { + help: `Transfer automatically generated thumbnail files out of an existing media directory and into the easier-to-manage media-cache directory`, type: 'flag', }, @@ -268,6 +290,18 @@ async function main() { type: 'flag', }, + 'no-input': { + help: `Don't wait on input from stdin - assume the device is headless`, + type: 'flag', + }, + + 'no-language-reloading': { + help: `Don't reload language files while the build is running\n\nApplied by default for --static-build`, + type: 'flag', + }, + + 'no-language-reload': {alias: 'no-language-reloading'}, + // Want sweet, sweet trace8ack info in aggreg8te error messages? This // will print all the juicy details (or at least the first relevant // line) right to your output, 8ut also pro8a8ly give you a headache @@ -312,18 +346,18 @@ async function main() { type: 'flag', }, - // Compute ALL data properties before moving on to building. This ensures - // writes are processed at a stable speed (since they don't have to perform - // any additional data computation besides what is done for the page - // itself), but it'll also take a long while for the initial caching to - // complete. This shouldn't have any overall difference on efficiency as - // it's the same amount of processing being done regardless; the option is - // mostly present for optimization testing (i.e. if you want to focus on - // efficiency of data calculation or write generation separately instead of - // mixed together). - 'precache-data': { - help: `Compute all runtime-cached values for wiki data objects before proceeding to site build (optimizes rate of content generation/serving, but waits a lot longer before build actually starts, and may compute data which is never required for this build)`, - type: 'flag', + 'precache-mode': { + help: + `Change the way certain runtime-computed values are preemptively evaluated and cached\n\n` + + `common: Preemptively compute certain properties which are needed for basic data loading and site generation\n\n` + + `all: Compute every visible data property, optimizing rate of content generation, but causing a long stall before the build actually starts\n\n` + + `none: Don't preemptively compute any values - strictly the most efficient, but may result in unpredictably "lopsided" performance for individual steps of loading data and building the site\n\n` + + `Defaults to 'common'`, + type: 'value', + validate(value) { + if (['common', 'all', 'none'].includes(value)) return true; + return 'common, all, or none'; + }, }, }; @@ -429,10 +463,13 @@ async function main() { const mediaPath = cliOptions['media-path'] || process.env.HSMUSIC_MEDIA; const langPath = cliOptions['lang-path'] || process.env.HSMUSIC_LANG; // Can 8e left unset! + const migrateThumbs = cliOptions['migrate-thumbs'] ?? false; const skipThumbs = cliOptions['skip-thumbs'] ?? false; const thumbsOnly = cliOptions['thumbs-only'] ?? false; - const clearThumbsFlag = cliOptions['clear-thumbs'] ?? false; + const skipReferenceValidation = cliOptions['skip-reference-validation'] ?? false; const noBuild = cliOptions['no-build'] ?? false; + const noInput = cliOptions['no-input'] ?? false; + let noLanguageReloading = cliOptions['no-language-reloading'] ?? null; // Will get default later. showStepStatusSummary = cliOptions['show-step-summary'] ?? false; @@ -441,7 +478,7 @@ async function main() { const showAggregateTraces = cliOptions['show-traces'] ?? false; - const precacheData = cliOptions['precache-data'] ?? false; + const precacheMode = cliOptions['precache-mode'] ?? 'common'; const showInvalidPropertyAccesses = cliOptions['show-invalid-property-accesses'] ?? false; // Makes writing nicer on the CPU and file I/O parts of the OS, with a @@ -473,46 +510,204 @@ async function main() { }); } - const niceShowAggregate = (error, ...opts) => { - showAggregate(error, { - showTraces: showAggregateTraces, - pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), - ...opts, + // Prepare not-applicable steps before anything else. + + if (skipThumbs) { + Object.assign(stepStatusSummary.generateThumbnails, { + status: STATUS_NOT_APPLICABLE, + annotation: `provided --skip-thumbs`, }); - }; + } else { + Object.assign(stepStatusSummary.loadThumbnailCache, { + status: STATUS_NOT_APPLICABLE, + annotation: `using cache from thumbnail generation`, + }); + } + + if (!migrateThumbs) { + Object.assign(stepStatusSummary.migrateThumbnails, { + status: STATUS_NOT_APPLICABLE, + annotation: `--migrate-thumbs not provided`, + }); + } + + if (skipReferenceValidation) { + logWarn`Skipping reference validation. If any reference errors are present`; + logWarn`in data, they will be silently passed along to the build.`; + + Object.assign(stepStatusSummary.filterReferenceErrors, { + status: STATUS_NOT_APPLICABLE, + annotation: `--skip-reference-validation provided`, + }); + } + + switch (precacheMode) { + case 'common': + Object.assign(stepStatusSummary.precacheAllData, { + status: STATUS_NOT_APPLICABLE, + annotation: `--precache-mode is common, not all`, + }); + + break; + + case 'all': + Object.assign(stepStatusSummary.precacheCommonData, { + status: STATUS_NOT_APPLICABLE, + annotation: `--precache-mode is all, not common`, + }); + + break; + + case 'none': + Object.assign(stepStatusSummary.precacheCommonData, { + status: STATUS_NOT_APPLICABLE, + annotation: `--precache-mode is none`, + }); + + Object.assign(stepStatusSummary.precacheAllData, { + status: STATUS_NOT_APPLICABLE, + annotation: `--precache-mode is none`, + }); + + break; + } + + if (!langPath) { + Object.assign(stepStatusSummary.loadLanguageFiles, { + status: STATUS_NOT_APPLICABLE, + annotation: `neither --lang-path nor HSMUSIC_LANG provided`, + }); + } + + if (noBuild) { + logInfo`Won't generate any site or page files this run (--no-build passed).`; + + Object.assign(stepStatusSummary.performBuild, { + status: STATUS_NOT_APPLICABLE, + annotation: `--no-build provided`, + }); + } else if (usingDefaultBuildMode) { + logInfo`No build mode specified, will use default: ${selectedBuildModeFlag}`; + } else { + logInfo`Will use specified build mode: ${selectedBuildModeFlag}`; + } + + noLanguageReloading ??= + ({ + 'static-build': true, + 'live-dev-server': false, + })[selectedBuildModeFlag]; if (skipThumbs && thumbsOnly) { logInfo`Well, you've put yourself rather between a roc and a hard place, hmmmm?`; return false; } - if (clearThumbsFlag) { - const result = await clearThumbs(mediaPath, {queueSize}); - if (result.success) { - logInfo`All done! Remove ${'--clear-thumbs'} to run the next build.`; - if (skipThumbs) { - logInfo`And don't forget to remove ${'--skip-thumbs'} too, eh?`; - } + Object.assign(stepStatusSummary.determineMediaCachePath, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + const {mediaCachePath, annotation: mediaCachePathAnnotation} = + await determineMediaCachePath({ + mediaPath, + providedMediaCachePath: + cliOptions['media-cache-path'] || process.env.HSMUSIC_MEDIA_CACHE, + disallowDoubling: + migrateThumbs, + }); + + if (!mediaCachePath) { + logError`Couldn't determine a media cache path. (${mediaCachePathAnnotation})`; + + switch (mediaCachePathAnnotation) { + case 'inferred path does not have cache': + logError`If you're certain this is the right path, you can provide it via`; + logError`${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}, and it should work.`; + break; + + case 'inferred path not readable': + logError`The folder couldn't be read, which usually indicates`; + logError`a permissions error. Try to resolve this, or provide`; + logError`a new path with ${'--media-cache-path'} or ${'HSMUSIC_MEDIA_CACHE'}.`; + break; + + case 'media path not provided': /* unreachable */ + logError`It seems a ${'--media-path'} (or ${'HSMUSIC_MEDIA'}) wasn't provided.`; + logError`Make sure one of these is actually pointing to a path that exists.`; + break; } + + Object.assign(stepStatusSummary.determineMediaCachePath, { + status: STATUS_FATAL_ERROR, + annotation: mediaCachePathAnnotation, + timeEnd: Date.now(), + }); + + return false; + } + + logInfo`Using media cache at: ${mediaCachePath} (${mediaCachePathAnnotation})`; + + Object.assign(stepStatusSummary.determineMediaCachePath, { + status: STATUS_DONE_CLEAN, + annotation: mediaCachePathAnnotation, + timeEnd: Date.now(), + }); + + if (migrateThumbs) { + Object.assign(stepStatusSummary.migrateThumbnails, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + const result = await migrateThumbsIntoDedicatedCacheDirectory({ + mediaPath, + mediaCachePath, + queueSize, + }); + + if (result.succses) { + Object.assign(stepStatusSummary.migrateThumbnails, { + status: STATUS_FATAL_ERROR, + annotation: `view log for details`, + timeEnd: Date.now(), + }); + + return false; + } + + logInfo`Good to go! Run hsmusic again without ${'--migrate-thumbs'} to start`; + logInfo`using the migrated media cache.`; + + Object.assign(stepStatusSummary.migrateThumbnails, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); + return true; } + const niceShowAggregate = (error, ...opts) => { + showAggregate(error, { + showTraces: showAggregateTraces, + pathToFileURL: (f) => path.relative(__dirname, fileURLToPath(f)), + ...opts, + }); + }; + let thumbsCache; if (skipThumbs) { - Object.assign(stepStatusSummary.generateThumbnails, { - status: STATUS_NOT_APPLICABLE, - annotation: `provided --skip-thumbs`, + Object.assign(stepStatusSummary.loadThumbnailCache, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), }); - stepStatusSummary.loadThumbnailCache.status = STATUS_STARTED_NOT_DONE; - - const thumbsCachePath = path.join(mediaPath, thumbsCacheFile); + const thumbsCachePath = path.join(mediaCachePath, thumbsCacheFile); try { thumbsCache = JSON.parse(await readFile(thumbsCachePath)); - logInfo`Thumbnail cache file successfully read.`; - stepStatusSummary.loadThumbnailCache.status = STATUS_DONE_CLEAN; } catch (error) { if (error.code === 'ENOENT') { logError`The thumbnail cache doesn't exist, and it's necessary to build` @@ -523,6 +718,7 @@ async function main() { Object.assign(stepStatusSummary.loadThumbnailCache, { status: STATUS_FATAL_ERROR, annotation: `cache does not exist`, + timeEnd: Date.now(), }); return false; @@ -539,24 +735,33 @@ async function main() { Object.assign(stepStatusSummary.loadThumbnailCache, { status: STATUS_FATAL_ERROR, annotation: `cache malformed or unreadable`, + timeEnd: Date.now(), }); return false; } } - logInfo`Skipping thumbnail generation.`; - } else { + logInfo`Thumbnail cache file successfully read.`; + Object.assign(stepStatusSummary.loadThumbnailCache, { - status: STATUS_NOT_APPLICABLE, - annotation: `using cache from thumbnail generation`, + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), }); - stepStatusSummary.generateThumbnails.status = STATUS_STARTED_NOT_DONE; + logInfo`Skipping thumbnail generation.`; + } else { + Object.assign(stepStatusSummary.generateThumbnails, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); logInfo`Begin thumbnail generation... -----+`; - const result = await genThumbs(mediaPath, { + const result = await genThumbs({ + mediaPath, + mediaCachePath, + queueSize, magickThreads, quiet: !thumbsOnly, @@ -568,12 +773,16 @@ async function main() { Object.assign(stepStatusSummary.generateThumbnails, { status: STATUS_FATAL_ERROR, annotation: `view log for details`, + timeEnd: Date.now(), }); return false; } - stepStatusSummary.generateThumbnails.status = STATUS_DONE_CLEAN; + Object.assign(stepStatusSummary.generateThumbnails, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); if (thumbsOnly) { return true; @@ -582,19 +791,14 @@ async function main() { thumbsCache = result.cache; } - if (noBuild) { - logInfo`Not generating any site or page files this run (--no-build passed).`; - } else if (usingDefaultBuildMode) { - logInfo`No build mode specified, using default: ${selectedBuildModeFlag}`; - } else { - logInfo`Using specified build mode: ${selectedBuildModeFlag}`; - } - if (showInvalidPropertyAccesses) { CacheableObject.DEBUG_SLOW_TRACK_INVALID_PROPERTIES = true; } - stepStatusSummary.loadDataFiles.status = STATUS_STARTED_NOT_DONE; + Object.assign(stepStatusSummary.loadDataFiles, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); let processDataAggregate, wikiDataResult; @@ -610,6 +814,7 @@ async function main() { Object.assign(stepStatusSummary.loadDataFiles, { status: STATUS_FATAL_ERROR, annotation: `javascript error - view log for details`, + timeEnd: Date.now(), }); return false; @@ -654,15 +859,7 @@ async function main() { } catch (error) { niceShowAggregate(error); logWarn`The above errors were detected while processing data files.`; - logWarn`If the remaining valid data is complete enough, the wiki will`; - logWarn`still build - but all errored data will be skipped.`; - logWarn`(Resolve errors for more complete output!)`; errorless = false; - - Object.assign(stepStatusSummary.loadDataFiles, { - status: STATUS_HAS_WARNINGS, - annotation: `view log for details`, - }); } if (!wikiData.wikiInfo) { @@ -671,6 +868,7 @@ async function main() { Object.assign(stepStatusSummary.loadDataFiles, { status: STATUS_FATAL_ERROR, annotation: `wiki info object not available`, + timeEnd: Date.now(), }); return false; @@ -678,7 +876,21 @@ async function main() { if (errorless) { logInfo`All data files processed without any errors - nice!`; - stepStatusSummary.loadDataFiles.status = STATUS_DONE_CLEAN; + + Object.assign(stepStatusSummary.loadDataFiles, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); + } else { + logWarn`If the remaining valid data is complete enough, the wiki will`; + logWarn`still build - but all errored data will be skipped.`; + logWarn`(Resolve errors for more complete output!)`; + + Object.assign(stepStatusSummary.loadDataFiles, { + status: STATUS_HAS_WARNINGS, + annotation: `view log for details`, + timeEnd: Date.now(), + }); } } @@ -686,16 +898,93 @@ async function main() { // complete, so properties (like dates!) are inherited where that's // appropriate. - stepStatusSummary.linkWikiDataArrays.status = STATUS_STARTED_NOT_DONE; + Object.assign(stepStatusSummary.linkWikiDataArrays, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); linkWikiDataArrays(wikiData); - stepStatusSummary.linkWikiDataArrays.status = STATUS_DONE_CLEAN; + Object.assign(stepStatusSummary.linkWikiDataArrays, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); + + if (precacheMode === 'common') { + Object.assign(stepStatusSummary.precacheCommonData, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + const commonDataMap = { + albumData: new Set([ + // Needed for sorting + 'date', 'tracks', + // Needed for computing page paths + 'commentary', + ]), + + artTagData: new Set([ + // Needed for computing page paths + 'isContentWarning', + ]), + + artistAliasData: new Set([ + // Needed for computing page paths + 'aliasedArtist', + ]), + + flashData: new Set([ + // Needed for sorting + 'act', 'date', + ]), + + flashActData: new Set([ + // Needed for sorting + 'flashes', + ]), + + groupData: new Set([ + // Needed for computing page paths + 'albums', + ]), + + listingSpec: new Set([ + // Needed for computing page paths + 'contentFunction', 'featureFlag', + ]), + + trackData: new Set([ + // Needed for sorting + 'album', 'date', + // Needed for computing page paths + 'commentary', + ]), + }; + + for (const [wikiDataKey, properties] of Object.entries(commonDataMap)) { + const thingData = wikiData[wikiDataKey]; + const allProperties = new Set(['name', 'directory', ...properties]); + for (const thing of thingData) { + for (const property of allProperties) { + void thing[property]; + } + } + } + + Object.assign(stepStatusSummary.precacheCommonData, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); + } // Filter out any things with duplicate directories throughout the data, // warning about them too. - stepStatusSummary.filterDuplicateDirectories.status = STATUS_STARTED_NOT_DONE; + Object.assign(stepStatusSummary.filterDuplicateDirectories, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); const filterDuplicateDirectoriesAggregate = filterDuplicateDirectories(wikiData); @@ -703,7 +992,11 @@ async function main() { try { filterDuplicateDirectoriesAggregate.close(); logInfo`No duplicate directories found - nice!`; - stepStatusSummary.filterDuplicateDirectories.status = STATUS_DONE_CLEAN; + + Object.assign(stepStatusSummary.filterDuplicateDirectories, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); } catch (aggregate) { niceShowAggregate(aggregate); @@ -715,6 +1008,7 @@ async function main() { Object.assign(stepStatusSummary.filterDuplicateDirectories, { status: STATUS_FATAL_ERROR, annotation: `duplicate directories found`, + timeEnd: Date.now(), }); return false; @@ -723,38 +1017,58 @@ async function main() { // Filter out any reference errors throughout the data, warning about them // too. - stepStatusSummary.filterReferenceErrors.status = STATUS_STARTED_NOT_DONE; + if (!skipReferenceValidation) { + Object.assign(stepStatusSummary.filterReferenceErrors, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + const filterReferenceErrorsAggregate = filterReferenceErrors(wikiData); - const filterReferenceErrorsAggregate = filterReferenceErrors(wikiData); + try { + filterReferenceErrorsAggregate.close(); - try { - filterReferenceErrorsAggregate.close(); - logInfo`All references validated without any errors - nice!`; - stepStatusSummary.filterReferenceErrors.status = STATUS_DONE_CLEAN; - } catch (error) { - niceShowAggregate(error); + logInfo`All references validated without any errors - nice!`; - logWarn`The above errors were detected while validating references in data files.`; - logWarn`The wiki will still build, but these connections between data objects`; - logWarn`will be completely skipped. Resolve the errors for more complete output.`; + Object.assign(stepStatusSummary.filterReferenceErrors, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); + } catch (error) { + niceShowAggregate(error); - Object.assign(stepStatusSummary.filterReferenceErrors, { - status: STATUS_HAS_WARNINGS, - annotation: `view log for details`, - }); + logWarn`The above errors were detected while validating references in data files.`; + logWarn`The wiki will still build, but these connections between data objects`; + logWarn`will be completely skipped. Resolve the errors for more complete output.`; + + Object.assign(stepStatusSummary.filterReferenceErrors, { + status: STATUS_HAS_WARNINGS, + annotation: `view log for details`, + timeEnd: Date.now(), + }); + } } // Sort data arrays so that they're all in order! This may use properties // which are only available after the initial linking. - stepStatusSummary.sortWikiDataArrays.status = STATUS_STARTED_NOT_DONE; + Object.assign(stepStatusSummary.sortWikiDataArrays, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); sortWikiDataArrays(wikiData); - stepStatusSummary.sortWikiDataArrays.status = STATUS_DONE_CLEAN; + Object.assign(stepStatusSummary.sortWikiDataArrays, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); - if (precacheData) { - stepStatusSummary.precacheData.status = STATUS_STARTED_NOT_DONE; + if (precacheMode === 'all') { + Object.assign(stepStatusSummary.precacheAllData, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); // TODO: Aggregate errors here, instead of just throwing. progressCallAll('Caching all data values', Object.entries(wikiData) @@ -768,148 +1082,373 @@ async function main() { .flatMap(([_key, things]) => things) .map(thing => () => CacheableObject.cacheAllExposedProperties(thing))); - stepStatusSummary.precacheData.status = STATUS_DONE_CLEAN; - } else { - Object.assign(stepStatusSummary.precacheData, { - status: STATUS_NOT_APPLICABLE, - annotation: `--precache-data not provided`, + Object.assign(stepStatusSummary.precacheAllData, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), }); } if (noBuild) { - Object.assign(stepStatusSummary.performBuild, { - status: STATUS_NOT_APPLICABLE, - annotation: `--no-build provided`, - }); - displayCompositeCacheAnalysis(); - if (precacheData) { + if (precacheMode === 'all') { return true; } } + Object.assign(stepStatusSummary.loadInternalDefaultLanguage, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + let internalDefaultLanguage; + let internalDefaultLanguageWatcher; - try { - internalDefaultLanguage = - await processLanguageFile(path.join(__dirname, DEFAULT_STRINGS_FILE)); + const internalDefaultStringsFile = path.join(__dirname, DEFAULT_STRINGS_FILE); - stepStatusSummary.loadInternalDefaultLanguage.status = STATUS_DONE_CLEAN; - } catch (error) { - console.error(error); + let errorLoadingInternalDefaultLanguage = false; + if (noLanguageReloading) { + internalDefaultLanguageWatcher = null; + + try { + internalDefaultLanguage = await processLanguageFile(internalDefaultStringsFile); + } catch (error) { + niceShowAggregate(error); + errorLoadingInternalDefaultLanguage = true; + } + } else { + internalDefaultLanguageWatcher = watchLanguageFile(internalDefaultStringsFile); + + try { + await new Promise((resolve, reject) => { + const watcher = internalDefaultLanguageWatcher; + + const onReady = () => { + watcher.removeListener('ready', onReady); + watcher.removeListener('error', onError); + resolve(); + }; + + const onError = error => { + watcher.removeListener('ready', onReady); + watcher.removeListener('error', onError); + watcher.close(); + reject(error); + }; + + watcher.on('ready', onReady); + watcher.on('error', onError); + }); + + internalDefaultLanguage = internalDefaultLanguageWatcher.language; + } catch (_error) { + // No need to display the error here - it's already printed by + // watchLanguageFile. + errorLoadingInternalDefaultLanguage = true; + } + } + + if (errorLoadingInternalDefaultLanguage) { logError`There was an error reading the internal language file.`; fileIssue(); Object.assign(stepStatusSummary.loadInternalDefaultLanguage, { status: STATUS_FATAL_ERROR, annotation: `see log for details`, + timeEnd: Date.now(), }); return false; } + if (!noLanguageReloading) { + // Bypass node.js special-case handling for uncaught error events + internalDefaultLanguageWatcher.on('error', () => {}); + } + + Object.assign(stepStatusSummary.loadInternalDefaultLanguage, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); + + let customLanguageWatchers; let languages; if (langPath) { - stepStatusSummary.loadLanguageFiles.status = STATUS_STARTED_NOT_DONE; + Object.assign(stepStatusSummary.loadLanguageFiles, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); const languageDataFiles = await traverse(langPath, { filterFile: name => path.extname(name) === '.json', pathStyle: 'device', }); - let results; + let errorLoadingCustomLanguages = false; - // TODO: Aggregate errors (with Promise.allSettled). - try { - results = - await progressPromiseAll(`Reading & processing language files.`, - languageDataFiles.map((file) => processLanguageFile(file))); - } catch (error) { - console.error(error); + if (noLanguageReloading) { + languages = {}; + const results = + await Promise.allSettled( + languageDataFiles + .map(file => processLanguageFile(file))); + + for (const {status, value: language, reason: error} of results) { + if (status === 'rejected') { + errorLoadingCustomLanguages = true; + niceShowAggregate(error); + } else { + languages[language.code] = language; + } + } + } else watchCustomLanguages: { + customLanguageWatchers = + languageDataFiles.map(file => { + const watcher = watchLanguageFile(file); + + // Bypass node.js special-case handling for uncaught error events + watcher.on('error', () => {}); + + return watcher; + }); + + const waitingOnWatchers = new Set(customLanguageWatchers); + + const initialResults = + await Promise.allSettled( + customLanguageWatchers + .map(watcher => new Promise((resolve, reject) => { + const onReady = () => { + watcher.removeListener('ready', onReady); + watcher.removeListener('error', onError); + waitingOnWatchers.delete(watcher); + resolve(); + }; + + const onError = error => { + watcher.removeListener('ready', onReady); + watcher.removeListener('error', onError); + reject(error); + }; + + watcher.on('ready', onReady); + watcher.on('error', onError); + }))); + + if (initialResults.some(({status}) => status === 'rejected')) { + logWarn`There were errors loading custom languages from the language path`; + logWarn`provided: ${langPath}`; + + if (noInput) { + internalDefaultLanguageWatcher.close(); + + for (const watcher of Object.values(customLanguageWatchers)) { + watcher.close(); + } + + errorLoadingCustomLanguages = true; + break watchCustomLanguages; + } + + logWarn`The build should start automatically if you investigate these.`; + logWarn`Or, exit by pressing ^C here (control+C) and run again without`; + logWarn`providing ${'--lang-path'} (or ${'HSMUSIC_LANG'}) to build without custom`; + logWarn`languages.`; + + await new Promise(resolve => { + for (const watcher of waitingOnWatchers) { + watcher.once('ready', () => { + waitingOnWatchers.remove(watcher); + if (empty(waitingOnWatchers)) { + resolve(); + } + }); + } + }); + } + + languages = + Object.fromEntries( + customLanguageWatchers + .map(({language}) => [language.code, language])); + } + + if (errorLoadingCustomLanguages) { logError`Failed to load language files. Please investigate these, or don't provide`; logError`--lang-path (or HSMUSIC_LANG) and build again.`; Object.assign(stepStatusSummary.loadLanguageFiles, { status: STATUS_FATAL_ERROR, annotation: `see log for details`, + timeEnd: Date.now(), }); return false; } - languages = - Object.fromEntries( - results.map((language) => [language.code, language])); - - stepStatusSummary.loadLanguageFiles.status = STATUS_DONE_CLEAN; - } else { - languages = {}; - Object.assign(stepStatusSummary.loadLanguageFiles, { - status: STATUS_NOT_APPLICABLE, - annotation: `--lang-path and HSMUSIC_LANG not provided`, + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + annotation: + (noLanguageReloading + ? (selectedBuildModeFlag === 'static-build' + ? `loaded statically, default for --static-build` + : `loaded statically, --no-language-reloading provided`) + : `watching for changes`), }); + } else { + languages = {}; } - stepStatusSummary.initializeDefaultLanguage.status = STATUS_STARTED_NOT_DONE; + Object.assign(stepStatusSummary.initializeDefaultLanguage, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); - const customDefaultLanguage = - languages[wikiData.wikiInfo.defaultLanguage ?? internalDefaultLanguage.code]; let finalDefaultLanguage; + let finalDefaultLanguageWatcher; + let finalDefaultLanguageAnnotation; + + if (wikiData.wikiInfo.defaultLanguage) { + const customDefaultLanguage = languages[wikiData.wikiInfo.defaultLanguage]; + + if (!customDefaultLanguage) { + logError`Wiki info file specified default language is ${wikiData.wikiInfo.defaultLanguage}, but no such language file exists!`; + if (langPath) { + logError`Check if an appropriate file exists in ${langPath}?`; + } else { + logError`Be sure to specify ${'--lang-path'} or ${'HSMUSIC_LANG'} with the path to language files.`; + } + + Object.assign(stepStatusSummary.initializeDefaultLanguage, { + status: STATUS_FATAL_ERROR, + annotation: `wiki specifies default language whose file is not available`, + timeEnd: Date.now(), + }); + + return false; + } - if (customDefaultLanguage) { logInfo`Applying new default strings from custom ${customDefaultLanguage.code} language file.`; - customDefaultLanguage.inheritedStrings = internalDefaultLanguage.strings; + finalDefaultLanguage = customDefaultLanguage; + finalDefaultLanguageAnnotation = `using wiki-specified custom default language`; - Object.assign(stepStatusSummary.initializeDefaultLanguage, { - status: STATUS_DONE_CLEAN, - annotation: `using wiki-specified custom default language`, - }); - } else if (wikiData.wikiInfo.defaultLanguage) { - logError`Wiki info file specified default language is ${wikiData.wikiInfo.defaultLanguage}, but no such language file exists!`; - if (langPath) { - logError`Check if an appropriate file exists in ${langPath}?`; - } else { - logError`Be sure to specify ${'--lang-path'} or ${'HSMUSIC_LANG'} with the path to language files.`; + if (!noLanguageReloading) { + finalDefaultLanguageWatcher = + customLanguageWatchers + .find(({language}) => language === customDefaultLanguage); } + } else if (languages[internalDefaultLanguage.code]) { + const customDefaultLanguage = languages[internalDefaultLanguage.code]; - Object.assign(stepStatusSummary.initializeDefaultLanguage, { - status: STATUS_FATAL_ERROR, - annotation: `wiki specifies default language whose file is not available`, - }); + finalDefaultLanguage = customDefaultLanguage; + finalDefaultLanguageAnnotation = `using inferred custom default language`; - return false; + if (!noLanguageReloading) { + finalDefaultLanguageWatcher = + customLanguageWatchers + .find(({language}) => language === customDefaultLanguage); + } } else { languages[internalDefaultLanguage.code] = internalDefaultLanguage; + finalDefaultLanguage = internalDefaultLanguage; - stepStatusSummary.initializeDefaultLanguage.status = STATUS_DONE_CLEAN; + finalDefaultLanguageAnnotation = `no custom default language specified`; - Object.assign(stepStatusSummary.initializeDefaultLanguage, { - status: STATUS_DONE_CLEAN, - annotation: `no custom default language specified`, - }); + if (!noLanguageReloading) { + finalDefaultLanguageWatcher = internalDefaultLanguageWatcher; + } } - for (const language of Object.values(languages)) { - if (language === finalDefaultLanguage) { - continue; + const inheritStringsFromInternalLanguage = () => { + // The custom default language, if set, will be the new one providing fallback + // strings for other languages. But on its own, it still might not be a complete + // list of strings - so it falls back to the internal default language, which + // won't otherwise be presented in the build. + if (finalDefaultLanguage === internalDefaultLanguage) return; + const {strings: inheritedStrings} = internalDefaultLanguage; + Object.assign(finalDefaultLanguage, {inheritedStrings}); + }; + + const inheritStringsFromDefaultLanguage = () => { + const {strings: inheritedStrings} = finalDefaultLanguage; + for (const language of Object.values(languages)) { + if (language === finalDefaultLanguage) continue; + Object.assign(language, {inheritedStrings}); } + }; - language.inheritedStrings = finalDefaultLanguage.strings; + if (finalDefaultLanguage !== internalDefaultLanguage) { + inheritStringsFromInternalLanguage(); + } + + inheritStringsFromDefaultLanguage(); + + if (!noLanguageReloading) { + if (finalDefaultLanguage !== internalDefaultLanguage) { + internalDefaultLanguageWatcher.on('update', () => { + inheritStringsFromInternalLanguage(); + inheritStringsFromDefaultLanguage(); + }); + } + + finalDefaultLanguageWatcher.on('update', () => { + inheritStringsFromDefaultLanguage(); + }); } logInfo`Loaded language strings: ${Object.keys(languages).join(', ')}`; + Object.assign(stepStatusSummary.initializeDefaultLanguage, { + status: STATUS_DONE_CLEAN, + annotation: finalDefaultLanguageAnnotation, + timeEnd: Date.now(), + }); + const urls = generateURLs(urlSpec); - const {missing: missingImagePaths} = + Object.assign(stepStatusSummary.verifyImagePaths, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + + const {missing: missingImagePaths, misplaced: misplacedImagePaths} = await verifyImagePaths(mediaPath, {urls, wikiData}); + if (empty(missingImagePaths) && empty(misplacedImagePaths)) { + Object.assign(stepStatusSummary.verifyImagePaths, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); + } else if (empty(missingImagePaths)) { + Object.assign(stepStatusSummary.verifyImagePaths, { + status: STATUS_HAS_WARNINGS, + annotation: `misplaced images detected`, + timeEnd: Date.now(), + }); + } else if (empty(misplacedImagePaths)) { + Object.assign(stepStatusSummary.verifyImagePaths, { + status: STATUS_HAS_WARNINGS, + annotation: `missing images detected`, + timeEnd: Date.now(), + }); + } else { + Object.assign(stepStatusSummary.verifyImagePaths, { + status: STATUS_HAS_WARNINGS, + annotation: `missing and misplaced images detected`, + timeEnd: Date.now(), + }); + } + + Object.assign(stepStatusSummary.preloadFileSizes, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); + const fileSizePreloader = new FileSizePreloader(); // File sizes of additional files need to be precalculated before we can @@ -973,8 +1512,6 @@ async function main() { const getSizeOfAdditionalFile = getSizeOfMediaFileHelper(additionalFilePaths); const getSizeOfImagePath = getSizeOfMediaFileHelper(imageFilePaths); - stepStatusSummary.preloadFileSizes.status = STATUS_STARTED_NOT_DONE; - logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`; fileSizePreloader.loadPaths(...additionalFilePaths.map((path) => path.device)); @@ -993,10 +1530,15 @@ async function main() { Object.assign(stepStatusSummary.preloadFileSizes, { status: STATUS_HAS_WARNINGS, annotation: `see log for details`, + timeEnd: Date.now(), }); } else { logInfo`Done preloading filesizes without any errors - nice!`; - stepStatusSummary.preloadFileSizes.status = STATUS_DONE_CLEAN; + + Object.assign(stepStatusSummary.preloadFileSizes, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); } if (noBuild) { @@ -1033,7 +1575,10 @@ async function main() { .map(line => ` ` + line) .join('\n') + `\n-->`; - stepStatusSummary.performBuild.status = STATUS_STARTED_NOT_DONE; + Object.assign(stepStatusSummary.performBuild, { + status: STATUS_STARTED_NOT_DONE, + timeStart: Date.now(), + }); let buildModeResult; @@ -1042,6 +1587,7 @@ async function main() { cliOptions, dataPath, mediaPath, + mediaCachePath, queueSize, srcRootPath: __dirname, @@ -1068,6 +1614,7 @@ async function main() { Object.assign(stepStatusSummary.performBuild, { status: STATUS_FATAL_ERROR, message: `javascript error - view log for details`, + timeEnd: Date.now(), }); return false; @@ -1076,13 +1623,17 @@ async function main() { if (buildModeResult !== true) { Object.assign(stepStatusSummary.performBuild, { status: STATUS_HAS_WARNINGS, - message: `may not have completed - view log for details`, + annotation: `may not have completed - view log for details`, + timeEnd: Date.now(), }); return false; } - stepStatusSummary.performBuild.status = STATUS_DONE_CLEAN; + Object.assign(stepStatusSummary.performBuild, { + status: STATUS_DONE_CLEAN, + timeEnd: Date.now(), + }); return true; } @@ -1093,17 +1644,43 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus (async () => { let result; + const totalTimeStart = Date.now(); + try { result = await main(); } catch (error) { if (error instanceof AggregateError) { showAggregate(error); + } else if (error.cause) { + console.error(error); + showAggregate(error); } else { console.error(error); } } + const totalTimeEnd = Date.now(); + + const formatDuration = timeDelta => { + const seconds = timeDelta / 1000; + + if (seconds > 90) { + const modSeconds = Math.floor(seconds % 60); + const minutes = Math.floor(seconds - seconds % 60) / 60; + return `${minutes}m${modSeconds}s`; + } + + if (seconds < 0.1) { + return 'instant'; + } + + const precision = (seconds > 1 ? 3 : 2); + return `${seconds.toPrecision(precision)}s`; + }; + if (showStepStatusSummary) { + const totalDuration = formatDuration(totalTimeEnd - totalTimeStart); + console.error(colors.bright(`Step summary:`)); const longestNameLength = @@ -1111,15 +1688,53 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus Object.values(stepStatusSummary) .map(({name}) => name.length)); - const anyStepsNotClean = + const stepsNotClean = Object.values(stepStatusSummary) - .some(({status}) => + .map(({status}) => status === STATUS_HAS_WARNINGS || status === STATUS_FATAL_ERROR || status === STATUS_STARTED_NOT_DONE); - for (const {name, status, annotation} of Object.values(stepStatusSummary)) { - let message = `${(name + ': ').padEnd(longestNameLength + 4, '.')} ${status}`; + const anyStepsNotClean = + stepsNotClean.includes(true); + + const stepDetails = Object.values(stepStatusSummary); + + const stepDurations = + stepDetails.map(({status, timeStart, timeEnd}) => { + if ( + status === STATUS_NOT_APPLICABLE || + status === STATUS_NOT_STARTED || + status === STATUS_STARTED_NOT_DONE + ) { + return '-'; + } + + if (typeof timeStart !== 'number' || typeof timeEnd !== 'number') { + return 'unknown'; + } + + return formatDuration(timeEnd - timeStart); + }); + + const longestDurationLength = + Math.max(...stepDurations.map(duration => duration.length)); + + for (let index = 0; index < stepDetails.length; index++) { + const {name, status, annotation} = stepDetails[index]; + const duration = stepDurations[index]; + + let message = + (stepsNotClean[index] + ? `!! ` + : ` - `); + + message += `(${duration})`.padStart(longestDurationLength + 2, ' '); + message += ` `; + message += `${name}: `.padEnd(longestNameLength + 4, '.'); + message += ` `; + message += status; + if (annotation) { message += ` (${annotation})`; } @@ -1149,6 +1764,8 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus } } + console.error(colors.bright(`Done in ${totalDuration}.`)); + if (result === true) { if (anyStepsNotClean) { console.error(colors.bright(`Final output is true, but some steps aren't clean.`)); @@ -1157,6 +1774,8 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus } else { console.error(colors.bright(`Final output is true and all steps are clean.`)); } + } else if (result === false) { + console.error(colors.bright(`Final output is false.`)); } else { console.error(colors.bright(`Final output is not true (${result}).`)); } diff --git a/src/url-spec.js b/src/url-spec.js index 2ff0fa5b..699f2bef 100644 --- a/src/url-spec.js +++ b/src/url-spec.js @@ -79,12 +79,25 @@ const urlSpec = { albumCover: 'album-art/<>/cover.<>', albumWallpaper: 'album-art/<>/bg.<>', albumBanner: 'album-art/<>/banner.<>', + trackCover: 'album-art/<>/<>.<>', + artistAvatar: 'artist-avatar/<>.<>', + flashArt: 'flash-art/<>.<>', + albumAdditionalFile: 'album-additional/<>/<>', }, }, + + thumb: { + prefix: 'thumb/', + + paths: { + root: '', + path: '<>', + }, + }, }; // This gets automatically switched in place when working from a baseDirectory, diff --git a/src/util/cli.js b/src/util/cli.js index 4c08c085..973fef19 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -340,3 +340,34 @@ export function fileIssue({ console.error(colors.red(`- https://hsmusic.wiki/feedback/`)); console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`)); } + +export async function logicalCWD() { + if (process.env.PWD) { + return process.env.PWD; + } + + const {exec} = await import('node:child_process'); + const {stat} = await import('node:fs/promises'); + + try { + await stat('/bin/sh'); + } catch (error) { + // Not logical, so sad. + return process.cwd(); + } + + const proc = exec('/bin/pwd -L'); + + let output = ''; + proc.stdout.on('data', buf => { output += buf; }); + + await new Promise(resolve => proc.on('exit', resolve)); + + return output.trim(); +} + +export async function logicalPathTo(target) { + const {relative} = await import('node:path'); + const cwd = await logicalCWD(); + return relative(cwd, target); +} diff --git a/src/util/html.js b/src/util/html.js index 282a52da..5b6743e0 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -181,6 +181,10 @@ export function tags(content, attributes = null) { return new Tag(null, attributes, content); } +export function normalize(content) { + return Tag.normalize(content); +} + export class Tag { #tagName = ''; #content = null; diff --git a/src/util/sugar.js b/src/util/sugar.js index 3e39e98f..9646be37 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -411,6 +411,18 @@ export function aggregateThrows(errorClass) { return {[openAggregate.errorClassSymbol]: errorClass}; } +// Helper function for allowing both (fn, aggregateOpts) and (aggregateOpts, fn) +// in aggregate utilities. +function _reorganizeAggregateArguments(arg1, arg2) { + if (typeof arg1 === 'function') { + return {fn: arg1, opts: arg2 ?? {}}; + } else if (typeof arg2 === 'function') { + return {fn: arg2, opts: arg1 ?? {}}; + } else { + throw new Error(`Expected a function`); + } +} + // Performs an ordinary array map with the given function, collating into a // results array (with errored inputs filtered out) and an error aggregate. // @@ -420,15 +432,15 @@ export function aggregateThrows(errorClass) { // Note the aggregate property is the result of openAggregate(), still unclosed; // use aggregate.close() to throw the error. (This aggregate may be passed to a // parent aggregate: `parent.call(aggregate.close)`!) -export function mapAggregate(array, fn, aggregateOpts) { - return _mapAggregate('sync', null, array, fn, aggregateOpts); +export function mapAggregate(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + return _mapAggregate('sync', null, array, fn, opts); } -export function mapAggregateAsync(array, fn, { - promiseAll = Promise.all.bind(Promise), - ...aggregateOpts -} = {}) { - return _mapAggregate('async', promiseAll, array, fn, aggregateOpts); +export function mapAggregateAsync(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; + return _mapAggregate('async', promiseAll, array, fn, remainingOpts); } // Helper function for mapAggregate which holds code common between sync and @@ -462,15 +474,15 @@ export function _mapAggregate(mode, promiseAll, array, fn, aggregateOpts) { // inputs to a particular output. // // As with mapAggregate, the returned aggregate property is not yet closed. -export function filterAggregate(array, fn, aggregateOpts) { - return _filterAggregate('sync', null, array, fn, aggregateOpts); +export function filterAggregate(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + return _filterAggregate('sync', null, array, fn, opts); } -export async function filterAggregateAsync(array, fn, { - promiseAll = Promise.all.bind(Promise), - ...aggregateOpts -} = {}) { - return _filterAggregate('async', promiseAll, array, fn, aggregateOpts); +export async function filterAggregateAsync(array, arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + const {promiseAll = Promise.all.bind(Promise), ...remainingOpts} = opts; + return _filterAggregate('async', promiseAll, array, fn, remainingOpts); } // Helper function for filterAggregate which holds code common between sync and @@ -530,20 +542,17 @@ function _filterAggregate(mode, promiseAll, array, fn, aggregateOpts) { // Totally sugar function for opening an aggregate, running the provided // function with it, then closing the function and returning the result (if // there's no throw). -export function withAggregate(aggregateOpts, fn) { - return _withAggregate('sync', aggregateOpts, fn); +export function withAggregate(arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + return _withAggregate('sync', opts, fn); } -export function withAggregateAsync(aggregateOpts, fn) { - return _withAggregate('async', aggregateOpts, fn); +export function withAggregateAsync(arg1, arg2) { + const {fn, opts} = _reorganizeAggregateArguments(arg1, arg2); + return _withAggregate('async', opts, fn); } export function _withAggregate(mode, aggregateOpts, fn) { - if (typeof aggregateOpts === 'function') { - fn = aggregateOpts; - aggregateOpts = {}; - } - const aggregate = openAggregate(aggregateOpts); if (mode === 'sync') { @@ -628,27 +637,101 @@ export function showAggregate(topError, { } } -export function decorateErrorWithIndex(fn) { - return (x, index, array) => { +export function annotateError(error, ...callbacks) { + for (const callback of callbacks) { + error = callback(error) ?? error; + } + + return error; +} + +export function annotateErrorWithIndex(error, index) { + return Object.assign(error, { + [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: + index, + + message: + `(${colors.yellow(`#${index + 1}`)}) ` + + error.message, + }); +} + +export function annotateErrorWithFile(error, file) { + return Object.assign(error, { + [Symbol.for('hsmusic.annotateError.file')]: + file, + + message: + error.message + + (error.message.includes('\n') ? '\n' : ' ') + + `(file: ${colors.bright(colors.blue(file))})`, + }); +} + +export function asyncAdaptiveDecorateError(fn, callback) { + if (typeof callback !== 'function') { + throw new Error(`Expected callback to be a function, got ${typeAppearance(callback)}`); + } + + const syncDecorated = function (...args) { try { - return fn(x, index, array); - } catch (error) { - error.message = `(${colors.yellow(`#${index + 1}`)}) ${error.message}`; - error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index; - throw error; + return fn(...args); + } catch (caughtError) { + throw callback(caughtError, ...args); } }; -} -export function decorateErrorWithCause(fn, cause) { - return (...args) => { + const asyncDecorated = async function(...args) { try { - return fn(...args); - } catch (error) { - error.cause = cause; - throw error; + return await fn(...args); + } catch (caughtError) { + throw callback(caughtError); } }; + + syncDecorated.async = asyncDecorated; + + return syncDecorated; +} + +export function decorateError(fn, callback) { + return asyncAdaptiveDecorateError(fn, callback); +} + +export function asyncDecorateError(fn, callback) { + return asyncAdaptiveDecorateError(fn, callback).async; +} + +export function decorateErrorWithAnnotation(fn, ...annotationCallbacks) { + return asyncAdaptiveDecorateError(fn, + (caughtError, ...args) => + annotateError(caughtError, + ...annotationCallbacks + .map(callback => error => callback(error, ...args)))); +} + +export function decorateErrorWithIndex(fn) { + return decorateErrorWithAnnotation(fn, + (caughtError, _value, index) => + annotateErrorWithIndex(caughtError, index)); +} + +export function decorateErrorWithCause(fn, cause) { + return asyncAdaptiveDecorateError(fn, + (caughtError) => + Object.assign(caughtError, {cause})); +} + +export function asyncDecorateErrorWithAnnotation(fn, ...annotationCallbacks) { + return decorateErrorWithAnnotation(fn, ...annotationCallbacks).async; +} + +export function asyncDecorateErrorWithIndex(fn) { + return decorateErrorWithIndex(fn).async; +} + +export function asyncDecorateErrorWithCause(fn, cause) { + return decorateErrorWithCause(fn, cause).async; } export function conditionallySuppressError(conditionFn, callbackFn) { diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index 1339c322..ab6ceecb 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -44,6 +44,11 @@ export function getCLIOptions() { help: `Enables outputting [200] and [404] responses in the server log, which are suppressed by default`, type: 'flag', }, + + 'skip-serving': { + help: `Causes the build to exit when it would start serving over HTTP instead\n\nMainly useful for testing performance`, + type: 'flag', + }, }; } @@ -51,6 +56,7 @@ export async function go({ cliOptions, _dataPath, mediaPath, + mediaCachePath, defaultLanguage, languages, @@ -77,6 +83,7 @@ export async function go({ const host = cliOptions['host'] ?? defaultHost; const port = parseInt(cliOptions['port'] ?? defaultPort); const loudResponses = cliOptions['loud-responses'] ?? false; + const skipServing = cliOptions['skip-serving'] ?? false; const contentDependenciesWatcher = await watchContentDependencies(); const {contentDependencies} = contentDependenciesWatcher; @@ -171,7 +178,7 @@ export async function go({ const { area: localFileArea, path: localFilePath - } = pathname.match(/^\/(?<area>static|util|media)\/(?<path>.*)/)?.groups ?? {}; + } = pathname.match(/^\/(?<area>static|util|media|thumb)\/(?<path>.*)/)?.groups ?? {}; if (localFileArea) { // Not security tested, man, this is a dev server!! @@ -182,6 +189,8 @@ export async function go({ localDirectory = path.join(srcRootPath, localFileArea); } else if (localFileArea === 'media') { localDirectory = mediaPath; + } else if (localFileArea === 'thumb') { + localDirectory = mediaCachePath; } let filePath; @@ -393,10 +402,14 @@ export async function go({ } }); - server.listen(port, host); + if (skipServing) { + logInfo`Ready to serve! But --skip-serving was passed, so all done.`; + } else { + server.listen(port, host); - // Just keep going... forever!!! - await new Promise(() => {}); + // Just keep going... forever!!! + await new Promise(() => {}); + } return true; } diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index 09316999..b6dc9643 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -84,6 +84,7 @@ export async function go({ cliOptions, _dataPath, mediaPath, + mediaCachePath, queueSize, defaultLanguage, @@ -133,6 +134,7 @@ export async function go({ await writeSymlinks({ srcRootPath, mediaPath, + mediaCachePath, outputPath, urls, }); @@ -372,6 +374,8 @@ export async function go({ logWarn`available - albeit possibly outdated! Please scroll up and send`; logWarn`the HSMusic developers a copy of the errors:`; fileIssue({topMessage: null}); + + return false; } return true; @@ -414,6 +418,7 @@ async function writePage({ function writeSymlinks({ srcRootPath, mediaPath, + mediaCachePath, outputPath, urls, }) { @@ -421,6 +426,7 @@ function writeSymlinks({ link(path.join(srcRootPath, 'util'), 'shared.utilityRoot'), link(path.join(srcRootPath, 'static'), 'shared.staticRoot'), link(mediaPath, 'media.root'), + link(mediaCachePath, 'thumb.root'), ]); async function link(directory, urlKey) { diff --git a/test/unit/data/composite/data/withPropertiesFromObject.js b/test/unit/data/composite/data/withPropertiesFromObject.js index ead1b9b2..cb1d8d21 100644 --- a/test/unit/data/composite/data/withPropertiesFromObject.js +++ b/test/unit/data/composite/data/withPropertiesFromObject.js @@ -207,7 +207,7 @@ t.test(`withPropertiesFromObject: validate static inputs`, t => { {message: `object: Expected an object, got array`}, {message: `properties: Errors validating array items`, errors: [ { - [Symbol.for('hsmusic.decorate.indexInSourceArray')]: 2, + [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2, message: /Expected a string, got number/, }, ]}, @@ -240,7 +240,7 @@ t.test(`withPropertiesFromObject: validate dynamic inputs`, t => { {message: `object: Expected an object, got array`}, {message: `properties: Errors validating array items`, errors: [ { - [Symbol.for('hsmusic.decorate.indexInSourceArray')]: 2, + [Symbol.for('hsmusic.annotateError.indexInSourceArray')]: 2, message: /Expected a string, got number/, }, ]}, |