diff options
Diffstat (limited to 'src/data/things')
-rw-r--r-- | src/data/things/album.js | 290 | ||||
-rw-r--r-- | src/data/things/flash.js | 10 | ||||
-rw-r--r-- | src/data/things/index.js | 7 | ||||
-rw-r--r-- | src/data/things/language.js | 206 | ||||
-rw-r--r-- | src/data/things/track.js | 9 | ||||
-rw-r--r-- | src/data/things/wiki-info.js | 21 |
6 files changed, 454 insertions, 89 deletions
diff --git a/src/data/things/album.js b/src/data/things/album.js index 40cd4631..e9f55b2c 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,20 +1,25 @@ export const DATA_ALBUM_DIRECTORY = 'album'; import * as path from 'node:path'; +import {inspect} from 'node:util'; +import CacheableObject from '#cacheable-object'; +import {colors} from '#cli'; import {input} from '#composite'; import find from '#find'; import {traverse} from '#node-utils'; import {sortAlbumsTracksChronologically, sortChronologically} from '#sort'; -import {empty} from '#sugar'; +import {accumulateSum, empty} from '#sugar'; import Thing from '#thing'; -import {isDate} from '#validators'; +import {isColor, isDate, validateWikiData} from '#validators'; import {parseAdditionalFiles, parseContributors, parseDate, parseDimensions} from '#yaml'; -import {exposeDependency, exposeUpdateValueOrContinue} +import {exitWithoutDependency, exposeDependency, exposeUpdateValueOrContinue} from '#composite/control-flow'; -import {exitWithoutContribs} from '#composite/wiki-data'; +import {withPropertyFromObject} from '#composite/data'; +import {exitWithoutContribs, withDirectory, withResolvedReference} + from '#composite/wiki-data'; import { additionalFiles, @@ -31,16 +36,24 @@ import { referenceList, simpleDate, simpleString, + singleReference, urls, wikiData, } from '#composite/wiki-properties'; -import {withTracks, withTrackSections} from '#composite/things/album'; +import {withTracks} from '#composite/things/album'; +import {withAlbum} from '#composite/things/track-section'; export class Album extends Thing { static [Thing.referenceType] = 'album'; - static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({ + static [Thing.getPropertyDescriptors] = ({ + ArtTag, + Artist, + Group, + Track, + TrackSection, + }) => ({ // Update & expose name: name('Unnamed Album'), @@ -48,6 +61,8 @@ export class Album extends Thing { directory: directory(), urls: urls(), + alwaysReferenceTracksByDirectory: flag(false), + bandcampAlbumIdentifier: simpleString(), bandcampArtworkIdentifier: simpleString(), @@ -109,10 +124,11 @@ export class Album extends Thing { commentary: commentary(), additionalFiles: additionalFiles(), - trackSections: [ - withTrackSections(), - exposeDependency({dependency: '#trackSections'}), - ], + trackSections: referenceList({ + referenceType: input.value('unqualified-track-section'), + data: 'ownTrackSectionData', + find: input.value(find.unqualifiedTrackSection), + }), artistContribs: contributionList(), coverArtistContribs: contributionList(), @@ -153,11 +169,8 @@ export class Album extends Thing { class: input.value(Group), }), - // 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), + ownTrackSectionData: wikiData({ + class: input.value(TrackSection), }), // Expose only @@ -226,6 +239,10 @@ export class Album extends Thing { 'Album': {property: 'name'}, 'Directory': {property: 'directory'}, + 'Always Reference Tracks By Directory': { + property: 'alwaysReferenceTracksByDirectory', + }, + 'Bandcamp Album ID': { property: 'bandcampAlbumIdentifier', transform: String, @@ -339,68 +356,77 @@ export class Album extends Thing { headerDocumentThing: Album, entryDocumentThing: document => ('Section' in document - ? TrackSectionHelper + ? TrackSection : Track), save(results) { const albumData = []; + const trackSectionData = []; const trackData = []; for (const {header: album, entries} of results) { - // We can't mutate an array once it's set as a property value, - // so prepare the track sections that will show up in a track list - // all the way before actually applying them. (It's okay to mutate - // an individual section before applying it, since those are just - // generic objects; they aren't Things in and of themselves.) const trackSections = []; - const ownTrackData = []; - let currentTrackSection = { + let currentTrackSection = new TrackSection(); + let currentTrackSectionTracks = []; + + Object.assign(currentTrackSection, { name: `Default Track Section`, isDefaultTrackSection: true, - tracks: [], - }; + }); const albumRef = Thing.getReference(album); const closeCurrentTrackSection = () => { - if (!empty(currentTrackSection.tracks)) { - trackSections.push(currentTrackSection); + if ( + currentTrackSection.isDefaultTrackSection && + empty(currentTrackSectionTracks) + ) { + return; } + + currentTrackSection.tracks = + currentTrackSectionTracks + .map(track => Thing.getReference(track)); + + currentTrackSection.ownTrackData = + currentTrackSectionTracks; + + currentTrackSection.ownAlbumData = + [album]; + + trackSections.push(currentTrackSection); + trackSectionData.push(currentTrackSection); }; for (const entry of entries) { - if (entry instanceof TrackSectionHelper) { + if (entry instanceof TrackSection) { closeCurrentTrackSection(); - - currentTrackSection = { - name: entry.name, - color: entry.color, - dateOriginallyReleased: entry.dateOriginallyReleased, - isDefaultTrackSection: false, - tracks: [], - }; - + currentTrackSection = entry; + currentTrackSectionTracks = []; continue; } + currentTrackSectionTracks.push(entry); trackData.push(entry); entry.dataSourceAlbum = albumRef; - - ownTrackData.push(entry); - currentTrackSection.tracks.push(Thing.getReference(entry)); } closeCurrentTrackSection(); albumData.push(album); - album.trackSections = trackSections; - album.ownTrackData = ownTrackData; + album.trackSections = + trackSections + .map(trackSection => + `unqualified-track-section:` + + trackSection.unqualifiedDirectory); + + album.ownTrackSectionData = trackSections; } - return {albumData, trackData}; + return {albumData, trackSectionData, trackData}; }, sort({albumData, trackData}) { @@ -410,15 +436,139 @@ export class Album extends Thing { }); } -export class TrackSectionHelper extends Thing { +export class TrackSection extends Thing { static [Thing.friendlyName] = `Track Section`; + static [Thing.referenceType] = `track-section`; + + static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ + // Update & expose - static [Thing.getPropertyDescriptors] = () => ({ name: name('Unnamed Track Section'), - color: color(), + + unqualifiedDirectory: directory(), + + color: [ + exposeUpdateValueOrContinue({ + validate: input.value(isColor), + }), + + withAlbum(), + + withPropertyFromObject({ + object: '#album', + property: input.value('color'), + }), + + exposeDependency({dependency: '#album.color'}), + ], + dateOriginallyReleased: simpleDate(), - isDefaultTrackGroup: flag(false), - }) + + isDefaultTrackSection: flag(false), + + album: [ + withAlbum(), + exposeDependency({dependency: '#album'}), + ], + + tracks: referenceList({ + class: input.value(Track), + data: 'ownTrackData', + find: input.value(find.track), + }), + + // Update only + + ownAlbumData: wikiData({ + class: input.value(Album), + }), + + ownTrackData: wikiData({ + class: input.value(Track), + }), + + // Expose only + + directory: [ + withAlbum(), + + exitWithoutDependency({ + dependency: '#album', + }), + + withPropertyFromObject({ + object: '#album', + property: input.value('directory'), + }), + + withDirectory({ + directory: 'unqualifiedDirectory', + }).outputs({ + '#directory': '#unqualifiedDirectory', + }), + + { + dependencies: ['#album.directory', '#unqualifiedDirectory'], + compute: ({ + ['#album.directory']: albumDirectory, + ['#unqualifiedDirectory']: unqualifiedDirectory, + }) => + albumDirectory + '/' + unqualifiedDirectory, + }, + ], + + startIndex: [ + withAlbum(), + + withPropertyFromObject({ + object: '#album', + property: input.value('trackSections'), + }), + + { + dependencies: ['#album.trackSections', input.myself()], + compute: (continuation, { + ['#album.trackSections']: trackSections, + [input.myself()]: myself, + }) => continuation({ + ['#index']: + trackSections.indexOf(myself), + }), + }, + + exitWithoutDependency({ + dependency: '#index', + mode: input.value('index'), + value: input.value(0), + }), + + { + dependencies: ['#album.trackSections', '#index'], + compute: ({ + ['#album.trackSections']: trackSections, + ['#index']: index, + }) => + accumulateSum( + trackSections + .slice(0, index) + .map(section => section.tracks.length)), + }, + ], + }); + + static [Thing.findSpecs] = { + trackSection: { + referenceTypes: ['track-section'], + bindTo: 'trackSectionData', + }, + + unqualifiedTrackSection: { + referenceTypes: ['unqualified-track-section'], + + getMatchableDirectories: trackSection => + [trackSection.unqualifiedDirectory], + }, + }; static [Thing.yamlDocumentSpec] = { fields: { @@ -431,4 +581,48 @@ export class TrackSectionHelper extends Thing { }, }, }; + + [inspect.custom](depth) { + const parts = []; + + parts.push(Thing.prototype[inspect.custom].apply(this)); + + if (depth >= 0) { + let album = null; + try { + album = this.album; + } catch {} + + let first = null; + try { + first = this.startIndex; + } catch {} + + let length = null; + try { + length = this.tracks.length; + } catch {} + + album ??= CacheableObject.getUpdateValue(this, 'ownAlbumData')?.[0]; + + if (album) { + const albumName = album.name; + const albumIndex = album.trackSections.indexOf(this); + + const num = + (albumIndex === -1 + ? 'indeterminate position' + : `#${albumIndex + 1}`); + + const range = + (albumIndex >= 0 && first !== null && length !== null + ? `: ${first + 1}-${first + length + 1}` + : ''); + + parts.push(` (${colors.yellow(num + range)} in ${colors.green(albumName)})`); + } + } + + return parts.join(''); + } } diff --git a/src/data/things/flash.js b/src/data/things/flash.js index ceed79f7..7038df86 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -7,7 +7,7 @@ import {sortFlashesChronologically} from '#sort'; import Thing from '#thing'; import {anyOf, isColor, isContentString, isDirectory, isNumber, isString} from '#validators'; -import {parseDate, parseContributors} from '#yaml'; +import {parseContributors, parseDate, parseDimensions} from '#yaml'; import {withPropertyFromObject} from '#composite/data'; @@ -24,6 +24,7 @@ import { commentatorArtists, contentString, contributionList, + dimensions, directory, fileExtension, name, @@ -89,6 +90,8 @@ export class Flash extends Thing { coverArtFileExtension: fileExtension('jpg'), + coverArtDimensions: dimensions(), + contributorContribs: contributionList(), featuredTracks: referenceList({ @@ -171,6 +174,11 @@ export class Flash extends Thing { 'Cover Art File Extension': {property: 'coverArtFileExtension'}, + 'Cover Art Dimensions': { + property: 'coverArtDimensions', + transform: parseDimensions, + }, + 'Featured Tracks': {property: 'featuredTracks'}, 'Contributors': { diff --git a/src/data/things/index.js b/src/data/things/index.js index 3bf84091..4f87f492 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -2,10 +2,10 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {openAggregate, showAggregate} from '#aggregate'; +import CacheableObject from '#cacheable-object'; import {logError} from '#cli'; import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; - import Thing from '#thing'; import * as albumClasses from './album.js'; @@ -142,7 +142,10 @@ function evaluatePropertyDescriptors() { } } - constructor.propertyDescriptors = results; + constructor[CacheableObject.propertyDescriptors] = { + ...constructor[CacheableObject.propertyDescriptors] ?? {}, + ...results, + }; }, showFailedClasses(failedClasses) { diff --git a/src/data/things/language.js b/src/data/things/language.js index dbe1ff3d..f20927a4 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -127,6 +127,13 @@ export class Language extends Thing { // Expose only + onlyIfOptions: { + flags: {expose: true}, + expose: { + compute: () => Symbol.for(`language.onlyIfOptions`), + }, + }, + intl_date: this.#intlHelper(Intl.DateTimeFormat, {full: true}), intl_number: this.#intlHelper(Intl.NumberFormat), intl_listConjunction: this.#intlHelper(Intl.ListFormat, {type: 'conjunction'}), @@ -218,18 +225,42 @@ export class Language extends Thing { throw new Error(`Invalid key ${key} accessed`); } + const constantCasify = name => + name + .replace(/[A-Z]/g, '_$&') + .toUpperCase(); + // These will be filled up as we iterate over the template, slotting in // each option (if it's present). const missingOptionNames = new Set(); + // These will also be filled. It's a bit different of an error, indicating + // a provided option was *expected,* but its value was null, undefined, or + // blank HTML content. + const valuelessOptionNames = new Set(); + + // These *might* be missing, and if they are, that's OK!! Instead of adding + // to the valueless set above, we'll just mark to return a blank for the + // whole string. + const expectedValuelessOptionNames = + new Set( + (options[this.onlyIfOptions] ?? []) + .map(constantCasify)); + + let seenExpectedValuelessOption = false; + + const isValueless = + value => + value === null || + value === undefined || + html.isBlank(value); + // And this will have entries deleted as they're encountered in the // template. Leftover entries are misplaced. const optionsMap = new Map( Object.entries(options).map(([name, value]) => [ - name - .replace(/[A-Z]/g, '_$&') - .toUpperCase(), + constantCasify(name), value, ])); @@ -239,32 +270,48 @@ export class Language extends Thing { match: languageOptionRegex, insert: ({name: optionName}, canceledForming) => { - if (optionsMap.has(optionName)) { - let optionValue; - - // We'll only need the option's value if we're going to use it as - // part of the formed output (see below). - if (!canceledForming) { - optionValue = optionsMap.get(optionName); - } - - // But we always have to delete expected options off the provided - // option map, since the leftovers are what will be used to tell - // which are misplaced. - optionsMap.delete(optionName); + if (!optionsMap.has(optionName)) { + missingOptionNames.add(optionName); - if (canceledForming) { - return undefined; - } else { - return optionValue; - } - } else { // We don't need to continue forming the output if we've hit a // missing option name, since the end result of this formatString // call will be a thrown error, and formed output won't be needed. - missingOptionNames.add(optionName); + // Return undefined to mark canceledForming for the following + // iterations (and exit early out of this iteration). + return undefined; + } + + // Even if we're not actually forming the output anymore, we'll still + // have to access this option's value to check if it is invalid. + const optionValue = optionsMap.get(optionName); + + // We always have to delete expected options off the provided option + // map, since the leftovers are what will be used to tell which are + // misplaced - information you want even (or doubly so) if we've + // already stopped forming the output thanks to missing options. + optionsMap.delete(optionName); + + // Just like if an option is missing, a valueless option cancels + // forming the rest of the output. + if (isValueless(optionValue)) { + // It's also an error, *except* if this option is one of the ones + // that we're indicated to *expect* might be valueless! In that case, + // we still need to stop forming the string (and mark a separate flag + // so that we return a blank), but it's not an error. + if (expectedValuelessOptionNames.has(optionName)) { + seenExpectedValuelessOption = true; + } else { + valuelessOptionNames.add(optionName); + } + return undefined; } + + if (canceledForming) { + return undefined; + } + + return optionValue; }, }); @@ -272,17 +319,30 @@ export class Language extends Thing { Array.from(optionsMap.keys()); withAggregate({message: `Errors in options for string "${key}"`}, ({push}) => { + const names = set => Array.from(set).join(', '); + if (!empty(missingOptionNames)) { - const names = Array.from(missingOptionNames).join(`, `); - push(new Error(`Missing options: ${names}`)); + push(new Error( + `Missing options: ${names(missingOptionNames)}`)); + } + + if (!empty(valuelessOptionNames)) { + push(new Error( + `Valueless options: ${names(valuelessOptionNames)}`)); } if (!empty(misplacedOptionNames)) { - const names = Array.from(misplacedOptionNames).join(`, `); - push(new Error(`Unexpected options: ${names}`)); + push(new Error( + `Unexpected options: ${names(misplacedOptionNames)}`)); } }); + // If an option was valueless as marked to expect, then that indicates + // the whole string should be treated as blank content. + if (seenExpectedValuelessOption) { + return html.blank(); + } + return output; } @@ -416,11 +476,32 @@ export class Language extends Thing { } formatDate(date) { + // Null or undefined date is blank content. + if (date === null || date === undefined) { + return html.blank(); + } + this.assertIntlAvailable('intl_date'); return this.intl_date.format(date); } formatDateRange(startDate, endDate) { + // formatDateRange expects both values to be present, but if both are null + // or both are undefined, that's just blank content. + const hasStart = startDate !== null && startDate !== undefined; + const hasEnd = endDate !== null && endDate !== undefined; + if (!hasStart || !hasEnd) { + if (startDate === endDate) { + return html.blank(); + } else if (hasStart) { + throw new Error(`Expected both start and end of date range, got only start`); + } else if (hasEnd) { + throw new Error(`Expected both start and end of date range, got only end`); + } else { + throw new Error(`Got mismatched ${startDate}/${endDate} for start and end`); + } + } + this.assertIntlAvailable('intl_date'); return this.intl_date.formatRange(startDate, endDate); } @@ -431,6 +512,17 @@ export class Language extends Thing { days: numDays = 0, approximate = false, }) { + // Give up if any of years, months, or days is null or undefined. + // These default to zero, so something's gone pretty badly wrong to + // pass in all or partial missing values. + if ( + numYears === undefined || numYears === null || + numMonths === undefined || numMonths === null || + numDays === undefined || numDays === null + ) { + throw new Error(`Expected values or default zero for years, months, and days`); + } + let basis; const years = this.countYears(numYears, {unit: true}); @@ -468,6 +560,14 @@ export class Language extends Thing { approximate = true, absolute = true, } = {}) { + // Give up if current and/or reference date is null or undefined. + if ( + currentDate === undefined || currentDate === null || + referenceDate === undefined || referenceDate === null + ) { + throw new Error(`Expected values for currentDate and referenceDate`); + } + const currentInstant = toTemporalInstant.apply(currentDate); const referenceInstant = toTemporalInstant.apply(referenceDate); @@ -528,6 +628,12 @@ export class Language extends Thing { } formatDuration(secTotal, {approximate = false, unit = false} = {}) { + // Null or undefined duration is blank content. + if (secTotal === null || secTotal === undefined) { + return html.blank(); + } + + // Zero duration is a "missing" string. if (secTotal === 0) { return this.formatString('count.duration.missing'); } @@ -565,6 +671,11 @@ export class Language extends Thing { throw new TypeError(`externalLinkSpec unavailable`); } + // Null or undefined url is blank content. + if (url === null || url === undefined) { + return html.blank(); + } + isExternalLinkContext(context); if (style === 'all') { @@ -589,16 +700,31 @@ export class Language extends Thing { } formatIndex(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + this.assertIntlAvailable('intl_pluralOrdinal'); return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value}); } formatNumber(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + this.assertIntlAvailable('intl_number'); return this.intl_number.format(value); } formatWordCount(value) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + const num = this.formatNumber( value > 1000 ? Math.floor(value / 100) / 10 : value ); @@ -612,6 +738,11 @@ export class Language extends Thing { } #formatListHelper(array, processFn) { + // Empty lists, null, and undefined are blank content. + if (empty(array) || array === null || array === undefined) { + return html.blank(); + } + // Operate on "insertion markers" instead of the actual contents of the // array, because the process function (likely an Intl operation) is taken // to only operate on strings. We'll insert the contents of the array back @@ -673,10 +804,22 @@ export class Language extends Thing { // File sizes: 42.5 kB, 127.2 MB, 4.13 GB, 998.82 TB formatFileSize(bytes) { - if (!bytes) return ''; + // Null or undefined bytes is blank content. + if (bytes === null || bytes === undefined) { + return html.blank(); + } + + // Zero bytes is blank content. + if (bytes === 0) { + return html.blank(); + } bytes = parseInt(bytes); - if (isNaN(bytes)) return ''; + + // Non-number bytes is blank content! Wow. + if (isNaN(bytes)) { + return html.blank(); + } const round = (exp) => Math.round(bytes / 10 ** (exp - 1)) / 10; @@ -704,6 +847,11 @@ export class Language extends Thing { const countHelper = (stringKey, optionName = stringKey) => function(value, {unit = false} = {}) { + // Null or undefined value is blank content. + if (value === null || value === undefined) { + return html.blank(); + } + return this.formatString( unit ? `count.${stringKey}.withUnit.` + this.getUnitForm(value) diff --git a/src/data/things/track.js b/src/data/things/track.js index cc49fc24..725b1bb7 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -168,10 +168,7 @@ export class Track extends Thing { commentary: commentary(), lyrics: [ - inheritFromOriginalRelease({ - property: input.value('lyrics'), - }), - + inheritFromOriginalRelease(), contentString(), ], @@ -196,7 +193,6 @@ export class Track extends Thing { artistContribs: [ inheritFromOriginalRelease({ - property: input.value('artistContribs'), notFoundValue: input.value([]), }), @@ -220,7 +216,6 @@ export class Track extends Thing { contributorContribs: [ inheritFromOriginalRelease({ - property: input.value('contributorContribs'), notFoundValue: input.value([]), }), @@ -255,7 +250,6 @@ export class Track extends Thing { referencedTracks: [ inheritFromOriginalRelease({ - property: input.value('referencedTracks'), notFoundValue: input.value([]), }), @@ -268,7 +262,6 @@ export class Track extends Thing { sampledTracks: [ inheritFromOriginalRelease({ - property: input.value('sampledTracks'), notFoundValue: input.value([]), }), diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 316bd3bb..2a2c9986 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -3,8 +3,9 @@ export const WIKI_INFO_FILE = 'wiki-info.yaml'; import {input} from '#composite'; import find from '#find'; import Thing from '#thing'; -import {isColor, isLanguageCode, isName, isURL} from '#validators'; +import {isBoolean, isColor, isLanguageCode, isName, isURL} from '#validators'; +import {exitWithoutDependency} from '#composite/control-flow'; import {contentString, flag, name, referenceList, wikiData} from '#composite/wiki-properties'; @@ -64,8 +65,26 @@ export class WikiInfo extends Thing { enableArtTagUI: flag(false), enableGroupUI: flag(false), + enableSearch: [ + exitWithoutDependency({ + dependency: 'searchDataAvailable', + mode: input.value('falsy'), + value: input.value(false), + }), + + flag(true), + ], + // Update only + searchDataAvailable: { + flags: {update: true}, + update: { + validate: isBoolean, + default: false, + }, + }, + groupData: wikiData({ class: input.value(Group), }), |