From c3dfb1d5607627e45595347628f39eb7546009da Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 23 Jan 2022 01:31:37 -0400 Subject: track data --- src/thing/album.js | 70 +++++++++++++++++++++++++--- src/thing/thing.js | 44 +++++++++--------- src/thing/track.js | 112 +++++++++++++++++++++++++++++++++++++++++++++ src/thing/validators.js | 47 ++++++++++++++++--- src/upd8.js | 119 ++++++++++++++++++++++++++++++++++++++++++++++-- src/util/find.js | 2 +- src/util/sugar.js | 4 +- test/data-validators.js | 13 +++++- 8 files changed, 368 insertions(+), 43 deletions(-) create mode 100644 src/thing/track.js diff --git a/src/thing/album.js b/src/thing/album.js index 9899b6a..11af801 100644 --- a/src/thing/album.js +++ b/src/thing/album.js @@ -1,4 +1,6 @@ +import CacheableObject from './cacheable-object.js'; import Thing from './thing.js'; +import find from '../util/find.js'; import { isBoolean, @@ -12,21 +14,70 @@ import { isURL, isString, validateArrayItems, + validateInstanceOf, validateReference, validateReferenceList, } from './validators.js'; -export default class Album extends Thing { +export class TrackGroup extends CacheableObject { static propertyDescriptors = { // Update & expose name: { flags: {update: true, expose: true}, + update: {default: 'Unnamed Track Group', validate: isName} + }, - update: { - default: 'Unnamed Album', - validate: isName + color: { + flags: {update: true, expose: true}, + update: {validate: isColor} + }, + + dateOriginallyReleased: { + flags: {update: true, expose: true}, + update: {validate: isDate} + }, + + tracksByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('track')} + }, + + isDefaultTrackGroup: { + flags: {update: true, expose: true}, + update: {validate: isBoolean} + }, + + // Update only + + trackData: { + flags: {update: true}, + update: {validate: validateArrayItems(item => isInstance(item, Track))} + }, + + // Expose only + + tracks: { + flags: {expose: true}, + + expose: { + dependencies: ['tracksByRef', 'trackData'], + compute: ({ tracksByRef, trackData }) => ( + tracksByRef.map(ref => find.track(ref, {wikiData: {trackData}}))) } + } + }; +} + +export default class Album extends Thing { + static [Thing.referenceType] = 'album'; + + static propertyDescriptors = { + // Update & expose + + name: { + flags: {update: true, expose: true}, + update: {default: 'Unnamed Album', validate: isName} }, color: { @@ -36,7 +87,8 @@ export default class Album extends Thing { directory: { flags: {update: true, expose: true}, - update: {validate: isDirectory} + update: {validate: isDirectory}, + expose: Thing.directoryExpose }, urls: { @@ -109,11 +161,11 @@ export default class Album extends Thing { } }, - tracksByRef: { + trackGroups: { flags: {update: true, expose: true}, update: { - validate: validateReferenceList('track') + validate: validateArrayItems(validateInstanceOf(TrackGroup)) } }, @@ -176,6 +228,7 @@ export default class Album extends Thing { // Expose only + /* tracks: { flags: {expose: true}, @@ -185,11 +238,14 @@ export default class Album extends Thing { trackReferences.map(ref => find.track(ref, {wikiData}))) } }, + */ // Update only + /* wikiData: { flags: {update: true} } + */ }; } diff --git a/src/thing/thing.js b/src/thing/thing.js index dd3126c..54a278d 100644 --- a/src/thing/thing.js +++ b/src/thing/thing.js @@ -1,30 +1,32 @@ // Base class for Things. No, we will not come up with a better name. // Sorry not sorry! :) -// -// NB: Since these methods all involve processing a variety of input data, some -// of which will pass and some of which may fail, any failures should be thrown -// together as an AggregateError. See util/sugar.js for utility functions to -// make writing code around this easier! import CacheableObject from './cacheable-object.js'; +import { getKebabCase } from '../util/wiki-data.js'; + export default class Thing extends CacheableObject { - static propertyDescriptors = Symbol('Thing property descriptors'); + static referenceType = Symbol('Thing.referenceType'); + + static directoryExpose = { + dependencies: ['name'], + transform(directory, { name }) { + if (directory === null && name === null) + return null; + else if (directory === null) + return getKebabCase(name); + else + return directory; + } + }; + + static getReference(thing) { + if (!thing.constructor[Thing.referenceType]) + throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`); - // Called when collecting the full list of available things of that type - // for wiki data; this method determine whether or not to include it. - // - // This should return whether or not the object is complete enough to be - // used across the wiki - not whether every optional attribute is provided! - // (That is, attributes required for postprocessing & basic page generation - // are all present.) - checkComplete() {} + if (!thing.directory) + throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); - // Called when adding the thing to the wiki data list, and when its source - // data is updated (provided checkComplete() passes). - // - // This should generate any cached object references, across other wiki - // data; for example, building an array of actual track objects - // corresponding to an album's track list ('track:cool-track' strings). - postprocess({wikiData}) {} + return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; + } } diff --git a/src/thing/track.js b/src/thing/track.js new file mode 100644 index 0000000..3174cab --- /dev/null +++ b/src/thing/track.js @@ -0,0 +1,112 @@ +import Thing from './thing.js'; + +import { + isBoolean, + isColor, + isCommentary, + isContributionList, + isDate, + isDirectory, + isDuration, + isName, + isURL, + isString, + validateArrayItems, + validateReference, + validateReferenceList, +} from './validators.js'; + +export default class Track extends Thing { + static [Thing.referenceType] = 'track'; + + static propertyDescriptors = { + // Update & expose + + name: { + flags: {update: true, expose: true}, + + update: { + default: 'Unnamed Track', + validate: isName + } + }, + + directory: { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + expose: Thing.directoryExpose + }, + + duration: { + flags: {update: true, expose: true}, + update: {validate: isDuration} + }, + + urls: { + flags: {update: true, expose: true}, + + update: { + validate: validateArrayItems(isURL) + } + }, + + dateFirstReleased: { + flags: {update: true, expose: true}, + update: {validate: isDate} + }, + + hasCoverArt: { + flags: {update: true, expose: true}, + update: {default: true, validate: isBoolean} + }, + + hasURLs: { + flags: {update: true, expose: true}, + update: {default: true, validate: isBoolean} + }, + + referencedTracksByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('track')} + }, + + artistContribsByRef: { + flags: {update: true, expose: true}, + update: {validate: isContributionList} + }, + + contributorContribsByRef: { + flags: {update: true, expose: true}, + update: {validate: isContributionList} + }, + + coverArtistContribsByRef: { + flags: {update: true, expose: true}, + update: {validate: isContributionList} + }, + + artTagsByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList('tag')} + }, + + originalReleaseTrackByRef: { + flags: {update: true, expose: true}, + update: {validate: validateReference('track')} + }, + + commentary: { + flags: {update: true, expose: true}, + update: {validate: isCommentary} + }, + + lyrics: { + flags: {update: true, expose: true}, + update: {validate: isString} + }, + + // Update only + + // Expose only + }; +} diff --git a/src/thing/validators.js b/src/thing/validators.js index 2bdb299..e745771 100644 --- a/src/thing/validators.js +++ b/src/thing/validators.js @@ -47,6 +47,24 @@ export function isNegative(number) { return true; } +export function isPositiveOrZero(number) { + isNumber(number); + + if (number < 0) + throw new TypeError(`Expected positive number or zero`); + + return true; +} + +export function isNegativeOrZero(number) { + isNumber(number); + + if (number > 0) + throw new TypeError(`Expected negative number or zero`); + + return true; +} + export function isInteger(number) { isNumber(number); @@ -130,6 +148,10 @@ export function validateArrayItems(itemValidator) { }; } +export function validateInstanceOf(constructor) { + return object => isInstance(object, constructor); +} + // Wiki data (primitives & non-primitives) export function isColor(color) { @@ -187,8 +209,15 @@ export function isDimensions(dimensions) { export function isDirectory(directory) { isStringNonEmpty(directory); - if (directory.match(/[^a-zA-Z0-9\-]/)) - throw new TypeError(`Expected only letters, numbers, and dash, got "${directory}"`); + if (directory.match(/[^a-zA-Z0-9_\-]/)) + throw new TypeError(`Expected only letters, numbers, dash, and underscore, got "${directory}"`); + + return true; +} + +export function isDuration(duration) { + isNumber(duration); + isPositiveOrZero(duration); return true; } @@ -209,13 +238,17 @@ export function validateReference(type = 'track') { return ref => { isStringNonEmpty(ref); - const hasTwoParts = ref.includes(':'); - const [ typePart, directoryPart ] = ref.split(':'); + const match = ref.trim().match(/^(?:(?\S+):(?=\S))?(?.+)(? new Date(value), + }, + + propertyFieldMapping: { + name: 'Group', + color: 'Color', + dateOriginallyReleased: 'Date Originally Released', + } +}); + +const parseTrackDocument = makeParseDocument(Track, { + fieldTransformations: { + 'Duration': getDurationInSeconds, + + 'Date First Released': value => new Date(value), + + 'Artists': parseContributors, + 'Contributors': parseContributors, + 'Cover Artists': parseContributors, + }, + + propertyFieldMapping: { + name: 'Track', + + directory: 'Directory', + duration: 'Duration', + urls: 'URLs', + + dateFirstReleased: 'Date First Released', + hasCoverArt: 'Has Cover Art', + hasURLs: 'Has URLs', + + referencedTracksByRef: 'Referenced Tracks', + artistContribsByRef: 'Artists', + contributorContribsByRef: 'Contributors', + coverArtistContribsByRef: 'Cover Artists', + artTagsByRef: 'Art Tags', + originalReleaseTrackByRef: 'Originally Released As', + + commentary: 'Commentary', + lyrics: 'Lyrics' + } +}); + async function processArtistDataFile(file) { let contents; try { @@ -1626,6 +1729,14 @@ async function processHomepageInfoFile(file) { } function getDurationInSeconds(string) { + if (typeof string === 'number') { + return string; + } + + if (typeof string !== 'string') { + throw new TypeError(`Expected a string or number, got ${string}`); + } + const parts = string.split(':').map(n => parseInt(n)) if (parts.length === 3) { return parts[0] * 3600 + parts[1] * 60 + parts[2] @@ -2645,7 +2756,7 @@ async function main() { try { processDataAggregate.close(); } catch (error) { - showAggregate(error); + showAggregate(error, {pathToFile: f => path.relative(__dirname, f)}); } process.exit(); diff --git a/src/util/find.js b/src/util/find.js index 5f69bbe..423046b 100644 --- a/src/util/find.js +++ b/src/util/find.js @@ -7,7 +7,7 @@ function findHelper(keys, dataProp, findFns = {}) { const byDirectory = findFns.byDirectory || matchDirectory; const byName = findFns.byName || matchName; - const keyRefRegex = new RegExp(`^((${keys.join('|')}):)?(.*)$`); + const keyRefRegex = new RegExp(`^((${keys.join('|')}):(?:\S))?(.*)$`); return (fullRef, {wikiData}) => { if (!fullRef) return null; diff --git a/src/util/sugar.js b/src/util/sugar.js index 64291f3..075aa19 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -328,7 +328,7 @@ export function withAggregate(aggregateOpts, fn) { return result; } -export function showAggregate(topError) { +export function showAggregate(topError, {pathToFile = null} = {}) { const recursive = error => { const stackLines = error.stack?.split('\n'); const stackLine = stackLines?.find(line => @@ -336,7 +336,7 @@ export function showAggregate(topError) { && !line.includes('sugar') && !line.includes('node:internal')); const tracePart = (stackLine - ? '- ' + stackLine.trim() + ? '- ' + stackLine.trim().replace(/file:\/\/(.*\.js)/, (match, pathname) => pathToFile(pathname)) : '(no stack trace)'); const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'} ${color.dim(tracePart)}`; diff --git a/test/data-validators.js b/test/data-validators.js index e6b8b43..867068c 100644 --- a/test/data-validators.js +++ b/test/data-validators.js @@ -16,6 +16,7 @@ import { // Wiki data isDimensions, isDirectory, + isDuration, validateReference, validateReferenceList, } from '../src/thing/validators.js'; @@ -138,14 +139,24 @@ test('isDimensions', t => { }); test('isDirectory', t => { - t.plan(5); + t.plan(6); t.ok(isDirectory('savior-of-the-waking-world')); t.ok(isDirectory('MeGaLoVania')); + t.ok(isDirectory('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_')); t.throws(() => isDirectory(123), TypeError); t.throws(() => isDirectory(''), TypeError); t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError); }); +test('isDuration', t => { + t.plan(5); + t.ok(isDuration(60)); + t.ok(isDuration(0.02)); + t.ok(isDuration(0)); + t.throws(() => isDuration(-1), TypeError); + t.throws(() => isDuration('10:25'), TypeError); +}); + test.skip('isName', t => { // TODO }); -- cgit 1.3.0-6-gf8a5