diff options
Diffstat (limited to 'src/data')
-rw-r--r-- | src/data/language.js | 208 | ||||
-rw-r--r-- | src/data/things/artist.js | 17 | ||||
-rw-r--r-- | src/data/things/wiki-info.js | 12 |
3 files changed, 210 insertions, 27 deletions
diff --git a/src/data/language.js b/src/data/language.js index 09466907..3fc14da7 100644 --- a/src/data/language.js +++ b/src/data/language.js @@ -1,39 +1,199 @@ +import EventEmitter from 'node:events'; import {readFile} from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; -// 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 const DEFAULT_STRINGS_FILE = 'strings-default.yaml'; + +export const internalDefaultStringsFile = + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../', + DEFAULT_STRINGS_FILE); + +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}; +} - const code = json['meta.languageCode']; - if (!code) { - throw new Error(`Missing language code (file: ${file})`); +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}); + + 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); - const language = new T.Language(); - language.code = code; - language.intlCode = intlCode; - language.name = name; - language.hidden = hidden; - language.escapeHTML = (string) => + try { + return processLanguageSpec(flattenedSpec, processLanguageSpecOpts); + } catch (caughtError) { + throw annotateErrorWithFile(caughtError, file); + } +} + +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/artist.js b/src/data/things/artist.js index e0350b86..a51723c4 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -107,6 +107,23 @@ export class Artist extends Thing { albumsAsBannerArtist: Artist.filterByContrib('albumData', 'bannerArtistContribs'), + albumsAsAny: { + flags: {expose: true}, + + expose: { + dependencies: ['albumData'], + + compute: ({albumData, [Artist.instance]: artist}) => + albumData?.filter((album) => + [ + ...album.artistContribs, + ...album.coverArtistContribs, + ...album.wallpaperArtistContribs, + ...album.bannerArtistContribs, + ].some(({who}) => who === artist)) ?? [], + }, + }, + albumsAsCommentator: { flags: {expose: true}, diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 89053d62..3db9727b 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,9 +1,8 @@ import {input} from '#composite'; import find from '#find'; -import {isLanguageCode, isName, isURL} from '#validators'; +import {isColor, isLanguageCode, isName, isURL} from '#validators'; import { - color, flag, name, referenceList, @@ -32,7 +31,14 @@ export class WikiInfo extends Thing { }, }, - color: color(), + color: { + flags: {update: true, expose: true}, + update: {validate: isColor}, + + expose: { + transform: color => color ?? '#0088ff', + }, + }, // One-line description used for <meta rel="description"> tag. description: simpleString(), |