diff options
author | (quasar) nebula <qznebula@protonmail.com> | 2023-12-03 17:55:58 -0400 |
---|---|---|
committer | (quasar) nebula <qznebula@protonmail.com> | 2023-12-03 17:55:58 -0400 |
commit | 11493b1a70c26d9aa11b98acf93b4d09d89f88bf (patch) | |
tree | fe062bc0b32698c3af6c41f4c7043dc1afb09b92 /src/data/things | |
parent | 213bddbd9851ee01f256835b1bca0c4bc0cf5fc6 (diff) | |
parent | 7039d7fa471318df40c1905cd5ac52688dc6adcf (diff) |
Merge branch 'preview' into news-tweaks
Diffstat (limited to 'src/data/things')
-rw-r--r-- | src/data/things/album.js | 8 | ||||
-rw-r--r-- | src/data/things/artist.js | 17 | ||||
-rw-r--r-- | src/data/things/index.js | 5 | ||||
-rw-r--r-- | src/data/things/language.js | 150 | ||||
-rw-r--r-- | src/data/things/static-page.js | 1 | ||||
-rw-r--r-- | src/data/things/track.js | 20 | ||||
-rw-r--r-- | src/data/things/validators.js | 125 | ||||
-rw-r--r-- | src/data/things/wiki-info.js | 12 |
8 files changed, 298 insertions, 40 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js index af3eb042..a95ba354 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -133,7 +133,10 @@ export class Album extends Thing { class: input.value(Group), }), - trackData: wikiData({ + // Only the tracks which belong to this album. + // Necessary for computing the track list, so provide this statically + // or keep it updated. + ownTrackData: wikiData({ class: input.value(Track), }), @@ -181,7 +184,8 @@ export class Album extends Thing { hasTrackArt: S.id, isListedOnHomepage: S.id, - commentary: S.id, + commentary: S.toCommentaryRefs, + additionalFiles: S.id, tracks: S.toRefs, 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/index.js b/src/data/things/index.js index 4ea1f007..d1143b0a 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -22,11 +22,6 @@ import * as wikiInfoClasses from './wiki-info.js'; export {default as Thing} from './thing.js'; -export { - default as CacheableObject, - CacheableObjectPropertyValueError, -} from './cacheable-object.js'; - const allClassLists = { 'album.js': albumClasses, 'art-tag.js': artTagClasses, diff --git a/src/data/things/language.js b/src/data/things/language.js index 0aeb7ad5..c1916313 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,5 +1,15 @@ -import {Tag} from '#html'; +import { Temporal, toTemporalInstant } from '@js-temporal/polyfill'; + import {isLanguageCode} from '#validators'; +import {Tag} from '#html'; + +import { + getExternalLinkStringOfStyleFromDescriptors, + getExternalLinkStringsFromDescriptors, + isExternalLinkContext, + isExternalLinkSpec, + isExternalLinkStyle, +} from '#external-links'; import { externalFunction, @@ -72,6 +82,13 @@ export class Language extends Thing { update: {validate: (t) => typeof t === 'object'}, }, + // List of descriptors for providing to external link utilities when using + // language.formatExternalLink - refer to util/external-links.js for info. + externalLinkSpec: { + flags: {update: true, expose: true}, + update: {validate: isExternalLinkSpec}, + }, + // Update only escapeHTML: externalFunction(), @@ -269,6 +286,108 @@ export class Language extends Thing { return this.intl_date.formatRange(startDate, endDate); } + formatDateDuration({ + years: numYears = 0, + months: numMonths = 0, + days: numDays = 0, + approximate = false, + }) { + let basis; + + const years = this.countYears(numYears, {unit: true}); + const months = this.countMonths(numMonths, {unit: true}); + const days = this.countDays(numDays, {unit: true}); + + if (numYears && numMonths && numDays) + basis = this.formatString('count.dateDuration.yearsMonthsDays', {years, months, days}); + else if (numYears && numMonths) + basis = this.formatString('count.dateDuration.yearsMonths', {years, months}); + else if (numYears && numDays) + basis = this.formatString('count.dateDuration.yearsDays', {years, days}); + else if (numYears) + basis = this.formatString('count.dateDuration.years', {years}); + else if (numMonths && numDays) + basis = this.formatString('count.dateDuration.monthsDays', {months, days}); + else if (numMonths) + basis = this.formatzString('count.dateDuration.months', {months}); + else if (numDays) + basis = this.formatString('count.dateDuration.days', {days}); + else + return this.formatString('count.dateDuration.zero'); + + if (approximate) { + return this.formatString('count.dateDuration.approximate', { + duration: basis, + }); + } else { + return basis; + } + } + + formatRelativeDate(currentDate, referenceDate, { + considerRoundingDays = false, + approximate = true, + absolute = true, + } = {}) { + const currentInstant = toTemporalInstant.apply(currentDate); + const referenceInstant = toTemporalInstant.apply(referenceDate); + + const comparison = + Temporal.Instant.compare(currentInstant, referenceInstant); + + if (comparison === 0) { + return this.formatString('count.dateDuration.same'); + } + + const currentTDZ = currentInstant.toZonedDateTimeISO('Etc/UTC'); + const referenceTDZ = referenceInstant.toZonedDateTimeISO('Etc/UTC'); + + const earlierTDZ = (comparison === -1 ? currentTDZ : referenceTDZ); + const laterTDZ = (comparison === 1 ? currentTDZ : referenceTDZ); + + const {years, months, days} = + laterTDZ.since(earlierTDZ, { + largestUnit: 'year', + smallestUnit: + (considerRoundingDays + ? (laterTDZ.since(earlierTDZ, { + largestUnit: 'year', + smallestUnit: 'day', + }).years + ? 'month' + : 'day') + : 'day'), + roundingMode: 'halfCeil', + }); + + const duration = + this.formatDateDuration({ + years, months, days, + approximate: false, + }); + + const relative = + this.formatString( + 'count.dateDuration', + (approximate && (years || months || days) + ? (comparison === -1 + ? 'approximateEarlier' + : 'approximateLater') + : (comparison === -1 + ? 'earlier' + : 'later')), + {duration}); + + if (absolute) { + return this.formatString('count.dateDuration.relativeAbsolute', { + relative, + absolute: this.formatDate(currentDate), + }); + } else { + return relative; + } + } + formatDuration(secTotal, {approximate = false, unit = false} = {}) { if (secTotal === 0) { return this.formatString('count.duration.missing'); @@ -299,6 +418,31 @@ export class Language extends Thing { : duration; } + formatExternalLink(url, { + style = 'normal', + context = 'generic', + } = {}) { + if (!this.externalLinkSpec) { + throw new TypeError(`externalLinkSpec unavailable`); + } + + isExternalLinkContext(context); + + if (style === 'all') { + return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, { + language: this, + context, + }); + } + + isExternalLinkStyle(style); + + return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, { + language: this, + context, + }); + } + formatIndex(value) { this.assertIntlAvailable('intl_pluralOrdinal'); return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); @@ -403,7 +547,11 @@ Object.assign(Language.prototype, { countCommentaryEntries: countHelper('commentaryEntries', 'entries'), countContributions: countHelper('contributions'), countCoverArts: countHelper('coverArts'), + countDays: countHelper('days'), + countMonths: countHelper('months'), countTimesReferenced: countHelper('timesReferenced'), countTimesUsed: countHelper('timesUsed'), countTracks: countHelper('tracks'), + countWeeks: countHelper('weeks'), + countYears: countHelper('years'), }); diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index ab9c5f98..8a3fd10e 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -30,5 +30,6 @@ export class StaticPage extends Thing { directory: directory(), content: simpleString(), stylesheet: simpleString(), + script: simpleString(), }); } diff --git a/src/data/things/track.js b/src/data/things/track.js index 8d310611..e3fe0804 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -24,6 +24,7 @@ import { import { additionalFiles, + additionalNameList, commentary, commentatorArtists, contributionList, @@ -42,7 +43,9 @@ import { import { exitWithoutUniqueCoverArt, + inferredAdditionalNameList, inheritFromOriginalRelease, + sharedAdditionalNameList, trackReverseReferenceList, withAlbum, withAlwaysReferenceByDirectory, @@ -64,6 +67,10 @@ export class Track extends Thing { name: name('Unnamed Track'), directory: directory(), + additionalNames: additionalNameList(), + sharedAdditionalNames: sharedAdditionalNameList(), + inferredAdditionalNames: inferredAdditionalNameList(), + duration: duration(), urls: urls(), dateFirstReleased: simpleDate(), @@ -329,12 +336,21 @@ export class Track extends Thing { } let album; - if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) { + + if (depth >= 0) { + try { + album = this.album; + } catch (_error) {} + + album ??= this.dataSourceAlbum; + } + + if (album) { const albumName = album.name; const albumIndex = album.tracks.indexOf(this); const trackNum = (albumIndex === -1 - ? '#?' + ? 'indeterminate position' : `#${albumIndex + 1}`); parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`); } diff --git a/src/data/things/validators.js b/src/data/things/validators.js index f60c363c..ac91b456 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -1,7 +1,12 @@ import {inspect as nodeInspect} from 'node:util'; +// Heresy. +import printable_characters from 'printable-characters'; +const {strlen} = printable_characters; + import {colors, ENABLE_COLOR} from '#cli'; -import {empty, typeAppearance, withAggregate} from '#sugar'; +import {cut, empty, typeAppearance, withAggregate} from '#sugar'; +import {commentaryRegex} from '#wiki-data'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); @@ -96,7 +101,10 @@ export function isStringNonEmpty(value) { } export function optional(validator) { - return value => value === null || value === undefined || validator(value); + return value => + value === null || + value === undefined || + validator(value); } // Complex types (non-primitives) @@ -166,29 +174,42 @@ export function is(...values) { } function validateArrayItemsHelper(itemValidator) { - return (item, index) => { + return (item, index, array) => { try { - const value = itemValidator(item); + const value = itemValidator(item, index, array); if (value !== true) { throw new Error(`Expected validator to return true`); } } catch (error) { - error.message = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)}) ${error.message}`; + const annotation = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)})`; + + error.message = + (error.message.includes('\n') || strlen(annotation) > 20 + ? annotation + '\n' + + error.message + .split('\n') + .map(line => ` ${line}`) + .join('\n') + : `${annotation} ${error}`); + error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index; + throw error; } }; } export function validateArrayItems(itemValidator) { - const fn = validateArrayItemsHelper(itemValidator); + const helper = validateArrayItemsHelper(itemValidator); return (array) => { isArray(array); - withAggregate({message: 'Errors validating array items'}, ({wrap}) => { - array.forEach(wrap(fn)); + withAggregate({message: 'Errors validating array items'}, ({call}) => { + for (let index = 0; index < array.length; index++) { + call(helper, array[index], index, array); + } }); return true; @@ -200,12 +221,12 @@ export function strictArrayOf(itemValidator) { } export function sparseArrayOf(itemValidator) { - return validateArrayItems(item => { + return validateArrayItems((item, index, array) => { if (item === false || item === null) { return true; } - return itemValidator(item); + return itemValidator(item, index, array); }); } @@ -231,18 +252,56 @@ export function isColor(color) { throw new TypeError(`Unknown color format`); } -export function isCommentary(commentary) { - isString(commentary); +export function isCommentary(commentaryText) { + isString(commentaryText); - const [firstLine] = commentary.match(/.*/); - if (!firstLine.replace(/<\/b>/g, '').includes(':</i>')) { - throw new TypeError(`Missing commentary citation: "${ - firstLine.length > 40 - ? firstLine.slice(0, 40) + '...' - : firstLine - }"`); + const rawMatches = + Array.from(commentaryText.matchAll(commentaryRegex)); + + if (empty(rawMatches)) { + throw new TypeError(`Expected at least one commentary heading`); } + const niceMatches = + rawMatches.map(match => ({ + position: match.index, + length: match[0].length, + })); + + validateArrayItems(({position, length}, index) => { + if (index === 0 && position > 0) { + throw new TypeError(`Expected first commentary heading to be at top`); + } + + const ownInput = commentaryText.slice(position, position + length); + const restOfInput = commentaryText.slice(position + length); + const nextLineBreak = restOfInput.indexOf('\n'); + const upToNextLineBreak = restOfInput.slice(0, nextLineBreak); + + if (/\S/.test(upToNextLineBreak)) { + throw new TypeError( + `Expected commentary heading to occupy entire line, got extra text:\n` + + `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` + + `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` + + `(Check for missing "|-" in YAML, or a misshapen annotation)`); + } + + const nextHeading = + (index === niceMatches.length - 1 + ? commentaryText.length + : niceMatches[index + 1].position); + + const upToNextHeading = + commentaryText.slice(position + length, nextHeading); + + if (!/\S/.test(upToNextHeading)) { + throw new TypeError( + `Expected commentary entry to have body text, only got a heading`); + } + + return true; + })(niceMatches); + return true; } @@ -285,20 +344,14 @@ export function validateProperties(spec) { export const isContribution = validateProperties({ who: isArtistRef, - what: (value) => - value === undefined || - value === null || - isStringNonEmpty(value), + what: optional(isStringNonEmpty), }); export const isContributionList = validateArrayItems(isContribution); export const isAdditionalFile = validateProperties({ title: isString, - description: (value) => - value === undefined || - value === null || - isString(value), + description: optional(isStringNonEmpty), files: validateArrayItems(isString), }); @@ -497,6 +550,24 @@ export function validateWikiData({ }; } +export const isAdditionalName = validateProperties({ + name: isName, + annotation: optional(isStringNonEmpty), + + // TODO: This only allows indicating sourcing from a track. + // That's okay for the current limited use of "from", but + // could be expanded later. + from: + // Double TODO: Explicitly allowing both references and + // live objects to co-exist is definitely weird, and + // altogether questions the way we define validators... + optional(oneOf( + validateReferenceList('track'), + validateWikiData({referenceType: 'track'}))), +}); + +export const isAdditionalNameList = validateArrayItems(isAdditionalName); + // Compositional utilities export function oneOf(...checks) { diff --git a/src/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(), |