From b90c2072f1ef8f55ef495bfa3920af4bb482f0cd Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 22:13:08 -0300 Subject: data: valdiateArrayItems: use same index formatting as other errors Specifically, the same as decorateErrorWithIndex. --- src/data/things/validators.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/things/validators.js b/src/data/things/validators.js index fc953c2a..5748eacf 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -174,7 +174,7 @@ function validateArrayItemsHelper(itemValidator) { throw new Error(`Expected validator to return true`); } } catch (error) { - error.message = `(index: ${color.green(index)}, item: ${inspect(item)}) ${error.message}`; + error.message = `(index: ${color.yellow(`#${index}`)}, item: ${inspect(item)}) ${error.message}`; throw error; } }; -- cgit 1.3.0-6-gf8a5 From 3adf33b567c53e79ce45a36031b29da719fb2377 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 22:14:04 -0300 Subject: sugar: showAggregate: display top-level non-AggregateErrors w/ more detail --- src/util/sugar.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/util/sugar.js b/src/util/sugar.js index 487c093c..5b1f3193 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -574,7 +574,12 @@ export function showAggregate(topError, { } }; - const message = recursive(topError, {level: 0}); + const message = + (topError instanceof AggregateError + ? recursive(topError, {level: 0}) + : (showTraces + ? topError.stack + : topError.toString())); if (print) { console.error(message); -- cgit 1.3.0-6-gf8a5 From 98133431c801a823f3430b0e178c56350e12622c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 22:14:34 -0300 Subject: write: static-build: gently log failed content functions & continue --- src/util/cli.js | 4 +++- src/write/build-modes/static-build.js | 38 +++++++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/util/cli.js b/src/util/cli.js index f83c8061..e8c8c79f 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -334,7 +334,9 @@ export function progressCallAll(msgOrMsgFn, array) { export function fileIssue({ topMessage = `This shouldn't happen.`, } = {}) { - console.error(color.red(`${topMessage} Please let the HSMusic developers know:`)); + if (topMessage) { + console.error(color.red(`${topMessage} Please let the HSMusic developers know:`)); + } console.error(color.red(`- https://hsmusic.wiki/feedback/`)); console.error(color.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`)); } diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index 2210dfe7..192b7966 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -17,6 +17,7 @@ import {serializeThings} from '#serialize'; import {empty, queue, withEntries} from '#sugar'; import { + fileIssue, logError, logInfo, logWarn, @@ -98,6 +99,7 @@ export async function go({ developersComment, getSizeOfAdditionalFile, getSizeOfImageFile, + niceShowAggregate, }) { const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT; const appendIndexHTML = cliOptions['append-index-html'] ?? false; @@ -253,6 +255,8 @@ export async function go({ )); */ + let errored = false; + const contentDependencies = await quickLoadContentDependencies(); const perLanguageFn = async (language, i, entries) => { @@ -303,14 +307,22 @@ export async function go({ wikiData, }); - const topLevelResult = - quickEvaluate({ - contentDependencies, - extraDependencies: {...bound, appendIndexHTML}, - - name: page.contentFunction.name, - args: page.contentFunction.args ?? [], - }); + let topLevelResult; + try { + topLevelResult = + quickEvaluate({ + contentDependencies, + extraDependencies: {...bound, appendIndexHTML}, + + name: page.contentFunction.name, + args: page.contentFunction.args ?? [], + }); + } catch (error) { + logError`\rError generating page: ${pathname}`; + niceShowAggregate(error); + errored = true; + return; + } const {pageHTML, oEmbedJSON} = html.resolve(topLevelResult); @@ -358,6 +370,16 @@ export async function go({ // The single most important step. logInfo`Written!`; + + if (errored) { + logWarn`The code generating content for some pages ended up erroring.`; + logWarn`These pages were skipped, so if you ran a build previously and`; + logWarn`they didn't error that time, then the old version is still`; + logWarn`available - albeit possibly outdated! Please scroll up and send`; + logWarn`the HSMusic developers a copy of the errors:`; + fileIssue({topMessage: null}); + } + return true; } -- cgit 1.3.0-6-gf8a5 From 5c2aca67db34301e4fb11c28b1b737e9e47c88ab Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 23 Aug 2023 18:35:33 -0300 Subject: write: live-dev-server: fix bad error response for data.json --- src/write/build-modes/live-dev-server.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index 2767a02f..28cf7a48 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -163,7 +163,7 @@ export async function go({ if (!quietResponses) console.log(`${requestHead} [200] /data.json`); } catch (error) { response.writeHead(500, contentTypeJSON); - response.end({error: `Internal error serializing wiki JSON`}); + response.end(`Internal error serializing wiki JSON`); console.error(`${requestHead} [500] /data.json`); showError(error); } -- cgit 1.3.0-6-gf8a5 From 1a359fd6f0a4f672db3e50ee7d5398f223124edb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 27 May 2023 11:13:18 -0300 Subject: thumbs: get identify binary in addition to convert --- src/gen-thumbs.js | 55 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index e9932822..0846dd6f 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -122,8 +122,8 @@ function readFileMD5(filePath) { }); } -async function getImageMagickVersion(spawnConvert) { - const proc = spawnConvert(['--version'], false); +async function getImageMagickVersion(binary) { + const proc = spawn(binary, ['--version']); let allData = ''; proc.stdout.on('data', (data) => { @@ -144,23 +144,33 @@ async function getImageMagickVersion(spawnConvert) { return match[1]; } -async function getSpawnConvert() { - let fn, description, version; - if (await commandExists('convert')) { - fn = (args) => spawn('convert', args); - description = 'convert'; - } else if (await commandExists('magick')) { - fn = (args, prefix = true) => - spawn('magick', prefix ? ['convert', ...args] : args); - description = 'magick convert'; - } else { - return [`no convert or magick binary`, null]; +async function getSpawnMagick(tool) { + if (tool !== 'identify' && tool !== 'convert') { + throw new Error(`Expected identify or convert`); } - version = await getImageMagickVersion(fn); + let fn = null; + let description = null; + let version = null; - if (version === null) { - return [`binary --version output didn't indicate it's ImageMagick`]; + if (await commandExists(tool)) { + version = await getImageMagickVersion(tool); + if (version !== null) { + fn = (args) => spawn(tool, args); + description = tool; + } + } + + if (fn === null && await commandExists('magick')) { + version = await getImageMagickVersion(fn); + if (version !== null) { + fn = (args) => spawn('magick', [tool, ...args]); + description = `magick ${tool}`; + } + } + + if (fn === null) { + return [`no ${tool} or magick binary`, null]; } return [`${description} (${version})`, fn]; @@ -290,18 +300,23 @@ export default async function genThumbs(mediaPath, { const quietInfo = quiet ? () => null : logInfo; - const [convertInfo, spawnConvert] = (await getSpawnConvert()) ?? []; - if (!spawnConvert) { + const [convertInfo, spawnConvert] = await getSpawnMagick('convert'); + const [identifyInfo, spawnIdentify] = await getSpawnMagick('convert'); + + if (!spawnConvert || !spawnIdentify) { logError`${`It looks like you don't have ImageMagick installed.`}`; logError`ImageMagick is required to generate thumbnails for display on the wiki.`; - logError`(Error message: ${convertInfo})`; + for (const error of [convertInfo, identifyInfo].filter(Boolean)) { + logError`(Error message: ${error})`; + } logInfo`You can find info to help install ImageMagick on Linux, Windows, or macOS`; logInfo`from its official website: ${`https://imagemagick.org/script/download.php`}`; logInfo`If you have trouble working ImageMagick and would like some help, feel free`; logInfo`to drop a message in the HSMusic Discord server! ${'https://hsmusic.wiki/discord/'}`; return false; } else { - logInfo`Found ImageMagick binary: ${convertInfo}`; + logInfo`Found ImageMagick convert binary: ${convertInfo}`; + logInfo`Found ImageMagick identify binary: ${identifyInfo}`; } quietInfo`Running up to ${magickThreads + ' magick threads'} simultaneously.`; -- cgit 1.3.0-6-gf8a5 From 0944e67a92b2ac7203af1e7152a33395b32923a2 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 27 May 2023 12:08:26 -0300 Subject: thumbs: imagemagick is fricking killing me --- src/gen-thumbs.js | 111 +++++++++++++++++++++++++++++++++++++++++++----------- src/upd8.js | 13 ++++--- 2 files changed, 97 insertions(+), 27 deletions(-) diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 0846dd6f..2f5304ea 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -122,6 +122,48 @@ function readFileMD5(filePath) { }); } +async function identifyImageDimensions(filePath, {spawnIdentify}) { + const maxTries = 5; + + const recursive = async n => { + if (n > maxTries) { + throw new Error(`Didn't get any output after ${maxTries} tries`); + } + + if (n > 1) { + logInfo`Attempt #${n} for ${filePath}`; + } + + const stdoutText = await new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + + const proc = spawnIdentify(['-format', '%w %h', filePath]); + proc.stdout.on('data', data => stdout += data); + proc.stderr.on('data', data => stderr += data); + + proc.on('exit', code => { + if (code === 0) { + resolve(stdout); + } else { + reject(stderr); + } + }); + }); + + if (stdoutText === '') { + return recursive(n + 1); + } + + const words = stdoutText.split(' '); + const width = parseInt(words[0]); + const height = parseInt(words[1]); + return [width, height]; + }; + + return recursive(1); +} + async function getImageMagickVersion(binary) { const proc = spawn(binary, ['--version']); @@ -301,7 +343,7 @@ export default async function genThumbs(mediaPath, { const quietInfo = quiet ? () => null : logInfo; const [convertInfo, spawnConvert] = await getSpawnMagick('convert'); - const [identifyInfo, spawnIdentify] = await getSpawnMagick('convert'); + const [identifyInfo, spawnIdentify] = await getSpawnMagick('identify'); if (!spawnConvert || !spawnIdentify) { logError`${`It looks like you don't have ImageMagick installed.`}`; @@ -313,7 +355,7 @@ export default async function genThumbs(mediaPath, { logInfo`from its official website: ${`https://imagemagick.org/script/download.php`}`; logInfo`If you have trouble working ImageMagick and would like some help, feel free`; logInfo`to drop a message in the HSMusic Discord server! ${'https://hsmusic.wiki/discord/'}`; - return false; + return {success: false}; } else { logInfo`Found ImageMagick convert binary: ${convertInfo}`; logInfo`Found ImageMagick identify binary: ${identifyInfo}`; @@ -356,19 +398,16 @@ export default async function genThumbs(mediaPath, { const imagePaths = await traverseSourceImagePaths(mediaPath, {target: 'generate'}); - const imageToMD5Entries = await progressPromiseAll( - `Generating MD5s of image files`, - queue( - imagePaths.map( - (imagePath) => () => - readFileMD5(path.join(mediaPath, imagePath)).then( - (md5) => [imagePath, md5], - (error) => [imagePath, {error}] - ) - ), - queueSize - ) - ); + const imageToMD5Entries = + await progressPromiseAll( + `Generating MD5s of image files`, + queue( + imagePaths.map(imagePath => () => + readFileMD5(path.join(mediaPath, imagePath)) + .then( + md5 => [imagePath, md5], + error => [imagePath, {error}])), + queueSize)); { let error = false; @@ -382,23 +421,53 @@ export default async function genThumbs(mediaPath, { logError`Failed to read at least one image file!`; logError`This implies a thumbnail probably won't be generatable.`; logError`So, exiting early.`; - return false; + return {success: false}; } else { quietInfo`All image files successfully read.`; } } + const imageToDimensionsEntries = + await progressPromiseAll( + `Identifying dimensions of image files`, + queue( + imagePaths.map(imagePath => () => + identifyImageDimensions(path.join(mediaPath, imagePath), {spawnIdentify}) + .then( + dimensions => [imagePath, dimensions], + error => [imagePath, {error}])), + queueSize)); + + { + let error = false; + for (const entry of imageToDimensionsEntries) { + if (entry[1].error) { + logError`Failed to identify dimensions ${entry[0]}: ${entry[1].error}`; + error = true; + } + } + if (error) { + logError`Failed to identify dimensions of at least one image file!`; + logError`This implies a thumbnail probably won't be generatable.`; + logError`So, exiting early.`; + return {success: false}; + } else { + quietInfo`All image files successfully had dimensions identified.`; + } + } + + const imageToDimensions = Object.fromEntries(imageToDimensionsEntries); + // Technically we could pro8a8ly mut8te the cache varia8le in-place? // 8ut that seems kinda iffy. const updatedCache = Object.assign({}, cache); const entriesToGenerate = imageToMD5Entries.filter( - ([filePath, md5]) => md5 !== cache[filePath] - ); + ([filePath, md5]) => md5 !== cache[filePath]?.[0]); if (empty(entriesToGenerate)) { logInfo`All image thumbnails are already up-to-date - nice!`; - return true; + return {success: true, cache}; } logInfo`Generating thumbnails for ${entriesToGenerate.length} media files.`; @@ -416,7 +485,7 @@ export default async function genThumbs(mediaPath, { entriesToGenerate.map(([filePath, md5]) => () => generateImageThumbnails(path.join(mediaPath, filePath), {spawnConvert}).then( () => { - updatedCache[filePath] = md5; + updatedCache[filePath] = [md5, ...imageToDimensions[filePath]]; succeeded.push(filePath); }, error => { @@ -446,7 +515,7 @@ export default async function genThumbs(mediaPath, { logWarn`Sorry about that!`; } - return true; + return {success: true, cache: updatedCache}; } export function getExpectedImagePaths(mediaPath, {urls, wikiData}) { diff --git a/src/upd8.js b/src/upd8.js index bfdd1c2a..2051517b 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -418,11 +418,12 @@ async function main() { } if (clearThumbsFlag) { - await clearThumbs(mediaPath, {queueSize}); - - logInfo`All done! Remove ${'--clear-thumbs'} to run the next build.`; - if (skipThumbs) { - logInfo`And don't forget to remove ${'--skip-thumbs'} too, eh?`; + 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?`; + } } return; } @@ -437,7 +438,7 @@ async function main() { quiet: !thumbsOnly, }); logInfo`Done thumbnail generation! --------+`; - if (!result) return; + if (!result.success) return; if (thumbsOnly) return; } -- cgit 1.3.0-6-gf8a5 From d68ead9ff27a166ccf90492cd900ef4d8d6b8e3e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 27 May 2023 12:15:41 -0300 Subject: thumbs: use image-size module instead of magick identify --- package-lock.json | 39 +++++++++++++++++++++++++++++++++++++ package.json | 1 + src/gen-thumbs.js | 57 +++++++++++-------------------------------------------- 3 files changed, 51 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index d9ab5cd1..edd3ad32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "command-exists": "^1.2.9", "eslint": "^8.37.0", "he": "^1.2.0", + "image-size": "^1.0.2", "js-yaml": "^4.1.0", "marked": "^5.0.2", "striptags": "^4.0.0-alpha.4", @@ -1632,6 +1633,20 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz", + "integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -2374,6 +2389,14 @@ "node": ">=6" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6136,6 +6159,14 @@ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", "integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==" }, + "image-size": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.0.2.tgz", + "integrity": "sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg==", + "requires": { + "queue": "6.0.2" + } + }, "import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -6694,6 +6725,14 @@ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" }, + "queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "requires": { + "inherits": "~2.0.3" + } + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 02789a76..3b8e1771 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "command-exists": "^1.2.9", "eslint": "^8.37.0", "he": "^1.2.0", + "image-size": "^1.0.2", "js-yaml": "^4.1.0", "marked": "^5.0.2", "striptags": "^4.0.0-alpha.4", diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 2f5304ea..4f5c0fec 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -91,6 +91,8 @@ import {createReadStream} from 'node:fs'; import {readFile, stat, unlink, writeFile} from 'node:fs/promises'; import * as path from 'node:path'; +import dimensionsOf from 'image-size'; + import { color, fileIssue, @@ -122,46 +124,11 @@ function readFileMD5(filePath) { }); } -async function identifyImageDimensions(filePath, {spawnIdentify}) { - const maxTries = 5; - - const recursive = async n => { - if (n > maxTries) { - throw new Error(`Didn't get any output after ${maxTries} tries`); - } - - if (n > 1) { - logInfo`Attempt #${n} for ${filePath}`; - } - - const stdoutText = await new Promise((resolve, reject) => { - let stdout = ''; - let stderr = ''; - - const proc = spawnIdentify(['-format', '%w %h', filePath]); - proc.stdout.on('data', data => stdout += data); - proc.stderr.on('data', data => stderr += data); - - proc.on('exit', code => { - if (code === 0) { - resolve(stdout); - } else { - reject(stderr); - } - }); - }); - - if (stdoutText === '') { - return recursive(n + 1); - } - - const words = stdoutText.split(' '); - const width = parseInt(words[0]); - const height = parseInt(words[1]); - return [width, height]; - }; - - return recursive(1); +async function identifyImageDimensions(filePath) { + // See: https://github.com/image-size/image-size/issues/96 + const buffer = await readFile(filePath); + const dimensions = dimensionsOf(buffer); + return [dimensions.width, dimensions.height]; } async function getImageMagickVersion(binary) { @@ -343,12 +310,11 @@ export default async function genThumbs(mediaPath, { const quietInfo = quiet ? () => null : logInfo; const [convertInfo, spawnConvert] = await getSpawnMagick('convert'); - const [identifyInfo, spawnIdentify] = await getSpawnMagick('identify'); - if (!spawnConvert || !spawnIdentify) { + if (!spawnConvert) { logError`${`It looks like you don't have ImageMagick installed.`}`; logError`ImageMagick is required to generate thumbnails for display on the wiki.`; - for (const error of [convertInfo, identifyInfo].filter(Boolean)) { + for (const error of [convertInfo].filter(Boolean)) { logError`(Error message: ${error})`; } logInfo`You can find info to help install ImageMagick on Linux, Windows, or macOS`; @@ -357,8 +323,7 @@ export default async function genThumbs(mediaPath, { logInfo`to drop a message in the HSMusic Discord server! ${'https://hsmusic.wiki/discord/'}`; return {success: false}; } else { - logInfo`Found ImageMagick convert binary: ${convertInfo}`; - logInfo`Found ImageMagick identify binary: ${identifyInfo}`; + logInfo`Found ImageMagick binary: ${convertInfo}`; } quietInfo`Running up to ${magickThreads + ' magick threads'} simultaneously.`; @@ -432,7 +397,7 @@ export default async function genThumbs(mediaPath, { `Identifying dimensions of image files`, queue( imagePaths.map(imagePath => () => - identifyImageDimensions(path.join(mediaPath, imagePath), {spawnIdentify}) + identifyImageDimensions(path.join(mediaPath, imagePath)) .then( dimensions => [imagePath, dimensions], error => [imagePath, {error}])), -- cgit 1.3.0-6-gf8a5 From 641d821d03ad96bca28b8b09d2408457443a9f7f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 30 May 2023 09:50:21 -0300 Subject: thumbs: only generate thumbs of appropriate sizes --- src/gen-thumbs.js | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 4f5c0fec..bf6de286 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -114,6 +114,60 @@ import {delay, empty, queue} from '#sugar'; export const defaultMagickThreads = 8; +export function getThumbnailsAvailableForDimensions([width, height]) { + // This function is intended to be portable, so it can be used both for + // calculating which thumbnails to generate, and which ones will be ready + // to reference in generated code. Sizes are in array [name, size] form + // with larger sizes earlier in return. Keep in mind this isn't a direct + // 1:1 mapping with the sizes listed in the thumbnail spec, because the + // largest thumbnail (first in return) will be adjusted to the provided + // dimensions. + + const {all} = getThumbnailsAvailableForDimensions; + + // Find the largest size which is beneath the passed dimensions. We use the + // longer edge here (of width and height) so that each resulting thumbnail is + // fully constrained within the size*size square defined by its spec. + const longerEdge = Math.max(width, height); + const index = all.findIndex(([name, size]) => size <= longerEdge); + + // Literal edge cases are handled specially. For dimensions which are bigger + // than the biggest thumbnail in the spec, return all possible results. + // These don't need any adjustments since the largest is already smaller than + // the provided dimensions. + if (index === 0) { + return [ + ...all, + ]; + } + + // For dimensions which are smaller than the smallest thumbnail, return only + // the smallest, adjusted to the provided dimensions. + if (index === -1) { + const smallest = all[all.length - 1]; + return [ + [smallest[0], longerEdge], + ]; + } + + // For non-edge cases, we return the largest size below the dimensions + // as well as everything smaller, but also the next size larger - that way + // there's a size which is as big as the original, but still JPEG compressed. + // The size larger is adjusted to the provided dimensions to represent the + // actual dimensions it'll provide. + const larger = all[index - 1]; + const rest = all.slice(index); + return [ + [larger[0], longerEdge], + ...rest, + ]; +} + +getThumbnailsAvailableForDimensions.all = + Object.entries(thumbnailSpec) + .map(([name, {size}]) => [name, size]) + .sort((a, b) => b[1] - a[1]); + function readFileMD5(filePath) { return new Promise((resolve, reject) => { const md5 = createHash('md5'); @@ -185,7 +239,11 @@ async function getSpawnMagick(tool) { return [`${description} (${version})`, fn]; } -function generateImageThumbnails(filePath, {spawnConvert}) { +function generateImageThumbnails({ + filePath, + dimensions, + spawnConvert, +}) { const dirname = path.dirname(filePath); const extname = path.extname(filePath); const basename = path.basename(filePath, extname); @@ -205,9 +263,10 @@ function generateImageThumbnails(filePath, {spawnConvert}) { ]); return Promise.all( - Object.entries(thumbnailSpec) - .map(([ext, details]) => - promisifyProcess(convert('.' + ext, details), false))); + getThumbnailsAvailableForDimensions(dimensions) + .map(([name]) => [name, thumbnailSpec[name]]) + .map(([name, details]) => + promisifyProcess(convert('.' + name, details), false))); } export async function clearThumbs(mediaPath, { @@ -448,7 +507,11 @@ export default async function genThumbs(mediaPath, { await progressPromiseAll(writeMessageFn, queue( entriesToGenerate.map(([filePath, md5]) => () => - generateImageThumbnails(path.join(mediaPath, filePath), {spawnConvert}).then( + generateImageThumbnails({ + filePath: path.join(mediaPath, filePath), + dimensions: imageToDimensions[filePath], + spawnConvert, + }).then( () => { updatedCache[filePath] = [md5, ...imageToDimensions[filePath]]; succeeded.push(filePath); -- cgit 1.3.0-6-gf8a5 From 029210cc329a015a939472a688209d3f3423242b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 30 May 2023 09:51:26 -0300 Subject: thumbs, content: integrate cached thumb sizes into content --- src/content/dependencies/image.js | 85 +++++++++++++++++++-------- src/gen-thumbs.js | 31 +++++++++- src/upd8.js | 36 +++++++++++- src/write/bind-utilities.js | 20 ++++++- src/write/build-modes/live-dev-server.js | 10 ++-- src/write/build-modes/static-build.js | 6 +- tap-snapshots/test/snapshot/image.js.test.cjs | 22 +++---- test/lib/content-function.js | 7 +++ test/snapshot/image.js | 4 +- 9 files changed, 171 insertions(+), 50 deletions(-) diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js index 71b905f7..905b3c2e 100644 --- a/src/content/dependencies/image.js +++ b/src/content/dependencies/image.js @@ -2,10 +2,12 @@ import {empty} from '#sugar'; export default { extraDependencies: [ - 'getSizeOfImageFile', + 'getDimensionsOfImagePath', + 'getSizeOfImagePath', + 'getThumbnailEqualOrSmaller', + 'getThumbnailsAvailableForDimensions', 'html', 'language', - 'thumb', 'to', ], @@ -52,10 +54,12 @@ export default { }, generate(data, slots, { - getSizeOfImageFile, + getDimensionsOfImagePath, + getSizeOfImagePath, + getThumbnailEqualOrSmaller, + getThumbnailsAvailableForDimensions, html, language, - thumb, to, }) { let originalSrc; @@ -68,12 +72,6 @@ export default { originalSrc = ''; } - const thumbSrc = - originalSrc && - (slots.thumb - ? thumb[slots.thumb](originalSrc) - : originalSrc); - const willLink = typeof slots.link === 'string' || slots.link; const customLink = typeof slots.link === 'string'; @@ -95,18 +93,6 @@ export default { slots.missingSourceContent)); } - let fileSize = null; - if (willLink) { - const mediaRoot = to('media.root'); - if (originalSrc.startsWith(mediaRoot)) { - fileSize = - getSizeOfImageFile( - originalSrc - .slice(mediaRoot.length) - .replace(/^\//, '')); - } - } - let reveal = null; if (willReveal) { reveal = [ @@ -119,16 +105,67 @@ export default { ]; } + let mediaSrc = null; + if (originalSrc.startsWith(to('media.root'))) { + mediaSrc = + originalSrc + .slice(to('media.root').length) + .replace(/^\//, ''); + } + + let thumbSrc = null; + if (mediaSrc) { + // Note: This provides mediaSrc to getThumbnailEqualOrSmaller, since + // it's the identifier which thumbnail utilities use to query from the + // thumbnail cache. But we use the result to operate on originalSrc, + // 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`); + } + + let originalWidth = null; + let originalHeight = null; + let availableThumbs = null; + if (mediaSrc) { + [originalWidth, originalHeight] = + getDimensionsOfImagePath(mediaSrc); + availableThumbs = + getThumbnailsAvailableForDimensions([originalWidth, originalHeight]); + } + + let fileSize = null; + if (willLink && mediaSrc) { + fileSize = getSizeOfImagePath(mediaSrc); + } + const imgAttributes = { id: idOnImg, class: classOnImg, alt: slots.alt, width: slots.width, height: slots.height, - 'data-original-size': fileSize, - 'data-no-image-preview': customLink, }; + if (customLink) { + imgAttributes['data-no-image-preview'] = true; + } + + if (fileSize) { + imgAttributes['data-original-size'] = fileSize; + } + + if (originalWidth && originalHeight) { + imgAttributes['data-original-length'] = Math.max(originalWidth, originalHeight); + } + + if (!empty(availableThumbs)) { + imgAttributes['data-thumbs'] = + availableThumbs + .map(([name, size]) => `${name}:${size}`) + .join(' '); + } + const nonlazyHTML = originalSrc && prepare( diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index bf6de286..f6a8eaaf 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -74,7 +74,7 @@ 'use strict'; -const CACHE_FILE = 'thumbnail-cache.json'; +export const CACHE_FILE = 'thumbnail-cache.json'; const WARNING_DELAY_TIME = 10000; const thumbnailSpec = { @@ -168,6 +168,30 @@ getThumbnailsAvailableForDimensions.all = .map(([name, {size}]) => [name, size]) .sort((a, b) => b[1] - a[1]); +export function getDimensionsOfImagePath(imagePath, cache) { + // This function is really generic. It takes the gen-thumbs image cache and + // returns the dimensions in that cache, so that other functions don't need + // to hard-code the cache format. + + const [width, height] = cache[imagePath].slice(1); + return [width, height]; +} + +export function getThumbnailEqualOrSmaller(preferred, imagePath, cache) { + // This function is totally exclusive to page generation. It's a shorthand + // for accessing dimensions from the thumbnail cache, calculating all the + // thumbnails available, and selecting the one which is equal to or smaller + // than the provided size. Since the path provided might not be the actual + // one which is being thumbnail-ified, this just returns the name of the + // selected thumbnail size. + + const {size: preferredSize} = thumbnailSpec[preferred]; + const [width, height] = getDimensionsOfImagePath(imagePath, cache); + const available = getThumbnailsAvailableForDimensions([width, height]); + const [selected] = available.find(([name, size]) => size <= preferredSize); + return selected; +} + function readFileMD5(filePath) { return new Promise((resolve, reject) => { const md5 = createHash('md5'); @@ -302,7 +326,7 @@ export async function clearThumbs(mediaPath, { console.error(file); } fileIssue(); - return; + return {success: false}; } logInfo`Clearing out ${thumbFiles.length} thumbs.`; @@ -327,6 +351,7 @@ export async function clearThumbs(mediaPath, { console.error(file); } logError`Check for permission errors?`; + return {success: false}; } else { logInfo`Successfully deleted all ${thumbFiles.length} thumbnail files!`; } @@ -355,6 +380,8 @@ export async function clearThumbs(mediaPath, { logWarn`Failed to remove cache file. Check its permissions?`; } } + + return {success: true}; } export default async function genThumbs(mediaPath, { diff --git a/src/upd8.js b/src/upd8.js index 2051517b..2f08204a 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -32,6 +32,7 @@ // node.js and you'll 8e fine. ...Within the project root. O8viously. import {execSync} from 'node:child_process'; +import {readFile} from 'node:fs/promises'; import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; @@ -57,6 +58,7 @@ import { } from '#cli'; import genThumbs, { + CACHE_FILE as thumbsCacheFile, clearThumbs, defaultMagickThreads, isThumb, @@ -428,7 +430,33 @@ async function main() { return; } + let thumbsCache; + if (skipThumbs) { + const thumbsCachePath = path.join(mediaPath, thumbsCacheFile); + try { + thumbsCache = JSON.parse(await readFile(thumbsCachePath)); + logInfo`Thumbnail cache file successfully read.`; + } catch (error) { + if (error.code === 'ENOENT') { + logError`The thumbnail cache doesn't exist, and it's necessary to build` + logError`the website. Please run once without ${'--skip-thumbs'} - after` + logError`that you'll be good to go and don't need to process thumbnails` + logError`again!`; + return; + } else { + logError`Malformed or unreadable thumbnail cache file: ${error}`; + logError`Path: ${thumbsCachePath}`; + logError`The thumbbnail cache is necessary to build the site, so you'll`; + logError`have to investigate this to get the build working. Try running`; + logError`again without ${'--skip-thumbs'}. If you can't get it working,`; + logError`you're welcome to message in the HSMusic Discord and we'll try`; + logError`to help you out with troubleshooting!`; + logError`${'https://hsmusic.wiki/discord/'}`; + return; + } + } + logInfo`Skipping thumbnail generation.`; } else { logInfo`Begin thumbnail generation... -----+`; @@ -440,6 +468,7 @@ async function main() { logInfo`Done thumbnail generation! --------+`; if (!result.success) return; if (thumbsOnly) return; + thumbsCache = result.cache; } if (noBuild) { @@ -705,7 +734,7 @@ async function main() { }; const getSizeOfAdditionalFile = getSizeOfMediaFileHelper(additionalFilePaths); - const getSizeOfImageFile = getSizeOfMediaFileHelper(imageFilePaths); + const getSizeOfImagePath = getSizeOfMediaFileHelper(imageFilePaths); logInfo`Preloading filesizes for ${additionalFilePaths.length} additional files...`; @@ -760,14 +789,15 @@ async function main() { defaultLanguage: finalDefaultLanguage, languages, - wikiData, + thumbsCache, urls, urlSpec, + wikiData, cachebust: '?' + CACHEBUST, developersComment, getSizeOfAdditionalFile, - getSizeOfImageFile, + getSizeOfImagePath, niceShowAggregate, }); } diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js index 8e2adea7..c32035f1 100644 --- a/src/write/bind-utilities.js +++ b/src/write/bind-utilities.js @@ -10,15 +10,22 @@ import * as html from '#html'; import {bindOpts} from '#sugar'; import {thumb} from '#urls'; +import { + getDimensionsOfImagePath, + getThumbnailEqualOrSmaller, + getThumbnailsAvailableForDimensions, +} from '#thumbs'; + export function bindUtilities({ absoluteTo, cachebust, defaultLanguage, getSizeOfAdditionalFile, - getSizeOfImageFile, + getSizeOfImagePath, language, languages, pagePath, + thumbsCache, to, urls, wikiData, @@ -30,7 +37,8 @@ export function bindUtilities({ cachebust, defaultLanguage, getSizeOfAdditionalFile, - getSizeOfImageFile, + getSizeOfImagePath, + getThumbnailsAvailableForDimensions, html, language, languages, @@ -46,5 +54,13 @@ export function bindUtilities({ bound.find = bindFind(wikiData, {mode: 'warn'}); + bound.getDimensionsOfImagePath = + (imagePath) => + getDimensionsOfImagePath(imagePath, thumbsCache); + + bound.getThumbnailEqualOrSmaller = + (preferred, imagePath) => + getThumbnailEqualOrSmaller(preferred, imagePath, thumbsCache); + return bound; } diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index 28cf7a48..9889b3f0 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -59,13 +59,14 @@ export async function go({ defaultLanguage, languages, srcRootPath, + thumbsCache, urls, wikiData, cachebust, developersComment, getSizeOfAdditionalFile, - getSizeOfImageFile, + getSizeOfImagePath, niceShowAggregate, }) { const showError = (error) => { @@ -343,10 +344,11 @@ export async function go({ cachebust, defaultLanguage, getSizeOfAdditionalFile, - getSizeOfImageFile, + getSizeOfImagePath, language, languages, pagePath: servePath, + thumbsCache, to, urls, wikiData, @@ -367,10 +369,10 @@ export async function go({ response.writeHead(200, contentTypeHTML); response.end(pageHTML); } catch (error) { - response.writeHead(500, contentTypePlain); - response.end(`Error generating page, view server log for details\n`); console.error(`${requestHead} [500] ${pathname}`); showError(error); + response.writeHead(500, contentTypePlain); + response.end(`Error generating page, view server log for details\n`); } }); diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index 192b7966..82a947c7 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -91,6 +91,7 @@ export async function go({ defaultLanguage, languages, srcRootPath, + thumbsCache, urls, urlSpec, wikiData, @@ -98,7 +99,7 @@ export async function go({ cachebust, developersComment, getSizeOfAdditionalFile, - getSizeOfImageFile, + getSizeOfImagePath, niceShowAggregate, }) { const outputPath = cliOptions['out-path'] || process.env.HSMUSIC_OUT; @@ -298,10 +299,11 @@ export async function go({ cachebust, defaultLanguage, getSizeOfAdditionalFile, - getSizeOfImageFile, + getSizeOfImagePath, language, languages, pagePath, + thumbsCache, to, urls, wikiData, diff --git a/tap-snapshots/test/snapshot/image.js.test.cjs b/tap-snapshots/test/snapshot/image.js.test.cjs index c2f6aea6..e451e1d5 100644 --- a/tap-snapshots/test/snapshot/image.js.test.cjs +++ b/tap-snapshots/test/snapshot/image.js.test.cjs @@ -7,7 +7,7 @@ 'use strict' exports[`test/snapshot/image.js TAP image (snapshot) > content warnings via tags 1`] = `
-
+
cw: too cool for school @@ -19,24 +19,24 @@ exports[`test/snapshot/image.js TAP image (snapshot) > content warnings via tags ` exports[`test/snapshot/image.js TAP image (snapshot) > id with link 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > id with square 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > id without link 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > lazy with square 1`] = ` - -
+ +
` exports[`test/snapshot/image.js TAP image (snapshot) > link with file size 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > source missing 1`] = ` @@ -44,17 +44,17 @@ exports[`test/snapshot/image.js TAP image (snapshot) > source missing 1`] = ` ` exports[`test/snapshot/image.js TAP image (snapshot) > source via path 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > source via src 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > square 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > width & height 1`] = ` -
+
` diff --git a/test/lib/content-function.js b/test/lib/content-function.js index bb12be82..cd86e9bc 100644 --- a/test/lib/content-function.js +++ b/test/lib/content-function.js @@ -49,8 +49,15 @@ export function testContentFunctions(t, message, fn) { thumb, to, urls, + appendIndexHTML: false, + getColors: c => getColors(c, {chroma}), + getDimensionsOfImagePath: () => [600, 600], + getThumbnailEqualOrSmaller: () => 'medium', + getThumbnailsAvailableForDimensions: () => + [['large', 800], ['medium', 400], ['small', 250]], + ...extraDependencies, }, }); diff --git a/test/snapshot/image.js b/test/snapshot/image.js index 6bec1cca..5e12cc25 100644 --- a/test/snapshot/image.js +++ b/test/snapshot/image.js @@ -8,7 +8,7 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => { evaluate.snapshot(message, { name: 'image', extraDependencies: { - getSizeOfImageFile: () => 0, + getSizeOfImagePath: () => 0, }, ...opts, }); @@ -79,7 +79,7 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => { quickSnapshot('link with file size', { extraDependencies: { - getSizeOfImageFile: () => 10 ** 6, + getSizeOfImagePath: () => 10 ** 6, }, slots: { path: ['media.albumCover', 'pingas', 'png'], -- cgit 1.3.0-6-gf8a5 From 145a4a292dea7dccb2bb9b58a760db32948c3918 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 30 May 2023 10:01:00 -0300 Subject: test: snapshot how thumbnail details are exposed in images --- tap-snapshots/test/snapshot/image.js.test.cjs | 4 ++++ test/snapshot/image.js | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/tap-snapshots/test/snapshot/image.js.test.cjs b/tap-snapshots/test/snapshot/image.js.test.cjs index e451e1d5..bee117e1 100644 --- a/tap-snapshots/test/snapshot/image.js.test.cjs +++ b/tap-snapshots/test/snapshot/image.js.test.cjs @@ -55,6 +55,10 @@ exports[`test/snapshot/image.js TAP image (snapshot) > square 1`] = `
` +exports[`test/snapshot/image.js TAP image (snapshot) > thumbnail details 1`] = ` +
+` + exports[`test/snapshot/image.js TAP image (snapshot) > width & height 1`] = `
` diff --git a/test/snapshot/image.js b/test/snapshot/image.js index 5e12cc25..a8796e11 100644 --- a/test/snapshot/image.js +++ b/test/snapshot/image.js @@ -98,4 +98,19 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => { path: ['media.albumCover', 'beyond-canon', 'png'], }, }); + + evaluate.snapshot('thumbnail details', { + name: 'image', + extraDependencies: { + getSizeOfImagePath: () => 0, + getDimensionsOfImagePath: () => [900, 1200], + getThumbnailsAvailableForDimensions: () => + [['voluminous', 1200], ['middling', 900], ['petite', 20]], + getThumbnailEqualOrSmaller: () => 'voluminous', + }, + slots: { + thumb: 'gargantuan', + path: ['media.albumCover', 'beyond-canon', 'png'], + }, + }); }); -- cgit 1.3.0-6-gf8a5 From 8dd2a2fd71fe0e1643201aff87acda8bbcc41295 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 09:37:17 -0300 Subject: test: move thumb-related utilities into image.js snapshot --- test/lib/content-function.js | 7 ------- test/snapshot/image.js | 4 ++++ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/test/lib/content-function.js b/test/lib/content-function.js index cd86e9bc..bb12be82 100644 --- a/test/lib/content-function.js +++ b/test/lib/content-function.js @@ -49,15 +49,8 @@ export function testContentFunctions(t, message, fn) { thumb, to, urls, - appendIndexHTML: false, - getColors: c => getColors(c, {chroma}), - getDimensionsOfImagePath: () => [600, 600], - getThumbnailEqualOrSmaller: () => 'medium', - getThumbnailsAvailableForDimensions: () => - [['large', 800], ['medium', 400], ['small', 250]], - ...extraDependencies, }, }); diff --git a/test/snapshot/image.js b/test/snapshot/image.js index a8796e11..48dcfb69 100644 --- a/test/snapshot/image.js +++ b/test/snapshot/image.js @@ -9,6 +9,10 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => { name: 'image', extraDependencies: { getSizeOfImagePath: () => 0, + getDimensionsOfImagePath: () => [600, 600], + getThumbnailEqualOrSmaller: () => 'medium', + getThumbnailsAvailableForDimensions: () => + [['large', 800], ['medium', 400], ['small', 250]], }, ...opts, }); -- cgit 1.3.0-6-gf8a5 From f25b377e0f06390e33835b5f3f0ea0cc31915173 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 1 Sep 2023 14:02:39 -0300 Subject: client: update image overlay for available thumb sizes --- src/static/client2.js | 71 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/src/static/client2.js b/src/static/client2.js index 0cdb8b0e..0a8eb860 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -709,24 +709,34 @@ function handleImageLinkClicked(evt) { const mainImage = document.getElementById('image-overlay-image'); const thumbImage = document.getElementById('image-overlay-image-thumb'); - const mainThumbSize = getPreferredThumbSize(); + const {href: originalSrc} = evt.target.closest('a'); + const {dataset: { + originalSize: originalFileSize, + thumbs: availableThumbList, + }} = evt.target.closest('a').querySelector('img'); - const source = evt.target.closest('a').href; + updateFileSizeInformation(originalFileSize); - const mainSrc = source.replace(/\.(jpg|png)$/, `.${mainThumbSize}.jpg`); - const thumbSrc = source.replace(/\.(jpg|png)$/, '.small.jpg'); + const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); + const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); + + const mainSrc = originalSrc.replace(/\.(jpg|png)$/, `.${mainThumb}.jpg`); + const thumbSrc = originalSrc.replace(/\.(jpg|png)$/, `.${smallThumb}.jpg`); thumbImage.src = thumbSrc; + + // Show the thumbnail size on each element's data attributes. + // Y'know, just for debugging convenience. + mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; + thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; + for (const viewOriginal of allViewOriginal) { - viewOriginal.href = source; + viewOriginal.href = originalSrc; } mainImage.addEventListener('load', handleMainImageLoaded); mainImage.addEventListener('error', handleMainImageErrored); - const fileSize = evt.target.closest('a').querySelector('img').dataset.originalSize; - updateFileSizeInformation(fileSize); - container.style.setProperty('--download-progress', '0%'); loadImage(mainSrc, progress => { container.style.setProperty('--download-progress', (20 + 0.8 * progress) + '%'); @@ -750,7 +760,21 @@ function handleImageLinkClicked(evt) { } } -function getPreferredThumbSize() { +function parseThumbList(availableThumbList) { + // Parse all the available thumbnail sizes! These are provided by the actual + // content generation on each image. + const defaultThumbList = 'huge:1400 semihuge:1200 large:800 medium:400 small:250' + const availableSizes = + (availableThumbList || defaultThumbList) + .split(' ') + .map(part => part.split(':')) + .map(([thumb, length]) => ({thumb, length: parseInt(length)})) + .sort((a, b) => a.length - b.length); + + return availableSizes; +} + +function getPreferredThumbSize(availableThumbList) { // Assuming a square, the image will be constrained to the lesser window // dimension. Coefficient here matches CSS dimensions for image overlay. const constrainedLength = Math.floor(Math.min( @@ -761,17 +785,30 @@ function getPreferredThumbSize() { // device configurations. const visualLength = window.devicePixelRatio * constrainedLength; - const largeLength = 800; - const semihugeLength = 1200; + const availableSizes = parseThumbList(availableThumbList); + + // Starting from the smallest dimensions, find (and return) the first + // available length which hits a "good enough" threshold - it's got to be + // at least that percent of the way to the actual displayed dimensions. const goodEnoughThreshold = 0.90; - if (Math.floor(visualLength * goodEnoughThreshold) <= largeLength) { - return 'large'; - } else if (Math.floor(visualLength * goodEnoughThreshold) <= semihugeLength) { - return 'semihuge'; - } else { - return 'huge'; + // (The last item is skipped since we'd be falling back to it anyway.) + for (const {thumb, length} of availableSizes.slice(0, -1)) { + if (Math.floor(visualLength * goodEnoughThreshold) <= length) { + return {thumb, length}; + } } + + // If none of the items in the list were big enough to hit the "good enough" + // threshold, just use the largest size available. + return availableSizes[availableSizes.length - 1]; +} + +function getSmallestThumbSize(availableThumbList) { + // Just snag the smallest size. This'll be used for displaying the "preview" + // as the bigger one is loading. + const availableSizes = parseThumbList(availableThumbList); + return availableSizes[0]; } function updateFileSizeInformation(fileSize) { -- cgit 1.3.0-6-gf8a5 From 2d1eaf5ea4c46527406df527f1b4d2fc36d8566e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 28 Aug 2023 14:45:38 -0300 Subject: thumbs: fix how magickThreads controlls queue --- src/gen-thumbs.js | 54 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index f6a8eaaf..51b2c72d 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -263,6 +263,8 @@ async function getSpawnMagick(tool) { return [`${description} (${version})`, fn]; } +// Note: This returns an array of no-argument functions, suitable for passing +// to queue(). function generateImageThumbnails({ filePath, dimensions, @@ -286,10 +288,10 @@ function generateImageThumbnails({ output(name), ]); - return Promise.all( + return ( getThumbnailsAvailableForDimensions(dimensions) .map(([name]) => [name, thumbnailSpec[name]]) - .map(([name, details]) => + .map(([name, details]) => () => promisifyProcess(convert('.' + name, details), false))); } @@ -527,35 +529,45 @@ export default async function genThumbs(mediaPath, { } const failed = []; - const succeeded = []; + const writeMessageFn = () => `Writing image thumbnails. [failed: ${failed.length}]`; + const generateCalls = + entriesToGenerate.flatMap(([filePath, md5]) => + generateImageThumbnails({ + filePath: path.join(mediaPath, filePath), + dimensions: imageToDimensions[filePath], + spawnConvert, + }).map(call => async () => { + try { + await call(); + } catch (error) { + failed.push([filePath, error]); + } + })); + await progressPromiseAll(writeMessageFn, - queue( - entriesToGenerate.map(([filePath, md5]) => () => - generateImageThumbnails({ - filePath: path.join(mediaPath, filePath), - dimensions: imageToDimensions[filePath], - spawnConvert, - }).then( - () => { - updatedCache[filePath] = [md5, ...imageToDimensions[filePath]]; - succeeded.push(filePath); - }, - error => { - failed.push([filePath, error]); - })), - magickThreads)); + queue(generateCalls, magickThreads)); + + // Sort by file path. + failed.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0); + + const failedFilePaths = new Set(failed.map(([filePath]) => filePath)); + + for (const [filePath, md5] of entriesToGenerate) { + if (failedFilePaths.has(filePath)) continue; + updatedCache[filePath] = [md5, ...imageToDimensions[filePath]]; + } if (empty(failed)) { logInfo`Generated all (updated) thumbnails successfully!`; } else { for (const [path, error] of failed) { - logError`Thumbnails failed to generate for ${path} - ${error}`; + logError`Thumbnail failed to generate for ${path} - ${error}`; } - logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`; - logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`; + logWarn`Result is incomplete - the above thumbnails should be checked for errors.`; + logWarn`Successfully generated images won't be regenerated next run, though!`; } try { -- cgit 1.3.0-6-gf8a5 From 75a7b56d3616d384b31757c9537a6e27f4e9b350 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 28 Aug 2023 14:46:06 -0300 Subject: upd8: accept and pass --magick-threads through properly --- src/upd8.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/upd8.js b/src/upd8.js index 2f08204a..2ec231c9 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -233,6 +233,12 @@ async function main() { 'magick-threads': { help: `Process more or fewer thumbnail files at once with ImageMagick when generating thumbnails. (Each ImageMagick thread may also make use of multi-core processing at its own utility.)`, + type: 'value', + validate(threads) { + if (parseInt(threads) !== parseFloat(threads)) return 'an integer'; + if (parseInt(threads) < 0) return 'a counting number or zero'; + return true; + } }, magick: {alias: 'magick-threads'}, -- cgit 1.3.0-6-gf8a5 From 7069268db096ab0aa7a8839a3594efb2d1be8f86 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 4 Sep 2023 20:46:58 -0300 Subject: thumbs: new check-has-thumbs util, others throw for missing info --- src/gen-thumbs.js | 16 ++++++++++++++++ src/write/bind-utilities.js | 5 +++++ 2 files changed, 21 insertions(+) diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 51b2c72d..741cdff3 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -168,11 +168,23 @@ getThumbnailsAvailableForDimensions.all = .map(([name, {size}]) => [name, size]) .sort((a, b) => b[1] - a[1]); +export function checkIfImagePathHasCachedThumbnails(imagePath, cache) { + // Generic utility for checking if the thumbnail cache includes any info for + // the provided image path, so that the other functions don't hard-code the + // cache format. + + return !!cache[imagePath]; +} + export function getDimensionsOfImagePath(imagePath, cache) { // This function is really generic. It takes the gen-thumbs image cache and // returns the dimensions in that cache, so that other functions don't need // to hard-code the cache format. + if (!cache[imagePath]) { + throw new Error(`Expected imagePath to be included in cache, got ${imagePath}`); + } + const [width, height] = cache[imagePath].slice(1); return [width, height]; } @@ -185,6 +197,10 @@ export function getThumbnailEqualOrSmaller(preferred, imagePath, cache) { // one which is being thumbnail-ified, this just returns the name of the // selected thumbnail size. + if (!cache[imagePath]) { + throw new Error(`Expected imagePath to be included in cache, got ${imagePath}`); + } + const {size: preferredSize} = thumbnailSpec[preferred]; const [width, height] = getDimensionsOfImagePath(imagePath, cache); const available = getThumbnailsAvailableForDimensions([width, height]); diff --git a/src/write/bind-utilities.js b/src/write/bind-utilities.js index c32035f1..942cce89 100644 --- a/src/write/bind-utilities.js +++ b/src/write/bind-utilities.js @@ -11,6 +11,7 @@ import {bindOpts} from '#sugar'; import {thumb} from '#urls'; import { + checkIfImagePathHasCachedThumbnails, getDimensionsOfImagePath, getThumbnailEqualOrSmaller, getThumbnailsAvailableForDimensions, @@ -54,6 +55,10 @@ export function bindUtilities({ bound.find = bindFind(wikiData, {mode: 'warn'}); + bound.checkIfImagePathHasCachedThumbnails = + (imagePath) => + checkIfImagePathHasCachedThumbnails(imagePath, thumbsCache); + bound.getDimensionsOfImagePath = (imagePath) => getDimensionsOfImagePath(imagePath, thumbsCache); -- cgit 1.3.0-6-gf8a5 From 669f435f2b86133a8e096b22371ff9fbb1b703b7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 4 Sep 2023 20:51:49 -0300 Subject: content: image: defend against unavailable thumbnail info --- src/content/dependencies/image.js | 68 ++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/src/content/dependencies/image.js b/src/content/dependencies/image.js index 905b3c2e..b5591e6d 100644 --- a/src/content/dependencies/image.js +++ b/src/content/dependencies/image.js @@ -1,7 +1,9 @@ +import {logWarn} from '#cli'; import {empty} from '#sugar'; export default { extraDependencies: [ + 'checkIfImagePathHasCachedThumbnails', 'getDimensionsOfImagePath', 'getSizeOfImagePath', 'getThumbnailEqualOrSmaller', @@ -54,6 +56,7 @@ export default { }, generate(data, slots, { + checkIfImagePathHasCachedThumbnails, getDimensionsOfImagePath, getSizeOfImagePath, getThumbnailEqualOrSmaller, @@ -113,8 +116,27 @@ export default { .replace(/^\//, ''); } + const hasThumbnails = + mediaSrc && + checkIfImagePathHasCachedThumbnails(mediaSrc); + + // Warn for images that *should* have cached thumbnail information but are + // missing from the thumbs cache. + if ( + slots.thumb && + !hasThumbnails && + !mediaSrc.endsWith('.gif') + ) { + logWarn`No thumbnail info cached: ${mediaSrc} - displaying original image here (instead of ${slots.thumb})`; + } + + // Important to note that these might not be set at all, even if + // slots.thumb was provided. let thumbSrc = null; - if (mediaSrc) { + let availableThumbs = null; + let originalLength = null; + + if (hasThumbnails && slots.thumb) { // Note: This provides mediaSrc to getThumbnailEqualOrSmaller, since // it's the identifier which thumbnail utilities use to query from the // thumbnail cache. But we use the result to operate on originalSrc, @@ -122,16 +144,12 @@ export default { // another alternate base path. const selectedSize = getThumbnailEqualOrSmaller(slots.thumb, mediaSrc); thumbSrc = originalSrc.replace(/\.(jpg|png)$/, `.${selectedSize}.jpg`); - } - let originalWidth = null; - let originalHeight = null; - let availableThumbs = null; - if (mediaSrc) { - [originalWidth, originalHeight] = - getDimensionsOfImagePath(mediaSrc); - availableThumbs = - getThumbnailsAvailableForDimensions([originalWidth, originalHeight]); + const dimensions = getDimensionsOfImagePath(mediaSrc); + availableThumbs = getThumbnailsAvailableForDimensions(dimensions); + + const [width, height] = dimensions; + originalLength = Math.max(width, height) } let fileSize = null; @@ -151,19 +169,23 @@ export default { imgAttributes['data-no-image-preview'] = true; } - if (fileSize) { - imgAttributes['data-original-size'] = fileSize; - } + // These attributes are only relevant when a thumbnail are available *and* + // being used. + if (hasThumbnails && slots.thumb) { + if (fileSize) { + imgAttributes['data-original-size'] = fileSize; + } - if (originalWidth && originalHeight) { - imgAttributes['data-original-length'] = Math.max(originalWidth, originalHeight); - } + if (originalLength) { + imgAttributes['data-original-length'] = originalLength; + } - if (!empty(availableThumbs)) { - imgAttributes['data-thumbs'] = - availableThumbs - .map(([name, size]) => `${name}:${size}`) - .join(' '); + if (!empty(availableThumbs)) { + imgAttributes['data-thumbs'] = + availableThumbs + .map(([name, size]) => `${name}:${size}`) + .join(' '); + } } const nonlazyHTML = @@ -171,7 +193,7 @@ export default { prepare( html.tag('img', { ...imgAttributes, - src: thumbSrc, + src: thumbSrc ?? originalSrc, })); if (slots.lazy) { @@ -182,7 +204,7 @@ export default { { ...imgAttributes, class: 'lazy', - 'data-original': thumbSrc, + 'data-original': thumbSrc ?? originalSrc, }), true), ]); -- cgit 1.3.0-6-gf8a5 From 63d9b8c2d455e2f74a837d8772fcfcf8e38e3a3e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 4 Sep 2023 20:52:28 -0300 Subject: client: defend client-side code against images without thumbs --- src/static/client2.js | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/static/client2.js b/src/static/client2.js index 0a8eb860..8ae9876e 100644 --- a/src/static/client2.js +++ b/src/static/client2.js @@ -717,18 +717,32 @@ function handleImageLinkClicked(evt) { updateFileSizeInformation(originalFileSize); - const {thumb: mainThumb, length: mainLength} = getPreferredThumbSize(availableThumbList); - const {thumb: smallThumb, length: smallLength} = getSmallestThumbSize(availableThumbList); - - const mainSrc = originalSrc.replace(/\.(jpg|png)$/, `.${mainThumb}.jpg`); - const thumbSrc = originalSrc.replace(/\.(jpg|png)$/, `.${smallThumb}.jpg`); - - thumbImage.src = thumbSrc; + let mainSrc = null; + let thumbSrc = null; + + 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`); + // Show the thumbnail size on each element's data attributes. + // Y'know, just for debugging convenience. + mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; + thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; + } else { + mainSrc = originalSrc; + thumbSrc = null; + mainImage.dataset.displayingThumb = ''; + thumbImage.dataset.displayingThumb = ''; + } - // Show the thumbnail size on each element's data attributes. - // Y'know, just for debugging convenience. - mainImage.dataset.displayingThumb = `${mainThumb}:${mainLength}`; - thumbImage.dataset.displayingThumb = `${smallThumb}:${smallLength}`; + if (thumbSrc) { + thumbImage.src = thumbSrc; + thumbImage.style.display = null; + } else { + thumbImage.src = ''; + thumbImage.style.display = 'none'; + } for (const viewOriginal of allViewOriginal) { viewOriginal.href = originalSrc; -- cgit 1.3.0-6-gf8a5 From d194fc4f537ee79b0558b54ff2e1fdc3e9cbf4d9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 4 Sep 2023 20:48:54 -0300 Subject: test: update & fix-up image snapshot tests --- tap-snapshots/test/snapshot/image.js.test.cjs | 26 +++++++++++++++----------- test/snapshot/image.js | 12 +++++++++++- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/tap-snapshots/test/snapshot/image.js.test.cjs b/tap-snapshots/test/snapshot/image.js.test.cjs index bee117e1..d87ab714 100644 --- a/tap-snapshots/test/snapshot/image.js.test.cjs +++ b/tap-snapshots/test/snapshot/image.js.test.cjs @@ -7,7 +7,7 @@ 'use strict' exports[`test/snapshot/image.js TAP image (snapshot) > content warnings via tags 1`] = `
-
+
cw: too cool for school @@ -19,24 +19,24 @@ exports[`test/snapshot/image.js TAP image (snapshot) > content warnings via tags ` exports[`test/snapshot/image.js TAP image (snapshot) > id with link 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > id with square 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > id without link 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > lazy with square 1`] = ` - -
+ +
` exports[`test/snapshot/image.js TAP image (snapshot) > link with file size 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > source missing 1`] = ` @@ -44,15 +44,19 @@ exports[`test/snapshot/image.js TAP image (snapshot) > source missing 1`] = ` ` exports[`test/snapshot/image.js TAP image (snapshot) > source via path 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > source via src 1`] = ` -
+
` exports[`test/snapshot/image.js TAP image (snapshot) > square 1`] = ` -
+
+` + +exports[`test/snapshot/image.js TAP image (snapshot) > thumb requested but source is gif 1`] = ` +
` exports[`test/snapshot/image.js TAP image (snapshot) > thumbnail details 1`] = ` @@ -60,5 +64,5 @@ exports[`test/snapshot/image.js TAP image (snapshot) > thumbnail details 1`] = ` ` exports[`test/snapshot/image.js TAP image (snapshot) > width & height 1`] = ` -
+
` diff --git a/test/snapshot/image.js b/test/snapshot/image.js index 48dcfb69..8608ab69 100644 --- a/test/snapshot/image.js +++ b/test/snapshot/image.js @@ -4,15 +4,17 @@ import {testContentFunctions} from '#test-lib'; testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => { await evaluate.load(); - const quickSnapshot = (message, opts) => + const quickSnapshot = (message, {extraDependencies, ...opts}) => evaluate.snapshot(message, { name: 'image', extraDependencies: { + checkIfImagePathHasCachedThumbnails: path => !path.endsWith('.gif'), getSizeOfImagePath: () => 0, getDimensionsOfImagePath: () => [600, 600], getThumbnailEqualOrSmaller: () => 'medium', getThumbnailsAvailableForDimensions: () => [['large', 800], ['medium', 400], ['small', 250]], + ...extraDependencies, }, ...opts, }); @@ -106,6 +108,7 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => { evaluate.snapshot('thumbnail details', { name: 'image', extraDependencies: { + checkIfImagePathHasCachedThumbnails: () => true, getSizeOfImagePath: () => 0, getDimensionsOfImagePath: () => [900, 1200], getThumbnailsAvailableForDimensions: () => @@ -117,4 +120,11 @@ testContentFunctions(t, 'image (snapshot)', async (t, evaluate) => { path: ['media.albumCover', 'beyond-canon', 'png'], }, }); + + quickSnapshot('thumb requested but source is gif', { + slots: { + thumb: 'medium', + path: ['media.flashArt', '5426', 'gif'], + }, + }); }); -- cgit 1.3.0-6-gf8a5 From ddaf34d6e97269719107398569716fc1f1e98073 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 5 Sep 2023 19:03:44 -0300 Subject: infra, test: cleaner output for stubTemplate --- .../snapshot/generatePreviousNextLinks.js.test.cjs | 12 ++++++------ test/lib/content-function.js | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs b/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs index 07268581..fa641830 100644 --- a/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs +++ b/tap-snapshots/test/snapshot/generatePreviousNextLinks.js.test.cjs @@ -6,13 +6,13 @@ */ 'use strict' exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > basic behavior 1`] = ` -previous: {"tooltip":true,"color":false,"attributes":{"id":"previous-button"},"content":"Previous"} -next: {"tooltip":true,"color":false,"attributes":{"id":"next-button"},"content":"Next"} +previous: { tooltip: true, color: false, attributes: { id: 'previous-button' }, content: 'Previous' } +next: { tooltip: true, color: false, attributes: { id: 'next-button' }, content: 'Next' } ` exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > disable id 1`] = ` -previous: {"tooltip":true,"color":false,"attributes":{"id":false},"content":"Previous"} -next: {"tooltip":true,"color":false,"attributes":{"id":false},"content":"Next"} +previous: { tooltip: true, color: false, attributes: { id: false }, content: 'Previous' } +next: { tooltip: true, color: false, attributes: { id: false }, content: 'Next' } ` exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > neither link present 1`] = ` @@ -20,9 +20,9 @@ exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLink ` exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > next missing 1`] = ` -previous: {"tooltip":true,"color":false,"attributes":{"id":"previous-button"},"content":"Previous"} +previous: { tooltip: true, color: false, attributes: { id: 'previous-button' }, content: 'Previous' } ` exports[`test/snapshot/generatePreviousNextLinks.js TAP generatePreviousNextLinks (snapshot) > previous missing 1`] = ` -next: {"tooltip":true,"color":false,"attributes":{"id":"next-button"},"content":"Next"} +next: { tooltip: true, color: false, attributes: { id: 'next-button' }, content: 'Next' } ` diff --git a/test/lib/content-function.js b/test/lib/content-function.js index bb12be82..b706cd8c 100644 --- a/test/lib/content-function.js +++ b/test/lib/content-function.js @@ -1,5 +1,6 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; +import {inspect} from 'node:util'; import chroma from 'chroma-js'; @@ -99,7 +100,7 @@ export function testContentFunctions(t, message, fn) { constructor() { super({ - content: () => `${name}: ${JSON.stringify(this.#slotValues)}`, + content: () => this.#getContent(this), }); } @@ -110,6 +111,24 @@ export function testContentFunctions(t, message, fn) { setSlot(slotName, slotValue) { this.#slotValues[slotName] = slotValue; } + + #getContent() { + const toInspect = + Object.fromEntries( + Object.entries(this.#slotValues) + .filter(([key, value]) => value !== null)); + + const inspected = + inspect(toInspect, { + breakLength: Infinity, + colors: false, + compact: true, + depth: Infinity, + sort: true, + }); + + return `${name}: ${inspected}`; + } }); }; -- cgit 1.3.0-6-gf8a5 From ae1131e54280da63a678eb3489b02a9fb292ee8b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 5 Sep 2023 19:39:51 -0300 Subject: infra, test: new stubContentFunction utility Just like stubTemplate, but the result is ready for passing to evaluate.load's {mock} option, and the template's content is formatted to include the content function's provided arguments as well. --- test/lib/content-function.js | 109 +++++++++++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 31 deletions(-) diff --git a/test/lib/content-function.js b/test/lib/content-function.js index b706cd8c..5cb499b1 100644 --- a/test/lib/content-function.js +++ b/test/lib/content-function.js @@ -91,45 +91,92 @@ export function testContentFunctions(t, message, fn) { t.matchSnapshot(result, description); }; - evaluate.stubTemplate = name => { + evaluate.stubTemplate = name => // Creates a particularly permissable template, allowing any slot values // to be stored and just outputting the contents of those slots as-are. + _stubTemplate(name, false); - return new (class extends html.Template { - #slotValues = {}; + evaluate.stubContentFunction = name => + // Like stubTemplate, but instead of a template directly, returns + // an object describing a content function - suitable for passing + // into evaluate.mock. + _stubTemplate(name, true); - constructor() { - super({ - content: () => this.#getContent(this), - }); - } - - setSlots(slotNamesToValues) { - Object.assign(this.#slotValues, slotNamesToValues); - } + const _stubTemplate = (name, mockContentFunction) => { + const inspectNicely = (value, opts = {}) => + inspect(value, { + ...opts, + colors: false, + sort: true, + }); - setSlot(slotName, slotValue) { - this.#slotValues[slotName] = slotValue; - } + const makeTemplate = formatContentFn => + new (class extends html.Template { + #slotValues = {}; - #getContent() { - const toInspect = - Object.fromEntries( - Object.entries(this.#slotValues) - .filter(([key, value]) => value !== null)); - - const inspected = - inspect(toInspect, { - breakLength: Infinity, - colors: false, - compact: true, - depth: Infinity, - sort: true, + constructor() { + super({ + content: () => this.#getContent(formatContentFn), }); + } - return `${name}: ${inspected}`; - } - }); + setSlots(slotNamesToValues) { + Object.assign(this.#slotValues, slotNamesToValues); + } + + setSlot(slotName, slotValue) { + this.#slotValues[slotName] = slotValue; + } + + #getContent(formatContentFn) { + const toInspect = + Object.fromEntries( + Object.entries(this.#slotValues) + .filter(([key, value]) => value !== null)); + + const inspected = + inspectNicely(toInspect, { + breakLength: Infinity, + compact: true, + depth: Infinity, + }); + + return formatContentFn(inspected); `${name}: ${inspected}`; + } + }); + + if (mockContentFunction) { + return { + data: (...args) => ({args}), + generate: (data) => + makeTemplate(slots => { + const argsLines = + (empty(data.args) + ? [] + : inspectNicely(data.args, {depth: Infinity}) + .split('\n')); + + return (`[mocked: ${name}` + + + (empty(data.args) + ? `` + : argsLines.length === 1 + ? `\n args: ${argsLines[0]}` + : `\n args: ${argsLines[0]}\n` + + argsLines.slice(1).join('\n').replace(/^/gm, ' ')) + + + (!empty(data.args) + ? `\n ` + : ` - `) + + + (slots + ? `slots: ${slots}]` + : `slots: none]`)); + }), + }; + } else { + return makeTemplate(slots => `${name}: ${slots}`); + } }; evaluate.mock = (...opts) => { -- cgit 1.3.0-6-gf8a5 From de0b85d81e6392597a35196bde523b9642d7e016 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 5 Sep 2023 20:09:03 -0300 Subject: test: update snapshot tests to always mock image dependency --- .../snapshot/generateAlbumCoverArtwork.js.test.cjs | 37 ++++++++++-------- .../test/snapshot/generateCoverArtwork.js.test.cjs | 37 ++++++++++-------- .../snapshot/generateTrackCoverArtwork.js.test.cjs | 45 +++++++++++++--------- .../test/snapshot/transformContent.js.test.cjs | 6 +-- test/snapshot/generateAlbumCoverArtwork.js | 14 +++---- test/snapshot/generateCoverArtwork.js | 12 +++--- test/snapshot/generateTrackCoverArtwork.js | 14 +++---- test/snapshot/transformContent.js | 17 +++++--- 8 files changed, 97 insertions(+), 85 deletions(-) diff --git a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs index d787df68..017ab0e4 100644 --- a/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateAlbumCoverArtwork.js.test.cjs @@ -7,26 +7,29 @@ 'use strict' exports[`test/snapshot/generateAlbumCoverArtwork.js TAP generateAlbumCoverArtwork (snapshot) > display: primary 1`] = `
- -
-
-
-
- - - cw: creepy crawlies -
- click to show -
-
-
-
-
-
+ [mocked: image + args: [ + [ + { name: 'Damara', directory: 'damara', isContentWarning: false }, + { name: 'Cronus', directory: 'cronus', isContentWarning: false }, + { name: 'Bees', directory: 'bees', isContentWarning: false }, + { name: 'creepy crawlies', isContentWarning: true } + ] + ] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]

Tags: Damara, Cronus, Bees

` exports[`test/snapshot/generateAlbumCoverArtwork.js TAP generateAlbumCoverArtwork (snapshot) > display: thumbnail 1`] = ` -
+[mocked: image + args: [ + [ + { name: 'Damara', directory: 'damara', isContentWarning: false }, + { name: 'Cronus', directory: 'cronus', isContentWarning: false }, + { name: 'Bees', directory: 'bees', isContentWarning: false }, + { name: 'creepy crawlies', isContentWarning: true } + ] + ] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'small', reveal: false, link: false, square: true }] ` diff --git a/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs index 88be76ea..c1c880bc 100644 --- a/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateCoverArtwork.js.test.cjs @@ -7,26 +7,29 @@ 'use strict' exports[`test/snapshot/generateCoverArtwork.js TAP generateCoverArtwork (snapshot) > display: primary 1`] = `
- -
-
-
-
- - - cw: creepy crawlies -
- click to show -
-
-
-
-
-
+ [mocked: image + args: [ + [ + { name: 'Damara', directory: 'damara', isContentWarning: false }, + { name: 'Cronus', directory: 'cronus', isContentWarning: false }, + { name: 'Bees', directory: 'bees', isContentWarning: false }, + { name: 'creepy crawlies', isContentWarning: true } + ] + ] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]

Tags: Damara, Cronus, Bees

` exports[`test/snapshot/generateCoverArtwork.js TAP generateCoverArtwork (snapshot) > display: thumbnail 1`] = ` -
+[mocked: image + args: [ + [ + { name: 'Damara', directory: 'damara', isContentWarning: false }, + { name: 'Cronus', directory: 'cronus', isContentWarning: false }, + { name: 'Bees', directory: 'bees', isContentWarning: false }, + { name: 'creepy crawlies', isContentWarning: true } + ] + ] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'small', reveal: false, link: false, square: true }] ` diff --git a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs index 92216a89..33b5d155 100644 --- a/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateTrackCoverArtwork.js.test.cjs @@ -7,37 +7,44 @@ 'use strict' exports[`test/snapshot/generateTrackCoverArtwork.js TAP generateTrackCoverArtwork (snapshot) > display: primary - no unique art 1`] = `
- -
-
-
-
- - - cw: creepy crawlies -
- click to show -
-
-
-
-
-
+ [mocked: image + args: [ + [ + { name: 'Damara', directory: 'damara', isContentWarning: false }, + { name: 'Cronus', directory: 'cronus', isContentWarning: false }, + { name: 'Bees', directory: 'bees', isContentWarning: false }, + { name: 'creepy crawlies', isContentWarning: true } + ] + ] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]

Tags: Damara, Cronus, Bees

` exports[`test/snapshot/generateTrackCoverArtwork.js TAP generateTrackCoverArtwork (snapshot) > display: primary - unique art 1`] = `
-
+ [mocked: image + args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ] + slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], thumb: 'medium', id: 'cover-art', reveal: true, link: true, square: true }]

Tags: Bees

` exports[`test/snapshot/generateTrackCoverArtwork.js TAP generateTrackCoverArtwork (snapshot) > display: thumbnail - no unique art 1`] = ` -
+[mocked: image + args: [ + [ + { name: 'Damara', directory: 'damara', isContentWarning: false }, + { name: 'Cronus', directory: 'cronus', isContentWarning: false }, + { name: 'Bees', directory: 'bees', isContentWarning: false }, + { name: 'creepy crawlies', isContentWarning: true } + ] + ] + slots: { path: [ 'media.albumCover', 'bee-forus-seatbelt-safebee', 'png' ], thumb: 'small', reveal: false, link: false, square: true }] ` exports[`test/snapshot/generateTrackCoverArtwork.js TAP generateTrackCoverArtwork (snapshot) > display: thumbnail - unique art 1`] = ` -
+[mocked: image + args: [ [ { name: 'Bees', directory: 'bees', isContentWarning: false } ] ] + slots: { path: [ 'media.trackCover', 'bee-forus-seatbelt-safebee', 'beesmp3', 'jpg' ], thumb: 'small', reveal: false, link: false, square: true }] ` diff --git a/tap-snapshots/test/snapshot/transformContent.js.test.cjs b/tap-snapshots/test/snapshot/transformContent.js.test.cjs index d144cf12..4af6b147 100644 --- a/tap-snapshots/test/snapshot/transformContent.js.test.cjs +++ b/tap-snapshots/test/snapshot/transformContent.js.test.cjs @@ -53,16 +53,16 @@ How it goes

` exports[`test/snapshot/transformContent.js TAP transformContent (snapshot) > non-inline image #1 1`] = ` -
+
[mocked: image - slots: { src: 'spark.png', link: true, thumb: 'large' }]
` exports[`test/snapshot/transformContent.js TAP transformContent (snapshot) > non-inline image #2 1`] = `

Rad.

-
+
[mocked: image - slots: { src: 'spark.png', link: true, thumb: 'large' }]
` exports[`test/snapshot/transformContent.js TAP transformContent (snapshot) > non-inline image #3 1`] = ` -
+
[mocked: image - slots: { src: 'spark.png', link: true, thumb: 'large' }]

Baller.

` diff --git a/test/snapshot/generateAlbumCoverArtwork.js b/test/snapshot/generateAlbumCoverArtwork.js index 98632d39..b1c7885f 100644 --- a/test/snapshot/generateAlbumCoverArtwork.js +++ b/test/snapshot/generateAlbumCoverArtwork.js @@ -1,12 +1,14 @@ import t from 'tap'; + +import contentFunction from '#content-function'; import {testContentFunctions} from '#test-lib'; testContentFunctions(t, 'generateAlbumCoverArtwork (snapshot)', async (t, evaluate) => { - await evaluate.load(); - - const extraDependencies = { - getSizeOfImageFile: () => 0, - }; + await evaluate.load({ + mock: { + image: evaluate.stubContentFunction('image'), + }, + }); const album = { directory: 'bee-forus-seatbelt-safebee', @@ -23,13 +25,11 @@ testContentFunctions(t, 'generateAlbumCoverArtwork (snapshot)', async (t, evalua name: 'generateAlbumCoverArtwork', args: [album], slots: {mode: 'primary'}, - extraDependencies, }); evaluate.snapshot('display: thumbnail', { name: 'generateAlbumCoverArtwork', args: [album], slots: {mode: 'thumbnail'}, - extraDependencies, }); }); diff --git a/test/snapshot/generateCoverArtwork.js b/test/snapshot/generateCoverArtwork.js index 21c91454..e35dd8d0 100644 --- a/test/snapshot/generateCoverArtwork.js +++ b/test/snapshot/generateCoverArtwork.js @@ -2,11 +2,11 @@ import t from 'tap'; import {testContentFunctions} from '#test-lib'; testContentFunctions(t, 'generateCoverArtwork (snapshot)', async (t, evaluate) => { - await evaluate.load(); - - const extraDependencies = { - getSizeOfImageFile: () => 0, - }; + await evaluate.load({ + mock: { + image: evaluate.stubContentFunction('image', {mock: true}), + }, + }); const artTags = [ {name: 'Damara', directory: 'damara', isContentWarning: false}, @@ -21,13 +21,11 @@ testContentFunctions(t, 'generateCoverArtwork (snapshot)', async (t, evaluate) = name: 'generateCoverArtwork', args: [artTags], slots: {path, mode: 'primary'}, - extraDependencies, }); evaluate.snapshot('display: thumbnail', { name: 'generateCoverArtwork', args: [artTags], slots: {path, mode: 'thumbnail'}, - extraDependencies, }); }); diff --git a/test/snapshot/generateTrackCoverArtwork.js b/test/snapshot/generateTrackCoverArtwork.js index 9e154703..03a181e7 100644 --- a/test/snapshot/generateTrackCoverArtwork.js +++ b/test/snapshot/generateTrackCoverArtwork.js @@ -2,11 +2,11 @@ import t from 'tap'; import {testContentFunctions} from '#test-lib'; testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evaluate) => { - await evaluate.load(); - - const extraDependencies = { - getSizeOfImageFile: () => 0, - }; + await evaluate.load({ + mock: { + image: evaluate.stubContentFunction('image'), + }, + }); const album = { directory: 'bee-forus-seatbelt-safebee', @@ -37,27 +37,23 @@ testContentFunctions(t, 'generateTrackCoverArtwork (snapshot)', async (t, evalua name: 'generateTrackCoverArtwork', args: [track1], slots: {mode: 'primary'}, - extraDependencies, }); evaluate.snapshot('display: thumbnail - unique art', { name: 'generateTrackCoverArtwork', args: [track1], slots: {mode: 'thumbnail'}, - extraDependencies, }); evaluate.snapshot('display: primary - no unique art', { name: 'generateTrackCoverArtwork', args: [track2], slots: {mode: 'primary'}, - extraDependencies, }); evaluate.snapshot('display: thumbnail - no unique art', { name: 'generateTrackCoverArtwork', args: [track2], slots: {mode: 'thumbnail'}, - extraDependencies, }); }); diff --git a/test/snapshot/transformContent.js b/test/snapshot/transformContent.js index 25952856..b05beac1 100644 --- a/test/snapshot/transformContent.js +++ b/test/snapshot/transformContent.js @@ -2,7 +2,11 @@ import t from 'tap'; import {testContentFunctions} from '#test-lib'; testContentFunctions(t, 'transformContent (snapshot)', async (t, evaluate) => { - await evaluate.load(); + await evaluate.load({ + mock: { + image: evaluate.stubContentFunction('image'), + }, + }); const extraDependencies = { wikiData: { @@ -11,8 +15,6 @@ testContentFunctions(t, 'transformContent (snapshot)', async (t, evaluate) => { ], }, - getSizeOfImageFile: () => 0, - to: (key, ...args) => `to-${key}/${args.join('/')}`, }; @@ -50,15 +52,18 @@ testContentFunctions(t, 'transformContent (snapshot)', async (t, evaluate) => { quickSnapshot( 'non-inline image #2', - `Rad.\n`); + `Rad.\n` + + ``); quickSnapshot( 'non-inline image #3', - `\nBaller.`); + `\n` + + `Baller.`); quickSnapshot( 'dates', - `[[date:2023-04-13]] Yep!\nVery nice: [[date:25 October 2413]]`); + `[[date:2023-04-13]] Yep!\n` + + `Very nice: [[date:25 October 2413]]`); quickSnapshot( 'super basic string', -- cgit 1.3.0-6-gf8a5 From aea700dc531b5183ad20c6fcbf8643aef3f102df Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 5 Sep 2023 20:24:08 -0300 Subject: test: fix & update generateAlbumSecondaryNav snapshot test --- .../test/snapshot/generateAlbumSecondaryNav.js.test.cjs | 16 ++++++++-------- test/snapshot/generateAlbumSecondaryNav.js | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs b/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs index f84827ae..032fdc05 100644 --- a/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs +++ b/tap-snapshots/test/snapshot/generateAlbumSecondaryNav.js.test.cjs @@ -7,27 +7,27 @@ 'use strict' exports[`test/snapshot/generateAlbumSecondaryNav.js TAP generateAlbumSecondaryNav (snapshot) > basic behavior, mode: album 1`] = ` ` exports[`test/snapshot/generateAlbumSecondaryNav.js TAP generateAlbumSecondaryNav (snapshot) > basic behavior, mode: track 1`] = ` ` exports[`test/snapshot/generateAlbumSecondaryNav.js TAP generateAlbumSecondaryNav (snapshot) > dateless album in mixed group 1`] = ` ` diff --git a/test/snapshot/generateAlbumSecondaryNav.js b/test/snapshot/generateAlbumSecondaryNav.js index a5cb2e91..709b062e 100644 --- a/test/snapshot/generateAlbumSecondaryNav.js +++ b/test/snapshot/generateAlbumSecondaryNav.js @@ -6,8 +6,8 @@ testContentFunctions(t, 'generateAlbumSecondaryNav (snapshot)', async (t, evalua let album, group1, group2; - group1 = {name: 'VCG', directory: 'vcg'}; - group2 = {name: 'Bepis', directory: 'bepis'}; + group1 = {name: 'VCG', directory: 'vcg', color: '#abcdef'}; + group2 = {name: 'Bepis', directory: 'bepis', color: '#123456'}; album = { date: new Date('2010-04-13'), -- cgit 1.3.0-6-gf8a5 From 0ff743b1350b1d42ba23d9701a0b7acfb7501254 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 5 Sep 2023 20:32:13 -0300 Subject: content: linkTemplate: handle null href w/ hash cleanly --- src/content/dependencies/linkTemplate.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/linkTemplate.js b/src/content/dependencies/linkTemplate.js index 1cf64c59..ba7c7cda 100644 --- a/src/content/dependencies/linkTemplate.js +++ b/src/content/dependencies/linkTemplate.js @@ -29,14 +29,16 @@ export default { language, to, }) { - let href = slots.href; + let href; let style; let title; - if (href) { - href = encodeURI(href); + if (slots.href) { + href = encodeURI(slots.href); } else if (!empty(slots.path)) { href = to(...slots.path); + } else { + href = ''; } if (appendIndexHTML) { -- cgit 1.3.0-6-gf8a5 From 218a99a3164e8ae6967335190b72fd36275d1892 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 10:58:55 -0300 Subject: data, test: track: inherit album props more declaratively --- src/data/things/track.js | 225 +++++++++++++++++------------------------ src/data/yaml.js | 6 +- test/unit/data/things/track.js | 55 ++++++---- 3 files changed, 131 insertions(+), 155 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index e176acb4..39c2930f 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -44,59 +44,58 @@ export class Track extends Thing { sampledTracksByRef: Thing.common.referenceList(Track), artTagsByRef: Thing.common.referenceList(ArtTag), - hasCoverArt: { + // Disables presenting the track as though it has its own unique artwork. + // This flag should only be used in select circumstances, i.e. to override + // an album's trackCoverArtists. This flag supercedes that property, as well + // as the track's own coverArtists. + disableUniqueCoverArt: Thing.common.flag(), + + // File extension for track's corresponding media file. This represents the + // track's unique cover artwork, if any, and does not inherit the cover's + // main artwork. (It does inherit `trackCoverArtFileExtension` if present + // on the album.) + coverArtFileExtension: { flags: {update: true, expose: true}, - update: { - validate(value) { - if (value !== false) { - throw new TypeError(`Expected false or null`); - } + update: {validate: isFileExtension}, - return true; - }, - }, + expose: Track.withAlbumProperties(['trackCoverArtistContribsByRef', 'trackCoverArtFileExtension'], { + dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (hasCoverArt, { - albumData, + transform(coverArtFileExtension, { coverArtistContribsByRef, - [Track.instance]: track, - }) => - Track.hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ), - }, + disableUniqueCoverArt, + album: {trackCoverArtistContribsByRef, trackCoverArtFileExtension}, + }) { + if (disableUniqueCoverArt) return null; + if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; + return coverArtFileExtension ?? trackCoverArtFileExtension ?? 'jpg'; + }, + }), }, - coverArtFileExtension: { + // Date of cover art release. Like coverArtFileExtension, this represents + // only the track's own unique cover artwork, if any. This exposes only as + // the track's own coverArtDate or its album's trackArtDate, so if neither + // is specified, this value is null. + coverArtDate: { flags: {update: true, expose: true}, - update: {validate: isFileExtension}, + update: {validate: isDate}, - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (coverArtFileExtension, { - albumData, + expose: Track.withAlbumProperties(['trackArtDate', 'trackCoverArtistContribsByRef'], { + dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], + + transform(coverArtDate, { coverArtistContribsByRef, - hasCoverArt, - [Track.instance]: track, - }) => - coverArtFileExtension ?? - (Track.hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) - ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension - : Track.findAlbum(track, albumData)?.coverArtFileExtension) ?? - 'jpg', - }, + disableUniqueCoverArt, + album: {trackArtDate, trackCoverArtistContribsByRef}, + }) { + if (disableUniqueCoverArt) return null; + if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; + return coverArtDate ?? trackArtDate; + }, + }), }, originalReleaseTrackByRef: Thing.common.singleReference(Track), @@ -170,53 +169,29 @@ export class Track extends Thing { }, }, - coverArtDate: { - flags: {update: true, expose: true}, - - update: {validate: isDate}, - - expose: { - dependencies: [ - 'albumData', - 'coverArtistContribsByRef', - 'dateFirstReleased', - 'hasCoverArt', - ], - transform: (coverArtDate, { - albumData, - coverArtistContribsByRef, - dateFirstReleased, - hasCoverArt, - [Track.instance]: track, - }) => - (Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt) - ? coverArtDate ?? - dateFirstReleased ?? - Track.findAlbum(track, albumData)?.trackArtDate ?? - Track.findAlbum(track, albumData)?.date ?? - null - : null), - }, - }, - + // Whether or not the track has "unique" cover artwork - a cover which is + // specifically associated with this track in particular, rather than with + // the track's album as a whole. This is typically used to select between + // displaying the track artwork and a fallback, such as the album artwork + // or a placeholder. (This property is named hasUniqueCoverArt instead of + // the usual hasCoverArt to emphasize that it does not inherit from the + // album.) hasUniqueCoverArt: { flags: {expose: true}, - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef', 'hasCoverArt'], - compute: ({ - albumData, + expose: Track.withAlbumProperties(['trackCoverArtistContribsByRef'], { + dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], + compute({ coverArtistContribsByRef, - hasCoverArt, - [Track.instance]: track, - }) => - Track.hasUniqueCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ), - }, + disableUniqueCoverArt, + album: {trackCoverArtistContribsByRef}, + }) { + if (disableUniqueCoverArt) return false; + if (!empty(coverArtistContribsByRef)) true; + if (!empty(trackCoverArtistContribsByRef)) return true; + return false; + }, + }), }, originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( @@ -342,53 +317,6 @@ export class Track extends Thing { ), }); - // This is a quick utility function for now, since the same code is reused in - // several places. Ideally it wouldn't be - we'd just reuse the `album` - // property - but support for that hasn't been coded yet :P - static findAlbum = (track, albumData) => - albumData?.find((album) => album.tracks.includes(track)); - - // Another reused utility function. This one's logic is a bit more complicated. - static hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) { - if (!empty(coverArtistContribsByRef)) { - return true; - } - - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; - } - - return false; - } - - static hasUniqueCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) { - if (!empty(coverArtistContribsByRef)) { - return true; - } - - if (hasCoverArt === false) { - return false; - } - - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; - } - - return false; - } - static inheritFromOriginalRelease( originalProperty, originalMissingValue, @@ -423,6 +351,39 @@ export class Track extends Thing { }; } + static withAlbumProperties(albumProperties, oldExpose) { + const applyAlbumDependency = dependencies => { + const track = dependencies[Track.instance]; + const album = + dependencies.albumData + ?.find((album) => album.tracks.includes(track)); + + const filteredAlbum = Object.create(null); + for (const property of albumProperties) { + filteredAlbum[property] = + (album + ? album[property] + : null); + } + + return {...dependencies, album: filteredAlbum}; + }; + + const newExpose = {dependencies: [...oldExpose.dependencies, 'albumData']}; + + if (oldExpose.compute) { + newExpose.compute = dependencies => + oldExpose.compute(applyAlbumDependency(dependencies)); + } + + if (oldExpose.transform) { + newExpose.transform = (value, dependencies) => + oldExpose.transform(value, applyAlbumDependency(dependencies)); + } + + return newExpose; + } + [inspect.custom]() { const base = Thing.prototype[inspect.custom].apply(this); diff --git a/src/data/yaml.js b/src/data/yaml.js index 35943199..13412f17 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -316,6 +316,10 @@ export const processTrackDocument = makeProcessDocument(T.Track, { 'Date First Released': (value) => new Date(value), 'Cover Art Date': (value) => new Date(value), + 'Has Cover Art': (value) => + (value === true ? false : + value === false ? true : + value), 'Artists': parseContributors, 'Contributors': parseContributors, @@ -336,7 +340,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, { dateFirstReleased: 'Date First Released', coverArtDate: 'Cover Art Date', coverArtFileExtension: 'Cover Art File Extension', - hasCoverArt: 'Has Cover Art', + disableCoverArt: 'Has Cover Art', // This gets transformed to flip true/false. lyrics: 'Lyrics', commentary: 'Commentary', diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 383e3e3f..d0e50c7f 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -3,6 +3,7 @@ import thingConstructors from '#things'; const { Album, + Artist, Thing, Track, TrackGroup, @@ -20,55 +21,65 @@ function stubAlbum(tracks) { } t.test(`Track.coverArtDate`, t => { - t.plan(5); + t.plan(6); - // Priority order is as follows, with the last (trackCoverArtDate) being - // greatest priority. - const albumDate = new Date('2010-10-10'); const albumTrackArtDate = new Date('2012-12-12'); - const trackDateFirstReleased = new Date('2008-08-08'); const trackCoverArtDate = new Date('2009-09-09'); + const dummyContribs = [{who: 'Test Artist', what: null}] const track = new Track(); track.directory = 'foo'; + track.coverArtistContribsByRef = dummyContribs; const album = stubAlbum([track]); + const artist = new Artist(); + artist.name = 'Test Artist'; + track.albumData = [album]; + track.artistData = [artist]; + + const XXX_CLEAR_TRACK_ALBUM_CACHE = () => { + // XXX clear cache so change in album's property is reflected + track.albumData = []; + track.albumData = [album]; + }; // 1. coverArtDate defaults to null t.equal(track.coverArtDate, null); - // 2. coverArtDate inherits album release date + // 2. coverArtDate inherits album trackArtDate - album.date = albumDate; + album.trackArtDate = albumTrackArtDate; - // XXX clear cache so change in album's property is reflected - track.albumData = []; - track.albumData = [album]; + XXX_CLEAR_TRACK_ALBUM_CACHE(); - t.equal(track.coverArtDate, albumDate); + t.equal(track.coverArtDate, albumTrackArtDate); - // 3. coverArtDate inherits album trackArtDate + // 3. coverArtDate is own value - album.trackArtDate = albumTrackArtDate; + track.coverArtDate = trackCoverArtDate; - // XXX clear cache again - track.albumData = []; - track.albumData = [album]; + t.equal(track.coverArtDate, trackCoverArtDate); - t.equal(track.coverArtDate, albumTrackArtDate); + // 4. coverArtDate is null if track is missing coverArtists - // 4. coverArtDate is overridden dateFirstReleased + track.coverArtistContribsByRef = []; - track.dateFirstReleased = trackDateFirstReleased; + t.equal(track.coverArtDate, null); - t.equal(track.coverArtDate, trackDateFirstReleased); + // 5. coverArtDate is not null if album specifies trackCoverArtistContribs - // 5. coverArtDate is overridden coverArtDate + album.trackCoverArtistContribsByRef = dummyContribs; - track.coverArtDate = trackCoverArtDate; + XXX_CLEAR_TRACK_ALBUM_CACHE(); t.equal(track.coverArtDate, trackCoverArtDate); + + // 6. coverArtDate is null if track disables unique cover artwork + + track.disableUniqueCoverArt = true; + + t.equal(track.coverArtDate, null); }); -- cgit 1.3.0-6-gf8a5 From 55e4afead38bc541cba4ae1cef183527c254f99a Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 17:28:15 -0300 Subject: data: track: experimental Thing.compose.from() processing style --- src/data/things/thing.js | 142 ++++++++++++++++++++++- src/data/things/track.js | 290 ++++++++++++++++++++++++----------------------- 2 files changed, 289 insertions(+), 143 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index c2876f56..143c1515 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -5,7 +5,7 @@ import {inspect} from 'node:util'; import {color} from '#cli'; import find from '#find'; -import {empty} from '#sugar'; +import {empty, openAggregate} from '#sugar'; import {getKebabCase} from '#wiki-data'; import { @@ -418,4 +418,144 @@ export default class Thing extends CacheableObject { return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; } + + static findArtistsFromContribs(contribsByRef, artistData) { + return ( + contribsByRef + .map(({who, what}) => ({ + who: find.artist(who, artistData), + what, + })) + .filter(({who}) => who)); + } + + static composite = { + from(composition) { + const base = composition.at(-1); + const steps = composition.slice(0, -1); + + const aggregate = openAggregate({message: `Errors preparing Thing.composite.from() composition`}); + + if (base.flags.compose) { + aggregate.push(new TypeError(`Base (bottom item) must not be {compose: true}`)); + } + + const exposeFunctionOrder = []; + const exposeDependencies = new Set(base.expose?.dependencies); + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const message = + (step.annotation + ? `Errors in step #${i + 1} (${step.annotation})` + : `Errors in step #${i + 1}`); + + aggregate.nest({message}, ({push}) => { + if (!step.flags.compose) { + push(new TypeError(`Steps (all but bottom item) must be {compose: true}`)); + } + + if (step.flags.update) { + push(new Error(`Steps which update aren't supported yet`)); + } + + if (step.flags.expose) expose: { + if (!step.expose.transform && !step.expose.compute) { + push(new TypeError(`Steps which expose must provide at least one of transform or compute`)); + break expose; + } + + if (step.expose.dependencies) { + for (const dependency of step.expose.dependencies) { + exposeDependencies.add(dependency); + } + } + + if (base.flags.update) { + if (step.expose.transform) { + exposeFunctionOrder.push({type: 'transform', fn: step.expose.transform}); + } else { + exposeFunctionOrder.push({type: 'compute', fn: step.expose.compute}); + } + } else { + if (step.expose.transform && !step.expose.compute) { + push(new TypeError(`Steps which only transform can't be composed with a non-updating base`)); + break expose; + } + + exposeFunctionOrder.push({type: 'compute', fn: step.expose.compute}); + } + } + }); + } + + aggregate.close(); + + const constructedDescriptor = {}; + + constructedDescriptor.flags = { + update: !!base.flags.update, + expose: !!base.flags.expose, + compose: false, + }; + + if (base.flags.update) { + constructedDescriptor.update = base.flags.update; + } + + if (base.flags.expose) { + const expose = constructedDescriptor.expose = {}; + expose.dependencies = Array.from(exposeDependencies); + + const continuationSymbol = Symbol(); + + if (base.flags.update) { + expose.transform = (value, initialDependencies) => { + const dependencies = {...initialDependencies}; + let valueSoFar = value; + + for (const {type, fn} of exposeFunctionOrder) { + const result = + (type === 'transform' + ? fn(valueSoFar, dependencies, (updatedValue, providedDependencies) => { + valueSoFar = updatedValue; + Object.assign(dependencies, providedDependencies ?? {}); + return continuationSymbol; + }) + : fn(dependencies, providedDependencies => { + Object.assign(dependencies, providedDependencies ?? {}); + return continuationSymbol; + })); + + if (result !== continuationSymbol) { + return result; + } + } + + return base.expose.transform(valueSoFar, dependencies); + }; + } else { + expose.compute = (initialDependencies) => { + const dependencies = {...initialDependencies}; + + for (const {fn} of exposeFunctionOrder) { + const result = + fn(valueSoFar, dependencies, providedDependencies => { + Object.assign(dependencies, providedDependencies ?? {}); + return continuationSymbol; + }); + + if (result !== continuationSymbol) { + return result; + } + } + + return base.expose.compute(dependencies); + }; + } + } + + return constructedDescriptor; + }, + }; } diff --git a/src/data/things/track.js b/src/data/things/track.js index 39c2930f..fe6af205 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -54,49 +54,53 @@ export class Track extends Thing { // track's unique cover artwork, if any, and does not inherit the cover's // main artwork. (It does inherit `trackCoverArtFileExtension` if present // on the album.) - coverArtFileExtension: { - flags: {update: true, expose: true}, - - update: {validate: isFileExtension}, - - expose: Track.withAlbumProperties(['trackCoverArtistContribsByRef', 'trackCoverArtFileExtension'], { - dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], - - transform(coverArtFileExtension, { - coverArtistContribsByRef, - disableUniqueCoverArt, - album: {trackCoverArtistContribsByRef, trackCoverArtFileExtension}, - }) { - if (disableUniqueCoverArt) return null; - if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; - return coverArtFileExtension ?? trackCoverArtFileExtension ?? 'jpg'; + coverArtFileExtension: Thing.composite.from([ + Track.withAlbumProperties(['trackCoverArtistContribsByRef', 'trackCoverArtFileExtension']), + + { + flags: {update: true, expos: true}, + update: {validate: isFileExtension}, + expose: { + dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], + + transform(coverArtFileExtension, { + coverArtistContribsByRef, + disableUniqueCoverArt, + album: {trackCoverArtistContribsByRef, trackCoverArtFileExtension}, + }) { + if (disableUniqueCoverArt) return null; + if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; + return coverArtFileExtension ?? trackCoverArtFileExtension ?? 'jpg'; + }, }, - }), - }, + }, + ]), // Date of cover art release. Like coverArtFileExtension, this represents // only the track's own unique cover artwork, if any. This exposes only as // the track's own coverArtDate or its album's trackArtDate, so if neither // is specified, this value is null. - coverArtDate: { - flags: {update: true, expose: true}, - - update: {validate: isDate}, - - expose: Track.withAlbumProperties(['trackArtDate', 'trackCoverArtistContribsByRef'], { - dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], - - transform(coverArtDate, { - coverArtistContribsByRef, - disableUniqueCoverArt, - album: {trackArtDate, trackCoverArtistContribsByRef}, - }) { - if (disableUniqueCoverArt) return null; - if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; - return coverArtDate ?? trackArtDate; + coverArtDate: Thing.composite.from([ + Track.withAlbumProperties(['trackArtDate', 'trackCoverArtistContribsByRef']), + + { + flags: {update: true, expose: true}, + update: {validate: isDate}, + expose: { + dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], + + transform(coverArtDate, { + coverArtistContribsByRef, + disableUniqueCoverArt, + album: {trackArtDate, trackCoverArtistContribsByRef}, + }) { + if (disableUniqueCoverArt) return null; + if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; + return coverArtDate ?? trackArtDate; + }, }, - }), - }, + } + ]), originalReleaseTrackByRef: Thing.common.singleReference(Track), @@ -176,23 +180,26 @@ export class Track extends Thing { // or a placeholder. (This property is named hasUniqueCoverArt instead of // the usual hasCoverArt to emphasize that it does not inherit from the // album.) - hasUniqueCoverArt: { - flags: {expose: true}, - - expose: Track.withAlbumProperties(['trackCoverArtistContribsByRef'], { - dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], - compute({ - coverArtistContribsByRef, - disableUniqueCoverArt, - album: {trackCoverArtistContribsByRef}, - }) { - if (disableUniqueCoverArt) return false; - if (!empty(coverArtistContribsByRef)) true; - if (!empty(trackCoverArtistContribsByRef)) return true; - return false; + hasUniqueCoverArt: Thing.composite.from([ + Track.withAlbumProperties(['trackCoverArtistContribsByRef']), + + { + flags: {expose: true}, + expose: { + dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], + compute({ + coverArtistContribsByRef, + disableUniqueCoverArt, + album: {trackCoverArtistContribsByRef}, + }) { + if (disableUniqueCoverArt) return false; + if (!empty(coverArtistContribsByRef)) true; + if (!empty(trackCoverArtistContribsByRef)) return true; + return false; + }, }, - }), - }, + }, + ]), originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( 'originalReleaseTrackByRef', @@ -228,43 +235,70 @@ export class Track extends Thing { }, }, - artistContribs: - Track.inheritFromOriginalRelease('artistContribs', [], - Thing.common.dynamicInheritContribs( - null, - 'artistContribsByRef', - 'artistContribsByRef', - 'albumData', - Track.findAlbum)), + artistContribs: Thing.composite.from([ + Track.inheritFromOriginalRelease('artistContribs'), + + { + flags: {expose: true}, + expose: { + dependencies: ['artistContribs'], + + compute({ + artistContribsByRef: contribsFromTrack, + album: {artistContribsByRef: contribsFromAlbum}, + }) { + let contribsByRef = contribsFromTrack; + if (empty(contribsByRef)) contribsByRef = contribsFromAlbum; + if (empty(contribsByRef)) return null; - contributorContribs: - Track.inheritFromOriginalRelease('contributorContribs', [], - Thing.common.dynamicContribs('contributorContribsByRef')), + return Thing.findArtistsFromContribs(contribsByRef, artistData); + }, + }, + }, + ]), + + contributorContribs: Thing.composite.from([ + Track.inheritFromOriginalRelease('contributorContribs'), + Thing.common.dynamicContribs('contributorContribsByRef'), + ]), // Cover artists aren't inherited from the original release, since it // typically varies by release and isn't defined by the musical qualities // of the track. - coverArtistContribs: - Thing.common.dynamicInheritContribs( - 'hasCoverArt', - 'coverArtistContribsByRef', - 'trackCoverArtistContribsByRef', - 'albumData', - Track.findAlbum), - - referencedTracks: - Track.inheritFromOriginalRelease('referencedTracks', [], - Thing.common.dynamicThingsFromReferenceList( - 'referencedTracksByRef', - 'trackData', - find.track)), - - sampledTracks: - Track.inheritFromOriginalRelease('sampledTracks', [], - Thing.common.dynamicThingsFromReferenceList( - 'sampledTracksByRef', - 'trackData', - find.track)), + coverArtistContribs: Thing.composite.from([ + Track.withAlbumProperties(['trackCoverArtistContribsByRef']), + + { + flags: {expose: true}, + expose: { + dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], + + compute({ + coverArtistContribsByRef: contribsFromTrack, + disableUniqueCoverArt, + album: {trackCoverArtistContribsByRef: contribsFromAlbum}, + }) { + if (disableUniqueCoverArt) return null; + + let contribsByRef = contribsFromTrack; + if (empty(contribsByRef)) contribsByRef = contribsFromAlbum; + if (empty(contribsByRef)) return null; + + return Thing.findArtistsFromContribs(contribsByRef, artistData); + }, + }, + }, + ]), + + referencedTracks: Thing.composite.from([ + Track.inheritFromOriginalRelease('referencedTracks'), + Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), + ]), + + sampledTracks: Thing.composite.from([ + Track.inheritFromOriginalRelease('sampledTracks'), + Thing.common.dynamicThingsFromReferenceList('sampledTracksByRef', 'trackData', find.track), + ]), // Specifically exclude re-releases from this list - while it's useful to // get from a re-release to the tracks it references, re-releases aren't @@ -317,72 +351,44 @@ export class Track extends Thing { ), }); - static inheritFromOriginalRelease( - originalProperty, - originalMissingValue, - ownPropertyDescriptor - ) { - return { - flags: {expose: true}, + static inheritFromOriginalRelease = originalProperty => ({ + flags: {expose: true, compose: true}, - expose: { - dependencies: [ - ...ownPropertyDescriptor.expose.dependencies, - 'originalReleaseTrackByRef', - 'trackData', - ], - - compute(dependencies) { - const { - originalReleaseTrackByRef, - trackData, - } = dependencies; - - if (originalReleaseTrackByRef) { - if (!trackData) return originalMissingValue; - const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); - if (!original) return originalMissingValue; - return original[originalProperty]; - } + expose: { + dependencies: ['originalReleaseTrackByRef', 'trackData'], - return ownPropertyDescriptor.expose.compute(dependencies); - }, - }, - }; - } + compute({originalReleaseTrackByRef, trackData}, callback) { + if (!originalReleaseTrackByRef) return callback(); - static withAlbumProperties(albumProperties, oldExpose) { - const applyAlbumDependency = dependencies => { - const track = dependencies[Track.instance]; - const album = - dependencies.albumData - ?.find((album) => album.tracks.includes(track)); - - const filteredAlbum = Object.create(null); - for (const property of albumProperties) { - filteredAlbum[property] = - (album - ? album[property] - : null); - } + if (!trackData) return null; + const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); + if (!original) return null; + return original[originalProperty]; + }, + }, + }); - return {...dependencies, album: filteredAlbum}; - }; + static withAlbumProperties = albumProperties => ({ + flags: {expose: true, compose: true}, - const newExpose = {dependencies: [...oldExpose.dependencies, 'albumData']}; + expose: { + dependencies: ['albumData'], - if (oldExpose.compute) { - newExpose.compute = dependencies => - oldExpose.compute(applyAlbumDependency(dependencies)); - } + compute({albumData, [Track.instance]: track}, callback) { + const album = albumData?.find((album) => album.tracks.includes(track)); - if (oldExpose.transform) { - newExpose.transform = (value, dependencies) => - oldExpose.transform(value, applyAlbumDependency(dependencies)); - } + const filteredAlbum = Object.create(null); + for (const property of albumProperties) { + filteredAlbum[property] = + (album + ? album[property] + : null); + } - return newExpose; - } + return callback({album: filteredAlbum}); + }, + }, + }); [inspect.custom]() { const base = Thing.prototype[inspect.custom].apply(this); -- cgit 1.3.0-6-gf8a5 From 9e23a5a9eff30af0d7c8e356520dec791aebd38f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 21:15:42 -0300 Subject: content, data: be more guarded about track contribs arrays --- src/content/dependencies/generateAlbumInfoPage.js | 2 +- src/content/dependencies/generateAlbumTrackListItem.js | 4 +++- src/content/dependencies/generateTrackInfoPage.js | 7 +++++-- src/data/things/artist.js | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index ce17ab21..51ea5927 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -44,7 +44,7 @@ export default { relations.coverArtistChronologyContributions = getChronologyRelations(album, { - contributions: album.coverArtistContribs, + contributions: album.coverArtistContribs ?? [], linkArtist: artist => relation('linkArtist', artist), diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js index f65b47c9..8b443baf 100644 --- a/src/content/dependencies/generateAlbumTrackListItem.js +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -1,4 +1,4 @@ -import {compareArrays} from '#sugar'; +import {compareArrays, empty} from '#sugar'; export default { contentDependencies: [ @@ -31,6 +31,8 @@ export default { } data.showArtists = + empty(track.artistContribs) || + empty(album.artistContribs) || !compareArrays( track.artistContribs.map(c => c.who), album.artistContribs.map(c => c.who), diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 334c5422..7002204c 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -51,7 +51,10 @@ export default { relations.artistChronologyContributions = getChronologyRelations(track, { - contributions: [...track.artistContribs, ...track.contributorContribs], + contributions: [ + ...track.artistContribs ?? [], + ...track.contributorContribs ?? [], + ], linkArtist: artist => relation('linkArtist', artist), linkThing: track => relation('linkTrack', track), @@ -65,7 +68,7 @@ export default { relations.coverArtistChronologyContributions = getChronologyRelations(track, { - contributions: track.coverArtistContribs, + contributions: track.coverArtistContribs ?? [], linkArtist: artist => relation('linkArtist', artist), diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 522ca5f9..43628b6b 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -156,7 +156,7 @@ export class Artist extends Thing { }) => thingData?.filter(thing => thing[contribsProperty] - .some(contrib => contrib.who === artist)) ?? [], + ?.some(contrib => contrib.who === artist)) ?? [], }, }); } -- cgit 1.3.0-6-gf8a5 From 128c47001a639d1569bdfadf783ccede22116350 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 21:17:02 -0300 Subject: data: fix compute() bugs in Thing.composite.from() --- src/data/things/thing.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 143c1515..111de550 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -420,6 +420,8 @@ export default class Thing extends CacheableObject { } static findArtistsFromContribs(contribsByRef, artistData) { + if (empty(contribsByRef)) return null; + return ( contribsByRef .map(({who, what}) => ({ @@ -518,7 +520,7 @@ export default class Thing extends CacheableObject { const result = (type === 'transform' ? fn(valueSoFar, dependencies, (updatedValue, providedDependencies) => { - valueSoFar = updatedValue; + valueSoFar = updatedValue ?? null; Object.assign(dependencies, providedDependencies ?? {}); return continuationSymbol; }) @@ -532,7 +534,11 @@ export default class Thing extends CacheableObject { } } - return base.expose.transform(valueSoFar, dependencies); + if (base.expose.transform) { + return base.expose.transform(valueSoFar, dependencies); + } else { + return base.expose.compute(dependencies); + } }; } else { expose.compute = (initialDependencies) => { @@ -540,7 +546,7 @@ export default class Thing extends CacheableObject { for (const {fn} of exposeFunctionOrder) { const result = - fn(valueSoFar, dependencies, providedDependencies => { + fn(dependencies, providedDependencies => { Object.assign(dependencies, providedDependencies ?? {}); return continuationSymbol; }); -- cgit 1.3.0-6-gf8a5 From d4af649bfdd546bd87b1a440bdba8152d010937e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 21:17:38 -0300 Subject: yaml: fix disableCoverArt -> disableUniqueCoverArt --- src/data/yaml.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/yaml.js b/src/data/yaml.js index 13412f17..25eda3c5 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -340,7 +340,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, { dateFirstReleased: 'Date First Released', coverArtDate: 'Cover Art Date', coverArtFileExtension: 'Cover Art File Extension', - disableCoverArt: 'Has Cover Art', // This gets transformed to flip true/false. + disableUniqueCoverArt: 'Has Cover Art', // This gets transformed to flip true/false. lyrics: 'Lyrics', commentary: 'Commentary', -- cgit 1.3.0-6-gf8a5 From 0fd10f2997db8ddec95e3caff94343eafdd9dda1 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 21:18:23 -0300 Subject: data: track: more composite shenanigans --- src/data/things/thing.js | 22 ++++-- src/data/things/track.js | 186 ++++++++++++++++++++++++++--------------------- 2 files changed, 116 insertions(+), 92 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 111de550..5d14b296 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -250,14 +250,7 @@ export default class Thing extends CacheableObject { expose: { dependencies: ['artistData', contribsByRefProperty], compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => - contribsByRef && artistData - ? contribsByRef - .map(({who: ref, what}) => ({ - who: find.artist(ref, artistData), - what, - })) - .filter(({who}) => who) - : [], + Thing.findArtistsFromContribs(contribsByRef, artistData), }, }), @@ -563,5 +556,18 @@ export default class Thing extends CacheableObject { return constructedDescriptor; }, + + withDynamicContribs: (contribsByRefProperty, dependencyName) => ({ + flags: {expose: true, compose: true}, + + expose: { + dependencies: ['artistData', contribsByRefProperty], + compute: ({artistData, [contribsByRefProperty]: contribsByRef}, callback) => + callback({ + [dependencyName]: + Thing.findArtistsFromContribs(contribsByRef, artistData), + }), + }, + }), }; } diff --git a/src/data/things/track.js b/src/data/things/track.js index fe6af205..8aa7ba26 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -44,6 +44,26 @@ export class Track extends Thing { sampledTracksByRef: Thing.common.referenceList(Track), artTagsByRef: Thing.common.referenceList(ArtTag), + color: Thing.composite.from([ + { + flags: {expose: true, compose: true}, + expose: { + transform: (color, {}, continuation) => + color ?? continuation(), + }, + }, + + Track.composite.withAlbumProperties(['color']), + + { + flags: {update: true, expose: true}, + update: {validate: isColor}, + expose: { + compute: ({album: {color}}) => color, + }, + }, + ]), + // Disables presenting the track as though it has its own unique artwork. // This flag should only be used in select circumstances, i.e. to override // an album's trackCoverArtists. This flag supercedes that property, as well @@ -55,7 +75,7 @@ export class Track extends Thing { // main artwork. (It does inherit `trackCoverArtFileExtension` if present // on the album.) coverArtFileExtension: Thing.composite.from([ - Track.withAlbumProperties(['trackCoverArtistContribsByRef', 'trackCoverArtFileExtension']), + Track.composite.withAlbumProperties(['trackCoverArtistContribsByRef', 'trackCoverArtFileExtension']), { flags: {update: true, expos: true}, @@ -81,7 +101,7 @@ export class Track extends Thing { // the track's own coverArtDate or its album's trackArtDate, so if neither // is specified, this value is null. coverArtDate: Thing.composite.from([ - Track.withAlbumProperties(['trackArtDate', 'trackCoverArtistContribsByRef']), + Track.composite.withAlbumProperties(['trackArtDate', 'trackCoverArtistContribsByRef']), { flags: {update: true, expose: true}, @@ -147,31 +167,25 @@ export class Track extends Thing { find.album ), - date: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - compute: ({albumData, dateFirstReleased, [Track.instance]: track}) => - dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null, + date: Thing.composite.from([ + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['dateFirstReleased'], + compute: ({dateFirstReleased}, continuation) => + dateFirstReleased ?? continuation(), + }, }, - }, - color: { - flags: {update: true, expose: true}, - - update: {validate: isColor}, + Track.composite.withAlbumProperties(['date']), - expose: { - dependencies: ['albumData'], - - transform: (color, {albumData, [Track.instance]: track}) => - color ?? - Track.findAlbum(track, albumData) - ?.trackSections.find(({tracks}) => tracks.includes(track)) - ?.color ?? null, + { + flags: {expose: true}, + expose: { + compute: ({album: {date}}) => date, + }, }, - }, + ]), // Whether or not the track has "unique" cover artwork - a cover which is // specifically associated with this track in particular, rather than with @@ -181,7 +195,7 @@ export class Track extends Thing { // the usual hasCoverArt to emphasize that it does not inherit from the // album.) hasUniqueCoverArt: Thing.composite.from([ - Track.withAlbumProperties(['trackCoverArtistContribsByRef']), + Track.composite.withAlbumProperties(['trackCoverArtistContribsByRef']), { flags: {expose: true}, @@ -236,29 +250,27 @@ export class Track extends Thing { }, artistContribs: Thing.composite.from([ - Track.inheritFromOriginalRelease('artistContribs'), + Track.composite.inheritFromOriginalRelease('artistContribs'), + + Thing.composite.withDynamicContribs('artistContribsByRef', 'artistContribs'), + Track.composite.withAlbumProperties(['artistContribs']), { flags: {expose: true}, expose: { - dependencies: ['artistContribs'], - - compute({ - artistContribsByRef: contribsFromTrack, - album: {artistContribsByRef: contribsFromAlbum}, - }) { - let contribsByRef = contribsFromTrack; - if (empty(contribsByRef)) contribsByRef = contribsFromAlbum; - if (empty(contribsByRef)) return null; - - return Thing.findArtistsFromContribs(contribsByRef, artistData); - }, + compute: ({ + artistContribs: contribsFromTrack, + album: {artistContribs: contribsFromAlbum}, + }) => + (empty(contribsFromTrack) + ? contribsFromAlbum + : contribsFromTrack), }, }, ]), contributorContribs: Thing.composite.from([ - Track.inheritFromOriginalRelease('contributorContribs'), + Track.composite.inheritFromOriginalRelease('contributorContribs'), Thing.common.dynamicContribs('contributorContribsByRef'), ]), @@ -266,37 +278,41 @@ export class Track extends Thing { // typically varies by release and isn't defined by the musical qualities // of the track. coverArtistContribs: Thing.composite.from([ - Track.withAlbumProperties(['trackCoverArtistContribsByRef']), - { - flags: {expose: true}, + flags: {expose: true, compose: true}, expose: { - dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], - - compute({ - coverArtistContribsByRef: contribsFromTrack, - disableUniqueCoverArt, - album: {trackCoverArtistContribsByRef: contribsFromAlbum}, - }) { - if (disableUniqueCoverArt) return null; + dependencies: ['disableUniqueCoverArt'], + compute: ({disableUniqueCoverArt}, continuation) => + (disableUniqueCoverArt + ? null + : continuation()), + }, + }, - let contribsByRef = contribsFromTrack; - if (empty(contribsByRef)) contribsByRef = contribsFromAlbum; - if (empty(contribsByRef)) return null; + Track.composite.withAlbumProperties(['trackCoverArtistContribs']), + Thing.composite.withDynamicContribs('coverArtistContribsByRef', 'coverArtistContribs'), - return Thing.findArtistsFromContribs(contribsByRef, artistData); - }, + { + flags: {expose: true}, + expose: { + compute: ({ + coverArtistContribs: contribsFromTrack, + album: {trackCoverArtistContribs: contribsFromAlbum}, + }) => + (empty(contribsFromTrack) + ? contribsFromAlbum + : contribsFromTrack), }, }, ]), referencedTracks: Thing.composite.from([ - Track.inheritFromOriginalRelease('referencedTracks'), + Track.composite.inheritFromOriginalRelease('referencedTracks'), Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), ]), sampledTracks: Thing.composite.from([ - Track.inheritFromOriginalRelease('sampledTracks'), + Track.composite.inheritFromOriginalRelease('sampledTracks'), Thing.common.dynamicThingsFromReferenceList('sampledTracksByRef', 'trackData', find.track), ]), @@ -351,44 +367,46 @@ export class Track extends Thing { ), }); - static inheritFromOriginalRelease = originalProperty => ({ - flags: {expose: true, compose: true}, + static composite = { + inheritFromOriginalRelease: originalProperty => ({ + flags: {expose: true, compose: true}, - expose: { - dependencies: ['originalReleaseTrackByRef', 'trackData'], + expose: { + dependencies: ['originalReleaseTrackByRef', 'trackData'], - compute({originalReleaseTrackByRef, trackData}, callback) { - if (!originalReleaseTrackByRef) return callback(); + compute({originalReleaseTrackByRef, trackData}, continuation) { + if (!originalReleaseTrackByRef) return continuation(); - if (!trackData) return null; - const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); - if (!original) return null; - return original[originalProperty]; + if (!trackData) return null; + const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); + if (!original) return null; + return original[originalProperty]; + }, }, - }, - }); + }), - static withAlbumProperties = albumProperties => ({ - flags: {expose: true, compose: true}, + withAlbumProperties: albumProperties => ({ + flags: {expose: true, compose: true}, - expose: { - dependencies: ['albumData'], + expose: { + dependencies: ['albumData'], - compute({albumData, [Track.instance]: track}, callback) { - const album = albumData?.find((album) => album.tracks.includes(track)); + compute({albumData, [Track.instance]: track}, continuation) { + const album = albumData?.find((album) => album.tracks.includes(track)); - const filteredAlbum = Object.create(null); - for (const property of albumProperties) { - filteredAlbum[property] = - (album - ? album[property] - : null); - } + const filteredAlbum = Object.create(null); + for (const property of albumProperties) { + filteredAlbum[property] = + (album + ? album[property] + : null); + } - return callback({album: filteredAlbum}); + return continuation({album: filteredAlbum}); + }, }, - }, - }); + }), + }; [inspect.custom]() { const base = Thing.prototype[inspect.custom].apply(this); -- cgit 1.3.0-6-gf8a5 From 8f8361c7c45b02a2221c01acc492ba4d3ae1c42e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 21:50:25 -0300 Subject: data: 2x facepalm combobob --- src/data/things/track.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 8aa7ba26..30c6fe58 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -78,7 +78,7 @@ export class Track extends Thing { Track.composite.withAlbumProperties(['trackCoverArtistContribsByRef', 'trackCoverArtFileExtension']), { - flags: {update: true, expos: true}, + flags: {update: true, expose: true}, update: {validate: isFileExtension}, expose: { dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], @@ -207,7 +207,7 @@ export class Track extends Thing { album: {trackCoverArtistContribsByRef}, }) { if (disableUniqueCoverArt) return false; - if (!empty(coverArtistContribsByRef)) true; + if (!empty(coverArtistContribsByRef)) return true; if (!empty(trackCoverArtistContribsByRef)) return true; return false; }, -- cgit 1.3.0-6-gf8a5 From 93448ef747b681d3b87b050b555311c0172b83cc Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 21 Aug 2023 22:12:24 -0300 Subject: content, data: be even more guarded about contrib arrays --- .../dependencies/generateAlbumTrackListItem.js | 20 ++++---- src/content/dependencies/generateTrackList.js | 59 +++++++++++++--------- src/data/things/artist.js | 6 +-- 3 files changed, 48 insertions(+), 37 deletions(-) diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js index 8b443baf..f92712f9 100644 --- a/src/content/dependencies/generateAlbumTrackListItem.js +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -11,9 +11,11 @@ export default { relations(relation, track) { const relations = {}; - relations.contributionLinks = - track.artistContribs - .map(contrib => relation('linkContribution', contrib)); + if (!empty(track.artistContribs)) { + relations.contributionLinks = + track.artistContribs + .map(contrib => relation('linkContribution', contrib)); + } relations.trackLink = relation('linkTrack', track); @@ -31,12 +33,12 @@ export default { } data.showArtists = - empty(track.artistContribs) || - empty(album.artistContribs) || - !compareArrays( - track.artistContribs.map(c => c.who), - album.artistContribs.map(c => c.who), - {checkOrder: false}); + !empty(track.artistContribs) && + (empty(album.artistContribs) || + !compareArrays( + track.artistContribs.map(c => c.who), + album.artistContribs.map(c => c.who), + {checkOrder: false})); return data; }, diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js index f001c3b3..65f5552b 100644 --- a/src/content/dependencies/generateTrackList.js +++ b/src/content/dependencies/generateTrackList.js @@ -1,4 +1,4 @@ -import {empty} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; export default { contentDependencies: ['linkTrack', 'linkContribution'], @@ -11,14 +11,17 @@ export default { } return { - items: tracks.map(track => ({ - trackLink: - relation('linkTrack', track), + trackLinks: + tracks + .map(track => relation('linkTrack', track)), - contributionLinks: - track.artistContribs - .map(contrib => relation('linkContribution', contrib)), - })), + contributionLinks: + tracks + .map(track => + (empty(track.artistContribs) + ? null + : track.artistContribs + .map(contrib => relation('linkContribution', contrib)))), }; }, @@ -28,22 +31,28 @@ export default { }, generate(relations, slots, {html, language}) { - return html.tag('ul', - relations.items.map(({trackLink, contributionLinks}) => - html.tag('li', - language.$('trackList.item.withArtists', { - track: trackLink, - by: - html.tag('span', {class: 'by'}, - language.$('trackList.item.withArtists.by', { - artists: - language.formatConjunctionList( - contributionLinks.map(link => - link.slots({ - showContribution: slots.showContribution, - showIcons: slots.showIcons, - }))), - })), - })))); + return ( + html.tag('ul', + stitchArrays({ + trackLink: relations.trackLinks, + contributionLinks: relations.contributionLinks, + }).map(({trackLink, contributionLinks}) => + html.tag('li', + (empty(contributionLinks) + ? trackLink + : language.$('trackList.item.withArtists', { + track: trackLink, + by: + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({ + showContribution: slots.showContribution, + showIcons: slots.showIcons, + }))), + })), + })))))); }, }; diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 43628b6b..4f157bc6 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -71,9 +71,9 @@ export class Artist extends Thing { compute: ({trackData, [Artist.instance]: artist}) => trackData?.filter((track) => [ - ...track.artistContribs, - ...track.contributorContribs, - ...track.coverArtistContribs, + ...track.artistContribs ?? [], + ...track.contributorContribs ?? [], + ...track.coverArtistContribs ?? [], ].some(({who}) => who === artist)) ?? [], }, }, -- cgit 1.3.0-6-gf8a5 From 75691866ed68b9261dd920b79d4ab214df3f049b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 22 Aug 2023 13:02:19 -0300 Subject: data: filter only requested deps, require requesting 'this' * Thing.composite.from() only provides the dependencies specified in each step and the base, and prevents '#'-prefixed keys from being specified on the main (composite) dependency list. * CacheableObject no longer provides a "reflection" dependency to every compute/transform function, and now requires the property 'this' to be specified instead of the constructor.instance symbol. (The static CacheableObject.instance, inherited by all subclasses, was also removed.) * Also minor improvements to sugar.js data processing utility functions. --- src/data/things/art-tag.js | 4 +- src/data/things/artist.js | 16 +++---- src/data/things/cacheable-object.js | 33 ++++++++------ src/data/things/flash.js | 8 ++-- src/data/things/group.js | 13 +++--- src/data/things/thing.js | 48 +++++++++++++++------ src/data/things/track.js | 85 ++++++++++++++++++++++++------------- src/util/sugar.js | 24 ++++++++--- 8 files changed, 147 insertions(+), 84 deletions(-) diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index c103c4d5..bb36e09e 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -37,8 +37,8 @@ export class ArtTag extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData', 'trackData'], - compute: ({albumData, trackData, [ArtTag.instance]: artTag}) => + dependencies: ['this', 'albumData', 'trackData'], + compute: ({this: artTag, albumData, trackData}) => sortAlbumsTracksChronologically( [...albumData, ...trackData] .filter(({artTags}) => artTags.includes(artTag)), diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 4f157bc6..bde84cfa 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -66,9 +66,9 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Artist.instance]: artist}) => + compute: ({this: artist, trackData}) => trackData?.filter((track) => [ ...track.artistContribs ?? [], @@ -82,9 +82,9 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Artist.instance]: artist}) => + compute: ({this: artist, trackData}) => trackData?.filter(({commentatorArtists}) => commentatorArtists.includes(artist)) ?? [], }, @@ -103,9 +103,9 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData'], + dependencies: [this, 'albumData'], - compute: ({albumData, [Artist.instance]: artist}) => + compute: ({this: artist, albumData}) => albumData?.filter(({commentatorArtists}) => commentatorArtists.includes(artist)) ?? [], }, @@ -148,11 +148,11 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: [thingDataProperty], + dependencies: ['this', thingDataProperty], compute: ({ + this: artist, [thingDataProperty]: thingData, - [Artist.instance]: artist }) => thingData?.filter(thing => thing[contribsProperty] diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js index ea705a61..24a6cf01 100644 --- a/src/data/things/cacheable-object.js +++ b/src/data/things/cacheable-object.js @@ -83,8 +83,6 @@ function inspect(value) { } export default class CacheableObject { - static instance = Symbol('CacheableObject `this` instance'); - #propertyUpdateValues = Object.create(null); #propertyUpdateCacheInvalidators = Object.create(null); @@ -250,20 +248,27 @@ export default class CacheableObject { let getAllDependencies; - const dependencyKeys = expose.dependencies; - if (dependencyKeys?.length > 0) { - const reflectionEntry = [this.constructor.instance, this]; - const dependencyGetters = dependencyKeys - .map(key => () => [key, this.#propertyUpdateValues[key]]); + if (expose.dependencies?.length > 0) { + const dependencyKeys = expose.dependencies.slice(); + const shouldReflect = dependencyKeys.includes('this'); + + getAllDependencies = () => { + const dependencies = Object.create(null); + + for (const key of dependencyKeys) { + dependencies[key] = this.#propertyUpdateValues[key]; + } - getAllDependencies = () => - Object.fromEntries(dependencyGetters - .map(f => f()) - .concat([reflectionEntry])); + if (shouldReflect) { + dependencies.this = this; + } + + return dependencies; + }; } else { - const allDependencies = {[this.constructor.instance]: this}; - Object.freeze(allDependencies); - getAllDependencies = () => allDependencies; + const dependencies = Object.create(null); + Object.freeze(dependencies); + getAllDependencies = () => dependencies; } if (flags.update) { diff --git a/src/data/things/flash.js b/src/data/things/flash.js index 6eb5234f..445fd07c 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -77,9 +77,9 @@ export class Flash extends Thing { flags: {expose: true}, expose: { - dependencies: ['flashActData'], + dependencies: ['this', 'flashActData'], - compute: ({flashActData, [Flash.instance]: flash}) => + compute: ({this: flash, flashActData}) => flashActData.find((act) => act.flashes.includes(flash)) ?? null, }, }, @@ -88,9 +88,9 @@ export class Flash extends Thing { flags: {expose: true}, expose: { - dependencies: ['flashActData'], + dependencies: ['this', 'flashActData'], - compute: ({flashActData, [Flash.instance]: flash}) => + compute: ({this: flash, flashActData}) => flashActData.find((act) => act.flashes.includes(flash))?.color ?? null, }, }, diff --git a/src/data/things/group.js b/src/data/things/group.js index ba339b3e..f552b8f3 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -41,8 +41,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData'], - compute: ({albumData, [Group.instance]: group}) => + dependencies: ['this', 'albumData'], + compute: ({this: group, albumData}) => albumData?.filter((album) => album.groups.includes(group)) ?? [], }, }, @@ -51,9 +51,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['groupCategoryData'], - - compute: ({groupCategoryData, [Group.instance]: group}) => + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => groupCategoryData.find((category) => category.groups.includes(group)) ?.color, }, @@ -63,8 +62,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['groupCategoryData'], - compute: ({groupCategoryData, [Group.instance]: group}) => + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => groupCategoryData.find((category) => category.groups.includes(group)) ?? null, }, diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 5d14b296..bc10e06b 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -5,7 +5,7 @@ import {inspect} from 'node:util'; import {color} from '#cli'; import find from '#find'; -import {empty, openAggregate} from '#sugar'; +import {empty, filterProperties, openAggregate} from '#sugar'; import {getKebabCase} from '#wiki-data'; import { @@ -278,6 +278,7 @@ export default class Thing extends CacheableObject { flags: {expose: true}, expose: { dependencies: [ + 'this', contribsByRefProperty, thingDataProperty, nullerProperty, @@ -285,7 +286,7 @@ export default class Thing extends CacheableObject { ].filter(Boolean), compute({ - [Thing.instance]: thing, + this: thing, [nullerProperty]: nuller, [contribsByRefProperty]: contribsByRef, [thingDataProperty]: thingData, @@ -330,9 +331,9 @@ export default class Thing extends CacheableObject { flags: {expose: true}, expose: { - dependencies: [thingDataProperty], + dependencies: ['this', thingDataProperty], - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => + compute: ({this: thing, [thingDataProperty]: thingData}) => thingData?.filter(t => t[referencerRefListProperty].includes(thing)) ?? [], }, }), @@ -344,9 +345,9 @@ export default class Thing extends CacheableObject { flags: {expose: true}, expose: { - dependencies: [thingDataProperty], + dependencies: ['this', thingDataProperty], - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => + compute: ({this: thing, [thingDataProperty]: thingData}) => thingData?.filter((t) => t[referencerRefListProperty] === thing) ?? [], }, }), @@ -462,15 +463,19 @@ export default class Thing extends CacheableObject { if (step.expose.dependencies) { for (const dependency of step.expose.dependencies) { + if (typeof dependency === 'string' && dependency.startsWith('#')) continue; exposeDependencies.add(dependency); } } + let fn, type; if (base.flags.update) { if (step.expose.transform) { - exposeFunctionOrder.push({type: 'transform', fn: step.expose.transform}); + type = 'transform'; + fn = step.expose.transform; } else { - exposeFunctionOrder.push({type: 'compute', fn: step.expose.compute}); + type = 'compute'; + fn = step.expose.compute; } } else { if (step.expose.transform && !step.expose.compute) { @@ -478,8 +483,15 @@ export default class Thing extends CacheableObject { break expose; } - exposeFunctionOrder.push({type: 'compute', fn: step.expose.compute}); + type = 'compute'; + fn = step.expose.compute; } + + exposeFunctionOrder.push({ + type, + fn, + ownDependencies: step.expose.dependencies, + }); } }); } @@ -509,15 +521,20 @@ export default class Thing extends CacheableObject { const dependencies = {...initialDependencies}; let valueSoFar = value; - for (const {type, fn} of exposeFunctionOrder) { + for (const {type, fn, ownDependencies} of exposeFunctionOrder) { + const filteredDependencies = + (ownDependencies + ? filterProperties(dependencies, ownDependencies) + : {}) + const result = (type === 'transform' - ? fn(valueSoFar, dependencies, (updatedValue, providedDependencies) => { + ? fn(valueSoFar, filteredDependencies, (updatedValue, providedDependencies) => { valueSoFar = updatedValue ?? null; Object.assign(dependencies, providedDependencies ?? {}); return continuationSymbol; }) - : fn(dependencies, providedDependencies => { + : fn(filteredDependencies, providedDependencies => { Object.assign(dependencies, providedDependencies ?? {}); return continuationSymbol; })); @@ -527,10 +544,13 @@ export default class Thing extends CacheableObject { } } + const filteredDependencies = + filterProperties(dependencies, base.expose.dependencies); + if (base.expose.transform) { - return base.expose.transform(valueSoFar, dependencies); + return base.expose.transform(valueSoFar, filteredDependencies); } else { - return base.expose.compute(dependencies); + return base.expose.compute(filteredDependencies); } }; } else { diff --git a/src/data/things/track.js b/src/data/things/track.js index 30c6fe58..551d9345 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -59,7 +59,8 @@ export class Track extends Thing { flags: {update: true, expose: true}, update: {validate: isColor}, expose: { - compute: ({album: {color}}) => color, + dependencies: ['#album.color'], + compute: ({'#album.color': color}) => color, }, }, ]), @@ -75,18 +76,27 @@ export class Track extends Thing { // main artwork. (It does inherit `trackCoverArtFileExtension` if present // on the album.) coverArtFileExtension: Thing.composite.from([ - Track.composite.withAlbumProperties(['trackCoverArtistContribsByRef', 'trackCoverArtFileExtension']), + Track.composite.withAlbumProperties([ + 'trackCoverArtistContribsByRef', + 'trackCoverArtFileExtension', + ]), { flags: {update: true, expose: true}, update: {validate: isFileExtension}, expose: { - dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], + dependencies: [ + 'coverArtistContribsByRef', + 'disableUniqueCoverArt', + '#album.trackCoverArtistContribsByRef', + '#album.trackCoverArtFileExtension', + ], transform(coverArtFileExtension, { coverArtistContribsByRef, disableUniqueCoverArt, - album: {trackCoverArtistContribsByRef, trackCoverArtFileExtension}, + '#album.trackCoverArtistContribsByRef': trackCoverArtistContribsByRef, + '#album.trackCoverArtFileExtension': trackCoverArtFileExtension, }) { if (disableUniqueCoverArt) return null; if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; @@ -101,18 +111,27 @@ export class Track extends Thing { // the track's own coverArtDate or its album's trackArtDate, so if neither // is specified, this value is null. coverArtDate: Thing.composite.from([ - Track.composite.withAlbumProperties(['trackArtDate', 'trackCoverArtistContribsByRef']), + Track.composite.withAlbumProperties([ + 'trackArtDate', + 'trackCoverArtistContribsByRef', + ]), { flags: {update: true, expose: true}, update: {validate: isDate}, expose: { - dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], + dependencies: [ + 'coverArtistContribsByRef', + 'disableUniqueCoverArt', + '#album.trackArtDate', + '#album.trackCoverArtistContribsByRef', + ], transform(coverArtDate, { coverArtistContribsByRef, disableUniqueCoverArt, - album: {trackArtDate, trackCoverArtistContribsByRef}, + '#album.trackArtDate': trackArtDate, + '#album.trackCoverArtistContribsByRef': trackCoverArtistContribsByRef, }) { if (disableUniqueCoverArt) return null; if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; @@ -148,8 +167,8 @@ export class Track extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData'], - compute: ({[Track.instance]: track, albumData}) => + dependencies: ['this', 'albumData'], + compute: ({this: track, albumData}) => albumData?.find((album) => album.tracks.includes(track)) ?? null, }, }, @@ -182,7 +201,8 @@ export class Track extends Thing { { flags: {expose: true}, expose: { - compute: ({album: {date}}) => date, + dependencies: ['#album.date'], + compute: ({'#album.date': date}) => date, }, }, ]), @@ -200,11 +220,16 @@ export class Track extends Thing { { flags: {expose: true}, expose: { - dependencies: ['coverArtistContribsByRef', 'disableUniqueCoverArt'], + dependencies: [ + 'coverArtistContribsByRef', + 'disableUniqueCoverArt', + '#album.trackCoverArtistContribsByRef', + ], + compute({ coverArtistContribsByRef, disableUniqueCoverArt, - album: {trackCoverArtistContribsByRef}, + '#album.trackCoverArtistContribsByRef': trackCoverArtistContribsByRef, }) { if (disableUniqueCoverArt) return false; if (!empty(coverArtistContribsByRef)) return true; @@ -225,12 +250,12 @@ export class Track extends Thing { flags: {expose: true}, expose: { - dependencies: ['originalReleaseTrackByRef', 'trackData'], + dependencies: ['this', 'originalReleaseTrackByRef', 'trackData'], compute: ({ + this: t1, originalReleaseTrackByRef: t1origRef, trackData, - [Track.instance]: t1, }) => { if (!trackData) { return []; @@ -252,15 +277,16 @@ export class Track extends Thing { artistContribs: Thing.composite.from([ Track.composite.inheritFromOriginalRelease('artistContribs'), - Thing.composite.withDynamicContribs('artistContribsByRef', 'artistContribs'), + Thing.composite.withDynamicContribs('artistContribsByRef', '#artistContribs'), Track.composite.withAlbumProperties(['artistContribs']), { flags: {expose: true}, expose: { + dependencies: ['#artistContribs', '#album.artistContribs'], compute: ({ - artistContribs: contribsFromTrack, - album: {artistContribs: contribsFromAlbum}, + '#artistContribs': contribsFromTrack, + '#album.artistContribs': contribsFromAlbum, }) => (empty(contribsFromTrack) ? contribsFromAlbum @@ -290,14 +316,15 @@ export class Track extends Thing { }, Track.composite.withAlbumProperties(['trackCoverArtistContribs']), - Thing.composite.withDynamicContribs('coverArtistContribsByRef', 'coverArtistContribs'), + Thing.composite.withDynamicContribs('coverArtistContribsByRef', '#coverArtistContribs'), { flags: {expose: true}, expose: { + dependencies: ['#coverArtistContribs', '#album.trackCoverArtistContribs'], compute: ({ - coverArtistContribs: contribsFromTrack, - album: {trackCoverArtistContribs: contribsFromAlbum}, + '#coverArtistContribs': contribsFromTrack, + '#album.trackCoverArtistContribs': contribsFromAlbum, }) => (empty(contribsFromTrack) ? contribsFromAlbum @@ -328,9 +355,9 @@ export class Track extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Track.instance]: track}) => + compute: ({this: track, trackData}) => trackData ? trackData .filter((t) => !t.originalReleaseTrack) @@ -344,9 +371,9 @@ export class Track extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Track.instance]: track}) => + compute: ({this: track, trackData}) => trackData ? trackData .filter((t) => !t.originalReleaseTrack) @@ -389,20 +416,20 @@ export class Track extends Thing { flags: {expose: true, compose: true}, expose: { - dependencies: ['albumData'], + dependencies: ['this', 'albumData'], - compute({albumData, [Track.instance]: track}, continuation) { + compute({this: track, albumData}, continuation) { const album = albumData?.find((album) => album.tracks.includes(track)); + const newDependencies = {}; - const filteredAlbum = Object.create(null); for (const property of albumProperties) { - filteredAlbum[property] = + newDependencies['#album.' + property] = (album ? album[property] : null); } - return continuation({album: filteredAlbum}); + return continuation(newDependencies); }, }, }), diff --git a/src/util/sugar.js b/src/util/sugar.js index 5b1f3193..1ba3f3ae 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -168,12 +168,24 @@ export function setIntersection(set1, set2) { return intersection; } -export function filterProperties(obj, properties) { - const set = new Set(properties); - return Object.fromEntries( - Object - .entries(obj) - .filter(([key]) => set.has(key))); +export function filterProperties(object, properties) { + if (typeof object !== 'object' || object === null) { + throw new TypeError(`Expected object to be an object, got ${object}`); + } + + if (!Array.isArray(properties)) { + throw new TypeError(`Expected properties to be an array, got ${properties}`); + } + + const filteredObject = {}; + + for (const property of properties) { + if (Object.hasOwn(object, property)) { + filteredObject[property] = object[property]; + } + } + + return filteredObject; } export function queue(array, max = 50) { -- cgit 1.3.0-6-gf8a5 From 1481db921e645ab09aad3a57b4ce308e2c57d738 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 22 Aug 2023 13:52:43 -0300 Subject: data: signature changes to misc compositional functions --- src/data/things/thing.js | 12 +++++--- src/data/things/track.js | 75 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 63 insertions(+), 24 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index bc10e06b..f1ae6c71 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -577,14 +577,18 @@ export default class Thing extends CacheableObject { return constructedDescriptor; }, - withDynamicContribs: (contribsByRefProperty, dependencyName) => ({ + // Resolves the contribsByRef contained in the provided dependency, + // providing (named by the second argument) the result. "Resolving" + // means mapping the "who" reference of each contribution to an artist + // object, and filtering out those whose "who" doesn't match any artist. + withResolvedContribs: ({from: contribsByRefDependency, to: outputDependency}) => ({ flags: {expose: true, compose: true}, expose: { - dependencies: ['artistData', contribsByRefProperty], - compute: ({artistData, [contribsByRefProperty]: contribsByRef}, callback) => + dependencies: ['artistData', contribsByRefDependency], + compute: ({artistData, [contribsByRefDependency]: contribsByRef}, callback) => callback({ - [dependencyName]: + [outputDependency]: Thing.findArtistsFromContribs(contribsByRef, artistData), }), }, diff --git a/src/data/things/track.js b/src/data/things/track.js index 551d9345..985de594 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -53,7 +53,9 @@ export class Track extends Thing { }, }, - Track.composite.withAlbumProperties(['color']), + Track.composite.withAlbumProperties({ + properties: ['color'], + }), { flags: {update: true, expose: true}, @@ -76,10 +78,12 @@ export class Track extends Thing { // main artwork. (It does inherit `trackCoverArtFileExtension` if present // on the album.) coverArtFileExtension: Thing.composite.from([ - Track.composite.withAlbumProperties([ - 'trackCoverArtistContribsByRef', - 'trackCoverArtFileExtension', - ]), + Track.composite.withAlbumProperties({ + properties: [ + 'trackCoverArtistContribsByRef', + 'trackCoverArtFileExtension', + ], + }), { flags: {update: true, expose: true}, @@ -111,10 +115,12 @@ export class Track extends Thing { // the track's own coverArtDate or its album's trackArtDate, so if neither // is specified, this value is null. coverArtDate: Thing.composite.from([ - Track.composite.withAlbumProperties([ - 'trackArtDate', - 'trackCoverArtistContribsByRef', - ]), + Track.composite.withAlbumProperties({ + properties: [ + 'trackArtDate', + 'trackCoverArtistContribsByRef', + ], + }), { flags: {update: true, expose: true}, @@ -196,7 +202,9 @@ export class Track extends Thing { }, }, - Track.composite.withAlbumProperties(['date']), + Track.composite.withAlbumProperties({ + properties: ['date'], + }), { flags: {expose: true}, @@ -215,7 +223,9 @@ export class Track extends Thing { // the usual hasCoverArt to emphasize that it does not inherit from the // album.) hasUniqueCoverArt: Thing.composite.from([ - Track.composite.withAlbumProperties(['trackCoverArtistContribsByRef']), + Track.composite.withAlbumProperties({ + properties: ['trackCoverArtistContribsByRef'], + }), { flags: {expose: true}, @@ -277,8 +287,14 @@ export class Track extends Thing { artistContribs: Thing.composite.from([ Track.composite.inheritFromOriginalRelease('artistContribs'), - Thing.composite.withDynamicContribs('artistContribsByRef', '#artistContribs'), - Track.composite.withAlbumProperties(['artistContribs']), + Track.composite.withAlbumProperties({ + properties: 'artistContribs', + }), + + Thing.composite.withResolvedContribs({ + from: 'artistContribsByRef', + to: '#artistContribs', + }), { flags: {expose: true}, @@ -315,8 +331,14 @@ export class Track extends Thing { }, }, - Track.composite.withAlbumProperties(['trackCoverArtistContribs']), - Thing.composite.withDynamicContribs('coverArtistContribsByRef', '#coverArtistContribs'), + Track.composite.withAlbumProperties({ + properties: ['trackCoverArtistContribs'], + }), + + Thing.composite.withResolvedContribs({ + from: 'coverArtistContribsByRef', + to: '#coverArtistContribs', + }), { flags: {expose: true}, @@ -395,7 +417,12 @@ export class Track extends Thing { }); static composite = { - inheritFromOriginalRelease: originalProperty => ({ + // Returns a value inherited from the original release, if this track + // is a rerelease, and otherwise continues with no further provided + // dependencies. If the second argument is provided true, then the + // continuation will also be called if the original release exposed + // the requested property as null. + inheritFromOriginalRelease: (originalProperty, allowOverride = false) => ({ flags: {expose: true, compose: true}, expose: { @@ -407,12 +434,20 @@ export class Track extends Thing { if (!trackData) return null; const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); if (!original) return null; - return original[originalProperty]; + + const value = original[originalProperty]; + if (allowOverride && value === null) return continuation(); + + return value; }, }, }), - withAlbumProperties: albumProperties => ({ + // Gets the listed properties from this track's album, providing them as + // dependencies (by default) with '#album.' prefixed before each property + // name. If the track's album isn't available, the same dependency names + // will each be provided as null. + withAlbumProperties: ({properties, prefix = '#album'}) => ({ flags: {expose: true, compose: true}, expose: { @@ -422,8 +457,8 @@ export class Track extends Thing { const album = albumData?.find((album) => album.tracks.includes(track)); const newDependencies = {}; - for (const property of albumProperties) { - newDependencies['#album.' + property] = + for (const property of properties) { + newDependencies[prefix + '.' + property] = (album ? album[property] : null); -- cgit 1.3.0-6-gf8a5 From 6483809c6d9c67f1311a64f2572b4fe5881d3a0d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 22 Aug 2023 22:36:20 -0300 Subject: data: composition docs, annotations, nesting --- src/data/things/thing.js | 316 +++++++++++++++++++++++++++++++++++++++++++++-- src/data/things/track.js | 81 +++++++----- 2 files changed, 359 insertions(+), 38 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index f1ae6c71..1186c389 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -426,14 +426,222 @@ export default class Thing extends CacheableObject { } static composite = { - from(composition) { + // Composes multiple compositional "steps" and a "base" to form a property + // descriptor out of modular building blocks. This is an extension to the + // more general-purpose CacheableObject property descriptor syntax, and + // aims to make modular data processing - which lends to declarativity - + // much easier, without fundamentally altering much of the typical syntax + // or terminology, nor building on it to an excessive degree. + // + // Think of a composition as being a chain of steps which lead into a final + // base property, which is usually responsible for returning the value that + // will actually get exposed when the property being described is accessed. + // + // == The compositional base: == + // + // The final item in a compositional list is its base, and it identifies + // the essential qualities of the property descriptor. The compositional + // steps preceding it may exit early, in which case the expose function + // defined on the base won't be called; or they will provide dependencies + // that the base may use to compute the final value that gets exposed for + // this property. + // + // The base indicates the capabilities of the composition as a whole. + // It should be {expose: true}, since that's the only area that preceding + // compositional steps (currently) can actually influence. If it's also + // {update: true}, then the composition as a whole accepts an update value + // just like normal update-flag property descriptors - meaning it can be + // set with `thing.someProperty = value` and that value will be paseed + // into each (implementing) step's transform() function, as well as the + // base. Bases usually aren't {compose: true}, but can be - check out the + // section on "nesting compositions" for details about that. + // + // Every composition always has exactly one compositional base, and it's + // always the last item in the composition list. All items preceding it + // are compositional steps, described below. + // + // == Compositional steps: == + // + // Compositional steps are, in essence, typical property descriptors with + // the extra flag {compose: true}. They operate on existing dependencies, + // and are typically dynamically constructed by "utility" functions (but + // can also be manually declared within the step list of a composition). + // Compositional steps serve two purposes: + // + // 1. exit early, if some condition is matched, returning and exposing + // some value directly from that step instead of continuing further + // down the step list; + // + // 2. and/or provide new, dynamically created "private" dependencies which + // can be accessed by further steps down the list, or at the base at + // the bottom, modularly supplying information that will contribute to + // the final value exposed for this property. + // + // Usually it's just one of those two, but it's fine for a step to perform + // both jobs if the situation benefits. + // + // Compositional steps are the real "modular" or "compositional" part of + // this data processing style - they're designed to be combined together + // in dynamic, versatile ways, as each property demands it. You usually + // define a compositional step to be returned by some ordinary static + // property-descriptor-returning function (customarily namespaced under + // the relevant Thing class's static `composite` field) - that lets you + // reuse it in multiple compositions later on. + // + // Compositional steps are implemented with "continuation passing style", + // meaning the connection to the next link on the chain is passed right to + // each step's compute (or transform) function, and the implementation gets + // to decide whether to continue on that chain or exit early by returning + // some other value. + // + // Every step along the chain, apart from the base at the bottom, has to + // have the {compose: true} step. That means its compute() or transform() + // function will be passed an extra argument at the end, `continuation`. + // To provide new dependencies to items further down the chain, just pass + // them directly to this continuation() function, customarily with a hash + // ('#') prefixing each name - for example: + // + // compute({..some dependencies..}, continuation) { + // return continuation({ + // '#excitingProperty': (..a value made from dependencies..), + // }); + // } + // + // Performing an early exit is as simple as returning some other value, + // instead of the continuation. + // + // It may be fine to simply provide new dependencies under a hard-coded + // name, such as '#excitingProperty' above, but if you're writing a utility + // that dynamically returns the compositional step and you suspect you + // might want to use this step multiple times in a single composition, + // it's customary to accept a name for the result. + // + // Here's a detailed example showing off early exit, dynamically operating + // on a provided dependency name, and then providing a result in another + // also-provided dependency name: + // + // static Thing.composite.withResolvedContribs = ({ + // from: contribsByRefDependency, + // to: outputDependency, + // }) => ({ + // flags: {expose: true, compose: true}, + // expose: { + // dependencies: [contribsByRefDependency, 'artistData'], + // compute({ + // [contribsByRefDependency]: contribsByRef, + // artistData, + // }, continuation) { + // if (!artistData) return null; /* early exit! */ + // return continuation({ + // [outputDependency]: /* this is the important part */ + // (..resolve contributions one way or another..), + // }); + // }, + // }, + // }); + // + // And how you might work that into a composition: + // + // static Track[Thing.getPropertyDescriptors].coverArtists = + // Thing.composite.from([ + // Track.composite.doSomethingWhichMightEarlyExit(), + // Thing.composite.withResolvedContribs({ + // from: 'coverArtistContribsByRef', + // to: '#coverArtistContribs', + // }), + // + // { + // flags: {expose: true}, + // expose: { + // dependencies: ['#coverArtistContribs'], + // compute({'#coverArtistContribs': coverArtistContribs}) { + // return coverArtistContribs.map(({who}) => who); + // }, + // }, + // }, + // ]); + // + // == To compute or to transform: == + // + // A compositional step can work directly on a property's stored update + // value, transforming it in place and either early exiting with it or + // passing it on (via continuation) to the next item(s) in the + // compositional step list. (If needed, these can provide dependencies + // the same way as compute functions too - just pass that object after + // the updated (or same) transform value in your call to continuation().) + // + // But in order to make them more versatile, compositional steps have an + // extra trick up their sleeve. If a compositional step implements compute + // and *not* transform, it can still be used in a composition targeting a + // property which updates! These retain their full dependency-providing and + // early exit functionality - they just won't be provided the update value. + // If a compute-implementing step returns its continuation, then whichever + // later step (or the base) next implements transform() will receive the + // update value that had so far been running - as well as any dependencies + // the compute() step returned, of course! + // + // Please note that a compositional step which transforms *should not* + // specify, in its flags, {update: true}. Just provide the transform() + // function in its expose descriptor; it will be automatically detected + // and used when appropriate. + // + // It's actually possible for a step to specify both transform and compute, + // in which case the transform() implementation will only be selected if + // the composition's base is {update: true}. It's not exactly known why you + // would want to specify unique-but-related transform and compute behavior, + // but the basic possibility was too cool to skip out on. + // + // == Nesting compositions: == + // + // Compositional steps are so convenient that you just might want to bundle + // them together, and form a whole new step-shaped unit of its own! + // + // In order to allow for this while helping to ensure internal dependencies + // remain neatly isolated from the composition which nests your bundle, + // the Thing.composite.from() function will accept and adapt to a base that + // specifies the {compose: true} flag, just like the steps preceding it. + // + // The continuation function that gets provided to the base will be mildly + // special - after all, nothing follows the base within the composition's + // own list! Instead of appending dependencies alongside any previously + // provided ones to be available to the next step, the base's continuation + // function should be used to define "exports" of the composition as a + // whole. It's similar to the usual behavior of the continuation, just + // expanded to the scope of the composition instead of following steps. + // + // For example, suppose your composition (which you expect to include in + // other compositions) brings about several internal, hash-prefixed + // dependencies to contribute to its own results. Those dependencies won't + // end up "bleeding" into the dependency list of whichever composition is + // nesting this one - they will totally disappear once all the steps in + // the nested composition have finished up. + // + // To "export" the results of processing all those dependencies (provided + // that's something you want to do and this composition isn't used purely + // for a conditional early-exit), you'll want to define them in the + // continuation passed to the base. (Customarily, those should start with + // a hash just like the exports from any other compositional step; they're + // still dynamically provided dependencies!) + // + from(firstArg, secondArg) { + let annotation, composition; + if (typeof firstArg === 'string') { + [annotation, composition] = [firstArg, secondArg]; + } else { + [annotation, composition] = [null, firstArg]; + } + const base = composition.at(-1); const steps = composition.slice(0, -1); - const aggregate = openAggregate({message: `Errors preparing Thing.composite.from() composition`}); + const aggregate = openAggregate({ + message: + `Errors preparing Thing.composite.from() composition` + + (annotation ? ` (${annotation})` : ''), + }); - if (base.flags.compose) { - aggregate.push(new TypeError(`Base (bottom item) must not be {compose: true}`)); + if (base.flags.compose && base.flags.compute) { + push(new TypeError(`Base which composes can't also update yet`)); } const exposeFunctionOrder = []; @@ -500,14 +708,18 @@ export default class Thing extends CacheableObject { const constructedDescriptor = {}; + if (annotation) { + constructedDescriptor.annotation = annotation; + } + constructedDescriptor.flags = { update: !!base.flags.update, expose: !!base.flags.expose, - compose: false, + compose: !!base.flags.compose, }; if (base.flags.update) { - constructedDescriptor.update = base.flags.update; + constructedDescriptor.update = base.update; } if (base.flags.expose) { @@ -547,6 +759,9 @@ export default class Thing extends CacheableObject { const filteredDependencies = filterProperties(dependencies, base.expose.dependencies); + // Note: base.flags.compose is not compatible with base.flags.update, + // so the base.flags.compose case is not handled here. + if (base.expose.transform) { return base.expose.transform(valueSoFar, filteredDependencies); } else { @@ -554,7 +769,7 @@ export default class Thing extends CacheableObject { } }; } else { - expose.compute = (initialDependencies) => { + expose.compute = (initialDependencies, continuationIfApplicable) => { const dependencies = {...initialDependencies}; for (const {fn} of exposeFunctionOrder) { @@ -569,7 +784,23 @@ export default class Thing extends CacheableObject { } } - return base.expose.compute(dependencies); + if (base.flags.compose) { + let exportDependencies; + + const result = + base.expose.compute(dependencies, providedDependencies => { + exportDependencies = providedDependencies; + return continuationSymbol; + }); + + if (result !== continuationSymbol) { + return result; + } + + return exportDependencies; + } else { + return base.expose.compute(dependencies); + } }; } } @@ -577,11 +808,48 @@ export default class Thing extends CacheableObject { return constructedDescriptor; }, + // Provides dependencies exactly as they are (or null if not defined) to the + // continuation. Although this can *technically* be used to alias existing + // dependencies to some other name within the middle of a composition, it's + // intended to be used only as a composition's base - doing so makes the + // composition as a whole suitable as a step in some other composition, + // providing the listed (internal) dependencies to later steps just like + // other compositional steps. + export(mapping) { + const mappingEntries = Object.entries(mapping); + + return { + annotation: `Thing.composite.export`, + flags: {expose: true, compose: true}, + + expose: { + dependencies: Object.values(mapping), + + compute(dependencies, continuation) { + const exports = {}; + + // Note: This is slightly different behavior from filterProperties, + // as defined in sugar.js, which doesn't fall back to null for + // properties which don't exist on the original object. + for (const [exportKey, dependencyKey] of mappingEntries) { + exports[exportKey] = + (Object.hasOwn(dependencies, dependencyKey) + ? dependencies[dependencyKey] + : null); + } + + return continuation(exports); + } + }, + }; + }, + // Resolves the contribsByRef contained in the provided dependency, // providing (named by the second argument) the result. "Resolving" // means mapping the "who" reference of each contribution to an artist // object, and filtering out those whose "who" doesn't match any artist. withResolvedContribs: ({from: contribsByRefDependency, to: outputDependency}) => ({ + annotation: `Thing.composite.withResolvedContribs`, flags: {expose: true, compose: true}, expose: { @@ -593,5 +861,37 @@ export default class Thing extends CacheableObject { }), }, }), + + // Resolves a reference by using the provided find function to match it + // within the provided thingData dependency. This will early exit if the + // data dependency is null, or, if earlyExitIfNotFound is set to true, + // if the find function doesn't match anything for the reference. + // Otherwise, the data object (or null, if not found) is provided on + // the output dependency. + withResolvedReference({ + ref: refDependency, + data: dataDependency, + to: outputDependency, + find: findFunction, + earlyExitIfNotFound = false, + }) { + return { + annotation: `Thing.composite.withResolvedReference`, + flags: {expose: true, compose: true}, + + expose: { + dependencies: [refDependency, dataDependency], + + compute({[refDependency]: ref, [dataDependency]: data}, continuation) { + if (data === null) return null; + + const match = findFunction(ref, data, {mode: 'quiet'}); + if (match === null && earlyExitIfNotFound) return null; + + return continuation({[outputDependency]: match}); + }, + }, + }; + } }; } diff --git a/src/data/things/track.js b/src/data/things/track.js index 985de594..718eb07e 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -44,7 +44,7 @@ export class Track extends Thing { sampledTracksByRef: Thing.common.referenceList(Track), artTagsByRef: Thing.common.referenceList(ArtTag), - color: Thing.composite.from([ + color: Thing.composite.from(`Track.color`, [ { flags: {expose: true, compose: true}, expose: { @@ -77,7 +77,7 @@ export class Track extends Thing { // track's unique cover artwork, if any, and does not inherit the cover's // main artwork. (It does inherit `trackCoverArtFileExtension` if present // on the album.) - coverArtFileExtension: Thing.composite.from([ + coverArtFileExtension: Thing.composite.from(`Track.coverArtFileExtension`, [ Track.composite.withAlbumProperties({ properties: [ 'trackCoverArtistContribsByRef', @@ -114,7 +114,7 @@ export class Track extends Thing { // only the track's own unique cover artwork, if any. This exposes only as // the track's own coverArtDate or its album's trackArtDate, so if neither // is specified, this value is null. - coverArtDate: Thing.composite.from([ + coverArtDate: Thing.composite.from(`Track.coverArtDate`, [ Track.composite.withAlbumProperties({ properties: [ 'trackArtDate', @@ -192,7 +192,7 @@ export class Track extends Thing { find.album ), - date: Thing.composite.from([ + date: Thing.composite.from(`Track.date`, [ { flags: {expose: true, compose: true}, expose: { @@ -222,7 +222,7 @@ export class Track extends Thing { // or a placeholder. (This property is named hasUniqueCoverArt instead of // the usual hasCoverArt to emphasize that it does not inherit from the // album.) - hasUniqueCoverArt: Thing.composite.from([ + hasUniqueCoverArt: Thing.composite.from(`Track.hasUniqueCoverArt`, [ Track.composite.withAlbumProperties({ properties: ['trackCoverArtistContribsByRef'], }), @@ -284,7 +284,7 @@ export class Track extends Thing { }, }, - artistContribs: Thing.composite.from([ + artistContribs: Thing.composite.from(`Track.artistContribs`, [ Track.composite.inheritFromOriginalRelease('artistContribs'), Track.composite.withAlbumProperties({ @@ -311,7 +311,7 @@ export class Track extends Thing { }, ]), - contributorContribs: Thing.composite.from([ + contributorContribs: Thing.composite.from(`Track.contributorContribs`, [ Track.composite.inheritFromOriginalRelease('contributorContribs'), Thing.common.dynamicContribs('contributorContribsByRef'), ]), @@ -319,7 +319,7 @@ export class Track extends Thing { // Cover artists aren't inherited from the original release, since it // typically varies by release and isn't defined by the musical qualities // of the track. - coverArtistContribs: Thing.composite.from([ + coverArtistContribs: Thing.composite.from(`Track.coverArtistContribs`, [ { flags: {expose: true, compose: true}, expose: { @@ -355,12 +355,12 @@ export class Track extends Thing { }, ]), - referencedTracks: Thing.composite.from([ + referencedTracks: Thing.composite.from(`Track.referencedTracks`, [ Track.composite.inheritFromOriginalRelease('referencedTracks'), Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), ]), - sampledTracks: Thing.composite.from([ + sampledTracks: Thing.composite.from(`Track.sampledTracks`, [ Track.composite.inheritFromOriginalRelease('sampledTracks'), Thing.common.dynamicThingsFromReferenceList('sampledTracksByRef', 'trackData', find.track), ]), @@ -417,37 +417,39 @@ export class Track extends Thing { }); static composite = { - // Returns a value inherited from the original release, if this track - // is a rerelease, and otherwise continues with no further provided - // dependencies. If the second argument is provided true, then the - // continuation will also be called if the original release exposed - // the requested property as null. - inheritFromOriginalRelease: (originalProperty, allowOverride = false) => ({ - flags: {expose: true, compose: true}, + // Early exits with a value inherited from the original release, if + // this track is a rerelease, and otherwise continues with no further + // dependencies provided. If allowOverride is true, then the continuation + // will also be called if the original release exposed the requested + // property as null. + inheritFromOriginalRelease: ({property: originalProperty, allowOverride = false}) => + Thing.composite.from(`Track.composite.inheritFromOriginalRelease`, [ + Track.composite.withOriginalRelease({to: '#originalRelease'}), - expose: { - dependencies: ['originalReleaseTrackByRef', 'trackData'], + { + flags: {expose: true, compose: true}, - compute({originalReleaseTrackByRef, trackData}, continuation) { - if (!originalReleaseTrackByRef) return continuation(); + expose: { + dependencies: ['#originalRelease'], - if (!trackData) return null; - const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); - if (!original) return null; + compute({'#originalRelease': originalRelease}, continuation) { + if (!originalRelease) return continuation(); - const value = original[originalProperty]; - if (allowOverride && value === null) return continuation(); + const value = originalRelease[originalProperty]; + if (allowOverride && value === null) return continuation(); - return value; - }, - }, - }), + return value; + }, + }, + } + ]), // Gets the listed properties from this track's album, providing them as // dependencies (by default) with '#album.' prefixed before each property // name. If the track's album isn't available, the same dependency names // will each be provided as null. withAlbumProperties: ({properties, prefix = '#album'}) => ({ + annotation: `Track.composite.withAlbumProperties`, flags: {expose: true, compose: true}, expose: { @@ -468,6 +470,25 @@ export class Track extends Thing { }, }, }), + + // Just includes the original release of this track as a dependency, or + // null, if it's not a rerelease. Note that this will early exit if the + // original release is specified by reference and that reference doesn't + // resolve to anything. + withOriginalRelease: ({to: outputDependency = '#originalRelease'}) => + Thing.composite.from(`Track.composite.withOriginalRelease`, [ + Thing.composite.withResolvedReference({ + ref: 'originalReleaseTrackByRef', + data: 'trackData', + to: '#originalRelease', + find: find.track, + earlyExitIfNotFound: true, + }), + + Thing.composite.export({ + [outputDependency]: '#originalRelease', + }), + ]), }; [inspect.custom]() { -- cgit 1.3.0-6-gf8a5 From c7e624684069fb9325c426500a5fcc153cf26b41 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Tue, 22 Aug 2023 22:39:22 -0300 Subject: data: track: remove unneeded explicit {to} on withOriginalRelease call --- src/data/things/track.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 718eb07e..118e3db0 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -424,7 +424,7 @@ export class Track extends Thing { // property as null. inheritFromOriginalRelease: ({property: originalProperty, allowOverride = false}) => Thing.composite.from(`Track.composite.inheritFromOriginalRelease`, [ - Track.composite.withOriginalRelease({to: '#originalRelease'}), + Track.composite.withOriginalRelease(), { flags: {expose: true, compose: true}, @@ -474,8 +474,8 @@ export class Track extends Thing { // Just includes the original release of this track as a dependency, or // null, if it's not a rerelease. Note that this will early exit if the // original release is specified by reference and that reference doesn't - // resolve to anything. - withOriginalRelease: ({to: outputDependency = '#originalRelease'}) => + // resolve to anything. Outputs to '#originalRelease' by default. + withOriginalRelease: ({to: outputDependency = '#originalRelease'} = {}) => Thing.composite.from(`Track.composite.withOriginalRelease`, [ Thing.composite.withResolvedReference({ ref: 'originalReleaseTrackByRef', -- cgit 1.3.0-6-gf8a5 From 94f684138329e17ab43bfe552056d7ea3ed28b17 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 23 Aug 2023 12:22:11 -0300 Subject: data: track.hasUniqueCoverArt: operate on resolved contributions --- src/data/things/track.js | 50 ++++++++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 118e3db0..414d5f29 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -223,29 +223,45 @@ export class Track extends Thing { // the usual hasCoverArt to emphasize that it does not inherit from the // album.) hasUniqueCoverArt: Thing.composite.from(`Track.hasUniqueCoverArt`, [ + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['disableUniqueCoverArt'], + compute: ({disableUniqueCoverArt}, continuation) => + (disableUniqueCoverArt + ? false + : continuation()), + }, + }, + + Thing.composite.withResolvedContribs({ + from: 'coverArtistContribsByRef', + to: '#coverArtistContribs', + }), + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#coverArtistContribs'], + compute: ({'#coverArtistContribs': coverArtistContribs}, continuation) => + (empty(coverArtistContribs) + ? continuation() + : true), + }, + }, + Track.composite.withAlbumProperties({ - properties: ['trackCoverArtistContribsByRef'], + properties: ['trackCoverArtistContribs'], }), { flags: {expose: true}, expose: { - dependencies: [ - 'coverArtistContribsByRef', - 'disableUniqueCoverArt', - '#album.trackCoverArtistContribsByRef', - ], - - compute({ - coverArtistContribsByRef, - disableUniqueCoverArt, - '#album.trackCoverArtistContribsByRef': trackCoverArtistContribsByRef, - }) { - if (disableUniqueCoverArt) return false; - if (!empty(coverArtistContribsByRef)) return true; - if (!empty(trackCoverArtistContribsByRef)) return true; - return false; - }, + dependencies: ['#album.trackCoverArtistContribs'], + compute: ({'#album.trackCoverArtistContribs': trackCoverArtistContribs}) => + (empty(trackCoverArtistContribs) + ? false + : true), }, }, ]), -- cgit 1.3.0-6-gf8a5 From 13914b9f07f60d6d8aaaddc7df675d41950320c3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 23 Aug 2023 12:22:34 -0300 Subject: test: Track.{color,date,hasUniqueCoverArt} (unit) --- src/data/things/thing.js | 2 +- test/unit/data/things/track.js | 157 ++++++++++++++++++++++++++++++++--------- 2 files changed, 126 insertions(+), 33 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 1186c389..decde6f4 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -419,7 +419,7 @@ export default class Thing extends CacheableObject { return ( contribsByRef .map(({who, what}) => ({ - who: find.artist(who, artistData), + who: find.artist(who, artistData, {mode: 'quiet'}), what, })) .filter(({who}) => who)); diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index d0e50c7f..37aa7b9f 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -20,66 +20,159 @@ function stubAlbum(tracks) { return album; } -t.test(`Track.coverArtDate`, t => { - t.plan(6); - - const albumTrackArtDate = new Date('2012-12-12'); - const trackCoverArtDate = new Date('2009-09-09'); - const dummyContribs = [{who: 'Test Artist', what: null}] - +function stubTrack() { const track = new Track(); track.directory = 'foo'; - track.coverArtistContribsByRef = dummyContribs; + return track; +} +function stubTrackAndAlbum() { + const track = stubTrack(); const album = stubAlbum([track]); + track.albumData = [album]; + return {track, album}; +} +function stubArtistAndContribs() { const artist = new Artist(); - artist.name = 'Test Artist'; + artist.name = `Test Artist`; + + const contribs = [{who: `Test Artist`, what: null}]; + const badContribs = [{who: `Figment of Your Imagination`, what: null}]; + return {artist, contribs, badContribs}; +} +function XXX_CLEAR_TRACK_ALBUM_CACHE(track, album) { + // XXX clear cache so change in album's property is reflected + track.albumData = []; track.albumData = [album]; - track.artistData = [artist]; +} - const XXX_CLEAR_TRACK_ALBUM_CACHE = () => { - // XXX clear cache so change in album's property is reflected - track.albumData = []; - track.albumData = [album]; - }; +t.test(`Track.color`, t => { + t.plan(3); - // 1. coverArtDate defaults to null + const {track, album} = stubTrackAndAlbum(); - t.equal(track.coverArtDate, null); + t.equal(track.color, null, + `color #1: defaults to null`); - // 2. coverArtDate inherits album trackArtDate + album.color = '#abcdef'; + XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); - album.trackArtDate = albumTrackArtDate; + t.equal(track.color, '#abcdef', + `color #2: inherits from album`); - XXX_CLEAR_TRACK_ALBUM_CACHE(); + track.color = '#123456'; - t.equal(track.coverArtDate, albumTrackArtDate); + t.equal(track.color, '#123456', + `color #3: is own value`); +}); - // 3. coverArtDate is own value +t.test(`Track.coverArtDate`, t => { + t.plan(6); + + const {track, album} = stubTrackAndAlbum(); + const {artist, contribs} = stubArtistAndContribs(); + + track.coverArtistContribsByRef = contribs; + track.artistData = [artist]; + + t.equal(track.coverArtDate, null, + `coverArtDate #1: defaults to null`); - track.coverArtDate = trackCoverArtDate; + album.trackArtDate = new Date('2012-12-12'); + XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); - t.equal(track.coverArtDate, trackCoverArtDate); + t.same(track.coverArtDate, new Date('2012-12-12'), + `coverArtDate #2: inherits album trackArtDate`); - // 4. coverArtDate is null if track is missing coverArtists + track.coverArtDate = new Date('2009-09-09'); + + t.same(track.coverArtDate, new Date('2009-09-09'), + `coverArtDate #3: is own value`); track.coverArtistContribsByRef = []; - t.equal(track.coverArtDate, null); + t.equal(track.coverArtDate, null, + `coverArtDate #4: is null if track is missing coverArtists`); + + album.trackCoverArtistContribsByRef = contribs; + XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); + + t.same(track.coverArtDate, new Date('2009-09-09'), + `coverArtDate #5: is not null if album specifies trackCoverArtistContribs`); + + track.disableUniqueCoverArt = true; + + t.equal(track.coverArtDate, null, + `coverArtDate #6: is null if track disables unique cover artwork`); +}); + +t.test(`Track.date`, t => { + t.plan(3); + + const {track, album} = stubTrackAndAlbum(); + + t.equal(track.date, null, + `date #1: defaults to null`); + + album.date = new Date('2012-12-12'); + XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); - // 5. coverArtDate is not null if album specifies trackCoverArtistContribs + t.same(track.date, album.date, + `date #2: inherits from album`); - album.trackCoverArtistContribsByRef = dummyContribs; + track.dateFirstReleased = new Date('2009-09-09'); + + t.same(track.date, new Date('2009-09-09'), + `date #3: is own dateFirstReleased`); +}); + +t.test(`Track.hasUniqueCoverArt`, t => { + t.plan(7); + + const {track, album} = stubTrackAndAlbum(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + track.artistData = [artist]; + album.artistData = [artist]; - XXX_CLEAR_TRACK_ALBUM_CACHE(); + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #1: defaults to false`); - t.equal(track.coverArtDate, trackCoverArtDate); + album.trackCoverArtistContribsByRef = contribs; + XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); - // 6. coverArtDate is null if track disables unique cover artwork + t.equal(track.hasUniqueCoverArt, true, + `hasUniqueCoverArt #2: is true if album specifies trackCoverArtistContribs`); track.disableUniqueCoverArt = true; - t.equal(track.coverArtDate, null); + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #3: is false if disableUniqueCoverArt is true (1/2)`); + + track.disableUniqueCoverArt = false; + + album.trackCoverArtistContribsByRef = badContribs; + XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #4: is false if album's trackCoverArtistContribsByRef resolve empty`); + + track.coverArtistContribsByRef = contribs; + + t.equal(track.hasUniqueCoverArt, true, + `hasUniqueCoverArt #5: is true if track specifies coverArtistContribs`); + + track.disableUniqueCoverArt = true; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #6: is false if disableUniqueCoverArt is true (2/2)`); + + track.disableUniqueCoverArt = false; + + track.coverArtistContribsByRef = badContribs; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #7: is false if track's coverArtistContribsByRef resolve empty`); }); -- cgit 1.3.0-6-gf8a5 From 0f4e27426384536c179583a8ffaf3dd9f121766b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 23 Aug 2023 19:00:35 -0300 Subject: data: Thing.composite.from: fix not calling export continuation --- src/data/things/thing.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index decde6f4..c1f969b2 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -797,7 +797,7 @@ export default class Thing extends CacheableObject { return result; } - return exportDependencies; + return continuationIfApplicable(exportDependencies); } else { return base.expose.compute(dependencies); } -- cgit 1.3.0-6-gf8a5 From 8dd100d04fdd13b4ab8348d61378de5fd74f72d4 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 23 Aug 2023 19:01:05 -0300 Subject: data: Thing.composite.withResolvedReference: fix null refs The `earlyExitIfNotFound` flag is only supposed to exit if the reference really existed and failed to match anything. If it was null in the first place, withResolvedReferences should always just pass null ahead. --- src/data/things/thing.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index c1f969b2..798a057a 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -866,8 +866,9 @@ export default class Thing extends CacheableObject { // within the provided thingData dependency. This will early exit if the // data dependency is null, or, if earlyExitIfNotFound is set to true, // if the find function doesn't match anything for the reference. - // Otherwise, the data object (or null, if not found) is provided on - // the output dependency. + // Otherwise, the data object is provided on the output dependency; + // or null, if the reference doesn't match anything or itself was null + // to begin with. withResolvedReference({ ref: refDependency, data: dataDependency, @@ -883,6 +884,8 @@ export default class Thing extends CacheableObject { dependencies: [refDependency, dataDependency], compute({[refDependency]: ref, [dataDependency]: data}, continuation) { + if (!ref) return continuation({[outputDependency]: null}); + if (data === null) return null; const match = findFunction(ref, data, {mode: 'quiet'}); -- cgit 1.3.0-6-gf8a5 From fab4b46d13795bcc82c8b4dd6b5a39ef23c42430 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 23 Aug 2023 19:02:44 -0300 Subject: data: fix more bad function signatures --- src/data/things/thing.js | 4 ++-- src/data/things/track.js | 13 +++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 798a057a..eaf4655d 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -854,8 +854,8 @@ export default class Thing extends CacheableObject { expose: { dependencies: ['artistData', contribsByRefDependency], - compute: ({artistData, [contribsByRefDependency]: contribsByRef}, callback) => - callback({ + compute: ({artistData, [contribsByRefDependency]: contribsByRef}, continuation) => + continuation({ [outputDependency]: Thing.findArtistsFromContribs(contribsByRef, artistData), }), diff --git a/src/data/things/track.js b/src/data/things/track.js index 414d5f29..74f5d7fb 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -301,12 +301,9 @@ export class Track extends Thing { }, artistContribs: Thing.composite.from(`Track.artistContribs`, [ - Track.composite.inheritFromOriginalRelease('artistContribs'), - - Track.composite.withAlbumProperties({ - properties: 'artistContribs', - }), + Track.composite.inheritFromOriginalRelease({property: 'artistContribs'}), + Track.composite.withAlbumProperties({properties: ['artistContribs']}), Thing.composite.withResolvedContribs({ from: 'artistContribsByRef', to: '#artistContribs', @@ -328,7 +325,7 @@ export class Track extends Thing { ]), contributorContribs: Thing.composite.from(`Track.contributorContribs`, [ - Track.composite.inheritFromOriginalRelease('contributorContribs'), + Track.composite.inheritFromOriginalRelease({property: 'contributorContribs'}), Thing.common.dynamicContribs('contributorContribsByRef'), ]), @@ -372,12 +369,12 @@ export class Track extends Thing { ]), referencedTracks: Thing.composite.from(`Track.referencedTracks`, [ - Track.composite.inheritFromOriginalRelease('referencedTracks'), + Track.composite.inheritFromOriginalRelease({property: 'referencedTracks'}), Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), ]), sampledTracks: Thing.composite.from(`Track.sampledTracks`, [ - Track.composite.inheritFromOriginalRelease('sampledTracks'), + Track.composite.inheritFromOriginalRelease({property: 'sampledTracks'}), Thing.common.dynamicThingsFromReferenceList('sampledTracksByRef', 'trackData', find.track), ]), -- cgit 1.3.0-6-gf8a5 From 2b2bbe9083d6f205e6b04b08c8bc4339a6a9ed87 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 24 Aug 2023 18:47:09 -0300 Subject: data: Thing.composite.from: mapDependencies/mapContinuation --- src/data/things/thing.js | 162 ++++++++++++++++++++++++++--------------------- 1 file changed, 91 insertions(+), 71 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index eaf4655d..a9fd220f 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -644,7 +644,7 @@ export default class Thing extends CacheableObject { push(new TypeError(`Base which composes can't also update yet`)); } - const exposeFunctionOrder = []; + const exposeSteps = []; const exposeDependencies = new Set(base.expose?.dependencies); for (let i = 0; i < steps.length; i++) { @@ -695,11 +695,7 @@ export default class Thing extends CacheableObject { fn = step.expose.compute; } - exposeFunctionOrder.push({ - type, - fn, - ownDependencies: step.expose.dependencies, - }); + exposeSteps.push(step.expose); } }); } @@ -727,81 +723,104 @@ export default class Thing extends CacheableObject { expose.dependencies = Array.from(exposeDependencies); const continuationSymbol = Symbol(); + const noTransformSymbol = Symbol(); - if (base.flags.update) { - expose.transform = (value, initialDependencies) => { - const dependencies = {...initialDependencies}; - let valueSoFar = value; - - for (const {type, fn, ownDependencies} of exposeFunctionOrder) { - const filteredDependencies = - (ownDependencies - ? filterProperties(dependencies, ownDependencies) - : {}) - - const result = - (type === 'transform' - ? fn(valueSoFar, filteredDependencies, (updatedValue, providedDependencies) => { + const _filterDependencies = (dependencies, step) => { + const filteredDependencies = + (step.dependencies + ? filterProperties(dependencies, step.dependencies) + : {}); + + if (step.mapDependencies) { + for (const [to, from] of Object.entries(step.mapDependencies)) { + filteredDependencies[to] = dependencies[from] ?? null; + } + } + + return filteredDependencies; + }; + + const _assignDependencies = (continuationAssignment, step) => { + if (!step.mapContinuation) { + return continuationAssignment; + } + + const assignDependencies = {}; + + for (const [from, to] of Object.entries(step.mapContinuation)) { + assignDependencies[to] = continuationAssignment[from] ?? null; + } + + return assignDependencies; + }; + + const _computeOrTransform = (value, initialDependencies) => { + const dependencies = {...initialDependencies}; + + let valueSoFar = value; + + for (const step of exposeSteps) { + const filteredDependencies = _filterDependencies(dependencies, step); + + let assignDependencies = null; + + const result = + (valueSoFar !== noTransformSymbol && step.transform + ? step.transform( + valueSoFar, filteredDependencies, + (updatedValue, providedDependencies) => { valueSoFar = updatedValue ?? null; - Object.assign(dependencies, providedDependencies ?? {}); + assignDependencies = providedDependencies; return continuationSymbol; }) - : fn(filteredDependencies, providedDependencies => { - Object.assign(dependencies, providedDependencies ?? {}); + : step.compute( + filteredDependencies, + (providedDependencies) => { + assignDependencies = providedDependencies; return continuationSymbol; })); - if (result !== continuationSymbol) { - return result; - } + if (result !== continuationSymbol) { + return result; } - const filteredDependencies = - filterProperties(dependencies, base.expose.dependencies); + Object.assign(dependencies, _assignDependencies(assignDependencies, step)); + } - // Note: base.flags.compose is not compatible with base.flags.update, - // so the base.flags.compose case is not handled here. + const filteredDependencies = _filterDependencies(dependencies, base.expose); - if (base.expose.transform) { - return base.expose.transform(valueSoFar, filteredDependencies); - } else { - return base.expose.compute(filteredDependencies); - } - }; - } else { - expose.compute = (initialDependencies, continuationIfApplicable) => { - const dependencies = {...initialDependencies}; - - for (const {fn} of exposeFunctionOrder) { - const result = - fn(dependencies, providedDependencies => { - Object.assign(dependencies, providedDependencies ?? {}); - return continuationSymbol; - }); - - if (result !== continuationSymbol) { - return result; - } - } + // Note: base.flags.compose is not compatible with base.flags.update. + if (base.expose.transform) { + return base.expose.transform(valueSoFar, filteredDependencies); + } else if (base.flags.compose) { + const continuation = continuationIfApplicable; - if (base.flags.compose) { - let exportDependencies; + let exportDependencies; - const result = - base.expose.compute(dependencies, providedDependencies => { - exportDependencies = providedDependencies; - return continuationSymbol; - }); + const result = + base.expose.compute(filteredDependencies, providedDependencies => { + exportDependencies = providedDependencies; + return continuationSymbol; + }); - if (result !== continuationSymbol) { - return result; - } - - return continuationIfApplicable(exportDependencies); - } else { - return base.expose.compute(dependencies); + if (result !== continuationSymbol) { + return result; } - }; + + return continuation(_assignDependencies(exportDependencies, base.expose)); + } else { + return base.expose.compute(filteredDependencies); + } + }; + + if (base.flags.update) { + expose.transform = + (value, initialDependencies) => + _computeOrTransform(value, initialDependencies); + } else { + expose.compute = + (initialDependencies) => + _computeOrTransform(undefined, initialDependencies); } } @@ -848,16 +867,17 @@ export default class Thing extends CacheableObject { // providing (named by the second argument) the result. "Resolving" // means mapping the "who" reference of each contribution to an artist // object, and filtering out those whose "who" doesn't match any artist. - withResolvedContribs: ({from: contribsByRefDependency, to: outputDependency}) => ({ + withResolvedContribs: ({from, to}) => ({ annotation: `Thing.composite.withResolvedContribs`, flags: {expose: true, compose: true}, expose: { - dependencies: ['artistData', contribsByRefDependency], - compute: ({artistData, [contribsByRefDependency]: contribsByRef}, continuation) => + dependencies: ['artistData'], + mapDependencies: {from}, + mapContinuation: {to}, + compute: ({artistData, from}, continuation) => continuation({ - [outputDependency]: - Thing.findArtistsFromContribs(contribsByRef, artistData), + to: Thing.findArtistsFromContribs(from, artistData), }), }, }), -- cgit 1.3.0-6-gf8a5 From 57edc116016f45f1bc9e7e3e6560450b6c480602 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 24 Aug 2023 18:49:11 -0300 Subject: data: fix not passing noTransformSymbol --- src/data/things/thing.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index a9fd220f..d553a3ec 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -820,7 +820,7 @@ export default class Thing extends CacheableObject { } else { expose.compute = (initialDependencies) => - _computeOrTransform(undefined, initialDependencies); + _computeOrTransform(noTransformSymbol, initialDependencies); } } -- cgit 1.3.0-6-gf8a5 From 287de65cea2fb72833eb2fe596f7e61c61939481 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 24 Aug 2023 19:00:14 -0300 Subject: data: Track.coverArtistContribs: lazier steps --- src/data/things/track.js | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 74f5d7fb..2a3148ef 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -344,28 +344,36 @@ export class Track extends Thing { }, }, - Track.composite.withAlbumProperties({ - properties: ['trackCoverArtistContribs'], - }), - Thing.composite.withResolvedContribs({ from: 'coverArtistContribsByRef', to: '#coverArtistContribs', }), { - flags: {expose: true}, + flags: {expose: true, compose: true}, expose: { - dependencies: ['#coverArtistContribs', '#album.trackCoverArtistContribs'], - compute: ({ - '#coverArtistContribs': contribsFromTrack, - '#album.trackCoverArtistContribs': contribsFromAlbum, - }) => + mapDependencies: {contribsFromTrack: '#coverArtistContribs'}, + compute: ({contribsFromTrack}, continuation) => (empty(contribsFromTrack) - ? contribsFromAlbum + ? continuation() : contribsFromTrack), }, }, + + Track.composite.withAlbumProperties({ + properties: ['trackCoverArtistContribs'], + }), + + { + flags: {expose: true}, + expose: { + mapDependencies: {contribsFromAlbum: '#album.trackCoverArtistContribs'}, + compute: ({contribsFromAlbum}) => + (empty(contribsFromAlbum) + ? null + : contribsFromAlbum), + }, + }, ]), referencedTracks: Thing.composite.from(`Track.referencedTracks`, [ -- cgit 1.3.0-6-gf8a5 From eb869dd1b786a4180647e5b8b1b6f20aefb6c004 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 24 Aug 2023 19:43:28 -0300 Subject: data: Track.compposite.from: 'options', cache-safe documentation --- src/data/things/thing.js | 126 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 112 insertions(+), 14 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index d553a3ec..1bca6c38 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -561,6 +561,97 @@ export default class Thing extends CacheableObject { // }, // ]); // + // == Cache-safe dependency names: == + // + // [Disclosure: The caching engine hasn't actually been implemented yet. + // As such, this section is subject to change, and simply provides sound + // forward-facing advice and interfaces.] + // + // It's a good idea to write individual compositional steps in such a way + // that they're "cache-safe" - meaning the same input (dependency) values + // will always result in the same output (continuation or early exit). + // + // In order to facilitate this, compositional step descriptors may specify + // unique `mapDependencies`, `mapContinuation`, and `options` values. + // + // Consider the `withResolvedContribs` example adjusted to make use of + // two of these options below: + // + // static Thing.composite.withResolvedContribs = ({ + // from: contribsByRefDependency, + // to: outputDependency, + // }) => ({ + // flags: {expose: true, compose: true}, + // expose: { + // dependencies: ['artistData'], + // mapDependencies: {contribsByRef: contribsByRefDependency}, + // mapContinuation: {outputDependency}, + // compute({ + // contribsByRef, /* no longer in square brackets */ + // artistData, + // }, continuation) { + // if (!artistData) return null; + // return continuation({ + // outputDependency: /* no longer in square brackets */ + // (..resolve contributions one way or another..), + // }); + // }, + // }, + // }); + // + // With a little destructuring and restructuring JavaScript sugar, the + // above can be simplified some more: + // + // static Thing.composite.withResolvedContribs = ({from, to}) => ({ + // flags: {expose: true, compose: true}, + // expose: { + // dependencies: ['artistData'], + // mapDependencies: {from}, + // mapContinuation: {to}, + // compute({artistData, from: contribsByRef}, continuation) { + // if (!artistData) return null; + // return continuation({ + // to: (..resolve contributions one way or another..), + // }); + // }, + // }, + // }); + // + // These two properties let you separate the name-mapping behavior (for + // dependencies and the continuation) from the main body of the compute + // function. That means the compute function will *always* get inputs in + // the same form (dependencies 'artistData' and 'from' above), and will + // *always* provide its output in the same form (early return or 'to'). + // + // Thanks to that, this `compute` function is cache-safe! Its outputs can + // be cached corresponding to each set of mapped inputs. So it won't matter + // whether the `from` dependency is named `coverArtistContribsByRef` or + // `contributorContribsByRef` or something else - the compute function + // doesn't care, and only expects that value to be provided via its `from` + // argument. Likewise, it doesn't matter if the output should be sent to + // '#coverArtistContribs` or `#contributorContribs` or some other name; + // the mapping is handled automatically outside, and compute will always + // output its value to the continuation's `to`. + // + // Note that `mapDependencies` and `mapContinuation` should be objects of + // the same "shape" each run - that is, the values will change depending on + // outside context, but the keys are always the same. You shouldn't use + // `mapDependencies` to dynamically select more or fewer dependencies. + // If you need to dynamically select a range of dependencies, just specify + // them in the `dependencies` array like usual. The caching engine will + // understand that differently named `dependencies` indicate separate + // input-output caches should be used. + // + // The 'options' property makes it possible to specify external arguments + // that fundamentally change the behavior of the `compute` function, while + // still remaining cache-safe. It indicates that the caching engine should + // use a completely different input-to-output cache for each permutation + // of the 'options' values. This way, those functions are still cacheable + // at all; they'll just be cached separately for each set of option values. + // Values on the 'options' property will always be provided in compute's + // dependencies under '#options' (to avoid name conflicts with other + // dependencies). + // // == To compute or to transform: == // // A compositional step can work directly on a property's stored update @@ -725,7 +816,7 @@ export default class Thing extends CacheableObject { const continuationSymbol = Symbol(); const noTransformSymbol = Symbol(); - const _filterDependencies = (dependencies, step) => { + function _filterDependencies(dependencies, step) { const filteredDependencies = (step.dependencies ? filterProperties(dependencies, step.dependencies) @@ -737,10 +828,14 @@ export default class Thing extends CacheableObject { } } + if (step.options) { + filteredDependencies['#options'] = step.options; + } + return filteredDependencies; - }; + } - const _assignDependencies = (continuationAssignment, step) => { + function _assignDependencies(continuationAssignment, step) { if (!step.mapContinuation) { return continuationAssignment; } @@ -752,9 +847,9 @@ export default class Thing extends CacheableObject { } return assignDependencies; - }; + } - const _computeOrTransform = (value, initialDependencies) => { + function _computeOrTransform(value, initialDependencies) { const dependencies = {...initialDependencies}; let valueSoFar = value; @@ -811,7 +906,7 @@ export default class Thing extends CacheableObject { } else { return base.expose.compute(filteredDependencies); } - }; + } if (base.flags.update) { expose.transform = @@ -842,9 +937,10 @@ export default class Thing extends CacheableObject { flags: {expose: true, compose: true}, expose: { + options: {mappingEntries}, dependencies: Object.values(mapping), - compute(dependencies, continuation) { + compute({'#options': {mappingEntries}, ...dependencies}, continuation) { const exports = {}; // Note: This is slightly different behavior from filterProperties, @@ -890,9 +986,9 @@ export default class Thing extends CacheableObject { // or null, if the reference doesn't match anything or itself was null // to begin with. withResolvedReference({ - ref: refDependency, - data: dataDependency, - to: outputDependency, + ref, + data, + to, find: findFunction, earlyExitIfNotFound = false, }) { @@ -901,17 +997,19 @@ export default class Thing extends CacheableObject { flags: {expose: true, compose: true}, expose: { - dependencies: [refDependency, dataDependency], + options: {findFunction, earlyExitIfNotFound}, + mapDependencies: {ref, data}, + mapContinuation: {to}, - compute({[refDependency]: ref, [dataDependency]: data}, continuation) { - if (!ref) return continuation({[outputDependency]: null}); + compute({ref, data, findFunction, earlyExitIfNotFound}, continuation) { + if (!ref) return continuation({to: null}); if (data === null) return null; const match = findFunction(ref, data, {mode: 'quiet'}); if (match === null && earlyExitIfNotFound) return null; - return continuation({[outputDependency]: match}); + return continuation({to: match}); }, }, }; -- cgit 1.3.0-6-gf8a5 From ba1cf3fe611661c85ef4a7150c924f99e1e94ba3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 24 Aug 2023 21:10:46 -0300 Subject: data: bug fixes & Thing.composite.from.debug mode --- src/data/things/thing.js | 272 +++++++++++++++++++++++++++++++++++++++-------- src/data/things/track.js | 3 +- 2 files changed, 229 insertions(+), 46 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 1bca6c38..555e443d 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -508,7 +508,9 @@ export default class Thing extends CacheableObject { // } // // Performing an early exit is as simple as returning some other value, - // instead of the continuation. + // instead of the continuation. You may also use `continuation.exit(value)` + // to perform the exact same kind of early exit - it's just a different + // syntax that might fit in better in certain longer compositions. // // It may be fine to simply provide new dependencies under a hard-coded // name, such as '#excitingProperty' above, but if you're writing a utility @@ -715,6 +717,17 @@ export default class Thing extends CacheableObject { // still dynamically provided dependencies!) // from(firstArg, secondArg) { + const debug = fn => { + if (Thing.composite.from.debug === true) { + const result = fn(); + if (Array.isArray(result)) { + console.log(`[composite]`, ...result); + } else { + console.log(`[composite]`, result); + } + } + }; + let annotation, composition; if (typeof firstArg === 'string') { [annotation, composition] = [firstArg, secondArg]; @@ -738,6 +751,13 @@ export default class Thing extends CacheableObject { const exposeSteps = []; const exposeDependencies = new Set(base.expose?.dependencies); + if (base.expose?.mapDependencies) { + for (const dependency of Object.values(base.expose.mapDependencies)) { + if (typeof dependency === 'string' && dependency.startsWith('#')) continue; + exposeDependencies.add(dependency); + } + } + for (let i = 0; i < steps.length; i++) { const step = steps[i]; const message = @@ -767,6 +787,13 @@ export default class Thing extends CacheableObject { } } + if (step.expose.mapDependencies) { + for (const dependency of Object.values(step.expose.mapDependencies)) { + if (typeof dependency === 'string' && dependency.startsWith('#')) continue; + exposeDependencies.add(dependency); + } + } + let fn, type; if (base.flags.update) { if (step.expose.transform) { @@ -813,8 +840,8 @@ export default class Thing extends CacheableObject { const expose = constructedDescriptor.expose = {}; expose.dependencies = Array.from(exposeDependencies); - const continuationSymbol = Symbol(); - const noTransformSymbol = Symbol(); + const continuationSymbol = Symbol('continuation symbol'); + const noTransformSymbol = Symbol('no-transform symbol'); function _filterDependencies(dependencies, step) { const filteredDependencies = @@ -849,48 +876,153 @@ export default class Thing extends CacheableObject { return assignDependencies; } + function _prepareContinuation(transform, step) { + const continuationStorage = { + returnedWith: null, + providedDependencies: null, + providedValue: null, + }; + + const continuation = + (transform + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.exit = (providedValue) => { + continuationStorage.returnedWith = 'exit'; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + }; + + if (base.flags.compose) { + continuation.raise = + (transform + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = 'raise'; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = 'raise'; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + } + + return {continuation, continuationStorage}; + } + function _computeOrTransform(value, initialDependencies) { const dependencies = {...initialDependencies}; - let valueSoFar = value; + let valueSoFar = value; // Set only for {update: true} compositions + let exportDependencies = null; // Set only for {compose: true} compositions + + debug(() => color.bright(`begin composition (annotation: ${annotation})`)); + + for (let i = 0; i < exposeSteps.length; i++) { + const step = exposeSteps[i]; + debug(() => [`step #${i+1}:`, step]); + + const transform = + valueSoFar !== noTransformSymbol && + step.transform; - for (const step of exposeSteps) { const filteredDependencies = _filterDependencies(dependencies, step); + const {continuation, continuationStorage} = _prepareContinuation(transform, step); - let assignDependencies = null; + if (transform) { + debug(() => `step #${i+1} - transform with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); + } else { + debug(() => `step #${i+1} - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); + } const result = - (valueSoFar !== noTransformSymbol && step.transform - ? step.transform( - valueSoFar, filteredDependencies, - (updatedValue, providedDependencies) => { - valueSoFar = updatedValue ?? null; - assignDependencies = providedDependencies; - return continuationSymbol; - }) - : step.compute( - filteredDependencies, - (providedDependencies) => { - assignDependencies = providedDependencies; - return continuationSymbol; - })); + (transform + ? step.transform(valueSoFar, filteredDependencies, continuation) + : step.compute(filteredDependencies, continuation)); if (result !== continuationSymbol) { + if (base.flags.compose) { + throw new TypeError(`Use continuation.exit() or continuation.raise() in {compose: true} compositions`); + } + + debug(() => `step #${i+1} - early-exit (inferred)`); + debug(() => `early-exit: ${inspect(result, {compact: true})}`); + debug(() => color.bright(`end composition (annotation: ${annotation})`)); + return result; } - Object.assign(dependencies, _assignDependencies(assignDependencies, step)); + if (continuationStorage.returnedWith === 'exit') { + debug(() => `step #${i+1} - result: early-exit (explicit)`); + debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`); + debug(() => color.bright(`end composition (annotation: ${annotation})`)); + + return continuationSymbol.providedValue; + } + + if (continuationStorage.returnedWith === 'raise') { + if (transform) { + valueSoFar = continuationStorage.providedValue; + } + + exportDependencies = _assignDependencies(continuationStorage.providedDependencies, step); + + debug(() => `step #${i+1} - result: raise`); + + break; + } + + if (continuationStorage.returnedWith === 'continuation') { + if (transform) { + valueSoFar = continuationStorage.providedValue; + } + + debug(() => `step #${i+1} - result: continuation`); + + if (continuationStorage.providedDependencies) { + const assignDependencies = _assignDependencies(continuationStorage.providedDependencies, step); + Object.assign(dependencies, assignDependencies); + + debug(() => [`assign dependencies:`, assignDependencies]); + } + } } + if (exportDependencies) { + debug(() => [`raise dependencies:`, exportDependencies]); + debug(() => color.bright(`end composition (annotation: ${annotation})`)); + return continuationIfApplicable(exportDependencies); + } + + debug(() => `completed all steps, reached base`); + const filteredDependencies = _filterDependencies(dependencies, base.expose); // Note: base.flags.compose is not compatible with base.flags.update. if (base.expose.transform) { - return base.expose.transform(valueSoFar, filteredDependencies); + debug(() => `base - transform with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); + + const result = base.expose.transform(valueSoFar, filteredDependencies); + + debug(() => `base - non-compose (final) result: ${inspect(result, {compact: true})}`); + + return result; } else if (base.flags.compose) { - const continuation = continuationIfApplicable; + const {continuation, continuationStorage} = _prepareContinuation(transform, base.expose); - let exportDependencies; + debug(() => `base - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); const result = base.expose.compute(filteredDependencies, providedDependencies => { @@ -899,12 +1031,39 @@ export default class Thing extends CacheableObject { }); if (result !== continuationSymbol) { - return result; + throw new TypeError(`Use continuation.exit() or continuation.raise() in {compose: true} composition`); + } + + if (continuationStorage.returnedWith === 'continuation') { + throw new TypeError(`Use continuation.raise() in base of {compose: true} composition`); + } + + if (continuationStorage.returnedWith === 'exit') { + debug(() => `base - result: early-exit (explicit)`); + debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`); + debug(() => color.bright(`end composition (annotation: ${annotation})`)); + + return continuationStorage.providedValue; } - return continuation(_assignDependencies(exportDependencies, base.expose)); + if (continuationStorage.returnedWith === 'raise') { + exportDependencies = _assignDependencies(continuationStorage.providedDependencies, base.expose); + + debug(() => `base - result: raise`); + debug(() => `raise dependencies: ${inspect(exportDependencies, {compact: true})}`); + debug(() => color.bright(`end composition (annotation: ${annotation})`)); + + return continuationIfApplicable(exportDependencies); + } } else { - return base.expose.compute(filteredDependencies); + debug(() => `base - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); + + const result = base.expose.compute(filteredDependencies); + + debug(() => `base - non-compose (final) result: ${inspect(result, {compact: true})}`); + debug(() => color.bright(`end composition (annotation: ${annotation})`)); + + return result; } } @@ -953,7 +1112,7 @@ export default class Thing extends CacheableObject { : null); } - return continuation(exports); + return continuation.raise(exports); } }, }; @@ -985,34 +1144,57 @@ export default class Thing extends CacheableObject { // Otherwise, the data object is provided on the output dependency; // or null, if the reference doesn't match anything or itself was null // to begin with. - withResolvedReference({ + withResolvedReference: ({ ref, data, to, find: findFunction, earlyExitIfNotFound = false, - }) { - return { - annotation: `Thing.composite.withResolvedReference`, - flags: {expose: true, compose: true}, + }) => + Thing.composite.from(`Thing.composite.withResolvedReference`, [ + { + flags: {expose: true, compose: true}, + expose: { + mapDependencies: {ref}, + mapContinuation: {to}, + + compute: ({ref}, continuation) => + (ref + ? continuation() + : continuation.raise({to: null})), + }, + }, - expose: { - options: {findFunction, earlyExitIfNotFound}, - mapDependencies: {ref, data}, - mapContinuation: {to}, + { + flags: {expose: true, compose: true}, + expose: { + mapDependencies: {data}, + + compute: ({data}, continuation) => + (data === null + ? continuation.exit(null) + : continuation()), + }, + }, - compute({ref, data, findFunction, earlyExitIfNotFound}, continuation) { - if (!ref) return continuation({to: null}); + { + flags: {expose: true, compose: true}, + expose: { + options: {findFunction, earlyExitIfNotFound}, + mapDependencies: {ref, data}, + mapContinuation: {match: to}, - if (data === null) return null; + compute({ref, data, '#options': {findFunction, earlyExitIfNotFound}}, continuation) { + const match = findFunction(ref, data, {mode: 'quiet'}); - const match = findFunction(ref, data, {mode: 'quiet'}); - if (match === null && earlyExitIfNotFound) return null; + if (match === null && earlyExitIfNotFound) { + return continuation.exit(null); + } - return continuation({to: match}); + return continuation({match}); + }, }, }, - }; - } + ]), }; } diff --git a/src/data/things/track.js b/src/data/things/track.js index 2a3148ef..23b6da56 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -475,8 +475,9 @@ export class Track extends Thing { expose: { dependencies: ['this', 'albumData'], + options: {properties, prefix}, - compute({this: track, albumData}, continuation) { + compute({this: track, albumData, '#options': {properties, prefix}}, continuation) { const album = albumData?.find((album) => album.tracks.includes(track)); const newDependencies = {}; -- cgit 1.3.0-6-gf8a5 From 2a87cbaf06e364585bae2b48919c46b6d1f7aa1f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 25 Aug 2023 13:28:29 -0300 Subject: data: Thing.composite.from bugfixes --- src/data/things/thing.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 555e443d..16dd786d 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -922,7 +922,7 @@ export default class Thing extends CacheableObject { return {continuation, continuationStorage}; } - function _computeOrTransform(value, initialDependencies) { + function _computeOrTransform(value, initialDependencies, continuationIfApplicable) { const dependencies = {...initialDependencies}; let valueSoFar = value; // Set only for {update: true} compositions @@ -1020,15 +1020,11 @@ export default class Thing extends CacheableObject { return result; } else if (base.flags.compose) { - const {continuation, continuationStorage} = _prepareContinuation(transform, base.expose); + const {continuation, continuationStorage} = _prepareContinuation(false, base.expose); debug(() => `base - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); - const result = - base.expose.compute(filteredDependencies, providedDependencies => { - exportDependencies = providedDependencies; - return continuationSymbol; - }); + const result = base.expose.compute(filteredDependencies, continuation); if (result !== continuationSymbol) { throw new TypeError(`Use continuation.exit() or continuation.raise() in {compose: true} composition`); @@ -1069,12 +1065,12 @@ export default class Thing extends CacheableObject { if (base.flags.update) { expose.transform = - (value, initialDependencies) => - _computeOrTransform(value, initialDependencies); + (value, initialDependencies, continuationIfApplicable) => + _computeOrTransform(value, initialDependencies, continuationIfApplicable); } else { expose.compute = - (initialDependencies) => - _computeOrTransform(noTransformSymbol, initialDependencies); + (initialDependencies, continuationIfApplicable) => + _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable); } } -- cgit 1.3.0-6-gf8a5 From fe9bd87d1e6b71c3019b38ca2f99e0c21d916186 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 25 Aug 2023 13:28:56 -0300 Subject: data: use continuation.exit and continuation.raise where needed --- src/data/things/thing.js | 2 +- src/data/things/track.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 16dd786d..578a5a4e 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1187,7 +1187,7 @@ export default class Thing extends CacheableObject { return continuation.exit(null); } - return continuation({match}); + return continuation.raise({match}); }, }, }, diff --git a/src/data/things/track.js b/src/data/things/track.js index 23b6da56..5c3a1d46 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -454,12 +454,12 @@ export class Track extends Thing { dependencies: ['#originalRelease'], compute({'#originalRelease': originalRelease}, continuation) { - if (!originalRelease) return continuation(); + if (!originalRelease) return continuation.raise(); const value = originalRelease[originalProperty]; - if (allowOverride && value === null) return continuation(); + if (allowOverride && value === null) return continuation.raise(); - return value; + return continuation.exit(value); }, }, } -- cgit 1.3.0-6-gf8a5 From c7d8ab3286854faa2ccf54e84b8efbff43a416fc Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 25 Aug 2023 13:29:38 -0300 Subject: data: Track.artistContribs: be lazy, like coverArtistContribs --- src/data/things/track.js | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 5c3a1d46..016f5199 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -303,25 +303,34 @@ export class Track extends Thing { artistContribs: Thing.composite.from(`Track.artistContribs`, [ Track.composite.inheritFromOriginalRelease({property: 'artistContribs'}), - Track.composite.withAlbumProperties({properties: ['artistContribs']}), Thing.composite.withResolvedContribs({ from: 'artistContribsByRef', to: '#artistContribs', }), { - flags: {expose: true}, + flags: {expose: true, compose: true}, expose: { - dependencies: ['#artistContribs', '#album.artistContribs'], - compute: ({ - '#artistContribs': contribsFromTrack, - '#album.artistContribs': contribsFromAlbum, - }) => + mapDependencies: {contribsFromTrack: '#artistContribs'}, + compute: ({contribsFromTrack}, continuation) => (empty(contribsFromTrack) - ? contribsFromAlbum + ? continuation() : contribsFromTrack), }, }, + + Track.composite.withAlbumProperties({properties: ['artistContribs']}), + + { + flags: {expose: true}, + expose: { + mapDependencies: {contribsFromAlbum: '#album.artistContribs'}, + compute: ({contribsFromAlbum}) => + (empty(contribsFromAlbum) + ? null + : contribsFromAlbum), + }, + }, ]), contributorContribs: Thing.composite.from(`Track.contributorContribs`, [ -- cgit 1.3.0-6-gf8a5 From 087564095ff06fed25a0c21fab01ed9d849937d0 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 25 Aug 2023 13:30:08 -0300 Subject: test: Track.otherReleases (unit) --- test/unit/data/things/track.js | 76 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 37aa7b9f..9d4ba2c3 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -20,14 +20,14 @@ function stubAlbum(tracks) { return album; } -function stubTrack() { +function stubTrack(directory = 'foo') { const track = new Track(); - track.directory = 'foo'; + track.directory = directory; return track; } -function stubTrackAndAlbum() { - const track = stubTrack(); +function stubTrackAndAlbum(trackDirectory = 'foo') { + const track = stubTrack(trackDirectory); const album = stubAlbum([track]); track.albumData = [album]; return {track, album}; @@ -42,10 +42,30 @@ function stubArtistAndContribs() { return {artist, contribs, badContribs}; } +function assignWikiData({ + XXX_DECACHE = false, + albumData, + trackData, +}) { + for (const album of albumData ?? []) { + if (XXX_DECACHE) { + album.trackData = []; + } + album.trackData = trackData ?? []; + } + + for (const track of trackData ?? []) { + if (XXX_DECACHE) { + track.albumData = []; + track.trackData = []; + } + track.albumData = albumData ?? []; + track.trackData = trackData ?? []; + } +} + function XXX_CLEAR_TRACK_ALBUM_CACHE(track, album) { - // XXX clear cache so change in album's property is reflected - track.albumData = []; - track.albumData = [album]; + assignWikiData({XXX_DECACHE: true, albumData: [album], trackData: [track]}); } t.test(`Track.color`, t => { @@ -176,3 +196,45 @@ t.test(`Track.hasUniqueCoverArt`, t => { t.equal(track.hasUniqueCoverArt, false, `hasUniqueCoverArt #7: is false if track's coverArtistContribsByRef resolve empty`); }); + +t.test(`Track.otherReleases`, t => { + t.plan(6); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + const {track: track3, album: album3} = stubTrackAndAlbum('track3'); + const {track: track4, album: album4} = stubTrackAndAlbum('track4'); + + let trackData = [track1, track2, track3, track4]; + let albumData = [album1, album2, album3, album4]; + assignWikiData({trackData, albumData}); + + t.same(track1.otherReleases, [], + `otherReleases #1: defaults to empty array`); + + track2.originalReleaseTrackByRef = 'track:track1'; + track3.originalReleaseTrackByRef = 'track:track1'; + track4.originalReleaseTrackByRef = 'track:track1'; + assignWikiData({trackData, albumData, XXX_DECACHE: true}); + + t.same(track1.otherReleases, [track2, track3, track4], + `otherReleases #2: otherReleases of original release are its rereleases`); + + trackData = [track1, track3, track2, track4]; + assignWikiData({trackData, albumData}); + + t.same(track1.otherReleases, [track3, track2, track4], + `otherReleases #3: otherReleases matches trackData order`); + + trackData = [track3, track2, track1, track4]; + assignWikiData({trackData, albumData}); + + t.same(track2.otherReleases, [track1, track3, track4], + `otherReleases #4: otherReleases of rerelease are original track then other rereleases (1/3)`); + + t.same(track3.otherReleases, [track1, track2, track4], + `otherReleases #5: otherReleases of rerelease are original track then other rereleases (2/3)`); + + t.same(track4.otherReleases, [track1, track3, track2], + `otherReleases #6: otherReleases of rerelease are original track then other rereleases (1/3)`); +}); -- cgit 1.3.0-6-gf8a5 From f562896d4d67558a32726f7086beebf29019a44d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 25 Aug 2023 14:06:00 -0300 Subject: yaml, test: mutate/decache wikiData in more reusable ways --- src/data/yaml.js | 20 ++++++-- test/lib/index.js | 1 + test/lib/wiki-data.js | 24 ++++++++++ test/unit/data/things/track.js | 105 ++++++++++++++++++++--------------------- 4 files changed, 93 insertions(+), 57 deletions(-) create mode 100644 test/lib/wiki-data.js diff --git a/src/data/yaml.js b/src/data/yaml.js index 25eda3c5..2ad2d41d 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -1320,13 +1320,27 @@ export async function loadAndProcessDataDocuments({dataPath}) { // Data linking! Basically, provide (portions of) wikiData to the Things which // require it - they'll expose dynamically computed properties as a result (many -// of which are required for page HTML generation). -export function linkWikiDataArrays(wikiData) { +// of which are required for page HTML generation and other expected behavior). +// +// The XXX_decacheWikiData option should be used specifically to mark +// points where you *aren't* replacing any of the arrays under wikiData with +// new values, and are using linkWikiDataArrays to instead "decache" data +// properties which depend on any of them. It's currently not possible for +// a CacheableObject to depend directly on the value of a property exposed +// on some other CacheableObject, so when those values change, you have to +// manually decache before the object will realize its cache isn't valid +// anymore. +export function linkWikiDataArrays(wikiData, { + XXX_decacheWikiData = false, +} = {}) { function assignWikiData(things, ...keys) { + if (things === undefined) return; for (let i = 0; i < things.length; i++) { const thing = things[i]; for (let j = 0; j < keys.length; j++) { const key = keys[j]; + if (!(key in wikiData)) continue; + if (XXX_decacheWikiData) thing[key] = []; thing[key] = wikiData[key]; } } @@ -1344,7 +1358,7 @@ export function linkWikiDataArrays(wikiData) { assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData'); assignWikiData(WD.flashActData, 'flashData'); assignWikiData(WD.artTagData, 'albumData', 'trackData'); - assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData'); + assignWikiData(WD.homepageLayout?.rows, 'albumData', 'groupData'); } export function sortWikiDataArrays(wikiData) { diff --git a/test/lib/index.js b/test/lib/index.js index b9cc82f8..6eaaa656 100644 --- a/test/lib/index.js +++ b/test/lib/index.js @@ -1,3 +1,4 @@ export * from './content-function.js'; export * from './generic-mock.js'; +export * from './wiki-data.js'; export * from './strict-match-error.js'; diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js new file mode 100644 index 00000000..c4083a56 --- /dev/null +++ b/test/lib/wiki-data.js @@ -0,0 +1,24 @@ +import {linkWikiDataArrays} from '#yaml'; + +export function linkAndBindWikiData(wikiData) { + linkWikiDataArrays(wikiData); + + return { + // Mutate to make the below functions aware of new data objects, or of + // reordering the existing ones. Don't mutate arrays such as trackData + // in-place; assign completely new arrays to this wikiData object instead. + wikiData, + + // Use this after you've mutated wikiData to assign new data arrays. + // It'll automatically relink everything on wikiData so all the objects + // are caught up to date. + linkWikiDataArrays: + linkWikiDataArrays.bind(null, wikiData), + + // Use this if you HAVEN'T mutated wikiData and just need to decache + // indirect dependencies on exposed properties of other data objects. + // See documentation on linkWikiDataArarys (in yaml.js) for more info. + XXX_decacheWikiData: + linkWikiDataArrays.bind(null, wikiData, {XXX_decacheWikiData: true}), + }; +} diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 9d4ba2c3..218353c8 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -1,4 +1,6 @@ import t from 'tap'; + +import {linkAndBindWikiData} from '#test-lib'; import thingConstructors from '#things'; const { @@ -6,30 +8,28 @@ const { Artist, Thing, Track, - TrackGroup, } = thingConstructors; -function stubAlbum(tracks) { +function stubAlbum(tracks, directory = 'bar') { const album = new Album(); - album.trackSections = [ - { - tracksByRef: tracks.map(t => Thing.getReference(t)), - }, - ]; - album.trackData = tracks; + album.directory = directory; + + const tracksByRef = tracks.map(t => Thing.getReference(t)); + album.trackSections = [{tracksByRef}]; + return album; } function stubTrack(directory = 'foo') { const track = new Track(); track.directory = directory; + return track; } -function stubTrackAndAlbum(trackDirectory = 'foo') { +function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') { const track = stubTrack(trackDirectory); - const album = stubAlbum([track]); - track.albumData = [album]; + const album = stubAlbum([track], albumDirectory); return {track, album}; } @@ -39,33 +39,8 @@ function stubArtistAndContribs() { const contribs = [{who: `Test Artist`, what: null}]; const badContribs = [{who: `Figment of Your Imagination`, what: null}]; - return {artist, contribs, badContribs}; -} - -function assignWikiData({ - XXX_DECACHE = false, - albumData, - trackData, -}) { - for (const album of albumData ?? []) { - if (XXX_DECACHE) { - album.trackData = []; - } - album.trackData = trackData ?? []; - } - - for (const track of trackData ?? []) { - if (XXX_DECACHE) { - track.albumData = []; - track.trackData = []; - } - track.albumData = albumData ?? []; - track.trackData = trackData ?? []; - } -} -function XXX_CLEAR_TRACK_ALBUM_CACHE(track, album) { - assignWikiData({XXX_DECACHE: true, albumData: [album], trackData: [track]}); + return {artist, contribs, badContribs}; } t.test(`Track.color`, t => { @@ -73,11 +48,16 @@ t.test(`Track.color`, t => { const {track, album} = stubTrackAndAlbum(); + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + trackData: [track], + }); + t.equal(track.color, null, `color #1: defaults to null`); album.color = '#abcdef'; - XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); + XXX_decacheWikiData(); t.equal(track.color, '#abcdef', `color #2: inherits from album`); @@ -94,14 +74,20 @@ t.test(`Track.coverArtDate`, t => { const {track, album} = stubTrackAndAlbum(); const {artist, contribs} = stubArtistAndContribs(); + const {XXX_decacheWikiData} = linkAndBindWikiData({ + trackData: [track], + albumData: [album], + artistData: [artist], + }); + track.coverArtistContribsByRef = contribs; - track.artistData = [artist]; t.equal(track.coverArtDate, null, `coverArtDate #1: defaults to null`); album.trackArtDate = new Date('2012-12-12'); - XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); + + XXX_decacheWikiData(); t.same(track.coverArtDate, new Date('2012-12-12'), `coverArtDate #2: inherits album trackArtDate`); @@ -117,7 +103,8 @@ t.test(`Track.coverArtDate`, t => { `coverArtDate #4: is null if track is missing coverArtists`); album.trackCoverArtistContribsByRef = contribs; - XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); + + XXX_decacheWikiData(); t.same(track.coverArtDate, new Date('2009-09-09'), `coverArtDate #5: is not null if album specifies trackCoverArtistContribs`); @@ -133,11 +120,16 @@ t.test(`Track.date`, t => { const {track, album} = stubTrackAndAlbum(); + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + trackData: [track], + }); + t.equal(track.date, null, `date #1: defaults to null`); album.date = new Date('2012-12-12'); - XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); + XXX_decacheWikiData(); t.same(track.date, album.date, `date #2: inherits from album`); @@ -154,14 +146,17 @@ t.test(`Track.hasUniqueCoverArt`, t => { const {track, album} = stubTrackAndAlbum(); const {artist, contribs, badContribs} = stubArtistAndContribs(); - track.artistData = [artist]; - album.artistData = [artist]; + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + trackData: [track], + }); t.equal(track.hasUniqueCoverArt, false, `hasUniqueCoverArt #1: defaults to false`); album.trackCoverArtistContribsByRef = contribs; - XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); + XXX_decacheWikiData(); t.equal(track.hasUniqueCoverArt, true, `hasUniqueCoverArt #2: is true if album specifies trackCoverArtistContribs`); @@ -174,7 +169,7 @@ t.test(`Track.hasUniqueCoverArt`, t => { track.disableUniqueCoverArt = false; album.trackCoverArtistContribsByRef = badContribs; - XXX_CLEAR_TRACK_ALBUM_CACHE(track, album); + XXX_decacheWikiData(); t.equal(track.hasUniqueCoverArt, false, `hasUniqueCoverArt #4: is false if album's trackCoverArtistContribsByRef resolve empty`); @@ -205,9 +200,10 @@ t.test(`Track.otherReleases`, t => { const {track: track3, album: album3} = stubTrackAndAlbum('track3'); const {track: track4, album: album4} = stubTrackAndAlbum('track4'); - let trackData = [track1, track2, track3, track4]; - let albumData = [album1, album2, album3, album4]; - assignWikiData({trackData, albumData}); + const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ + trackData: [track1, track2, track3, track4], + albumData: [album1, album2, album3, album4], + }); t.same(track1.otherReleases, [], `otherReleases #1: defaults to empty array`); @@ -215,19 +211,20 @@ t.test(`Track.otherReleases`, t => { track2.originalReleaseTrackByRef = 'track:track1'; track3.originalReleaseTrackByRef = 'track:track1'; track4.originalReleaseTrackByRef = 'track:track1'; - assignWikiData({trackData, albumData, XXX_DECACHE: true}); + + XXX_decacheWikiData(); t.same(track1.otherReleases, [track2, track3, track4], `otherReleases #2: otherReleases of original release are its rereleases`); - trackData = [track1, track3, track2, track4]; - assignWikiData({trackData, albumData}); + wikiData.trackData = [track1, track3, track2, track4]; + linkWikiDataArrays(); t.same(track1.otherReleases, [track3, track2, track4], `otherReleases #3: otherReleases matches trackData order`); - trackData = [track3, track2, track1, track4]; - assignWikiData({trackData, albumData}); + wikiData.trackData = [track3, track2, track1, track4]; + linkWikiDataArrays(); t.same(track2.otherReleases, [track1, track3, track4], `otherReleases #4: otherReleases of rerelease are original track then other rereleases (1/3)`); -- cgit 1.3.0-6-gf8a5 From 809ae313afdb6c7bb859a94170f6fd2c6c888591 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 26 Aug 2023 18:28:19 -0300 Subject: data: Track.composite.withAlbum --- src/data/things/track.js | 83 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 016f5199..6c08aa01 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -474,34 +474,85 @@ export class Track extends Thing { } ]), - // Gets the listed properties from this track's album, providing them as - // dependencies (by default) with '#album.' prefixed before each property - // name. If the track's album isn't available, the same dependency names - // will each be provided as null. - withAlbumProperties: ({properties, prefix = '#album'}) => ({ - annotation: `Track.composite.withAlbumProperties`, + // Gets the track's album. Unless earlyExitIfNotFound is overridden false, + // this will early-exit with null in two cases - albumData being missing, + // or not including an album whose .tracks array includes this track. + withAlbum: ({to = '#album', earlyExitIfNotFound = true} = {}) => ({ + annotation: `Track.composite.withAlbum`, flags: {expose: true, compose: true}, expose: { dependencies: ['this', 'albumData'], - options: {properties, prefix}, + mapContinuation: {to}, + options: {earlyExitIfNotFound}, + + compute({ + this: track, + albumData, + '#options': {earlyExitIfNotFound}, + }, continuation) { + if (empty(albumData)) { + return ( + (earlyExitIfNotFound + ? continuation.exit(null) + : continuation({to: null}))); + } - compute({this: track, albumData, '#options': {properties, prefix}}, continuation) { - const album = albumData?.find((album) => album.tracks.includes(track)); - const newDependencies = {}; + const album = + albumData?.find(album => album.tracks.includes(track)); - for (const property of properties) { - newDependencies[prefix + '.' + property] = - (album - ? album[property] - : null); + if (!album) { + return ( + (earlyExitIfNotFound + ? continuation.exit(null) + : continuation({to: null}))); } - return continuation(newDependencies); + return continuation({to: album}); }, }, }), + // Gets the listed properties from this track's album, providing them as + // dependencies (by default) with '#album.' prefixed before each property + // name. If the track's album isn't available, and earlyExitIfNotFound + // hasn't been set, the same dependency names will be provided as null. + withAlbumProperties: ({ + properties, + prefix = '#album', + earlyExitIfNotFound = false, + }) => + Thing.composite.from(`Track.composite.withAlbumProperties`, [ + Track.composite.withAlbum({earlyExitIfNotFound}), + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#album'], + options: {properties, prefix}, + + compute({ + '#album': album, + '#options': {properties, prefix}, + }, continuation) { + const raise = {}; + + if (album) { + for (const property of properties) { + raise[prefix + '.' + property] = album[property]; + } + } else { + for (const property of properties) { + raise[prefix + '.' + property] = null; + } + } + + return continuation.raise(raise); + }, + }, + }, + ]), + // Just includes the original release of this track as a dependency, or // null, if it's not a rerelease. Note that this will early exit if the // original release is specified by reference and that reference doesn't -- cgit 1.3.0-6-gf8a5 From e2f1cd30f8d5804f97043faedc5aea9fe06cea32 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 26 Aug 2023 18:46:14 -0300 Subject: data: Thing.composite.from: fix undefined return for explicit exit --- src/data/things/thing.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 578a5a4e..c870b89c 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -969,7 +969,7 @@ export default class Thing extends CacheableObject { debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`); debug(() => color.bright(`end composition (annotation: ${annotation})`)); - return continuationSymbol.providedValue; + return continuationStorage.providedValue; } if (continuationStorage.returnedWith === 'raise') { -- cgit 1.3.0-6-gf8a5 From 25beb8731d756bfa4fe6babb9e4b0a707c7823e0 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 26 Aug 2023 19:22:38 -0300 Subject: data, test: misc. additions * Thing.composite.expose * Thing.composite.exposeUpdateValueOrContinue * Track.composite.withAlbumProperty * refactor: Track.color, Track.album, Track.date * refactor: Track.coverArtistContribs * test: Track.album (unit) --- src/data/things/thing.js | 51 +++++++++++++++++++++++ src/data/things/track.js | 95 ++++++++++++++++++------------------------ test/unit/data/things/track.js | 53 +++++++++++++++++++++++ 3 files changed, 145 insertions(+), 54 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index c870b89c..2af06904 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1114,6 +1114,57 @@ export default class Thing extends CacheableObject { }; }, + // Exposes a dependency exactly as it is; this is typically the base of a + // composition which was created to serve as one property's descriptor. + // Since this serves as a base, specify {update: true} to indicate that + // the property as a whole updates (and some previous compositional step + // works with that update value). + // + // Please note that this *doesn't* verify that the dependency exists, so + // if you provide the wrong name or it hasn't been set by a previous + // compositional step, the property will be exposed as undefined instead + // of null. + // + expose: (dependency, {update = false} = {}) => ({ + annotation: `Thing.composite.expose`, + flags: {expose: true, update}, + + expose: { + mapDependencies: {dependency}, + compute: ({dependency}) => dependency, + }, + }), + + // Exposes the update value of an {update: true} property, or continues if + // it's unavailable. By default, "unavailable" means value === null, but + // set {mode: 'empty'} to + exposeUpdateValueOrContinue({mode = 'null'} = {}) { + if (mode !== 'null' && mode !== 'empty') { + throw new TypeError(`Expected mode to be null or empty`); + } + + return { + annotation: `Thing.composite.exposeUpdateValueOrContinue`, + flags: {expose: true, compose: true}, + expose: { + options: {mode}, + + transform(value, {'#options': {mode}}, continuation) { + const shouldContinue = + (mode === 'empty' + ? empty(value) + : value === null); + + if (shouldContinue) { + return continuation(); + } else { + return continuation.exit(value); + } + } + }, + }; + }, + // Resolves the contribsByRef contained in the provided dependency, // providing (named by the second argument) the result. "Resolving" // means mapping the "who" reference of each contribution to an artist diff --git a/src/data/things/track.js b/src/data/things/track.js index 6c08aa01..15a48bb4 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -45,26 +45,9 @@ export class Track extends Thing { artTagsByRef: Thing.common.referenceList(ArtTag), color: Thing.composite.from(`Track.color`, [ - { - flags: {expose: true, compose: true}, - expose: { - transform: (color, {}, continuation) => - color ?? continuation(), - }, - }, - - Track.composite.withAlbumProperties({ - properties: ['color'], - }), - - { - flags: {update: true, expose: true}, - update: {validate: isColor}, - expose: { - dependencies: ['#album.color'], - compute: ({'#album.color': color}) => color, - }, - }, + Thing.composite.exposeUpdateValueOrContinue(), + Track.composite.withAlbumProperty('color'), + Thing.composite.expose('#album.color', {update: true}), ]), // Disables presenting the track as though it has its own unique artwork. @@ -169,15 +152,11 @@ export class Track extends Thing { commentatorArtists: Thing.common.commentatorArtists(), - album: { - flags: {expose: true}, - - expose: { - dependencies: ['this', 'albumData'], - compute: ({this: track, albumData}) => - albumData?.find((album) => album.tracks.includes(track)) ?? null, - }, - }, + album: + Thing.composite.from(`Track.album`, [ + Track.composite.withAlbum(), + Thing.composite.expose('#album'), + ]), // Note - this is an internal property used only to help identify a track. // It should not be assumed in general that the album and dataSourceAlbum match @@ -202,17 +181,8 @@ export class Track extends Thing { }, }, - Track.composite.withAlbumProperties({ - properties: ['date'], - }), - - { - flags: {expose: true}, - expose: { - dependencies: ['#album.date'], - compute: ({'#album.date': date}) => date, - }, - }, + Track.composite.withAlbumProperties({properties: ['date']}), + Thing.composite.expose('#album.date'), ]), // Whether or not the track has "unique" cover artwork - a cover which is @@ -369,20 +339,8 @@ export class Track extends Thing { }, }, - Track.composite.withAlbumProperties({ - properties: ['trackCoverArtistContribs'], - }), - - { - flags: {expose: true}, - expose: { - mapDependencies: {contribsFromAlbum: '#album.trackCoverArtistContribs'}, - compute: ({contribsFromAlbum}) => - (empty(contribsFromAlbum) - ? null - : contribsFromAlbum), - }, - }, + Track.composite.withAlbumProperty('trackCoverArtistContribs'), + Thing.composite.expose('#album.trackCoverArtistContribs'), ]), referencedTracks: Thing.composite.from(`Track.referencedTracks`, [ @@ -513,6 +471,35 @@ export class Track extends Thing { }, }), + // Gets a single property from this track's album, providing it as the same + // property name prefixed with '#album.' (by default). If the track's album + // isn't available, and earlyExitIfNotFound hasn't been set, the property + // will be provided as null. + withAlbumProperty: (property, { + to = '#album.' + property, + earlyExitIfNotFound = false, + } = {}) => + Thing.composite.from(`Track.composite.withAlbumProperty`, [ + Track.composite.withAlbum({earlyExitIfNotFound}), + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#album'], + options: {property}, + mapContinuation: {to}, + + compute: ({ + '#album': album, + '#options': {property}, + }, continuation) => + (album + ? continuation.raise({to: album[property]}) + : continuation.raise({to: null})), + }, + }, + ]), + // Gets the listed properties from this track's album, providing them as // dependencies (by default) with '#album.' prefixed before each property // name. If the track's album isn't available, and earlyExitIfNotFound diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 218353c8..08e91732 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -43,6 +43,59 @@ function stubArtistAndContribs() { return {artist, contribs, badContribs}; } +t.test(`Track.album`, t => { + t.plan(6); + + // Note: These asserts use manual albumData/trackData relationships + // to illustrate more specifically the properties which are expected to + // be relevant for this case. Other properties use the same underlying + // get-album behavior as Track.album so aren't tested as aggressively. + + const track1 = stubTrack('track1'); + const track2 = stubTrack('track2'); + const album1 = new Album(); + const album2 = new Album(); + + t.equal(track1.album, null, + `album #1: defaults to null`); + + track1.albumData = [album1, album2]; + track2.albumData = [album1, album2]; + album1.trackData = [track1, track2]; + album2.trackData = [track1, track2]; + album1.trackSections = [{tracksByRef: ['track:track1']}]; + album2.trackSections = [{tracksByRef: ['track:track2']}]; + + t.equal(track1.album, album1, + `album #2: is album when album's trackSections matches track`); + + track1.albumData = [album2, album1]; + + t.equal(track1.album, album1, + `album #3: is album when albumData is in different order`); + + track1.albumData = []; + + t.equal(track1.album, null, + `album #4: is null when track missing albumData`); + + album1.trackData = []; + track1.albumData = [album1, album2]; + + t.equal(track1.album, null, + `album #5: is null when album missing trackData`); + + album1.trackData = [track1, track2]; + album1.trackSections = [{tracksByRef: ['track:track2']}]; + + // XXX_decacheWikiData + track1.albumData = []; + track1.albumData = [album1, album2]; + + t.equal(track1.album, null, + `album #6: is null when album's trackSections don't match track`); +}); + t.test(`Track.color`, t => { t.plan(3); -- cgit 1.3.0-6-gf8a5 From 12b8040b05e81a523ef59ba583dde751206f2e1d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 26 Aug 2023 20:38:27 -0300 Subject: data, test: retain validator for Track.color --- src/data/things/thing.js | 20 ++++++++++++++------ src/data/things/track.js | 4 +++- test/unit/data/things/track.js | 5 ++++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 2af06904..2adba5c4 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1116,9 +1116,11 @@ export default class Thing extends CacheableObject { // Exposes a dependency exactly as it is; this is typically the base of a // composition which was created to serve as one property's descriptor. - // Since this serves as a base, specify {update: true} to indicate that - // the property as a whole updates (and some previous compositional step - // works with that update value). + // Since this serves as a base, specify a value for {update} to indicate + // that the property as a whole updates (and some previous compositional + // step works with that update value). Set {update: true} to only enable + // the update flag, or set update to an object to specify a descriptor + // (e.g. for custom value validation). // // Please note that this *doesn't* verify that the dependency exists, so // if you provide the wrong name or it hasn't been set by a previous @@ -1127,17 +1129,23 @@ export default class Thing extends CacheableObject { // expose: (dependency, {update = false} = {}) => ({ annotation: `Thing.composite.expose`, - flags: {expose: true, update}, + flags: {expose: true, update: !!update}, expose: { mapDependencies: {dependency}, compute: ({dependency}) => dependency, }, + + update: + (typeof update === 'object' + ? update + : null), }), // Exposes the update value of an {update: true} property, or continues if // it's unavailable. By default, "unavailable" means value === null, but - // set {mode: 'empty'} to + // set {mode: 'empty'} to check with empty() instead, continuing for empty + // arrays also. exposeUpdateValueOrContinue({mode = 'null'} = {}) { if (mode !== 'null' && mode !== 'empty') { throw new TypeError(`Expected mode to be null or empty`); @@ -1156,7 +1164,7 @@ export default class Thing extends CacheableObject { : value === null); if (shouldContinue) { - return continuation(); + return continuation(value); } else { return continuation.exit(value); } diff --git a/src/data/things/track.js b/src/data/things/track.js index 15a48bb4..8d0a7ad4 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -47,7 +47,9 @@ export class Track extends Thing { color: Thing.composite.from(`Track.color`, [ Thing.composite.exposeUpdateValueOrContinue(), Track.composite.withAlbumProperty('color'), - Thing.composite.expose('#album.color', {update: true}), + Thing.composite.expose('#album.color', { + update: {validate: isColor}, + }), ]), // Disables presenting the track as though it has its own unique artwork. diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 08e91732..dbc8434f 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -97,7 +97,7 @@ t.test(`Track.album`, t => { }); t.test(`Track.color`, t => { - t.plan(3); + t.plan(4); const {track, album} = stubTrackAndAlbum(); @@ -119,6 +119,9 @@ t.test(`Track.color`, t => { t.equal(track.color, '#123456', `color #3: is own value`); + + t.throws(() => { track.color = '#aeiouw'; }, TypeError, + `color #4: must be set to valid color`); }); t.test(`Track.coverArtDate`, t => { -- cgit 1.3.0-6-gf8a5 From e6038d8c07971447f444cf597328ca8d9863f8fd Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 26 Aug 2023 21:18:43 -0300 Subject: data, test: Track.color inherits from track section --- src/data/things/thing.js | 2 +- src/data/things/track.js | 69 ++++++++++++++++++++++++++++++++++++++---- test/unit/data/things/track.js | 34 ++++++++++++++++++--- 3 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 2adba5c4..f5dc786e 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1127,7 +1127,7 @@ export default class Thing extends CacheableObject { // compositional step, the property will be exposed as undefined instead // of null. // - expose: (dependency, {update = false} = {}) => ({ + exposeDependency: (dependency, {update = false} = {}) => ({ annotation: `Thing.composite.expose`, flags: {expose: true, update: !!update}, diff --git a/src/data/things/track.js b/src/data/things/track.js index 8d0a7ad4..621044d5 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -46,8 +46,25 @@ export class Track extends Thing { color: Thing.composite.from(`Track.color`, [ Thing.composite.exposeUpdateValueOrContinue(), + Track.composite.withContainingTrackSection({earlyExitIfNotFound: false}), + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#trackSection'], + compute: ({'#trackSection': trackSection}, continuation) => + // Album.trackSections guarantees the track section will have a + // color property (inheriting from the album's own color), but only + // if it's actually present! Color will be inherited directly from + // album otherwise. + (trackSection + ? trackSection.color + : continuation()), + }, + }, + Track.composite.withAlbumProperty('color'), - Thing.composite.expose('#album.color', { + Thing.composite.exposeDependency('#album.color', { update: {validate: isColor}, }), ]), @@ -157,7 +174,7 @@ export class Track extends Thing { album: Thing.composite.from(`Track.album`, [ Track.composite.withAlbum(), - Thing.composite.expose('#album'), + Thing.composite.exposeDependency('#album'), ]), // Note - this is an internal property used only to help identify a track. @@ -183,8 +200,8 @@ export class Track extends Thing { }, }, - Track.composite.withAlbumProperties({properties: ['date']}), - Thing.composite.expose('#album.date'), + Track.composite.withAlbumProperty('date'), + Thing.composite.exposeDependency('#album.date'), ]), // Whether or not the track has "unique" cover artwork - a cover which is @@ -342,7 +359,7 @@ export class Track extends Thing { }, Track.composite.withAlbumProperty('trackCoverArtistContribs'), - Thing.composite.expose('#album.trackCoverArtistContribs'), + Thing.composite.exposeDependency('#album.trackCoverArtistContribs'), ]), referencedTracks: Thing.composite.from(`Track.referencedTracks`, [ @@ -435,7 +452,7 @@ export class Track extends Thing { ]), // Gets the track's album. Unless earlyExitIfNotFound is overridden false, - // this will early-exit with null in two cases - albumData being missing, + // this will early exit with null in two cases - albumData being missing, // or not including an album whose .tracks array includes this track. withAlbum: ({to = '#album', earlyExitIfNotFound = true} = {}) => ({ annotation: `Track.composite.withAlbum`, @@ -542,6 +559,46 @@ export class Track extends Thing { }, ]), + // Gets the track section containing this track from its album's track list. + // Unless earlyExitIfNotFound is overridden false, this will early exit if + // the album can't be found or if none of its trackSections includes the + // track for some reason. + withContainingTrackSection: ({ + to = '#trackSection', + earlyExitIfNotFound = true, + } = {}) => + Thing.composite.from(`Track.composite.withContainingTrackSection`, [ + Track.composite.withAlbumProperty('trackSections', {earlyExitIfNotFound}), + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['this', '#album.trackSections'], + mapContinuation: {to}, + + compute({ + this: track, + '#album.trackSections': trackSections, + }, continuation) { + if (!trackSections) { + return continuation.raise({to: null}); + } + + const trackSection = + trackSections.find(({tracks}) => tracks.includes(track)); + + if (trackSection) { + return continuation.raise({to: trackSection}); + } else if (earlyExitIfNotFound) { + return continuation.exit(null); + } else { + return continuation.raise({to: null}); + } + }, + }, + }, + ]), + // Just includes the original release of this track as a dependency, or // null, if it's not a rerelease. Note that this will early exit if the // original release is specified by reference and that reference doesn't diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index dbc8434f..9132376c 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -97,11 +97,11 @@ t.test(`Track.album`, t => { }); t.test(`Track.color`, t => { - t.plan(4); + t.plan(5); const {track, album} = stubTrackAndAlbum(); - const {XXX_decacheWikiData} = linkAndBindWikiData({ + const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ albumData: [album], trackData: [track], }); @@ -110,18 +110,42 @@ t.test(`Track.color`, t => { `color #1: defaults to null`); album.color = '#abcdef'; + album.trackSections = [{ + color: '#beeeef', + tracksByRef: [Thing.getReference(track)], + }]; XXX_decacheWikiData(); + t.equal(track.color, '#beeeef', + `color #2: inherits from track section before album`); + + // Replace the album with a completely fake one. This isn't realistic, since + // in correct data, Album.tracks depends on Albums.trackSections and so the + // track's album will always have a corresponding track section. But if that + // connection breaks for some future reason (with the album still present), + // Track.color should still inherit directly from the album. + wikiData.albumData = [ + new Proxy({ + color: '#abcdef', + tracks: [track], + trackSections: [ + {color: '#baaaad', tracks: []}, + ], + }, {getPrototypeOf: () => Album.prototype}), + ]; + + linkWikiDataArrays(); + t.equal(track.color, '#abcdef', - `color #2: inherits from album`); + `color #3: inherits from album without matching track section`); track.color = '#123456'; t.equal(track.color, '#123456', - `color #3: is own value`); + `color #4: is own value`); t.throws(() => { track.color = '#aeiouw'; }, TypeError, - `color #4: must be set to valid color`); + `color #5: must be set to valid color`); }); t.test(`Track.coverArtDate`, t => { -- cgit 1.3.0-6-gf8a5 From 618f49e0ddcea245a4e0972efe5450419b27c639 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 26 Aug 2023 21:31:07 -0300 Subject: data: Thing.composite.exposeDependencyOrContinue --- src/data/things/thing.js | 48 +++++++++++++++++++++++++++++++++++++++++++----- src/data/things/track.js | 10 +--------- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index f5dc786e..f88e8726 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1077,6 +1077,8 @@ export default class Thing extends CacheableObject { return constructedDescriptor; }, + // -- Compositional steps for compositions to nest -- + // Provides dependencies exactly as they are (or null if not defined) to the // continuation. Although this can *technically* be used to alias existing // dependencies to some other name within the middle of a composition, it's @@ -1114,6 +1116,8 @@ export default class Thing extends CacheableObject { }; }, + // -- Compositional steps for top-level property descriptors -- + // Exposes a dependency exactly as it is; this is typically the base of a // composition which was created to serve as one property's descriptor. // Since this serves as a base, specify a value for {update} to indicate @@ -1128,7 +1132,7 @@ export default class Thing extends CacheableObject { // of null. // exposeDependency: (dependency, {update = false} = {}) => ({ - annotation: `Thing.composite.expose`, + annotation: `Thing.composite.exposeDependency`, flags: {expose: true, update: !!update}, expose: { @@ -1142,10 +1146,42 @@ export default class Thing extends CacheableObject { : null), }), - // Exposes the update value of an {update: true} property, or continues if - // it's unavailable. By default, "unavailable" means value === null, but - // set {mode: 'empty'} to check with empty() instead, continuing for empty - // arrays also. + // Exposes a dependency as it is, or continues if it's unavailable. + // By default, "unavailable" means dependency === null; provide + // {mode: 'empty'} to check with empty() instead, continuing for + // empty arrays also. + exposeDependencyOrContinue(dependency, {mode = 'null'} = {}) { + if (mode !== 'null' && mode !== 'empty') { + throw new TypeError(`Expected mode to be null or empty`); + } + + return { + annotation: `Thing.composite.exposeDependencyOrContinue`, + flags: {expose: true, compose: true}, + expose: { + options: {mode}, + mapDependencies: {dependency}, + + compute({dependency, '#options': {mode}}, continuation) { + const shouldContinue = + (mode === 'empty' + ? empty(dependency) + : dependency === null); + + if (shouldContinue) { + return continuation(); + } else { + return continuation.exit(dependency); + } + }, + }, + }; + }, + + // Exposes the update value of an {update: true} property as it is, + // or continues if it's unavailable. By default, "unavailable" means + // value === null; provide {mode: 'empty'} to check with empty() instead, + // continuing for empty arrays also. exposeUpdateValueOrContinue({mode = 'null'} = {}) { if (mode !== 'null' && mode !== 'empty') { throw new TypeError(`Expected mode to be null or empty`); @@ -1173,6 +1209,8 @@ export default class Thing extends CacheableObject { }; }, + // -- Compositional steps for processing data -- + // Resolves the contribsByRef contained in the provided dependency, // providing (named by the second argument) the result. "Resolving" // means mapping the "who" reference of each contribution to an artist diff --git a/src/data/things/track.js b/src/data/things/track.js index 621044d5..228b2af1 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -191,15 +191,7 @@ export class Track extends Thing { ), date: Thing.composite.from(`Track.date`, [ - { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['dateFirstReleased'], - compute: ({dateFirstReleased}, continuation) => - dateFirstReleased ?? continuation(), - }, - }, - + Thing.composite.exposeDependencyOrContinue('dateFirstReleased'), Track.composite.withAlbumProperty('date'), Thing.composite.exposeDependency('#album.date'), ]), -- cgit 1.3.0-6-gf8a5 From 083a4b8c3a0e545a2d8195255d57c5b7e0c49028 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sun, 27 Aug 2023 16:15:34 -0300 Subject: data: misc. additions, fixes & refactoring Thing.composite.from: * Transparently support expose.transform steps inside nested compositions, w/ various Thing.composite.from clean-up * Support continuation.raise() without provided dependencies * add Thing.composite.exposeConstant * add Thing.composite.withResultOfAvailabilityCheck * supports {mode: 'null' | 'empty' | 'falsy'} * works with dependency or update value * add Thing.composite.earlyExitWithoutDependency * refactor Thing.composite.exposeDependencyOrContinue * refactor Thing.composite.exposeUpdateValueOrContinue * add Track.withHasUniqueCoverArt * refactor Track.coverArtFileExtension * refactor Track.hasUniqueCoverArt --- src/data/things/thing.js | 433 +++++++++++++++++++++++++++++------------------ src/data/things/track.js | 137 +++++++-------- 2 files changed, 331 insertions(+), 239 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index f88e8726..892a3a4b 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -780,6 +780,16 @@ export default class Thing extends CacheableObject { break expose; } + if ( + step.expose.transform && + !step.expose.compute && + !base.flags.update && + !base.flags.compose + ) { + push(new TypeError(`Steps which only transform can't be composed with a non-updating base`)); + break expose; + } + if (step.expose.dependencies) { for (const dependency of step.expose.dependencies) { if (typeof dependency === 'string' && dependency.startsWith('#')) continue; @@ -794,26 +804,7 @@ export default class Thing extends CacheableObject { } } - let fn, type; - if (base.flags.update) { - if (step.expose.transform) { - type = 'transform'; - fn = step.expose.transform; - } else { - type = 'compute'; - fn = step.expose.compute; - } - } else { - if (step.expose.transform && !step.expose.compute) { - push(new TypeError(`Steps which only transform can't be composed with a non-updating base`)); - break expose; - } - - type = 'compute'; - fn = step.expose.compute; - } - - exposeSteps.push(step.expose); + exposeSteps.push(step); } }); } @@ -845,38 +836,38 @@ export default class Thing extends CacheableObject { function _filterDependencies(dependencies, step) { const filteredDependencies = - (step.dependencies - ? filterProperties(dependencies, step.dependencies) + (step.expose.dependencies + ? filterProperties(dependencies, step.expose.dependencies) : {}); - if (step.mapDependencies) { - for (const [to, from] of Object.entries(step.mapDependencies)) { + if (step.expose.mapDependencies) { + for (const [to, from] of Object.entries(step.expose.mapDependencies)) { filteredDependencies[to] = dependencies[from] ?? null; } } - if (step.options) { - filteredDependencies['#options'] = step.options; + if (step.expose.options) { + filteredDependencies['#options'] = step.expose.options; } return filteredDependencies; } function _assignDependencies(continuationAssignment, step) { - if (!step.mapContinuation) { + if (!step.expose.mapContinuation) { return continuationAssignment; } const assignDependencies = {}; - for (const [from, to] of Object.entries(step.mapContinuation)) { + for (const [from, to] of Object.entries(step.expose.mapContinuation)) { assignDependencies[to] = continuationAssignment[from] ?? null; } return assignDependencies; } - function _prepareContinuation(transform, step) { + function _prepareContinuation(transform) { const continuationStorage = { returnedWith: null, providedDependencies: null, @@ -930,27 +921,25 @@ export default class Thing extends CacheableObject { debug(() => color.bright(`begin composition (annotation: ${annotation})`)); - for (let i = 0; i < exposeSteps.length; i++) { + stepLoop: for (let i = 0; i < exposeSteps.length; i++) { const step = exposeSteps[i]; debug(() => [`step #${i+1}:`, step]); const transform = valueSoFar !== noTransformSymbol && - step.transform; + step.expose.transform; const filteredDependencies = _filterDependencies(dependencies, step); - const {continuation, continuationStorage} = _prepareContinuation(transform, step); + const {continuation, continuationStorage} = _prepareContinuation(transform); - if (transform) { - debug(() => `step #${i+1} - transform with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); - } else { - debug(() => `step #${i+1} - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); - } + debug(() => + `step #${i+1} - ${transform ? 'transform' : 'compute'} ` + + `with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); const result = (transform - ? step.transform(valueSoFar, filteredDependencies, continuation) - : step.compute(filteredDependencies, continuation)); + ? step.expose.transform(valueSoFar, filteredDependencies, continuation) + : step.expose.compute(filteredDependencies, continuation)); if (result !== continuationSymbol) { if (base.flags.compose) { @@ -964,39 +953,34 @@ export default class Thing extends CacheableObject { return result; } - if (continuationStorage.returnedWith === 'exit') { - debug(() => `step #${i+1} - result: early-exit (explicit)`); - debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`); - debug(() => color.bright(`end composition (annotation: ${annotation})`)); - - return continuationStorage.providedValue; - } - - if (continuationStorage.returnedWith === 'raise') { - if (transform) { - valueSoFar = continuationStorage.providedValue; - } - - exportDependencies = _assignDependencies(continuationStorage.providedDependencies, step); - - debug(() => `step #${i+1} - result: raise`); - - break; - } - - if (continuationStorage.returnedWith === 'continuation') { - if (transform) { - valueSoFar = continuationStorage.providedValue; - } - - debug(() => `step #${i+1} - result: continuation`); + switch (continuationStorage.returnedWith) { + case 'exit': + debug(() => `step #${i+1} - result: early-exit (explicit)`); + debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`); + debug(() => color.bright(`end composition (annotation: ${annotation})`)); + return continuationStorage.providedValue; + + case 'raise': + debug(() => `step #${i+1} - result: raise`); + exportDependencies = _assignDependencies(continuationStorage.providedDependencies, step) ?? {}; + if (transform) valueSoFar = continuationStorage.providedValue; + break stepLoop; + + case 'continuation': + if (transform) { + valueSoFar = continuationStorage.providedValue; + } - if (continuationStorage.providedDependencies) { - const assignDependencies = _assignDependencies(continuationStorage.providedDependencies, step); - Object.assign(dependencies, assignDependencies); + if (continuationStorage.providedDependencies) { + const assignDependencies = _assignDependencies(continuationStorage.providedDependencies, step); + Object.assign(dependencies, assignDependencies); + debug(() => `step #${i+1} - result: continuation`); + debug(() => [`assign dependencies:`, assignDependencies]); + } else { + debug(() => `step #${i+1} - result: continuation (no provided dependencies)`); + } - debug(() => [`assign dependencies:`, assignDependencies]); - } + break; } } @@ -1008,53 +992,50 @@ export default class Thing extends CacheableObject { debug(() => `completed all steps, reached base`); - const filteredDependencies = _filterDependencies(dependencies, base.expose); + const filteredDependencies = _filterDependencies(dependencies, base); - // Note: base.flags.compose is not compatible with base.flags.update. - if (base.expose.transform) { - debug(() => `base - transform with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); + const transform = + valueSoFar !== noTransformSymbol && + base.expose.transform; - const result = base.expose.transform(valueSoFar, filteredDependencies); + debug(() => + `base - ${transform ? 'transform' : 'compute'} ` + + `with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); - debug(() => `base - non-compose (final) result: ${inspect(result, {compact: true})}`); - - return result; - } else if (base.flags.compose) { - const {continuation, continuationStorage} = _prepareContinuation(false, base.expose); - - debug(() => `base - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); + if (base.flags.compose) { + const {continuation, continuationStorage} = _prepareContinuation(transform); - const result = base.expose.compute(filteredDependencies, continuation); + const result = + (transform + ? base.expose.transform(valueSoFar, filteredDependencies, continuation) + : base.expose.compute(filteredDependencies, continuation)); if (result !== continuationSymbol) { throw new TypeError(`Use continuation.exit() or continuation.raise() in {compose: true} composition`); } - if (continuationStorage.returnedWith === 'continuation') { - throw new TypeError(`Use continuation.raise() in base of {compose: true} composition`); - } - - if (continuationStorage.returnedWith === 'exit') { - debug(() => `base - result: early-exit (explicit)`); - debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`); - debug(() => color.bright(`end composition (annotation: ${annotation})`)); - - return continuationStorage.providedValue; - } - - if (continuationStorage.returnedWith === 'raise') { - exportDependencies = _assignDependencies(continuationStorage.providedDependencies, base.expose); - - debug(() => `base - result: raise`); - debug(() => `raise dependencies: ${inspect(exportDependencies, {compact: true})}`); - debug(() => color.bright(`end composition (annotation: ${annotation})`)); - - return continuationIfApplicable(exportDependencies); + switch (continuationStorage.returnedWith) { + case 'continuation': + throw new TypeError(`Use continuation.raise() in base of {compose: true} composition`); + + case 'exit': + debug(() => `base - result: early-exit (explicit)`); + debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`); + debug(() => color.bright(`end composition (annotation: ${annotation})`)); + return continuationStorage.providedValue; + + case 'raise': + exportDependencies = _assignDependencies(continuationStorage.providedDependencies, base); + debug(() => `base - result: raise`); + debug(() => `raise dependencies: ${inspect(exportDependencies, {compact: true})}`); + debug(() => color.bright(`end composition (annotation: ${annotation})`)); + return continuationIfApplicable(exportDependencies); } } else { - debug(() => `base - compute with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); - - const result = base.expose.compute(filteredDependencies); + const result = + (transform + ? base.expose.transform(valueSoFar, filteredDependencies) + : base.expose.compute(filteredDependencies)); debug(() => `base - non-compose (final) result: ${inspect(result, {compact: true})}`); debug(() => color.bright(`end composition (annotation: ${annotation})`)); @@ -1063,14 +1044,23 @@ export default class Thing extends CacheableObject { } } - if (base.flags.update) { - expose.transform = - (value, initialDependencies, continuationIfApplicable) => - _computeOrTransform(value, initialDependencies, continuationIfApplicable); + const transformFn = + (value, initialDependencies, continuationIfApplicable) => + _computeOrTransform(value, initialDependencies, continuationIfApplicable); + + const computeFn = + (initialDependencies, continuationIfApplicable) => + _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable); + + if (base.flags.compose) { + if (exposeSteps.some(step => step.expose.transform)) { + expose.transform = transformFn; + } + expose.compute = computeFn; + } else if (base.flags.update) { + expose.transform = transformFn; } else { - expose.compute = - (initialDependencies, continuationIfApplicable) => - _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable); + expose.compute = computeFn; } } @@ -1146,68 +1136,177 @@ export default class Thing extends CacheableObject { : null), }), - // Exposes a dependency as it is, or continues if it's unavailable. - // By default, "unavailable" means dependency === null; provide - // {mode: 'empty'} to check with empty() instead, continuing for - // empty arrays also. - exposeDependencyOrContinue(dependency, {mode = 'null'} = {}) { - if (mode !== 'null' && mode !== 'empty') { - throw new TypeError(`Expected mode to be null or empty`); + // Exposes a constant value exactly as it is; like exposeDependency, this + // is typically the base of a composition serving as a particular property + // descriptor. It generally follows steps which will conditionally early + // exit with some other value, with the exposeConstant base serving as the + // fallback default value. Like exposeDependency, set {update} to true or + // an object to indicate that the property as a whole updates. + exposeConstant: (value, {update = false} = {}) => ({ + annotation: `Thing.composite.exposeConstant`, + flags: {expose: true, update: !!update}, + + expose: { + options: {value}, + compute: ({'#options': {value}}) => value, + }, + + update: + (typeof update === 'object' + ? update + : null), + }), + + // Checks the availability of a dependency or the update value and provides + // the result to later steps under '#availability' (by default). This is + // mainly intended for use by the more specific utilities, which you should + // consider using instead. Customize {mode} to select one of these modes, + // or leave unset and default to 'null': + // + // * 'null': Check that the value isn't null. + // * 'empty': Check that the value is neither null nor an empty array. + // * 'falsy': Check that the value isn't false when treated as a boolean + // (nor an empty array). Keep in mind this will also be false + // for values like zero and the empty string! + // + withResultOfAvailabilityCheck({ + fromUpdateValue, + fromDependency, + mode = 'null', + to = '#availability', + }) { + if (!['null', 'empty', 'falsy'].includes(mode)) { + throw new TypeError(`Expected mode to be null, empty, or falsy`); } - return { - annotation: `Thing.composite.exposeDependencyOrContinue`, - flags: {expose: true, compose: true}, - expose: { - options: {mode}, - mapDependencies: {dependency}, - - compute({dependency, '#options': {mode}}, continuation) { - const shouldContinue = - (mode === 'empty' - ? empty(dependency) - : dependency === null); - - if (shouldContinue) { - return continuation(); - } else { - return continuation.exit(dependency); - } - }, - }, + if (fromUpdateValue && fromDependency) { + throw new TypeError(`Don't provide both fromUpdateValue and fromDependency`); + } + + if (!fromUpdateValue && !fromDependency) { + throw new TypeError(`Missing dependency name (or fromUpdateValue)`); + } + + const checkAvailability = (value, mode) => { + switch (mode) { + case 'null': return value !== null; + case 'empty': return !empty(value); + case 'falsy': return !empty(value) && !!value; + default: return false; + } }; + + if (fromDependency) { + return { + annotation: `Thing.composite.withResultOfCommonComparison.fromDependency`, + flags: {expose: true, compose: true}, + expose: { + mapDependencies: {from: fromDependency}, + mapContinuation: {to}, + options: {mode}, + compute: ({from, '#options': {mode}}, continuation) => + continuation({to: checkAvailability(from, mode)}), + }, + }; + } else { + return { + annotation: `Thing.composite.withResultOfCommonComparison.fromUpdateValue`, + flags: {expose: true, compose: true}, + expose: { + mapContinuation: {to}, + options: {mode}, + transform: (value, {'#options': {mode}}, continuation) => + continuation(value, {to: checkAvailability(value, mode)}), + }, + }; + } }, + // Exposes a dependency as it is, or continues if it's unavailable. + // See withResultOfAvailabilityCheck for {mode} options! + exposeDependencyOrContinue: (dependency, {mode = 'null'} = {}) => + Thing.composite.from(`Thing.composite.exposeDependencyOrContinue`, [ + Thing.composite.withResultOfAvailabilityCheck({ + fromDependency: dependency, + mode, + }), + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation() + : continuation.raise()), + }, + }, + + { + flags: {expose: true, compose: true}, + expose: { + mapDependencies: {dependency}, + compute: ({dependency}, continuation) => + continuation.exit(dependency), + }, + }, + ]), + // Exposes the update value of an {update: true} property as it is, - // or continues if it's unavailable. By default, "unavailable" means - // value === null; provide {mode: 'empty'} to check with empty() instead, - // continuing for empty arrays also. - exposeUpdateValueOrContinue({mode = 'null'} = {}) { - if (mode !== 'null' && mode !== 'empty') { - throw new TypeError(`Expected mode to be null or empty`); - } + // or continues if it's unavailable. See withResultOfAvailabilityCheck + // for {mode} options! + exposeUpdateValueOrContinue: ({mode = 'null'} = {}) => + Thing.composite.from(`Thing.composite.exposeUpdateValueOrContinue`, [ + Thing.composite.withResultOfAvailabilityCheck({ + fromUpdateValue: true, + mode, + }), - return { - annotation: `Thing.composite.exposeUpdateValueOrContinue`, - flags: {expose: true, compose: true}, - expose: { - options: {mode}, - - transform(value, {'#options': {mode}}, continuation) { - const shouldContinue = - (mode === 'empty' - ? empty(value) - : value === null); - - if (shouldContinue) { - return continuation(value); - } else { - return continuation.exit(value); - } - } + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation() + : continuation.raise()), + }, }, - }; - }, + + { + flags: {expose: true, compose: true}, + expose: { + transform: (value, {}, continuation) => + continuation.exit(value), + }, + }, + ]), + + // Early exits if a dependency isn't available. + // See withResultOfAvailabilityCheck for {mode} options! + earlyExitWithoutDependency: (dependency, {mode = 'null', value = null} = {}) => + Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [ + Thing.composite.withResultOfAvailabilityCheck({ + fromDependency: dependency, + mode, + }), + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#availability'], + options: {value}, + + compute: ({ + '#availability': availability, + '#options': {value}, + }, continuation) => + (availability + ? continuation() + : continuation.exit(value)), + }, + }, + ]), // -- Compositional steps for processing data -- diff --git a/src/data/things/track.js b/src/data/things/track.js index 228b2af1..dc1f5f2a 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -76,40 +76,24 @@ export class Track extends Thing { disableUniqueCoverArt: Thing.common.flag(), // File extension for track's corresponding media file. This represents the - // track's unique cover artwork, if any, and does not inherit the cover's - // main artwork. (It does inherit `trackCoverArtFileExtension` if present - // on the album.) + // track's unique cover artwork, if any, and does not inherit the extension + // of the album's main artwork. It does inherit trackCoverArtFileExtension, + // if present on the album. coverArtFileExtension: Thing.composite.from(`Track.coverArtFileExtension`, [ - Track.composite.withAlbumProperties({ - properties: [ - 'trackCoverArtistContribsByRef', - 'trackCoverArtFileExtension', - ], - }), + // No cover art file extension if the track doesn't have unique artwork + // in the first place. + Track.composite.withHasUniqueCoverArt(), + Thing.composite.earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), - { - flags: {update: true, expose: true}, - update: {validate: isFileExtension}, - expose: { - dependencies: [ - 'coverArtistContribsByRef', - 'disableUniqueCoverArt', - '#album.trackCoverArtistContribsByRef', - '#album.trackCoverArtFileExtension', - ], + // Expose custom coverArtFileExtension update value first. + Thing.composite.exposeUpdateValueOrContinue(), - transform(coverArtFileExtension, { - coverArtistContribsByRef, - disableUniqueCoverArt, - '#album.trackCoverArtistContribsByRef': trackCoverArtistContribsByRef, - '#album.trackCoverArtFileExtension': trackCoverArtFileExtension, - }) { - if (disableUniqueCoverArt) return null; - if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; - return coverArtFileExtension ?? trackCoverArtFileExtension ?? 'jpg'; - }, - }, - }, + // Expose album's trackCoverArtFileExtension if no update value set. + Track.composite.withAlbumProperty('trackCoverArtFileExtension'), + Thing.composite.exposeDependencyOrContinue('#album.trackCoverArtFileExtension'), + + // Fallback to 'jpg'. + Thing.composite.exposeConstant('jpg'), ]), // Date of cover art release. Like coverArtFileExtension, this represents @@ -204,47 +188,8 @@ export class Track extends Thing { // the usual hasCoverArt to emphasize that it does not inherit from the // album.) hasUniqueCoverArt: Thing.composite.from(`Track.hasUniqueCoverArt`, [ - { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['disableUniqueCoverArt'], - compute: ({disableUniqueCoverArt}, continuation) => - (disableUniqueCoverArt - ? false - : continuation()), - }, - }, - - Thing.composite.withResolvedContribs({ - from: 'coverArtistContribsByRef', - to: '#coverArtistContribs', - }), - - { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#coverArtistContribs'], - compute: ({'#coverArtistContribs': coverArtistContribs}, continuation) => - (empty(coverArtistContribs) - ? continuation() - : true), - }, - }, - - Track.composite.withAlbumProperties({ - properties: ['trackCoverArtistContribs'], - }), - - { - flags: {expose: true}, - expose: { - dependencies: ['#album.trackCoverArtistContribs'], - compute: ({'#album.trackCoverArtistContribs': trackCoverArtistContribs}) => - (empty(trackCoverArtistContribs) - ? false - : true), - }, - }, + Track.composite.withHasUniqueCoverArt(), + Thing.composite.exposeDependency('#hasUniqueCoverArt'), ]), originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( @@ -609,6 +554,54 @@ export class Track extends Thing { [outputDependency]: '#originalRelease', }), ]), + + // The algorithm for checking if a track has unique cover art is used in a + // couple places, so it's defined in full as a compositional step. + withHasUniqueCoverArt: ({to = '#hasUniqueCoverArt'} = {}) => + Thing.composite.from(`Track.composite.withHasUniqueCoverArt`, [ + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['disableUniqueCoverArt'], + mapContinuation: {to}, + compute: ({disableUniqueCoverArt}, continuation) => + (disableUniqueCoverArt + ? continuation.raise({to: false}) + : continuation()), + }, + }, + + Thing.composite.withResolvedContribs({ + from: 'coverArtistContribsByRef', + to: '#coverArtistContribs', + }), + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#coverArtistContribs'], + mapContinuation: {to}, + compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => + (empty(contribsFromTrack) + ? continuation() + : continuation.raise({to: true})), + }, + }, + + Track.composite.withAlbumProperty('trackCoverArtistContribs'), + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#album.trackCoverArtistContribs'], + mapContinuation: {to}, + compute: ({'#album.trackCoverArtistContribs': contribsFromAlbum}, continuation) => + (empty(contribsFromAlbum) + ? continuation.raise({to: false}) + : continuation.raise({to: true})), + }, + }, + ]), }; [inspect.custom]() { -- cgit 1.3.0-6-gf8a5 From 29580733b79872333f3f9e45d50d987218d334ea Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Mon, 28 Aug 2023 13:19:57 -0300 Subject: data: fix annotation typo --- src/data/things/thing.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 892a3a4b..78ff4c81 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1198,7 +1198,7 @@ export default class Thing extends CacheableObject { if (fromDependency) { return { - annotation: `Thing.composite.withResultOfCommonComparison.fromDependency`, + annotation: `Thing.composite.withResultOfAvailabilityCheck.fromDependency`, flags: {expose: true, compose: true}, expose: { mapDependencies: {from: fromDependency}, @@ -1210,7 +1210,7 @@ export default class Thing extends CacheableObject { }; } else { return { - annotation: `Thing.composite.withResultOfCommonComparison.fromUpdateValue`, + annotation: `Thing.composite.withResultOfAvailabilityCheck.fromUpdateValue`, flags: {expose: true, compose: true}, expose: { mapContinuation: {to}, -- cgit 1.3.0-6-gf8a5 From 895712f5a0381c41557c6d306d6697019368bb7b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 30 Aug 2023 15:33:46 -0300 Subject: data: clean up Thing.composite.from debug messaging * print annotation next to every log message, instead of just the begin/end messages * add Thing.composite.debug() to conveniently wrap one property access * don't output (and don't access) track album in inspect.custom when depth < 0 --- src/data/things/thing.js | 69 ++++++++++++++++++++++++++++++++---------------- src/data/things/track.js | 40 ++++++++++------------------ 2 files changed, 60 insertions(+), 49 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 78ff4c81..4fd6a26a 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -719,11 +719,18 @@ export default class Thing extends CacheableObject { from(firstArg, secondArg) { const debug = fn => { if (Thing.composite.from.debug === true) { + const label = + (annotation + ? color.dim(`[composite: ${annotation}]`) + : color.dim(`[composite]`)); const result = fn(); if (Array.isArray(result)) { - console.log(`[composite]`, ...result); + console.log(label, ...result.map(value => + (typeof value === 'object' + ? inspect(value, {depth: 0, colors: true, compact: true, breakLength: Infinity}) + : value))); } else { - console.log(`[composite]`, result); + console.log(label, result); } } }; @@ -919,7 +926,7 @@ export default class Thing extends CacheableObject { let valueSoFar = value; // Set only for {update: true} compositions let exportDependencies = null; // Set only for {compose: true} compositions - debug(() => color.bright(`begin composition (annotation: ${annotation})`)); + debug(() => color.bright(`begin composition`)); stepLoop: for (let i = 0; i < exposeSteps.length; i++) { const step = exposeSteps[i]; @@ -932,9 +939,9 @@ export default class Thing extends CacheableObject { const filteredDependencies = _filterDependencies(dependencies, step); const {continuation, continuationStorage} = _prepareContinuation(transform); - debug(() => - `step #${i+1} - ${transform ? 'transform' : 'compute'} ` + - `with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); + debug(() => [ + `step #${i+1} - ${transform ? 'transform' : 'compute'}`, + `with dependencies:`, filteredDependencies]); const result = (transform @@ -946,18 +953,15 @@ export default class Thing extends CacheableObject { throw new TypeError(`Use continuation.exit() or continuation.raise() in {compose: true} compositions`); } - debug(() => `step #${i+1} - early-exit (inferred)`); - debug(() => `early-exit: ${inspect(result, {compact: true})}`); - debug(() => color.bright(`end composition (annotation: ${annotation})`)); - + debug(() => [`step #${i+1} - early-exit (inferred) ->`, result]); + debug(() => color.bright(`end composition`)); return result; } switch (continuationStorage.returnedWith) { case 'exit': - debug(() => `step #${i+1} - result: early-exit (explicit)`); - debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`); - debug(() => color.bright(`end composition (annotation: ${annotation})`)); + debug(() => [`step #${i+1} - result: early-exit (explicit) ->`, continuationStorage.providedValue]); + debug(() => color.bright(`end composition`)); return continuationStorage.providedValue; case 'raise': @@ -986,7 +990,7 @@ export default class Thing extends CacheableObject { if (exportDependencies) { debug(() => [`raise dependencies:`, exportDependencies]); - debug(() => color.bright(`end composition (annotation: ${annotation})`)); + debug(() => color.bright(`end composition`)); return continuationIfApplicable(exportDependencies); } @@ -998,9 +1002,9 @@ export default class Thing extends CacheableObject { valueSoFar !== noTransformSymbol && base.expose.transform; - debug(() => - `base - ${transform ? 'transform' : 'compute'} ` + - `with dependencies: ${inspect(filteredDependencies, {depth: 0})}`); + debug(() => [ + `base - ${transform ? 'transform' : 'compute'}`, + `with dependencies:`, filteredDependencies]); if (base.flags.compose) { const {continuation, continuationStorage} = _prepareContinuation(transform); @@ -1020,15 +1024,15 @@ export default class Thing extends CacheableObject { case 'exit': debug(() => `base - result: early-exit (explicit)`); - debug(() => `early-exit: ${inspect(continuationStorage.providedValue, {compact: true})}`); - debug(() => color.bright(`end composition (annotation: ${annotation})`)); + debug(() => [`early-exit:`, continuationStorage.providedValue]); + debug(() => color.bright(`end composition`)); return continuationStorage.providedValue; case 'raise': exportDependencies = _assignDependencies(continuationStorage.providedDependencies, base); debug(() => `base - result: raise`); - debug(() => `raise dependencies: ${inspect(exportDependencies, {compact: true})}`); - debug(() => color.bright(`end composition (annotation: ${annotation})`)); + debug(() => [`raise dependencies:`, exportDependencies]); + debug(() => color.bright(`end composition`)); return continuationIfApplicable(exportDependencies); } } else { @@ -1037,8 +1041,8 @@ export default class Thing extends CacheableObject { ? base.expose.transform(valueSoFar, filteredDependencies) : base.expose.compute(filteredDependencies)); - debug(() => `base - non-compose (final) result: ${inspect(result, {compact: true})}`); - debug(() => color.bright(`end composition (annotation: ${annotation})`)); + debug(() => [`base - non-compose (final) result:`, result]); + debug(() => color.bright(`end composition`)); return result; } @@ -1067,6 +1071,25 @@ export default class Thing extends CacheableObject { return constructedDescriptor; }, + // Evaluates a function with composite debugging enabled, turns debugging + // off again, and returns the result of the function. This is mostly syntax + // sugar, but also helps avoid unit tests avoid accidentally printing debug + // info for a bunch of unrelated composites (due to property enumeration + // when displaying an unexpected result). Use as so: + // + // Without debugging: + // t.same(thing.someProp, value) + // + // With debugging: + // t.same(Thing.composite.debug(() => thing.someProp), value) + // + debug(fn) { + Thing.composite.from.debug = true; + const value = fn(); + Thing.composite.from.debug = false; + return value; + }, + // -- Compositional steps for compositions to nest -- // Provides dependencies exactly as they are (or null if not defined) to the diff --git a/src/data/things/track.js b/src/data/things/track.js index dc1f5f2a..8ddf3624 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -604,38 +604,26 @@ export class Track extends Thing { ]), }; - [inspect.custom]() { - const base = Thing.prototype[inspect.custom].apply(this); + [inspect.custom](depth) { + const parts = []; - const rereleasePart = - (this.originalReleaseTrackByRef - ? `${color.yellow('[rerelease]')} ` - : ``); + parts.push(Thing.prototype[inspect.custom].apply(this)); - const {album, dataSourceAlbum} = this; + if (this.originalReleaseTrackByRef) { + parts.unshift(`${color.yellow('[rerelease]')} `); + } - const albumName = - (album - ? album.name - : dataSourceAlbum?.name); - - const albumIndex = - albumName && - (album - ? album.tracks.indexOf(this) - : dataSourceAlbum.tracks.indexOf(this)); - - const trackNum = - albumName && + let album; + if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) { + const albumName = album.name; + const albumIndex = album.tracks.indexOf(this); + const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`); + parts.push(` (${color.yellow(trackNum)} in ${color.green(albumName)})`); + } - const albumPart = - albumName - ? ` (${color.yellow(trackNum)} in ${color.green(albumName)})` - : ``; - - return rereleasePart + base + albumPart; + return parts.join(''); } } -- cgit 1.3.0-6-gf8a5 From 3336d5f15e29350656273a37c0a1c7a69d24663b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 30 Aug 2023 15:56:04 -0300 Subject: data: Thing.composite.from: fix including '#' deps from base ...in the final composition's dependencies. --- src/data/things/thing.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 4fd6a26a..25d8c8a3 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -756,7 +756,14 @@ export default class Thing extends CacheableObject { } const exposeSteps = []; - const exposeDependencies = new Set(base.expose?.dependencies); + const exposeDependencies = new Set(); + + if (base.expose?.dependencies) { + for (const dependency of base.expose.dependencies) { + if (typeof dependency === 'string' && dependency.startsWith('#')) continue; + exposeDependencies.add(dependency); + } + } if (base.expose?.mapDependencies) { for (const dependency of Object.values(base.expose.mapDependencies)) { -- cgit 1.3.0-6-gf8a5 From 7d6d8a2839ece38c4a70bd9e3fda73b2e0aa39b8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 30 Aug 2023 15:57:22 -0300 Subject: data: Thing.composite.earlyExitWithoutDependency: latest syntax --- src/data/things/thing.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 25d8c8a3..6bdc897f 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1321,19 +1321,25 @@ export default class Thing extends CacheableObject { mode, }), + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), + }, + }, + { flags: {expose: true, compose: true}, expose: { dependencies: ['#availability'], options: {value}, - compute: ({ - '#availability': availability, - '#options': {value}, - }, continuation) => - (availability - ? continuation() - : continuation.exit(value)), + compute: ({'#options': {value}}, continuation) => + continuation.exit(value), }, }, ]), -- cgit 1.3.0-6-gf8a5 From 011c197aeedab56d501b03b800433dd0cd9bc4f7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 30 Aug 2023 16:28:47 -0300 Subject: data: always define composite utilities with `key() {}` syntax Sublime Text doesn't index the key in `key: () => {}` as a symbol for function definitions if the parameter list takes up more than one line, but always works for `key() {}`. This also just makes it a little easier to add "preamble" before the main return value, when relevant. Consistent syntax is usually a plus for recurring behavioral forms! --- src/data/things/thing.js | 121 +++++++++++++++++++++++++++------------------- src/data/things/track.js | 123 ++++++++++++++++++++++++++--------------------- 2 files changed, 140 insertions(+), 104 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 6bdc897f..cd62288e 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1151,20 +1151,24 @@ export default class Thing extends CacheableObject { // compositional step, the property will be exposed as undefined instead // of null. // - exposeDependency: (dependency, {update = false} = {}) => ({ - annotation: `Thing.composite.exposeDependency`, - flags: {expose: true, update: !!update}, + exposeDependency(dependency, { + update = false, + } = {}) { + return { + annotation: `Thing.composite.exposeDependency`, + flags: {expose: true, update: !!update}, - expose: { - mapDependencies: {dependency}, - compute: ({dependency}) => dependency, - }, + expose: { + mapDependencies: {dependency}, + compute: ({dependency}) => dependency, + }, - update: - (typeof update === 'object' - ? update - : null), - }), + update: + (typeof update === 'object' + ? update + : null), + }; + }, // Exposes a constant value exactly as it is; like exposeDependency, this // is typically the base of a composition serving as a particular property @@ -1172,20 +1176,24 @@ export default class Thing extends CacheableObject { // exit with some other value, with the exposeConstant base serving as the // fallback default value. Like exposeDependency, set {update} to true or // an object to indicate that the property as a whole updates. - exposeConstant: (value, {update = false} = {}) => ({ - annotation: `Thing.composite.exposeConstant`, - flags: {expose: true, update: !!update}, + exposeConstant(value, { + update = false, + } = {}) { + return { + annotation: `Thing.composite.exposeConstant`, + flags: {expose: true, update: !!update}, - expose: { - options: {value}, - compute: ({'#options': {value}}) => value, - }, + expose: { + options: {value}, + compute: ({'#options': {value}}) => value, + }, - update: - (typeof update === 'object' - ? update - : null), - }), + update: + (typeof update === 'object' + ? update + : null), + }; + }, // Checks the availability of a dependency or the update value and provides // the result to later steps under '#availability' (by default). This is @@ -1254,8 +1262,10 @@ export default class Thing extends CacheableObject { // Exposes a dependency as it is, or continues if it's unavailable. // See withResultOfAvailabilityCheck for {mode} options! - exposeDependencyOrContinue: (dependency, {mode = 'null'} = {}) => - Thing.composite.from(`Thing.composite.exposeDependencyOrContinue`, [ + exposeDependencyOrContinue(dependency, { + mode = 'null', + } = {}) { + return Thing.composite.from(`Thing.composite.exposeDependencyOrContinue`, [ Thing.composite.withResultOfAvailabilityCheck({ fromDependency: dependency, mode, @@ -1280,13 +1290,16 @@ export default class Thing extends CacheableObject { continuation.exit(dependency), }, }, - ]), + ]); + }, // Exposes the update value of an {update: true} property as it is, // or continues if it's unavailable. See withResultOfAvailabilityCheck // for {mode} options! - exposeUpdateValueOrContinue: ({mode = 'null'} = {}) => - Thing.composite.from(`Thing.composite.exposeUpdateValueOrContinue`, [ + exposeUpdateValueOrContinue({ + mode = 'null', + } = {}) { + return Thing.composite.from(`Thing.composite.exposeUpdateValueOrContinue`, [ Thing.composite.withResultOfAvailabilityCheck({ fromUpdateValue: true, mode, @@ -1310,12 +1323,16 @@ export default class Thing extends CacheableObject { continuation.exit(value), }, }, - ]), + ]); + }, // Early exits if a dependency isn't available. // See withResultOfAvailabilityCheck for {mode} options! - earlyExitWithoutDependency: (dependency, {mode = 'null', value = null} = {}) => - Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [ + earlyExitWithoutDependency(dependency, { + mode = 'null', + value = null, + } = {}) { + return Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [ Thing.composite.withResultOfAvailabilityCheck({ fromDependency: dependency, mode, @@ -1342,7 +1359,8 @@ export default class Thing extends CacheableObject { continuation.exit(value), }, }, - ]), + ]); + }, // -- Compositional steps for processing data -- @@ -1350,20 +1368,22 @@ export default class Thing extends CacheableObject { // providing (named by the second argument) the result. "Resolving" // means mapping the "who" reference of each contribution to an artist // object, and filtering out those whose "who" doesn't match any artist. - withResolvedContribs: ({from, to}) => ({ - annotation: `Thing.composite.withResolvedContribs`, - flags: {expose: true, compose: true}, + withResolvedContribs({from, to}) { + return { + annotation: `Thing.composite.withResolvedContribs`, + flags: {expose: true, compose: true}, - expose: { - dependencies: ['artistData'], - mapDependencies: {from}, - mapContinuation: {to}, - compute: ({artistData, from}, continuation) => - continuation({ - to: Thing.findArtistsFromContribs(from, artistData), - }), - }, - }), + expose: { + dependencies: ['artistData'], + mapDependencies: {from}, + mapContinuation: {to}, + compute: ({artistData, from}, continuation) => + continuation({ + to: Thing.findArtistsFromContribs(from, artistData), + }), + }, + }; + }, // Resolves a reference by using the provided find function to match it // within the provided thingData dependency. This will early exit if the @@ -1372,14 +1392,14 @@ export default class Thing extends CacheableObject { // Otherwise, the data object is provided on the output dependency; // or null, if the reference doesn't match anything or itself was null // to begin with. - withResolvedReference: ({ + withResolvedReference({ ref, data, to, find: findFunction, earlyExitIfNotFound = false, - }) => - Thing.composite.from(`Thing.composite.withResolvedReference`, [ + }) { + return Thing.composite.from(`Thing.composite.withResolvedReference`, [ { flags: {expose: true, compose: true}, expose: { @@ -1423,6 +1443,7 @@ export default class Thing extends CacheableObject { }, }, }, - ]), + ]); + }, }; } diff --git a/src/data/things/track.js b/src/data/things/track.js index 8ddf3624..cdc9cec3 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -366,8 +366,11 @@ export class Track extends Thing { // dependencies provided. If allowOverride is true, then the continuation // will also be called if the original release exposed the requested // property as null. - inheritFromOriginalRelease: ({property: originalProperty, allowOverride = false}) => - Thing.composite.from(`Track.composite.inheritFromOriginalRelease`, [ + inheritFromOriginalRelease({ + property: originalProperty, + allowOverride = false, + }) { + return Thing.composite.from(`Track.composite.inheritFromOriginalRelease`, [ Track.composite.withOriginalRelease(), { @@ -386,56 +389,59 @@ export class Track extends Thing { }, }, } - ]), + ]); + }, // Gets the track's album. Unless earlyExitIfNotFound is overridden false, // this will early exit with null in two cases - albumData being missing, // or not including an album whose .tracks array includes this track. - withAlbum: ({to = '#album', earlyExitIfNotFound = true} = {}) => ({ - annotation: `Track.composite.withAlbum`, - flags: {expose: true, compose: true}, - - expose: { - dependencies: ['this', 'albumData'], - mapContinuation: {to}, - options: {earlyExitIfNotFound}, - - compute({ - this: track, - albumData, - '#options': {earlyExitIfNotFound}, - }, continuation) { - if (empty(albumData)) { - return ( - (earlyExitIfNotFound - ? continuation.exit(null) - : continuation({to: null}))); - } - - const album = - albumData?.find(album => album.tracks.includes(track)); - - if (!album) { - return ( - (earlyExitIfNotFound - ? continuation.exit(null) - : continuation({to: null}))); - } + withAlbum({to = '#album', earlyExitIfNotFound = true} = {}) { + return { + annotation: `Track.composite.withAlbum`, + flags: {expose: true, compose: true}, - return continuation({to: album}); + expose: { + dependencies: ['this', 'albumData'], + mapContinuation: {to}, + options: {earlyExitIfNotFound}, + + compute({ + this: track, + albumData, + '#options': {earlyExitIfNotFound}, + }, continuation) { + if (empty(albumData)) { + return ( + (earlyExitIfNotFound + ? continuation.exit(null) + : continuation({to: null}))); + } + + const album = + albumData?.find(album => album.tracks.includes(track)); + + if (!album) { + return ( + (earlyExitIfNotFound + ? continuation.exit(null) + : continuation({to: null}))); + } + + return continuation({to: album}); + }, }, - }, - }), + }; + }, // Gets a single property from this track's album, providing it as the same // property name prefixed with '#album.' (by default). If the track's album // isn't available, and earlyExitIfNotFound hasn't been set, the property // will be provided as null. - withAlbumProperty: (property, { + withAlbumProperty(property, { to = '#album.' + property, earlyExitIfNotFound = false, - } = {}) => - Thing.composite.from(`Track.composite.withAlbumProperty`, [ + } = {}) { + return Thing.composite.from(`Track.composite.withAlbumProperty`, [ Track.composite.withAlbum({earlyExitIfNotFound}), { @@ -454,18 +460,19 @@ export class Track extends Thing { : continuation.raise({to: null})), }, }, - ]), + ]); + }, // Gets the listed properties from this track's album, providing them as // dependencies (by default) with '#album.' prefixed before each property // name. If the track's album isn't available, and earlyExitIfNotFound // hasn't been set, the same dependency names will be provided as null. - withAlbumProperties: ({ + withAlbumProperties({ properties, prefix = '#album', earlyExitIfNotFound = false, - }) => - Thing.composite.from(`Track.composite.withAlbumProperties`, [ + }) { + return Thing.composite.from(`Track.composite.withAlbumProperties`, [ Track.composite.withAlbum({earlyExitIfNotFound}), { @@ -494,17 +501,18 @@ export class Track extends Thing { }, }, }, - ]), + ]); + }, // Gets the track section containing this track from its album's track list. // Unless earlyExitIfNotFound is overridden false, this will early exit if // the album can't be found or if none of its trackSections includes the // track for some reason. - withContainingTrackSection: ({ + withContainingTrackSection({ to = '#trackSection', earlyExitIfNotFound = true, - } = {}) => - Thing.composite.from(`Track.composite.withContainingTrackSection`, [ + } = {}) { + return Thing.composite.from(`Track.composite.withContainingTrackSection`, [ Track.composite.withAlbumProperty('trackSections', {earlyExitIfNotFound}), { @@ -534,14 +542,17 @@ export class Track extends Thing { }, }, }, - ]), + ]); + }, // Just includes the original release of this track as a dependency, or // null, if it's not a rerelease. Note that this will early exit if the // original release is specified by reference and that reference doesn't // resolve to anything. Outputs to '#originalRelease' by default. - withOriginalRelease: ({to: outputDependency = '#originalRelease'} = {}) => - Thing.composite.from(`Track.composite.withOriginalRelease`, [ + withOriginalRelease({ + to = '#originalRelease', + } = {}) { + return Thing.composite.from(`Track.composite.withOriginalRelease`, [ Thing.composite.withResolvedReference({ ref: 'originalReleaseTrackByRef', data: 'trackData', @@ -553,12 +564,15 @@ export class Track extends Thing { Thing.composite.export({ [outputDependency]: '#originalRelease', }), - ]), + ]); + }, // The algorithm for checking if a track has unique cover art is used in a // couple places, so it's defined in full as a compositional step. - withHasUniqueCoverArt: ({to = '#hasUniqueCoverArt'} = {}) => - Thing.composite.from(`Track.composite.withHasUniqueCoverArt`, [ + withHasUniqueCoverArt({ + to = '#hasUniqueCoverArt', + } = {}) { + return Thing.composite.from(`Track.composite.withHasUniqueCoverArt`, [ { flags: {expose: true, compose: true}, expose: { @@ -601,7 +615,8 @@ export class Track extends Thing { : continuation.raise({to: true})), }, }, - ]), + ]); + }, }; [inspect.custom](depth) { -- cgit 1.3.0-6-gf8a5 From b38a3c787dc4fbec5e2dc0c297bbcd3ceae83349 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 30 Aug 2023 16:31:54 -0300 Subject: data: update Track.otherReleases implementation Also adds {selfIfOriginal} option to withOriginalRelease(). --- src/data/things/track.js | 81 +++++++++++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index cdc9cec3..3b2029e8 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -198,33 +198,30 @@ export class Track extends Thing { find.track ), - otherReleases: { - flags: {expose: true}, + otherReleases: + Thing.composite.from(`Track.otherReleases`, [ + Thing.composite.earlyExitWithoutDependency('trackData'), + Track.composite.withOriginalRelease({selfIfOriginal: true}), - expose: { - dependencies: ['this', 'originalReleaseTrackByRef', 'trackData'], - - compute: ({ - this: t1, - originalReleaseTrackByRef: t1origRef, - trackData, - }) => { - if (!trackData) { - return []; - } - - const t1orig = find.track(t1origRef, trackData); - - return [ - t1orig, - ...trackData.filter((t2) => { - const {originalReleaseTrack: t2orig} = t2; - return t2 !== t1 && t2orig && (t2orig === t1orig || t2orig === t1); - }), - ].filter(Boolean); + { + flags: {expose: true}, + expose: { + dependencies: ['this', 'trackData', '#originalRelease'], + compute: ({ + this: thisTrack, + trackData, + '#originalRelease': originalRelease, + }) => + (originalRelease === thisTrack + ? [] + : [originalRelease]) + .concat(trackData.filter(track => + track !== originalRelease && + track !== thisTrack && + track.originalReleaseTrack === originalRelease)), + }, }, - }, - }, + ]), artistContribs: Thing.composite.from(`Track.artistContribs`, [ Track.composite.inheritFromOriginalRelease({property: 'artistContribs'}), @@ -545,12 +542,15 @@ export class Track extends Thing { ]); }, - // Just includes the original release of this track as a dependency, or - // null, if it's not a rerelease. Note that this will early exit if the - // original release is specified by reference and that reference doesn't - // resolve to anything. Outputs to '#originalRelease' by default. + // Just includes the original release of this track as a dependency. + // If this track isn't a rerelease, then it'll provide null, unless the + // {selfIfOriginal} option is set, in which case it'll provide this track + // itself. Note that this will early exit if the original release is + // specified by reference and that reference doesn't resolve to anything. + // Outputs to '#originalRelease' by default. withOriginalRelease({ to = '#originalRelease', + selfIfOriginal = false, } = {}) { return Thing.composite.from(`Track.composite.withOriginalRelease`, [ Thing.composite.withResolvedReference({ @@ -561,9 +561,26 @@ export class Track extends Thing { earlyExitIfNotFound: true, }), - Thing.composite.export({ - [outputDependency]: '#originalRelease', - }), + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['this', '#originalRelease'], + options: {selfIfOriginal}, + mapContinuation: {to}, + compute: ({ + this: track, + '#originalRelease': originalRelease, + '#options': {selfIfOriginal}, + }, continuation) => + continuation.raise({ + to: + (originalRelease ?? + (selfIfOriginal + ? track + : null)), + }), + }, + }, ]); }, -- cgit 1.3.0-6-gf8a5 From 1cf06b4898b517993a171a5f6c39d00609105253 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 30 Aug 2023 16:32:40 -0300 Subject: data, infra: only make exposed properties enumerable This prevents them from being displayed in, for example, node-tap mismatched test case output. AFAIK, we generally don't depend on the enumerability of properties anywhere in hsmusic's codebase, and it doesn't really make sense for unexposed properties to be enumerable in the first place. --- src/data/things/cacheable-object.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js index 24a6cf01..62c23d13 100644 --- a/src/data/things/cacheable-object.js +++ b/src/data/things/cacheable-object.js @@ -141,7 +141,7 @@ export default class CacheableObject { const definition = { configurable: false, - enumerable: true, + enumerable: flags.expose, }; if (flags.update) { -- cgit 1.3.0-6-gf8a5 From c98a1a5faa40bfad79bc3b07aa8e9d53111b10a7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 30 Aug 2023 16:46:58 -0300 Subject: data: Track: misc. minor fixes --- src/data/things/track.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 3b2029e8..724c8606 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -200,7 +200,7 @@ export class Track extends Thing { otherReleases: Thing.composite.from(`Track.otherReleases`, [ - Thing.composite.earlyExitWithoutDependency('trackData'), + Thing.composite.earlyExitWithoutDependency('trackData', {mode: 'empty'}), Track.composite.withOriginalRelease({selfIfOriginal: true}), { @@ -242,7 +242,7 @@ export class Track extends Thing { }, }, - Track.composite.withAlbumProperties({properties: ['artistContribs']}), + Track.composite.withAlbumProperty('artistContribs'), { flags: {expose: true}, -- cgit 1.3.0-6-gf8a5 From d4e5d37ef9bc09789715f978e1b636bc2a1f8e97 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 30 Aug 2023 16:47:16 -0300 Subject: data: update Track.composite.withAlbum implementation --- src/data/things/track.js | 84 ++++++++++++++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 724c8606..22043688 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -393,41 +393,61 @@ export class Track extends Thing { // this will early exit with null in two cases - albumData being missing, // or not including an album whose .tracks array includes this track. withAlbum({to = '#album', earlyExitIfNotFound = true} = {}) { - return { - annotation: `Track.composite.withAlbum`, - flags: {expose: true, compose: true}, + return Thing.composite.from(`Track.composite.withAlbum`, [ + Thing.composite.withResultOfAvailabilityCheck({ + fromDependency: 'albumData', + mode: 'empty', + to: '#albumDataAvailability', + }), - expose: { - dependencies: ['this', 'albumData'], - mapContinuation: {to}, - options: {earlyExitIfNotFound}, - - compute({ - this: track, - albumData, - '#options': {earlyExitIfNotFound}, - }, continuation) { - if (empty(albumData)) { - return ( - (earlyExitIfNotFound - ? continuation.exit(null) - : continuation({to: null}))); - } - - const album = - albumData?.find(album => album.tracks.includes(track)); - - if (!album) { - return ( - (earlyExitIfNotFound - ? continuation.exit(null) - : continuation({to: null}))); - } - - return continuation({to: album}); + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#albumDataAvailability'], + options: {earlyExitIfNotFound}, + mapContinuation: {to}, + compute: ({ + '#albumDataAvailability': albumDataAvailability, + '#options': {earlyExitIfNotFound}, + }, continuation) => + (albumDataAvailability + ? continuation() + : (earlyExitIfNotFound + ? continuation.exit(null) + : continuation.raise({to: null}))), + }, + }, + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['this', 'albumData'], + compute: ({this: track, albumData}, continuation) => + continuation({ + '#album': + albumData.find(album => album.tracks.includes(track)), + }), }, }, - }; + + { + flags: {expose: true, compose: true}, + expose: { + dependencies: ['#album'], + options: {earlyExitIfNotFound}, + mapContinuation: {to}, + compute: ({ + '#album': album, + '#options': {earlyExitIfNotFound}, + }, continuation) => + (album + ? continuation.raise({to: album}) + : (earlyExitIfNotFound + ? continuation.exit(null) + : continuation.raise({to: album}))), + }, + }, + ]); }, // Gets a single property from this track's album, providing it as the same -- cgit 1.3.0-6-gf8a5 From f874ea879e8b9555baaaa3a38ec6a00432721846 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 11:00:07 -0300 Subject: data, test: update & test Track.originalReleaseTrack --- src/data/things/track.js | 9 ++++----- test/unit/data/things/track.js | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 22043688..9b1a8226 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -192,11 +192,10 @@ export class Track extends Thing { Thing.composite.exposeDependency('#hasUniqueCoverArt'), ]), - originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( - 'originalReleaseTrackByRef', - 'trackData', - find.track - ), + originalReleaseTrack: Thing.composite.from(`Track.originalReleaseTrack`, [ + Track.composite.withOriginalRelease(), + Thing.composite.exposeDependency('#originalRelease'), + ]), otherReleases: Thing.composite.from(`Track.otherReleases`, [ diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 9132376c..97d664ee 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -272,6 +272,31 @@ t.test(`Track.hasUniqueCoverArt`, t => { `hasUniqueCoverArt #7: is false if track's coverArtistContribsByRef resolve empty`); }); +t.only(`Track.originalReleaseTrack`, t => { + t.plan(3); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + + const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ + trackData: [track1, track2], + albumData: [album1, album2], + }); + + t.equal(track2.originalReleaseTrack, null, + `originalReleaseTrack #1: defaults to null`); + + track2.originalReleaseTrackByRef = 'track:track1'; + + t.equal(track2.originalReleaseTrack, track1, + `originalReleaseTrack #2: is resolved from originalReleaseTrackByRef`); + + track2.trackData = []; + + t.equal(track2.originalReleaseTrack, null, + `originalReleaseTrack #3: is null when track missing trackData`); +}); + t.test(`Track.otherReleases`, t => { t.plan(6); -- cgit 1.3.0-6-gf8a5 From 59023bad2de5cd76edced5393cc38afc6b46fc1c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 11:01:25 -0300 Subject: data: fix mis-indented Thing.composite.from calls --- src/data/things/track.js | 52 +++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 9b1a8226..3a4e1585 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -155,11 +155,10 @@ export class Track extends Thing { commentatorArtists: Thing.common.commentatorArtists(), - album: - Thing.composite.from(`Track.album`, [ - Track.composite.withAlbum(), - Thing.composite.exposeDependency('#album'), - ]), + album: Thing.composite.from(`Track.album`, [ + Track.composite.withAlbum(), + Thing.composite.exposeDependency('#album'), + ]), // Note - this is an internal property used only to help identify a track. // It should not be assumed in general that the album and dataSourceAlbum match @@ -197,30 +196,29 @@ export class Track extends Thing { Thing.composite.exposeDependency('#originalRelease'), ]), - otherReleases: - Thing.composite.from(`Track.otherReleases`, [ - Thing.composite.earlyExitWithoutDependency('trackData', {mode: 'empty'}), - Track.composite.withOriginalRelease({selfIfOriginal: true}), + otherReleases: Thing.composite.from(`Track.otherReleases`, [ + Thing.composite.earlyExitWithoutDependency('trackData', {mode: 'empty'}), + Track.composite.withOriginalRelease({selfIfOriginal: true}), - { - flags: {expose: true}, - expose: { - dependencies: ['this', 'trackData', '#originalRelease'], - compute: ({ - this: thisTrack, - trackData, - '#originalRelease': originalRelease, - }) => - (originalRelease === thisTrack - ? [] - : [originalRelease]) - .concat(trackData.filter(track => - track !== originalRelease && - track !== thisTrack && - track.originalReleaseTrack === originalRelease)), - }, + { + flags: {expose: true}, + expose: { + dependencies: ['this', 'trackData', '#originalRelease'], + compute: ({ + this: thisTrack, + trackData, + '#originalRelease': originalRelease, + }) => + (originalRelease === thisTrack + ? [] + : [originalRelease]) + .concat(trackData.filter(track => + track !== originalRelease && + track !== thisTrack && + track.originalReleaseTrack === originalRelease)), }, - ]), + }, + ]), artistContribs: Thing.composite.from(`Track.artistContribs`, [ Track.composite.inheritFromOriginalRelease({property: 'artistContribs'}), -- cgit 1.3.0-6-gf8a5 From 5e5c2d9e1ee9dbe1c715e4d53bcb244ffcf606b0 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 11:05:58 -0300 Subject: data: misc. style consistency tweaks --- src/data/things/album.js | 15 +++------------ src/data/things/artist.js | 6 ++---- src/data/things/flash.js | 6 +----- src/data/things/track.js | 19 ++++--------------- src/data/things/wiki-info.js | 6 +----- 5 files changed, 11 insertions(+), 41 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index c012c243..06982903 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -128,6 +128,9 @@ export class Album extends Thing { commentatorArtists: Thing.common.commentatorArtists(), + groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group), + artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'), hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'), hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'), @@ -146,18 +149,6 @@ export class Album extends Thing { : [], }, }, - - groups: Thing.common.dynamicThingsFromReferenceList( - 'groupsByRef', - 'groupData', - find.group - ), - - artTags: Thing.common.dynamicThingsFromReferenceList( - 'artTagsByRef', - 'artTagData', - find.artTag - ), }); static [Thing.getSerializeDescriptors] = ({ diff --git a/src/data/things/artist.js b/src/data/things/artist.js index bde84cfa..b2383057 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -111,10 +111,8 @@ export class Artist extends Thing { }, }, - flashesAsContributor: Artist.filterByContrib( - 'flashData', - 'contributorContribs' - ), + flashesAsContributor: + Artist.filterByContrib('flashData', 'contributorContribs'), }); static [Thing.getSerializeDescriptors] = ({ diff --git a/src/data/things/flash.js b/src/data/things/flash.js index 445fd07c..3f870c51 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -141,10 +141,6 @@ export class FlashAct extends Thing { // Expose only - flashes: Thing.common.dynamicThingsFromReferenceList( - 'flashesByRef', - 'flashData', - find.flash - ), + flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash), }) } diff --git a/src/data/things/track.js b/src/data/things/track.js index 3a4e1585..74713a00 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -167,11 +167,7 @@ export class Track extends Thing { // not generally relevant information). It's also not guaranteed that // dataSourceAlbum is available (depending on the Track creator to optionally // provide dataSourceAlbumByRef). - dataSourceAlbum: Thing.common.dynamicThingFromSingleReference( - 'dataSourceAlbumByRef', - 'albumData', - find.album - ), + dataSourceAlbum: Thing.common.dynamicThingFromSingleReference('dataSourceAlbumByRef', 'albumData', find.album), date: Thing.composite.from(`Track.date`, [ Thing.composite.exposeDependencyOrContinue('dateFirstReleased'), @@ -303,6 +299,8 @@ export class Track extends Thing { Thing.common.dynamicThingsFromReferenceList('sampledTracksByRef', 'trackData', find.track), ]), + artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + // Specifically exclude re-releases from this list - while it's useful to // get from a re-release to the tracks it references, re-releases aren't // generally relevant from the perspective of the tracks being referenced. @@ -342,16 +340,7 @@ export class Track extends Thing { }, }, - featuredInFlashes: Thing.common.reverseReferenceList( - 'flashData', - 'featuredTracks' - ), - - artTags: Thing.common.dynamicThingsFromReferenceList( - 'artTagsByRef', - 'artTagData', - find.artTag - ), + featuredInFlashes: Thing.common.reverseReferenceList('flashData', 'featuredTracks'), }); static composite = { diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index e906cab1..e8279987 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -59,10 +59,6 @@ export class WikiInfo extends Thing { // Expose only - divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList( - 'divideTrackListsByGroupsByRef', - 'groupData', - find.group - ), + divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group), }); } -- cgit 1.3.0-6-gf8a5 From 001bcb69db4f4050fca222568ae2895f58a2f0df Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 15:51:01 -0300 Subject: data: simplify Thing.composite.from (needs docs update) --- src/data/things/thing.js | 402 +++++++++++++++++++++++++++-------------------- 1 file changed, 230 insertions(+), 172 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index cd62288e..782946ce 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -743,7 +743,7 @@ export default class Thing extends CacheableObject { } const base = composition.at(-1); - const steps = composition.slice(0, -1); + const steps = composition.slice(); const aggregate = openAggregate({ message: @@ -751,78 +751,118 @@ export default class Thing extends CacheableObject { (annotation ? ` (${annotation})` : ''), }); - if (base.flags.compose && base.flags.compute) { - push(new TypeError(`Base which composes can't also update yet`)); - } + const baseExposes = + (base.flags + ? base.flags.expose + : true); - const exposeSteps = []; - const exposeDependencies = new Set(); + const baseUpdates = + (base.flags + ? base.flags.update + : false); - if (base.expose?.dependencies) { - for (const dependency of base.expose.dependencies) { - if (typeof dependency === 'string' && dependency.startsWith('#')) continue; - exposeDependencies.add(dependency); - } - } + const baseComposes = + (base.flags + ? base.flags.compose + : true); - if (base.expose?.mapDependencies) { - for (const dependency of Object.values(base.expose.mapDependencies)) { - if (typeof dependency === 'string' && dependency.startsWith('#')) continue; - exposeDependencies.add(dependency); - } + if (!baseExposes) { + aggregate.push(new TypeError(`All steps, including base, must expose`)); } + const exposeDependencies = new Set(); + + let anyStepsCompute = false; + let anyStepsTransform = false; + for (let i = 0; i < steps.length; i++) { const step = steps[i]; + const isBase = i === steps.length - 1; const message = - (step.annotation - ? `Errors in step #${i + 1} (${step.annotation})` - : `Errors in step #${i + 1}`); + `Errors in step #${i + 1}` + + (isBase ? ` (base)` : ``) + + (step.annotation ? ` (${step.annotation})` : ``); aggregate.nest({message}, ({push}) => { - if (!step.flags.compose) { - push(new TypeError(`Steps (all but bottom item) must be {compose: true}`)); - } + if (step.flags) { + let flagsErrored = false; - if (step.flags.update) { - push(new Error(`Steps which update aren't supported yet`)); - } - - if (step.flags.expose) expose: { - if (!step.expose.transform && !step.expose.compute) { - push(new TypeError(`Steps which expose must provide at least one of transform or compute`)); - break expose; + if (!step.flags.compose && !isBase) { + push(new TypeError(`All steps but base must compose`)); + flagsErrored = true; } - if ( - step.expose.transform && - !step.expose.compute && - !base.flags.update && - !base.flags.compose - ) { - push(new TypeError(`Steps which only transform can't be composed with a non-updating base`)); - break expose; + if (!step.flags.expose) { + push(new TypeError(`All steps must expose`)); + flagsErrored = true; } - if (step.expose.dependencies) { - for (const dependency of step.expose.dependencies) { - if (typeof dependency === 'string' && dependency.startsWith('#')) continue; - exposeDependencies.add(dependency); - } + if (flagsErrored) { + return; } + } - if (step.expose.mapDependencies) { - for (const dependency of Object.values(step.expose.mapDependencies)) { - if (typeof dependency === 'string' && dependency.startsWith('#')) continue; - exposeDependencies.add(dependency); - } + const expose = + (step.flags + ? step.expose + : step); + + const stepComputes = !!expose.compute; + const stepTransforms = !!expose.transform; + + if (!stepComputes && !stepTransforms) { + push(new TypeError(`Steps must provide compute or transform (or both)`)); + return; + } + + if ( + stepTransforms && !stepComputes && + !baseUpdates && !baseComposes + ) { + push(new TypeError(`Steps which only transform can't be composed with a non-updating base`)); + return; + } + + if (stepComputes) { + anyStepsCompute = true; + } + + if (stepTransforms) { + anyStepsTransform = true; + } + + // Unmapped dependencies are exposed on the final composition only if + // they're "public", i.e. pointing to update values of other properties + // on the CacheableObject. + for (const dependency of expose.dependencies ?? []) { + if (typeof dependency === 'string' && dependency.startsWith('#')) { + continue; } - exposeSteps.push(step); + exposeDependencies.add(dependency); + } + + // Mapped dependencies are always exposed on the final composition. + // These are explicitly for reading values which are named outside of + // the current compositional step. + for (const dependency of Object.values(expose.mapDependencies ?? {})) { + exposeDependencies.add(dependency); } }); } + if (!baseComposes) { + if (baseUpdates) { + if (!anyStepsTransform) { + push(new TypeError(`Expected at least one step to transform`)); + } + } else { + if (!anyStepsCompute) { + push(new TypeError(`Expected at least one step to compute`)); + } + } + } + aggregate.close(); const constructedDescriptor = {}; @@ -832,64 +872,68 @@ export default class Thing extends CacheableObject { } constructedDescriptor.flags = { - update: !!base.flags.update, - expose: !!base.flags.expose, - compose: !!base.flags.compose, + update: baseUpdates, + expose: baseExposes, + compose: baseComposes, }; - if (base.flags.update) { + if (baseUpdates) { constructedDescriptor.update = base.update; } - if (base.flags.expose) { + if (baseExposes) { const expose = constructedDescriptor.expose = {}; expose.dependencies = Array.from(exposeDependencies); const continuationSymbol = Symbol('continuation symbol'); const noTransformSymbol = Symbol('no-transform symbol'); - function _filterDependencies(dependencies, step) { + function _filterDependencies(availableDependencies, { + dependencies, + mapDependencies, + options, + }) { const filteredDependencies = - (step.expose.dependencies - ? filterProperties(dependencies, step.expose.dependencies) + (dependencies + ? filterProperties(availableDependencies, dependencies) : {}); - if (step.expose.mapDependencies) { - for (const [to, from] of Object.entries(step.expose.mapDependencies)) { - filteredDependencies[to] = dependencies[from] ?? null; + if (mapDependencies) { + for (const [to, from] of Object.entries(mapDependencies)) { + filteredDependencies[to] = availableDependencies[from] ?? null; } } - if (step.expose.options) { - filteredDependencies['#options'] = step.expose.options; + if (options) { + filteredDependencies['#options'] = options; } return filteredDependencies; } - function _assignDependencies(continuationAssignment, step) { - if (!step.expose.mapContinuation) { + function _assignDependencies(continuationAssignment, {mapContinuation}) { + if (!mapContinuation) { return continuationAssignment; } const assignDependencies = {}; - for (const [from, to] of Object.entries(step.expose.mapContinuation)) { + for (const [from, to] of Object.entries(mapContinuation)) { assignDependencies[to] = continuationAssignment[from] ?? null; } return assignDependencies; } - function _prepareContinuation(transform) { + function _prepareContinuation(callingTransformForThisStep) { const continuationStorage = { returnedWith: null, - providedDependencies: null, - providedValue: null, + providedDependencies: undefined, + providedValue: undefined, }; const continuation = - (transform + (callingTransformForThisStep ? (providedValue, providedDependencies = null) => { continuationStorage.returnedWith = 'continuation'; continuationStorage.providedDependencies = providedDependencies; @@ -908,150 +952,166 @@ export default class Thing extends CacheableObject { return continuationSymbol; }; - if (base.flags.compose) { - continuation.raise = - (transform + if (baseComposes) { + const makeRaiseLike = returnWith => + (callingTransformForThisStep ? (providedValue, providedDependencies = null) => { - continuationStorage.returnedWith = 'raise'; + continuationStorage.returnedWith = returnWith; continuationStorage.providedDependencies = providedDependencies; continuationStorage.providedValue = providedValue; return continuationSymbol; } : (providedDependencies = null) => { - continuationStorage.returnedWith = 'raise'; + continuationStorage.returnedWith = returnWith; continuationStorage.providedDependencies = providedDependencies; return continuationSymbol; }); + + continuation.raise = makeRaiseLike('raise'); + continuation.raiseAbove = makeRaiseLike('raiseAbove'); } return {continuation, continuationStorage}; } - function _computeOrTransform(value, initialDependencies, continuationIfApplicable) { - const dependencies = {...initialDependencies}; + function _computeOrTransform(initialValue, initialDependencies, continuationIfApplicable) { + const expectingTransform = initialValue !== noTransformSymbol; - let valueSoFar = value; // Set only for {update: true} compositions - let exportDependencies = null; // Set only for {compose: true} compositions + let valueSoFar = + (expectingTransform + ? initialValue + : undefined); - debug(() => color.bright(`begin composition`)); + const availableDependencies = {...initialDependencies}; - stepLoop: for (let i = 0; i < exposeSteps.length; i++) { - const step = exposeSteps[i]; - debug(() => [`step #${i+1}:`, step]); + if (expectingTransform) { + debug(() => [color.bright(`begin composition - transforming from:`), initialValue]); + } else { + debug(() => color.bright(`begin composition - not transforming`)); + } - const transform = - valueSoFar !== noTransformSymbol && - step.expose.transform; + stepLoop: for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; - const filteredDependencies = _filterDependencies(dependencies, step); - const {continuation, continuationStorage} = _prepareContinuation(transform); + debug(() => [ + `step #${i+1}` + + (isBase + ? ` (base):` + : ` of ${steps.length}:`), + step]); + + const expose = + (step.flags + ? step.expose + : step); + + const callingTransformForThisStep = + expectingTransform && expose.transform; + + const filteredDependencies = _filterDependencies(availableDependencies, expose); + const {continuation, continuationStorage} = _prepareContinuation(callingTransformForThisStep); debug(() => [ - `step #${i+1} - ${transform ? 'transform' : 'compute'}`, + `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, `with dependencies:`, filteredDependencies]); const result = - (transform + (callingTransformForThisStep ? step.expose.transform(valueSoFar, filteredDependencies, continuation) : step.expose.compute(filteredDependencies, continuation)); if (result !== continuationSymbol) { - if (base.flags.compose) { - throw new TypeError(`Use continuation.exit() or continuation.raise() in {compose: true} compositions`); + debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); + + if (baseComposes) { + throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); } - debug(() => [`step #${i+1} - early-exit (inferred) ->`, result]); - debug(() => color.bright(`end composition`)); + debug(() => color.bright(`end composition - exit (inferred)`)); + return result; } - switch (continuationStorage.returnedWith) { - case 'exit': - debug(() => [`step #${i+1} - result: early-exit (explicit) ->`, continuationStorage.providedValue]); - debug(() => color.bright(`end composition`)); - return continuationStorage.providedValue; - - case 'raise': - debug(() => `step #${i+1} - result: raise`); - exportDependencies = _assignDependencies(continuationStorage.providedDependencies, step) ?? {}; - if (transform) valueSoFar = continuationStorage.providedValue; - break stepLoop; + const {returnedWith} = continuationStorage; - case 'continuation': - if (transform) { - valueSoFar = continuationStorage.providedValue; - } + if (returnedWith === 'exit') { + const {providedValue} = continuationStorage; - if (continuationStorage.providedDependencies) { - const assignDependencies = _assignDependencies(continuationStorage.providedDependencies, step); - Object.assign(dependencies, assignDependencies); - debug(() => `step #${i+1} - result: continuation`); - debug(() => [`assign dependencies:`, assignDependencies]); - } else { - debug(() => `step #${i+1} - result: continuation (no provided dependencies)`); - } + debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); + debug(() => color.bright(`end composition - exit (explicit)`)); - break; + if (baseComposes) { + return continuationIfApplicable.exit(providedValue); + } else { + return providedValue; + } } - } - if (exportDependencies) { - debug(() => [`raise dependencies:`, exportDependencies]); - debug(() => color.bright(`end composition`)); - return continuationIfApplicable(exportDependencies); - } - - debug(() => `completed all steps, reached base`); + const {providedValue, providedDependencies} = continuationStorage; - const filteredDependencies = _filterDependencies(dependencies, base); + const continuingWithValue = + (expectingTransform + ? (callingTransformForThisStep + ? providedValue ?? null + : valueSoFar ?? null) + : undefined); - const transform = - valueSoFar !== noTransformSymbol && - base.expose.transform; + const continuingWithDependencies = + (providedDependencies + ? _assignDependencies(providedDependencies, expose) + : null); - debug(() => [ - `base - ${transform ? 'transform' : 'compute'}`, - `with dependencies:`, filteredDependencies]); + const continuationArgs = []; + if (continuingWithValue !== undefined) continuationArgs.push(continuingWithValue); + if (continuingWithDependencies !== null) continuationArgs.push(continuingWithDependencies); - if (base.flags.compose) { - const {continuation, continuationStorage} = _prepareContinuation(transform); - - const result = - (transform - ? base.expose.transform(valueSoFar, filteredDependencies, continuation) - : base.expose.compute(filteredDependencies, continuation)); + debug(() => { + const base = `step #${i+1} - result: ` + returnedWith; + const parts = []; - if (result !== continuationSymbol) { - throw new TypeError(`Use continuation.exit() or continuation.raise() in {compose: true} composition`); - } + if (callingTransformForThisStep) { + if (continuingWithValue === undefined) { + parts.push(`(no value)`); + } else { + parts.push(`value:`, providedValue); + } + } - switch (continuationStorage.returnedWith) { - case 'continuation': - throw new TypeError(`Use continuation.raise() in base of {compose: true} composition`); + if (continuingWithDependencies !== null) { + parts.push(`deps:`, continuingWithDependencies); + } else { + parts.push(`(no deps)`); + } - case 'exit': - debug(() => `base - result: early-exit (explicit)`); - debug(() => [`early-exit:`, continuationStorage.providedValue]); - debug(() => color.bright(`end composition`)); - return continuationStorage.providedValue; + if (empty(parts)) { + return base; + } else { + return [base + ' ->', ...parts]; + } + }); + switch (returnedWith) { case 'raise': - exportDependencies = _assignDependencies(continuationStorage.providedDependencies, base); - debug(() => `base - result: raise`); - debug(() => [`raise dependencies:`, exportDependencies]); - debug(() => color.bright(`end composition`)); - return continuationIfApplicable(exportDependencies); - } - } else { - const result = - (transform - ? base.expose.transform(valueSoFar, filteredDependencies) - : base.expose.compute(filteredDependencies)); + debug(() => + (isBase + ? color.bright(`end composition - raise (base: explicit)`) + : color.bright(`end composition - raise`))); + return continuationIfApplicable(...continuationArgs); - debug(() => [`base - non-compose (final) result:`, result]); - debug(() => color.bright(`end composition`)); + case 'raiseAbove': + debug(() => color.bright(`end composition - raiseAbove`)); + return continuationIfApplicable.raise(...continuationArgs); - return result; + case 'continuation': + if (isBase) { + debug(() => color.bright(`end composition - raise (inferred)`)); + return continuationIfApplicable(...continuationArgs); + } else { + Object.assign(availableDependencies, continuingWithDependencies); + break; + } + } } } @@ -1063,12 +1123,10 @@ export default class Thing extends CacheableObject { (initialDependencies, continuationIfApplicable) => _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable); - if (base.flags.compose) { - if (exposeSteps.some(step => step.expose.transform)) { - expose.transform = transformFn; - } - expose.compute = computeFn; - } else if (base.flags.update) { + if (baseComposes) { + if (anyStepsTransform) expose.transform = transformFn; + if (anyStepsCompute) expose.compute = computeFn; + } else if (baseUpdates) { expose.transform = transformFn; } else { expose.compute = computeFn; @@ -1229,7 +1287,7 @@ export default class Thing extends CacheableObject { switch (mode) { case 'null': return value !== null; case 'empty': return !empty(value); - case 'falsy': return !empty(value) && !!value; + case 'falsy': return !!value && (!Array.isArray(value) || !empty(value)); default: return false; } }; -- cgit 1.3.0-6-gf8a5 From c0bbd7e8fa6c76df4fa492e3a9d3b5e9ef42ec5c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 15:53:02 -0300 Subject: data: misc. utility additions * add earlyExitWithoutUpdateValue * add raiseWithoutDependency * add raiseWithoutUpdateValue * add earlyExitIfAvailabilityCheckFailed (internal) * refactor earlyExitWithoutDependency The "raise" utilities make use of the new `raiseAbove` continuation feature. --- src/data/things/thing.js | 100 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 8 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 782946ce..501286d7 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1384,6 +1384,36 @@ export default class Thing extends CacheableObject { ]); }, + // Early exits if an availability check fails. + // This is for internal use only - use `earlyExitWithoutDependency` or + // `earlyExitWIthoutUpdateValue` instead. + earlyExitIfAvailabilityCheckFailed({ + availability = '#availability', + value = null, + }) { + return Thing.composite.from(`Thing.composite.earlyExitIfAvailabilityCheckFailed`, [ + { + flags: {expose: true, compose: true}, + expose: { + mapDependencies: {availability}, + compute: ({availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), + }, + }, + + { + flags: {expose: true, compose: true}, + expose: { + options: {value}, + compute: ({'#options': {value}}, continuation) => + continuation.exit(value), + }, + }, + ]); + }, + // Early exits if a dependency isn't available. // See withResultOfAvailabilityCheck for {mode} options! earlyExitWithoutDependency(dependency, { @@ -1391,10 +1421,32 @@ export default class Thing extends CacheableObject { value = null, } = {}) { return Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [ - Thing.composite.withResultOfAvailabilityCheck({ - fromDependency: dependency, - mode, - }), + Thing.composite.withResultOfAvailabilityCheck({fromDependency: dependency, mode}), + Thing.composite.earlyExitIfAvailabilityCheckFailed({value}), + ]); + }, + + // Early exits if this property's update value isn't available. + // See withResultOfAvailabilityCheck for {mode} options! + earlyExitWithoutUpdateValue({ + mode = 'null', + value = null, + } = {}) { + return Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [ + Thing.composite.withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), + Thing.composite.earlyExitIfAvailabilityCheckFailed({value}), + ]); + }, + + // Raises if a dependency isn't available. + // See withResultOfAvailabilityCheck for {mode} options! + raiseWithoutDependency(dependency, { + mode = 'null', + map = {}, + raise = {}, + } = {}) { + return Thing.composite.from(`Thing.composite.raiseWithoutDependency`, [ + Thing.composite.withResultOfAvailabilityCheck({fromDependency: dependency, mode}), { flags: {expose: true, compose: true}, @@ -1410,11 +1462,43 @@ export default class Thing extends CacheableObject { { flags: {expose: true, compose: true}, expose: { - dependencies: ['#availability'], - options: {value}, + options: {raise}, + mapContinuation: map, + compute: ({'#options': {raise}}, continuation) => + continuation.raiseAbove(raise), + }, + }, + ]); + }, - compute: ({'#options': {value}}, continuation) => - continuation.exit(value), + // Raises if this property's update value isn't available. + // See withResultOfAvailabilityCheck for {mode} options! + raiseWithoutUpdateValue({ + mode = 'null', + map = {}, + raise = {}, + } = {}) { + return Thing.composite.from(`Thing.composite.raiseWithoutUpdateValue`, [ + Thing.composite.withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), + + { + flags: {expose: true, compose: true}, + expose: { + mapDependencies: {availability}, + compute: ({availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), + }, + }, + + { + flags: {expose: true, compose: true}, + expose: { + options: {raise}, + mapContinuation: map, + compute: ({'#options': {raise}}, continuation) => + continuation.raiseAbove(raise), }, }, ]); -- cgit 1.3.0-6-gf8a5 From 918fb043a640cf937de604fc74cb95566fa66459 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 15:56:34 -0300 Subject: data: refactor Thing.composite.withResolvedReference --- src/data/things/thing.js | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 501286d7..389b3845 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1542,30 +1542,8 @@ export default class Thing extends CacheableObject { earlyExitIfNotFound = false, }) { return Thing.composite.from(`Thing.composite.withResolvedReference`, [ - { - flags: {expose: true, compose: true}, - expose: { - mapDependencies: {ref}, - mapContinuation: {to}, - - compute: ({ref}, continuation) => - (ref - ? continuation() - : continuation.raise({to: null})), - }, - }, - - { - flags: {expose: true, compose: true}, - expose: { - mapDependencies: {data}, - - compute: ({data}, continuation) => - (data === null - ? continuation.exit(null) - : continuation()), - }, - }, + Thing.composite.raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), + Thing.composite.earlyExitWithoutDependency(data), { flags: {expose: true, compose: true}, -- cgit 1.3.0-6-gf8a5 From 5a63b96cfd3d26e4b74ff4c6dfc793aef057f81b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 15:57:15 -0300 Subject: data: update Thing.common.dynamicThingsFromReferenceList Only the internal implementation. This should really be updated to take key/value-style parameters, and probably be renamed, but this helps to confirm a swathe of expected behavior continues to work with an existing `common` utility reimplemented compositionally. --- src/data/things/thing.js | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 389b3845..751e168f 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -192,26 +192,29 @@ export default class Thing extends CacheableObject { // Corresponding dynamic property to referenceList, which takes the values // in the provided property and searches the specified wiki data for // matching actual Thing-subclass objects. - dynamicThingsFromReferenceList: ( - referenceListProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, + dynamicThingsFromReferenceList( + refs, + data, + findFunction + ) { + return Thing.composite.from(`Thing.common.dynamicThingsFromReferenceList`, [ + Thing.composite.earlyExitWithoutDependency(refs, {value: []}), + Thing.composite.earlyExitWithoutDependency(data, {value: []}), - expose: { - dependencies: [referenceListProperty, thingDataProperty], - compute: ({ - [referenceListProperty]: refs, - [thingDataProperty]: thingData, - }) => - refs && thingData - ? refs - .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) - .filter(Boolean) - : [], - }, - }), + { + flags: {expose: true}, + expose: { + mapDependencies: {refs, data}, + options: {findFunction}, + + compute: ({refs, data, '#options': {findFunction}}) => + refs + .map(ref => findFunction(ref, data, {mode: 'quiet'})) + .filter(Boolean), + }, + }, + ]); + }, // Corresponding function for a single reference. dynamicThingFromSingleReference: ( -- cgit 1.3.0-6-gf8a5 From f3162203ef1f758d500e065804f9dbe478d0481d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 16:05:53 -0300 Subject: data: Thing.composite.from: fix missed step.expose assumptions --- src/data/things/thing.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 751e168f..d4d7c850 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -993,7 +993,7 @@ export default class Thing extends CacheableObject { debug(() => color.bright(`begin composition - not transforming`)); } - stepLoop: for (let i = 0; i < steps.length; i++) { + for (let i = 0; i < steps.length; i++) { const step = steps[i]; const isBase = i === steps.length - 1; @@ -1021,8 +1021,8 @@ export default class Thing extends CacheableObject { const result = (callingTransformForThisStep - ? step.expose.transform(valueSoFar, filteredDependencies, continuation) - : step.expose.compute(filteredDependencies, continuation)); + ? expose.transform(valueSoFar, filteredDependencies, continuation) + : expose.compute(filteredDependencies, continuation)); if (result !== continuationSymbol) { debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); -- cgit 1.3.0-6-gf8a5 From 56a8e47f0e5ad276baef9d27c16960e3ea2c583b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 16:06:58 -0300 Subject: data: remove lots of boilerplate {expose: true, compose: true} --- src/data/things/thing.js | 133 +++++++------------ src/data/things/track.js | 336 ++++++++++++++++++++--------------------------- 2 files changed, 195 insertions(+), 274 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index d4d7c850..15ec62c3 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -1333,23 +1333,17 @@ export default class Thing extends CacheableObject { }), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#availability'], - compute: ({'#availability': availability}, continuation) => - (availability - ? continuation() - : continuation.raise()), - }, + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation() + : continuation.raise()), }, { - flags: {expose: true, compose: true}, - expose: { - mapDependencies: {dependency}, - compute: ({dependency}, continuation) => - continuation.exit(dependency), - }, + mapDependencies: {dependency}, + compute: ({dependency}, continuation) => + continuation.exit(dependency), }, ]); }, @@ -1367,22 +1361,16 @@ export default class Thing extends CacheableObject { }), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#availability'], - compute: ({'#availability': availability}, continuation) => - (availability - ? continuation() - : continuation.raise()), - }, + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation() + : continuation.raise()), }, { - flags: {expose: true, compose: true}, - expose: { - transform: (value, {}, continuation) => - continuation.exit(value), - }, + transform: (value, {}, continuation) => + continuation.exit(value), }, ]); }, @@ -1396,23 +1384,17 @@ export default class Thing extends CacheableObject { }) { return Thing.composite.from(`Thing.composite.earlyExitIfAvailabilityCheckFailed`, [ { - flags: {expose: true, compose: true}, - expose: { - mapDependencies: {availability}, - compute: ({availability}, continuation) => - (availability - ? continuation.raise() - : continuation()), - }, + mapDependencies: {availability}, + compute: ({availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), }, { - flags: {expose: true, compose: true}, - expose: { - options: {value}, - compute: ({'#options': {value}}, continuation) => - continuation.exit(value), - }, + options: {value}, + compute: ({'#options': {value}}, continuation) => + continuation.exit(value), }, ]); }, @@ -1452,24 +1434,18 @@ export default class Thing extends CacheableObject { Thing.composite.withResultOfAvailabilityCheck({fromDependency: dependency, mode}), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#availability'], - compute: ({'#availability': availability}, continuation) => - (availability - ? continuation.raise() - : continuation()), - }, + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), }, { - flags: {expose: true, compose: true}, - expose: { - options: {raise}, - mapContinuation: map, - compute: ({'#options': {raise}}, continuation) => - continuation.raiseAbove(raise), - }, + options: {raise}, + mapContinuation: map, + compute: ({'#options': {raise}}, continuation) => + continuation.raiseAbove(raise), }, ]); }, @@ -1485,24 +1461,18 @@ export default class Thing extends CacheableObject { Thing.composite.withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), { - flags: {expose: true, compose: true}, - expose: { - mapDependencies: {availability}, - compute: ({availability}, continuation) => - (availability - ? continuation.raise() - : continuation()), - }, + mapDependencies: {availability}, + compute: ({availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), }, { - flags: {expose: true, compose: true}, - expose: { - options: {raise}, - mapContinuation: map, - compute: ({'#options': {raise}}, continuation) => - continuation.raiseAbove(raise), - }, + options: {raise}, + mapContinuation: map, + compute: ({'#options': {raise}}, continuation) => + continuation.raiseAbove(raise), }, ]); }, @@ -1549,21 +1519,18 @@ export default class Thing extends CacheableObject { Thing.composite.earlyExitWithoutDependency(data), { - flags: {expose: true, compose: true}, - expose: { - options: {findFunction, earlyExitIfNotFound}, - mapDependencies: {ref, data}, - mapContinuation: {match: to}, + options: {findFunction, earlyExitIfNotFound}, + mapDependencies: {ref, data}, + mapContinuation: {match: to}, - compute({ref, data, '#options': {findFunction, earlyExitIfNotFound}}, continuation) { - const match = findFunction(ref, data, {mode: 'quiet'}); + compute({ref, data, '#options': {findFunction, earlyExitIfNotFound}}, continuation) { + const match = findFunction(ref, data, {mode: 'quiet'}); - if (match === null && earlyExitIfNotFound) { - return continuation.exit(null); - } + if (match === null && earlyExitIfNotFound) { + return continuation.exit(null); + } - return continuation.raise({match}); - }, + return continuation.raise({match}); }, }, ]); diff --git a/src/data/things/track.js b/src/data/things/track.js index 74713a00..ad001445 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -49,18 +49,15 @@ export class Track extends Thing { Track.composite.withContainingTrackSection({earlyExitIfNotFound: false}), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#trackSection'], - compute: ({'#trackSection': trackSection}, continuation) => - // Album.trackSections guarantees the track section will have a - // color property (inheriting from the album's own color), but only - // if it's actually present! Color will be inherited directly from - // album otherwise. - (trackSection - ? trackSection.color - : continuation()), - }, + dependencies: ['#trackSection'], + compute: ({'#trackSection': trackSection}, continuation) => + // Album.trackSections guarantees the track section will have a + // color property (inheriting from the album's own color), but only + // if it's actually present! Color will be inherited directly from + // album otherwise. + (trackSection + ? trackSection.color + : continuation()), }, Track.composite.withAlbumProperty('color'), @@ -225,14 +222,11 @@ export class Track extends Thing { }), { - flags: {expose: true, compose: true}, - expose: { - mapDependencies: {contribsFromTrack: '#artistContribs'}, - compute: ({contribsFromTrack}, continuation) => - (empty(contribsFromTrack) - ? continuation() - : contribsFromTrack), - }, + mapDependencies: {contribsFromTrack: '#artistContribs'}, + compute: ({contribsFromTrack}, continuation) => + (empty(contribsFromTrack) + ? continuation() + : contribsFromTrack), }, Track.composite.withAlbumProperty('artistContribs'), @@ -259,14 +253,11 @@ export class Track extends Thing { // of the track. coverArtistContribs: Thing.composite.from(`Track.coverArtistContribs`, [ { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['disableUniqueCoverArt'], - compute: ({disableUniqueCoverArt}, continuation) => - (disableUniqueCoverArt - ? null - : continuation()), - }, + dependencies: ['disableUniqueCoverArt'], + compute: ({disableUniqueCoverArt}, continuation) => + (disableUniqueCoverArt + ? null + : continuation()), }, Thing.composite.withResolvedContribs({ @@ -275,14 +266,11 @@ export class Track extends Thing { }), { - flags: {expose: true, compose: true}, - expose: { - mapDependencies: {contribsFromTrack: '#coverArtistContribs'}, - compute: ({contribsFromTrack}, continuation) => - (empty(contribsFromTrack) - ? continuation() - : contribsFromTrack), - }, + mapDependencies: {contribsFromTrack: '#coverArtistContribs'}, + compute: ({contribsFromTrack}, continuation) => + (empty(contribsFromTrack) + ? continuation() + : contribsFromTrack), }, Track.composite.withAlbumProperty('trackCoverArtistContribs'), @@ -357,21 +345,16 @@ export class Track extends Thing { Track.composite.withOriginalRelease(), { - flags: {expose: true, compose: true}, - - expose: { - dependencies: ['#originalRelease'], + dependencies: ['#originalRelease'], + compute({'#originalRelease': originalRelease}, continuation) { + if (!originalRelease) return continuation.raise(); - compute({'#originalRelease': originalRelease}, continuation) { - if (!originalRelease) return continuation.raise(); + const value = originalRelease[originalProperty]; + if (allowOverride && value === null) return continuation.raise(); - const value = originalRelease[originalProperty]; - if (allowOverride && value === null) return continuation.raise(); - - return continuation.exit(value); - }, + return continuation.exit(value); }, - } + }, ]); }, @@ -387,51 +370,43 @@ export class Track extends Thing { }), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#albumDataAvailability'], - options: {earlyExitIfNotFound}, - mapContinuation: {to}, - compute: ({ - '#albumDataAvailability': albumDataAvailability, - '#options': {earlyExitIfNotFound}, - }, continuation) => - (albumDataAvailability - ? continuation() - : (earlyExitIfNotFound - ? continuation.exit(null) - : continuation.raise({to: null}))), - }, + dependencies: ['#albumDataAvailability'], + options: {earlyExitIfNotFound}, + mapContinuation: {to}, + + compute: ({ + '#albumDataAvailability': albumDataAvailability, + '#options': {earlyExitIfNotFound}, + }, continuation) => + (albumDataAvailability + ? continuation() + : (earlyExitIfNotFound + ? continuation.exit(null) + : continuation.raise({to: null}))), }, { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['this', 'albumData'], - compute: ({this: track, albumData}, continuation) => - continuation({ - '#album': - albumData.find(album => album.tracks.includes(track)), - }), - }, + dependencies: ['this', 'albumData'], + compute: ({this: track, albumData}, continuation) => + continuation({ + '#album': + albumData.find(album => album.tracks.includes(track)), + }), }, { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#album'], - options: {earlyExitIfNotFound}, - mapContinuation: {to}, - compute: ({ - '#album': album, - '#options': {earlyExitIfNotFound}, - }, continuation) => - (album - ? continuation.raise({to: album}) - : (earlyExitIfNotFound - ? continuation.exit(null) - : continuation.raise({to: album}))), - }, + dependencies: ['#album'], + options: {earlyExitIfNotFound}, + mapContinuation: {to}, + compute: ({ + '#album': album, + '#options': {earlyExitIfNotFound}, + }, continuation) => + (album + ? continuation.raise({to: album}) + : (earlyExitIfNotFound + ? continuation.exit(null) + : continuation.raise({to: album}))), }, ]); }, @@ -448,20 +423,17 @@ export class Track extends Thing { Track.composite.withAlbum({earlyExitIfNotFound}), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#album'], - options: {property}, - mapContinuation: {to}, - - compute: ({ - '#album': album, - '#options': {property}, - }, continuation) => - (album - ? continuation.raise({to: album[property]}) - : continuation.raise({to: null})), - }, + dependencies: ['#album'], + options: {property}, + mapContinuation: {to}, + + compute: ({ + '#album': album, + '#options': {property}, + }, continuation) => + (album + ? continuation.raise({to: album[property]}) + : continuation.raise({to: null})), }, ]); }, @@ -479,29 +451,26 @@ export class Track extends Thing { Track.composite.withAlbum({earlyExitIfNotFound}), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#album'], - options: {properties, prefix}, - - compute({ - '#album': album, - '#options': {properties, prefix}, - }, continuation) { - const raise = {}; - - if (album) { - for (const property of properties) { - raise[prefix + '.' + property] = album[property]; - } - } else { - for (const property of properties) { - raise[prefix + '.' + property] = null; - } + dependencies: ['#album'], + options: {properties, prefix}, + + compute({ + '#album': album, + '#options': {properties, prefix}, + }, continuation) { + const raise = {}; + + if (album) { + for (const property of properties) { + raise[prefix + '.' + property] = album[property]; } + } else { + for (const property of properties) { + raise[prefix + '.' + property] = null; + } + } - return continuation.raise(raise); - }, + return continuation.raise(raise); }, }, ]); @@ -519,30 +488,27 @@ export class Track extends Thing { Track.composite.withAlbumProperty('trackSections', {earlyExitIfNotFound}), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['this', '#album.trackSections'], - mapContinuation: {to}, - - compute({ - this: track, - '#album.trackSections': trackSections, - }, continuation) { - if (!trackSections) { - return continuation.raise({to: null}); - } - - const trackSection = - trackSections.find(({tracks}) => tracks.includes(track)); - - if (trackSection) { - return continuation.raise({to: trackSection}); - } else if (earlyExitIfNotFound) { - return continuation.exit(null); - } else { - return continuation.raise({to: null}); - } - }, + dependencies: ['this', '#album.trackSections'], + mapContinuation: {to}, + + compute({ + this: track, + '#album.trackSections': trackSections, + }, continuation) { + if (!trackSections) { + return continuation.raise({to: null}); + } + + const trackSection = + trackSections.find(({tracks}) => tracks.includes(track)); + + if (trackSection) { + return continuation.raise({to: trackSection}); + } else if (earlyExitIfNotFound) { + return continuation.exit(null); + } else { + return continuation.raise({to: null}); + } }, }, ]); @@ -568,24 +534,21 @@ export class Track extends Thing { }), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['this', '#originalRelease'], - options: {selfIfOriginal}, - mapContinuation: {to}, - compute: ({ - this: track, - '#originalRelease': originalRelease, - '#options': {selfIfOriginal}, - }, continuation) => - continuation.raise({ - to: - (originalRelease ?? - (selfIfOriginal - ? track - : null)), - }), - }, + dependencies: ['this', '#originalRelease'], + options: {selfIfOriginal}, + mapContinuation: {to}, + compute: ({ + this: track, + '#originalRelease': originalRelease, + '#options': {selfIfOriginal}, + }, continuation) => + continuation.raise({ + to: + (originalRelease ?? + (selfIfOriginal + ? track + : null)), + }), }, ]); }, @@ -597,15 +560,12 @@ export class Track extends Thing { } = {}) { return Thing.composite.from(`Track.composite.withHasUniqueCoverArt`, [ { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['disableUniqueCoverArt'], - mapContinuation: {to}, - compute: ({disableUniqueCoverArt}, continuation) => - (disableUniqueCoverArt - ? continuation.raise({to: false}) - : continuation()), - }, + dependencies: ['disableUniqueCoverArt'], + mapContinuation: {to}, + compute: ({disableUniqueCoverArt}, continuation) => + (disableUniqueCoverArt + ? continuation.raise({to: false}) + : continuation()), }, Thing.composite.withResolvedContribs({ @@ -614,29 +574,23 @@ export class Track extends Thing { }), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#coverArtistContribs'], - mapContinuation: {to}, - compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => - (empty(contribsFromTrack) - ? continuation() - : continuation.raise({to: true})), - }, + dependencies: ['#coverArtistContribs'], + mapContinuation: {to}, + compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => + (empty(contribsFromTrack) + ? continuation() + : continuation.raise({to: true})), }, Track.composite.withAlbumProperty('trackCoverArtistContribs'), { - flags: {expose: true, compose: true}, - expose: { - dependencies: ['#album.trackCoverArtistContribs'], - mapContinuation: {to}, - compute: ({'#album.trackCoverArtistContribs': contribsFromAlbum}, continuation) => - (empty(contribsFromAlbum) - ? continuation.raise({to: false}) - : continuation.raise({to: true})), - }, + dependencies: ['#album.trackCoverArtistContribs'], + mapContinuation: {to}, + compute: ({'#album.trackCoverArtistContribs': contribsFromAlbum}, continuation) => + (empty(contribsFromAlbum) + ? continuation.raise({to: false}) + : continuation.raise({to: true})), }, ]); }, -- cgit 1.3.0-6-gf8a5 From f0a94d03d01220ff44c9c7cf610373781dd4c09d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 19:13:32 -0300 Subject: data: refactor Track.coverArtDate --- src/data/things/track.js | 35 +++++++---------------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index ad001445..ad90dd2c 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -98,36 +98,15 @@ export class Track extends Thing { // the track's own coverArtDate or its album's trackArtDate, so if neither // is specified, this value is null. coverArtDate: Thing.composite.from(`Track.coverArtDate`, [ - Track.composite.withAlbumProperties({ - properties: [ - 'trackArtDate', - 'trackCoverArtistContribsByRef', - ], - }), + Track.composite.withHasUniqueCoverArt(), + Thing.composite.earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), - { - flags: {update: true, expose: true}, + Thing.composite.exposeUpdateValueOrContinue(), + + Track.composite.withAlbumProperty('trackArtDate'), + Thing.composite.exposeDependency('#album.trackArtDate', { update: {validate: isDate}, - expose: { - dependencies: [ - 'coverArtistContribsByRef', - 'disableUniqueCoverArt', - '#album.trackArtDate', - '#album.trackCoverArtistContribsByRef', - ], - - transform(coverArtDate, { - coverArtistContribsByRef, - disableUniqueCoverArt, - '#album.trackArtDate': trackArtDate, - '#album.trackCoverArtistContribsByRef': trackCoverArtistContribsByRef, - }) { - if (disableUniqueCoverArt) return null; - if (empty(coverArtistContribsByRef) && empty(trackCoverArtistContribsByRef)) return null; - return coverArtDate ?? trackArtDate; - }, - }, - } + }), ]), originalReleaseTrackByRef: Thing.common.singleReference(Track), -- cgit 1.3.0-6-gf8a5 From 6f54b1211b5b07fe747ce4ebafdf917ce7851324 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 19:22:54 -0300 Subject: test: Track.coverArtFileExtension (unit) --- src/data/things/track.js | 4 ++- test/unit/data/things/track.js | 60 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index ad90dd2c..0b34de20 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -90,7 +90,9 @@ export class Track extends Thing { Thing.composite.exposeDependencyOrContinue('#album.trackCoverArtFileExtension'), // Fallback to 'jpg'. - Thing.composite.exposeConstant('jpg'), + Thing.composite.exposeConstant('jpg', { + update: {validate: isFileExtension}, + }), ]), // Date of cover art release. Like coverArtFileExtension, this represents diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 97d664ee..16162fb7 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -195,6 +195,66 @@ t.test(`Track.coverArtDate`, t => { `coverArtDate #6: is null if track disables unique cover artwork`); }); +t.test(`Track.coverArtFileExtension`, t => { + t.plan(8); + + const {track, album} = stubTrackAndAlbum(); + const {artist, contribs} = stubArtistAndContribs(); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + trackData: [track], + albumData: [album], + artistData: [artist], + }); + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #1: defaults to null`); + + track.coverArtistContribsByRef = contribs; + + t.equal(track.coverArtFileExtension, 'jpg', + `coverArtFileExtension #2: is jpg if has cover art and not further specified`); + + track.coverArtistContribsByRef = []; + + album.coverArtistContribsByRef = contribs; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #3: only has value for unique cover art`); + + track.coverArtistContribsByRef = contribs; + + album.trackCoverArtFileExtension = 'png'; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, 'png', + `coverArtFileExtension #4: inherits album trackCoverArtFileExtension (1/2)`); + + track.coverArtFileExtension = 'gif'; + + t.equal(track.coverArtFileExtension, 'gif', + `coverArtFileExtension #5: is own value (1/2)`); + + track.coverArtistContribsByRef = []; + + album.trackCoverArtistContribsByRef = contribs; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, 'gif', + `coverArtFileExtension #6: is own value (2/2)`); + + track.coverArtFileExtension = null; + + t.equal(track.coverArtFileExtension, 'png', + `coverArtFileExtension #7: inherits album trackCoverArtFileExtension (2/2)`); + + track.disableUniqueCoverArt = true; + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #8: is null if track disables unique cover art`); +}); + t.test(`Track.date`, t => { t.plan(3); -- cgit 1.3.0-6-gf8a5 From 6325a70991396412eb8e93cee5f17bdb2859ae9d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 19:52:42 -0300 Subject: data, test: update & test misc. Track reverse reference lists * update & test Track.referencedByTracks * update & test Track.sampledByTracks * update & test Track.featuredInFlashes * update Thing.common.reverseReferenceList * add Thing.composite.withReverseReferenceList * add Track.composite.trackReverseReferenceList --- src/data/things/thing.js | 43 +++++++++++---- src/data/things/track.js | 54 +++++++++---------- test/unit/data/things/track.js | 119 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 172 insertions(+), 44 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 15ec62c3..1c99a323 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -330,16 +330,15 @@ export default class Thing extends CacheableObject { // you would use this to compute a corresponding "referenced *by* tracks" // property. Naturally, the passed ref list property is of the things in the // wiki data provided, not the requesting Thing itself. - reverseReferenceList: (thingDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: ['this', thingDataProperty], - - compute: ({this: thing, [thingDataProperty]: thingData}) => - thingData?.filter(t => t[referencerRefListProperty].includes(thing)) ?? [], - }, - }), + reverseReferenceList({ + data, + refList, + }) { + return Thing.composite.from(`Thing.common.reverseReferenceList`, [ + Thing.composite.withReverseReferenceList({data, refList}), + Thing.composite.exposeDependency('#reverseReferenceList'), + ]); + }, // Corresponding function for single references. Note that the return value // is still a list - this is for matching all the objects whose single @@ -1535,5 +1534,29 @@ export default class Thing extends CacheableObject { }, ]); }, + + // Check out the info on Thing.common.reverseReferenceList! + // This is its composable form. + withReverseReferenceList({ + data, + to = '#reverseReferenceList', + refList: refListProperty, + }) { + return Thing.composite.from(`Thing.common.reverseReferenceList`, [ + Thing.composite.earlyExitWithoutDependency(data, {value: []}), + + { + dependencies: ['this'], + mapDependencies: {data}, + mapContinuation: {to}, + options: {refListProperty}, + + compute: ({this: thisThing, data, '#options': {refListProperty}}, continuation) => + continuation({ + to: data.filter(thing => thing[refListProperty].includes(thisThing)), + }), + }, + ]); + }, }; } diff --git a/src/data/things/track.js b/src/data/things/track.js index 0b34de20..bc9affbe 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -278,38 +278,15 @@ export class Track extends Thing { // counting the number of times a track has been referenced, for use in // the "Tracks - by Times Referenced" listing page (or other data // processing). - referencedByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['this', 'trackData'], - - compute: ({this: track, trackData}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.referencedTracks?.includes(track)) - : [], - }, - }, + referencedByTracks: Track.composite.trackReverseReferenceList('referencedTracks'), // For the same reasoning, exclude re-releases from sampled tracks too. - sampledByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['this', 'trackData'], - - compute: ({this: track, trackData}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.sampledTracks?.includes(track)) - : [], - }, - }, + sampledByTracks: Track.composite.trackReverseReferenceList('sampledTracks'), - featuredInFlashes: Thing.common.reverseReferenceList('flashData', 'featuredTracks'), + featuredInFlashes: Thing.common.reverseReferenceList({ + data: 'flashData', + refList: 'featuredTracks', + }), }); static composite = { @@ -575,6 +552,25 @@ export class Track extends Thing { }, ]); }, + + trackReverseReferenceList(refListProperty) { + return Thing.composite.from(`Track.composite.trackReverseReferenceList`, [ + Thing.composite.withReverseReferenceList({ + data: 'trackData', + refList: refListProperty, + originalTracksOnly: true, + }), + + { + flags: {expose: true}, + expose: { + dependencies: ['#reverseReferenceList'], + compute: ({'#reverseReferenceList': reverseReferenceList}) => + reverseReferenceList.filter(track => !track.originalReleaseTrack), + }, + }, + ]); + }, }; [inspect.custom](depth) { diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 16162fb7..8939d964 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -6,6 +6,8 @@ import thingConstructors from '#things'; const { Album, Artist, + Flash, + FlashAct, Thing, Track, } = thingConstructors; @@ -43,6 +45,16 @@ function stubArtistAndContribs() { return {artist, contribs, badContribs}; } +function stubFlashAndAct(directory = 'zam') { + const flash = new Flash(); + flash.directory = directory; + + const flashAct = new FlashAct(); + flashAct.flashesByRef = [Thing.getReference(flash)]; + + return {flash, flashAct}; +} + t.test(`Track.album`, t => { t.plan(6); @@ -155,9 +167,9 @@ t.test(`Track.coverArtDate`, t => { const {artist, contribs} = stubArtistAndContribs(); const {XXX_decacheWikiData} = linkAndBindWikiData({ - trackData: [track], albumData: [album], artistData: [artist], + trackData: [track], }); track.coverArtistContribsByRef = contribs; @@ -202,9 +214,9 @@ t.test(`Track.coverArtFileExtension`, t => { const {artist, contribs} = stubArtistAndContribs(); const {XXX_decacheWikiData} = linkAndBindWikiData({ - trackData: [track], albumData: [album], artistData: [artist], + trackData: [track], }); t.equal(track.coverArtFileExtension, null, @@ -280,6 +292,32 @@ t.test(`Track.date`, t => { `date #3: is own dateFirstReleased`); }); +t.test(`Track.featuredInFlashes`, t => { + t.plan(2); + + const {track, album} = stubTrackAndAlbum('track1'); + + const {flash: flash1, flashAct: flashAct1} = stubFlashAndAct('flash1'); + const {flash: flash2, flashAct: flashAct2} = stubFlashAndAct('flash2'); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + trackData: [track], + flashData: [flash1, flash2], + flashActData: [flashAct1, flashAct2], + }); + + t.same(track.featuredInFlashes, [], + `featuredInFlashes #1: defaults to empty array`); + + flash1.featuredTracksByRef = ['track:track1']; + flash2.featuredTracksByRef = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track.featuredInFlashes, [flash1, flash2], + `featuredInFlashes #2: matches flashes' featuredTracks`); +}); + t.test(`Track.hasUniqueCoverArt`, t => { t.plan(7); @@ -339,8 +377,8 @@ t.only(`Track.originalReleaseTrack`, t => { const {track: track2, album: album2} = stubTrackAndAlbum('track2'); const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ - trackData: [track1, track2], albumData: [album1, album2], + trackData: [track1, track2], }); t.equal(track2.originalReleaseTrack, null, @@ -366,8 +404,8 @@ t.test(`Track.otherReleases`, t => { const {track: track4, album: album4} = stubTrackAndAlbum('track4'); const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ - trackData: [track1, track2, track3, track4], albumData: [album1, album2, album3, album4], + trackData: [track1, track2, track3, track4], }); t.same(track1.otherReleases, [], @@ -376,7 +414,6 @@ t.test(`Track.otherReleases`, t => { track2.originalReleaseTrackByRef = 'track:track1'; track3.originalReleaseTrackByRef = 'track:track1'; track4.originalReleaseTrackByRef = 'track:track1'; - XXX_decacheWikiData(); t.same(track1.otherReleases, [track2, track3, track4], @@ -400,3 +437,75 @@ t.test(`Track.otherReleases`, t => { t.same(track4.otherReleases, [track1, track3, track2], `otherReleases #6: otherReleases of rerelease are original track then other rereleases (1/3)`); }); + +t.test(`Track.referencedByTracks`, t => { + t.plan(4); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + const {track: track3, album: album3} = stubTrackAndAlbum('track3'); + const {track: track4, album: album4} = stubTrackAndAlbum('track4'); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2, album3, album4], + trackData: [track1, track2, track3, track4], + }); + + t.same(track1.referencedByTracks, [], + `referencedByTracks #1: defaults to empty array`); + + track2.referencedTracksByRef = ['track:track1']; + track3.referencedTracksByRef = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2, track3], + `referencedByTracks #2: matches tracks' referencedTracks`); + + track4.sampledTracksByRef = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2, track3], + `referencedByTracks #3: doesn't match tracks' sampledTracks`); + + track3.originalReleaseTrackByRef = 'track:track2'; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2], + `referencedByTracks #4: doesn't include re-releases`); +}); + +t.test(`Track.sampledByTracks`, t => { + t.plan(4); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + const {track: track3, album: album3} = stubTrackAndAlbum('track3'); + const {track: track4, album: album4} = stubTrackAndAlbum('track4'); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2, album3, album4], + trackData: [track1, track2, track3, track4], + }); + + t.same(track1.sampledByTracks, [], + `sampledByTracks #1: defaults to empty array`); + + track2.sampledTracksByRef = ['track:track1']; + track3.sampledTracksByRef = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.sampledByTracks, [track2, track3], + `sampledByTracks #2: matches tracks' sampledTracks`); + + track4.referencedTracksByRef = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.sampledByTracks, [track2, track3], + `sampledByTracks #3: doesn't match tracks' referencedTracks`); + + track3.originalReleaseTrackByRef = 'track:track2'; + XXX_decacheWikiData(); + + t.same(track1.sampledByTracks, [track2], + `sampledByTracks #4: doesn't include re-releases`); +}); -- cgit 1.3.0-6-gf8a5 From 8e783429194f58909f26c7b11d558d5b0a9b163f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 19:59:11 -0300 Subject: data: clean up bad mapDependencies usages --- src/data/things/track.js | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index bc9affbe..bf56a6dd 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -203,25 +203,15 @@ export class Track extends Thing { }), { - mapDependencies: {contribsFromTrack: '#artistContribs'}, - compute: ({contribsFromTrack}, continuation) => + dependencies: ['#artistContribs'], + compute: ({'#artistContribs': contribsFromTrack}, continuation) => (empty(contribsFromTrack) ? continuation() : contribsFromTrack), }, Track.composite.withAlbumProperty('artistContribs'), - - { - flags: {expose: true}, - expose: { - mapDependencies: {contribsFromAlbum: '#album.artistContribs'}, - compute: ({contribsFromAlbum}) => - (empty(contribsFromAlbum) - ? null - : contribsFromAlbum), - }, - }, + Thing.composite.exposeDependency('#album.artistContribs'), ]), contributorContribs: Thing.composite.from(`Track.contributorContribs`, [ @@ -247,8 +237,8 @@ export class Track extends Thing { }), { - mapDependencies: {contribsFromTrack: '#coverArtistContribs'}, - compute: ({contribsFromTrack}, continuation) => + dependencies: ['#coverArtistContribs'], + compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => (empty(contribsFromTrack) ? continuation() : contribsFromTrack), -- cgit 1.3.0-6-gf8a5 From a3b80f08fc54cda6a6787bcd078059823026add6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 31 Aug 2023 20:19:22 -0300 Subject: data: update Thing.composition.from documentation --- src/data/things/thing.js | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 1c99a323..19f5fb53 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -565,6 +565,14 @@ export default class Thing extends CacheableObject { // }, // ]); // + // One last note! A super common code pattern when creating more complex + // compositions is to have several steps which *only* expose and compose. + // As a syntax shortcut, you can skip the outer section. It's basically + // like writing out just the {expose: {...}} part. Remember that this + // indicates that the step you're defining is compositional, so you have + // to specify the flags manually for the base, even if this property isn't + // going to get an {update: true} flag. + // // == Cache-safe dependency names: == // // [Disclosure: The caching engine hasn't actually been implemented yet. @@ -705,7 +713,7 @@ export default class Thing extends CacheableObject { // expanded to the scope of the composition instead of following steps. // // For example, suppose your composition (which you expect to include in - // other compositions) brings about several internal, hash-prefixed + // other compositions) brings about several private, hash-prefixed // dependencies to contribute to its own results. Those dependencies won't // end up "bleeding" into the dependency list of whichever composition is // nesting this one - they will totally disappear once all the steps in @@ -718,6 +726,40 @@ export default class Thing extends CacheableObject { // a hash just like the exports from any other compositional step; they're // still dynamically provided dependencies!) // + // Another way to "export" dependencies is by using calling *any* step's + // `continuation.raise()` function. This is sort of like early exiting, + // but instead of quitting out the whole entire property, it will just + // break out of the current, nested composition's list of steps, acting + // as though the composition had finished naturally. The dependencies + // passed to `raise` will be the ones which get exported. + // + // Since `raise` is another way to export dependencies, if you're using + // dynamic export names, you should specify `mapContinuation` on the step + // calling `continuation.raise` as well. + // + // An important note on `mapDependencies` here: A nested composition gets + // free access to all the ordinary properties defined on the thing it's + // working on, but if you want it to depend on *private* dependencies - + // ones prefixed with '#' - which were provided by some other compositional + // step preceding wherever this one gets nested, then you *have* to use + // `mapDependencies` to gain access. Check out the section on "cache-safe + // dependency names" for information on this syntax! + // + // Also - on rare occasion - you might want to make a reusable composition + // that itself causes the composition *it's* nested in to raise. If that's + // the case, give `composition.raiseAbove()` a go! This effectively means + // kicking out of *two* layers of nested composition - the one including + // the step with the `raiseAbove` call, and the composition which that one + // is nested within. You don't need to use `raiseAbove` if the reusable + // utility function just returns a single compositional step, but if you + // want to make use of other compositional steps, it gives you access to + // the same conditional-raise capabilities. + // + // Have some syntax sugar! Since nested compositions are defined by having + // the base be {compose: true}, the composition will infer as much if you + // don't specifying the base's flags at all. Simply use the same shorthand + // syntax as for other compositional steps, and it'll work out cleanly! + // from(firstArg, secondArg) { const debug = fn => { if (Thing.composite.from.debug === true) { -- cgit 1.3.0-6-gf8a5 From 9d8616ced8f505b499780e859d96f288d67f2154 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 12:13:25 -0300 Subject: data: remove unused Thing.common utilities dynamicInheritContribs is replaced by more specialized behavior on tracks (which are the only thing that inherit contribs this way), and reverseSingleReference, introduced with reverseReferenceList, was never used anywhere. --- src/data/things/thing.js | 68 ------------------------------------------------ 1 file changed, 68 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 19f5fb53..ad27ca55 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -257,60 +257,6 @@ export default class Thing extends CacheableObject { }, }), - // Dynamically inherit a contribution list from some other object, if it - // hasn't been overridden on this object. This is handy for solo albums - // where all tracks have the same artist, for example. - dynamicInheritContribs: ( - // If this property is explicitly false, the contribution list returned - // will always be empty. - nullerProperty, - - // Property holding contributions on the current object. - contribsByRefProperty, - - // Property holding corresponding "default" contributions on the parent - // object, which will fallen back to if the object doesn't have its own - // contribs. - parentContribsByRefProperty, - - // Data array to search in and "find" function to locate parent object - // (which will be passed the child object and the wiki data array). - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - expose: { - dependencies: [ - 'this', - contribsByRefProperty, - thingDataProperty, - nullerProperty, - 'artistData', - ].filter(Boolean), - - compute({ - this: thing, - [nullerProperty]: nuller, - [contribsByRefProperty]: contribsByRef, - [thingDataProperty]: thingData, - artistData, - }) { - if (!artistData) return []; - if (nuller === false) return []; - const refs = - contribsByRef ?? - findFn(thing, thingData, {mode: 'quiet'})?.[parentContribsByRefProperty]; - if (!refs) return []; - return refs - .map(({who: ref, what}) => ({ - who: find.artist(ref, artistData), - what, - })) - .filter(({who}) => who); - }, - }, - }), - // Nice 'n simple shorthand for an exposed-only flag which is true when any // contributions are present in the specified property. contribsPresent: (contribsByRefProperty) => ({ @@ -340,20 +286,6 @@ export default class Thing extends CacheableObject { ]); }, - // Corresponding function for single references. Note that the return value - // is still a list - this is for matching all the objects whose single - // reference (in the given property) matches this Thing. - reverseSingleReference: (thingDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: ['this', thingDataProperty], - - compute: ({this: thing, [thingDataProperty]: thingData}) => - thingData?.filter((t) => t[referencerRefListProperty] === thing) ?? [], - }, - }), - // General purpose wiki data constructor, for properties like artistData, // trackData, etc. wikiData: (thingClass) => ({ -- cgit 1.3.0-6-gf8a5 From 703f065560e71ec7f750ea8a9dfdff2c71e0fde8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 12:27:50 -0300 Subject: data: move Thing.composite definition into dedicated file --- src/data/things/composite.js | 1179 ++++++++++++++++++++++++++++++++++++++++++ src/data/things/thing.js | 1176 +---------------------------------------- 2 files changed, 1181 insertions(+), 1174 deletions(-) create mode 100644 src/data/things/composite.js diff --git a/src/data/things/composite.js b/src/data/things/composite.js new file mode 100644 index 00000000..1be60cd1 --- /dev/null +++ b/src/data/things/composite.js @@ -0,0 +1,1179 @@ +// Composes multiple compositional "steps" and a "base" to form a property +// descriptor out of modular building blocks. This is an extension to the +// more general-purpose CacheableObject property descriptor syntax, and +// aims to make modular data processing - which lends to declarativity - +// much easier, without fundamentally altering much of the typical syntax +// or terminology, nor building on it to an excessive degree. +// +// Think of a composition as being a chain of steps which lead into a final +// base property, which is usually responsible for returning the value that +// will actually get exposed when the property being described is accessed. +// +// == The compositional base: == +// +// The final item in a compositional list is its base, and it identifies +// the essential qualities of the property descriptor. The compositional +// steps preceding it may exit early, in which case the expose function +// defined on the base won't be called; or they will provide dependencies +// that the base may use to compute the final value that gets exposed for +// this property. +// +// The base indicates the capabilities of the composition as a whole. +// It should be {expose: true}, since that's the only area that preceding +// compositional steps (currently) can actually influence. If it's also +// {update: true}, then the composition as a whole accepts an update value +// just like normal update-flag property descriptors - meaning it can be +// set with `thing.someProperty = value` and that value will be paseed +// into each (implementing) step's transform() function, as well as the +// base. Bases usually aren't {compose: true}, but can be - check out the +// section on "nesting compositions" for details about that. +// +// Every composition always has exactly one compositional base, and it's +// always the last item in the composition list. All items preceding it +// are compositional steps, described below. +// +// == Compositional steps: == +// +// Compositional steps are, in essence, typical property descriptors with +// the extra flag {compose: true}. They operate on existing dependencies, +// and are typically dynamically constructed by "utility" functions (but +// can also be manually declared within the step list of a composition). +// Compositional steps serve two purposes: +// +// 1. exit early, if some condition is matched, returning and exposing +// some value directly from that step instead of continuing further +// down the step list; +// +// 2. and/or provide new, dynamically created "private" dependencies which +// can be accessed by further steps down the list, or at the base at +// the bottom, modularly supplying information that will contribute to +// the final value exposed for this property. +// +// Usually it's just one of those two, but it's fine for a step to perform +// both jobs if the situation benefits. +// +// Compositional steps are the real "modular" or "compositional" part of +// this data processing style - they're designed to be combined together +// in dynamic, versatile ways, as each property demands it. You usually +// define a compositional step to be returned by some ordinary static +// property-descriptor-returning function (customarily namespaced under +// the relevant Thing class's static `composite` field) - that lets you +// reuse it in multiple compositions later on. +// +// Compositional steps are implemented with "continuation passing style", +// meaning the connection to the next link on the chain is passed right to +// each step's compute (or transform) function, and the implementation gets +// to decide whether to continue on that chain or exit early by returning +// some other value. +// +// Every step along the chain, apart from the base at the bottom, has to +// have the {compose: true} step. That means its compute() or transform() +// function will be passed an extra argument at the end, `continuation`. +// To provide new dependencies to items further down the chain, just pass +// them directly to this continuation() function, customarily with a hash +// ('#') prefixing each name - for example: +// +// compute({..some dependencies..}, continuation) { +// return continuation({ +// '#excitingProperty': (..a value made from dependencies..), +// }); +// } +// +// Performing an early exit is as simple as returning some other value, +// instead of the continuation. You may also use `continuation.exit(value)` +// to perform the exact same kind of early exit - it's just a different +// syntax that might fit in better in certain longer compositions. +// +// It may be fine to simply provide new dependencies under a hard-coded +// name, such as '#excitingProperty' above, but if you're writing a utility +// that dynamically returns the compositional step and you suspect you +// might want to use this step multiple times in a single composition, +// it's customary to accept a name for the result. +// +// Here's a detailed example showing off early exit, dynamically operating +// on a provided dependency name, and then providing a result in another +// also-provided dependency name: +// +// static Thing.composite.withResolvedContribs = ({ +// from: contribsByRefDependency, +// to: outputDependency, +// }) => ({ +// flags: {expose: true, compose: true}, +// expose: { +// dependencies: [contribsByRefDependency, 'artistData'], +// compute({ +// [contribsByRefDependency]: contribsByRef, +// artistData, +// }, continuation) { +// if (!artistData) return null; /* early exit! */ +// return continuation({ +// [outputDependency]: /* this is the important part */ +// (..resolve contributions one way or another..), +// }); +// }, +// }, +// }); +// +// And how you might work that into a composition: +// +// static Track[Thing.getPropertyDescriptors].coverArtists = +// Thing.composite.from([ +// Track.composite.doSomethingWhichMightEarlyExit(), +// Thing.composite.withResolvedContribs({ +// from: 'coverArtistContribsByRef', +// to: '#coverArtistContribs', +// }), +// +// { +// flags: {expose: true}, +// expose: { +// dependencies: ['#coverArtistContribs'], +// compute({'#coverArtistContribs': coverArtistContribs}) { +// return coverArtistContribs.map(({who}) => who); +// }, +// }, +// }, +// ]); +// +// One last note! A super common code pattern when creating more complex +// compositions is to have several steps which *only* expose and compose. +// As a syntax shortcut, you can skip the outer section. It's basically +// like writing out just the {expose: {...}} part. Remember that this +// indicates that the step you're defining is compositional, so you have +// to specify the flags manually for the base, even if this property isn't +// going to get an {update: true} flag. +// +// == Cache-safe dependency names: == +// +// [Disclosure: The caching engine hasn't actually been implemented yet. +// As such, this section is subject to change, and simply provides sound +// forward-facing advice and interfaces.] +// +// It's a good idea to write individual compositional steps in such a way +// that they're "cache-safe" - meaning the same input (dependency) values +// will always result in the same output (continuation or early exit). +// +// In order to facilitate this, compositional step descriptors may specify +// unique `mapDependencies`, `mapContinuation`, and `options` values. +// +// Consider the `withResolvedContribs` example adjusted to make use of +// two of these options below: +// +// static Thing.composite.withResolvedContribs = ({ +// from: contribsByRefDependency, +// to: outputDependency, +// }) => ({ +// flags: {expose: true, compose: true}, +// expose: { +// dependencies: ['artistData'], +// mapDependencies: {contribsByRef: contribsByRefDependency}, +// mapContinuation: {outputDependency}, +// compute({ +// contribsByRef, /* no longer in square brackets */ +// artistData, +// }, continuation) { +// if (!artistData) return null; +// return continuation({ +// outputDependency: /* no longer in square brackets */ +// (..resolve contributions one way or another..), +// }); +// }, +// }, +// }); +// +// With a little destructuring and restructuring JavaScript sugar, the +// above can be simplified some more: +// +// static Thing.composite.withResolvedContribs = ({from, to}) => ({ +// flags: {expose: true, compose: true}, +// expose: { +// dependencies: ['artistData'], +// mapDependencies: {from}, +// mapContinuation: {to}, +// compute({artistData, from: contribsByRef}, continuation) { +// if (!artistData) return null; +// return continuation({ +// to: (..resolve contributions one way or another..), +// }); +// }, +// }, +// }); +// +// These two properties let you separate the name-mapping behavior (for +// dependencies and the continuation) from the main body of the compute +// function. That means the compute function will *always* get inputs in +// the same form (dependencies 'artistData' and 'from' above), and will +// *always* provide its output in the same form (early return or 'to'). +// +// Thanks to that, this `compute` function is cache-safe! Its outputs can +// be cached corresponding to each set of mapped inputs. So it won't matter +// whether the `from` dependency is named `coverArtistContribsByRef` or +// `contributorContribsByRef` or something else - the compute function +// doesn't care, and only expects that value to be provided via its `from` +// argument. Likewise, it doesn't matter if the output should be sent to +// '#coverArtistContribs` or `#contributorContribs` or some other name; +// the mapping is handled automatically outside, and compute will always +// output its value to the continuation's `to`. +// +// Note that `mapDependencies` and `mapContinuation` should be objects of +// the same "shape" each run - that is, the values will change depending on +// outside context, but the keys are always the same. You shouldn't use +// `mapDependencies` to dynamically select more or fewer dependencies. +// If you need to dynamically select a range of dependencies, just specify +// them in the `dependencies` array like usual. The caching engine will +// understand that differently named `dependencies` indicate separate +// input-output caches should be used. +// +// The 'options' property makes it possible to specify external arguments +// that fundamentally change the behavior of the `compute` function, while +// still remaining cache-safe. It indicates that the caching engine should +// use a completely different input-to-output cache for each permutation +// of the 'options' values. This way, those functions are still cacheable +// at all; they'll just be cached separately for each set of option values. +// Values on the 'options' property will always be provided in compute's +// dependencies under '#options' (to avoid name conflicts with other +// dependencies). +// +// == To compute or to transform: == +// +// A compositional step can work directly on a property's stored update +// value, transforming it in place and either early exiting with it or +// passing it on (via continuation) to the next item(s) in the +// compositional step list. (If needed, these can provide dependencies +// the same way as compute functions too - just pass that object after +// the updated (or same) transform value in your call to continuation().) +// +// But in order to make them more versatile, compositional steps have an +// extra trick up their sleeve. If a compositional step implements compute +// and *not* transform, it can still be used in a composition targeting a +// property which updates! These retain their full dependency-providing and +// early exit functionality - they just won't be provided the update value. +// If a compute-implementing step returns its continuation, then whichever +// later step (or the base) next implements transform() will receive the +// update value that had so far been running - as well as any dependencies +// the compute() step returned, of course! +// +// Please note that a compositional step which transforms *should not* +// specify, in its flags, {update: true}. Just provide the transform() +// function in its expose descriptor; it will be automatically detected +// and used when appropriate. +// +// It's actually possible for a step to specify both transform and compute, +// in which case the transform() implementation will only be selected if +// the composition's base is {update: true}. It's not exactly known why you +// would want to specify unique-but-related transform and compute behavior, +// but the basic possibility was too cool to skip out on. +// +// == Nesting compositions: == +// +// Compositional steps are so convenient that you just might want to bundle +// them together, and form a whole new step-shaped unit of its own! +// +// In order to allow for this while helping to ensure internal dependencies +// remain neatly isolated from the composition which nests your bundle, +// the Thing.composite.from() function will accept and adapt to a base that +// specifies the {compose: true} flag, just like the steps preceding it. +// +// The continuation function that gets provided to the base will be mildly +// special - after all, nothing follows the base within the composition's +// own list! Instead of appending dependencies alongside any previously +// provided ones to be available to the next step, the base's continuation +// function should be used to define "exports" of the composition as a +// whole. It's similar to the usual behavior of the continuation, just +// expanded to the scope of the composition instead of following steps. +// +// For example, suppose your composition (which you expect to include in +// other compositions) brings about several private, hash-prefixed +// dependencies to contribute to its own results. Those dependencies won't +// end up "bleeding" into the dependency list of whichever composition is +// nesting this one - they will totally disappear once all the steps in +// the nested composition have finished up. +// +// To "export" the results of processing all those dependencies (provided +// that's something you want to do and this composition isn't used purely +// for a conditional early-exit), you'll want to define them in the +// continuation passed to the base. (Customarily, those should start with +// a hash just like the exports from any other compositional step; they're +// still dynamically provided dependencies!) +// +// Another way to "export" dependencies is by using calling *any* step's +// `continuation.raise()` function. This is sort of like early exiting, +// but instead of quitting out the whole entire property, it will just +// break out of the current, nested composition's list of steps, acting +// as though the composition had finished naturally. The dependencies +// passed to `raise` will be the ones which get exported. +// +// Since `raise` is another way to export dependencies, if you're using +// dynamic export names, you should specify `mapContinuation` on the step +// calling `continuation.raise` as well. +// +// An important note on `mapDependencies` here: A nested composition gets +// free access to all the ordinary properties defined on the thing it's +// working on, but if you want it to depend on *private* dependencies - +// ones prefixed with '#' - which were provided by some other compositional +// step preceding wherever this one gets nested, then you *have* to use +// `mapDependencies` to gain access. Check out the section on "cache-safe +// dependency names" for information on this syntax! +// +// Also - on rare occasion - you might want to make a reusable composition +// that itself causes the composition *it's* nested in to raise. If that's +// the case, give `composition.raiseAbove()` a go! This effectively means +// kicking out of *two* layers of nested composition - the one including +// the step with the `raiseAbove` call, and the composition which that one +// is nested within. You don't need to use `raiseAbove` if the reusable +// utility function just returns a single compositional step, but if you +// want to make use of other compositional steps, it gives you access to +// the same conditional-raise capabilities. +// +// Have some syntax sugar! Since nested compositions are defined by having +// the base be {compose: true}, the composition will infer as much if you +// don't specifying the base's flags at all. Simply use the same shorthand +// syntax as for other compositional steps, and it'll work out cleanly! +// + +import {empty, filterProperties, openAggregate} from '#sugar'; + +import Thing from './thing.js'; + +export {compositeFrom as from}; +function compositeFrom(firstArg, secondArg) { + const debug = fn => { + if (compositeFrom.debug === true) { + const label = + (annotation + ? color.dim(`[composite: ${annotation}]`) + : color.dim(`[composite]`)); + const result = fn(); + if (Array.isArray(result)) { + console.log(label, ...result.map(value => + (typeof value === 'object' + ? inspect(value, {depth: 0, colors: true, compact: true, breakLength: Infinity}) + : value))); + } else { + console.log(label, result); + } + } + }; + + let annotation, composition; + if (typeof firstArg === 'string') { + [annotation, composition] = [firstArg, secondArg]; + } else { + [annotation, composition] = [null, firstArg]; + } + + const base = composition.at(-1); + const steps = composition.slice(); + + const aggregate = openAggregate({ + message: + `Errors preparing Thing.composite.from() composition` + + (annotation ? ` (${annotation})` : ''), + }); + + const baseExposes = + (base.flags + ? base.flags.expose + : true); + + const baseUpdates = + (base.flags + ? base.flags.update + : false); + + const baseComposes = + (base.flags + ? base.flags.compose + : true); + + if (!baseExposes) { + aggregate.push(new TypeError(`All steps, including base, must expose`)); + } + + const exposeDependencies = new Set(); + + let anyStepsCompute = false; + let anyStepsTransform = false; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; + const message = + `Errors in step #${i + 1}` + + (isBase ? ` (base)` : ``) + + (step.annotation ? ` (${step.annotation})` : ``); + + aggregate.nest({message}, ({push}) => { + if (step.flags) { + let flagsErrored = false; + + if (!step.flags.compose && !isBase) { + push(new TypeError(`All steps but base must compose`)); + flagsErrored = true; + } + + if (!step.flags.expose) { + push(new TypeError(`All steps must expose`)); + flagsErrored = true; + } + + if (flagsErrored) { + return; + } + } + + const expose = + (step.flags + ? step.expose + : step); + + const stepComputes = !!expose.compute; + const stepTransforms = !!expose.transform; + + if (!stepComputes && !stepTransforms) { + push(new TypeError(`Steps must provide compute or transform (or both)`)); + return; + } + + if ( + stepTransforms && !stepComputes && + !baseUpdates && !baseComposes + ) { + push(new TypeError(`Steps which only transform can't be composed with a non-updating base`)); + return; + } + + if (stepComputes) { + anyStepsCompute = true; + } + + if (stepTransforms) { + anyStepsTransform = true; + } + + // Unmapped dependencies are exposed on the final composition only if + // they're "public", i.e. pointing to update values of other properties + // on the CacheableObject. + for (const dependency of expose.dependencies ?? []) { + if (typeof dependency === 'string' && dependency.startsWith('#')) { + continue; + } + + exposeDependencies.add(dependency); + } + + // Mapped dependencies are always exposed on the final composition. + // These are explicitly for reading values which are named outside of + // the current compositional step. + for (const dependency of Object.values(expose.mapDependencies ?? {})) { + exposeDependencies.add(dependency); + } + }); + } + + if (!baseComposes) { + if (baseUpdates) { + if (!anyStepsTransform) { + push(new TypeError(`Expected at least one step to transform`)); + } + } else { + if (!anyStepsCompute) { + push(new TypeError(`Expected at least one step to compute`)); + } + } + } + + aggregate.close(); + + const constructedDescriptor = {}; + + if (annotation) { + constructedDescriptor.annotation = annotation; + } + + constructedDescriptor.flags = { + update: baseUpdates, + expose: baseExposes, + compose: baseComposes, + }; + + if (baseUpdates) { + constructedDescriptor.update = base.update; + } + + if (baseExposes) { + const expose = constructedDescriptor.expose = {}; + expose.dependencies = Array.from(exposeDependencies); + + const continuationSymbol = Symbol('continuation symbol'); + const noTransformSymbol = Symbol('no-transform symbol'); + + function _filterDependencies(availableDependencies, { + dependencies, + mapDependencies, + options, + }) { + const filteredDependencies = + (dependencies + ? filterProperties(availableDependencies, dependencies) + : {}); + + if (mapDependencies) { + for (const [to, from] of Object.entries(mapDependencies)) { + filteredDependencies[to] = availableDependencies[from] ?? null; + } + } + + if (options) { + filteredDependencies['#options'] = options; + } + + return filteredDependencies; + } + + function _assignDependencies(continuationAssignment, {mapContinuation}) { + if (!mapContinuation) { + return continuationAssignment; + } + + const assignDependencies = {}; + + for (const [from, to] of Object.entries(mapContinuation)) { + assignDependencies[to] = continuationAssignment[from] ?? null; + } + + return assignDependencies; + } + + function _prepareContinuation(callingTransformForThisStep) { + const continuationStorage = { + returnedWith: null, + providedDependencies: undefined, + providedValue: undefined, + }; + + const continuation = + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.exit = (providedValue) => { + continuationStorage.returnedWith = 'exit'; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + }; + + if (baseComposes) { + const makeRaiseLike = returnWith => + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.raise = makeRaiseLike('raise'); + continuation.raiseAbove = makeRaiseLike('raiseAbove'); + } + + return {continuation, continuationStorage}; + } + + function _computeOrTransform(initialValue, initialDependencies, continuationIfApplicable) { + const expectingTransform = initialValue !== noTransformSymbol; + + let valueSoFar = + (expectingTransform + ? initialValue + : undefined); + + const availableDependencies = {...initialDependencies}; + + if (expectingTransform) { + debug(() => [color.bright(`begin composition - transforming from:`), initialValue]); + } else { + debug(() => color.bright(`begin composition - not transforming`)); + } + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; + + debug(() => [ + `step #${i+1}` + + (isBase + ? ` (base):` + : ` of ${steps.length}:`), + step]); + + const expose = + (step.flags + ? step.expose + : step); + + const callingTransformForThisStep = + expectingTransform && expose.transform; + + const filteredDependencies = _filterDependencies(availableDependencies, expose); + const {continuation, continuationStorage} = _prepareContinuation(callingTransformForThisStep); + + debug(() => [ + `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, + `with dependencies:`, filteredDependencies]); + + const result = + (callingTransformForThisStep + ? expose.transform(valueSoFar, filteredDependencies, continuation) + : expose.compute(filteredDependencies, continuation)); + + if (result !== continuationSymbol) { + debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); + + if (baseComposes) { + throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); + } + + debug(() => color.bright(`end composition - exit (inferred)`)); + + return result; + } + + const {returnedWith} = continuationStorage; + + if (returnedWith === 'exit') { + const {providedValue} = continuationStorage; + + debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); + debug(() => color.bright(`end composition - exit (explicit)`)); + + if (baseComposes) { + return continuationIfApplicable.exit(providedValue); + } else { + return providedValue; + } + } + + const {providedValue, providedDependencies} = continuationStorage; + + const continuingWithValue = + (expectingTransform + ? (callingTransformForThisStep + ? providedValue ?? null + : valueSoFar ?? null) + : undefined); + + const continuingWithDependencies = + (providedDependencies + ? _assignDependencies(providedDependencies, expose) + : null); + + const continuationArgs = []; + if (continuingWithValue !== undefined) continuationArgs.push(continuingWithValue); + if (continuingWithDependencies !== null) continuationArgs.push(continuingWithDependencies); + + debug(() => { + const base = `step #${i+1} - result: ` + returnedWith; + const parts = []; + + if (callingTransformForThisStep) { + if (continuingWithValue === undefined) { + parts.push(`(no value)`); + } else { + parts.push(`value:`, providedValue); + } + } + + if (continuingWithDependencies !== null) { + parts.push(`deps:`, continuingWithDependencies); + } else { + parts.push(`(no deps)`); + } + + if (empty(parts)) { + return base; + } else { + return [base + ' ->', ...parts]; + } + }); + + switch (returnedWith) { + case 'raise': + debug(() => + (isBase + ? color.bright(`end composition - raise (base: explicit)`) + : color.bright(`end composition - raise`))); + return continuationIfApplicable(...continuationArgs); + + case 'raiseAbove': + debug(() => color.bright(`end composition - raiseAbove`)); + return continuationIfApplicable.raise(...continuationArgs); + + case 'continuation': + if (isBase) { + debug(() => color.bright(`end composition - raise (inferred)`)); + return continuationIfApplicable(...continuationArgs); + } else { + Object.assign(availableDependencies, continuingWithDependencies); + break; + } + } + } + } + + const transformFn = + (value, initialDependencies, continuationIfApplicable) => + _computeOrTransform(value, initialDependencies, continuationIfApplicable); + + const computeFn = + (initialDependencies, continuationIfApplicable) => + _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable); + + if (baseComposes) { + if (anyStepsTransform) expose.transform = transformFn; + if (anyStepsCompute) expose.compute = computeFn; + } else if (baseUpdates) { + expose.transform = transformFn; + } else { + expose.compute = computeFn; + } + } + + return constructedDescriptor; +} + +// Evaluates a function with composite debugging enabled, turns debugging +// off again, and returns the result of the function. This is mostly syntax +// sugar, but also helps avoid unit tests avoid accidentally printing debug +// info for a bunch of unrelated composites (due to property enumeration +// when displaying an unexpected result). Use as so: +// +// Without debugging: +// t.same(thing.someProp, value) +// +// With debugging: +// t.same(Thing.composite.debug(() => thing.someProp), value) +// +export function debug(fn) { + compositeFrom.debug = true; + const value = fn(); + compositeFrom.debug = false; + return value; +} + +// -- Compositional steps for compositions to nest -- + +// Provides dependencies exactly as they are (or null if not defined) to the +// continuation. Although this can *technically* be used to alias existing +// dependencies to some other name within the middle of a composition, it's +// intended to be used only as a composition's base - doing so makes the +// composition as a whole suitable as a step in some other composition, +// providing the listed (internal) dependencies to later steps just like +// other compositional steps. +export {_export as export}; +function _export(mapping) { + const mappingEntries = Object.entries(mapping); + + return { + annotation: `Thing.composite.export`, + flags: {expose: true, compose: true}, + + expose: { + options: {mappingEntries}, + dependencies: Object.values(mapping), + + compute({'#options': {mappingEntries}, ...dependencies}, continuation) { + const exports = {}; + + // Note: This is slightly different behavior from filterProperties, + // as defined in sugar.js, which doesn't fall back to null for + // properties which don't exist on the original object. + for (const [exportKey, dependencyKey] of mappingEntries) { + exports[exportKey] = + (Object.hasOwn(dependencies, dependencyKey) + ? dependencies[dependencyKey] + : null); + } + + return continuation.raise(exports); + } + }, + }; +} + +// -- Compositional steps for top-level property descriptors -- + +// Exposes a dependency exactly as it is; this is typically the base of a +// composition which was created to serve as one property's descriptor. +// Since this serves as a base, specify a value for {update} to indicate +// that the property as a whole updates (and some previous compositional +// step works with that update value). Set {update: true} to only enable +// the update flag, or set update to an object to specify a descriptor +// (e.g. for custom value validation). +// +// Please note that this *doesn't* verify that the dependency exists, so +// if you provide the wrong name or it hasn't been set by a previous +// compositional step, the property will be exposed as undefined instead +// of null. +// +export function exposeDependency(dependency, { + update = false, +} = {}) { + return { + annotation: `Thing.composite.exposeDependency`, + flags: {expose: true, update: !!update}, + + expose: { + mapDependencies: {dependency}, + compute: ({dependency}) => dependency, + }, + + update: + (typeof update === 'object' + ? update + : null), + }; +} + +// Exposes a constant value exactly as it is; like exposeDependency, this +// is typically the base of a composition serving as a particular property +// descriptor. It generally follows steps which will conditionally early +// exit with some other value, with the exposeConstant base serving as the +// fallback default value. Like exposeDependency, set {update} to true or +// an object to indicate that the property as a whole updates. +export function exposeConstant(value, { + update = false, +} = {}) { + return { + annotation: `Thing.composite.exposeConstant`, + flags: {expose: true, update: !!update}, + + expose: { + options: {value}, + compute: ({'#options': {value}}) => value, + }, + + update: + (typeof update === 'object' + ? update + : null), + }; +} + +// Checks the availability of a dependency or the update value and provides +// the result to later steps under '#availability' (by default). This is +// mainly intended for use by the more specific utilities, which you should +// consider using instead. Customize {mode} to select one of these modes, +// or leave unset and default to 'null': +// +// * 'null': Check that the value isn't null. +// * 'empty': Check that the value is neither null nor an empty array. +// * 'falsy': Check that the value isn't false when treated as a boolean +// (nor an empty array). Keep in mind this will also be false +// for values like zero and the empty string! +// +export function withResultOfAvailabilityCheck({ + fromUpdateValue, + fromDependency, + mode = 'null', + to = '#availability', +}) { + if (!['null', 'empty', 'falsy'].includes(mode)) { + throw new TypeError(`Expected mode to be null, empty, or falsy`); + } + + if (fromUpdateValue && fromDependency) { + throw new TypeError(`Don't provide both fromUpdateValue and fromDependency`); + } + + if (!fromUpdateValue && !fromDependency) { + throw new TypeError(`Missing dependency name (or fromUpdateValue)`); + } + + const checkAvailability = (value, mode) => { + switch (mode) { + case 'null': return value !== null; + case 'empty': return !empty(value); + case 'falsy': return !!value && (!Array.isArray(value) || !empty(value)); + default: return false; + } + }; + + if (fromDependency) { + return { + annotation: `Thing.composite.withResultOfAvailabilityCheck.fromDependency`, + flags: {expose: true, compose: true}, + expose: { + mapDependencies: {from: fromDependency}, + mapContinuation: {to}, + options: {mode}, + compute: ({from, '#options': {mode}}, continuation) => + continuation({to: checkAvailability(from, mode)}), + }, + }; + } else { + return { + annotation: `Thing.composite.withResultOfAvailabilityCheck.fromUpdateValue`, + flags: {expose: true, compose: true}, + expose: { + mapContinuation: {to}, + options: {mode}, + transform: (value, {'#options': {mode}}, continuation) => + continuation(value, {to: checkAvailability(value, mode)}), + }, + }; + } +} + +// Exposes a dependency as it is, or continues if it's unavailable. +// See withResultOfAvailabilityCheck for {mode} options! +export function exposeDependencyOrContinue(dependency, { + mode = 'null', +} = {}) { + return compositeFrom(`Thing.composite.exposeDependencyOrContinue`, [ + withResultOfAvailabilityCheck({ + fromDependency: dependency, + mode, + }), + + { + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation() + : continuation.raise()), + }, + + { + mapDependencies: {dependency}, + compute: ({dependency}, continuation) => + continuation.exit(dependency), + }, + ]); +} + +// Exposes the update value of an {update: true} property as it is, +// or continues if it's unavailable. See withResultOfAvailabilityCheck +// for {mode} options! +export function exposeUpdateValueOrContinue({ + mode = 'null', +} = {}) { + return compositeFrom(`Thing.composite.exposeUpdateValueOrContinue`, [ + withResultOfAvailabilityCheck({ + fromUpdateValue: true, + mode, + }), + + { + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation() + : continuation.raise()), + }, + + { + transform: (value, {}, continuation) => + continuation.exit(value), + }, + ]); +} + +// Early exits if an availability check has failed. +// This is for internal use only - use `earlyExitWithoutDependency` or +// `earlyExitWIthoutUpdateValue` instead. +export function earlyExitIfAvailabilityCheckFailed({ + availability = '#availability', + value = null, +} = {}) { + return compositeFrom(`Thing.composite.earlyExitIfAvailabilityCheckFailed`, [ + { + mapDependencies: {availability}, + compute: ({availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), + }, + + { + options: {value}, + compute: ({'#options': {value}}, continuation) => + continuation.exit(value), + }, + ]); +} + +// Early exits if a dependency isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export function earlyExitWithoutDependency(dependency, { + mode = 'null', + value = null, +} = {}) { + return compositeFrom(`Thing.composite.earlyExitWithoutDependency`, [ + withResultOfAvailabilityCheck({fromDependency: dependency, mode}), + earlyExitIfAvailabilityCheckFailed({value}), + ]); +} + +// Early exits if this property's update value isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export function earlyExitWithoutUpdateValue({ + mode = 'null', + value = null, +} = {}) { + return compositeFrom(`Thing.composite.earlyExitWithoutDependency`, [ + withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), + earlyExitIfAvailabilityCheckFailed({value}), + ]); +} + +// Raises if a dependency isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export function raiseWithoutDependency(dependency, { + mode = 'null', + map = {}, + raise = {}, +} = {}) { + return compositeFrom(`Thing.composite.raiseWithoutDependency`, [ + withResultOfAvailabilityCheck({fromDependency: dependency, mode}), + + { + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), + }, + + { + options: {raise}, + mapContinuation: map, + compute: ({'#options': {raise}}, continuation) => + continuation.raiseAbove(raise), + }, + ]); +} + +// Raises if this property's update value isn't available. +// See withResultOfAvailabilityCheck for {mode} options! +export function raiseWithoutUpdateValue({ + mode = 'null', + map = {}, + raise = {}, +} = {}) { + return compositeFrom(`Thing.composite.raiseWithoutUpdateValue`, [ + withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), + + { + mapDependencies: {availability}, + compute: ({availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), + }, + + { + options: {raise}, + mapContinuation: map, + compute: ({'#options': {raise}}, continuation) => + continuation.raiseAbove(raise), + }, + ]); +} + +// -- Compositional steps for processing data -- + +// Resolves the contribsByRef contained in the provided dependency, +// providing (named by the second argument) the result. "Resolving" +// means mapping the "who" reference of each contribution to an artist +// object, and filtering out those whose "who" doesn't match any artist. +export function withResolvedContribs({from, to}) { + return { + annotation: `Thing.composite.withResolvedContribs`, + flags: {expose: true, compose: true}, + + expose: { + dependencies: ['artistData'], + mapDependencies: {from}, + mapContinuation: {to}, + compute: ({artistData, from}, continuation) => + continuation({ + to: Thing.findArtistsFromContribs(from, artistData), + }), + }, + }; +} + +// Resolves a reference by using the provided find function to match it +// within the provided thingData dependency. This will early exit if the +// data dependency is null, or, if earlyExitIfNotFound is set to true, +// if the find function doesn't match anything for the reference. +// Otherwise, the data object is provided on the output dependency; +// or null, if the reference doesn't match anything or itself was null +// to begin with. +export function withResolvedReference({ + ref, + data, + to, + find: findFunction, + earlyExitIfNotFound = false, +}) { + return compositeFrom(`Thing.composite.withResolvedReference`, [ + raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), + earlyExitWithoutDependency(data), + + { + options: {findFunction, earlyExitIfNotFound}, + mapDependencies: {ref, data}, + mapContinuation: {match: to}, + + compute({ref, data, '#options': {findFunction, earlyExitIfNotFound}}, continuation) { + const match = findFunction(ref, data, {mode: 'quiet'}); + + if (match === null && earlyExitIfNotFound) { + return continuation.exit(null); + } + + return continuation.raise({match}); + }, + }, + ]); +} + +// Check out the info on Thing.common.reverseReferenceList! +// This is its composable form. +export function withReverseReferenceList({ + data, + to = '#reverseReferenceList', + refList: refListProperty, +}) { + return compositeFrom(`Thing.common.reverseReferenceList`, [ + earlyExitWithoutDependency(data, {value: []}), + + { + dependencies: ['this'], + mapDependencies: {data}, + mapContinuation: {to}, + options: {refListProperty}, + + compute: ({this: thisThing, data, '#options': {refListProperty}}, continuation) => + continuation({ + to: data.filter(thing => thing[refListProperty].includes(thisThing)), + }), + }, + ]); +} diff --git a/src/data/things/thing.js b/src/data/things/thing.js index ad27ca55..01aa8b1b 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -27,6 +27,7 @@ import { } from '#validators'; import CacheableObject from './cacheable-object.js'; +import * as composite from './composite.js'; export default class Thing extends CacheableObject { static referenceType = Symbol('Thing.referenceType'); @@ -359,1178 +360,5 @@ export default class Thing extends CacheableObject { .filter(({who}) => who)); } - static composite = { - // Composes multiple compositional "steps" and a "base" to form a property - // descriptor out of modular building blocks. This is an extension to the - // more general-purpose CacheableObject property descriptor syntax, and - // aims to make modular data processing - which lends to declarativity - - // much easier, without fundamentally altering much of the typical syntax - // or terminology, nor building on it to an excessive degree. - // - // Think of a composition as being a chain of steps which lead into a final - // base property, which is usually responsible for returning the value that - // will actually get exposed when the property being described is accessed. - // - // == The compositional base: == - // - // The final item in a compositional list is its base, and it identifies - // the essential qualities of the property descriptor. The compositional - // steps preceding it may exit early, in which case the expose function - // defined on the base won't be called; or they will provide dependencies - // that the base may use to compute the final value that gets exposed for - // this property. - // - // The base indicates the capabilities of the composition as a whole. - // It should be {expose: true}, since that's the only area that preceding - // compositional steps (currently) can actually influence. If it's also - // {update: true}, then the composition as a whole accepts an update value - // just like normal update-flag property descriptors - meaning it can be - // set with `thing.someProperty = value` and that value will be paseed - // into each (implementing) step's transform() function, as well as the - // base. Bases usually aren't {compose: true}, but can be - check out the - // section on "nesting compositions" for details about that. - // - // Every composition always has exactly one compositional base, and it's - // always the last item in the composition list. All items preceding it - // are compositional steps, described below. - // - // == Compositional steps: == - // - // Compositional steps are, in essence, typical property descriptors with - // the extra flag {compose: true}. They operate on existing dependencies, - // and are typically dynamically constructed by "utility" functions (but - // can also be manually declared within the step list of a composition). - // Compositional steps serve two purposes: - // - // 1. exit early, if some condition is matched, returning and exposing - // some value directly from that step instead of continuing further - // down the step list; - // - // 2. and/or provide new, dynamically created "private" dependencies which - // can be accessed by further steps down the list, or at the base at - // the bottom, modularly supplying information that will contribute to - // the final value exposed for this property. - // - // Usually it's just one of those two, but it's fine for a step to perform - // both jobs if the situation benefits. - // - // Compositional steps are the real "modular" or "compositional" part of - // this data processing style - they're designed to be combined together - // in dynamic, versatile ways, as each property demands it. You usually - // define a compositional step to be returned by some ordinary static - // property-descriptor-returning function (customarily namespaced under - // the relevant Thing class's static `composite` field) - that lets you - // reuse it in multiple compositions later on. - // - // Compositional steps are implemented with "continuation passing style", - // meaning the connection to the next link on the chain is passed right to - // each step's compute (or transform) function, and the implementation gets - // to decide whether to continue on that chain or exit early by returning - // some other value. - // - // Every step along the chain, apart from the base at the bottom, has to - // have the {compose: true} step. That means its compute() or transform() - // function will be passed an extra argument at the end, `continuation`. - // To provide new dependencies to items further down the chain, just pass - // them directly to this continuation() function, customarily with a hash - // ('#') prefixing each name - for example: - // - // compute({..some dependencies..}, continuation) { - // return continuation({ - // '#excitingProperty': (..a value made from dependencies..), - // }); - // } - // - // Performing an early exit is as simple as returning some other value, - // instead of the continuation. You may also use `continuation.exit(value)` - // to perform the exact same kind of early exit - it's just a different - // syntax that might fit in better in certain longer compositions. - // - // It may be fine to simply provide new dependencies under a hard-coded - // name, such as '#excitingProperty' above, but if you're writing a utility - // that dynamically returns the compositional step and you suspect you - // might want to use this step multiple times in a single composition, - // it's customary to accept a name for the result. - // - // Here's a detailed example showing off early exit, dynamically operating - // on a provided dependency name, and then providing a result in another - // also-provided dependency name: - // - // static Thing.composite.withResolvedContribs = ({ - // from: contribsByRefDependency, - // to: outputDependency, - // }) => ({ - // flags: {expose: true, compose: true}, - // expose: { - // dependencies: [contribsByRefDependency, 'artistData'], - // compute({ - // [contribsByRefDependency]: contribsByRef, - // artistData, - // }, continuation) { - // if (!artistData) return null; /* early exit! */ - // return continuation({ - // [outputDependency]: /* this is the important part */ - // (..resolve contributions one way or another..), - // }); - // }, - // }, - // }); - // - // And how you might work that into a composition: - // - // static Track[Thing.getPropertyDescriptors].coverArtists = - // Thing.composite.from([ - // Track.composite.doSomethingWhichMightEarlyExit(), - // Thing.composite.withResolvedContribs({ - // from: 'coverArtistContribsByRef', - // to: '#coverArtistContribs', - // }), - // - // { - // flags: {expose: true}, - // expose: { - // dependencies: ['#coverArtistContribs'], - // compute({'#coverArtistContribs': coverArtistContribs}) { - // return coverArtistContribs.map(({who}) => who); - // }, - // }, - // }, - // ]); - // - // One last note! A super common code pattern when creating more complex - // compositions is to have several steps which *only* expose and compose. - // As a syntax shortcut, you can skip the outer section. It's basically - // like writing out just the {expose: {...}} part. Remember that this - // indicates that the step you're defining is compositional, so you have - // to specify the flags manually for the base, even if this property isn't - // going to get an {update: true} flag. - // - // == Cache-safe dependency names: == - // - // [Disclosure: The caching engine hasn't actually been implemented yet. - // As such, this section is subject to change, and simply provides sound - // forward-facing advice and interfaces.] - // - // It's a good idea to write individual compositional steps in such a way - // that they're "cache-safe" - meaning the same input (dependency) values - // will always result in the same output (continuation or early exit). - // - // In order to facilitate this, compositional step descriptors may specify - // unique `mapDependencies`, `mapContinuation`, and `options` values. - // - // Consider the `withResolvedContribs` example adjusted to make use of - // two of these options below: - // - // static Thing.composite.withResolvedContribs = ({ - // from: contribsByRefDependency, - // to: outputDependency, - // }) => ({ - // flags: {expose: true, compose: true}, - // expose: { - // dependencies: ['artistData'], - // mapDependencies: {contribsByRef: contribsByRefDependency}, - // mapContinuation: {outputDependency}, - // compute({ - // contribsByRef, /* no longer in square brackets */ - // artistData, - // }, continuation) { - // if (!artistData) return null; - // return continuation({ - // outputDependency: /* no longer in square brackets */ - // (..resolve contributions one way or another..), - // }); - // }, - // }, - // }); - // - // With a little destructuring and restructuring JavaScript sugar, the - // above can be simplified some more: - // - // static Thing.composite.withResolvedContribs = ({from, to}) => ({ - // flags: {expose: true, compose: true}, - // expose: { - // dependencies: ['artistData'], - // mapDependencies: {from}, - // mapContinuation: {to}, - // compute({artistData, from: contribsByRef}, continuation) { - // if (!artistData) return null; - // return continuation({ - // to: (..resolve contributions one way or another..), - // }); - // }, - // }, - // }); - // - // These two properties let you separate the name-mapping behavior (for - // dependencies and the continuation) from the main body of the compute - // function. That means the compute function will *always* get inputs in - // the same form (dependencies 'artistData' and 'from' above), and will - // *always* provide its output in the same form (early return or 'to'). - // - // Thanks to that, this `compute` function is cache-safe! Its outputs can - // be cached corresponding to each set of mapped inputs. So it won't matter - // whether the `from` dependency is named `coverArtistContribsByRef` or - // `contributorContribsByRef` or something else - the compute function - // doesn't care, and only expects that value to be provided via its `from` - // argument. Likewise, it doesn't matter if the output should be sent to - // '#coverArtistContribs` or `#contributorContribs` or some other name; - // the mapping is handled automatically outside, and compute will always - // output its value to the continuation's `to`. - // - // Note that `mapDependencies` and `mapContinuation` should be objects of - // the same "shape" each run - that is, the values will change depending on - // outside context, but the keys are always the same. You shouldn't use - // `mapDependencies` to dynamically select more or fewer dependencies. - // If you need to dynamically select a range of dependencies, just specify - // them in the `dependencies` array like usual. The caching engine will - // understand that differently named `dependencies` indicate separate - // input-output caches should be used. - // - // The 'options' property makes it possible to specify external arguments - // that fundamentally change the behavior of the `compute` function, while - // still remaining cache-safe. It indicates that the caching engine should - // use a completely different input-to-output cache for each permutation - // of the 'options' values. This way, those functions are still cacheable - // at all; they'll just be cached separately for each set of option values. - // Values on the 'options' property will always be provided in compute's - // dependencies under '#options' (to avoid name conflicts with other - // dependencies). - // - // == To compute or to transform: == - // - // A compositional step can work directly on a property's stored update - // value, transforming it in place and either early exiting with it or - // passing it on (via continuation) to the next item(s) in the - // compositional step list. (If needed, these can provide dependencies - // the same way as compute functions too - just pass that object after - // the updated (or same) transform value in your call to continuation().) - // - // But in order to make them more versatile, compositional steps have an - // extra trick up their sleeve. If a compositional step implements compute - // and *not* transform, it can still be used in a composition targeting a - // property which updates! These retain their full dependency-providing and - // early exit functionality - they just won't be provided the update value. - // If a compute-implementing step returns its continuation, then whichever - // later step (or the base) next implements transform() will receive the - // update value that had so far been running - as well as any dependencies - // the compute() step returned, of course! - // - // Please note that a compositional step which transforms *should not* - // specify, in its flags, {update: true}. Just provide the transform() - // function in its expose descriptor; it will be automatically detected - // and used when appropriate. - // - // It's actually possible for a step to specify both transform and compute, - // in which case the transform() implementation will only be selected if - // the composition's base is {update: true}. It's not exactly known why you - // would want to specify unique-but-related transform and compute behavior, - // but the basic possibility was too cool to skip out on. - // - // == Nesting compositions: == - // - // Compositional steps are so convenient that you just might want to bundle - // them together, and form a whole new step-shaped unit of its own! - // - // In order to allow for this while helping to ensure internal dependencies - // remain neatly isolated from the composition which nests your bundle, - // the Thing.composite.from() function will accept and adapt to a base that - // specifies the {compose: true} flag, just like the steps preceding it. - // - // The continuation function that gets provided to the base will be mildly - // special - after all, nothing follows the base within the composition's - // own list! Instead of appending dependencies alongside any previously - // provided ones to be available to the next step, the base's continuation - // function should be used to define "exports" of the composition as a - // whole. It's similar to the usual behavior of the continuation, just - // expanded to the scope of the composition instead of following steps. - // - // For example, suppose your composition (which you expect to include in - // other compositions) brings about several private, hash-prefixed - // dependencies to contribute to its own results. Those dependencies won't - // end up "bleeding" into the dependency list of whichever composition is - // nesting this one - they will totally disappear once all the steps in - // the nested composition have finished up. - // - // To "export" the results of processing all those dependencies (provided - // that's something you want to do and this composition isn't used purely - // for a conditional early-exit), you'll want to define them in the - // continuation passed to the base. (Customarily, those should start with - // a hash just like the exports from any other compositional step; they're - // still dynamically provided dependencies!) - // - // Another way to "export" dependencies is by using calling *any* step's - // `continuation.raise()` function. This is sort of like early exiting, - // but instead of quitting out the whole entire property, it will just - // break out of the current, nested composition's list of steps, acting - // as though the composition had finished naturally. The dependencies - // passed to `raise` will be the ones which get exported. - // - // Since `raise` is another way to export dependencies, if you're using - // dynamic export names, you should specify `mapContinuation` on the step - // calling `continuation.raise` as well. - // - // An important note on `mapDependencies` here: A nested composition gets - // free access to all the ordinary properties defined on the thing it's - // working on, but if you want it to depend on *private* dependencies - - // ones prefixed with '#' - which were provided by some other compositional - // step preceding wherever this one gets nested, then you *have* to use - // `mapDependencies` to gain access. Check out the section on "cache-safe - // dependency names" for information on this syntax! - // - // Also - on rare occasion - you might want to make a reusable composition - // that itself causes the composition *it's* nested in to raise. If that's - // the case, give `composition.raiseAbove()` a go! This effectively means - // kicking out of *two* layers of nested composition - the one including - // the step with the `raiseAbove` call, and the composition which that one - // is nested within. You don't need to use `raiseAbove` if the reusable - // utility function just returns a single compositional step, but if you - // want to make use of other compositional steps, it gives you access to - // the same conditional-raise capabilities. - // - // Have some syntax sugar! Since nested compositions are defined by having - // the base be {compose: true}, the composition will infer as much if you - // don't specifying the base's flags at all. Simply use the same shorthand - // syntax as for other compositional steps, and it'll work out cleanly! - // - from(firstArg, secondArg) { - const debug = fn => { - if (Thing.composite.from.debug === true) { - const label = - (annotation - ? color.dim(`[composite: ${annotation}]`) - : color.dim(`[composite]`)); - const result = fn(); - if (Array.isArray(result)) { - console.log(label, ...result.map(value => - (typeof value === 'object' - ? inspect(value, {depth: 0, colors: true, compact: true, breakLength: Infinity}) - : value))); - } else { - console.log(label, result); - } - } - }; - - let annotation, composition; - if (typeof firstArg === 'string') { - [annotation, composition] = [firstArg, secondArg]; - } else { - [annotation, composition] = [null, firstArg]; - } - - const base = composition.at(-1); - const steps = composition.slice(); - - const aggregate = openAggregate({ - message: - `Errors preparing Thing.composite.from() composition` + - (annotation ? ` (${annotation})` : ''), - }); - - const baseExposes = - (base.flags - ? base.flags.expose - : true); - - const baseUpdates = - (base.flags - ? base.flags.update - : false); - - const baseComposes = - (base.flags - ? base.flags.compose - : true); - - if (!baseExposes) { - aggregate.push(new TypeError(`All steps, including base, must expose`)); - } - - const exposeDependencies = new Set(); - - let anyStepsCompute = false; - let anyStepsTransform = false; - - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - const isBase = i === steps.length - 1; - const message = - `Errors in step #${i + 1}` + - (isBase ? ` (base)` : ``) + - (step.annotation ? ` (${step.annotation})` : ``); - - aggregate.nest({message}, ({push}) => { - if (step.flags) { - let flagsErrored = false; - - if (!step.flags.compose && !isBase) { - push(new TypeError(`All steps but base must compose`)); - flagsErrored = true; - } - - if (!step.flags.expose) { - push(new TypeError(`All steps must expose`)); - flagsErrored = true; - } - - if (flagsErrored) { - return; - } - } - - const expose = - (step.flags - ? step.expose - : step); - - const stepComputes = !!expose.compute; - const stepTransforms = !!expose.transform; - - if (!stepComputes && !stepTransforms) { - push(new TypeError(`Steps must provide compute or transform (or both)`)); - return; - } - - if ( - stepTransforms && !stepComputes && - !baseUpdates && !baseComposes - ) { - push(new TypeError(`Steps which only transform can't be composed with a non-updating base`)); - return; - } - - if (stepComputes) { - anyStepsCompute = true; - } - - if (stepTransforms) { - anyStepsTransform = true; - } - - // Unmapped dependencies are exposed on the final composition only if - // they're "public", i.e. pointing to update values of other properties - // on the CacheableObject. - for (const dependency of expose.dependencies ?? []) { - if (typeof dependency === 'string' && dependency.startsWith('#')) { - continue; - } - - exposeDependencies.add(dependency); - } - - // Mapped dependencies are always exposed on the final composition. - // These are explicitly for reading values which are named outside of - // the current compositional step. - for (const dependency of Object.values(expose.mapDependencies ?? {})) { - exposeDependencies.add(dependency); - } - }); - } - - if (!baseComposes) { - if (baseUpdates) { - if (!anyStepsTransform) { - push(new TypeError(`Expected at least one step to transform`)); - } - } else { - if (!anyStepsCompute) { - push(new TypeError(`Expected at least one step to compute`)); - } - } - } - - aggregate.close(); - - const constructedDescriptor = {}; - - if (annotation) { - constructedDescriptor.annotation = annotation; - } - - constructedDescriptor.flags = { - update: baseUpdates, - expose: baseExposes, - compose: baseComposes, - }; - - if (baseUpdates) { - constructedDescriptor.update = base.update; - } - - if (baseExposes) { - const expose = constructedDescriptor.expose = {}; - expose.dependencies = Array.from(exposeDependencies); - - const continuationSymbol = Symbol('continuation symbol'); - const noTransformSymbol = Symbol('no-transform symbol'); - - function _filterDependencies(availableDependencies, { - dependencies, - mapDependencies, - options, - }) { - const filteredDependencies = - (dependencies - ? filterProperties(availableDependencies, dependencies) - : {}); - - if (mapDependencies) { - for (const [to, from] of Object.entries(mapDependencies)) { - filteredDependencies[to] = availableDependencies[from] ?? null; - } - } - - if (options) { - filteredDependencies['#options'] = options; - } - - return filteredDependencies; - } - - function _assignDependencies(continuationAssignment, {mapContinuation}) { - if (!mapContinuation) { - return continuationAssignment; - } - - const assignDependencies = {}; - - for (const [from, to] of Object.entries(mapContinuation)) { - assignDependencies[to] = continuationAssignment[from] ?? null; - } - - return assignDependencies; - } - - function _prepareContinuation(callingTransformForThisStep) { - const continuationStorage = { - returnedWith: null, - providedDependencies: undefined, - providedValue: undefined, - }; - - const continuation = - (callingTransformForThisStep - ? (providedValue, providedDependencies = null) => { - continuationStorage.returnedWith = 'continuation'; - continuationStorage.providedDependencies = providedDependencies; - continuationStorage.providedValue = providedValue; - return continuationSymbol; - } - : (providedDependencies = null) => { - continuationStorage.returnedWith = 'continuation'; - continuationStorage.providedDependencies = providedDependencies; - return continuationSymbol; - }); - - continuation.exit = (providedValue) => { - continuationStorage.returnedWith = 'exit'; - continuationStorage.providedValue = providedValue; - return continuationSymbol; - }; - - if (baseComposes) { - const makeRaiseLike = returnWith => - (callingTransformForThisStep - ? (providedValue, providedDependencies = null) => { - continuationStorage.returnedWith = returnWith; - continuationStorage.providedDependencies = providedDependencies; - continuationStorage.providedValue = providedValue; - return continuationSymbol; - } - : (providedDependencies = null) => { - continuationStorage.returnedWith = returnWith; - continuationStorage.providedDependencies = providedDependencies; - return continuationSymbol; - }); - - continuation.raise = makeRaiseLike('raise'); - continuation.raiseAbove = makeRaiseLike('raiseAbove'); - } - - return {continuation, continuationStorage}; - } - - function _computeOrTransform(initialValue, initialDependencies, continuationIfApplicable) { - const expectingTransform = initialValue !== noTransformSymbol; - - let valueSoFar = - (expectingTransform - ? initialValue - : undefined); - - const availableDependencies = {...initialDependencies}; - - if (expectingTransform) { - debug(() => [color.bright(`begin composition - transforming from:`), initialValue]); - } else { - debug(() => color.bright(`begin composition - not transforming`)); - } - - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - const isBase = i === steps.length - 1; - - debug(() => [ - `step #${i+1}` + - (isBase - ? ` (base):` - : ` of ${steps.length}:`), - step]); - - const expose = - (step.flags - ? step.expose - : step); - - const callingTransformForThisStep = - expectingTransform && expose.transform; - - const filteredDependencies = _filterDependencies(availableDependencies, expose); - const {continuation, continuationStorage} = _prepareContinuation(callingTransformForThisStep); - - debug(() => [ - `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, - `with dependencies:`, filteredDependencies]); - - const result = - (callingTransformForThisStep - ? expose.transform(valueSoFar, filteredDependencies, continuation) - : expose.compute(filteredDependencies, continuation)); - - if (result !== continuationSymbol) { - debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); - - if (baseComposes) { - throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); - } - - debug(() => color.bright(`end composition - exit (inferred)`)); - - return result; - } - - const {returnedWith} = continuationStorage; - - if (returnedWith === 'exit') { - const {providedValue} = continuationStorage; - - debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); - debug(() => color.bright(`end composition - exit (explicit)`)); - - if (baseComposes) { - return continuationIfApplicable.exit(providedValue); - } else { - return providedValue; - } - } - - const {providedValue, providedDependencies} = continuationStorage; - - const continuingWithValue = - (expectingTransform - ? (callingTransformForThisStep - ? providedValue ?? null - : valueSoFar ?? null) - : undefined); - - const continuingWithDependencies = - (providedDependencies - ? _assignDependencies(providedDependencies, expose) - : null); - - const continuationArgs = []; - if (continuingWithValue !== undefined) continuationArgs.push(continuingWithValue); - if (continuingWithDependencies !== null) continuationArgs.push(continuingWithDependencies); - - debug(() => { - const base = `step #${i+1} - result: ` + returnedWith; - const parts = []; - - if (callingTransformForThisStep) { - if (continuingWithValue === undefined) { - parts.push(`(no value)`); - } else { - parts.push(`value:`, providedValue); - } - } - - if (continuingWithDependencies !== null) { - parts.push(`deps:`, continuingWithDependencies); - } else { - parts.push(`(no deps)`); - } - - if (empty(parts)) { - return base; - } else { - return [base + ' ->', ...parts]; - } - }); - - switch (returnedWith) { - case 'raise': - debug(() => - (isBase - ? color.bright(`end composition - raise (base: explicit)`) - : color.bright(`end composition - raise`))); - return continuationIfApplicable(...continuationArgs); - - case 'raiseAbove': - debug(() => color.bright(`end composition - raiseAbove`)); - return continuationIfApplicable.raise(...continuationArgs); - - case 'continuation': - if (isBase) { - debug(() => color.bright(`end composition - raise (inferred)`)); - return continuationIfApplicable(...continuationArgs); - } else { - Object.assign(availableDependencies, continuingWithDependencies); - break; - } - } - } - } - - const transformFn = - (value, initialDependencies, continuationIfApplicable) => - _computeOrTransform(value, initialDependencies, continuationIfApplicable); - - const computeFn = - (initialDependencies, continuationIfApplicable) => - _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable); - - if (baseComposes) { - if (anyStepsTransform) expose.transform = transformFn; - if (anyStepsCompute) expose.compute = computeFn; - } else if (baseUpdates) { - expose.transform = transformFn; - } else { - expose.compute = computeFn; - } - } - - return constructedDescriptor; - }, - - // Evaluates a function with composite debugging enabled, turns debugging - // off again, and returns the result of the function. This is mostly syntax - // sugar, but also helps avoid unit tests avoid accidentally printing debug - // info for a bunch of unrelated composites (due to property enumeration - // when displaying an unexpected result). Use as so: - // - // Without debugging: - // t.same(thing.someProp, value) - // - // With debugging: - // t.same(Thing.composite.debug(() => thing.someProp), value) - // - debug(fn) { - Thing.composite.from.debug = true; - const value = fn(); - Thing.composite.from.debug = false; - return value; - }, - - // -- Compositional steps for compositions to nest -- - - // Provides dependencies exactly as they are (or null if not defined) to the - // continuation. Although this can *technically* be used to alias existing - // dependencies to some other name within the middle of a composition, it's - // intended to be used only as a composition's base - doing so makes the - // composition as a whole suitable as a step in some other composition, - // providing the listed (internal) dependencies to later steps just like - // other compositional steps. - export(mapping) { - const mappingEntries = Object.entries(mapping); - - return { - annotation: `Thing.composite.export`, - flags: {expose: true, compose: true}, - - expose: { - options: {mappingEntries}, - dependencies: Object.values(mapping), - - compute({'#options': {mappingEntries}, ...dependencies}, continuation) { - const exports = {}; - - // Note: This is slightly different behavior from filterProperties, - // as defined in sugar.js, which doesn't fall back to null for - // properties which don't exist on the original object. - for (const [exportKey, dependencyKey] of mappingEntries) { - exports[exportKey] = - (Object.hasOwn(dependencies, dependencyKey) - ? dependencies[dependencyKey] - : null); - } - - return continuation.raise(exports); - } - }, - }; - }, - - // -- Compositional steps for top-level property descriptors -- - - // Exposes a dependency exactly as it is; this is typically the base of a - // composition which was created to serve as one property's descriptor. - // Since this serves as a base, specify a value for {update} to indicate - // that the property as a whole updates (and some previous compositional - // step works with that update value). Set {update: true} to only enable - // the update flag, or set update to an object to specify a descriptor - // (e.g. for custom value validation). - // - // Please note that this *doesn't* verify that the dependency exists, so - // if you provide the wrong name or it hasn't been set by a previous - // compositional step, the property will be exposed as undefined instead - // of null. - // - exposeDependency(dependency, { - update = false, - } = {}) { - return { - annotation: `Thing.composite.exposeDependency`, - flags: {expose: true, update: !!update}, - - expose: { - mapDependencies: {dependency}, - compute: ({dependency}) => dependency, - }, - - update: - (typeof update === 'object' - ? update - : null), - }; - }, - - // Exposes a constant value exactly as it is; like exposeDependency, this - // is typically the base of a composition serving as a particular property - // descriptor. It generally follows steps which will conditionally early - // exit with some other value, with the exposeConstant base serving as the - // fallback default value. Like exposeDependency, set {update} to true or - // an object to indicate that the property as a whole updates. - exposeConstant(value, { - update = false, - } = {}) { - return { - annotation: `Thing.composite.exposeConstant`, - flags: {expose: true, update: !!update}, - - expose: { - options: {value}, - compute: ({'#options': {value}}) => value, - }, - - update: - (typeof update === 'object' - ? update - : null), - }; - }, - - // Checks the availability of a dependency or the update value and provides - // the result to later steps under '#availability' (by default). This is - // mainly intended for use by the more specific utilities, which you should - // consider using instead. Customize {mode} to select one of these modes, - // or leave unset and default to 'null': - // - // * 'null': Check that the value isn't null. - // * 'empty': Check that the value is neither null nor an empty array. - // * 'falsy': Check that the value isn't false when treated as a boolean - // (nor an empty array). Keep in mind this will also be false - // for values like zero and the empty string! - // - withResultOfAvailabilityCheck({ - fromUpdateValue, - fromDependency, - mode = 'null', - to = '#availability', - }) { - if (!['null', 'empty', 'falsy'].includes(mode)) { - throw new TypeError(`Expected mode to be null, empty, or falsy`); - } - - if (fromUpdateValue && fromDependency) { - throw new TypeError(`Don't provide both fromUpdateValue and fromDependency`); - } - - if (!fromUpdateValue && !fromDependency) { - throw new TypeError(`Missing dependency name (or fromUpdateValue)`); - } - - const checkAvailability = (value, mode) => { - switch (mode) { - case 'null': return value !== null; - case 'empty': return !empty(value); - case 'falsy': return !!value && (!Array.isArray(value) || !empty(value)); - default: return false; - } - }; - - if (fromDependency) { - return { - annotation: `Thing.composite.withResultOfAvailabilityCheck.fromDependency`, - flags: {expose: true, compose: true}, - expose: { - mapDependencies: {from: fromDependency}, - mapContinuation: {to}, - options: {mode}, - compute: ({from, '#options': {mode}}, continuation) => - continuation({to: checkAvailability(from, mode)}), - }, - }; - } else { - return { - annotation: `Thing.composite.withResultOfAvailabilityCheck.fromUpdateValue`, - flags: {expose: true, compose: true}, - expose: { - mapContinuation: {to}, - options: {mode}, - transform: (value, {'#options': {mode}}, continuation) => - continuation(value, {to: checkAvailability(value, mode)}), - }, - }; - } - }, - - // Exposes a dependency as it is, or continues if it's unavailable. - // See withResultOfAvailabilityCheck for {mode} options! - exposeDependencyOrContinue(dependency, { - mode = 'null', - } = {}) { - return Thing.composite.from(`Thing.composite.exposeDependencyOrContinue`, [ - Thing.composite.withResultOfAvailabilityCheck({ - fromDependency: dependency, - mode, - }), - - { - dependencies: ['#availability'], - compute: ({'#availability': availability}, continuation) => - (availability - ? continuation() - : continuation.raise()), - }, - - { - mapDependencies: {dependency}, - compute: ({dependency}, continuation) => - continuation.exit(dependency), - }, - ]); - }, - - // Exposes the update value of an {update: true} property as it is, - // or continues if it's unavailable. See withResultOfAvailabilityCheck - // for {mode} options! - exposeUpdateValueOrContinue({ - mode = 'null', - } = {}) { - return Thing.composite.from(`Thing.composite.exposeUpdateValueOrContinue`, [ - Thing.composite.withResultOfAvailabilityCheck({ - fromUpdateValue: true, - mode, - }), - - { - dependencies: ['#availability'], - compute: ({'#availability': availability}, continuation) => - (availability - ? continuation() - : continuation.raise()), - }, - - { - transform: (value, {}, continuation) => - continuation.exit(value), - }, - ]); - }, - - // Early exits if an availability check fails. - // This is for internal use only - use `earlyExitWithoutDependency` or - // `earlyExitWIthoutUpdateValue` instead. - earlyExitIfAvailabilityCheckFailed({ - availability = '#availability', - value = null, - }) { - return Thing.composite.from(`Thing.composite.earlyExitIfAvailabilityCheckFailed`, [ - { - mapDependencies: {availability}, - compute: ({availability}, continuation) => - (availability - ? continuation.raise() - : continuation()), - }, - - { - options: {value}, - compute: ({'#options': {value}}, continuation) => - continuation.exit(value), - }, - ]); - }, - - // Early exits if a dependency isn't available. - // See withResultOfAvailabilityCheck for {mode} options! - earlyExitWithoutDependency(dependency, { - mode = 'null', - value = null, - } = {}) { - return Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [ - Thing.composite.withResultOfAvailabilityCheck({fromDependency: dependency, mode}), - Thing.composite.earlyExitIfAvailabilityCheckFailed({value}), - ]); - }, - - // Early exits if this property's update value isn't available. - // See withResultOfAvailabilityCheck for {mode} options! - earlyExitWithoutUpdateValue({ - mode = 'null', - value = null, - } = {}) { - return Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [ - Thing.composite.withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), - Thing.composite.earlyExitIfAvailabilityCheckFailed({value}), - ]); - }, - - // Raises if a dependency isn't available. - // See withResultOfAvailabilityCheck for {mode} options! - raiseWithoutDependency(dependency, { - mode = 'null', - map = {}, - raise = {}, - } = {}) { - return Thing.composite.from(`Thing.composite.raiseWithoutDependency`, [ - Thing.composite.withResultOfAvailabilityCheck({fromDependency: dependency, mode}), - - { - dependencies: ['#availability'], - compute: ({'#availability': availability}, continuation) => - (availability - ? continuation.raise() - : continuation()), - }, - - { - options: {raise}, - mapContinuation: map, - compute: ({'#options': {raise}}, continuation) => - continuation.raiseAbove(raise), - }, - ]); - }, - - // Raises if this property's update value isn't available. - // See withResultOfAvailabilityCheck for {mode} options! - raiseWithoutUpdateValue({ - mode = 'null', - map = {}, - raise = {}, - } = {}) { - return Thing.composite.from(`Thing.composite.raiseWithoutUpdateValue`, [ - Thing.composite.withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), - - { - mapDependencies: {availability}, - compute: ({availability}, continuation) => - (availability - ? continuation.raise() - : continuation()), - }, - - { - options: {raise}, - mapContinuation: map, - compute: ({'#options': {raise}}, continuation) => - continuation.raiseAbove(raise), - }, - ]); - }, - - // -- Compositional steps for processing data -- - - // Resolves the contribsByRef contained in the provided dependency, - // providing (named by the second argument) the result. "Resolving" - // means mapping the "who" reference of each contribution to an artist - // object, and filtering out those whose "who" doesn't match any artist. - withResolvedContribs({from, to}) { - return { - annotation: `Thing.composite.withResolvedContribs`, - flags: {expose: true, compose: true}, - - expose: { - dependencies: ['artistData'], - mapDependencies: {from}, - mapContinuation: {to}, - compute: ({artistData, from}, continuation) => - continuation({ - to: Thing.findArtistsFromContribs(from, artistData), - }), - }, - }; - }, - - // Resolves a reference by using the provided find function to match it - // within the provided thingData dependency. This will early exit if the - // data dependency is null, or, if earlyExitIfNotFound is set to true, - // if the find function doesn't match anything for the reference. - // Otherwise, the data object is provided on the output dependency; - // or null, if the reference doesn't match anything or itself was null - // to begin with. - withResolvedReference({ - ref, - data, - to, - find: findFunction, - earlyExitIfNotFound = false, - }) { - return Thing.composite.from(`Thing.composite.withResolvedReference`, [ - Thing.composite.raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), - Thing.composite.earlyExitWithoutDependency(data), - - { - options: {findFunction, earlyExitIfNotFound}, - mapDependencies: {ref, data}, - mapContinuation: {match: to}, - - compute({ref, data, '#options': {findFunction, earlyExitIfNotFound}}, continuation) { - const match = findFunction(ref, data, {mode: 'quiet'}); - - if (match === null && earlyExitIfNotFound) { - return continuation.exit(null); - } - - return continuation.raise({match}); - }, - }, - ]); - }, - - // Check out the info on Thing.common.reverseReferenceList! - // This is its composable form. - withReverseReferenceList({ - data, - to = '#reverseReferenceList', - refList: refListProperty, - }) { - return Thing.composite.from(`Thing.common.reverseReferenceList`, [ - Thing.composite.earlyExitWithoutDependency(data, {value: []}), - - { - dependencies: ['this'], - mapDependencies: {data}, - mapContinuation: {to}, - options: {refListProperty}, - - compute: ({this: thisThing, data, '#options': {refListProperty}}, continuation) => - continuation({ - to: data.filter(thing => thing[refListProperty].includes(thisThing)), - }), - }, - ]); - }, - }; + static composite = composite; } -- cgit 1.3.0-6-gf8a5 From 6f242fc864028a12321255ba04a88c6190801510 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 14:34:12 -0300 Subject: data: isolate withResolvedContribs internal behavior --- src/data/things/composite.js | 26 ++++++++++++++++++++------ src/data/things/thing.js | 30 ++++++++++-------------------- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 1be60cd1..bf2d11ea 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -331,6 +331,7 @@ // syntax as for other compositional steps, and it'll work out cleanly! // +import find from '#find'; import {empty, filterProperties, openAggregate} from '#sugar'; import Thing from './thing.js'; @@ -1102,20 +1103,33 @@ export function raiseWithoutUpdateValue({ // means mapping the "who" reference of each contribution to an artist // object, and filtering out those whose "who" doesn't match any artist. export function withResolvedContribs({from, to}) { - return { - annotation: `Thing.composite.withResolvedContribs`, - flags: {expose: true, compose: true}, + return Thing.composite.from(`Thing.composite.withResolvedContribs`, [ + Thing.composite.earlyExitWithoutDependency('artistData', { + value: [], + }), - expose: { + Thing.composite.raiseWithoutDependency(from, { + mode: 'empty', + map: {to}, + raise: {to: []}, + }), + + { dependencies: ['artistData'], mapDependencies: {from}, mapContinuation: {to}, compute: ({artistData, from}, continuation) => continuation({ - to: Thing.findArtistsFromContribs(from, artistData), + to: + from + .map(({who, what}) => ({ + who: find.artist(who, artistData, {mode: 'quiet'}), + what, + })) + .filter(({who}) => who), }), }, - }; + ]); } // Resolves a reference by using the provided find function to match it diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 01aa8b1b..9bfed080 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -249,14 +249,16 @@ export default class Thing extends CacheableObject { // filtered out. (So if the list is all empty, chances are that either the // reference list is somehow messed up, or artistData isn't being provided // properly.) - dynamicContribs: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: ['artistData', contribsByRefProperty], - compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => - Thing.findArtistsFromContribs(contribsByRef, artistData), - }, - }), + dynamicContribs(contribsByRefProperty) { + return Thing.composite.from(`Thing.common.dynamicContribs`, [ + Thing.composite.withResolvedContribs({ + from: contribsByRefProperty, + to: '#contribs', + }), + + Thing.composite.exposeDependency('#contribs'), + ]); + }, // Nice 'n simple shorthand for an exposed-only flag which is true when any // contributions are present in the specified property. @@ -348,17 +350,5 @@ export default class Thing extends CacheableObject { return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; } - static findArtistsFromContribs(contribsByRef, artistData) { - if (empty(contribsByRef)) return null; - - return ( - contribsByRef - .map(({who, what}) => ({ - who: find.artist(who, artistData, {mode: 'quiet'}), - what, - })) - .filter(({who}) => who)); - } - static composite = composite; } -- cgit 1.3.0-6-gf8a5 From 117f1e6b707dfe102b968e421b21906d03100dc8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 14:57:16 -0300 Subject: data: new withResolvedReferenceList utility --- src/data/things/composite.js | 107 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 88 insertions(+), 19 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index bf2d11ea..18a5f434 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1,3 +1,15 @@ +import find from '#find'; +import {filterMultipleArrays} from '#wiki-data'; + +import { + empty, + filterProperties, + openAggregate, + stitchArrays, +} from '#sugar'; + +import Thing from './thing.js'; + // Composes multiple compositional "steps" and a "base" to form a property // descriptor out of modular building blocks. This is an extension to the // more general-purpose CacheableObject property descriptor syntax, and @@ -331,11 +343,6 @@ // syntax as for other compositional steps, and it'll work out cleanly! // -import find from '#find'; -import {empty, filterProperties, openAggregate} from '#sugar'; - -import Thing from './thing.js'; - export {compositeFrom as from}; function compositeFrom(firstArg, secondArg) { const debug = fn => { @@ -1104,10 +1111,6 @@ export function raiseWithoutUpdateValue({ // object, and filtering out those whose "who" doesn't match any artist. export function withResolvedContribs({from, to}) { return Thing.composite.from(`Thing.composite.withResolvedContribs`, [ - Thing.composite.earlyExitWithoutDependency('artistData', { - value: [], - }), - Thing.composite.raiseWithoutDependency(from, { mode: 'empty', map: {to}, @@ -1115,20 +1118,32 @@ export function withResolvedContribs({from, to}) { }), { - dependencies: ['artistData'], mapDependencies: {from}, - mapContinuation: {to}, - compute: ({artistData, from}, continuation) => + compute: ({from}, continuation) => continuation({ - to: - from - .map(({who, what}) => ({ - who: find.artist(who, artistData, {mode: 'quiet'}), - what, - })) - .filter(({who}) => who), + '#whoByRef': from.map(({who}) => who), + '#what': from.map(({what}) => what), }), }, + + withResolvedReferenceList({ + refList: '#whoByRef', + data: 'artistData', + to: '#who', + find: find.artist, + notFoundMode: 'null', + }), + + { + dependencies: ['#who', '#what'], + mapContinuation: {to}, + compute({'#who': who, '#what': what}, continuation) { + filterMultipleArrays(who, what, (who, _what) => who); + return continuation({ + to: stitchArrays({who, what}), + }); + }, + }, ]); } @@ -1168,6 +1183,60 @@ export function withResolvedReference({ ]); } +// Resolves a list of references, with each reference matched with provided +// data in the same way as withResolvedReference. This will early exit if the +// data dependency is null (even if the reference list is empty). By default +// it will filter out references which don't match, but this can be changed +// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null'). +export function withResolvedReferenceList({ + refList, + data, + to, + find: findFunction, + notFoundMode = 'filter', +}) { + if (!['filter', 'exit', 'null'].includes(notFoundMode)) { + throw new TypeError(`Expected notFoundMode to be filter, exit, or null`); + } + + return compositeFrom(`Thing.composite.withResolvedReferenceList`, [ + earlyExitWithoutDependency(data, {value: []}), + + raiseWithoutDependency(refList, { + map: {to}, + raise: [], + mode: 'empty', + }), + + { + options: {findFunction, notFoundMode}, + mapDependencies: {refList, data}, + mapContinuation: {matches: to}, + + compute({refList, data, '#options': {findFunction, notFoundMode}}, continuation) { + const matches = + refList.map(ref => findFunction(ref, data, {mode: 'quiet'})); + + if (!matches.includes(null)) { + return continuation.raise({matches}); + } + + switch (notFoundMode) { + case 'filter': + matches = matches.filter(value => value !== null); + return contination.raise({matches}); + + case 'exit': + return continuation.exit([]); + + case 'null': + return continuation.raise({matches}); + } + }, + }, + ]); +} + // Check out the info on Thing.common.reverseReferenceList! // This is its composable form. export function withReverseReferenceList({ -- cgit 1.3.0-6-gf8a5 From 91624e5d61a1473e143bad8860c8c2ccffec38fe Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 15:10:48 -0300 Subject: data: misc. eslint-caught fixes in composite.js --- src/data/things/composite.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 18a5f434..4f1abdb4 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1,3 +1,6 @@ +import {inspect} from 'node:util'; + +import {color} from '#cli'; import find from '#find'; import {filterMultipleArrays} from '#wiki-data'; @@ -482,11 +485,11 @@ function compositeFrom(firstArg, secondArg) { if (!baseComposes) { if (baseUpdates) { if (!anyStepsTransform) { - push(new TypeError(`Expected at least one step to transform`)); + aggregate.push(new TypeError(`Expected at least one step to transform`)); } } else { if (!anyStepsCompute) { - push(new TypeError(`Expected at least one step to compute`)); + aggregate.push(new TypeError(`Expected at least one step to compute`)); } } } @@ -1087,8 +1090,8 @@ export function raiseWithoutUpdateValue({ withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), { - mapDependencies: {availability}, - compute: ({availability}, continuation) => + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => (availability ? continuation.raise() : continuation()), @@ -1214,7 +1217,7 @@ export function withResolvedReferenceList({ mapContinuation: {matches: to}, compute({refList, data, '#options': {findFunction, notFoundMode}}, continuation) { - const matches = + let matches = refList.map(ref => findFunction(ref, data, {mode: 'quiet'})); if (!matches.includes(null)) { @@ -1224,7 +1227,7 @@ export function withResolvedReferenceList({ switch (notFoundMode) { case 'filter': matches = matches.filter(value => value !== null); - return contination.raise({matches}); + return continuation.raise({matches}); case 'exit': return continuation.exit([]); -- cgit 1.3.0-6-gf8a5 From 137bd813980d77441a86303ac6c04b61d9ccb8da Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 15:14:37 -0300 Subject: data: isolate internals of dynamicThingsFromReferenceList --- src/data/things/composite.js | 2 +- src/data/things/thing.js | 26 ++++++++++---------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 4f1abdb4..e930e228 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1207,7 +1207,7 @@ export function withResolvedReferenceList({ raiseWithoutDependency(refList, { map: {to}, - raise: [], + raise: {to: []}, mode: 'empty', }), diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 9bfed080..9f77c3fc 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -194,26 +194,20 @@ export default class Thing extends CacheableObject { // in the provided property and searches the specified wiki data for // matching actual Thing-subclass objects. dynamicThingsFromReferenceList( - refs, + refList, data, findFunction ) { return Thing.composite.from(`Thing.common.dynamicThingsFromReferenceList`, [ - Thing.composite.earlyExitWithoutDependency(refs, {value: []}), - Thing.composite.earlyExitWithoutDependency(data, {value: []}), - - { - flags: {expose: true}, - expose: { - mapDependencies: {refs, data}, - options: {findFunction}, - - compute: ({refs, data, '#options': {findFunction}}) => - refs - .map(ref => findFunction(ref, data, {mode: 'quiet'})) - .filter(Boolean), - }, - }, + Thing.composite.withResolvedReferenceList({ + refList, + data, + to: '#things', + find: findFunction, + notFoundMode: 'filter', + }), + + Thing.composite.exposeDependency('#things'), ]); }, -- cgit 1.3.0-6-gf8a5 From 2d7c536ee91a8f5bf8f16db1fc2d0a4d8bb4fc85 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 15:22:58 -0300 Subject: data: dynamicThingsFromReferenceList -> resolvedReferenceList --- src/data/things/album.js | 13 +++++++++++-- src/data/things/composite.js | 12 ++++++------ src/data/things/flash.js | 16 ++++++++++------ src/data/things/group.js | 16 ++++++++++------ src/data/things/homepage-layout.js | 10 +++++----- src/data/things/thing.js | 12 ++++++------ src/data/things/track.js | 18 +++++++++++++++--- src/data/things/wiki-info.js | 6 +++++- 8 files changed, 68 insertions(+), 35 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 06982903..81f04f70 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -128,8 +128,17 @@ export class Album extends Thing { commentatorArtists: Thing.common.commentatorArtists(), - groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group), - artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + groups: Thing.common.resolvedReferenceList({ + list: 'groupsByRef', + data: 'groupData', + find: find.group, + }), + + artTags: Thing.common.resolvedReferenceList({ + list: 'artTagsByRef', + data: 'artTagData', + find: find.artTag, + }), hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'), hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'), diff --git a/src/data/things/composite.js b/src/data/things/composite.js index e930e228..7f3463cf 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1130,7 +1130,7 @@ export function withResolvedContribs({from, to}) { }, withResolvedReferenceList({ - refList: '#whoByRef', + list: '#whoByRef', data: 'artistData', to: '#who', find: find.artist, @@ -1192,7 +1192,7 @@ export function withResolvedReference({ // it will filter out references which don't match, but this can be changed // to early exit ({notFoundMode: 'exit'}) or leave null in place ('null'). export function withResolvedReferenceList({ - refList, + list, data, to, find: findFunction, @@ -1205,7 +1205,7 @@ export function withResolvedReferenceList({ return compositeFrom(`Thing.composite.withResolvedReferenceList`, [ earlyExitWithoutDependency(data, {value: []}), - raiseWithoutDependency(refList, { + raiseWithoutDependency(list, { map: {to}, raise: {to: []}, mode: 'empty', @@ -1213,12 +1213,12 @@ export function withResolvedReferenceList({ { options: {findFunction, notFoundMode}, - mapDependencies: {refList, data}, + mapDependencies: {list, data}, mapContinuation: {matches: to}, - compute({refList, data, '#options': {findFunction, notFoundMode}}, continuation) { + compute({list, data, '#options': {findFunction, notFoundMode}}, continuation) { let matches = - refList.map(ref => findFunction(ref, data, {mode: 'quiet'})); + list.map(ref => findFunction(ref, data, {mode: 'quiet'})); if (!matches.includes(null)) { return continuation.raise({matches}); diff --git a/src/data/things/flash.js b/src/data/things/flash.js index 3f870c51..baef23d8 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -67,11 +67,11 @@ export class Flash extends Thing { contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), - featuredTracks: Thing.common.dynamicThingsFromReferenceList( - 'featuredTracksByRef', - 'trackData', - find.track - ), + featuredTracks: Thing.common.resolvedReferenceList({ + list: 'featuredTracksByRef', + data: 'trackData', + find: find.track, + }), act: { flags: {expose: true}, @@ -141,6 +141,10 @@ export class FlashAct extends Thing { // Expose only - flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash), + flashes: Thing.common.resolvedReferenceList({ + list: 'flashesByRef', + data: 'flashData', + find: find.flash, + }), }) } diff --git a/src/data/things/group.js b/src/data/things/group.js index f552b8f3..d04fcf56 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -26,7 +26,11 @@ export class Group extends Thing { // Expose only - featuredAlbums: Thing.common.dynamicThingsFromReferenceList('featuredAlbumsByRef', 'albumData', find.album), + featuredAlbums: Thing.common.resolvedReferenceList({ + list: 'featuredAlbumsByRef', + data: 'albumData', + find: find.album, + }), descriptionShort: { flags: {expose: true}, @@ -88,10 +92,10 @@ export class GroupCategory extends Thing { // Expose only - groups: Thing.common.dynamicThingsFromReferenceList( - 'groupsByRef', - 'groupData', - find.group - ), + groups: Thing.common.resolvedReferenceList({ + list: 'groupsByRef', + data: 'groupData', + find: find.group, + }), }); } diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index ec9e9556..cbdcb99a 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -125,10 +125,10 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { find.group ), - sourceAlbums: Thing.common.dynamicThingsFromReferenceList( - 'sourceAlbumsByRef', - 'albumData', - find.album - ), + sourceAlbums: Thing.common.resolvedReferenceList({ + list: 'sourceAlbumsByRef', + data: 'albumData', + find: find.album, + }), }); } diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 9f77c3fc..f36b08bc 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -193,14 +193,14 @@ export default class Thing extends CacheableObject { // Corresponding dynamic property to referenceList, which takes the values // in the provided property and searches the specified wiki data for // matching actual Thing-subclass objects. - dynamicThingsFromReferenceList( - refList, + resolvedReferenceList({ + list, data, - findFunction - ) { - return Thing.composite.from(`Thing.common.dynamicThingsFromReferenceList`, [ + find: findFunction, + }) { + return Thing.composite.from(`Thing.common.resolvedReferenceList`, [ Thing.composite.withResolvedReferenceList({ - refList, + list, data, to: '#things', find: findFunction, diff --git a/src/data/things/track.js b/src/data/things/track.js index bf56a6dd..733c81c9 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -250,15 +250,27 @@ export class Track extends Thing { referencedTracks: Thing.composite.from(`Track.referencedTracks`, [ Track.composite.inheritFromOriginalRelease({property: 'referencedTracks'}), - Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), + Thing.common.resolvedReferenceList({ + list: 'referencedTracksByRef', + data: 'trackData', + find: find.track, + }), ]), sampledTracks: Thing.composite.from(`Track.sampledTracks`, [ Track.composite.inheritFromOriginalRelease({property: 'sampledTracks'}), - Thing.common.dynamicThingsFromReferenceList('sampledTracksByRef', 'trackData', find.track), + Thing.common.resolvedReferenceList({ + list: 'sampledTracksByRef', + data: 'trackData', + find: find.track, + }), ]), - artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + artTags: Thing.common.resolvedReferenceList({ + list: 'artTagsByRef', + data: 'artTagData', + find: find.artTag, + }), // Specifically exclude re-releases from this list - while it's useful to // get from a re-release to the tracks it references, re-releases aren't diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index e8279987..d6790c55 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -59,6 +59,10 @@ export class WikiInfo extends Thing { // Expose only - divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group), + divideTrackListsByGroups: Thing.common.resolvedReferenceList({ + list: 'divideTrackListsByGroupsByRef', + data: 'groupData', + find: find.group, + }), }); } -- cgit 1.3.0-6-gf8a5 From 007c70642a60ed83bd840f550aa06563d4ba6a99 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 15:31:41 -0300 Subject: data: reverseReferenceList refList -> list --- src/data/things/composite.js | 2 +- src/data/things/thing.js | 4 ++-- src/data/things/track.js | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 7f3463cf..138814d9 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1244,8 +1244,8 @@ export function withResolvedReferenceList({ // This is its composable form. export function withReverseReferenceList({ data, + list: refListProperty, to = '#reverseReferenceList', - refList: refListProperty, }) { return compositeFrom(`Thing.common.reverseReferenceList`, [ earlyExitWithoutDependency(data, {value: []}), diff --git a/src/data/things/thing.js b/src/data/things/thing.js index f36b08bc..915474d4 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -275,10 +275,10 @@ export default class Thing extends CacheableObject { // wiki data provided, not the requesting Thing itself. reverseReferenceList({ data, - refList, + list, }) { return Thing.composite.from(`Thing.common.reverseReferenceList`, [ - Thing.composite.withReverseReferenceList({data, refList}), + Thing.composite.withReverseReferenceList({data, list}), Thing.composite.exposeDependency('#reverseReferenceList'), ]); }, diff --git a/src/data/things/track.js b/src/data/things/track.js index 733c81c9..87e796b9 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -287,7 +287,7 @@ export class Track extends Thing { featuredInFlashes: Thing.common.reverseReferenceList({ data: 'flashData', - refList: 'featuredTracks', + list: 'featuredTracks', }), }); @@ -559,8 +559,7 @@ export class Track extends Thing { return Thing.composite.from(`Track.composite.trackReverseReferenceList`, [ Thing.composite.withReverseReferenceList({ data: 'trackData', - refList: refListProperty, - originalTracksOnly: true, + list: refListProperty, }), { -- cgit 1.3.0-6-gf8a5 From 2437ac322a4c44f2fd9f6a77ac7a65bbb3afc2c0 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 15:42:47 -0300 Subject: data: dynamicThingFromSingleReference -> resolvedReference --- src/data/things/composite.js | 4 ++-- src/data/things/homepage-layout.js | 10 +++++----- src/data/things/thing.js | 40 ++++++++++---------------------------- src/data/things/track.js | 6 +++++- 4 files changed, 22 insertions(+), 38 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 138814d9..d3f76b11 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1160,8 +1160,8 @@ export function withResolvedContribs({from, to}) { export function withResolvedReference({ ref, data, - to, find: findFunction, + to = '#resolvedReference', earlyExitIfNotFound = false, }) { return compositeFrom(`Thing.composite.withResolvedReference`, [ @@ -1194,8 +1194,8 @@ export function withResolvedReference({ export function withResolvedReferenceList({ list, data, - to, find: findFunction, + to = '#resolvedReferenceList', notFoundMode = 'filter', }) { if (!['filter', 'exit', 'null'].includes(notFoundMode)) { diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index cbdcb99a..c478bc41 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -119,11 +119,11 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { // Expose only - sourceGroup: Thing.common.dynamicThingFromSingleReference( - 'sourceGroupByRef', - 'groupData', - find.group - ), + sourceGroup: Thing.common.resolvedReference({ + ref: 'sourceGroupByRef', + data: 'groupData', + find: find.group, + }), sourceAlbums: Thing.common.resolvedReferenceList({ list: 'sourceAlbumsByRef', diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 915474d4..36a1f58a 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -193,40 +193,23 @@ export default class Thing extends CacheableObject { // Corresponding dynamic property to referenceList, which takes the values // in the provided property and searches the specified wiki data for // matching actual Thing-subclass objects. - resolvedReferenceList({ - list, - data, - find: findFunction, - }) { + resolvedReferenceList({list, data, find}) { return Thing.composite.from(`Thing.common.resolvedReferenceList`, [ Thing.composite.withResolvedReferenceList({ - list, - data, - to: '#things', - find: findFunction, + list, data, find, notFoundMode: 'filter', }), - - Thing.composite.exposeDependency('#things'), + Thing.composite.exposeDependency('#resolvedReferenceList'), ]); }, // Corresponding function for a single reference. - dynamicThingFromSingleReference: ( - singleReferenceProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, - - expose: { - dependencies: [singleReferenceProperty, thingDataProperty], - compute: ({ - [singleReferenceProperty]: ref, - [thingDataProperty]: thingData, - }) => (ref && thingData ? findFn(ref, thingData, {mode: 'quiet'}) : null), - }, - }), + resolvedReference({ref, data, find}) { + return Thing.composite.from(`Thing.common.resolvedReference`, [ + Thing.composite.withResolvedReference({ref, data, find}), + Thing.composite.exposeDependency('#resolvedReference'), + ]); + }, // Corresponding dynamic property to contribsByRef, which takes the values // in the provided property and searches the object's artistData for @@ -273,10 +256,7 @@ export default class Thing extends CacheableObject { // you would use this to compute a corresponding "referenced *by* tracks" // property. Naturally, the passed ref list property is of the things in the // wiki data provided, not the requesting Thing itself. - reverseReferenceList({ - data, - list, - }) { + reverseReferenceList({data, list}) { return Thing.composite.from(`Thing.common.reverseReferenceList`, [ Thing.composite.withReverseReferenceList({data, list}), Thing.composite.exposeDependency('#reverseReferenceList'), diff --git a/src/data/things/track.js b/src/data/things/track.js index 87e796b9..2b628b66 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -145,7 +145,11 @@ export class Track extends Thing { // not generally relevant information). It's also not guaranteed that // dataSourceAlbum is available (depending on the Track creator to optionally // provide dataSourceAlbumByRef). - dataSourceAlbum: Thing.common.dynamicThingFromSingleReference('dataSourceAlbumByRef', 'albumData', find.album), + dataSourceAlbum: Thing.common.resolvedReference({ + ref: 'dataSourceAlbumByRef', + data: 'albumData', + find: find.album, + }), date: Thing.composite.from(`Track.date`, [ Thing.composite.exposeDependencyOrContinue('dateFirstReleased'), -- cgit 1.3.0-6-gf8a5 From 659620b7522d0e36ca15a54716b46d83f0bfc4f3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 17:45:56 -0300 Subject: data: move composite helper functions to top function scope --- src/data/things/composite.js | 390 +++++++++++++++++++++---------------------- 1 file changed, 195 insertions(+), 195 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index d3f76b11..805331a9 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -496,255 +496,255 @@ function compositeFrom(firstArg, secondArg) { aggregate.close(); - const constructedDescriptor = {}; - - if (annotation) { - constructedDescriptor.annotation = annotation; - } + function _filterDependencies(availableDependencies, { + dependencies, + mapDependencies, + options, + }) { + const filteredDependencies = + (dependencies + ? filterProperties(availableDependencies, dependencies) + : {}); + + if (mapDependencies) { + for (const [to, from] of Object.entries(mapDependencies)) { + filteredDependencies[to] = availableDependencies[from] ?? null; + } + } - constructedDescriptor.flags = { - update: baseUpdates, - expose: baseExposes, - compose: baseComposes, - }; + if (options) { + filteredDependencies['#options'] = options; + } - if (baseUpdates) { - constructedDescriptor.update = base.update; + return filteredDependencies; } - if (baseExposes) { - const expose = constructedDescriptor.expose = {}; - expose.dependencies = Array.from(exposeDependencies); - - const continuationSymbol = Symbol('continuation symbol'); - const noTransformSymbol = Symbol('no-transform symbol'); - - function _filterDependencies(availableDependencies, { - dependencies, - mapDependencies, - options, - }) { - const filteredDependencies = - (dependencies - ? filterProperties(availableDependencies, dependencies) - : {}); - - if (mapDependencies) { - for (const [to, from] of Object.entries(mapDependencies)) { - filteredDependencies[to] = availableDependencies[from] ?? null; - } - } - - if (options) { - filteredDependencies['#options'] = options; - } - - return filteredDependencies; + function _assignDependencies(continuationAssignment, {mapContinuation}) { + if (!mapContinuation) { + return continuationAssignment; } - function _assignDependencies(continuationAssignment, {mapContinuation}) { - if (!mapContinuation) { - return continuationAssignment; - } + const assignDependencies = {}; - const assignDependencies = {}; + for (const [from, to] of Object.entries(mapContinuation)) { + assignDependencies[to] = continuationAssignment[from] ?? null; + } - for (const [from, to] of Object.entries(mapContinuation)) { - assignDependencies[to] = continuationAssignment[from] ?? null; - } + return assignDependencies; + } - return assignDependencies; - } + function _prepareContinuation(callingTransformForThisStep) { + const continuationStorage = { + returnedWith: null, + providedDependencies: undefined, + providedValue: undefined, + }; - function _prepareContinuation(callingTransformForThisStep) { - const continuationStorage = { - returnedWith: null, - providedDependencies: undefined, - providedValue: undefined, - }; + const continuation = + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.exit = (providedValue) => { + continuationStorage.returnedWith = 'exit'; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + }; - const continuation = + if (baseComposes) { + const makeRaiseLike = returnWith => (callingTransformForThisStep ? (providedValue, providedDependencies = null) => { - continuationStorage.returnedWith = 'continuation'; + continuationStorage.returnedWith = returnWith; continuationStorage.providedDependencies = providedDependencies; continuationStorage.providedValue = providedValue; return continuationSymbol; } : (providedDependencies = null) => { - continuationStorage.returnedWith = 'continuation'; + continuationStorage.returnedWith = returnWith; continuationStorage.providedDependencies = providedDependencies; return continuationSymbol; }); - continuation.exit = (providedValue) => { - continuationStorage.returnedWith = 'exit'; - continuationStorage.providedValue = providedValue; - return continuationSymbol; - }; - - if (baseComposes) { - const makeRaiseLike = returnWith => - (callingTransformForThisStep - ? (providedValue, providedDependencies = null) => { - continuationStorage.returnedWith = returnWith; - continuationStorage.providedDependencies = providedDependencies; - continuationStorage.providedValue = providedValue; - return continuationSymbol; - } - : (providedDependencies = null) => { - continuationStorage.returnedWith = returnWith; - continuationStorage.providedDependencies = providedDependencies; - return continuationSymbol; - }); - - continuation.raise = makeRaiseLike('raise'); - continuation.raiseAbove = makeRaiseLike('raiseAbove'); - } - - return {continuation, continuationStorage}; + continuation.raise = makeRaiseLike('raise'); + continuation.raiseAbove = makeRaiseLike('raiseAbove'); } - function _computeOrTransform(initialValue, initialDependencies, continuationIfApplicable) { - const expectingTransform = initialValue !== noTransformSymbol; + return {continuation, continuationStorage}; + } - let valueSoFar = - (expectingTransform - ? initialValue - : undefined); + const continuationSymbol = Symbol('continuation symbol'); + const noTransformSymbol = Symbol('no-transform symbol'); - const availableDependencies = {...initialDependencies}; + function _computeOrTransform(initialValue, initialDependencies, continuationIfApplicable) { + const expectingTransform = initialValue !== noTransformSymbol; - if (expectingTransform) { - debug(() => [color.bright(`begin composition - transforming from:`), initialValue]); - } else { - debug(() => color.bright(`begin composition - not transforming`)); - } + let valueSoFar = + (expectingTransform + ? initialValue + : undefined); - for (let i = 0; i < steps.length; i++) { - const step = steps[i]; - const isBase = i === steps.length - 1; + const availableDependencies = {...initialDependencies}; - debug(() => [ - `step #${i+1}` + - (isBase - ? ` (base):` - : ` of ${steps.length}:`), - step]); + if (expectingTransform) { + debug(() => [color.bright(`begin composition - transforming from:`), initialValue]); + } else { + debug(() => color.bright(`begin composition - not transforming`)); + } - const expose = - (step.flags - ? step.expose - : step); + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; - const callingTransformForThisStep = - expectingTransform && expose.transform; + debug(() => [ + `step #${i+1}` + + (isBase + ? ` (base):` + : ` of ${steps.length}:`), + step]); - const filteredDependencies = _filterDependencies(availableDependencies, expose); - const {continuation, continuationStorage} = _prepareContinuation(callingTransformForThisStep); + const expose = + (step.flags + ? step.expose + : step); - debug(() => [ - `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, - `with dependencies:`, filteredDependencies]); + const callingTransformForThisStep = + expectingTransform && expose.transform; - const result = - (callingTransformForThisStep - ? expose.transform(valueSoFar, filteredDependencies, continuation) - : expose.compute(filteredDependencies, continuation)); + const filteredDependencies = _filterDependencies(availableDependencies, expose); + const {continuation, continuationStorage} = _prepareContinuation(callingTransformForThisStep); - if (result !== continuationSymbol) { - debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); + debug(() => [ + `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, + `with dependencies:`, filteredDependencies]); - if (baseComposes) { - throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); - } + const result = + (callingTransformForThisStep + ? expose.transform(valueSoFar, filteredDependencies, continuation) + : expose.compute(filteredDependencies, continuation)); - debug(() => color.bright(`end composition - exit (inferred)`)); + if (result !== continuationSymbol) { + debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); - return result; + if (baseComposes) { + throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); } - const {returnedWith} = continuationStorage; + debug(() => color.bright(`end composition - exit (inferred)`)); + + return result; + } - if (returnedWith === 'exit') { - const {providedValue} = continuationStorage; + const {returnedWith} = continuationStorage; - debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); - debug(() => color.bright(`end composition - exit (explicit)`)); + if (returnedWith === 'exit') { + const {providedValue} = continuationStorage; - if (baseComposes) { - return continuationIfApplicable.exit(providedValue); - } else { - return providedValue; - } + debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); + debug(() => color.bright(`end composition - exit (explicit)`)); + + if (baseComposes) { + return continuationIfApplicable.exit(providedValue); + } else { + return providedValue; } + } - const {providedValue, providedDependencies} = continuationStorage; - - const continuingWithValue = - (expectingTransform - ? (callingTransformForThisStep - ? providedValue ?? null - : valueSoFar ?? null) - : undefined); - - const continuingWithDependencies = - (providedDependencies - ? _assignDependencies(providedDependencies, expose) - : null); - - const continuationArgs = []; - if (continuingWithValue !== undefined) continuationArgs.push(continuingWithValue); - if (continuingWithDependencies !== null) continuationArgs.push(continuingWithDependencies); - - debug(() => { - const base = `step #${i+1} - result: ` + returnedWith; - const parts = []; - - if (callingTransformForThisStep) { - if (continuingWithValue === undefined) { - parts.push(`(no value)`); - } else { - parts.push(`value:`, providedValue); - } - } + const {providedValue, providedDependencies} = continuationStorage; - if (continuingWithDependencies !== null) { - parts.push(`deps:`, continuingWithDependencies); - } else { - parts.push(`(no deps)`); - } + const continuingWithValue = + (expectingTransform + ? (callingTransformForThisStep + ? providedValue ?? null + : valueSoFar ?? null) + : undefined); + + const continuingWithDependencies = + (providedDependencies + ? _assignDependencies(providedDependencies, expose) + : null); - if (empty(parts)) { - return base; + const continuationArgs = []; + if (continuingWithValue !== undefined) continuationArgs.push(continuingWithValue); + if (continuingWithDependencies !== null) continuationArgs.push(continuingWithDependencies); + + debug(() => { + const base = `step #${i+1} - result: ` + returnedWith; + const parts = []; + + if (callingTransformForThisStep) { + if (continuingWithValue === undefined) { + parts.push(`(no value)`); } else { - return [base + ' ->', ...parts]; + parts.push(`value:`, providedValue); } - }); + } - switch (returnedWith) { - case 'raise': - debug(() => - (isBase - ? color.bright(`end composition - raise (base: explicit)`) - : color.bright(`end composition - raise`))); - return continuationIfApplicable(...continuationArgs); + if (continuingWithDependencies !== null) { + parts.push(`deps:`, continuingWithDependencies); + } else { + parts.push(`(no deps)`); + } - case 'raiseAbove': - debug(() => color.bright(`end composition - raiseAbove`)); - return continuationIfApplicable.raise(...continuationArgs); - - case 'continuation': - if (isBase) { - debug(() => color.bright(`end composition - raise (inferred)`)); - return continuationIfApplicable(...continuationArgs); - } else { - Object.assign(availableDependencies, continuingWithDependencies); - break; - } + if (empty(parts)) { + return base; + } else { + return [base + ' ->', ...parts]; } + }); + + switch (returnedWith) { + case 'raise': + debug(() => + (isBase + ? color.bright(`end composition - raise (base: explicit)`) + : color.bright(`end composition - raise`))); + return continuationIfApplicable(...continuationArgs); + + case 'raiseAbove': + debug(() => color.bright(`end composition - raiseAbove`)); + return continuationIfApplicable.raise(...continuationArgs); + + case 'continuation': + if (isBase) { + debug(() => color.bright(`end composition - raise (inferred)`)); + return continuationIfApplicable(...continuationArgs); + } else { + Object.assign(availableDependencies, continuingWithDependencies); + break; + } } } + } + + const constructedDescriptor = {}; + + if (annotation) { + constructedDescriptor.annotation = annotation; + } + + constructedDescriptor.flags = { + update: baseUpdates, + expose: baseExposes, + compose: baseComposes, + }; + + if (baseUpdates) { + constructedDescriptor.update = base.update; + } + + if (baseExposes) { + const expose = constructedDescriptor.expose = {}; + expose.dependencies = Array.from(exposeDependencies); const transformFn = (value, initialDependencies, continuationIfApplicable) => -- cgit 1.3.0-6-gf8a5 From 1594885c506ed76c0f4f1dc58ab14a4fabba6be5 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 17:46:36 -0300 Subject: data: don't pass dependencies without expose properties --- src/data/things/composite.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 805331a9..21cf365f 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -501,6 +501,10 @@ function compositeFrom(firstArg, secondArg) { mapDependencies, options, }) { + if (!dependencies && !mapDependencies && !options) { + return null; + } + const filteredDependencies = (dependencies ? filterProperties(availableDependencies, dependencies) @@ -629,8 +633,12 @@ function compositeFrom(firstArg, secondArg) { const result = (callingTransformForThisStep - ? expose.transform(valueSoFar, filteredDependencies, continuation) - : expose.compute(filteredDependencies, continuation)); + ? (filteredDependencies + ? expose.transform(valueSoFar, filteredDependencies, continuation) + : expose.transform(valueSoFar, continuation)) + : (filteredDependencies + ? expose.compute(filteredDependencies, continuation) + : expose.compute(continuation))); if (result !== continuationSymbol) { debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); @@ -998,7 +1006,7 @@ export function exposeUpdateValueOrContinue({ }, { - transform: (value, {}, continuation) => + transform: (value, continuation) => continuation.exit(value), }, ]); -- cgit 1.3.0-6-gf8a5 From c6ba294c4fef425074f2352b640cc02c4768ee6e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Wed, 6 Sep 2023 17:49:56 -0300 Subject: data: unused import fixes --- src/data/things/thing.js | 2 +- src/data/things/track.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 36a1f58a..968dd102 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -5,7 +5,7 @@ import {inspect} from 'node:util'; import {color} from '#cli'; import find from '#find'; -import {empty, filterProperties, openAggregate} from '#sugar'; +import {empty} from '#sugar'; import {getKebabCase} from '#wiki-data'; import { diff --git a/src/data/things/track.js b/src/data/things/track.js index 2b628b66..7d7e8a68 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -16,7 +16,6 @@ export class Track extends Thing { Flash, validators: { - isBoolean, isColor, isDate, isDuration, -- cgit 1.3.0-6-gf8a5 From ff6d14354612a9da430d523fa9dbc237cae3a6e2 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 09:38:26 -0300 Subject: infra, data: allow exporting non-classes from things/ files --- src/data/things/index.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/data/things/index.js b/src/data/things/index.js index 591cdc3b..2d4f77d7 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -82,6 +82,8 @@ function errorDuplicateClassNames() { function flattenClassLists() { for (const classes of Object.values(allClassLists)) { for (const [name, constructor] of Object.entries(classes)) { + if (typeof constructor !== 'function') continue; + if (!(constructor.prototype instanceof Thing)) continue; allClasses[name] = constructor; } } -- cgit 1.3.0-6-gf8a5 From 78d293d5f4eea7ed6ee6f3cddd3ffcf73c5056a0 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 09:40:55 -0300 Subject: data: directly import from #composite; define own utils at module --- package.json | 1 + src/data/things/composite.js | 32 +-- src/data/things/thing.js | 36 ++- src/data/things/track.js | 670 ++++++++++++++++++++++--------------------- 4 files changed, 380 insertions(+), 359 deletions(-) diff --git a/package.json b/package.json index 3b8e1771..a3f14d3a 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ }, "imports": { "#colors": "./src/util/colors.js", + "#composite": "./src/data/things/composite.js", "#content-dependencies": "./src/content/dependencies/index.js", "#content-function": "./src/content-function.js", "#cli": "./src/util/cli.js", diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 21cf365f..bcc52a2a 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -808,7 +808,7 @@ function _export(mapping) { const mappingEntries = Object.entries(mapping); return { - annotation: `Thing.composite.export`, + annotation: `export`, flags: {expose: true, compose: true}, expose: { @@ -853,7 +853,7 @@ export function exposeDependency(dependency, { update = false, } = {}) { return { - annotation: `Thing.composite.exposeDependency`, + annotation: `exposeDependency`, flags: {expose: true, update: !!update}, expose: { @@ -878,7 +878,7 @@ export function exposeConstant(value, { update = false, } = {}) { return { - annotation: `Thing.composite.exposeConstant`, + annotation: `exposeConstant`, flags: {expose: true, update: !!update}, expose: { @@ -934,7 +934,7 @@ export function withResultOfAvailabilityCheck({ if (fromDependency) { return { - annotation: `Thing.composite.withResultOfAvailabilityCheck.fromDependency`, + annotation: `withResultOfAvailabilityCheck.fromDependency`, flags: {expose: true, compose: true}, expose: { mapDependencies: {from: fromDependency}, @@ -946,7 +946,7 @@ export function withResultOfAvailabilityCheck({ }; } else { return { - annotation: `Thing.composite.withResultOfAvailabilityCheck.fromUpdateValue`, + annotation: `withResultOfAvailabilityCheck.fromUpdateValue`, flags: {expose: true, compose: true}, expose: { mapContinuation: {to}, @@ -963,7 +963,7 @@ export function withResultOfAvailabilityCheck({ export function exposeDependencyOrContinue(dependency, { mode = 'null', } = {}) { - return compositeFrom(`Thing.composite.exposeDependencyOrContinue`, [ + return compositeFrom(`exposeDependencyOrContinue`, [ withResultOfAvailabilityCheck({ fromDependency: dependency, mode, @@ -991,7 +991,7 @@ export function exposeDependencyOrContinue(dependency, { export function exposeUpdateValueOrContinue({ mode = 'null', } = {}) { - return compositeFrom(`Thing.composite.exposeUpdateValueOrContinue`, [ + return compositeFrom(`exposeUpdateValueOrContinue`, [ withResultOfAvailabilityCheck({ fromUpdateValue: true, mode, @@ -1019,7 +1019,7 @@ export function earlyExitIfAvailabilityCheckFailed({ availability = '#availability', value = null, } = {}) { - return compositeFrom(`Thing.composite.earlyExitIfAvailabilityCheckFailed`, [ + return compositeFrom(`earlyExitIfAvailabilityCheckFailed`, [ { mapDependencies: {availability}, compute: ({availability}, continuation) => @@ -1042,7 +1042,7 @@ export function earlyExitWithoutDependency(dependency, { mode = 'null', value = null, } = {}) { - return compositeFrom(`Thing.composite.earlyExitWithoutDependency`, [ + return compositeFrom(`earlyExitWithoutDependency`, [ withResultOfAvailabilityCheck({fromDependency: dependency, mode}), earlyExitIfAvailabilityCheckFailed({value}), ]); @@ -1054,7 +1054,7 @@ export function earlyExitWithoutUpdateValue({ mode = 'null', value = null, } = {}) { - return compositeFrom(`Thing.composite.earlyExitWithoutDependency`, [ + return compositeFrom(`earlyExitWithoutDependency`, [ withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), earlyExitIfAvailabilityCheckFailed({value}), ]); @@ -1067,7 +1067,7 @@ export function raiseWithoutDependency(dependency, { map = {}, raise = {}, } = {}) { - return compositeFrom(`Thing.composite.raiseWithoutDependency`, [ + return compositeFrom(`raiseWithoutDependency`, [ withResultOfAvailabilityCheck({fromDependency: dependency, mode}), { @@ -1094,7 +1094,7 @@ export function raiseWithoutUpdateValue({ map = {}, raise = {}, } = {}) { - return compositeFrom(`Thing.composite.raiseWithoutUpdateValue`, [ + return compositeFrom(`raiseWithoutUpdateValue`, [ withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), { @@ -1121,8 +1121,8 @@ export function raiseWithoutUpdateValue({ // means mapping the "who" reference of each contribution to an artist // object, and filtering out those whose "who" doesn't match any artist. export function withResolvedContribs({from, to}) { - return Thing.composite.from(`Thing.composite.withResolvedContribs`, [ - Thing.composite.raiseWithoutDependency(from, { + return compositeFrom(`withResolvedContribs`, [ + raiseWithoutDependency(from, { mode: 'empty', map: {to}, raise: {to: []}, @@ -1172,7 +1172,7 @@ export function withResolvedReference({ to = '#resolvedReference', earlyExitIfNotFound = false, }) { - return compositeFrom(`Thing.composite.withResolvedReference`, [ + return compositeFrom(`withResolvedReference`, [ raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), earlyExitWithoutDependency(data), @@ -1210,7 +1210,7 @@ export function withResolvedReferenceList({ throw new TypeError(`Expected notFoundMode to be filter, exit, or null`); } - return compositeFrom(`Thing.composite.withResolvedReferenceList`, [ + return compositeFrom(`withResolvedReferenceList`, [ earlyExitWithoutDependency(data, {value: []}), raiseWithoutDependency(list, { diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 968dd102..0716931a 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -8,6 +8,15 @@ import find from '#find'; import {empty} from '#sugar'; import {getKebabCase} from '#wiki-data'; +import { + from as compositeFrom, + exposeDependency, + withReverseReferenceList, + withResolvedContribs, + withResolvedReference, + withResolvedReferenceList, +} from '#composite'; + import { isAdditionalFileList, isBoolean, @@ -27,7 +36,6 @@ import { } from '#validators'; import CacheableObject from './cacheable-object.js'; -import * as composite from './composite.js'; export default class Thing extends CacheableObject { static referenceType = Symbol('Thing.referenceType'); @@ -194,20 +202,20 @@ export default class Thing extends CacheableObject { // in the provided property and searches the specified wiki data for // matching actual Thing-subclass objects. resolvedReferenceList({list, data, find}) { - return Thing.composite.from(`Thing.common.resolvedReferenceList`, [ - Thing.composite.withResolvedReferenceList({ + return compositeFrom(`Thing.common.resolvedReferenceList`, [ + withResolvedReferenceList({ list, data, find, notFoundMode: 'filter', }), - Thing.composite.exposeDependency('#resolvedReferenceList'), + exposeDependency('#resolvedReferenceList'), ]); }, // Corresponding function for a single reference. resolvedReference({ref, data, find}) { - return Thing.composite.from(`Thing.common.resolvedReference`, [ - Thing.composite.withResolvedReference({ref, data, find}), - Thing.composite.exposeDependency('#resolvedReference'), + return compositeFrom(`Thing.common.resolvedReference`, [ + withResolvedReference({ref, data, find}), + exposeDependency('#resolvedReference'), ]); }, @@ -227,13 +235,13 @@ export default class Thing extends CacheableObject { // reference list is somehow messed up, or artistData isn't being provided // properly.) dynamicContribs(contribsByRefProperty) { - return Thing.composite.from(`Thing.common.dynamicContribs`, [ - Thing.composite.withResolvedContribs({ + return compositeFrom(`Thing.common.dynamicContribs`, [ + withResolvedContribs({ from: contribsByRefProperty, to: '#contribs', }), - Thing.composite.exposeDependency('#contribs'), + exposeDependency('#contribs'), ]); }, @@ -257,9 +265,9 @@ export default class Thing extends CacheableObject { // property. Naturally, the passed ref list property is of the things in the // wiki data provided, not the requesting Thing itself. reverseReferenceList({data, list}) { - return Thing.composite.from(`Thing.common.reverseReferenceList`, [ - Thing.composite.withReverseReferenceList({data, list}), - Thing.composite.exposeDependency('#reverseReferenceList'), + return compositeFrom(`Thing.common.reverseReferenceList`, [ + withReverseReferenceList({data, list}), + exposeDependency('#reverseReferenceList'), ]); }, @@ -323,6 +331,4 @@ export default class Thing extends CacheableObject { return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; } - - static composite = composite; } diff --git a/src/data/things/track.js b/src/data/things/track.js index 7d7e8a68..c5e6ff34 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -4,6 +4,19 @@ import {color} from '#cli'; import find from '#find'; import {empty} from '#sugar'; +import { + from as compositeFrom, + earlyExitWithoutDependency, + exposeConstant, + exposeDependency, + exposeDependencyOrContinue, + exposeUpdateValueOrContinue, + withResolvedContribs, + withResolvedReference, + withResultOfAvailabilityCheck, + withReverseReferenceList, +} from '#composite'; + import Thing from './thing.js'; export class Track extends Thing { @@ -43,9 +56,9 @@ export class Track extends Thing { sampledTracksByRef: Thing.common.referenceList(Track), artTagsByRef: Thing.common.referenceList(ArtTag), - color: Thing.composite.from(`Track.color`, [ - Thing.composite.exposeUpdateValueOrContinue(), - Track.composite.withContainingTrackSection({earlyExitIfNotFound: false}), + color: compositeFrom(`Track.color`, [ + exposeUpdateValueOrContinue(), + withContainingTrackSection({earlyExitIfNotFound: false}), { dependencies: ['#trackSection'], @@ -59,8 +72,8 @@ export class Track extends Thing { : continuation()), }, - Track.composite.withAlbumProperty('color'), - Thing.composite.exposeDependency('#album.color', { + withAlbumProperty('color'), + exposeDependency('#album.color', { update: {validate: isColor}, }), ]), @@ -75,21 +88,21 @@ export class Track extends Thing { // track's unique cover artwork, if any, and does not inherit the extension // of the album's main artwork. It does inherit trackCoverArtFileExtension, // if present on the album. - coverArtFileExtension: Thing.composite.from(`Track.coverArtFileExtension`, [ + coverArtFileExtension: compositeFrom(`Track.coverArtFileExtension`, [ // No cover art file extension if the track doesn't have unique artwork // in the first place. - Track.composite.withHasUniqueCoverArt(), - Thing.composite.earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), + withHasUniqueCoverArt(), + earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), // Expose custom coverArtFileExtension update value first. - Thing.composite.exposeUpdateValueOrContinue(), + exposeUpdateValueOrContinue(), // Expose album's trackCoverArtFileExtension if no update value set. - Track.composite.withAlbumProperty('trackCoverArtFileExtension'), - Thing.composite.exposeDependencyOrContinue('#album.trackCoverArtFileExtension'), + withAlbumProperty('trackCoverArtFileExtension'), + exposeDependencyOrContinue('#album.trackCoverArtFileExtension'), // Fallback to 'jpg'. - Thing.composite.exposeConstant('jpg', { + exposeConstant('jpg', { update: {validate: isFileExtension}, }), ]), @@ -98,14 +111,14 @@ export class Track extends Thing { // only the track's own unique cover artwork, if any. This exposes only as // the track's own coverArtDate or its album's trackArtDate, so if neither // is specified, this value is null. - coverArtDate: Thing.composite.from(`Track.coverArtDate`, [ - Track.composite.withHasUniqueCoverArt(), - Thing.composite.earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), + coverArtDate: compositeFrom(`Track.coverArtDate`, [ + withHasUniqueCoverArt(), + earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), - Thing.composite.exposeUpdateValueOrContinue(), + exposeUpdateValueOrContinue(), - Track.composite.withAlbumProperty('trackArtDate'), - Thing.composite.exposeDependency('#album.trackArtDate', { + withAlbumProperty('trackArtDate'), + exposeDependency('#album.trackArtDate', { update: {validate: isDate}, }), ]), @@ -132,9 +145,9 @@ export class Track extends Thing { commentatorArtists: Thing.common.commentatorArtists(), - album: Thing.composite.from(`Track.album`, [ - Track.composite.withAlbum(), - Thing.composite.exposeDependency('#album'), + album: compositeFrom(`Track.album`, [ + withAlbum(), + exposeDependency('#album'), ]), // Note - this is an internal property used only to help identify a track. @@ -150,10 +163,10 @@ export class Track extends Thing { find: find.album, }), - date: Thing.composite.from(`Track.date`, [ - Thing.composite.exposeDependencyOrContinue('dateFirstReleased'), - Track.composite.withAlbumProperty('date'), - Thing.composite.exposeDependency('#album.date'), + date: compositeFrom(`Track.date`, [ + exposeDependencyOrContinue('dateFirstReleased'), + withAlbumProperty('date'), + exposeDependency('#album.date'), ]), // Whether or not the track has "unique" cover artwork - a cover which is @@ -163,19 +176,19 @@ export class Track extends Thing { // or a placeholder. (This property is named hasUniqueCoverArt instead of // the usual hasCoverArt to emphasize that it does not inherit from the // album.) - hasUniqueCoverArt: Thing.composite.from(`Track.hasUniqueCoverArt`, [ - Track.composite.withHasUniqueCoverArt(), - Thing.composite.exposeDependency('#hasUniqueCoverArt'), + hasUniqueCoverArt: compositeFrom(`Track.hasUniqueCoverArt`, [ + withHasUniqueCoverArt(), + exposeDependency('#hasUniqueCoverArt'), ]), - originalReleaseTrack: Thing.composite.from(`Track.originalReleaseTrack`, [ - Track.composite.withOriginalRelease(), - Thing.composite.exposeDependency('#originalRelease'), + originalReleaseTrack: compositeFrom(`Track.originalReleaseTrack`, [ + withOriginalRelease(), + exposeDependency('#originalRelease'), ]), - otherReleases: Thing.composite.from(`Track.otherReleases`, [ - Thing.composite.earlyExitWithoutDependency('trackData', {mode: 'empty'}), - Track.composite.withOriginalRelease({selfIfOriginal: true}), + otherReleases: compositeFrom(`Track.otherReleases`, [ + earlyExitWithoutDependency('trackData', {mode: 'empty'}), + withOriginalRelease({selfIfOriginal: true}), { flags: {expose: true}, @@ -197,10 +210,10 @@ export class Track extends Thing { }, ]), - artistContribs: Thing.composite.from(`Track.artistContribs`, [ - Track.composite.inheritFromOriginalRelease({property: 'artistContribs'}), + artistContribs: compositeFrom(`Track.artistContribs`, [ + inheritFromOriginalRelease({property: 'artistContribs'}), - Thing.composite.withResolvedContribs({ + withResolvedContribs({ from: 'artistContribsByRef', to: '#artistContribs', }), @@ -213,19 +226,19 @@ export class Track extends Thing { : contribsFromTrack), }, - Track.composite.withAlbumProperty('artistContribs'), - Thing.composite.exposeDependency('#album.artistContribs'), + withAlbumProperty('artistContribs'), + exposeDependency('#album.artistContribs'), ]), - contributorContribs: Thing.composite.from(`Track.contributorContribs`, [ - Track.composite.inheritFromOriginalRelease({property: 'contributorContribs'}), + contributorContribs: compositeFrom(`Track.contributorContribs`, [ + inheritFromOriginalRelease({property: 'contributorContribs'}), Thing.common.dynamicContribs('contributorContribsByRef'), ]), // Cover artists aren't inherited from the original release, since it // typically varies by release and isn't defined by the musical qualities // of the track. - coverArtistContribs: Thing.composite.from(`Track.coverArtistContribs`, [ + coverArtistContribs: compositeFrom(`Track.coverArtistContribs`, [ { dependencies: ['disableUniqueCoverArt'], compute: ({disableUniqueCoverArt}, continuation) => @@ -234,7 +247,7 @@ export class Track extends Thing { : continuation()), }, - Thing.composite.withResolvedContribs({ + withResolvedContribs({ from: 'coverArtistContribsByRef', to: '#coverArtistContribs', }), @@ -247,12 +260,12 @@ export class Track extends Thing { : contribsFromTrack), }, - Track.composite.withAlbumProperty('trackCoverArtistContribs'), - Thing.composite.exposeDependency('#album.trackCoverArtistContribs'), + withAlbumProperty('trackCoverArtistContribs'), + exposeDependency('#album.trackCoverArtistContribs'), ]), - referencedTracks: Thing.composite.from(`Track.referencedTracks`, [ - Track.composite.inheritFromOriginalRelease({property: 'referencedTracks'}), + referencedTracks: compositeFrom(`Track.referencedTracks`, [ + inheritFromOriginalRelease({property: 'referencedTracks'}), Thing.common.resolvedReferenceList({ list: 'referencedTracksByRef', data: 'trackData', @@ -260,8 +273,8 @@ export class Track extends Thing { }), ]), - sampledTracks: Thing.composite.from(`Track.sampledTracks`, [ - Track.composite.inheritFromOriginalRelease({property: 'sampledTracks'}), + sampledTracks: compositeFrom(`Track.sampledTracks`, [ + inheritFromOriginalRelease({property: 'sampledTracks'}), Thing.common.resolvedReferenceList({ list: 'sampledTracksByRef', data: 'trackData', @@ -283,10 +296,10 @@ export class Track extends Thing { // counting the number of times a track has been referenced, for use in // the "Tracks - by Times Referenced" listing page (or other data // processing). - referencedByTracks: Track.composite.trackReverseReferenceList('referencedTracks'), + referencedByTracks: trackReverseReferenceList('referencedTracks'), // For the same reasoning, exclude re-releases from sampled tracks too. - sampledByTracks: Track.composite.trackReverseReferenceList('sampledTracks'), + sampledByTracks: trackReverseReferenceList('sampledTracks'), featuredInFlashes: Thing.common.reverseReferenceList({ data: 'flashData', @@ -294,309 +307,310 @@ export class Track extends Thing { }), }); - static composite = { - // Early exits with a value inherited from the original release, if - // this track is a rerelease, and otherwise continues with no further - // dependencies provided. If allowOverride is true, then the continuation - // will also be called if the original release exposed the requested - // property as null. - inheritFromOriginalRelease({ - property: originalProperty, - allowOverride = false, - }) { - return Thing.composite.from(`Track.composite.inheritFromOriginalRelease`, [ - Track.composite.withOriginalRelease(), - - { - dependencies: ['#originalRelease'], - compute({'#originalRelease': originalRelease}, continuation) { - if (!originalRelease) return continuation.raise(); - - const value = originalRelease[originalProperty]; - if (allowOverride && value === null) return continuation.raise(); - - return continuation.exit(value); - }, - }, - ]); - }, + [inspect.custom](depth) { + const parts = []; - // Gets the track's album. Unless earlyExitIfNotFound is overridden false, - // this will early exit with null in two cases - albumData being missing, - // or not including an album whose .tracks array includes this track. - withAlbum({to = '#album', earlyExitIfNotFound = true} = {}) { - return Thing.composite.from(`Track.composite.withAlbum`, [ - Thing.composite.withResultOfAvailabilityCheck({ - fromDependency: 'albumData', - mode: 'empty', - to: '#albumDataAvailability', - }), + parts.push(Thing.prototype[inspect.custom].apply(this)); - { - dependencies: ['#albumDataAvailability'], - options: {earlyExitIfNotFound}, - mapContinuation: {to}, + if (this.originalReleaseTrackByRef) { + parts.unshift(`${color.yellow('[rerelease]')} `); + } - compute: ({ - '#albumDataAvailability': albumDataAvailability, - '#options': {earlyExitIfNotFound}, - }, continuation) => - (albumDataAvailability - ? continuation() - : (earlyExitIfNotFound - ? continuation.exit(null) - : continuation.raise({to: null}))), - }, + let album; + if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) { + const albumName = album.name; + const albumIndex = album.tracks.indexOf(this); + const trackNum = + (albumIndex === -1 + ? '#?' + : `#${albumIndex + 1}`); + parts.push(` (${color.yellow(trackNum)} in ${color.green(albumName)})`); + } - { - dependencies: ['this', 'albumData'], - compute: ({this: track, albumData}, continuation) => - continuation({ - '#album': - albumData.find(album => album.tracks.includes(track)), - }), - }, + return parts.join(''); + } +} - { - dependencies: ['#album'], - options: {earlyExitIfNotFound}, - mapContinuation: {to}, - compute: ({ - '#album': album, - '#options': {earlyExitIfNotFound}, - }, continuation) => - (album - ? continuation.raise({to: album}) - : (earlyExitIfNotFound - ? continuation.exit(null) - : continuation.raise({to: album}))), - }, - ]); +// Early exits with a value inherited from the original release, if +// this track is a rerelease, and otherwise continues with no further +// dependencies provided. If allowOverride is true, then the continuation +// will also be called if the original release exposed the requested +// property as null. +function inheritFromOriginalRelease({ + property: originalProperty, + allowOverride = false, +}) { + return compositeFrom(`inheritFromOriginalRelease`, [ + withOriginalRelease(), + + { + dependencies: ['#originalRelease'], + compute({'#originalRelease': originalRelease}, continuation) { + if (!originalRelease) return continuation.raise(); + + const value = originalRelease[originalProperty]; + if (allowOverride && value === null) return continuation.raise(); + + return continuation.exit(value); + }, }, + ]); +} - // Gets a single property from this track's album, providing it as the same - // property name prefixed with '#album.' (by default). If the track's album - // isn't available, and earlyExitIfNotFound hasn't been set, the property - // will be provided as null. - withAlbumProperty(property, { - to = '#album.' + property, - earlyExitIfNotFound = false, - } = {}) { - return Thing.composite.from(`Track.composite.withAlbumProperty`, [ - Track.composite.withAlbum({earlyExitIfNotFound}), - - { - dependencies: ['#album'], - options: {property}, - mapContinuation: {to}, +// Gets the track's album. Unless earlyExitIfNotFound is overridden false, +// this will early exit with null in two cases - albumData being missing, +// or not including an album whose .tracks array includes this track. +function withAlbum({ + to = '#album', + earlyExitIfNotFound = true, +} = {}) { + return compositeFrom(`withAlbum`, [ + withResultOfAvailabilityCheck({ + fromDependency: 'albumData', + mode: 'empty', + to: '#albumDataAvailability', + }), - compute: ({ - '#album': album, - '#options': {property}, - }, continuation) => - (album - ? continuation.raise({to: album[property]}) - : continuation.raise({to: null})), - }, - ]); + { + dependencies: ['#albumDataAvailability'], + options: {earlyExitIfNotFound}, + mapContinuation: {to}, + + compute: ({ + '#albumDataAvailability': albumDataAvailability, + '#options': {earlyExitIfNotFound}, + }, continuation) => + (albumDataAvailability + ? continuation() + : (earlyExitIfNotFound + ? continuation.exit(null) + : continuation.raise({to: null}))), }, - // Gets the listed properties from this track's album, providing them as - // dependencies (by default) with '#album.' prefixed before each property - // name. If the track's album isn't available, and earlyExitIfNotFound - // hasn't been set, the same dependency names will be provided as null. - withAlbumProperties({ - properties, - prefix = '#album', - earlyExitIfNotFound = false, - }) { - return Thing.composite.from(`Track.composite.withAlbumProperties`, [ - Track.composite.withAlbum({earlyExitIfNotFound}), - - { - dependencies: ['#album'], - options: {properties, prefix}, - - compute({ - '#album': album, - '#options': {properties, prefix}, - }, continuation) { - const raise = {}; - - if (album) { - for (const property of properties) { - raise[prefix + '.' + property] = album[property]; - } - } else { - for (const property of properties) { - raise[prefix + '.' + property] = null; - } - } - - return continuation.raise(raise); - }, - }, - ]); + { + dependencies: ['this', 'albumData'], + compute: ({this: track, albumData}, continuation) => + continuation({ + '#album': + albumData.find(album => album.tracks.includes(track)), + }), }, - // Gets the track section containing this track from its album's track list. - // Unless earlyExitIfNotFound is overridden false, this will early exit if - // the album can't be found or if none of its trackSections includes the - // track for some reason. - withContainingTrackSection({ - to = '#trackSection', - earlyExitIfNotFound = true, - } = {}) { - return Thing.composite.from(`Track.composite.withContainingTrackSection`, [ - Track.composite.withAlbumProperty('trackSections', {earlyExitIfNotFound}), - - { - dependencies: ['this', '#album.trackSections'], - mapContinuation: {to}, - - compute({ - this: track, - '#album.trackSections': trackSections, - }, continuation) { - if (!trackSections) { - return continuation.raise({to: null}); - } - - const trackSection = - trackSections.find(({tracks}) => tracks.includes(track)); - - if (trackSection) { - return continuation.raise({to: trackSection}); - } else if (earlyExitIfNotFound) { - return continuation.exit(null); - } else { - return continuation.raise({to: null}); - } - }, - }, - ]); + { + dependencies: ['#album'], + options: {earlyExitIfNotFound}, + mapContinuation: {to}, + compute: ({ + '#album': album, + '#options': {earlyExitIfNotFound}, + }, continuation) => + (album + ? continuation.raise({to: album}) + : (earlyExitIfNotFound + ? continuation.exit(null) + : continuation.raise({to: album}))), }, + ]); +} - // Just includes the original release of this track as a dependency. - // If this track isn't a rerelease, then it'll provide null, unless the - // {selfIfOriginal} option is set, in which case it'll provide this track - // itself. Note that this will early exit if the original release is - // specified by reference and that reference doesn't resolve to anything. - // Outputs to '#originalRelease' by default. - withOriginalRelease({ - to = '#originalRelease', - selfIfOriginal = false, - } = {}) { - return Thing.composite.from(`Track.composite.withOriginalRelease`, [ - Thing.composite.withResolvedReference({ - ref: 'originalReleaseTrackByRef', - data: 'trackData', - to: '#originalRelease', - find: find.track, - earlyExitIfNotFound: true, - }), - - { - dependencies: ['this', '#originalRelease'], - options: {selfIfOriginal}, - mapContinuation: {to}, - compute: ({ - this: track, - '#originalRelease': originalRelease, - '#options': {selfIfOriginal}, - }, continuation) => - continuation.raise({ - to: - (originalRelease ?? - (selfIfOriginal - ? track - : null)), - }), - }, - ]); +// Gets a single property from this track's album, providing it as the same +// property name prefixed with '#album.' (by default). If the track's album +// isn't available, and earlyExitIfNotFound hasn't been set, the property +// will be provided as null. +function withAlbumProperty(property, { + to = '#album.' + property, + earlyExitIfNotFound = false, +} = {}) { + return compositeFrom(`withAlbumProperty`, [ + withAlbum({earlyExitIfNotFound}), + + { + dependencies: ['#album'], + options: {property}, + mapContinuation: {to}, + + compute: ({ + '#album': album, + '#options': {property}, + }, continuation) => + (album + ? continuation.raise({to: album[property]}) + : continuation.raise({to: null})), }, + ]); +} - // The algorithm for checking if a track has unique cover art is used in a - // couple places, so it's defined in full as a compositional step. - withHasUniqueCoverArt({ - to = '#hasUniqueCoverArt', - } = {}) { - return Thing.composite.from(`Track.composite.withHasUniqueCoverArt`, [ - { - dependencies: ['disableUniqueCoverArt'], - mapContinuation: {to}, - compute: ({disableUniqueCoverArt}, continuation) => - (disableUniqueCoverArt - ? continuation.raise({to: false}) - : continuation()), - }, +// Gets the listed properties from this track's album, providing them as +// dependencies (by default) with '#album.' prefixed before each property +// name. If the track's album isn't available, and earlyExitIfNotFound +// hasn't been set, the same dependency names will be provided as null. +function withAlbumProperties({ + properties, + prefix = '#album', + earlyExitIfNotFound = false, +}) { + return compositeFrom(`withAlbumProperties`, [ + withAlbum({earlyExitIfNotFound}), + + { + dependencies: ['#album'], + options: {properties, prefix}, + + compute({ + '#album': album, + '#options': {properties, prefix}, + }, continuation) { + const raise = {}; + + if (album) { + for (const property of properties) { + raise[prefix + '.' + property] = album[property]; + } + } else { + for (const property of properties) { + raise[prefix + '.' + property] = null; + } + } + + return continuation.raise(raise); + }, + }, + ]); +} - Thing.composite.withResolvedContribs({ - from: 'coverArtistContribsByRef', - to: '#coverArtistContribs', - }), +// Gets the track section containing this track from its album's track list. +// Unless earlyExitIfNotFound is overridden false, this will early exit if +// the album can't be found or if none of its trackSections includes the +// track for some reason. +function withContainingTrackSection({ + to = '#trackSection', + earlyExitIfNotFound = true, +} = {}) { + return compositeFrom(`withContainingTrackSection`, [ + withAlbumProperty('trackSections', {earlyExitIfNotFound}), + + { + dependencies: ['this', '#album.trackSections'], + mapContinuation: {to}, + + compute({ + this: track, + '#album.trackSections': trackSections, + }, continuation) { + if (!trackSections) { + return continuation.raise({to: null}); + } + + const trackSection = + trackSections.find(({tracks}) => tracks.includes(track)); + + if (trackSection) { + return continuation.raise({to: trackSection}); + } else if (earlyExitIfNotFound) { + return continuation.exit(null); + } else { + return continuation.raise({to: null}); + } + }, + }, + ]); +} - { - dependencies: ['#coverArtistContribs'], - mapContinuation: {to}, - compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => - (empty(contribsFromTrack) - ? continuation() - : continuation.raise({to: true})), - }, +// Just includes the original release of this track as a dependency. +// If this track isn't a rerelease, then it'll provide null, unless the +// {selfIfOriginal} option is set, in which case it'll provide this track +// itself. Note that this will early exit if the original release is +// specified by reference and that reference doesn't resolve to anything. +// Outputs to '#originalRelease' by default. +function withOriginalRelease({ + to = '#originalRelease', + selfIfOriginal = false, +} = {}) { + return compositeFrom(`withOriginalRelease`, [ + withResolvedReference({ + ref: 'originalReleaseTrackByRef', + data: 'trackData', + to: '#originalRelease', + find: find.track, + earlyExitIfNotFound: true, + }), - Track.composite.withAlbumProperty('trackCoverArtistContribs'), + { + dependencies: ['this', '#originalRelease'], + options: {selfIfOriginal}, + mapContinuation: {to}, + compute: ({ + this: track, + '#originalRelease': originalRelease, + '#options': {selfIfOriginal}, + }, continuation) => + continuation.raise({ + to: + (originalRelease ?? + (selfIfOriginal + ? track + : null)), + }), + }, + ]); +} - { - dependencies: ['#album.trackCoverArtistContribs'], - mapContinuation: {to}, - compute: ({'#album.trackCoverArtistContribs': contribsFromAlbum}, continuation) => - (empty(contribsFromAlbum) - ? continuation.raise({to: false}) - : continuation.raise({to: true})), - }, - ]); +// The algorithm for checking if a track has unique cover art is used in a +// couple places, so it's defined in full as a compositional step. +function withHasUniqueCoverArt({ + to = '#hasUniqueCoverArt', +} = {}) { + return compositeFrom(`withHasUniqueCoverArt`, [ + { + dependencies: ['disableUniqueCoverArt'], + mapContinuation: {to}, + compute: ({disableUniqueCoverArt}, continuation) => + (disableUniqueCoverArt + ? continuation.raise({to: false}) + : continuation()), }, - trackReverseReferenceList(refListProperty) { - return Thing.composite.from(`Track.composite.trackReverseReferenceList`, [ - Thing.composite.withReverseReferenceList({ - data: 'trackData', - list: refListProperty, - }), + withResolvedContribs({ + from: 'coverArtistContribsByRef', + to: '#coverArtistContribs', + }), - { - flags: {expose: true}, - expose: { - dependencies: ['#reverseReferenceList'], - compute: ({'#reverseReferenceList': reverseReferenceList}) => - reverseReferenceList.filter(track => !track.originalReleaseTrack), - }, - }, - ]); + { + dependencies: ['#coverArtistContribs'], + mapContinuation: {to}, + compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => + (empty(contribsFromTrack) + ? continuation() + : continuation.raise({to: true})), }, - }; - - [inspect.custom](depth) { - const parts = []; - parts.push(Thing.prototype[inspect.custom].apply(this)); + withAlbumProperty('trackCoverArtistContribs'), - if (this.originalReleaseTrackByRef) { - parts.unshift(`${color.yellow('[rerelease]')} `); - } + { + dependencies: ['#album.trackCoverArtistContribs'], + mapContinuation: {to}, + compute: ({'#album.trackCoverArtistContribs': contribsFromAlbum}, continuation) => + (empty(contribsFromAlbum) + ? continuation.raise({to: false}) + : continuation.raise({to: true})), + }, + ]); +} - let album; - if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) { - const albumName = album.name; - const albumIndex = album.tracks.indexOf(this); - const trackNum = - (albumIndex === -1 - ? '#?' - : `#${albumIndex + 1}`); - parts.push(` (${color.yellow(trackNum)} in ${color.green(albumName)})`); - } +function trackReverseReferenceList(refListProperty) { + return compositeFrom(`trackReverseReferenceList`, [ + withReverseReferenceList({ + data: 'trackData', + list: refListProperty, + }), - return parts.join(''); - } + { + flags: {expose: true}, + expose: { + dependencies: ['#reverseReferenceList'], + compute: ({'#reverseReferenceList': reverseReferenceList}) => + reverseReferenceList.filter(track => !track.originalReleaseTrack), + }, + }, + ]); } -- cgit 1.3.0-6-gf8a5 From c86de8a2be3867c14ca92c8e6799fd9b325305ec Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 09:55:29 -0300 Subject: data: move composite utilities related to wiki data into thing.js --- src/data/things/composite.js | 167 ------------------------------------------- src/data/things/thing.js | 166 ++++++++++++++++++++++++++++++++++++++++-- src/data/things/track.js | 9 +-- 3 files changed, 165 insertions(+), 177 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index bcc52a2a..7cba1e97 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1,18 +1,13 @@ import {inspect} from 'node:util'; import {color} from '#cli'; -import find from '#find'; -import {filterMultipleArrays} from '#wiki-data'; import { empty, filterProperties, openAggregate, - stitchArrays, } from '#sugar'; -import Thing from './thing.js'; - // Composes multiple compositional "steps" and a "base" to form a property // descriptor out of modular building blocks. This is an extension to the // more general-purpose CacheableObject property descriptor syntax, and @@ -794,8 +789,6 @@ export function debug(fn) { return value; } -// -- Compositional steps for compositions to nest -- - // Provides dependencies exactly as they are (or null if not defined) to the // continuation. Although this can *technically* be used to alias existing // dependencies to some other name within the middle of a composition, it's @@ -834,8 +827,6 @@ function _export(mapping) { }; } -// -- Compositional steps for top-level property descriptors -- - // Exposes a dependency exactly as it is; this is typically the base of a // composition which was created to serve as one property's descriptor. // Since this serves as a base, specify a value for {update} to indicate @@ -1113,161 +1104,3 @@ export function raiseWithoutUpdateValue({ }, ]); } - -// -- Compositional steps for processing data -- - -// Resolves the contribsByRef contained in the provided dependency, -// providing (named by the second argument) the result. "Resolving" -// means mapping the "who" reference of each contribution to an artist -// object, and filtering out those whose "who" doesn't match any artist. -export function withResolvedContribs({from, to}) { - return compositeFrom(`withResolvedContribs`, [ - raiseWithoutDependency(from, { - mode: 'empty', - map: {to}, - raise: {to: []}, - }), - - { - mapDependencies: {from}, - compute: ({from}, continuation) => - continuation({ - '#whoByRef': from.map(({who}) => who), - '#what': from.map(({what}) => what), - }), - }, - - withResolvedReferenceList({ - list: '#whoByRef', - data: 'artistData', - to: '#who', - find: find.artist, - notFoundMode: 'null', - }), - - { - dependencies: ['#who', '#what'], - mapContinuation: {to}, - compute({'#who': who, '#what': what}, continuation) { - filterMultipleArrays(who, what, (who, _what) => who); - return continuation({ - to: stitchArrays({who, what}), - }); - }, - }, - ]); -} - -// Resolves a reference by using the provided find function to match it -// within the provided thingData dependency. This will early exit if the -// data dependency is null, or, if earlyExitIfNotFound is set to true, -// if the find function doesn't match anything for the reference. -// Otherwise, the data object is provided on the output dependency; -// or null, if the reference doesn't match anything or itself was null -// to begin with. -export function withResolvedReference({ - ref, - data, - find: findFunction, - to = '#resolvedReference', - earlyExitIfNotFound = false, -}) { - return compositeFrom(`withResolvedReference`, [ - raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), - earlyExitWithoutDependency(data), - - { - options: {findFunction, earlyExitIfNotFound}, - mapDependencies: {ref, data}, - mapContinuation: {match: to}, - - compute({ref, data, '#options': {findFunction, earlyExitIfNotFound}}, continuation) { - const match = findFunction(ref, data, {mode: 'quiet'}); - - if (match === null && earlyExitIfNotFound) { - return continuation.exit(null); - } - - return continuation.raise({match}); - }, - }, - ]); -} - -// Resolves a list of references, with each reference matched with provided -// data in the same way as withResolvedReference. This will early exit if the -// data dependency is null (even if the reference list is empty). By default -// it will filter out references which don't match, but this can be changed -// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null'). -export function withResolvedReferenceList({ - list, - data, - find: findFunction, - to = '#resolvedReferenceList', - notFoundMode = 'filter', -}) { - if (!['filter', 'exit', 'null'].includes(notFoundMode)) { - throw new TypeError(`Expected notFoundMode to be filter, exit, or null`); - } - - return compositeFrom(`withResolvedReferenceList`, [ - earlyExitWithoutDependency(data, {value: []}), - - raiseWithoutDependency(list, { - map: {to}, - raise: {to: []}, - mode: 'empty', - }), - - { - options: {findFunction, notFoundMode}, - mapDependencies: {list, data}, - mapContinuation: {matches: to}, - - compute({list, data, '#options': {findFunction, notFoundMode}}, continuation) { - let matches = - list.map(ref => findFunction(ref, data, {mode: 'quiet'})); - - if (!matches.includes(null)) { - return continuation.raise({matches}); - } - - switch (notFoundMode) { - case 'filter': - matches = matches.filter(value => value !== null); - return continuation.raise({matches}); - - case 'exit': - return continuation.exit([]); - - case 'null': - return continuation.raise({matches}); - } - }, - }, - ]); -} - -// Check out the info on Thing.common.reverseReferenceList! -// This is its composable form. -export function withReverseReferenceList({ - data, - list: refListProperty, - to = '#reverseReferenceList', -}) { - return compositeFrom(`Thing.common.reverseReferenceList`, [ - earlyExitWithoutDependency(data, {value: []}), - - { - dependencies: ['this'], - mapDependencies: {data}, - mapContinuation: {to}, - options: {refListProperty}, - - compute: ({this: thisThing, data, '#options': {refListProperty}}, continuation) => - continuation({ - to: data.filter(thing => thing[refListProperty].includes(thisThing)), - }), - }, - ]); -} diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 0716931a..1077a652 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -5,16 +5,14 @@ import {inspect} from 'node:util'; import {color} from '#cli'; import find from '#find'; -import {empty} from '#sugar'; -import {getKebabCase} from '#wiki-data'; +import {empty, stitchArrays} from '#sugar'; +import {filterMultipleArrays, getKebabCase} from '#wiki-data'; import { from as compositeFrom, + earlyExitWithoutDependency, exposeDependency, - withReverseReferenceList, - withResolvedContribs, - withResolvedReference, - withResolvedReferenceList, + raiseWithoutDependency, } from '#composite'; import { @@ -332,3 +330,159 @@ export default class Thing extends CacheableObject { return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; } } + +// Resolves the contribsByRef contained in the provided dependency, +// providing (named by the second argument) the result. "Resolving" +// means mapping the "who" reference of each contribution to an artist +// object, and filtering out those whose "who" doesn't match any artist. +export function withResolvedContribs({from, to}) { + return compositeFrom(`withResolvedContribs`, [ + raiseWithoutDependency(from, { + mode: 'empty', + map: {to}, + raise: {to: []}, + }), + + { + mapDependencies: {from}, + compute: ({from}, continuation) => + continuation({ + '#whoByRef': from.map(({who}) => who), + '#what': from.map(({what}) => what), + }), + }, + + withResolvedReferenceList({ + list: '#whoByRef', + data: 'artistData', + to: '#who', + find: find.artist, + notFoundMode: 'null', + }), + + { + dependencies: ['#who', '#what'], + mapContinuation: {to}, + compute({'#who': who, '#what': what}, continuation) { + filterMultipleArrays(who, what, (who, _what) => who); + return continuation({ + to: stitchArrays({who, what}), + }); + }, + }, + ]); +} + +// Resolves a reference by using the provided find function to match it +// within the provided thingData dependency. This will early exit if the +// data dependency is null, or, if earlyExitIfNotFound is set to true, +// if the find function doesn't match anything for the reference. +// Otherwise, the data object is provided on the output dependency; +// or null, if the reference doesn't match anything or itself was null +// to begin with. +export function withResolvedReference({ + ref, + data, + find: findFunction, + to = '#resolvedReference', + earlyExitIfNotFound = false, +}) { + return compositeFrom(`withResolvedReference`, [ + raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), + earlyExitWithoutDependency(data), + + { + options: {findFunction, earlyExitIfNotFound}, + mapDependencies: {ref, data}, + mapContinuation: {match: to}, + + compute({ref, data, '#options': {findFunction, earlyExitIfNotFound}}, continuation) { + const match = findFunction(ref, data, {mode: 'quiet'}); + + if (match === null && earlyExitIfNotFound) { + return continuation.exit(null); + } + + return continuation.raise({match}); + }, + }, + ]); +} + +// Resolves a list of references, with each reference matched with provided +// data in the same way as withResolvedReference. This will early exit if the +// data dependency is null (even if the reference list is empty). By default +// it will filter out references which don't match, but this can be changed +// to early exit ({notFoundMode: 'exit'}) or leave null in place ('null'). +export function withResolvedReferenceList({ + list, + data, + find: findFunction, + to = '#resolvedReferenceList', + notFoundMode = 'filter', +}) { + if (!['filter', 'exit', 'null'].includes(notFoundMode)) { + throw new TypeError(`Expected notFoundMode to be filter, exit, or null`); + } + + return compositeFrom(`withResolvedReferenceList`, [ + earlyExitWithoutDependency(data, {value: []}), + + raiseWithoutDependency(list, { + map: {to}, + raise: {to: []}, + mode: 'empty', + }), + + { + options: {findFunction, notFoundMode}, + mapDependencies: {list, data}, + mapContinuation: {matches: to}, + + compute({list, data, '#options': {findFunction, notFoundMode}}, continuation) { + let matches = + list.map(ref => findFunction(ref, data, {mode: 'quiet'})); + + if (!matches.includes(null)) { + return continuation.raise({matches}); + } + + switch (notFoundMode) { + case 'filter': + matches = matches.filter(value => value !== null); + return continuation.raise({matches}); + + case 'exit': + return continuation.exit([]); + + case 'null': + return continuation.raise({matches}); + } + }, + }, + ]); +} + +// Check out the info on Thing.common.reverseReferenceList! +// This is its composable form. +export function withReverseReferenceList({ + data, + list: refListProperty, + to = '#reverseReferenceList', +}) { + return compositeFrom(`Thing.common.reverseReferenceList`, [ + earlyExitWithoutDependency(data, {value: []}), + + { + dependencies: ['this'], + mapDependencies: {data}, + mapContinuation: {to}, + options: {refListProperty}, + + compute: ({this: thisThing, data, '#options': {refListProperty}}, continuation) => + continuation({ + to: data.filter(thing => thing[refListProperty].includes(thisThing)), + }), + }, + ]); +} diff --git a/src/data/things/track.js b/src/data/things/track.js index c5e6ff34..0d7803bd 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -11,13 +11,14 @@ import { exposeDependency, exposeDependencyOrContinue, exposeUpdateValueOrContinue, - withResolvedContribs, - withResolvedReference, withResultOfAvailabilityCheck, - withReverseReferenceList, } from '#composite'; -import Thing from './thing.js'; +import Thing, { + withResolvedContribs, + withResolvedReference, + withReverseReferenceList, +} from './thing.js'; export class Track extends Thing { static [Thing.referenceType] = 'track'; -- cgit 1.3.0-6-gf8a5 From 3437936e6127192d30a308b68731cd4aa33555e7 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 10:10:44 -0300 Subject: data: earlyExit -> exit in misc. utility names --- src/data/things/composite.js | 20 ++++++++++---------- src/data/things/thing.js | 9 ++++----- src/data/things/track.js | 8 ++++---- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 7cba1e97..84a98290 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1004,13 +1004,13 @@ export function exposeUpdateValueOrContinue({ } // Early exits if an availability check has failed. -// This is for internal use only - use `earlyExitWithoutDependency` or -// `earlyExitWIthoutUpdateValue` instead. -export function earlyExitIfAvailabilityCheckFailed({ +// This is for internal use only - use `exitWithoutDependency` or +// `exitWithoutUpdateValue` instead. +export function exitIfAvailabilityCheckFailed({ availability = '#availability', value = null, } = {}) { - return compositeFrom(`earlyExitIfAvailabilityCheckFailed`, [ + return compositeFrom(`exitIfAvailabilityCheckFailed`, [ { mapDependencies: {availability}, compute: ({availability}, continuation) => @@ -1029,25 +1029,25 @@ export function earlyExitIfAvailabilityCheckFailed({ // Early exits if a dependency isn't available. // See withResultOfAvailabilityCheck for {mode} options! -export function earlyExitWithoutDependency(dependency, { +export function exitWithoutDependency(dependency, { mode = 'null', value = null, } = {}) { - return compositeFrom(`earlyExitWithoutDependency`, [ + return compositeFrom(`exitWithoutDependency`, [ withResultOfAvailabilityCheck({fromDependency: dependency, mode}), - earlyExitIfAvailabilityCheckFailed({value}), + exitIfAvailabilityCheckFailed({value}), ]); } // Early exits if this property's update value isn't available. // See withResultOfAvailabilityCheck for {mode} options! -export function earlyExitWithoutUpdateValue({ +export function exitWithoutUpdateValue({ mode = 'null', value = null, } = {}) { - return compositeFrom(`earlyExitWithoutDependency`, [ + return compositeFrom(`exitWithoutUpdateValue`, [ withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), - earlyExitIfAvailabilityCheckFailed({value}), + exitIfAvailabilityCheckFailed({value}), ]); } diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 1077a652..5d407153 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -10,7 +10,7 @@ import {filterMultipleArrays, getKebabCase} from '#wiki-data'; import { from as compositeFrom, - earlyExitWithoutDependency, + exitWithoutDependency, exposeDependency, raiseWithoutDependency, } from '#composite'; @@ -389,7 +389,7 @@ export function withResolvedReference({ }) { return compositeFrom(`withResolvedReference`, [ raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), - earlyExitWithoutDependency(data), + exitWithoutDependency(data), { options: {findFunction, earlyExitIfNotFound}, @@ -426,8 +426,7 @@ export function withResolvedReferenceList({ } return compositeFrom(`withResolvedReferenceList`, [ - earlyExitWithoutDependency(data, {value: []}), - + exitWithoutDependency(data, {value: []}), raiseWithoutDependency(list, { map: {to}, raise: {to: []}, @@ -471,7 +470,7 @@ export function withReverseReferenceList({ to = '#reverseReferenceList', }) { return compositeFrom(`Thing.common.reverseReferenceList`, [ - earlyExitWithoutDependency(data, {value: []}), + exitWithoutDependency(data, {value: []}), { dependencies: ['this'], diff --git a/src/data/things/track.js b/src/data/things/track.js index 0d7803bd..a7f96e42 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -6,7 +6,7 @@ import {empty} from '#sugar'; import { from as compositeFrom, - earlyExitWithoutDependency, + exitWithoutDependency, exposeConstant, exposeDependency, exposeDependencyOrContinue, @@ -93,7 +93,7 @@ export class Track extends Thing { // No cover art file extension if the track doesn't have unique artwork // in the first place. withHasUniqueCoverArt(), - earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), + exitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), // Expose custom coverArtFileExtension update value first. exposeUpdateValueOrContinue(), @@ -114,7 +114,7 @@ export class Track extends Thing { // is specified, this value is null. coverArtDate: compositeFrom(`Track.coverArtDate`, [ withHasUniqueCoverArt(), - earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), + exitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), exposeUpdateValueOrContinue(), @@ -188,7 +188,7 @@ export class Track extends Thing { ]), otherReleases: compositeFrom(`Track.otherReleases`, [ - earlyExitWithoutDependency('trackData', {mode: 'empty'}), + exitWithoutDependency('trackData', {mode: 'empty'}), withOriginalRelease({selfIfOriginal: true}), { -- cgit 1.3.0-6-gf8a5 From b076c87e435bbe2403122158ee03e4934c220c6c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 10:32:41 -0300 Subject: data: earlyExitIfNotFound -> notFoundMode --- src/data/things/thing.js | 21 +++++++++------- src/data/things/track.js | 65 +++++++++++++++++++++++++++--------------------- 2 files changed, 48 insertions(+), 38 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 5d407153..93f19799 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -375,31 +375,34 @@ export function withResolvedContribs({from, to}) { // Resolves a reference by using the provided find function to match it // within the provided thingData dependency. This will early exit if the -// data dependency is null, or, if earlyExitIfNotFound is set to true, -// if the find function doesn't match anything for the reference. -// Otherwise, the data object is provided on the output dependency; -// or null, if the reference doesn't match anything or itself was null -// to begin with. +// data dependency is null, or, if notFoundMode is set to 'exit', if the find +// function doesn't match anything for the reference. Otherwise, the data +// object is provided on the output dependency; or null, if the reference +// doesn't match anything or itself was null to begin with. export function withResolvedReference({ ref, data, find: findFunction, to = '#resolvedReference', - earlyExitIfNotFound = false, + notFoundMode = 'null', }) { + if (!['exit', 'null'].includes(notFoundMode)) { + throw new TypeError(`Expected notFoundMode to be exit or null`); + } + return compositeFrom(`withResolvedReference`, [ raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), exitWithoutDependency(data), { - options: {findFunction, earlyExitIfNotFound}, + options: {findFunction, notFoundMode}, mapDependencies: {ref, data}, mapContinuation: {match: to}, - compute({ref, data, '#options': {findFunction, earlyExitIfNotFound}}, continuation) { + compute({ref, data, '#options': {findFunction, notFoundMode}}, continuation) { const match = findFunction(ref, data, {mode: 'quiet'}); - if (match === null && earlyExitIfNotFound) { + if (match === null && notFoundMode === 'exit') { return continuation.exit(null); } diff --git a/src/data/things/track.js b/src/data/things/track.js index a7f96e42..9e1942e3 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -59,7 +59,7 @@ export class Track extends Thing { color: compositeFrom(`Track.color`, [ exposeUpdateValueOrContinue(), - withContainingTrackSection({earlyExitIfNotFound: false}), + withContainingTrackSection(), { dependencies: ['#trackSection'], @@ -358,12 +358,13 @@ function inheritFromOriginalRelease({ ]); } -// Gets the track's album. Unless earlyExitIfNotFound is overridden false, -// this will early exit with null in two cases - albumData being missing, -// or not including an album whose .tracks array includes this track. +// Gets the track's album. This will early exit if albumData is missing. +// By default, if there's no album whose list of tracks includes this track, +// the output dependency will be null; set {notFoundMode: 'exit'} to early +// exit instead. function withAlbum({ to = '#album', - earlyExitIfNotFound = true, + notFoundMode = 'null', } = {}) { return compositeFrom(`withAlbum`, [ withResultOfAvailabilityCheck({ @@ -374,16 +375,16 @@ function withAlbum({ { dependencies: ['#albumDataAvailability'], - options: {earlyExitIfNotFound}, + options: {notFoundMode}, mapContinuation: {to}, compute: ({ '#albumDataAvailability': albumDataAvailability, - '#options': {earlyExitIfNotFound}, + '#options': {notFoundMode}, }, continuation) => (albumDataAvailability ? continuation() - : (earlyExitIfNotFound + : (notFoundMode === 'exit' ? continuation.exit(null) : continuation.raise({to: null}))), }, @@ -392,38 +393,38 @@ function withAlbum({ dependencies: ['this', 'albumData'], compute: ({this: track, albumData}, continuation) => continuation({ - '#album': - albumData.find(album => album.tracks.includes(track)), + '#album': albumData.find(album => album.tracks.includes(track)), }), }, { dependencies: ['#album'], - options: {earlyExitIfNotFound}, + options: {notFoundMode}, mapContinuation: {to}, + compute: ({ '#album': album, - '#options': {earlyExitIfNotFound}, + '#options': {notFoundMode}, }, continuation) => (album ? continuation.raise({to: album}) - : (earlyExitIfNotFound + : (notFoundMode === 'exit' ? continuation.exit(null) - : continuation.raise({to: album}))), + : continuation.raise({to: null}))), }, ]); } // Gets a single property from this track's album, providing it as the same // property name prefixed with '#album.' (by default). If the track's album -// isn't available, and earlyExitIfNotFound hasn't been set, the property -// will be provided as null. +// isn't available, then by default, the property will be provided as null; +// set {notFoundMode: 'exit'} to early exit instead. function withAlbumProperty(property, { to = '#album.' + property, - earlyExitIfNotFound = false, + notFoundMode = 'null', } = {}) { return compositeFrom(`withAlbumProperty`, [ - withAlbum({earlyExitIfNotFound}), + withAlbum({notFoundMode}), { dependencies: ['#album'], @@ -443,15 +444,16 @@ function withAlbumProperty(property, { // Gets the listed properties from this track's album, providing them as // dependencies (by default) with '#album.' prefixed before each property -// name. If the track's album isn't available, and earlyExitIfNotFound -// hasn't been set, the same dependency names will be provided as null. +// name. If the track's album isn't available, then by default, the same +// dependency names will be provided as null; set {notFoundMode: 'exit'} +// to early exit instead. function withAlbumProperties({ properties, prefix = '#album', - earlyExitIfNotFound = false, + notFoundMode = 'null', }) { return compositeFrom(`withAlbumProperties`, [ - withAlbum({earlyExitIfNotFound}), + withAlbum({notFoundMode}), { dependencies: ['#album'], @@ -480,23 +482,28 @@ function withAlbumProperties({ } // Gets the track section containing this track from its album's track list. -// Unless earlyExitIfNotFound is overridden false, this will early exit if -// the album can't be found or if none of its trackSections includes the -// track for some reason. +// If notFoundMode is set to 'exit', this will early exit if the album can't be +// found or if none of its trackSections includes the track for some reason. function withContainingTrackSection({ to = '#trackSection', - earlyExitIfNotFound = true, + notFoundMode = 'null', } = {}) { + if (!['exit', 'null'].includes(notFoundMode)) { + throw new TypeError(`Expected notFoundMode to be exit or null`); + } + return compositeFrom(`withContainingTrackSection`, [ - withAlbumProperty('trackSections', {earlyExitIfNotFound}), + withAlbumProperty('trackSections', {notFoundMode}), { dependencies: ['this', '#album.trackSections'], + options: {notFoundMode}, mapContinuation: {to}, compute({ this: track, '#album.trackSections': trackSections, + '#options': {notFoundMode}, }, continuation) { if (!trackSections) { return continuation.raise({to: null}); @@ -507,7 +514,7 @@ function withContainingTrackSection({ if (trackSection) { return continuation.raise({to: trackSection}); - } else if (earlyExitIfNotFound) { + } else if (notFoundMode === 'exit') { return continuation.exit(null); } else { return continuation.raise({to: null}); @@ -533,7 +540,7 @@ function withOriginalRelease({ data: 'trackData', to: '#originalRelease', find: find.track, - earlyExitIfNotFound: true, + notFoundMode: 'exit', }), { -- cgit 1.3.0-6-gf8a5 From fcdc788a3b9efe308518ccdce89f8db0dd5618f6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 10:36:32 -0300 Subject: data: composite docs update --- src/data/things/composite.js | 31 +++++++++++++++---------------- src/data/things/thing.js | 2 +- src/data/things/track.js | 2 +- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 84a98290..b9cd6bfb 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -104,7 +104,7 @@ import { // on a provided dependency name, and then providing a result in another // also-provided dependency name: // -// static Thing.composite.withResolvedContribs = ({ +// withResolvedContribs = ({ // from: contribsByRefDependency, // to: outputDependency, // }) => ({ @@ -126,10 +126,11 @@ import { // // And how you might work that into a composition: // -// static Track[Thing.getPropertyDescriptors].coverArtists = -// Thing.composite.from([ -// Track.composite.doSomethingWhichMightEarlyExit(), -// Thing.composite.withResolvedContribs({ +// Track.coverArtists = +// compositeFrom([ +// doSomethingWhichMightEarlyExit(), +// +// withResolvedContribs({ // from: 'coverArtistContribsByRef', // to: '#coverArtistContribs', // }), @@ -138,9 +139,8 @@ import { // flags: {expose: true}, // expose: { // dependencies: ['#coverArtistContribs'], -// compute({'#coverArtistContribs': coverArtistContribs}) { -// return coverArtistContribs.map(({who}) => who); -// }, +// compute: ({'#coverArtistContribs': coverArtistContribs}) => +// coverArtistContribs.map(({who}) => who), // }, // }, // ]); @@ -169,7 +169,7 @@ import { // Consider the `withResolvedContribs` example adjusted to make use of // two of these options below: // -// static Thing.composite.withResolvedContribs = ({ +// withResolvedContribs = ({ // from: contribsByRefDependency, // to: outputDependency, // }) => ({ @@ -194,7 +194,7 @@ import { // With a little destructuring and restructuring JavaScript sugar, the // above can be simplified some more: // -// static Thing.composite.withResolvedContribs = ({from, to}) => ({ +// withResolvedContribs = ({from, to}) => ({ // flags: {expose: true, compose: true}, // expose: { // dependencies: ['artistData'], @@ -281,7 +281,7 @@ import { // // In order to allow for this while helping to ensure internal dependencies // remain neatly isolated from the composition which nests your bundle, -// the Thing.composite.from() function will accept and adapt to a base that +// the compositeFrom() function will accept and adapt to a base that // specifies the {compose: true} flag, just like the steps preceding it. // // The continuation function that gets provided to the base will be mildly @@ -341,8 +341,7 @@ import { // syntax as for other compositional steps, and it'll work out cleanly! // -export {compositeFrom as from}; -function compositeFrom(firstArg, secondArg) { +export function compositeFrom(firstArg, secondArg) { const debug = fn => { if (compositeFrom.debug === true) { const label = @@ -373,7 +372,7 @@ function compositeFrom(firstArg, secondArg) { const aggregate = openAggregate({ message: - `Errors preparing Thing.composite.from() composition` + + `Errors preparing composition` + (annotation ? ` (${annotation})` : ''), }); @@ -780,9 +779,9 @@ function compositeFrom(firstArg, secondArg) { // t.same(thing.someProp, value) // // With debugging: -// t.same(Thing.composite.debug(() => thing.someProp), value) +// t.same(debugComposite(() => thing.someProp), value) // -export function debug(fn) { +export function debugComposite(fn) { compositeFrom.debug = true; const value = fn(); compositeFrom.debug = false; diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 93f19799..9b564ee9 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -9,7 +9,7 @@ import {empty, stitchArrays} from '#sugar'; import {filterMultipleArrays, getKebabCase} from '#wiki-data'; import { - from as compositeFrom, + compositeFrom, exitWithoutDependency, exposeDependency, raiseWithoutDependency, diff --git a/src/data/things/track.js b/src/data/things/track.js index 9e1942e3..1818e003 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -5,7 +5,7 @@ import find from '#find'; import {empty} from '#sugar'; import { - from as compositeFrom, + compositeFrom, exitWithoutDependency, exposeConstant, exposeDependency, -- cgit 1.3.0-6-gf8a5 From ba04498715423c165cdb254676cc211c48b7c8ab Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 10:38:16 -0300 Subject: data: remove unused export() raising utility --- src/data/things/composite.js | 38 -------------------------------------- 1 file changed, 38 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index b9cd6bfb..f59e7d75 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -788,44 +788,6 @@ export function debugComposite(fn) { return value; } -// Provides dependencies exactly as they are (or null if not defined) to the -// continuation. Although this can *technically* be used to alias existing -// dependencies to some other name within the middle of a composition, it's -// intended to be used only as a composition's base - doing so makes the -// composition as a whole suitable as a step in some other composition, -// providing the listed (internal) dependencies to later steps just like -// other compositional steps. -export {_export as export}; -function _export(mapping) { - const mappingEntries = Object.entries(mapping); - - return { - annotation: `export`, - flags: {expose: true, compose: true}, - - expose: { - options: {mappingEntries}, - dependencies: Object.values(mapping), - - compute({'#options': {mappingEntries}, ...dependencies}, continuation) { - const exports = {}; - - // Note: This is slightly different behavior from filterProperties, - // as defined in sugar.js, which doesn't fall back to null for - // properties which don't exist on the original object. - for (const [exportKey, dependencyKey] of mappingEntries) { - exports[exportKey] = - (Object.hasOwn(dependencies, dependencyKey) - ? dependencies[dependencyKey] - : null); - } - - return continuation.raise(exports); - } - }, - }; -} - // Exposes a dependency exactly as it is; this is typically the base of a // composition which was created to serve as one property's descriptor. // Since this serves as a base, specify a value for {update} to indicate -- cgit 1.3.0-6-gf8a5 From 4541b2aa65a2f5ccfb7f9a13d5605311fd8ef801 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 11:37:58 -0300 Subject: data: composite "to" -> "into" --- src/data/things/composite.js | 28 ++++++++++---------- src/data/things/thing.js | 34 ++++++++++++------------ src/data/things/track.js | 62 ++++++++++++++++++++++---------------------- 3 files changed, 62 insertions(+), 62 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index f59e7d75..976f7804 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -106,7 +106,7 @@ import { // // withResolvedContribs = ({ // from: contribsByRefDependency, -// to: outputDependency, +// into: outputDependency, // }) => ({ // flags: {expose: true, compose: true}, // expose: { @@ -132,7 +132,7 @@ import { // // withResolvedContribs({ // from: 'coverArtistContribsByRef', -// to: '#coverArtistContribs', +// into: '#coverArtistContribs', // }), // // { @@ -171,7 +171,7 @@ import { // // withResolvedContribs = ({ // from: contribsByRefDependency, -// to: outputDependency, +// into: outputDependency, // }) => ({ // flags: {expose: true, compose: true}, // expose: { @@ -199,11 +199,11 @@ import { // expose: { // dependencies: ['artistData'], // mapDependencies: {from}, -// mapContinuation: {to}, +// mapContinuation: {into}, // compute({artistData, from: contribsByRef}, continuation) { // if (!artistData) return null; // return continuation({ -// to: (..resolve contributions one way or another..), +// into: (..resolve contributions one way or another..), // }); // }, // }, @@ -505,8 +505,8 @@ export function compositeFrom(firstArg, secondArg) { : {}); if (mapDependencies) { - for (const [to, from] of Object.entries(mapDependencies)) { - filteredDependencies[to] = availableDependencies[from] ?? null; + for (const [into, from] of Object.entries(mapDependencies)) { + filteredDependencies[into] = availableDependencies[from] ?? null; } } @@ -524,8 +524,8 @@ export function compositeFrom(firstArg, secondArg) { const assignDependencies = {}; - for (const [from, to] of Object.entries(mapContinuation)) { - assignDependencies[to] = continuationAssignment[from] ?? null; + for (const [from, into] of Object.entries(mapContinuation)) { + assignDependencies[into] = continuationAssignment[from] ?? null; } return assignDependencies; @@ -861,7 +861,7 @@ export function withResultOfAvailabilityCheck({ fromUpdateValue, fromDependency, mode = 'null', - to = '#availability', + into = '#availability', }) { if (!['null', 'empty', 'falsy'].includes(mode)) { throw new TypeError(`Expected mode to be null, empty, or falsy`); @@ -890,10 +890,10 @@ export function withResultOfAvailabilityCheck({ flags: {expose: true, compose: true}, expose: { mapDependencies: {from: fromDependency}, - mapContinuation: {to}, + mapContinuation: {into}, options: {mode}, compute: ({from, '#options': {mode}}, continuation) => - continuation({to: checkAvailability(from, mode)}), + continuation({into: checkAvailability(from, mode)}), }, }; } else { @@ -901,10 +901,10 @@ export function withResultOfAvailabilityCheck({ annotation: `withResultOfAvailabilityCheck.fromUpdateValue`, flags: {expose: true, compose: true}, expose: { - mapContinuation: {to}, + mapContinuation: {into}, options: {mode}, transform: (value, {'#options': {mode}}, continuation) => - continuation(value, {to: checkAvailability(value, mode)}), + continuation(value, {into: checkAvailability(value, mode)}), }, }; } diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 9b564ee9..16003b00 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -236,7 +236,7 @@ export default class Thing extends CacheableObject { return compositeFrom(`Thing.common.dynamicContribs`, [ withResolvedContribs({ from: contribsByRefProperty, - to: '#contribs', + into: '#contribs', }), exposeDependency('#contribs'), @@ -335,12 +335,12 @@ export default class Thing extends CacheableObject { // providing (named by the second argument) the result. "Resolving" // means mapping the "who" reference of each contribution to an artist // object, and filtering out those whose "who" doesn't match any artist. -export function withResolvedContribs({from, to}) { +export function withResolvedContribs({from, into}) { return compositeFrom(`withResolvedContribs`, [ raiseWithoutDependency(from, { mode: 'empty', - map: {to}, - raise: {to: []}, + map: {into}, + raise: {into: []}, }), { @@ -355,18 +355,18 @@ export function withResolvedContribs({from, to}) { withResolvedReferenceList({ list: '#whoByRef', data: 'artistData', - to: '#who', + into: '#who', find: find.artist, notFoundMode: 'null', }), { dependencies: ['#who', '#what'], - mapContinuation: {to}, + mapContinuation: {into}, compute({'#who': who, '#what': what}, continuation) { filterMultipleArrays(who, what, (who, _what) => who); return continuation({ - to: stitchArrays({who, what}), + into: stitchArrays({who, what}), }); }, }, @@ -383,7 +383,7 @@ export function withResolvedReference({ ref, data, find: findFunction, - to = '#resolvedReference', + into = '#resolvedReference', notFoundMode = 'null', }) { if (!['exit', 'null'].includes(notFoundMode)) { @@ -391,13 +391,13 @@ export function withResolvedReference({ } return compositeFrom(`withResolvedReference`, [ - raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), + raiseWithoutDependency(ref, {map: {into}, raise: {into: null}}), exitWithoutDependency(data), { options: {findFunction, notFoundMode}, mapDependencies: {ref, data}, - mapContinuation: {match: to}, + mapContinuation: {match: into}, compute({ref, data, '#options': {findFunction, notFoundMode}}, continuation) { const match = findFunction(ref, data, {mode: 'quiet'}); @@ -421,7 +421,7 @@ export function withResolvedReferenceList({ list, data, find: findFunction, - to = '#resolvedReferenceList', + into = '#resolvedReferenceList', notFoundMode = 'filter', }) { if (!['filter', 'exit', 'null'].includes(notFoundMode)) { @@ -431,15 +431,15 @@ export function withResolvedReferenceList({ return compositeFrom(`withResolvedReferenceList`, [ exitWithoutDependency(data, {value: []}), raiseWithoutDependency(list, { - map: {to}, - raise: {to: []}, + map: {into}, + raise: {into: []}, mode: 'empty', }), { options: {findFunction, notFoundMode}, mapDependencies: {list, data}, - mapContinuation: {matches: to}, + mapContinuation: {matches: into}, compute({list, data, '#options': {findFunction, notFoundMode}}, continuation) { let matches = @@ -470,7 +470,7 @@ export function withResolvedReferenceList({ export function withReverseReferenceList({ data, list: refListProperty, - to = '#reverseReferenceList', + into = '#reverseReferenceList', }) { return compositeFrom(`Thing.common.reverseReferenceList`, [ exitWithoutDependency(data, {value: []}), @@ -478,12 +478,12 @@ export function withReverseReferenceList({ { dependencies: ['this'], mapDependencies: {data}, - mapContinuation: {to}, + mapContinuation: {into}, options: {refListProperty}, compute: ({this: thisThing, data, '#options': {refListProperty}}, continuation) => continuation({ - to: data.filter(thing => thing[refListProperty].includes(thisThing)), + into: data.filter(thing => thing[refListProperty].includes(thisThing)), }), }, ]); diff --git a/src/data/things/track.js b/src/data/things/track.js index 1818e003..1adfe71a 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -216,7 +216,7 @@ export class Track extends Thing { withResolvedContribs({ from: 'artistContribsByRef', - to: '#artistContribs', + into: '#artistContribs', }), { @@ -250,7 +250,7 @@ export class Track extends Thing { withResolvedContribs({ from: 'coverArtistContribsByRef', - to: '#coverArtistContribs', + into: '#coverArtistContribs', }), { @@ -363,20 +363,20 @@ function inheritFromOriginalRelease({ // the output dependency will be null; set {notFoundMode: 'exit'} to early // exit instead. function withAlbum({ - to = '#album', + into = '#album', notFoundMode = 'null', } = {}) { return compositeFrom(`withAlbum`, [ withResultOfAvailabilityCheck({ fromDependency: 'albumData', mode: 'empty', - to: '#albumDataAvailability', + into: '#albumDataAvailability', }), { dependencies: ['#albumDataAvailability'], options: {notFoundMode}, - mapContinuation: {to}, + mapContinuation: {into}, compute: ({ '#albumDataAvailability': albumDataAvailability, @@ -386,7 +386,7 @@ function withAlbum({ ? continuation() : (notFoundMode === 'exit' ? continuation.exit(null) - : continuation.raise({to: null}))), + : continuation.raise({into: null}))), }, { @@ -400,17 +400,17 @@ function withAlbum({ { dependencies: ['#album'], options: {notFoundMode}, - mapContinuation: {to}, + mapContinuation: {into}, compute: ({ '#album': album, '#options': {notFoundMode}, }, continuation) => (album - ? continuation.raise({to: album}) + ? continuation.raise({into: album}) : (notFoundMode === 'exit' ? continuation.exit(null) - : continuation.raise({to: null}))), + : continuation.raise({into: null}))), }, ]); } @@ -420,7 +420,7 @@ function withAlbum({ // isn't available, then by default, the property will be provided as null; // set {notFoundMode: 'exit'} to early exit instead. function withAlbumProperty(property, { - to = '#album.' + property, + into = '#album.' + property, notFoundMode = 'null', } = {}) { return compositeFrom(`withAlbumProperty`, [ @@ -429,15 +429,15 @@ function withAlbumProperty(property, { { dependencies: ['#album'], options: {property}, - mapContinuation: {to}, + mapContinuation: {into}, compute: ({ '#album': album, '#options': {property}, }, continuation) => (album - ? continuation.raise({to: album[property]}) - : continuation.raise({to: null})), + ? continuation.raise({into: album[property]}) + : continuation.raise({into: null})), }, ]); } @@ -485,7 +485,7 @@ function withAlbumProperties({ // If notFoundMode is set to 'exit', this will early exit if the album can't be // found or if none of its trackSections includes the track for some reason. function withContainingTrackSection({ - to = '#trackSection', + into = '#trackSection', notFoundMode = 'null', } = {}) { if (!['exit', 'null'].includes(notFoundMode)) { @@ -498,7 +498,7 @@ function withContainingTrackSection({ { dependencies: ['this', '#album.trackSections'], options: {notFoundMode}, - mapContinuation: {to}, + mapContinuation: {into}, compute({ this: track, @@ -506,18 +506,18 @@ function withContainingTrackSection({ '#options': {notFoundMode}, }, continuation) { if (!trackSections) { - return continuation.raise({to: null}); + return continuation.raise({into: null}); } const trackSection = trackSections.find(({tracks}) => tracks.includes(track)); if (trackSection) { - return continuation.raise({to: trackSection}); + return continuation.raise({into: trackSection}); } else if (notFoundMode === 'exit') { return continuation.exit(null); } else { - return continuation.raise({to: null}); + return continuation.raise({into: null}); } }, }, @@ -531,14 +531,14 @@ function withContainingTrackSection({ // specified by reference and that reference doesn't resolve to anything. // Outputs to '#originalRelease' by default. function withOriginalRelease({ - to = '#originalRelease', + into = '#originalRelease', selfIfOriginal = false, } = {}) { return compositeFrom(`withOriginalRelease`, [ withResolvedReference({ ref: 'originalReleaseTrackByRef', data: 'trackData', - to: '#originalRelease', + into: '#originalRelease', find: find.track, notFoundMode: 'exit', }), @@ -546,14 +546,14 @@ function withOriginalRelease({ { dependencies: ['this', '#originalRelease'], options: {selfIfOriginal}, - mapContinuation: {to}, + mapContinuation: {into}, compute: ({ this: track, '#originalRelease': originalRelease, '#options': {selfIfOriginal}, }, continuation) => continuation.raise({ - to: + into: (originalRelease ?? (selfIfOriginal ? track @@ -566,41 +566,41 @@ function withOriginalRelease({ // The algorithm for checking if a track has unique cover art is used in a // couple places, so it's defined in full as a compositional step. function withHasUniqueCoverArt({ - to = '#hasUniqueCoverArt', + into = '#hasUniqueCoverArt', } = {}) { return compositeFrom(`withHasUniqueCoverArt`, [ { dependencies: ['disableUniqueCoverArt'], - mapContinuation: {to}, + mapContinuation: {into}, compute: ({disableUniqueCoverArt}, continuation) => (disableUniqueCoverArt - ? continuation.raise({to: false}) + ? continuation.raise({into: false}) : continuation()), }, withResolvedContribs({ from: 'coverArtistContribsByRef', - to: '#coverArtistContribs', + into: '#coverArtistContribs', }), { dependencies: ['#coverArtistContribs'], - mapContinuation: {to}, + mapContinuation: {into}, compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => (empty(contribsFromTrack) ? continuation() - : continuation.raise({to: true})), + : continuation.raise({into: true})), }, withAlbumProperty('trackCoverArtistContribs'), { dependencies: ['#album.trackCoverArtistContribs'], - mapContinuation: {to}, + mapContinuation: {into}, compute: ({'#album.trackCoverArtistContribs': contribsFromAlbum}, continuation) => (empty(contribsFromAlbum) - ? continuation.raise({to: false}) - : continuation.raise({to: true})), + ? continuation.raise({into: false}) + : continuation.raise({into: true})), }, ]); } -- cgit 1.3.0-6-gf8a5 From 8b379954c9d74f0d47ac32ef395627353940c728 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 11:56:05 -0300 Subject: data: use key/value-style for all compositional utility args --- src/data/things/composite.js | 25 ++++++++++------- src/data/things/thing.js | 39 ++++++++++++++++++-------- src/data/things/track.js | 65 ++++++++++++++++++++++++++------------------ 3 files changed, 81 insertions(+), 48 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 976f7804..5b6de901 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -801,9 +801,10 @@ export function debugComposite(fn) { // compositional step, the property will be exposed as undefined instead // of null. // -export function exposeDependency(dependency, { +export function exposeDependency({ + dependency, update = false, -} = {}) { +}) { return { annotation: `exposeDependency`, flags: {expose: true, update: !!update}, @@ -826,9 +827,10 @@ export function exposeDependency(dependency, { // exit with some other value, with the exposeConstant base serving as the // fallback default value. Like exposeDependency, set {update} to true or // an object to indicate that the property as a whole updates. -export function exposeConstant(value, { +export function exposeConstant({ + value, update = false, -} = {}) { +}) { return { annotation: `exposeConstant`, flags: {expose: true, update: !!update}, @@ -912,9 +914,10 @@ export function withResultOfAvailabilityCheck({ // Exposes a dependency as it is, or continues if it's unavailable. // See withResultOfAvailabilityCheck for {mode} options! -export function exposeDependencyOrContinue(dependency, { +export function exposeDependencyOrContinue({ + dependency, mode = 'null', -} = {}) { +}) { return compositeFrom(`exposeDependencyOrContinue`, [ withResultOfAvailabilityCheck({ fromDependency: dependency, @@ -990,10 +993,11 @@ export function exitIfAvailabilityCheckFailed({ // Early exits if a dependency isn't available. // See withResultOfAvailabilityCheck for {mode} options! -export function exitWithoutDependency(dependency, { +export function exitWithoutDependency({ + dependency, mode = 'null', value = null, -} = {}) { +}) { return compositeFrom(`exitWithoutDependency`, [ withResultOfAvailabilityCheck({fromDependency: dependency, mode}), exitIfAvailabilityCheckFailed({value}), @@ -1014,11 +1018,12 @@ export function exitWithoutUpdateValue({ // Raises if a dependency isn't available. // See withResultOfAvailabilityCheck for {mode} options! -export function raiseWithoutDependency(dependency, { +export function raiseWithoutDependency({ + dependency, mode = 'null', map = {}, raise = {}, -} = {}) { +}) { return compositeFrom(`raiseWithoutDependency`, [ withResultOfAvailabilityCheck({fromDependency: dependency, mode}), diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 16003b00..98dec3c3 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -205,7 +205,8 @@ export default class Thing extends CacheableObject { list, data, find, notFoundMode: 'filter', }), - exposeDependency('#resolvedReferenceList'), + + exposeDependency({dependency: '#resolvedReferenceList'}), ]); }, @@ -213,7 +214,7 @@ export default class Thing extends CacheableObject { resolvedReference({ref, data, find}) { return compositeFrom(`Thing.common.resolvedReference`, [ withResolvedReference({ref, data, find}), - exposeDependency('#resolvedReference'), + exposeDependency({dependency: '#resolvedReference'}), ]); }, @@ -239,7 +240,7 @@ export default class Thing extends CacheableObject { into: '#contribs', }), - exposeDependency('#contribs'), + exposeDependency({dependency: '#contribs'}), ]); }, @@ -265,7 +266,7 @@ export default class Thing extends CacheableObject { reverseReferenceList({data, list}) { return compositeFrom(`Thing.common.reverseReferenceList`, [ withReverseReferenceList({data, list}), - exposeDependency('#reverseReferenceList'), + exposeDependency({dependency: '#reverseReferenceList'}), ]); }, @@ -337,7 +338,8 @@ export default class Thing extends CacheableObject { // object, and filtering out those whose "who" doesn't match any artist. export function withResolvedContribs({from, into}) { return compositeFrom(`withResolvedContribs`, [ - raiseWithoutDependency(from, { + raiseWithoutDependency({ + dependency: from, mode: 'empty', map: {into}, raise: {into: []}, @@ -391,8 +393,15 @@ export function withResolvedReference({ } return compositeFrom(`withResolvedReference`, [ - raiseWithoutDependency(ref, {map: {into}, raise: {into: null}}), - exitWithoutDependency(data), + raiseWithoutDependency({ + dependency: ref, + map: {into}, + raise: {into: null}, + }), + + exitWithoutDependency({ + dependency: data, + }), { options: {findFunction, notFoundMode}, @@ -429,11 +438,16 @@ export function withResolvedReferenceList({ } return compositeFrom(`withResolvedReferenceList`, [ - exitWithoutDependency(data, {value: []}), - raiseWithoutDependency(list, { + exitWithoutDependency({ + dependency: data, + value: [], + }), + + raiseWithoutDependency({ + dependency: list, + mode: 'empty', map: {into}, raise: {into: []}, - mode: 'empty', }), { @@ -473,7 +487,10 @@ export function withReverseReferenceList({ into = '#reverseReferenceList', }) { return compositeFrom(`Thing.common.reverseReferenceList`, [ - exitWithoutDependency(data, {value: []}), + exitWithoutDependency({ + dependency: data, + value: [], + }), { dependencies: ['this'], diff --git a/src/data/things/track.js b/src/data/things/track.js index 1adfe71a..7dde88db 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -73,8 +73,10 @@ export class Track extends Thing { : continuation()), }, - withAlbumProperty('color'), - exposeDependency('#album.color', { + withAlbumProperty({property: 'color'}), + + exposeDependency({ + dependency: '#album.color', update: {validate: isColor}, }), ]), @@ -93,17 +95,18 @@ export class Track extends Thing { // No cover art file extension if the track doesn't have unique artwork // in the first place. withHasUniqueCoverArt(), - exitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), + exitWithoutDependency({dependency: '#hasUniqueCoverArt', mode: 'falsy'}), // Expose custom coverArtFileExtension update value first. exposeUpdateValueOrContinue(), // Expose album's trackCoverArtFileExtension if no update value set. - withAlbumProperty('trackCoverArtFileExtension'), - exposeDependencyOrContinue('#album.trackCoverArtFileExtension'), + withAlbumProperty({property: 'trackCoverArtFileExtension'}), + exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), // Fallback to 'jpg'. - exposeConstant('jpg', { + exposeConstant({ + value: 'jpg', update: {validate: isFileExtension}, }), ]), @@ -114,12 +117,13 @@ export class Track extends Thing { // is specified, this value is null. coverArtDate: compositeFrom(`Track.coverArtDate`, [ withHasUniqueCoverArt(), - exitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), + exitWithoutDependency({dependency: '#hasUniqueCoverArt', mode: 'falsy'}), exposeUpdateValueOrContinue(), - withAlbumProperty('trackArtDate'), - exposeDependency('#album.trackArtDate', { + withAlbumProperty({property: 'trackArtDate'}), + exposeDependency({ + dependency: '#album.trackArtDate', update: {validate: isDate}, }), ]), @@ -148,7 +152,7 @@ export class Track extends Thing { album: compositeFrom(`Track.album`, [ withAlbum(), - exposeDependency('#album'), + exposeDependency({dependency: '#album'}), ]), // Note - this is an internal property used only to help identify a track. @@ -165,9 +169,9 @@ export class Track extends Thing { }), date: compositeFrom(`Track.date`, [ - exposeDependencyOrContinue('dateFirstReleased'), - withAlbumProperty('date'), - exposeDependency('#album.date'), + exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), + withAlbumProperty({property: 'date'}), + exposeDependency({dependency: '#album.date'}), ]), // Whether or not the track has "unique" cover artwork - a cover which is @@ -179,16 +183,16 @@ export class Track extends Thing { // album.) hasUniqueCoverArt: compositeFrom(`Track.hasUniqueCoverArt`, [ withHasUniqueCoverArt(), - exposeDependency('#hasUniqueCoverArt'), + exposeDependency({dependency: '#hasUniqueCoverArt'}), ]), originalReleaseTrack: compositeFrom(`Track.originalReleaseTrack`, [ withOriginalRelease(), - exposeDependency('#originalRelease'), + exposeDependency({dependency: '#originalRelease'}), ]), otherReleases: compositeFrom(`Track.otherReleases`, [ - exitWithoutDependency('trackData', {mode: 'empty'}), + exitWithoutDependency({dependency: 'trackData', mode: 'empty'}), withOriginalRelease({selfIfOriginal: true}), { @@ -227,8 +231,8 @@ export class Track extends Thing { : contribsFromTrack), }, - withAlbumProperty('artistContribs'), - exposeDependency('#album.artistContribs'), + withAlbumProperty({property: 'artistContribs'}), + exposeDependency({dependency: '#album.artistContribs'}), ]), contributorContribs: compositeFrom(`Track.contributorContribs`, [ @@ -261,8 +265,8 @@ export class Track extends Thing { : contribsFromTrack), }, - withAlbumProperty('trackCoverArtistContribs'), - exposeDependency('#album.trackCoverArtistContribs'), + withAlbumProperty({property: 'trackCoverArtistContribs'}), + exposeDependency({dependency: '#album.trackCoverArtistContribs'}), ]), referencedTracks: compositeFrom(`Track.referencedTracks`, [ @@ -297,10 +301,14 @@ export class Track extends Thing { // counting the number of times a track has been referenced, for use in // the "Tracks - by Times Referenced" listing page (or other data // processing). - referencedByTracks: trackReverseReferenceList('referencedTracks'), + referencedByTracks: trackReverseReferenceList({ + property: 'referencedTracks', + }), // For the same reasoning, exclude re-releases from sampled tracks too. - sampledByTracks: trackReverseReferenceList('sampledTracks'), + sampledByTracks: trackReverseReferenceList({ + property: 'sampledTracks', + }), featuredInFlashes: Thing.common.reverseReferenceList({ data: 'flashData', @@ -419,10 +427,11 @@ function withAlbum({ // property name prefixed with '#album.' (by default). If the track's album // isn't available, then by default, the property will be provided as null; // set {notFoundMode: 'exit'} to early exit instead. -function withAlbumProperty(property, { +function withAlbumProperty({ + property, into = '#album.' + property, notFoundMode = 'null', -} = {}) { +}) { return compositeFrom(`withAlbumProperty`, [ withAlbum({notFoundMode}), @@ -493,7 +502,7 @@ function withContainingTrackSection({ } return compositeFrom(`withContainingTrackSection`, [ - withAlbumProperty('trackSections', {notFoundMode}), + withAlbumProperty({property: 'trackSections', notFoundMode}), { dependencies: ['this', '#album.trackSections'], @@ -592,7 +601,7 @@ function withHasUniqueCoverArt({ : continuation.raise({into: true})), }, - withAlbumProperty('trackCoverArtistContribs'), + withAlbumProperty({property: 'trackCoverArtistContribs'}), { dependencies: ['#album.trackCoverArtistContribs'], @@ -605,7 +614,9 @@ function withHasUniqueCoverArt({ ]); } -function trackReverseReferenceList(refListProperty) { +function trackReverseReferenceList({ + property: refListProperty, +}) { return compositeFrom(`trackReverseReferenceList`, [ withReverseReferenceList({ data: 'trackData', -- cgit 1.3.0-6-gf8a5 From 6889c764caef5542ba9ad8362acf6e8b7b879ea9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 12:06:06 -0300 Subject: data, infra: import validators directly --- src/data/things/album.js | 16 +++------------- src/data/things/art-tag.js | 5 +---- src/data/things/artist.js | 12 ++---------- src/data/things/flash.js | 27 ++++++++++----------------- src/data/things/group.js | 8 ++------ src/data/things/homepage-layout.js | 36 ++++++++++++------------------------ src/data/things/index.js | 3 +-- src/data/things/language.js | 8 +++----- src/data/things/static-page.js | 8 +++----- src/data/things/track.js | 15 ++------------- src/data/things/wiki-info.js | 11 ++--------- 11 files changed, 41 insertions(+), 108 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 81f04f70..da018856 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,23 +1,13 @@ -import {empty} from '#sugar'; import find from '#find'; +import {empty} from '#sugar'; +import {isDate, isDimensions, isTrackSectionList} from '#validators'; import Thing from './thing.js'; export class Album extends Thing { static [Thing.referenceType] = 'album'; - static [Thing.getPropertyDescriptors] = ({ - ArtTag, - Artist, - Group, - Track, - - validators: { - isDate, - isDimensions, - isTrackSectionList, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({ // Update & expose name: Thing.common.name('Unnamed Album'), diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index bb36e09e..5d7d0cbf 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -5,10 +5,7 @@ import Thing from './thing.js'; export class ArtTag extends Thing { static [Thing.referenceType] = 'tag'; - static [Thing.getPropertyDescriptors] = ({ - Album, - Track, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ // Update & expose name: Thing.common.name('Unnamed Art Tag'), diff --git a/src/data/things/artist.js b/src/data/things/artist.js index b2383057..93a1b51b 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -1,20 +1,12 @@ import find from '#find'; +import {isName, validateArrayItems} from '#validators'; import Thing from './thing.js'; export class Artist extends Thing { static [Thing.referenceType] = 'artist'; - static [Thing.getPropertyDescriptors] = ({ - Album, - Flash, - Track, - - validators: { - isName, - validateArrayItems, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({ // Update & expose name: Thing.common.name('Unnamed Artist'), diff --git a/src/data/things/flash.js b/src/data/things/flash.js index baef23d8..ce2e7fac 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -1,22 +1,19 @@ import find from '#find'; +import { + isColor, + isDirectory, + isNumber, + isString, + oneOf, +} from '#validators'; + import Thing from './thing.js'; export class Flash extends Thing { static [Thing.referenceType] = 'flash'; - static [Thing.getPropertyDescriptors] = ({ - Artist, - Track, - FlashAct, - - validators: { - isDirectory, - isNumber, - isString, - oneOf, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({ // Update & expose name: Thing.common.name('Unnamed Flash'), @@ -111,11 +108,7 @@ export class Flash extends Thing { } export class FlashAct extends Thing { - static [Thing.getPropertyDescriptors] = ({ - validators: { - isColor, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose name: Thing.common.name('Unnamed Flash Act'), diff --git a/src/data/things/group.js b/src/data/things/group.js index d04fcf56..6c712847 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -5,9 +5,7 @@ import Thing from './thing.js'; export class Group extends Thing { static [Thing.referenceType] = 'group'; - static [Thing.getPropertyDescriptors] = ({ - Album, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album}) => ({ // Update & expose name: Thing.common.name('Unnamed Group'), @@ -76,9 +74,7 @@ export class Group extends Thing { } export class GroupCategory extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Group, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose name: Thing.common.name('Unnamed Group Category'), diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index c478bc41..59656b41 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -1,17 +1,18 @@ import find from '#find'; +import { + is, + isCountingNumber, + isString, + isStringNonEmpty, + validateArrayItems, + validateInstanceOf, +} from '#validators'; + import Thing from './thing.js'; export class HomepageLayout extends Thing { - static [Thing.getPropertyDescriptors] = ({ - HomepageLayoutRow, - - validators: { - isStringNonEmpty, - validateArrayItems, - validateInstanceOf, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ // Update & expose sidebarContent: Thing.common.simpleString(), @@ -32,10 +33,7 @@ export class HomepageLayout extends Thing { } export class HomepageLayoutRow extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Album, - Group, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ // Update & expose name: Thing.common.name('Unnamed Homepage Row'), @@ -63,17 +61,7 @@ export class HomepageLayoutRow extends Thing { } export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { - static [Thing.getPropertyDescriptors] = (opts, { - Album, - Group, - - validators: { - is, - isCountingNumber, - isString, - validateArrayItems, - }, - } = opts) => ({ + static [Thing.getPropertyDescriptors] = (opts, {Album, Group} = opts) => ({ ...HomepageLayoutRow[Thing.getPropertyDescriptors](opts), // Update & expose diff --git a/src/data/things/index.js b/src/data/things/index.js index 2d4f77d7..3b73a772 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -4,7 +4,6 @@ import {fileURLToPath} from 'node:url'; import {logError} from '#cli'; import * as serialize from '#serialize'; import {openAggregate, showAggregate} from '#sugar'; -import * as validators from '#validators'; import Thing from './thing.js'; @@ -121,7 +120,7 @@ function descriptorAggregateHelper({ } function evaluatePropertyDescriptors() { - const opts = {...allClasses, validators}; + const opts = {...allClasses}; return descriptorAggregateHelper({ message: `Errors evaluating Thing class property descriptors`, diff --git a/src/data/things/language.js b/src/data/things/language.js index 7755c505..0638afa2 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,11 +1,9 @@ +import {isLanguageCode} from '#validators'; + import Thing from './thing.js'; export class Language extends Thing { - static [Thing.getPropertyDescriptors] = ({ - validators: { - isLanguageCode, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose // General language code. This is used to identify the language distinctly diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index 3d8d474c..ae0ca420 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -1,13 +1,11 @@ +import {isName} from '#validators'; + import Thing from './thing.js'; export class StaticPage extends Thing { static [Thing.referenceType] = 'static'; - static [Thing.getPropertyDescriptors] = ({ - validators: { - isName, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = () => ({ // Update & expose name: Thing.common.name('Unnamed Static Page'), diff --git a/src/data/things/track.js b/src/data/things/track.js index 7dde88db..10b966a7 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -3,6 +3,7 @@ import {inspect} from 'node:util'; import {color} from '#cli'; import find from '#find'; import {empty} from '#sugar'; +import {isColor, isDate, isDuration, isFileExtension} from '#validators'; import { compositeFrom, @@ -23,19 +24,7 @@ import Thing, { export class Track extends Thing { static [Thing.referenceType] = 'track'; - static [Thing.getPropertyDescriptors] = ({ - Album, - ArtTag, - Artist, - Flash, - - validators: { - isColor, - isDate, - isDuration, - isFileExtension, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({ // Update & expose name: Thing.common.name('Unnamed Track'), diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index d6790c55..0ccef5ed 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,17 +1,10 @@ import find from '#find'; +import {isLanguageCode, isName, isURL} from '#validators'; import Thing from './thing.js'; export class WikiInfo extends Thing { - static [Thing.getPropertyDescriptors] = ({ - Group, - - validators: { - isLanguageCode, - isName, - isURL, - }, - }) => ({ + static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose name: Thing.common.name('Unnamed Wiki'), -- cgit 1.3.0-6-gf8a5 From eb00f2993a1aaaba171ad6c918656552f80bb748 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 12:38:34 -0300 Subject: data: import Thing.common utilities directly Also rename 'color' (from #cli) to 'colors'. --- data-tests/index.js | 4 +- src/content/dependencies/index.js | 8 +- src/data/things/album.js | 108 ++++--- src/data/things/art-tag.js | 20 +- src/data/things/artist.js | 35 ++- src/data/things/cacheable-object.js | 4 +- src/data/things/composite.js | 22 +- src/data/things/flash.js | 50 ++-- src/data/things/group.js | 37 ++- src/data/things/homepage-layout.js | 29 +- src/data/things/language.js | 12 +- src/data/things/news-entry.js | 15 +- src/data/things/static-page.js | 14 +- src/data/things/thing.js | 554 +++++++++++++++++++----------------- src/data/things/track.js | 83 +++--- src/data/things/validators.js | 6 +- src/data/things/wiki-info.js | 34 ++- src/data/yaml.js | 42 +-- src/find.js | 6 +- src/gen-thumbs.js | 6 +- src/upd8.js | 14 +- src/util/cli.js | 2 +- src/util/sugar.js | 10 +- 23 files changed, 624 insertions(+), 491 deletions(-) diff --git a/data-tests/index.js b/data-tests/index.js index b05de9ed..d0770907 100644 --- a/data-tests/index.js +++ b/data-tests/index.js @@ -3,7 +3,7 @@ import {fileURLToPath} from 'node:url'; import chokidar from 'chokidar'; -import {color, logError, logInfo, logWarn, parseOptions} from '#cli'; +import {colors, logError, logInfo, logWarn, parseOptions} from '#cli'; import {isMain} from '#node-utils'; import {getContextAssignments} from '#repl'; import {bindOpts, showAggregate} from '#sugar'; @@ -24,7 +24,7 @@ async function main() { } console.log(`HSMusic automated data tests`); - console.log(`${color.bright(color.yellow(`:star:`))} Now featuring quick-reloading! ${color.bright(color.cyan(`:earth:`))}`); + console.log(`${colors.bright(colors.yellow(`:star:`))} Now featuring quick-reloading! ${colors.bright(colors.cyan(`:earth:`))}`); // Watch adjacent files in data-tests directory const metaPath = fileURLToPath(import.meta.url); diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js index 3bc34845..57025a5d 100644 --- a/src/content/dependencies/index.js +++ b/src/content/dependencies/index.js @@ -6,7 +6,7 @@ import {fileURLToPath} from 'node:url'; import chokidar from 'chokidar'; import {ESLint} from 'eslint'; -import {color, logWarn} from '#cli'; +import {colors, logWarn} from '#cli'; import contentFunction, {ContentFunctionSpecError} from '#content-function'; import {annotateFunction} from '#sugar'; @@ -192,7 +192,7 @@ export function watchContentDependencies({ if (logging && emittedReady) { const timestamp = new Date().toLocaleString('en-US', {timeStyle: 'medium'}); - console.log(color.green(`[${timestamp}] Updated ${functionName}`)); + console.log(colors.green(`[${timestamp}] Updated ${functionName}`)); } contentDependencies[functionName] = fn; @@ -219,9 +219,9 @@ export function watchContentDependencies({ } if (typeof error === 'string') { - console.error(color.yellow(error)); + console.error(colors.yellow(error)); } else if (error instanceof ContentFunctionSpecError) { - console.error(color.yellow(error.message)); + console.error(colors.yellow(error.message)); } else { console.error(error); } diff --git a/src/data/things/album.js b/src/data/things/album.js index da018856..9cf58641 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -2,7 +2,25 @@ import find from '#find'; import {empty} from '#sugar'; import {isDate, isDimensions, isTrackSectionList} from '#validators'; -import Thing from './thing.js'; +import Thing, { + additionalFiles, + commentary, + color, + commentatorArtists, + contribsByRef, + contribsPresent, + directory, + dynamicContribs, + fileExtension, + flag, + name, + resolvedReferenceList, + referenceList, + simpleDate, + simpleString, + urls, + wikiData, +} from './thing.js'; export class Album extends Thing { static [Thing.referenceType] = 'album'; @@ -10,14 +28,14 @@ export class Album extends Thing { static [Thing.getPropertyDescriptors] = ({ArtTag, Artist, Group, Track}) => ({ // Update & expose - name: Thing.common.name('Unnamed Album'), - color: Thing.common.color(), - directory: Thing.common.directory(), - urls: Thing.common.urls(), + name: name('Unnamed Album'), + color: color(), + directory: directory(), + urls: urls(), - date: Thing.common.simpleDate(), - trackArtDate: Thing.common.simpleDate(), - dateAddedToWiki: Thing.common.simpleDate(), + date: simpleDate(), + trackArtDate: simpleDate(), + dateAddedToWiki: simpleDate(), coverArtDate: { flags: {update: true, expose: true}, @@ -36,14 +54,14 @@ export class Album extends Thing { }, }, - artistContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), - trackCoverArtistContribsByRef: Thing.common.contribsByRef(), - wallpaperArtistContribsByRef: Thing.common.contribsByRef(), - bannerArtistContribsByRef: Thing.common.contribsByRef(), + artistContribsByRef: contribsByRef(), + coverArtistContribsByRef: contribsByRef(), + trackCoverArtistContribsByRef: contribsByRef(), + wallpaperArtistContribsByRef: contribsByRef(), + bannerArtistContribsByRef: contribsByRef(), - groupsByRef: Thing.common.referenceList(Group), - artTagsByRef: Thing.common.referenceList(ArtTag), + groupsByRef: referenceList(Group), + artTagsByRef: referenceList(ArtTag), trackSections: { flags: {update: true, expose: true}, @@ -81,58 +99,58 @@ export class Album extends Thing { }, }, - coverArtFileExtension: Thing.common.fileExtension('jpg'), - trackCoverArtFileExtension: Thing.common.fileExtension('jpg'), + coverArtFileExtension: fileExtension('jpg'), + trackCoverArtFileExtension: fileExtension('jpg'), - wallpaperStyle: Thing.common.simpleString(), - wallpaperFileExtension: Thing.common.fileExtension('jpg'), + wallpaperStyle: simpleString(), + wallpaperFileExtension: fileExtension('jpg'), - bannerStyle: Thing.common.simpleString(), - bannerFileExtension: Thing.common.fileExtension('jpg'), + bannerStyle: simpleString(), + bannerFileExtension: fileExtension('jpg'), bannerDimensions: { flags: {update: true, expose: true}, update: {validate: isDimensions}, }, - hasTrackNumbers: Thing.common.flag(true), - isListedOnHomepage: Thing.common.flag(true), - isListedInGalleries: Thing.common.flag(true), + hasTrackNumbers: flag(true), + isListedOnHomepage: flag(true), + isListedInGalleries: flag(true), - commentary: Thing.common.commentary(), - additionalFiles: Thing.common.additionalFiles(), + commentary: commentary(), + additionalFiles: additionalFiles(), // Update only - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - groupData: Thing.common.wikiData(Group), - trackData: Thing.common.wikiData(Track), + artistData: wikiData(Artist), + artTagData: wikiData(ArtTag), + groupData: wikiData(Group), + trackData: wikiData(Track), // Expose only - artistContribs: Thing.common.dynamicContribs('artistContribsByRef'), - coverArtistContribs: Thing.common.dynamicContribs('coverArtistContribsByRef'), - trackCoverArtistContribs: Thing.common.dynamicContribs('trackCoverArtistContribsByRef'), - wallpaperArtistContribs: Thing.common.dynamicContribs('wallpaperArtistContribsByRef'), - bannerArtistContribs: Thing.common.dynamicContribs('bannerArtistContribsByRef'), + artistContribs: dynamicContribs('artistContribsByRef'), + coverArtistContribs: dynamicContribs('coverArtistContribsByRef'), + trackCoverArtistContribs: dynamicContribs('trackCoverArtistContribsByRef'), + wallpaperArtistContribs: dynamicContribs('wallpaperArtistContribsByRef'), + bannerArtistContribs: dynamicContribs('bannerArtistContribsByRef'), - commentatorArtists: Thing.common.commentatorArtists(), + commentatorArtists: commentatorArtists(), - groups: Thing.common.resolvedReferenceList({ + groups: resolvedReferenceList({ list: 'groupsByRef', data: 'groupData', find: find.group, }), - artTags: Thing.common.resolvedReferenceList({ + artTags: resolvedReferenceList({ list: 'artTagsByRef', data: 'artTagData', find: find.artTag, }), - hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'), - hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'), - hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'), + hasCoverArt: contribsPresent('coverArtistContribsByRef'), + hasWallpaperArt: contribsPresent('wallpaperArtistContribsByRef'), + hasBannerArt: contribsPresent('bannerArtistContribsByRef'), tracks: { flags: {expose: true}, @@ -192,9 +210,9 @@ export class Album extends Thing { export class TrackSectionHelper extends Thing { static [Thing.getPropertyDescriptors] = () => ({ - name: Thing.common.name('Unnamed Track Group'), - color: Thing.common.color(), - dateOriginallyReleased: Thing.common.simpleDate(), - isDefaultTrackGroup: Thing.common.flag(false), + name: name('Unnamed Track Group'), + color: color(), + dateOriginallyReleased: simpleDate(), + isDefaultTrackGroup: flag(false), }) } diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index 5d7d0cbf..3d65b578 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,6 +1,12 @@ import {sortAlbumsTracksChronologically} from '#wiki-data'; -import Thing from './thing.js'; +import Thing, { + color, + directory, + flag, + name, + wikiData, +} from './thing.js'; export class ArtTag extends Thing { static [Thing.referenceType] = 'tag'; @@ -8,10 +14,10 @@ export class ArtTag extends Thing { static [Thing.getPropertyDescriptors] = ({Album, Track}) => ({ // Update & expose - name: Thing.common.name('Unnamed Art Tag'), - directory: Thing.common.directory(), - color: Thing.common.color(), - isContentWarning: Thing.common.flag(false), + name: name('Unnamed Art Tag'), + directory: directory(), + color: color(), + isContentWarning: flag(false), nameShort: { flags: {update: true, expose: true}, @@ -25,8 +31,8 @@ export class ArtTag extends Thing { // Update only - albumData: Thing.common.wikiData(Album), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + trackData: wikiData(Track), // Expose only diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 93a1b51b..2676591a 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -1,7 +1,16 @@ import find from '#find'; import {isName, validateArrayItems} from '#validators'; -import Thing from './thing.js'; +import Thing, { + directory, + fileExtension, + flag, + name, + simpleString, + singleReference, + urls, + wikiData, +} from './thing.js'; export class Artist extends Thing { static [Thing.referenceType] = 'artist'; @@ -9,13 +18,13 @@ export class Artist extends Thing { static [Thing.getPropertyDescriptors] = ({Album, Flash, Track}) => ({ // Update & expose - name: Thing.common.name('Unnamed Artist'), - directory: Thing.common.directory(), - urls: Thing.common.urls(), - contextNotes: Thing.common.simpleString(), + name: name('Unnamed Artist'), + directory: directory(), + urls: urls(), + contextNotes: simpleString(), - hasAvatar: Thing.common.flag(false), - avatarFileExtension: Thing.common.fileExtension('jpg'), + hasAvatar: flag(false), + avatarFileExtension: fileExtension('jpg'), aliasNames: { flags: {update: true, expose: true}, @@ -23,15 +32,15 @@ export class Artist extends Thing { expose: {transform: (names) => names ?? []}, }, - isAlias: Thing.common.flag(), - aliasedArtistRef: Thing.common.singleReference(Artist), + isAlias: flag(), + aliasedArtistRef: singleReference(Artist), // Update only - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + artistData: wikiData(Artist), + flashData: wikiData(Flash), + trackData: wikiData(Track), // Expose only diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js index 62c23d13..92a46d66 100644 --- a/src/data/things/cacheable-object.js +++ b/src/data/things/cacheable-object.js @@ -76,7 +76,7 @@ import {inspect as nodeInspect} from 'node:util'; -import {color, ENABLE_COLOR} from '#cli'; +import {colors, ENABLE_COLOR} from '#cli'; function inspect(value) { return nodeInspect(value, {colors: ENABLE_COLOR}); @@ -183,7 +183,7 @@ export default class CacheableObject { } } catch (error) { error.message = [ - `Property ${color.green(property)}`, + `Property ${colors.green(property)}`, `(${inspect(this[property])} -> ${inspect(newValue)}):`, error.message ].join(' '); diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 5b6de901..fd52aa0f 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1,6 +1,6 @@ import {inspect} from 'node:util'; -import {color} from '#cli'; +import {colors} from '#cli'; import { empty, @@ -346,8 +346,8 @@ export function compositeFrom(firstArg, secondArg) { if (compositeFrom.debug === true) { const label = (annotation - ? color.dim(`[composite: ${annotation}]`) - : color.dim(`[composite]`)); + ? colors.dim(`[composite: ${annotation}]`) + : colors.dim(`[composite]`)); const result = fn(); if (Array.isArray(result)) { console.log(label, ...result.map(value => @@ -594,9 +594,9 @@ export function compositeFrom(firstArg, secondArg) { const availableDependencies = {...initialDependencies}; if (expectingTransform) { - debug(() => [color.bright(`begin composition - transforming from:`), initialValue]); + debug(() => [colors.bright(`begin composition - transforming from:`), initialValue]); } else { - debug(() => color.bright(`begin composition - not transforming`)); + debug(() => colors.bright(`begin composition - not transforming`)); } for (let i = 0; i < steps.length; i++) { @@ -641,7 +641,7 @@ export function compositeFrom(firstArg, secondArg) { throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); } - debug(() => color.bright(`end composition - exit (inferred)`)); + debug(() => colors.bright(`end composition - exit (inferred)`)); return result; } @@ -652,7 +652,7 @@ export function compositeFrom(firstArg, secondArg) { const {providedValue} = continuationStorage; debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); - debug(() => color.bright(`end composition - exit (explicit)`)); + debug(() => colors.bright(`end composition - exit (explicit)`)); if (baseComposes) { return continuationIfApplicable.exit(providedValue); @@ -708,17 +708,17 @@ export function compositeFrom(firstArg, secondArg) { case 'raise': debug(() => (isBase - ? color.bright(`end composition - raise (base: explicit)`) - : color.bright(`end composition - raise`))); + ? colors.bright(`end composition - raise (base: explicit)`) + : colors.bright(`end composition - raise`))); return continuationIfApplicable(...continuationArgs); case 'raiseAbove': - debug(() => color.bright(`end composition - raiseAbove`)); + debug(() => colors.bright(`end composition - raiseAbove`)); return continuationIfApplicable.raise(...continuationArgs); case 'continuation': if (isBase) { - debug(() => color.bright(`end composition - raise (inferred)`)); + debug(() => colors.bright(`end composition - raise (inferred)`)); return continuationIfApplicable(...continuationArgs); } else { Object.assign(availableDependencies, continuingWithDependencies); diff --git a/src/data/things/flash.js b/src/data/things/flash.js index ce2e7fac..4e640dac 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -8,7 +8,19 @@ import { oneOf, } from '#validators'; -import Thing from './thing.js'; +import Thing, { + dynamicContribs, + color, + contribsByRef, + fileExtension, + name, + referenceList, + resolvedReferenceList, + simpleDate, + simpleString, + urls, + wikiData, +} from './thing.js'; export class Flash extends Thing { static [Thing.referenceType] = 'flash'; @@ -16,7 +28,7 @@ export class Flash extends Thing { static [Thing.getPropertyDescriptors] = ({Artist, Track, FlashAct}) => ({ // Update & expose - name: Thing.common.name('Unnamed Flash'), + name: name('Unnamed Flash'), directory: { flags: {update: true, expose: true}, @@ -44,27 +56,27 @@ export class Flash extends Thing { }, }, - date: Thing.common.simpleDate(), + date: simpleDate(), - coverArtFileExtension: Thing.common.fileExtension('jpg'), + coverArtFileExtension: fileExtension('jpg'), - contributorContribsByRef: Thing.common.contribsByRef(), + contributorContribsByRef: contribsByRef(), - featuredTracksByRef: Thing.common.referenceList(Track), + featuredTracksByRef: referenceList(Track), - urls: Thing.common.urls(), + urls: urls(), // Update only - artistData: Thing.common.wikiData(Artist), - trackData: Thing.common.wikiData(Track), - flashActData: Thing.common.wikiData(FlashAct), + artistData: wikiData(Artist), + trackData: wikiData(Track), + flashActData: wikiData(FlashAct), // Expose only - contributorContribs: Thing.common.dynamicContribs('contributorContribsByRef'), + contributorContribs: dynamicContribs('contributorContribsByRef'), - featuredTracks: Thing.common.resolvedReferenceList({ + featuredTracks: resolvedReferenceList({ list: 'featuredTracksByRef', data: 'trackData', find: find.track, @@ -111,10 +123,10 @@ export class FlashAct extends Thing { static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: Thing.common.name('Unnamed Flash Act'), - color: Thing.common.color(), - anchor: Thing.common.simpleString(), - jump: Thing.common.simpleString(), + name: name('Unnamed Flash Act'), + color: color(), + anchor: simpleString(), + jump: simpleString(), jumpColor: { flags: {update: true, expose: true}, @@ -126,15 +138,15 @@ export class FlashAct extends Thing { } }, - flashesByRef: Thing.common.referenceList(Flash), + flashesByRef: referenceList(Flash), // Update only - flashData: Thing.common.wikiData(Flash), + flashData: wikiData(Flash), // Expose only - flashes: Thing.common.resolvedReferenceList({ + flashes: resolvedReferenceList({ list: 'flashesByRef', data: 'flashData', find: find.flash, diff --git a/src/data/things/group.js b/src/data/things/group.js index 6c712847..873c6d88 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -1,6 +1,15 @@ import find from '#find'; -import Thing from './thing.js'; +import Thing, { + color, + directory, + name, + referenceList, + resolvedReferenceList, + simpleString, + urls, + wikiData, +} from './thing.js'; export class Group extends Thing { static [Thing.referenceType] = 'group'; @@ -8,23 +17,23 @@ export class Group extends Thing { static [Thing.getPropertyDescriptors] = ({Album}) => ({ // Update & expose - name: Thing.common.name('Unnamed Group'), - directory: Thing.common.directory(), + name: name('Unnamed Group'), + directory: directory(), - description: Thing.common.simpleString(), + description: simpleString(), - urls: Thing.common.urls(), + urls: urls(), - featuredAlbumsByRef: Thing.common.referenceList(Album), + featuredAlbumsByRef: referenceList(Album), // Update only - albumData: Thing.common.wikiData(Album), - groupCategoryData: Thing.common.wikiData(GroupCategory), + albumData: wikiData(Album), + groupCategoryData: wikiData(GroupCategory), // Expose only - featuredAlbums: Thing.common.resolvedReferenceList({ + featuredAlbums: resolvedReferenceList({ list: 'featuredAlbumsByRef', data: 'albumData', find: find.album, @@ -77,18 +86,18 @@ export class GroupCategory extends Thing { static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose - name: Thing.common.name('Unnamed Group Category'), - color: Thing.common.color(), + name: name('Unnamed Group Category'), + color: color(), - groupsByRef: Thing.common.referenceList(Group), + groupsByRef: referenceList(Group), // Update only - groupData: Thing.common.wikiData(Group), + groupData: wikiData(Group), // Expose only - groups: Thing.common.resolvedReferenceList({ + groups: resolvedReferenceList({ list: 'groupsByRef', data: 'groupData', find: find.group, diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index 59656b41..ab6f4cff 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -9,13 +9,22 @@ import { validateInstanceOf, } from '#validators'; -import Thing from './thing.js'; +import Thing, { + color, + name, + referenceList, + resolvedReference, + resolvedReferenceList, + simpleString, + singleReference, + wikiData, +} from './thing.js'; export class HomepageLayout extends Thing { static [Thing.getPropertyDescriptors] = ({HomepageLayoutRow}) => ({ // Update & expose - sidebarContent: Thing.common.simpleString(), + sidebarContent: simpleString(), navbarLinks: { flags: {update: true, expose: true}, @@ -36,7 +45,7 @@ export class HomepageLayoutRow extends Thing { static [Thing.getPropertyDescriptors] = ({Album, Group}) => ({ // Update & expose - name: Thing.common.name('Unnamed Homepage Row'), + name: name('Unnamed Homepage Row'), type: { flags: {update: true, expose: true}, @@ -48,15 +57,15 @@ export class HomepageLayoutRow extends Thing { }, }, - color: Thing.common.color(), + color: color(), // 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: Thing.common.wikiData(Album), - groupData: Thing.common.wikiData(Group), + albumData: wikiData(Album), + groupData: wikiData(Group), }); } @@ -92,8 +101,8 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { }, }, - sourceGroupByRef: Thing.common.singleReference(Group), - sourceAlbumsByRef: Thing.common.referenceList(Album), + sourceGroupByRef: singleReference(Group), + sourceAlbumsByRef: referenceList(Album), countAlbumsFromGroup: { flags: {update: true, expose: true}, @@ -107,13 +116,13 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { // Expose only - sourceGroup: Thing.common.resolvedReference({ + sourceGroup: resolvedReference({ ref: 'sourceGroupByRef', data: 'groupData', find: find.group, }), - sourceAlbums: Thing.common.resolvedReferenceList({ + sourceAlbums: resolvedReferenceList({ list: 'sourceAlbumsByRef', data: 'albumData', find: find.album, diff --git a/src/data/things/language.js b/src/data/things/language.js index 0638afa2..c98495dc 100644 --- a/src/data/things/language.js +++ b/src/data/things/language.js @@ -1,6 +1,10 @@ import {isLanguageCode} from '#validators'; -import Thing from './thing.js'; +import Thing, { + externalFunction, + flag, + simpleString, +} from './thing.js'; export class Language extends Thing { static [Thing.getPropertyDescriptors] = () => ({ @@ -16,7 +20,7 @@ export class Language extends Thing { // Human-readable name. This should be the language's own native name, not // localized to any other language. - name: Thing.common.simpleString(), + name: simpleString(), // Language code specific to JavaScript's Internationalization (Intl) API. // Usually this will be the same as the language's general code, but it @@ -38,7 +42,7 @@ export class Language extends Thing { // with languages that are currently in development and not ready for // formal release, or which are just kept hidden as "experimental zones" // for wiki development or content testing. - hidden: Thing.common.flag(false), + hidden: flag(false), // Mapping of translation keys to values (strings). Generally, don't // access this object directly - use methods instead. @@ -66,7 +70,7 @@ export class Language extends Thing { // Update only - escapeHTML: Thing.common.externalFunction(), + escapeHTML: externalFunction(), // Expose only diff --git a/src/data/things/news-entry.js b/src/data/things/news-entry.js index 43911410..6984874e 100644 --- a/src/data/things/news-entry.js +++ b/src/data/things/news-entry.js @@ -1,4 +1,9 @@ -import Thing from './thing.js'; +import Thing, { + directory, + name, + simpleDate, + simpleString, +} from './thing.js'; export class NewsEntry extends Thing { static [Thing.referenceType] = 'news-entry'; @@ -6,11 +11,11 @@ export class NewsEntry extends Thing { static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: Thing.common.name('Unnamed News Entry'), - directory: Thing.common.directory(), - date: Thing.common.simpleDate(), + name: name('Unnamed News Entry'), + directory: directory(), + date: simpleDate(), - content: Thing.common.simpleString(), + content: simpleString(), // Expose only diff --git a/src/data/things/static-page.js b/src/data/things/static-page.js index ae0ca420..0133e0b6 100644 --- a/src/data/things/static-page.js +++ b/src/data/things/static-page.js @@ -1,6 +1,10 @@ import {isName} from '#validators'; -import Thing from './thing.js'; +import Thing, { + directory, + name, + simpleString, +} from './thing.js'; export class StaticPage extends Thing { static [Thing.referenceType] = 'static'; @@ -8,7 +12,7 @@ export class StaticPage extends Thing { static [Thing.getPropertyDescriptors] = () => ({ // Update & expose - name: Thing.common.name('Unnamed Static Page'), + name: name('Unnamed Static Page'), nameShort: { flags: {update: true, expose: true}, @@ -20,8 +24,8 @@ export class StaticPage extends Thing { }, }, - directory: Thing.common.directory(), - content: Thing.common.simpleString(), - stylesheet: Thing.common.simpleString(), + directory: directory(), + content: simpleString(), + stylesheet: simpleString(), }); } diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 98dec3c3..19f00b3e 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -3,7 +3,7 @@ import {inspect} from 'node:util'; -import {color} from '#cli'; +import {colors} from '#cli'; import find from '#find'; import {empty, stitchArrays} from '#sugar'; import {filterMultipleArrays, getKebabCase} from '#wiki-data'; @@ -41,297 +41,329 @@ export default class Thing extends CacheableObject { static getPropertyDescriptors = Symbol('Thing.getPropertyDescriptors'); static getSerializeDescriptors = Symbol('Thing.getSerializeDescriptors'); - // Regularly reused property descriptors, for ease of access and generally - // duplicating less code across wiki data types. These are specialized utility - // functions, so check each for how its own arguments behave! - static common = { - name: (defaultName) => ({ - flags: {update: true, expose: true}, - update: {validate: isName, default: defaultName}, - }), + // Default custom inspect function, which may be overridden by Thing + // subclasses. This will be used when displaying aggregate errors and other + // command-line logging - it's the place to provide information useful in + // identifying the Thing being presented. + [inspect.custom]() { + const cname = this.constructor.name; - color: () => ({ - flags: {update: true, expose: true}, - update: {validate: isColor}, - }), + return ( + (this.name ? `${cname} ${colors.green(`"${this.name}"`)}` : `${cname}`) + + (this.directory ? ` (${colors.blue(Thing.getReference(this))})` : '') + ); + } - directory: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDirectory}, - expose: { - 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]`); + } - urls: () => ({ - flags: {update: true, expose: true}, - update: {validate: validateArrayItems(isURL)}, - expose: {transform: (value) => value ?? []}, - }), + if (!thing.directory) { + throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); + } - // A file extension! Or the default, if provided when calling this. - fileExtension: (defaultFileExtension = null) => ({ - flags: {update: true, expose: true}, - update: {validate: isFileExtension}, - expose: {transform: (value) => value ?? defaultFileExtension}, - }), + return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; + } +} + +// Property descriptor templates +// +// Regularly reused property descriptors, for ease of access and generally +// duplicating less code across wiki data types. These are specialized utility +// functions, so check each for how its own arguments behave! + +export function name(defaultName) { + return { + flags: {update: true, expose: true}, + update: {validate: isName, default: defaultName}, + }; +} + +export function color() { + return { + flags: {update: true, expose: true}, + update: {validate: isColor}, + }; +} - // Straightforward flag descriptor for a variety of property purposes. - // Provide a default value, true or false! - flag: (defaultValue = false) => { - if (typeof defaultValue !== 'boolean') { - throw new TypeError(`Always set explicit defaults for flags!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: isBoolean, default: defaultValue}, - }; +export function directory() { + return { + flags: {update: true, expose: true}, + update: {validate: isDirectory}, + expose: { + dependencies: ['name'], + transform(directory, {name}) { + if (directory === null && name === null) return null; + else if (directory === null) return getKebabCase(name); + else return directory; + }, }, + }; +} - // General date type, used as the descriptor for a bunch of properties. - // This isn't dynamic though - it won't inherit from a date stored on - // another object, for example. - simpleDate: () => ({ - flags: {update: true, expose: true}, - update: {validate: isDate}, - }), +export function urls() { + return { + flags: {update: true, expose: true}, + update: {validate: validateArrayItems(isURL)}, + expose: {transform: (value) => value ?? []}, + }; +} - // General string type. This should probably generally be avoided in favor - // of more specific validation, but using it makes it easy to find where we - // might want to improve later, and it's a useful shorthand meanwhile. - simpleString: () => ({ - flags: {update: true, expose: true}, - update: {validate: isString}, - }), +// A file extension! Or the default, if provided when calling this. +export function fileExtension(defaultFileExtension = null) { + return { + flags: {update: true, expose: true}, + update: {validate: isFileExtension}, + expose: {transform: (value) => value ?? defaultFileExtension}, + }; +} - // External function. These should only be used as dependencies for other - // properties, so they're left unexposed. - externalFunction: () => ({ - flags: {update: true}, - update: {validate: (t) => typeof t === 'function'}, - }), +// Straightforward flag descriptor for a variety of property purposes. +// Provide a default value, true or false! +export function flag(defaultValue = false) { + // TODO: ^ Are you actually kidding me + if (typeof defaultValue !== 'boolean') { + throw new TypeError(`Always set explicit defaults for flags!`); + } - // Super simple "contributions by reference" list, used for a variety of - // properties (Artists, Cover Artists, etc). This is the property which is - // externally provided, in the form: - // - // [ - // {who: 'Artist Name', what: 'Viola'}, - // {who: 'artist:john-cena', what: null}, - // ... - // ] - // - // ...processed from YAML, spreadsheet, or any other kind of input. - contribsByRef: () => ({ - flags: {update: true, expose: true}, - update: {validate: isContributionList}, - }), + return { + flags: {update: true, expose: true}, + update: {validate: isBoolean, default: defaultValue}, + }; +} - // Artist commentary! Generally present on tracks and albums. - commentary: () => ({ - flags: {update: true, expose: true}, - update: {validate: isCommentary}, - }), +// General date type, used as the descriptor for a bunch of properties. +// This isn't dynamic though - it won't inherit from a date stored on +// another object, for example. +export function simpleDate() { + return { + flags: {update: true, expose: true}, + update: {validate: isDate}, + }; +} - // This is a somewhat more involved data structure - it's for additional - // or "bonus" files associated with albums or tracks (or anything else). - // It's got this form: - // - // [ - // {title: 'Booklet', files: ['Booklet.pdf']}, - // { - // title: 'Wallpaper', - // description: 'Cool Wallpaper!', - // files: ['1440x900.png', '1920x1080.png'] - // }, - // {title: 'Alternate Covers', description: null, files: [...]}, - // ... - // ] - // - additionalFiles: () => ({ - flags: {update: true, expose: true}, - update: {validate: isAdditionalFileList}, - expose: { - transform: (additionalFiles) => - additionalFiles ?? [], - }, - }), +// General string type. This should probably generally be avoided in favor +// of more specific validation, but using it makes it easy to find where we +// might want to improve later, and it's a useful shorthand meanwhile. +export function simpleString() { + return { + flags: {update: true, expose: true}, + update: {validate: isString}, + }; +} - // A reference list! Keep in mind this is for general references to wiki - // objects of (usually) other Thing subclasses, not specifically leitmotif - // references in tracks (although that property uses referenceList too!). - // - // The underlying function validateReferenceList expects a string like - // 'artist' or 'track', but this utility keeps from having to hard-code the - // string in multiple places by referencing the value saved on the class - // instead. - referenceList: (thingClass) => { - const {[Thing.referenceType]: referenceType} = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList(referenceType)}, - }; - }, +// External function. These should only be used as dependencies for other +// properties, so they're left unexposed. +export function externalFunction() { + return { + flags: {update: true}, + update: {validate: (t) => typeof t === 'function'}, + }; +} - // Corresponding function for a single reference. - singleReference: (thingClass) => { - const {[Thing.referenceType]: referenceType} = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); - } - - return { - flags: {update: true, expose: true}, - update: {validate: validateReference(referenceType)}, - }; - }, +// Super simple "contributions by reference" list, used for a variety of +// properties (Artists, Cover Artists, etc). This is the property which is +// externally provided, in the form: +// +// [ +// {who: 'Artist Name', what: 'Viola'}, +// {who: 'artist:john-cena', what: null}, +// ... +// ] +// +// ...processed from YAML, spreadsheet, or any other kind of input. +export function contribsByRef() { + return { + flags: {update: true, expose: true}, + update: {validate: isContributionList}, + }; +} - // Corresponding dynamic property to referenceList, which takes the values - // in the provided property and searches the specified wiki data for - // matching actual Thing-subclass objects. - resolvedReferenceList({list, data, find}) { - return compositeFrom(`Thing.common.resolvedReferenceList`, [ - withResolvedReferenceList({ - list, data, find, - notFoundMode: 'filter', - }), +// Artist commentary! Generally present on tracks and albums. +export function commentary() { + return { + flags: {update: true, expose: true}, + update: {validate: isCommentary}, + }; +} - exposeDependency({dependency: '#resolvedReferenceList'}), - ]); +// This is a somewhat more involved data structure - it's for additional +// or "bonus" files associated with albums or tracks (or anything else). +// It's got this form: +// +// [ +// {title: 'Booklet', files: ['Booklet.pdf']}, +// { +// title: 'Wallpaper', +// description: 'Cool Wallpaper!', +// files: ['1440x900.png', '1920x1080.png'] +// }, +// {title: 'Alternate Covers', description: null, files: [...]}, +// ... +// ] +// +export function additionalFiles() { + return { + flags: {update: true, expose: true}, + update: {validate: isAdditionalFileList}, + expose: { + transform: (additionalFiles) => + additionalFiles ?? [], }, + }; +} - // Corresponding function for a single reference. - resolvedReference({ref, data, find}) { - return compositeFrom(`Thing.common.resolvedReference`, [ - withResolvedReference({ref, data, find}), - exposeDependency({dependency: '#resolvedReference'}), - ]); - }, +// A reference list! Keep in mind this is for general references to wiki +// objects of (usually) other Thing subclasses, not specifically leitmotif +// references in tracks (although that property uses referenceList too!). +// +// The underlying function validateReferenceList expects a string like +// 'artist' or 'track', but this utility keeps from having to hard-code the +// string in multiple places by referencing the value saved on the class +// instead. +export function referenceList(thingClass) { + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); + } - // Corresponding dynamic property to contribsByRef, which takes the values - // in the provided property and searches the object's artistData for - // matching actual Artist objects. The computed structure has the same form - // as contribsByRef, but with Artist objects instead of string references: - // - // [ - // {who: (an Artist), what: 'Viola'}, - // {who: (an Artist), what: null}, - // ... - // ] - // - // Contributions whose "who" values don't match anything in artistData are - // filtered out. (So if the list is all empty, chances are that either the - // reference list is somehow messed up, or artistData isn't being provided - // properly.) - dynamicContribs(contribsByRefProperty) { - return compositeFrom(`Thing.common.dynamicContribs`, [ - withResolvedContribs({ - from: contribsByRefProperty, - into: '#contribs', - }), + return { + flags: {update: true, expose: true}, + update: {validate: validateReferenceList(referenceType)}, + }; +} - exposeDependency({dependency: '#contribs'}), - ]); - }, +// Corresponding function for a single reference. +export function singleReference(thingClass) { + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); + } - // Nice 'n simple shorthand for an exposed-only flag which is true when any - // contributions are present in the specified property. - contribsPresent: (contribsByRefProperty) => ({ - flags: {expose: true}, - expose: { - dependencies: [contribsByRefProperty], - compute({ - [contribsByRefProperty]: contribsByRef, - }) { - return !empty(contribsByRef); - }, - } + return { + flags: {update: true, expose: true}, + update: {validate: validateReference(referenceType)}, + }; +} + +// Corresponding dynamic property to referenceList, which takes the values +// in the provided property and searches the specified wiki data for +// matching actual Thing-subclass objects. +export function resolvedReferenceList({list, data, find}) { + return compositeFrom(`resolvedReferenceList`, [ + withResolvedReferenceList({ + list, data, find, + notFoundMode: 'filter', }), - // Neat little shortcut for "reversing" the reference lists stored on other - // things - for example, tracks specify a "referenced tracks" property, and - // you would use this to compute a corresponding "referenced *by* tracks" - // property. Naturally, the passed ref list property is of the things in the - // wiki data provided, not the requesting Thing itself. - reverseReferenceList({data, list}) { - return compositeFrom(`Thing.common.reverseReferenceList`, [ - withReverseReferenceList({data, list}), - exposeDependency({dependency: '#reverseReferenceList'}), - ]); - }, + exposeDependency({dependency: '#resolvedReferenceList'}), + ]); +} - // General purpose wiki data constructor, for properties like artistData, - // trackData, etc. - wikiData: (thingClass) => ({ - flags: {update: true}, - update: { - validate: validateArrayItems(validateInstanceOf(thingClass)), - }, - }), +// Corresponding function for a single reference. +export function resolvedReference({ref, data, find}) { + return compositeFrom(`resolvedReference`, [ + withResolvedReference({ref, data, find}), + exposeDependency({dependency: '#resolvedReference'}), + ]); +} - // This one's kinda tricky: it parses artist "references" from the - // commentary content, and finds the matching artist for each reference. - // This is mostly useful for credits and listings on artist pages. - commentatorArtists: () => ({ - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({artistData, commentary}) => - artistData && commentary - ? Array.from( - new Set( - Array.from( - commentary - .replace(/<\/?b>/g, '') - .matchAll(/(?.*?):<\/i>/g) - ).map(({groups: {who}}) => - find.artist(who, artistData, {mode: 'quiet'}) - ) - ) - ) - : [], - }, +// Corresponding dynamic property to contribsByRef, which takes the values +// in the provided property and searches the object's artistData for +// matching actual Artist objects. The computed structure has the same form +// as contribsByRef, but with Artist objects instead of string references: +// +// [ +// {who: (an Artist), what: 'Viola'}, +// {who: (an Artist), what: null}, +// ... +// ] +// +// Contributions whose "who" values don't match anything in artistData are +// filtered out. (So if the list is all empty, chances are that either the +// reference list is somehow messed up, or artistData isn't being provided +// properly.) +export function dynamicContribs(contribsByRefProperty) { + return compositeFrom(`dynamicContribs`, [ + withResolvedContribs({ + from: contribsByRefProperty, + into: '#contribs', }), - }; - - // Default custom inspect function, which may be overridden by Thing - // subclasses. This will be used when displaying aggregate errors and other - // command-line logging - it's the place to provide information useful in - // identifying the Thing being presented. - [inspect.custom]() { - const cname = this.constructor.name; - return ( - (this.name ? `${cname} ${color.green(`"${this.name}"`)}` : `${cname}`) + - (this.directory ? ` (${color.blue(Thing.getReference(this))})` : '') - ); - } + exposeDependency({dependency: '#contribs'}), + ]); +} - static getReference(thing) { - if (!thing.constructor[Thing.referenceType]) { - throw TypeError(`Passed Thing is ${thing.constructor.name}, which provides no [Thing.referenceType]`); +// Nice 'n simple shorthand for an exposed-only flag which is true when any +// contributions are present in the specified property. +export function contribsPresent(contribsByRefProperty) { + return { + flags: {expose: true}, + expose: { + dependencies: [contribsByRefProperty], + compute({ + [contribsByRefProperty]: contribsByRef, + }) { + return !empty(contribsByRef); + }, } + }; +} - if (!thing.directory) { - throw TypeError(`Passed ${thing.constructor.name} is missing its directory`); - } +// Neat little shortcut for "reversing" the reference lists stored on other +// things - for example, tracks specify a "referenced tracks" property, and +// you would use this to compute a corresponding "referenced *by* tracks" +// property. Naturally, the passed ref list property is of the things in the +// wiki data provided, not the requesting Thing itself. +export function reverseReferenceList({data, list}) { + return compositeFrom(`reverseReferenceList`, [ + withReverseReferenceList({data, list}), + exposeDependency({dependency: '#reverseReferenceList'}), + ]); +} - return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; - } +// General purpose wiki data constructor, for properties like artistData, +// trackData, etc. +export function wikiData(thingClass) { + return { + flags: {update: true}, + update: { + validate: validateArrayItems(validateInstanceOf(thingClass)), + }, + }; } +// This one's kinda tricky: it parses artist "references" from the +// commentary content, and finds the matching artist for each reference. +// This is mostly useful for credits and listings on artist pages. +export function commentatorArtists(){ + return { + flags: {expose: true}, + + expose: { + dependencies: ['artistData', 'commentary'], + + compute: ({artistData, commentary}) => + artistData && commentary + ? Array.from( + new Set( + Array.from( + commentary + .replace(/<\/?b>/g, '') + .matchAll(/(?.*?):<\/i>/g) + ).map(({groups: {who}}) => + find.artist(who, artistData, {mode: 'quiet'}) + ) + ) + ) + : [], + }, + }; +} + +// Compositional utilities + // Resolves the contribsByRef contained in the provided dependency, // providing (named by the second argument) the result. "Resolving" // means mapping the "who" reference of each contribution to an artist @@ -479,14 +511,14 @@ export function withResolvedReferenceList({ ]); } -// Check out the info on Thing.common.reverseReferenceList! +// Check out the info on reverseReferenceList! // This is its composable form. export function withReverseReferenceList({ data, list: refListProperty, into = '#reverseReferenceList', }) { - return compositeFrom(`Thing.common.reverseReferenceList`, [ + return compositeFrom(`withReverseReferenceList`, [ exitWithoutDependency({ dependency: data, value: [], diff --git a/src/data/things/track.js b/src/data/things/track.js index 10b966a7..41c92092 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -1,6 +1,6 @@ import {inspect} from 'node:util'; -import {color} from '#cli'; +import {colors} from '#cli'; import find from '#find'; import {empty} from '#sugar'; import {isColor, isDate, isDuration, isFileExtension} from '#validators'; @@ -16,6 +16,23 @@ import { } from '#composite'; import Thing, { + additionalFiles, + commentary, + commentatorArtists, + contribsByRef, + directory, + dynamicContribs, + flag, + name, + referenceList, + resolvedReference, + resolvedReferenceList, + reverseReferenceList, + simpleDate, + singleReference, + simpleString, + urls, + wikiData, withResolvedContribs, withResolvedReference, withReverseReferenceList, @@ -27,24 +44,24 @@ export class Track extends Thing { static [Thing.getPropertyDescriptors] = ({Album, ArtTag, Artist, Flash}) => ({ // Update & expose - name: Thing.common.name('Unnamed Track'), - directory: Thing.common.directory(), + name: name('Unnamed Track'), + directory: directory(), duration: { flags: {update: true, expose: true}, update: {validate: isDuration}, }, - urls: Thing.common.urls(), - dateFirstReleased: Thing.common.simpleDate(), + urls: urls(), + dateFirstReleased: simpleDate(), - artistContribsByRef: Thing.common.contribsByRef(), - contributorContribsByRef: Thing.common.contribsByRef(), - coverArtistContribsByRef: Thing.common.contribsByRef(), + artistContribsByRef: contribsByRef(), + contributorContribsByRef: contribsByRef(), + coverArtistContribsByRef: contribsByRef(), - referencedTracksByRef: Thing.common.referenceList(Track), - sampledTracksByRef: Thing.common.referenceList(Track), - artTagsByRef: Thing.common.referenceList(ArtTag), + referencedTracksByRef: referenceList(Track), + sampledTracksByRef: referenceList(Track), + artTagsByRef: referenceList(ArtTag), color: compositeFrom(`Track.color`, [ exposeUpdateValueOrContinue(), @@ -74,7 +91,7 @@ export class Track extends Thing { // This flag should only be used in select circumstances, i.e. to override // an album's trackCoverArtists. This flag supercedes that property, as well // as the track's own coverArtists. - disableUniqueCoverArt: Thing.common.flag(), + disableUniqueCoverArt: flag(), // File extension for track's corresponding media file. This represents the // track's unique cover artwork, if any, and does not inherit the extension @@ -117,27 +134,27 @@ export class Track extends Thing { }), ]), - originalReleaseTrackByRef: Thing.common.singleReference(Track), + originalReleaseTrackByRef: singleReference(Track), - dataSourceAlbumByRef: Thing.common.singleReference(Album), + dataSourceAlbumByRef: singleReference(Album), - commentary: Thing.common.commentary(), - lyrics: Thing.common.simpleString(), - additionalFiles: Thing.common.additionalFiles(), - sheetMusicFiles: Thing.common.additionalFiles(), - midiProjectFiles: Thing.common.additionalFiles(), + commentary: commentary(), + lyrics: simpleString(), + additionalFiles: additionalFiles(), + sheetMusicFiles: additionalFiles(), + midiProjectFiles: additionalFiles(), // Update only - albumData: Thing.common.wikiData(Album), - artistData: Thing.common.wikiData(Artist), - artTagData: Thing.common.wikiData(ArtTag), - flashData: Thing.common.wikiData(Flash), - trackData: Thing.common.wikiData(Track), + albumData: wikiData(Album), + artistData: wikiData(Artist), + artTagData: wikiData(ArtTag), + flashData: wikiData(Flash), + trackData: wikiData(Track), // Expose only - commentatorArtists: Thing.common.commentatorArtists(), + commentatorArtists: commentatorArtists(), album: compositeFrom(`Track.album`, [ withAlbum(), @@ -151,7 +168,7 @@ export class Track extends Thing { // not generally relevant information). It's also not guaranteed that // dataSourceAlbum is available (depending on the Track creator to optionally // provide dataSourceAlbumByRef). - dataSourceAlbum: Thing.common.resolvedReference({ + dataSourceAlbum: resolvedReference({ ref: 'dataSourceAlbumByRef', data: 'albumData', find: find.album, @@ -226,7 +243,7 @@ export class Track extends Thing { contributorContribs: compositeFrom(`Track.contributorContribs`, [ inheritFromOriginalRelease({property: 'contributorContribs'}), - Thing.common.dynamicContribs('contributorContribsByRef'), + dynamicContribs('contributorContribsByRef'), ]), // Cover artists aren't inherited from the original release, since it @@ -260,7 +277,7 @@ export class Track extends Thing { referencedTracks: compositeFrom(`Track.referencedTracks`, [ inheritFromOriginalRelease({property: 'referencedTracks'}), - Thing.common.resolvedReferenceList({ + resolvedReferenceList({ list: 'referencedTracksByRef', data: 'trackData', find: find.track, @@ -269,14 +286,14 @@ export class Track extends Thing { sampledTracks: compositeFrom(`Track.sampledTracks`, [ inheritFromOriginalRelease({property: 'sampledTracks'}), - Thing.common.resolvedReferenceList({ + resolvedReferenceList({ list: 'sampledTracksByRef', data: 'trackData', find: find.track, }), ]), - artTags: Thing.common.resolvedReferenceList({ + artTags: resolvedReferenceList({ list: 'artTagsByRef', data: 'artTagData', find: find.artTag, @@ -299,7 +316,7 @@ export class Track extends Thing { property: 'sampledTracks', }), - featuredInFlashes: Thing.common.reverseReferenceList({ + featuredInFlashes: reverseReferenceList({ data: 'flashData', list: 'featuredTracks', }), @@ -311,7 +328,7 @@ export class Track extends Thing { parts.push(Thing.prototype[inspect.custom].apply(this)); if (this.originalReleaseTrackByRef) { - parts.unshift(`${color.yellow('[rerelease]')} `); + parts.unshift(`${colors.yellow('[rerelease]')} `); } let album; @@ -322,7 +339,7 @@ export class Track extends Thing { (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`); - parts.push(` (${color.yellow(trackNum)} in ${color.green(albumName)})`); + parts.push(` (${colors.yellow(trackNum)} in ${colors.green(albumName)})`); } return parts.join(''); diff --git a/src/data/things/validators.js b/src/data/things/validators.js index 5748eacf..4c8f683b 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -1,6 +1,6 @@ import {inspect as nodeInspect} from 'node:util'; -import {color, ENABLE_COLOR} from '#cli'; +import {colors, ENABLE_COLOR} from '#cli'; import {withAggregate} from '#sugar'; function inspect(value) { @@ -174,7 +174,7 @@ function validateArrayItemsHelper(itemValidator) { throw new Error(`Expected validator to return true`); } } catch (error) { - error.message = `(index: ${color.yellow(`#${index}`)}, item: ${inspect(item)}) ${error.message}`; + error.message = `(index: ${colors.yellow(`#${index}`)}, item: ${inspect(item)}) ${error.message}`; throw error; } }; @@ -264,7 +264,7 @@ export function validateProperties(spec) { try { specValidator(value); } catch (error) { - error.message = `(key: ${color.green(specKey)}, value: ${inspect(value)}) ${error.message}`; + error.message = `(key: ${colors.green(specKey)}, value: ${inspect(value)}) ${error.message}`; throw error; } }); diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 0ccef5ed..416b6c4e 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -1,13 +1,21 @@ import find from '#find'; import {isLanguageCode, isName, isURL} from '#validators'; -import Thing from './thing.js'; +import Thing, { + color, + flag, + name, + referenceList, + resolvedReferenceList, + simpleString, + wikiData, +} from './thing.js'; export class WikiInfo extends Thing { static [Thing.getPropertyDescriptors] = ({Group}) => ({ // Update & expose - name: Thing.common.name('Unnamed Wiki'), + name: name('Unnamed Wiki'), // Displayed in nav bar. nameShort: { @@ -20,12 +28,12 @@ export class WikiInfo extends Thing { }, }, - color: Thing.common.color(), + color: color(), // One-line description used for tag. - description: Thing.common.simpleString(), + description: simpleString(), - footerContent: Thing.common.simpleString(), + footerContent: simpleString(), defaultLanguage: { flags: {update: true, expose: true}, @@ -37,22 +45,22 @@ export class WikiInfo extends Thing { update: {validate: isURL}, }, - divideTrackListsByGroupsByRef: Thing.common.referenceList(Group), + divideTrackListsByGroupsByRef: referenceList(Group), // Feature toggles - enableFlashesAndGames: Thing.common.flag(false), - enableListings: Thing.common.flag(false), - enableNews: Thing.common.flag(false), - enableArtTagUI: Thing.common.flag(false), - enableGroupUI: Thing.common.flag(false), + enableFlashesAndGames: flag(false), + enableListings: flag(false), + enableNews: flag(false), + enableArtTagUI: flag(false), + enableGroupUI: flag(false), // Update only - groupData: Thing.common.wikiData(Group), + groupData: wikiData(Group), // Expose only - divideTrackListsByGroups: Thing.common.resolvedReferenceList({ + divideTrackListsByGroups: resolvedReferenceList({ list: 'divideTrackListsByGroupsByRef', data: 'groupData', find: find.group, diff --git a/src/data/yaml.js b/src/data/yaml.js index 2ad2d41d..c0aad943 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -7,7 +7,7 @@ import {inspect as nodeInspect} from 'node:util'; import yaml from 'js-yaml'; -import {color, ENABLE_COLOR, logInfo, logWarn} from '#cli'; +import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; import find, {bindFind} from '#find'; import {traverse} from '#node-utils'; import T from '#things'; @@ -137,7 +137,7 @@ function makeProcessDocument( const name = document[nameField]; error.message = name ? `(name: ${inspect(name)}) ${error.message}` - : `(${color.dim(`no name found`)}) ${error.message}`; + : `(${colors.dim(`no name found`)}) ${error.message}`; throw error; } }; @@ -195,7 +195,7 @@ function makeProcessDocument( const thing = Reflect.construct(thingClass, []); - withAggregate({message: `Errors applying ${color.green(thingClass.name)} properties`}, ({call}) => { + withAggregate({message: `Errors applying ${colors.green(thingClass.name)} properties`}, ({call}) => { for (const [property, value] of Object.entries(sourceProperties)) { call(() => (thing[property] = value)); } @@ -228,7 +228,7 @@ makeProcessDocument.FieldCombinationsError = class FieldCombinationsError extend makeProcessDocument.FieldCombinationError = class FieldCombinationError extends Error { constructor(fields, message) { const fieldNames = Object.keys(fields); - const combinePart = `Don't combine ${fieldNames.map(field => color.red(field)).join(', ')}`; + const combinePart = `Don't combine ${fieldNames.map(field => colors.red(field)).join(', ')}`; const messagePart = (typeof message === 'function' @@ -1009,7 +1009,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { } catch (error) { error.message += (error.message.includes('\n') ? '\n' : ' ') + - `(file: ${color.bright(color.blue(path.relative(dataPath, x.file)))})`; + `(file: ${colors.bright(colors.blue(path.relative(dataPath, x.file)))})`; throw error; } }; @@ -1032,7 +1032,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { // just without the callbacks. Thank you. const filterBlankDocuments = documents => { const aggregate = openAggregate({ - message: `Found blank documents - check for extra '${color.cyan(`---`)}'`, + message: `Found blank documents - check for extra '${colors.cyan(`---`)}'`, }); const filteredDocuments = @@ -1076,10 +1076,10 @@ export async function loadAndProcessDataDocuments({dataPath}) { if (count === 1) { const range = `#${start + 1}`; - parts.push(`${count} document (${color.yellow(range)}), `); + parts.push(`${count} document (${colors.yellow(range)}), `); } else { const range = `#${start + 1}-${end + 1}`; - parts.push(`${count} documents (${color.yellow(range)}), `); + parts.push(`${count} documents (${colors.yellow(range)}), `); } if (previous === null) { @@ -1089,7 +1089,7 @@ export async function loadAndProcessDataDocuments({dataPath}) { } else { const previousDescription = Object.entries(previous).at(0).join(': '); const nextDescription = Object.entries(next).at(0).join(': '); - parts.push(`between "${color.cyan(previousDescription)}" and "${color.cyan(nextDescription)}"`); + parts.push(`between "${colors.cyan(previousDescription)}" and "${colors.cyan(nextDescription)}"`); } aggregate.push(new Error(parts.join(''))); @@ -1395,7 +1395,7 @@ export function filterDuplicateDirectories(wikiData) { const aggregate = openAggregate({message: `Duplicate directories found`}); for (const thingDataProp of deduplicateSpec) { const thingData = wikiData[thingDataProp]; - aggregate.nest({message: `Duplicate directories found in ${color.green('wikiData.' + thingDataProp)}`}, ({call}) => { + aggregate.nest({message: `Duplicate directories found in ${colors.green('wikiData.' + thingDataProp)}`}, ({call}) => { const directoryPlaces = Object.create(null); const duplicateDirectories = []; @@ -1421,7 +1421,7 @@ export function filterDuplicateDirectories(wikiData) { const places = directoryPlaces[directory]; call(() => { throw new Error( - `Duplicate directory ${color.green(directory)}:\n` + + `Duplicate directory ${colors.green(directory)}:\n` + places.map((thing) => ` - ` + inspect(thing)).join('\n') ); }); @@ -1516,7 +1516,7 @@ export function filterReferenceErrors(wikiData) { for (const [thingDataProp, providedProcessDocumentFn, propSpec] of referenceSpec) { const thingData = getNestedProp(wikiData, thingDataProp); - aggregate.nest({message: `Reference errors in ${color.green('wikiData.' + thingDataProp)}`}, ({nest}) => { + aggregate.nest({message: `Reference errors in ${colors.green('wikiData.' + thingDataProp)}`}, ({nest}) => { const things = Array.isArray(thingData) ? thingData : [thingData]; for (const thing of things) { @@ -1535,7 +1535,7 @@ export function filterReferenceErrors(wikiData) { const value = thing[property]; if (value === undefined) { - push(new TypeError(`Property ${color.red(property)} isn't valid for ${color.green(thing.constructor.name)}`)); + push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`)); continue; } @@ -1553,7 +1553,7 @@ export function filterReferenceErrors(wikiData) { // No need to check if the original exists here. Aliases are automatically // created from a field on the original, so the original certainly exists. const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'}); - throw new Error(`Reference ${color.red(contribRef.who)} is to an alias, should be ${color.green(original.name)}`); + throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`); } return boundFind.artist(contribRef.who); @@ -1578,12 +1578,12 @@ export function filterReferenceErrors(wikiData) { const shouldBeMessage = (originalByName - ? color.green(original.name) + ? colors.green(original.name) : original - ? color.green('track:' + original.directory) - : color.green(track.originalReleaseTrackByRef)); + ? colors.green('track:' + original.directory) + : colors.green(track.originalReleaseTrackByRef)); - throw new Error(`Reference ${color.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`); + throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`); } return track; @@ -1614,13 +1614,13 @@ export function filterReferenceErrors(wikiData) { const fieldPropertyMessage = (processDocumentFn?.propertyFieldMapping?.[property] - ? ` in field ${color.green(processDocumentFn.propertyFieldMapping[property])}` - : ` in property ${color.green(property)}`); + ? ` in field ${colors.green(processDocumentFn.propertyFieldMapping[property])}` + : ` in property ${colors.green(property)}`); const findFnMessage = (findFnKey.startsWith('_') ? `` - : ` (${color.green('find.' + findFnKey)})`); + : ` (${colors.green('find.' + findFnKey)})`); const errorMessage = (Array.isArray(value) diff --git a/src/find.js b/src/find.js index b8230800..5ad8dae7 100644 --- a/src/find.js +++ b/src/find.js @@ -1,6 +1,6 @@ import {inspect} from 'node:util'; -import {color, logWarn} from '#cli'; +import {colors, logWarn} from '#cli'; function warnOrThrow(mode, message) { if (mode === 'error') { @@ -66,7 +66,7 @@ function findHelper(keys, findFns = {}) { const found = key ? byDirectory(ref, data, mode) : byName(ref, data, mode); if (!found) { - warnOrThrow(mode, `Didn't match anything for ${color.bright(fullRef)}`); + warnOrThrow(mode, `Didn't match anything for ${colors.bright(fullRef)}`); } cacheForThisData[fullRef] = found; @@ -102,7 +102,7 @@ function matchName(ref, data, mode) { if (ref !== thing.name) { warnOrThrow( mode, - `Bad capitalization: ${color.red(ref)} -> ${color.green(thing.name)}` + `Bad capitalization: ${colors.red(ref)} -> ${colors.green(thing.name)}` ); } diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index 741cdff3..fafd17f6 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -94,7 +94,7 @@ import * as path from 'node:path'; import dimensionsOf from 'image-size'; import { - color, + colors, fileIssue, logError, logInfo, @@ -662,14 +662,14 @@ export async function verifyImagePaths(mediaPath, {urls, wikiData}) { if (!empty(missing)) { logWarn`** Some image files are missing! (${missing.length + ' files'}) **`; for (const file of missing) { - console.warn(color.yellow(` - `) + file); + console.warn(colors.yellow(` - `) + file); } } if (!empty(misplaced)) { logWarn`** Some image files are misplaced! (${misplaced.length + ' files'}) **`; for (const file of misplaced) { - console.warn(color.yellow(` - `) + file); + console.warn(colors.yellow(` - `) + file); } } } diff --git a/src/upd8.js b/src/upd8.js index 2ec231c9..f6091ca2 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -47,7 +47,7 @@ import {generateURLs, urlSpec} from '#urls'; import {sortByName} from '#wiki-data'; import { - color, + colors, decorateTime, logWarn, logInfo, @@ -279,7 +279,7 @@ async function main() { const indentWrap = (spaces, str) => wrap(str, {width: 60 - spaces, indent: ' '.repeat(spaces)}); const showOptions = (msg, options) => { - console.log(color.bright(msg)); + console.log(colors.bright(msg)); const entries = Object.entries(options); const sortedOptions = sortByName(entries @@ -310,13 +310,13 @@ async function main() { console.log(''); } - console.log(color.bright(` --` + name) + + console.log(colors.bright(` --` + name) + (aliases.length - ? ` (or: ${aliases.map(alias => color.bright(`--` + alias)).join(', ')})` + ? ` (or: ${aliases.map(alias => colors.bright(`--` + alias)).join(', ')})` : '') + (descriptor.help ? '' - : color.dim(' (no help provided)'))); + : colors.dim(' (no help provided)'))); if (wrappedHelp) { console.log(wrappedHelp); @@ -336,7 +336,7 @@ async function main() { }; console.log( - color.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) + + colors.bright(`hsmusic (aka. Homestuck Music Wiki)\n`) + `static wiki software cataloguing collaborative creation\n`); console.log(indentWrap(0, @@ -496,7 +496,7 @@ async function main() { { const logThings = (thingDataProp, label) => - logInfo` - ${wikiData[thingDataProp]?.length ?? color.red('(Missing!)')} ${color.normal(color.dim(label))}`; + logInfo` - ${wikiData[thingDataProp]?.length ?? colors.red('(Missing!)')} ${colors.normal(colors.dim(label))}`; try { logInfo`Loaded data and processed objects:`; logThings('albumData', 'albums'); diff --git a/src/util/cli.js b/src/util/cli.js index e8c8c79f..9f2b35ab 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -17,7 +17,7 @@ export const ENABLE_COLOR = const C = (n) => ENABLE_COLOR ? (text) => `\x1b[${n}m${text}\x1b[0m` : (text) => text; -export const color = { +export const colors = { bright: C('1'), dim: C('2'), normal: C('22'), diff --git a/src/util/sugar.js b/src/util/sugar.js index 1ba3f3ae..ebb7d61e 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -6,7 +6,7 @@ // It will likely only do exactly what I want it to, and only in the cases I // decided were relevant enough to 8other handling. -import {color} from './cli.js'; +import {colors} from './cli.js'; // Apparently JavaScript doesn't come with a function to split an array into // chunks! Weird. Anyway, this is an awesome place to use a generator, even @@ -566,10 +566,10 @@ export function showAggregate(topError, { .trim() .replace(/file:\/\/.*\.js/, (match) => pathToFileURL(match)) : '(no stack trace)'; - header += ` ${color.dim(tracePart)}`; + header += ` ${colors.dim(tracePart)}`; } - const bar = level % 2 === 0 ? '\u2502' : color.dim('\u254e'); - const head = level % 2 === 0 ? '\u257f' : color.dim('\u257f'); + const bar = level % 2 === 0 ? '\u2502' : colors.dim('\u254e'); + const head = level % 2 === 0 ? '\u257f' : colors.dim('\u257f'); if (error instanceof AggregateError) { return ( @@ -605,7 +605,7 @@ export function decorateErrorWithIndex(fn) { try { return fn(x, index, array); } catch (error) { - error.message = `(${color.yellow(`#${index + 1}`)}) ${error.message}`; + error.message = `(${colors.yellow(`#${index + 1}`)}) ${error.message}`; throw error; } }; -- cgit 1.3.0-6-gf8a5 From d33effa272c3388640974648fe2888a284c6701c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 14:50:41 -0300 Subject: data: withAlbum: perform proper availability check on album --- src/data/things/composite.js | 5 +++-- src/data/things/track.js | 21 +++++++++++++++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index fd52aa0f..29f5770c 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -853,8 +853,9 @@ export function exposeConstant({ // consider using instead. Customize {mode} to select one of these modes, // or leave unset and default to 'null': // -// * 'null': Check that the value isn't null. +// * 'null': Check that the value isn't null (and not undefined either). // * 'empty': Check that the value is neither null nor an empty array. +// This will outright error for undefined. // * 'falsy': Check that the value isn't false when treated as a boolean // (nor an empty array). Keep in mind this will also be false // for values like zero and the empty string! @@ -879,7 +880,7 @@ export function withResultOfAvailabilityCheck({ const checkAvailability = (value, mode) => { switch (mode) { - case 'null': return value !== null; + case 'null': return value !== null && value !== undefined; case 'empty': return !empty(value); case 'falsy': return !!value && (!Array.isArray(value) || !empty(value)); default: return false; diff --git a/src/data/things/track.js b/src/data/things/track.js index 41c92092..fcfd39c7 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -411,21 +411,34 @@ function withAlbum({ }), }, + withResultOfAvailabilityCheck({ + fromDependency: '#album', + mode: 'null', + into: '#albumAvailability', + }), + { - dependencies: ['#album'], + dependencies: ['#albumAvailability'], options: {notFoundMode}, mapContinuation: {into}, compute: ({ - '#album': album, + '#albumAvailability': albumAvailability, '#options': {notFoundMode}, }, continuation) => - (album - ? continuation.raise({into: album}) + (albumAvailability + ? continuation() : (notFoundMode === 'exit' ? continuation.exit(null) : continuation.raise({into: null}))), }, + + { + dependencies: ['#album'], + mapContinuation: {into}, + compute: ({'#album': album}, continuation) => + continuation({into: album}), + }, ]); } -- cgit 1.3.0-6-gf8a5 From 9db4b91c66f8b9b98d098bfe446e29f5b3caee53 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 14:53:25 -0300 Subject: data: withResolvedContribs: use default "into" --- src/data/things/thing.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 19f00b3e..9d8b2ea2 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -286,12 +286,8 @@ export function resolvedReference({ref, data, find}) { // properly.) export function dynamicContribs(contribsByRefProperty) { return compositeFrom(`dynamicContribs`, [ - withResolvedContribs({ - from: contribsByRefProperty, - into: '#contribs', - }), - - exposeDependency({dependency: '#contribs'}), + withResolvedContribs({from: contribsByRefProperty}), + exposeDependency({dependency: '#resolvedContribs'}), ]); } @@ -368,7 +364,10 @@ export function commentatorArtists(){ // providing (named by the second argument) the result. "Resolving" // means mapping the "who" reference of each contribution to an artist // object, and filtering out those whose "who" doesn't match any artist. -export function withResolvedContribs({from, into}) { +export function withResolvedContribs({ + from, + into = '#resolvedContribs', +}) { return compositeFrom(`withResolvedContribs`, [ raiseWithoutDependency({ dependency: from, -- cgit 1.3.0-6-gf8a5 From a24a72339f6e6e416a797d869fe9c4d9057fcac0 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 17:23:54 -0300 Subject: data: custom _homepageSourceGroup reference validation function --- src/data/yaml.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/data/yaml.js b/src/data/yaml.js index c0aad943..8aca3299 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -1490,7 +1490,7 @@ export function filterReferenceErrors(wikiData) { }], ['homepageLayout.rows', undefined, { - sourceGroupByRef: 'group', + sourceGroupByRef: '_homepageSourceGroup', sourceAlbumsByRef: 'album', }], @@ -1560,6 +1560,16 @@ export function filterReferenceErrors(wikiData) { }; break; + case '_homepageSourceGroup': + findFn = groupRef => { + if (groupRef === 'new-additions' || groupRef === 'new-releases') { + return true; + } + + return boundFind.group(groupRef); + }; + break; + case '_trackNotRerelease': findFn = trackRef => { const track = find.track(trackRef, wikiData.trackData, {mode: 'error'}); -- cgit 1.3.0-6-gf8a5 From c18844784bd1c0ead7c49d0519727b7a92e23e13 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 17:24:26 -0300 Subject: repl: expose CacheableObject in repl --- src/repl.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/repl.js b/src/repl.js index 9ab4ddf0..ead01567 100644 --- a/src/repl.js +++ b/src/repl.js @@ -11,7 +11,7 @@ import {generateURLs, urlSpec} from '#urls'; import {quickLoadAllFromYAML} from '#yaml'; import _find, {bindFind} from '#find'; -import thingConstructors from '#things'; +import thingConstructors, {CacheableObject} from '#things'; import * as serialize from '#serialize'; import * as sugar from '#sugar'; import * as wikiDataUtils from '#wiki-data'; @@ -63,6 +63,7 @@ export async function getContextAssignments({ WD: wikiData, ...thingConstructors, + CacheableObject, language, ...sugar, -- cgit 1.3.0-6-gf8a5 From bbccaf51222cb4bed73466164496f5bc1030292c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Thu, 7 Sep 2023 17:30:54 -0300 Subject: data: roll paired "byRef" and "dynamic" properties into one --- .../dependencies/generateWikiHomeAlbumsRow.js | 2 +- src/data/things/album.js | 75 +++++------ src/data/things/artist.js | 19 +-- src/data/things/cacheable-object.js | 26 ++-- src/data/things/composite.js | 15 +++ src/data/things/flash.js | 34 ++--- src/data/things/group.js | 27 ++-- src/data/things/homepage-layout.js | 60 ++++++--- src/data/things/thing.js | 147 +++++++++++---------- src/data/things/track.js | 132 ++++++++---------- src/data/things/validators.js | 2 +- src/data/things/wiki-info.js | 15 +-- src/data/yaml.js | 133 ++++++++++--------- src/gen-thumbs.js | 9 +- test/unit/data/things/track.js | 76 +++++------ 15 files changed, 379 insertions(+), 393 deletions(-) diff --git a/src/content/dependencies/generateWikiHomeAlbumsRow.js b/src/content/dependencies/generateWikiHomeAlbumsRow.js index 99c1be55..cb0860f5 100644 --- a/src/content/dependencies/generateWikiHomeAlbumsRow.js +++ b/src/content/dependencies/generateWikiHomeAlbumsRow.js @@ -16,7 +16,7 @@ export default { sprawl({albumData}, row) { const sprawl = {}; - switch (row.sourceGroupByRef) { + switch (row.sourceGroup) { case 'new-releases': sprawl.albums = getNewReleases(row.countAlbumsFromGroup, {albumData}); break; diff --git a/src/data/things/album.js b/src/data/things/album.js index 9cf58641..88308182 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -7,14 +7,12 @@ import Thing, { commentary, color, commentatorArtists, - contribsByRef, contribsPresent, + contributionList, directory, - dynamicContribs, fileExtension, flag, name, - resolvedReferenceList, referenceList, simpleDate, simpleString, @@ -43,25 +41,31 @@ export class Album extends Thing { update: {validate: isDate}, expose: { - dependencies: ['date', 'coverArtistContribsByRef'], - transform: (coverArtDate, { - coverArtistContribsByRef, - date, - }) => - (!empty(coverArtistContribsByRef) + dependencies: ['date', 'coverArtistContribs'], + transform: (coverArtDate, {coverArtistContribs, date}) => + (!empty(coverArtistContribs) ? coverArtDate ?? date ?? null : null), }, }, - artistContribsByRef: contribsByRef(), - coverArtistContribsByRef: contribsByRef(), - trackCoverArtistContribsByRef: contribsByRef(), - wallpaperArtistContribsByRef: contribsByRef(), - bannerArtistContribsByRef: contribsByRef(), + artistContribs: contributionList(), + coverArtistContribs: contributionList(), + trackCoverArtistContribs: contributionList(), + wallpaperArtistContribs: contributionList(), + bannerArtistContribs: contributionList(), - groupsByRef: referenceList(Group), - artTagsByRef: referenceList(ArtTag), + groups: referenceList({ + class: Group, + find: find.group, + data: 'groupData', + }), + + artTags: referenceList({ + class: ArtTag, + find: find.artTag, + data: 'artTagData', + }), trackSections: { flags: {update: true, expose: true}, @@ -84,13 +88,12 @@ export class Album extends Thing { isDefaultTrackSection: section.isDefaultTrackSection ?? false, startIndex: ( - startIndex += section.tracksByRef.length, - startIndex - section.tracksByRef.length + startIndex += section.tracks.length, + startIndex - section.tracks.length ), - tracksByRef: section.tracksByRef ?? [], tracks: - (trackData && section.tracksByRef + (trackData && section.tracks ?.map(ref => find.track(ref, trackData, {mode: 'quiet'})) .filter(Boolean)) ?? [], @@ -128,29 +131,11 @@ export class Album extends Thing { // Expose only - artistContribs: dynamicContribs('artistContribsByRef'), - coverArtistContribs: dynamicContribs('coverArtistContribsByRef'), - trackCoverArtistContribs: dynamicContribs('trackCoverArtistContribsByRef'), - wallpaperArtistContribs: dynamicContribs('wallpaperArtistContribsByRef'), - bannerArtistContribs: dynamicContribs('bannerArtistContribsByRef'), - commentatorArtists: commentatorArtists(), - groups: resolvedReferenceList({ - list: 'groupsByRef', - data: 'groupData', - find: find.group, - }), - - artTags: resolvedReferenceList({ - list: 'artTagsByRef', - data: 'artTagData', - find: find.artTag, - }), - - hasCoverArt: contribsPresent('coverArtistContribsByRef'), - hasWallpaperArt: contribsPresent('wallpaperArtistContribsByRef'), - hasBannerArt: contribsPresent('bannerArtistContribsByRef'), + hasCoverArt: contribsPresent('coverArtistContribs'), + hasWallpaperArt: contribsPresent('wallpaperArtistContribs'), + hasBannerArt: contribsPresent('bannerArtistContribs'), tracks: { flags: {expose: true}, @@ -158,12 +143,12 @@ export class Album extends Thing { expose: { dependencies: ['trackSections', 'trackData'], compute: ({trackSections, trackData}) => - trackSections && trackData + (trackSections && trackData ? trackSections - .flatMap((section) => section.tracksByRef ?? []) - .map((ref) => find.track(ref, trackData, {mode: 'quiet'})) + .flatMap(section => section.tracks ?? []) + .map(ref => find.track(ref, trackData, {mode: 'quiet'})) .filter(Boolean) - : [], + : []), }, }, }); diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 2676591a..7a9dbd3c 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -33,7 +33,12 @@ export class Artist extends Thing { }, isAlias: flag(), - aliasedArtistRef: singleReference(Artist), + + aliasedArtist: singleReference({ + class: Artist, + find: find.artist, + data: 'artistData', + }), // Update only @@ -44,18 +49,6 @@ export class Artist extends Thing { // Expose only - aliasedArtist: { - flags: {expose: true}, - - expose: { - dependencies: ['artistData', 'aliasedArtistRef'], - compute: ({artistData, aliasedArtistRef}) => - aliasedArtistRef && artistData - ? find.artist(aliasedArtistRef, artistData, {mode: 'quiet'}) - : null, - }, - }, - tracksAsArtist: Artist.filterByContrib('trackData', 'artistContribs'), tracksAsContributor: diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js index 92a46d66..4bc3668d 100644 --- a/src/data/things/cacheable-object.js +++ b/src/data/things/cacheable-object.js @@ -86,16 +86,14 @@ export default class CacheableObject { #propertyUpdateValues = Object.create(null); #propertyUpdateCacheInvalidators = Object.create(null); - /* - // Note the constructor doesn't take an initial data source. Due to a quirk - // of JavaScript, private members can't be accessed before the superclass's - // constructor is finished processing - so if we call the overridden - // update() function from inside this constructor, it will error when - // writing to private members. Pretty bad! - // - // That means initial data must be provided by following up with update() - // after constructing the new instance of the Thing (sub)class. - */ + // Note the constructor doesn't take an initial data source. Due to a quirk + // of JavaScript, private members can't be accessed before the superclass's + // constructor is finished processing - so if we call the overridden + // update() function from inside this constructor, it will error when + // writing to private members. Pretty bad! + // + // That means initial data must be provided by following up with update() + // after constructing the new instance of the Thing (sub)class. constructor() { this.#defineProperties(); @@ -352,4 +350,12 @@ export default class CacheableObject { console.log(` - ${line}`); } } + + static getUpdateValue(object, key) { + if (!Object.hasOwn(object, key)) { + return undefined; + } + + return object.#propertyUpdateValues[key] ?? null; + } } diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 29f5770c..96abf4af 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1071,3 +1071,18 @@ export function raiseWithoutUpdateValue({ }, ]); } + +export function withUpdateValueAsDependency({ + into = '#updateValue', +} = {}) { + return { + annotation: `withUpdateValueAsDependency`, + flags: {expose: true, compose: true}, + + expose: { + mapContinuation: {into}, + transform: (value, continuation) => + continuation(value, {into: value}), + }, + }; +} diff --git a/src/data/things/flash.js b/src/data/things/flash.js index 4e640dac..eb16d29e 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -9,13 +9,11 @@ import { } from '#validators'; import Thing, { - dynamicContribs, color, - contribsByRef, + contributionList, fileExtension, name, referenceList, - resolvedReferenceList, simpleDate, simpleString, urls, @@ -60,9 +58,13 @@ export class Flash extends Thing { coverArtFileExtension: fileExtension('jpg'), - contributorContribsByRef: contribsByRef(), + contributorContribs: contributionList(), - featuredTracksByRef: referenceList(Track), + featuredTracks: referenceList({ + class: Track, + find: find.track, + data: 'trackData', + }), urls: urls(), @@ -74,14 +76,6 @@ export class Flash extends Thing { // Expose only - contributorContribs: dynamicContribs('contributorContribsByRef'), - - featuredTracks: resolvedReferenceList({ - list: 'featuredTracksByRef', - data: 'trackData', - find: find.track, - }), - act: { flags: {expose: true}, @@ -138,18 +132,14 @@ export class FlashAct extends Thing { } }, - flashesByRef: referenceList(Flash), + flashes: referenceList({ + class: Flash, + data: 'flashData', + find: find.flash, + }), // Update only flashData: wikiData(Flash), - - // Expose only - - flashes: resolvedReferenceList({ - list: 'flashesByRef', - data: 'flashData', - find: find.flash, - }), }) } diff --git a/src/data/things/group.js b/src/data/things/group.js index 873c6d88..f53fa48e 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -5,7 +5,6 @@ import Thing, { directory, name, referenceList, - resolvedReferenceList, simpleString, urls, wikiData, @@ -24,7 +23,11 @@ export class Group extends Thing { urls: urls(), - featuredAlbumsByRef: referenceList(Album), + featuredAlbums: referenceList({ + class: Album, + find: find.album, + data: 'albumData', + }), // Update only @@ -33,12 +36,6 @@ export class Group extends Thing { // Expose only - featuredAlbums: resolvedReferenceList({ - list: 'featuredAlbumsByRef', - data: 'albumData', - find: find.album, - }), - descriptionShort: { flags: {expose: true}, @@ -89,18 +86,14 @@ export class GroupCategory extends Thing { name: name('Unnamed Group Category'), color: color(), - groupsByRef: referenceList(Group), + groups: referenceList({ + class: Group, + find: find.group, + data: 'groupData', + }), // Update only groupData: wikiData(Group), - - // Expose only - - groups: resolvedReferenceList({ - list: 'groupsByRef', - data: 'groupData', - find: find.group, - }), }); } diff --git a/src/data/things/homepage-layout.js b/src/data/things/homepage-layout.js index ab6f4cff..b509c1e2 100644 --- a/src/data/things/homepage-layout.js +++ b/src/data/things/homepage-layout.js @@ -1,23 +1,29 @@ import find from '#find'; +import { + compositeFrom, + exposeDependency, + withUpdateValueAsDependency, +} from '#composite'; + import { is, isCountingNumber, isString, isStringNonEmpty, + oneOf, validateArrayItems, validateInstanceOf, + validateReference, } from '#validators'; import Thing, { color, name, referenceList, - resolvedReference, - resolvedReferenceList, simpleString, - singleReference, wikiData, + withResolvedReference, } from './thing.js'; export class HomepageLayout extends Thing { @@ -101,8 +107,38 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { }, }, - sourceGroupByRef: singleReference(Group), - sourceAlbumsByRef: referenceList(Album), + sourceGroup: compositeFrom(`HomepageLayoutAlbumsRow.sourceGroup`, [ + { + transform: (value, continuation) => + (value === 'new-releases' || value === 'new-additions' + ? value + : continuation(value)), + }, + + withUpdateValueAsDependency(), + + withResolvedReference({ + ref: '#updateValue', + data: 'groupData', + find: find.group, + }), + + exposeDependency({ + dependency: '#resolvedReference', + update: { + validate: + oneOf( + is('new-releases', 'new-additions'), + validateReference(Group[Thing.referenceType])), + }, + }), + ]), + + sourceAlbums: referenceList({ + class: Album, + find: find.album, + data: 'albumData', + }), countAlbumsFromGroup: { flags: {update: true, expose: true}, @@ -113,19 +149,5 @@ export class HomepageLayoutAlbumsRow extends HomepageLayoutRow { flags: {update: true, expose: true}, update: {validate: validateArrayItems(isString)}, }, - - // Expose only - - sourceGroup: resolvedReference({ - ref: 'sourceGroupByRef', - data: 'groupData', - find: find.group, - }), - - sourceAlbums: resolvedReferenceList({ - list: 'sourceAlbumsByRef', - data: 'albumData', - find: find.album, - }), }); } diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 9d8b2ea2..91ad96af 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -11,8 +11,11 @@ import {filterMultipleArrays, getKebabCase} from '#wiki-data'; import { compositeFrom, exitWithoutDependency, + exposeConstant, exposeDependency, + exposeDependencyOrContinue, raiseWithoutDependency, + withUpdateValueAsDependency, } from '#composite'; import { @@ -162,22 +165,31 @@ export function externalFunction() { }; } -// Super simple "contributions by reference" list, used for a variety of -// properties (Artists, Cover Artists, etc). This is the property which is -// externally provided, in the form: +// Strong 'n sturdy contribution list, rolling a list of references (provided +// as this property's update value) and the resolved results (as get exposed) +// into one property. Update value will look something like this: // -// [ -// {who: 'Artist Name', what: 'Viola'}, -// {who: 'artist:john-cena', what: null}, -// ... -// ] +// [ +// {who: 'Artist Name', what: 'Viola'}, +// {who: 'artist:john-cena', what: null}, +// ... +// ] // -// ...processed from YAML, spreadsheet, or any other kind of input. -export function contribsByRef() { - return { - flags: {update: true, expose: true}, - update: {validate: isContributionList}, - }; +// ...typically as processed from YAML, spreadsheet, or elsewhere. +// Exposes as the same, but with the "who" replaced with matches found in +// artistData - which means this always depends on an `artistData` property +// also existing on this object! +// +export function contributionList() { + return compositeFrom(`contributionList`, [ + withUpdateValueAsDependency(), + withResolvedContribs({from: '#updateValue'}), + exposeDependencyOrContinue({dependency: '#resolvedContribs'}), + exposeConstant({ + value: [], + update: {validate: isContributionList}, + }), + ]); } // Artist commentary! Generally present on tracks and albums. @@ -222,88 +234,77 @@ export function additionalFiles() { // 'artist' or 'track', but this utility keeps from having to hard-code the // string in multiple places by referencing the value saved on the class // instead. -export function referenceList(thingClass) { - const {[Thing.referenceType]: referenceType} = thingClass; - if (!referenceType) { - throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); +export function referenceList({ + class: thingClass, + data, + find, +}) { + if (!thingClass) { + throw new TypeError(`Expected a Thing class`); } - return { - flags: {update: true, expose: true}, - update: {validate: validateReferenceList(referenceType)}, - }; -} - -// Corresponding function for a single reference. -export function singleReference(thingClass) { const {[Thing.referenceType]: referenceType} = thingClass; if (!referenceType) { throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); } - return { - flags: {update: true, expose: true}, - update: {validate: validateReference(referenceType)}, - }; -} + return compositeFrom(`referenceList`, [ + withUpdateValueAsDependency(), -// Corresponding dynamic property to referenceList, which takes the values -// in the provided property and searches the specified wiki data for -// matching actual Thing-subclass objects. -export function resolvedReferenceList({list, data, find}) { - return compositeFrom(`resolvedReferenceList`, [ withResolvedReferenceList({ - list, data, find, + data, find, + list: '#updateValue', notFoundMode: 'filter', }), - exposeDependency({dependency: '#resolvedReferenceList'}), + exposeDependency({ + dependency: '#resolvedReferenceList', + update: { + validate: validateReferenceList(referenceType), + }, + }), ]); } // Corresponding function for a single reference. -export function resolvedReference({ref, data, find}) { - return compositeFrom(`resolvedReference`, [ - withResolvedReference({ref, data, find}), - exposeDependency({dependency: '#resolvedReference'}), - ]); -} +export function singleReference({ + class: thingClass, + data, + find, +}) { + if (!thingClass) { + throw new TypeError(`Expected a Thing class`); + } -// Corresponding dynamic property to contribsByRef, which takes the values -// in the provided property and searches the object's artistData for -// matching actual Artist objects. The computed structure has the same form -// as contribsByRef, but with Artist objects instead of string references: -// -// [ -// {who: (an Artist), what: 'Viola'}, -// {who: (an Artist), what: null}, -// ... -// ] -// -// Contributions whose "who" values don't match anything in artistData are -// filtered out. (So if the list is all empty, chances are that either the -// reference list is somehow messed up, or artistData isn't being provided -// properly.) -export function dynamicContribs(contribsByRefProperty) { - return compositeFrom(`dynamicContribs`, [ - withResolvedContribs({from: contribsByRefProperty}), - exposeDependency({dependency: '#resolvedContribs'}), + const {[Thing.referenceType]: referenceType} = thingClass; + if (!referenceType) { + throw new Error(`The passed constructor ${thingClass.name} doesn't define Thing.referenceType!`); + } + + return compositeFrom(`singleReference`, [ + withUpdateValueAsDependency(), + + withResolvedReference({ref: '#updateValue', data, find}), + + exposeDependency({ + dependency: '#resolvedReference', + update: { + validate: validateReference(referenceType), + }, + }), ]); } // Nice 'n simple shorthand for an exposed-only flag which is true when any // contributions are present in the specified property. -export function contribsPresent(contribsByRefProperty) { +export function contribsPresent(contribsProperty) { return { flags: {expose: true}, expose: { - dependencies: [contribsByRefProperty], - compute({ - [contribsByRefProperty]: contribsByRef, - }) { - return !empty(contribsByRef); - }, - } + dependencies: [contribsProperty], + compute: ({[contribsProperty]: contribs}) => + !empty(contribs), + }, }; } @@ -380,13 +381,13 @@ export function withResolvedContribs({ mapDependencies: {from}, compute: ({from}, continuation) => continuation({ - '#whoByRef': from.map(({who}) => who), + '#artistRefs': from.map(({who}) => who), '#what': from.map(({what}) => what), }), }, withResolvedReferenceList({ - list: '#whoByRef', + list: '#artistRefs', data: 'artistData', into: '#who', find: find.artist, diff --git a/src/data/things/track.js b/src/data/things/track.js index fcfd39c7..8263d399 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -3,7 +3,6 @@ import {inspect} from 'node:util'; import {colors} from '#cli'; import find from '#find'; import {empty} from '#sugar'; -import {isColor, isDate, isDuration, isFileExtension} from '#validators'; import { compositeFrom, @@ -13,20 +12,28 @@ import { exposeDependencyOrContinue, exposeUpdateValueOrContinue, withResultOfAvailabilityCheck, + withUpdateValueAsDependency, } from '#composite'; +import { + isColor, + isContributionList, + isDate, + isDuration, + isFileExtension, +} from '#validators'; + +import CacheableObject from './cacheable-object.js'; + import Thing, { additionalFiles, commentary, commentatorArtists, - contribsByRef, + contributionList, directory, - dynamicContribs, flag, name, referenceList, - resolvedReference, - resolvedReferenceList, reverseReferenceList, simpleDate, singleReference, @@ -55,13 +62,11 @@ export class Track extends Thing { urls: urls(), dateFirstReleased: simpleDate(), - artistContribsByRef: contribsByRef(), - contributorContribsByRef: contribsByRef(), - coverArtistContribsByRef: contribsByRef(), - - referencedTracksByRef: referenceList(Track), - sampledTracksByRef: referenceList(Track), - artTagsByRef: referenceList(ArtTag), + artTags: referenceList({ + class: ArtTag, + find: find.artTag, + data: 'artTagData', + }), color: compositeFrom(`Track.color`, [ exposeUpdateValueOrContinue(), @@ -134,9 +139,24 @@ export class Track extends Thing { }), ]), - originalReleaseTrackByRef: singleReference(Track), + originalReleaseTrack: singleReference({ + class: Track, + find: find.track, + data: 'trackData', + }), - dataSourceAlbumByRef: singleReference(Album), + // Note - this is an internal property used only to help identify a track. + // It should not be assumed in general that the album and dataSourceAlbum match + // (i.e. a track may dynamically be moved from one album to another, at + // which point dataSourceAlbum refers to where it was originally from, and is + // not generally relevant information). It's also not guaranteed that + // dataSourceAlbum is available (depending on the Track creator to optionally + // provide this property's update value). + dataSourceAlbum: singleReference({ + class: Album, + find: find.album, + data: 'albumData', + }), commentary: commentary(), lyrics: simpleString(), @@ -161,19 +181,6 @@ export class Track extends Thing { exposeDependency({dependency: '#album'}), ]), - // Note - this is an internal property used only to help identify a track. - // It should not be assumed in general that the album and dataSourceAlbum match - // (i.e. a track may dynamically be moved from one album to another, at - // which point dataSourceAlbum refers to where it was originally from, and is - // not generally relevant information). It's also not guaranteed that - // dataSourceAlbum is available (depending on the Track creator to optionally - // provide dataSourceAlbumByRef). - dataSourceAlbum: resolvedReference({ - ref: 'dataSourceAlbumByRef', - data: 'albumData', - find: find.album, - }), - date: compositeFrom(`Track.date`, [ exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), withAlbumProperty({property: 'date'}), @@ -192,11 +199,6 @@ export class Track extends Thing { exposeDependency({dependency: '#hasUniqueCoverArt'}), ]), - originalReleaseTrack: compositeFrom(`Track.originalReleaseTrack`, [ - withOriginalRelease(), - exposeDependency({dependency: '#originalRelease'}), - ]), - otherReleases: compositeFrom(`Track.otherReleases`, [ exitWithoutDependency({dependency: 'trackData', mode: 'empty'}), withOriginalRelease({selfIfOriginal: true}), @@ -224,26 +226,20 @@ export class Track extends Thing { artistContribs: compositeFrom(`Track.artistContribs`, [ inheritFromOriginalRelease({property: 'artistContribs'}), - withResolvedContribs({ - from: 'artistContribsByRef', - into: '#artistContribs', - }), - - { - dependencies: ['#artistContribs'], - compute: ({'#artistContribs': contribsFromTrack}, continuation) => - (empty(contribsFromTrack) - ? continuation() - : contribsFromTrack), - }, + withUpdateValueAsDependency(), + withResolvedContribs({from: '#updateValue', into: '#artistContribs'}), + exposeDependencyOrContinue({dependency: '#artistContribs'}), withAlbumProperty({property: 'artistContribs'}), - exposeDependency({dependency: '#album.artistContribs'}), + exposeDependency({ + dependency: '#album.artistContribs', + update: {validate: isContributionList}, + }), ]), contributorContribs: compositeFrom(`Track.contributorContribs`, [ inheritFromOriginalRelease({property: 'contributorContribs'}), - dynamicContribs('contributorContribsByRef'), + contributionList(), ]), // Cover artists aren't inherited from the original release, since it @@ -258,47 +254,35 @@ export class Track extends Thing { : continuation()), }, - withResolvedContribs({ - from: 'coverArtistContribsByRef', - into: '#coverArtistContribs', - }), - - { - dependencies: ['#coverArtistContribs'], - compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => - (empty(contribsFromTrack) - ? continuation() - : contribsFromTrack), - }, + withUpdateValueAsDependency(), + withResolvedContribs({from: '#updateValue', into: '#coverArtistContribs'}), + exposeDependencyOrContinue({dependency: '#coverArtistContribs'}), withAlbumProperty({property: 'trackCoverArtistContribs'}), - exposeDependency({dependency: '#album.trackCoverArtistContribs'}), + exposeDependency({ + dependency: '#album.trackCoverArtistContribs', + update: {validate: isContributionList}, + }), ]), referencedTracks: compositeFrom(`Track.referencedTracks`, [ inheritFromOriginalRelease({property: 'referencedTracks'}), - resolvedReferenceList({ - list: 'referencedTracksByRef', - data: 'trackData', + referenceList({ + class: Track, find: find.track, + data: 'trackData', }), ]), sampledTracks: compositeFrom(`Track.sampledTracks`, [ inheritFromOriginalRelease({property: 'sampledTracks'}), - resolvedReferenceList({ - list: 'sampledTracksByRef', - data: 'trackData', + referenceList({ + class: Track, find: find.track, + data: 'trackData', }), ]), - artTags: resolvedReferenceList({ - list: 'artTagsByRef', - data: 'artTagData', - find: find.artTag, - }), - // Specifically exclude re-releases from this list - while it's useful to // get from a re-release to the tracks it references, re-releases aren't // generally relevant from the perspective of the tracks being referenced. @@ -327,7 +311,7 @@ export class Track extends Thing { parts.push(Thing.prototype[inspect.custom].apply(this)); - if (this.originalReleaseTrackByRef) { + if (CacheableObject.getUpdateValue(this, 'originalReleaseTrack')) { parts.unshift(`${colors.yellow('[rerelease]')} `); } @@ -564,7 +548,7 @@ function withOriginalRelease({ } = {}) { return compositeFrom(`withOriginalRelease`, [ withResolvedReference({ - ref: 'originalReleaseTrackByRef', + ref: 'originalReleaseTrack', data: 'trackData', into: '#originalRelease', find: find.track, @@ -607,7 +591,7 @@ function withHasUniqueCoverArt({ }, withResolvedContribs({ - from: 'coverArtistContribsByRef', + from: 'coverArtistContribs', into: '#coverArtistContribs', }), diff --git a/src/data/things/validators.js b/src/data/things/validators.js index 4c8f683b..f0d1d9fd 100644 --- a/src/data/things/validators.js +++ b/src/data/things/validators.js @@ -308,7 +308,7 @@ export const isTrackSection = validateProperties({ color: optional(isColor), dateOriginallyReleased: optional(isDate), isDefaultTrackSection: optional(isBoolean), - tracksByRef: optional(validateReferenceList('track')), + tracks: optional(validateReferenceList('track')), }); export const isTrackSectionList = validateArrayItems(isTrackSection); diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index 416b6c4e..7c2de324 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -6,7 +6,6 @@ import Thing, { flag, name, referenceList, - resolvedReferenceList, simpleString, wikiData, } from './thing.js'; @@ -45,7 +44,11 @@ export class WikiInfo extends Thing { update: {validate: isURL}, }, - divideTrackListsByGroupsByRef: referenceList(Group), + divideTrackListsByGroups: referenceList({ + class: Group, + find: find.group, + data: 'groupData', + }), // Feature toggles enableFlashesAndGames: flag(false), @@ -57,13 +60,5 @@ export class WikiInfo extends Thing { // Update only groupData: wikiData(Group), - - // Expose only - - divideTrackListsByGroups: resolvedReferenceList({ - list: 'divideTrackListsByGroupsByRef', - data: 'groupData', - find: find.group, - }), }); } diff --git a/src/data/yaml.js b/src/data/yaml.js index 8aca3299..e1e5803d 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -10,7 +10,7 @@ import yaml from 'js-yaml'; import {colors, ENABLE_COLOR, logInfo, logWarn} from '#cli'; import find, {bindFind} from '#find'; import {traverse} from '#node-utils'; -import T from '#things'; +import T, {CacheableObject, Thing} from '#things'; import { conditionallySuppressError, @@ -278,11 +278,11 @@ export const processAlbumDocument = makeProcessDocument(T.Album, { coverArtFileExtension: 'Cover Art File Extension', trackCoverArtFileExtension: 'Track Art File Extension', - wallpaperArtistContribsByRef: 'Wallpaper Artists', + wallpaperArtistContribs: 'Wallpaper Artists', wallpaperStyle: 'Wallpaper Style', wallpaperFileExtension: 'Wallpaper File Extension', - bannerArtistContribsByRef: 'Banner Artists', + bannerArtistContribs: 'Banner Artists', bannerStyle: 'Banner Style', bannerFileExtension: 'Banner File Extension', bannerDimensions: 'Banner Dimensions', @@ -290,11 +290,11 @@ export const processAlbumDocument = makeProcessDocument(T.Album, { commentary: 'Commentary', additionalFiles: 'Additional Files', - artistContribsByRef: 'Artists', - coverArtistContribsByRef: 'Cover Artists', - trackCoverArtistContribsByRef: 'Default Track Cover Artists', - groupsByRef: 'Groups', - artTagsByRef: 'Art Tags', + artistContribs: 'Artists', + coverArtistContribs: 'Cover Artists', + trackCoverArtistContribs: 'Default Track Cover Artists', + groups: 'Groups', + artTags: 'Art Tags', }, }); @@ -348,13 +348,13 @@ export const processTrackDocument = makeProcessDocument(T.Track, { sheetMusicFiles: 'Sheet Music Files', midiProjectFiles: 'MIDI Project Files', - originalReleaseTrackByRef: 'Originally Released As', - referencedTracksByRef: 'Referenced Tracks', - sampledTracksByRef: 'Sampled Tracks', - artistContribsByRef: 'Artists', - contributorContribsByRef: 'Contributors', - coverArtistContribsByRef: 'Cover Artists', - artTagsByRef: 'Art Tags', + originalReleaseTrack: 'Originally Released As', + referencedTracks: 'Referenced Tracks', + sampledTracks: 'Sampled Tracks', + artistContribs: 'Artists', + contributorContribs: 'Contributors', + coverArtistContribs: 'Cover Artists', + artTags: 'Art Tags', }, invalidFieldCombinations: [ @@ -424,8 +424,8 @@ export const processFlashDocument = makeProcessDocument(T.Flash, { date: 'Date', coverArtFileExtension: 'Cover Art File Extension', - featuredTracksByRef: 'Featured Tracks', - contributorContribsByRef: 'Contributors', + featuredTracks: 'Featured Tracks', + contributorContribs: 'Contributors', }, }); @@ -470,7 +470,7 @@ export const processGroupDocument = makeProcessDocument(T.Group, { description: 'Description', urls: 'URLs', - featuredAlbumsByRef: 'Featured Albums', + featuredAlbums: 'Featured Albums', }, }); @@ -501,7 +501,7 @@ export const processWikiInfoDocument = makeProcessDocument(T.WikiInfo, { footerContent: 'Footer Content', defaultLanguage: 'Default Language', canonicalBase: 'Canonical Base', - divideTrackListsByGroupsByRef: 'Divide Track Lists By Groups', + divideTrackListsByGroups: 'Divide Track Lists By Groups', enableFlashesAndGames: 'Enable Flashes & Games', enableListings: 'Enable Listings', enableNews: 'Enable News', @@ -536,9 +536,9 @@ export const homepageLayoutRowTypeProcessMapping = { albums: makeProcessHomepageLayoutRowDocument(T.HomepageLayoutAlbumsRow, { propertyFieldMapping: { displayStyle: 'Display Style', - sourceGroupByRef: 'Group', + sourceGroup: 'Group', countAlbumsFromGroup: 'Count', - sourceAlbumsByRef: 'Albums', + sourceAlbums: 'Albums', actionLinks: 'Actions', }, }), @@ -771,13 +771,13 @@ export const dataSteps = [ let currentTrackSection = { name: `Default Track Section`, isDefaultTrackSection: true, - tracksByRef: [], + tracks: [], }; - const albumRef = T.Thing.getReference(album); + const albumRef = Thing.getReference(album); const closeCurrentTrackSection = () => { - if (!empty(currentTrackSection.tracksByRef)) { + if (!empty(currentTrackSection.tracks)) { trackSections.push(currentTrackSection); } }; @@ -791,7 +791,7 @@ export const dataSteps = [ color: entry.color, dateOriginallyReleased: entry.dateOriginallyReleased, isDefaultTrackSection: false, - tracksByRef: [], + tracks: [], }; continue; @@ -799,9 +799,9 @@ export const dataSteps = [ trackData.push(entry); - entry.dataSourceAlbumByRef = albumRef; + entry.dataSourceAlbum = albumRef; - currentTrackSection.tracksByRef.push(T.Thing.getReference(entry)); + currentTrackSection.tracks.push(Thing.getReference(entry)); } closeCurrentTrackSection(); @@ -825,12 +825,12 @@ export const dataSteps = [ const artistData = results; const artistAliasData = results.flatMap((artist) => { - const origRef = T.Thing.getReference(artist); + const origRef = Thing.getReference(artist); return artist.aliasNames?.map((name) => { const alias = new T.Artist(); alias.name = name; alias.isAlias = true; - alias.aliasedArtistRef = origRef; + alias.aliasedArtist = origRef; alias.artistData = artistData; return alias; }) ?? []; @@ -854,7 +854,7 @@ export const dataSteps = [ save(results) { let flashAct; - let flashesByRef = []; + let flashRefs = []; if (results[0] && !(results[0] instanceof T.FlashAct)) { throw new Error(`Expected an act at top of flash data file`); @@ -863,18 +863,18 @@ export const dataSteps = [ for (const thing of results) { if (thing instanceof T.FlashAct) { if (flashAct) { - Object.assign(flashAct, {flashesByRef}); + Object.assign(flashAct, {flashes: flashRefs}); } flashAct = thing; - flashesByRef = []; + flashRefs = []; } else { - flashesByRef.push(T.Thing.getReference(thing)); + flashRefs.push(Thing.getReference(thing)); } } if (flashAct) { - Object.assign(flashAct, {flashesByRef}); + Object.assign(flashAct, {flashes: flashRefs}); } const flashData = results.filter((x) => x instanceof T.Flash); @@ -897,7 +897,7 @@ export const dataSteps = [ save(results) { let groupCategory; - let groupsByRef = []; + let groupRefs = []; if (results[0] && !(results[0] instanceof T.GroupCategory)) { throw new Error(`Expected a category at top of group data file`); @@ -906,18 +906,18 @@ export const dataSteps = [ for (const thing of results) { if (thing instanceof T.GroupCategory) { if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); + Object.assign(groupCategory, {groups: groupRefs}); } groupCategory = thing; - groupsByRef = []; + groupRefs = []; } else { - groupsByRef.push(T.Thing.getReference(thing)); + groupRefs.push(Thing.getReference(thing)); } } if (groupCategory) { - Object.assign(groupCategory, {groupsByRef}); + Object.assign(groupCategory, {groups: groupRefs}); } const groupData = results.filter((x) => x instanceof T.Group); @@ -1462,45 +1462,45 @@ export function filterDuplicateDirectories(wikiData) { export function filterReferenceErrors(wikiData) { const referenceSpec = [ ['wikiInfo', processWikiInfoDocument, { - divideTrackListsByGroupsByRef: 'group', + divideTrackListsByGroups: 'group', }], ['albumData', processAlbumDocument, { - artistContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - trackCoverArtistContribsByRef: '_contrib', - wallpaperArtistContribsByRef: '_contrib', - bannerArtistContribsByRef: '_contrib', - groupsByRef: 'group', - artTagsByRef: 'artTag', + artistContribs: '_contrib', + coverArtistContribs: '_contrib', + trackCoverArtistContribs: '_contrib', + wallpaperArtistContribs: '_contrib', + bannerArtistContribs: '_contrib', + groups: 'group', + artTags: 'artTag', }], ['trackData', processTrackDocument, { - artistContribsByRef: '_contrib', - contributorContribsByRef: '_contrib', - coverArtistContribsByRef: '_contrib', - referencedTracksByRef: '_trackNotRerelease', - sampledTracksByRef: '_trackNotRerelease', - artTagsByRef: 'artTag', - originalReleaseTrackByRef: '_trackNotRerelease', + artistContribs: '_contrib', + contributorContribs: '_contrib', + coverArtistContribs: '_contrib', + referencedTracks: '_trackNotRerelease', + sampledTracks: '_trackNotRerelease', + artTags: 'artTag', + originalReleaseTrack: '_trackNotRerelease', }], ['groupCategoryData', processGroupCategoryDocument, { - groupsByRef: 'group', + groups: 'group', }], ['homepageLayout.rows', undefined, { - sourceGroupByRef: '_homepageSourceGroup', - sourceAlbumsByRef: 'album', + sourceGroup: '_homepageSourceGroup', + sourceAlbums: 'album', }], ['flashData', processFlashDocument, { - contributorContribsByRef: '_contrib', - featuredTracksByRef: 'track', + contributorContribs: '_contrib', + featuredTracks: 'track', }], ['flashActData', processFlashActDocument, { - flashesByRef: 'flash', + flashes: 'flash', }], ]; @@ -1532,7 +1532,7 @@ export function filterReferenceErrors(wikiData) { nest({message: `Reference errors in ${inspect(thing)}`}, ({push, filter}) => { for (const [property, findFnKey] of Object.entries(propSpec)) { - const value = thing[property]; + const value = CacheableObject.getUpdateValue(thing, property); if (value === undefined) { push(new TypeError(`Property ${colors.red(property)} isn't valid for ${colors.green(thing.constructor.name)}`)); @@ -1552,7 +1552,7 @@ export function filterReferenceErrors(wikiData) { if (alias) { // No need to check if the original exists here. Aliases are automatically // created from a field on the original, so the original certainly exists. - const original = find.artist(alias.aliasedArtistRef, wikiData.artistData, {mode: 'quiet'}); + const original = alias.aliasedArtist; throw new Error(`Reference ${colors.red(contribRef.who)} is to an alias, should be ${colors.green(original.name)}`); } @@ -1573,12 +1573,13 @@ export function filterReferenceErrors(wikiData) { case '_trackNotRerelease': findFn = trackRef => { const track = find.track(trackRef, wikiData.trackData, {mode: 'error'}); + const originalRef = track && CacheableObject.getUpdateValue(track, 'originalReleaseTrack'); - if (track?.originalReleaseTrackByRef) { + if (originalRef) { // It's possible for the original to not actually exist, in this case. // It should still be reported since the 'Originally Released As' field // was present. - const original = find.track(track.originalReleaseTrackByRef, wikiData.trackData, {mode: 'quiet'}); + const original = find.track(originalRef, wikiData.trackData, {mode: 'quiet'}); // Prefer references by name, but only if it's unambiguous. const originalByName = @@ -1591,7 +1592,7 @@ export function filterReferenceErrors(wikiData) { ? colors.green(original.name) : original ? colors.green('track:' + original.directory) - : colors.green(track.originalReleaseTrackByRef)); + : colors.green(originalRef)); throw new Error(`Reference ${colors.red(trackRef)} is to a rerelease, should be ${shouldBeMessage}`); } @@ -1606,7 +1607,7 @@ export function filterReferenceErrors(wikiData) { } const suppress = fn => conditionallySuppressError(error => { - if (property === 'sampledTracksByRef') { + if (property === 'sampledTracks') { // Suppress "didn't match anything" errors in particular, just for samples. // In hsmusic-data we have a lot of "stub" sample data which don't have // corresponding tracks yet, so it won't be useful to report such reference diff --git a/src/gen-thumbs.js b/src/gen-thumbs.js index fafd17f6..4977ade7 100644 --- a/src/gen-thumbs.js +++ b/src/gen-thumbs.js @@ -93,6 +93,9 @@ import * as path from 'node:path'; import dimensionsOf from 'image-size'; +import {delay, empty, queue} from '#sugar'; +import {CacheableObject} from '#things'; + import { colors, fileIssue, @@ -110,8 +113,6 @@ import { traverse, } from '#node-utils'; -import {delay, empty, queue} from '#sugar'; - export const defaultMagickThreads = 8; export function getThumbnailsAvailableForDimensions([width, height]) { @@ -608,8 +609,8 @@ export function getExpectedImagePaths(mediaPath, {urls, wikiData}) { wikiData.albumData .flatMap(album => [ album.hasCoverArt && fromRoot.to('media.albumCover', album.directory, album.coverArtFileExtension), - !empty(album.bannerArtistContribsByRef) && fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension), - !empty(album.wallpaperArtistContribsByRef) && fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension), + !empty(CacheableObject.getUpdateValue(album, 'bannerArtistContribs')) && fromRoot.to('media.albumBanner', album.directory, album.bannerFileExtension), + !empty(CacheableObject.getUpdateValue(album, 'wallpaperArtistContribs')) && fromRoot.to('media.albumWallpaper', album.directory, album.wallpaperFileExtension), ]) .filter(Boolean), diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 8939d964..bb8d7079 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -16,8 +16,8 @@ function stubAlbum(tracks, directory = 'bar') { const album = new Album(); album.directory = directory; - const tracksByRef = tracks.map(t => Thing.getReference(t)); - album.trackSections = [{tracksByRef}]; + const trackRefs = tracks.map(t => Thing.getReference(t)); + album.trackSections = [{tracks: trackRefs}]; return album; } @@ -50,7 +50,7 @@ function stubFlashAndAct(directory = 'zam') { flash.directory = directory; const flashAct = new FlashAct(); - flashAct.flashesByRef = [Thing.getReference(flash)]; + flashAct.flashes = [Thing.getReference(flash)]; return {flash, flashAct}; } @@ -75,8 +75,8 @@ t.test(`Track.album`, t => { track2.albumData = [album1, album2]; album1.trackData = [track1, track2]; album2.trackData = [track1, track2]; - album1.trackSections = [{tracksByRef: ['track:track1']}]; - album2.trackSections = [{tracksByRef: ['track:track2']}]; + album1.trackSections = [{tracks: ['track:track1']}]; + album2.trackSections = [{tracks: ['track:track2']}]; t.equal(track1.album, album1, `album #2: is album when album's trackSections matches track`); @@ -98,7 +98,7 @@ t.test(`Track.album`, t => { `album #5: is null when album missing trackData`); album1.trackData = [track1, track2]; - album1.trackSections = [{tracksByRef: ['track:track2']}]; + album1.trackSections = [{tracks: ['track:track2']}]; // XXX_decacheWikiData track1.albumData = []; @@ -124,7 +124,7 @@ t.test(`Track.color`, t => { album.color = '#abcdef'; album.trackSections = [{ color: '#beeeef', - tracksByRef: [Thing.getReference(track)], + tracks: [Thing.getReference(track)], }]; XXX_decacheWikiData(); @@ -172,7 +172,7 @@ t.test(`Track.coverArtDate`, t => { trackData: [track], }); - track.coverArtistContribsByRef = contribs; + track.coverArtistContribs = contribs; t.equal(track.coverArtDate, null, `coverArtDate #1: defaults to null`); @@ -189,12 +189,12 @@ t.test(`Track.coverArtDate`, t => { t.same(track.coverArtDate, new Date('2009-09-09'), `coverArtDate #3: is own value`); - track.coverArtistContribsByRef = []; + track.coverArtistContribs = []; t.equal(track.coverArtDate, null, `coverArtDate #4: is null if track is missing coverArtists`); - album.trackCoverArtistContribsByRef = contribs; + album.trackCoverArtistContribs = contribs; XXX_decacheWikiData(); @@ -222,20 +222,20 @@ t.test(`Track.coverArtFileExtension`, t => { t.equal(track.coverArtFileExtension, null, `coverArtFileExtension #1: defaults to null`); - track.coverArtistContribsByRef = contribs; + track.coverArtistContribs = contribs; t.equal(track.coverArtFileExtension, 'jpg', `coverArtFileExtension #2: is jpg if has cover art and not further specified`); - track.coverArtistContribsByRef = []; + track.coverArtistContribs = []; - album.coverArtistContribsByRef = contribs; + album.coverArtistContribs = contribs; XXX_decacheWikiData(); t.equal(track.coverArtFileExtension, null, `coverArtFileExtension #3: only has value for unique cover art`); - track.coverArtistContribsByRef = contribs; + track.coverArtistContribs = contribs; album.trackCoverArtFileExtension = 'png'; XXX_decacheWikiData(); @@ -248,9 +248,9 @@ t.test(`Track.coverArtFileExtension`, t => { t.equal(track.coverArtFileExtension, 'gif', `coverArtFileExtension #5: is own value (1/2)`); - track.coverArtistContribsByRef = []; + track.coverArtistContribs = []; - album.trackCoverArtistContribsByRef = contribs; + album.trackCoverArtistContribs = contribs; XXX_decacheWikiData(); t.equal(track.coverArtFileExtension, 'gif', @@ -310,8 +310,8 @@ t.test(`Track.featuredInFlashes`, t => { t.same(track.featuredInFlashes, [], `featuredInFlashes #1: defaults to empty array`); - flash1.featuredTracksByRef = ['track:track1']; - flash2.featuredTracksByRef = ['track:track1']; + flash1.featuredTracks = ['track:track1']; + flash2.featuredTracks = ['track:track1']; XXX_decacheWikiData(); t.same(track.featuredInFlashes, [flash1, flash2], @@ -333,7 +333,7 @@ t.test(`Track.hasUniqueCoverArt`, t => { t.equal(track.hasUniqueCoverArt, false, `hasUniqueCoverArt #1: defaults to false`); - album.trackCoverArtistContribsByRef = contribs; + album.trackCoverArtistContribs = contribs; XXX_decacheWikiData(); t.equal(track.hasUniqueCoverArt, true, @@ -346,13 +346,13 @@ t.test(`Track.hasUniqueCoverArt`, t => { track.disableUniqueCoverArt = false; - album.trackCoverArtistContribsByRef = badContribs; + album.trackCoverArtistContribs = badContribs; XXX_decacheWikiData(); t.equal(track.hasUniqueCoverArt, false, - `hasUniqueCoverArt #4: is false if album's trackCoverArtistContribsByRef resolve empty`); + `hasUniqueCoverArt #4: is false if album's trackCoverArtistContribs resolve empty`); - track.coverArtistContribsByRef = contribs; + track.coverArtistContribs = contribs; t.equal(track.hasUniqueCoverArt, true, `hasUniqueCoverArt #5: is true if track specifies coverArtistContribs`); @@ -364,10 +364,10 @@ t.test(`Track.hasUniqueCoverArt`, t => { track.disableUniqueCoverArt = false; - track.coverArtistContribsByRef = badContribs; + track.coverArtistContribs = badContribs; t.equal(track.hasUniqueCoverArt, false, - `hasUniqueCoverArt #7: is false if track's coverArtistContribsByRef resolve empty`); + `hasUniqueCoverArt #7: is false if track's coverArtistContribs resolve empty`); }); t.only(`Track.originalReleaseTrack`, t => { @@ -384,10 +384,10 @@ t.only(`Track.originalReleaseTrack`, t => { t.equal(track2.originalReleaseTrack, null, `originalReleaseTrack #1: defaults to null`); - track2.originalReleaseTrackByRef = 'track:track1'; + track2.originalReleaseTrack = 'track:track1'; t.equal(track2.originalReleaseTrack, track1, - `originalReleaseTrack #2: is resolved from originalReleaseTrackByRef`); + `originalReleaseTrack #2: is resolved from own value`); track2.trackData = []; @@ -411,9 +411,9 @@ t.test(`Track.otherReleases`, t => { t.same(track1.otherReleases, [], `otherReleases #1: defaults to empty array`); - track2.originalReleaseTrackByRef = 'track:track1'; - track3.originalReleaseTrackByRef = 'track:track1'; - track4.originalReleaseTrackByRef = 'track:track1'; + track2.originalReleaseTrack = 'track:track1'; + track3.originalReleaseTrack = 'track:track1'; + track4.originalReleaseTrack = 'track:track1'; XXX_decacheWikiData(); t.same(track1.otherReleases, [track2, track3, track4], @@ -435,7 +435,7 @@ t.test(`Track.otherReleases`, t => { `otherReleases #5: otherReleases of rerelease are original track then other rereleases (2/3)`); t.same(track4.otherReleases, [track1, track3, track2], - `otherReleases #6: otherReleases of rerelease are original track then other rereleases (1/3)`); + `otherReleases #6: otherReleases of rerelease are original track then other rereleases (3/3)`); }); t.test(`Track.referencedByTracks`, t => { @@ -454,20 +454,20 @@ t.test(`Track.referencedByTracks`, t => { t.same(track1.referencedByTracks, [], `referencedByTracks #1: defaults to empty array`); - track2.referencedTracksByRef = ['track:track1']; - track3.referencedTracksByRef = ['track:track1']; + track2.referencedTracks = ['track:track1']; + track3.referencedTracks = ['track:track1']; XXX_decacheWikiData(); t.same(track1.referencedByTracks, [track2, track3], `referencedByTracks #2: matches tracks' referencedTracks`); - track4.sampledTracksByRef = ['track:track1']; + track4.sampledTracks = ['track:track1']; XXX_decacheWikiData(); t.same(track1.referencedByTracks, [track2, track3], `referencedByTracks #3: doesn't match tracks' sampledTracks`); - track3.originalReleaseTrackByRef = 'track:track2'; + track3.originalReleaseTrack = 'track:track2'; XXX_decacheWikiData(); t.same(track1.referencedByTracks, [track2], @@ -490,20 +490,20 @@ t.test(`Track.sampledByTracks`, t => { t.same(track1.sampledByTracks, [], `sampledByTracks #1: defaults to empty array`); - track2.sampledTracksByRef = ['track:track1']; - track3.sampledTracksByRef = ['track:track1']; + track2.sampledTracks = ['track:track1']; + track3.sampledTracks = ['track:track1']; XXX_decacheWikiData(); t.same(track1.sampledByTracks, [track2, track3], `sampledByTracks #2: matches tracks' sampledTracks`); - track4.referencedTracksByRef = ['track:track1']; + track4.referencedTracks = ['track:track1']; XXX_decacheWikiData(); t.same(track1.sampledByTracks, [track2, track3], `sampledByTracks #3: doesn't match tracks' referencedTracks`); - track3.originalReleaseTrackByRef = 'track:track2'; + track3.originalReleaseTrack = 'track:track2'; XXX_decacheWikiData(); t.same(track1.sampledByTracks, [track2], -- cgit 1.3.0-6-gf8a5 From 19b6cbd1cde35399c0ecdee5459c5a4946e84299 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 08:40:03 -0300 Subject: util: fix missing color -> colors rename in cli utils --- src/util/cli.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/util/cli.js b/src/util/cli.js index 9f2b35ab..4c08c085 100644 --- a/src/util/cli.js +++ b/src/util/cli.js @@ -335,8 +335,8 @@ export function fileIssue({ topMessage = `This shouldn't happen.`, } = {}) { if (topMessage) { - console.error(color.red(`${topMessage} Please let the HSMusic developers know:`)); + console.error(colors.red(`${topMessage} Please let the HSMusic developers know:`)); } - console.error(color.red(`- https://hsmusic.wiki/feedback/`)); - console.error(color.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`)); + console.error(colors.red(`- https://hsmusic.wiki/feedback/`)); + console.error(colors.red(`- https://github.com/hsmusic/hsmusic-wiki/issues/`)); } -- cgit 1.3.0-6-gf8a5 From 745322c71c2443a631533ad7ef651ea30ad87a7c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 08:45:30 -0300 Subject: util: remove unused getPagePathnameAcrossLanguages util --- src/util/urls.js | 21 --------------------- src/write/build-modes/live-dev-server.js | 8 -------- src/write/build-modes/static-build.js | 9 --------- 3 files changed, 38 deletions(-) diff --git a/src/util/urls.js b/src/util/urls.js index d2b303e9..11b9b8b0 100644 --- a/src/util/urls.js +++ b/src/util/urls.js @@ -237,27 +237,6 @@ export function getPagePathname({ : to('localized.' + pagePath[0], ...pagePath.slice(1))); } -export function getPagePathnameAcrossLanguages({ - defaultLanguage, - languages, - pagePath, - urls, -}) { - return withEntries(languages, entries => entries - .filter(([key, language]) => key !== 'default' && !language.hidden) - .map(([_key, language]) => [ - language.code, - getPagePathname({ - baseDirectory: - (language === defaultLanguage - ? '' - : language.code), - pagePath, - urls, - }), - ])); -} - // Needed for the rare path arguments which themselves contains one or more // slashes, e.g. for listings, with arguments like 'albums/by-name'. export function getPageSubdirectoryPrefix({ diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index 9889b3f0..94f0962c 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -14,7 +14,6 @@ import {empty} from '#sugar'; import { getPagePathname, - getPagePathnameAcrossLanguages, getURLsFrom, getURLsFromRoot, } from '#urls'; @@ -332,13 +331,6 @@ export async function go({ return; } - const localizedPathnames = getPagePathnameAcrossLanguages({ - defaultLanguage, - languages, - pagePath: servePath, - urls, - }); - const bound = bindUtilities({ absoluteTo, cachebust, diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index 82a947c7..c10342e9 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -21,13 +21,11 @@ import { logError, logInfo, logWarn, - progressCallAll, progressPromiseAll, } from '#cli'; import { getPagePathname, - getPagePathnameAcrossLanguages, getURLsFrom, getURLsFromRoot, } from '#urls'; @@ -270,13 +268,6 @@ export async function go({ ...pageWrites.map(page => () => { const pagePath = page.path; - const localizedPathnames = getPagePathnameAcrossLanguages({ - defaultLanguage, - languages, - pagePath, - urls, - }); - const pathname = getPagePathname({ baseDirectory, pagePath, -- cgit 1.3.0-6-gf8a5 From 40729920984ca1362f1672b1307bf7aa32687107 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 08:49:54 -0300 Subject: infra, content: use watchPath variable where appropriate --- src/content/dependencies/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js index 57025a5d..65241faa 100644 --- a/src/content/dependencies/index.js +++ b/src/content/dependencies/index.js @@ -77,12 +77,12 @@ export function watchContentDependencies({ // prematurely find out there aren't any nulls - before the nulls have // been entered at all!). - readdir(metaDirname).then(files => { + readdir(watchPath).then(files => { if (closed) { return; } - const filePaths = files.map(file => path.join(metaDirname, file)); + const filePaths = files.map(file => path.join(watchPath, file)); for (const filePath of filePaths) { if (filePath === metaPath) continue; const functionName = getFunctionName(filePath); @@ -91,7 +91,7 @@ export function watchContentDependencies({ } } - const watcher = chokidar.watch(metaDirname); + const watcher = chokidar.watch(watchPath); watcher.on('all', (event, filePath) => { if (!['add', 'change'].includes(event)) return; -- cgit 1.3.0-6-gf8a5 From 21a270ca6efa561cad3e87048cf8deb8a166d55f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 08:51:20 -0300 Subject: fix miscellaneous eslint errors --- src/content/dependencies/index.js | 1 - src/listing-spec.js | 12 +----------- src/page/flash.js | 2 -- src/util/html.js | 2 +- src/util/replacer.js | 1 - src/util/wiki-data.js | 2 +- src/write/build-modes/live-dev-server.js | 1 - src/write/build-modes/static-build.js | 6 ------ 8 files changed, 3 insertions(+), 24 deletions(-) diff --git a/src/content/dependencies/index.js b/src/content/dependencies/index.js index 65241faa..71802050 100644 --- a/src/content/dependencies/index.js +++ b/src/content/dependencies/index.js @@ -30,7 +30,6 @@ export function watchContentDependencies({ const contentDependencies = {}; let emittedReady = false; - let allDependenciesFulfilled = false; let closed = false; let _close = () => {}; diff --git a/src/listing-spec.js b/src/listing-spec.js index fe36fc01..2b33744a 100644 --- a/src/listing-spec.js +++ b/src/listing-spec.js @@ -1,14 +1,4 @@ -import {accumulateSum, empty, showAggregate} from '#sugar'; - -import { - chunkByProperties, - getArtistNumContributions, - getTotalDuration, - sortAlphabetically, - sortByDate, - sortChronologically, - sortFlashesChronologically, -} from '#wiki-data'; +import {empty, showAggregate} from '#sugar'; const listingSpec = []; diff --git a/src/page/flash.js b/src/page/flash.js index b9d27d0f..7df74158 100644 --- a/src/page/flash.js +++ b/src/page/flash.js @@ -1,5 +1,3 @@ -import {empty} from '#sugar'; - export const description = `flash & game pages`; export function condition({wikiData}) { diff --git a/src/util/html.js b/src/util/html.js index a311bbba..b1668558 100644 --- a/src/util/html.js +++ b/src/util/html.js @@ -242,7 +242,7 @@ export class Tag { this.selfClosing && !(value === null || value === undefined || - !Boolean(value) || + !value || Array.isArray(value) && value.filter(Boolean).length === 0) ) { throw new Error(`Tag <${this.tagName}> is self-closing but got content`); diff --git a/src/util/replacer.js b/src/util/replacer.js index c5289cc5..647d1f0e 100644 --- a/src/util/replacer.js +++ b/src/util/replacer.js @@ -5,7 +5,6 @@ // function, which converts nodes parsed here into actual HTML, links, etc // for embedding in a wiki webpage. -import {logError, logWarn} from '#cli'; import * as html from '#html'; import {escapeRegex} from '#sugar'; diff --git a/src/util/wiki-data.js b/src/util/wiki-data.js index ad2f82fb..0eab2204 100644 --- a/src/util/wiki-data.js +++ b/src/util/wiki-data.js @@ -1,6 +1,6 @@ // Utility functions for interacting with wiki data. -import {accumulateSum, empty, stitchArrays, unique} from './sugar.js'; +import {accumulateSum, empty, unique} from './sugar.js'; // Generic value operations diff --git a/src/write/build-modes/live-dev-server.js b/src/write/build-modes/live-dev-server.js index 94f0962c..644efdbc 100644 --- a/src/write/build-modes/live-dev-server.js +++ b/src/write/build-modes/live-dev-server.js @@ -10,7 +10,6 @@ import {quickEvaluate} from '#content-function'; import * as html from '#html'; import * as pageSpecs from '#page-specs'; import {serializeThings} from '#serialize'; -import {empty} from '#sugar'; import { getPagePathname, diff --git a/src/write/build-modes/static-build.js b/src/write/build-modes/static-build.js index c10342e9..6ef69759 100644 --- a/src/write/build-modes/static-build.js +++ b/src/write/build-modes/static-build.js @@ -91,7 +91,6 @@ export async function go({ srcRootPath, thumbsCache, urls, - urlSpec, wikiData, cachebust, @@ -469,14 +468,9 @@ async function writeFavicon({ } async function writeSharedFilesAndPages({ - language, outputPath, - urls, - wikiData, wikiDataJSON, }) { - const {groupData, wikiInfo} = wikiData; - return progressPromiseAll(`Writing files & pages shared across languages.`, [ wikiDataJSON && writeFile( -- cgit 1.3.0-6-gf8a5 From 7132dc6df4a2aabcd0c6f445a91bbd988e64623d Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 09:05:24 -0300 Subject: test: Track.coverArtDate: test contribs lists resolving empty --- test/unit/data/things/track.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index bb8d7079..6597c2f9 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -161,10 +161,10 @@ t.test(`Track.color`, t => { }); t.test(`Track.coverArtDate`, t => { - t.plan(6); + t.plan(8); const {track, album} = stubTrackAndAlbum(); - const {artist, contribs} = stubArtistAndContribs(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); const {XXX_decacheWikiData} = linkAndBindWikiData({ albumData: [album], @@ -192,19 +192,32 @@ t.test(`Track.coverArtDate`, t => { track.coverArtistContribs = []; t.equal(track.coverArtDate, null, - `coverArtDate #4: is null if track is missing coverArtists`); + `coverArtDate #4: is null if track coverArtistContribs empty`); album.trackCoverArtistContribs = contribs; XXX_decacheWikiData(); t.same(track.coverArtDate, new Date('2009-09-09'), - `coverArtDate #5: is not null if album specifies trackCoverArtistContribs`); + `coverArtDate #5: is not null if album trackCoverArtistContribs specified`); + + album.trackCoverArtistContribs = badContribs; + + XXX_decacheWikiData(); + t.equal(track.coverArtDate, null, + `coverArtDate #6: is null if album trackCoverArtistContribs resolves empty`); + + track.coverArtistContribs = badContribs; + + t.equal(track.coverArtDate, null, + `coverArtDate #7: is null if track coverArtistContribs resolves empty`); + + track.coverArtistContribs = contribs; track.disableUniqueCoverArt = true; t.equal(track.coverArtDate, null, - `coverArtDate #6: is null if track disables unique cover artwork`); + `coverArtDate #8: is null if track disables unique cover artwork`); }); t.test(`Track.coverArtFileExtension`, t => { -- cgit 1.3.0-6-gf8a5 From 2ce923876663fcbdd2c9aaec96692592a066436c Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 09:08:51 -0300 Subject: test: Album.coverArtDate (unit) The last test is deliberately failing. --- test/unit/data/things/album.js | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 test/unit/data/things/album.js diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js new file mode 100644 index 00000000..358abb3b --- /dev/null +++ b/test/unit/data/things/album.js @@ -0,0 +1,61 @@ +import t from 'tap'; + +import {linkAndBindWikiData} from '#test-lib'; +import thingConstructors from '#things'; + +const { + Album, + Artist, + Thing, + Track, +} = thingConstructors; + +function stubArtistAndContribs() { + const artist = new Artist(); + artist.name = `Test Artist`; + + const contribs = [{who: `Test Artist`, what: null}]; + const badContribs = [{who: `Figment of Your Imagination`, what: null}]; + + return {artist, contribs, badContribs}; +} + +t.test(`Album.coverArtDate`, t => { + t.plan(6); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #1: defaults to null`); + + album.date = new Date('2012-10-25'); + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #2: is null if coverArtistContribs empty (1/2)`); + + album.coverArtDate = new Date('2011-04-13'); + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #3: is null if coverArtistContribs empty (2/2)`); + + album.coverArtistContribs = contribs; + + t.same(album.coverArtDate, new Date('2011-04-13'), + `Album.coverArtDate #4: is own value`); + + album.coverArtDate = null; + + t.same(album.coverArtDate, new Date(`2012-10-25`), + `Album.coverArtDate #5: inherits album release date`); + + album.coverArtistContribs = badContribs; + + t.equal(album.coverArtDate, null, + `Album.coverArtDate #6: is null if coverArtistContribs resolves empty`); +}); -- cgit 1.3.0-6-gf8a5 From 3344a16a02a2c680d9e9eaf27c81890e17b9b5f4 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 09:12:42 -0300 Subject: test: Album.coverArtFileExtension (unit) Most of these currently fail. --- test/unit/data/things/album.js | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js index 358abb3b..42f73082 100644 --- a/test/unit/data/things/album.js +++ b/test/unit/data/things/album.js @@ -59,3 +59,39 @@ t.test(`Album.coverArtDate`, t => { t.equal(album.coverArtDate, null, `Album.coverArtDate #6: is null if coverArtistContribs resolves empty`); }); + +t.test(`Album.coverArtFileExtension`, t => { + t.plan(5); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.coverArtFileExtension, null, + `Album.coverArtFileExtension #1: is null if coverArtistContribs empty (1/2)`); + + album.coverArtFileExtension = 'png'; + + t.equal(album.coverArtFileExtension, null, + `Album.coverArtFileExtension #2: is null if coverArtistContribs empty (2/2)`); + + album.coverArtFileExtension = null; + album.coverArtistContribs = contribs; + + t.equal(album.coverArtFileExtension, 'jpg', + `Album.coverArtFileExtension #3: defaults to jpg`); + + album.coverArtFileExtension = 'png'; + + t.equal(album.coverArtFileExtension, 'png', + `Album.coverArtFileExtension #4: is own value`); + + album.coverArtistContribs = badContribs; + + t.equal(album.coverArtFileExtension, null, + `Album.coverArtFileExtension #5: is null if coverArtistContribs resolves empty`); +}); -- cgit 1.3.0-6-gf8a5 From ee46a4f78f1bfc8348834fbd3349849148f178a8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 10:54:58 -0300 Subject: data: Album.coverArt{Date,FileExtension}: depend on contribs --- src/data/things/album.js | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 88308182..3726a463 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -2,6 +2,13 @@ import find from '#find'; import {empty} from '#sugar'; import {isDate, isDimensions, isTrackSectionList} from '#validators'; +import { + compositeFrom, + exitWithoutDependency, + exposeDependency, + exposeUpdateValueOrContinue, +} from '#composite'; + import Thing, { additionalFiles, commentary, @@ -18,6 +25,7 @@ import Thing, { simpleString, urls, wikiData, + withResolvedContribs, } from './thing.js'; export class Album extends Thing { @@ -35,19 +43,16 @@ export class Album extends Thing { trackArtDate: simpleDate(), dateAddedToWiki: simpleDate(), - coverArtDate: { - flags: {update: true, expose: true}, + coverArtDate: compositeFrom(`Album.coverArtDate`, [ + withResolvedContribs({from: 'coverArtistContribs'}), + exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}), - update: {validate: isDate}, - - expose: { - dependencies: ['date', 'coverArtistContribs'], - transform: (coverArtDate, {coverArtistContribs, date}) => - (!empty(coverArtistContribs) - ? coverArtDate ?? date ?? null - : null), - }, - }, + exposeUpdateValueOrContinue(), + exposeDependency({ + dependency: 'date', + update: {validate: isDate}, + }), + ]), artistContribs: contributionList(), coverArtistContribs: contributionList(), @@ -102,7 +107,12 @@ export class Album extends Thing { }, }, - coverArtFileExtension: fileExtension('jpg'), + coverArtFileExtension: compositeFrom(`Album.coverArtFileExtension`, [ + withResolvedContribs({from: 'coverArtistContribs'}), + exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}), + fileExtension('jpg'), + ]), + trackCoverArtFileExtension: fileExtension('jpg'), wallpaperStyle: simpleString(), -- cgit 1.3.0-6-gf8a5 From 3ebe98d51d94a3e5277d65b2a4d2b5b433449214 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 11:29:53 -0300 Subject: data: withResolvedReferenceList: handle undefined matches --- src/data/things/thing.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 91ad96af..79d8ae0e 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -491,19 +491,20 @@ export function withResolvedReferenceList({ let matches = list.map(ref => findFunction(ref, data, {mode: 'quiet'})); - if (!matches.includes(null)) { + if (matches.every(match => match)) { return continuation.raise({matches}); } switch (notFoundMode) { case 'filter': - matches = matches.filter(value => value !== null); + matches = matches.filter(match => match); return continuation.raise({matches}); case 'exit': return continuation.exit([]); case 'null': + matches = matches.map(match => match ?? null); return continuation.raise({matches}); } }, -- cgit 1.3.0-6-gf8a5 From f82c43f594ee5eadde34c05d45b9ce7a14301a87 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 11:30:27 -0300 Subject: test: Album.tracks (unit) --- test/unit/data/things/album.js | 50 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js index 42f73082..63d787e5 100644 --- a/test/unit/data/things/album.js +++ b/test/unit/data/things/album.js @@ -6,7 +6,6 @@ import thingConstructors from '#things'; const { Album, Artist, - Thing, Track, } = thingConstructors; @@ -20,6 +19,13 @@ function stubArtistAndContribs() { return {artist, contribs, badContribs}; } +function stubTrack(directory = 'foo') { + const track = new Track(); + track.directory = directory; + + return track; +} + t.test(`Album.coverArtDate`, t => { t.plan(6); @@ -95,3 +101,45 @@ t.test(`Album.coverArtFileExtension`, t => { t.equal(album.coverArtFileExtension, null, `Album.coverArtFileExtension #5: is null if coverArtistContribs resolves empty`); }); + +t.test(`Album.tracks`, t => { + t.plan(4); + + const album = new Album(); + const track1 = stubTrack('track1'); + const track2 = stubTrack('track2'); + const track3 = stubTrack('track3'); + + linkAndBindWikiData({ + albumData: [album], + trackData: [track1, track2, track3], + }); + + t.same(album.tracks, [], + `Album.tracks #1: defaults to empty array`); + + album.trackSections = [ + {tracks: ['track:track1', 'track:track2', 'track:track3']}, + ]; + + t.same(album.tracks, [track1, track2, track3], + `Album.tracks #2: pulls tracks from one track section`); + + album.trackSections = [ + {tracks: ['track:track1']}, + {tracks: ['track:track2', 'track:track3']}, + ]; + + t.same(album.tracks, [track1, track2, track3], + `Album.tracks #3: pulls tracks from multiple track sections`); + + album.trackSections = [ + {tracks: ['track:track1', 'track:does-not-exist']}, + {tracks: ['track:this-one-neither', 'track:track2']}, + {tracks: ['track:effectively-empty-section']}, + {tracks: ['track:track3']}, + ]; + + t.same(album.tracks, [track1, track2, track3], + `Album.tracks #4: filters out references without matches`); +}); -- cgit 1.3.0-6-gf8a5 From bf0be010c9d9b860ad42762fc2e373130c7535eb Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 11:32:50 -0300 Subject: data: update Album.tracks --- src/data/things/album.js | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 3726a463..76e0f638 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -26,6 +26,7 @@ import Thing, { urls, wikiData, withResolvedContribs, + withResolvedReferenceList, } from './thing.js'; export class Album extends Thing { @@ -147,20 +148,30 @@ export class Album extends Thing { hasWallpaperArt: contribsPresent('wallpaperArtistContribs'), hasBannerArt: contribsPresent('bannerArtistContribs'), - tracks: { - flags: {expose: true}, + tracks: compositeFrom(`Album.tracks`, [ + exitWithoutDependency({ + dependency: 'trackSections', + mode: 'empty', + value: [], + }), - expose: { + { dependencies: ['trackSections', 'trackData'], - compute: ({trackSections, trackData}) => - (trackSections && trackData - ? trackSections - .flatMap(section => section.tracks ?? []) - .map(ref => find.track(ref, trackData, {mode: 'quiet'})) - .filter(Boolean) - : []), + compute: ({trackSections, trackData}, continuation) => + continuation({ + '#trackRefs': trackSections + .flatMap(section => section.tracks ?? []), + }), }, - }, + + withResolvedReferenceList({ + list: '#trackRefs', + data: 'trackData', + find: find.track, + }), + + exposeDependency({dependency: '#resolvedReferenceList'}), + ]), }); static [Thing.getSerializeDescriptors] = ({ -- cgit 1.3.0-6-gf8a5 From 65260d7fc2790ece0c13820ba18bc821163f164e Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 12:19:49 -0300 Subject: data: new withFlattenedArray, withUnflattenedArray utilities --- src/data/things/composite.js | 75 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 96abf4af..1f6482f6 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1072,6 +1072,8 @@ export function raiseWithoutUpdateValue({ ]); } +// Turns an updating property's update value into a dependency, so it can be +// conveniently passed to other functions. export function withUpdateValueAsDependency({ into = '#updateValue', } = {}) { @@ -1086,3 +1088,76 @@ export function withUpdateValueAsDependency({ }, }; } + +// Flattens an array with one level of nested arrays, providing as dependencies +// both the flattened array as well as the original starting indices of each +// successive source array. +export function withFlattenedArray({ + from, + into = '#flattenedArray', + intoIndices = '#flattenedIndices', +}) { + return { + annotation: `withFlattenedArray`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {from}, + mapContinuation: {into, intoIndices}, + + compute({from: sourceArray}, continuation) { + const into = sourceArray.flat(); + const intoIndices = []; + + let lastEndIndex = 0; + for (const {length} of sourceArray) { + intoIndices.push(lastEndIndex); + lastEndIndex += length; + } + + return continuation({into, intoIndices}); + }, + }, + }; +} + +// After mapping the contents of a flattened array in-place (being careful to +// retain the original indices by replacing unmatched results with null instead +// of filtering them out), this function allows for recombining them. It will +// filter out null and undefined items by default (pass {filter: false} to +// disable this). +export function withUnflattenedArray({ + from, + fromIndices = '#flattenedIndices', + into = '#unflattenedArray', + filter = true, +}) { + return { + annotation: `withUnflattenedArray`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {from, fromIndices}, + mapContinuation: {into}, + compute({from, fromIndices}, continuation) { + const arrays = []; + + for (let i = 0; i < fromIndices.length; i++) { + const startIndex = fromIndices[i]; + const endIndex = + (i === fromIndices.length - 1 + ? from.length + : fromIndices[i + 1]); + + const values = from.slice(startIndex, endIndex); + arrays.push( + (filter + ? values.filter(value => value !== null && value !== undefined) + : values)); + } + + return continuation({into: arrays}); + }, + }, + }; +} -- cgit 1.3.0-6-gf8a5 From f4305e5ab0a64a648f39c647b817a4ba09848f11 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 12:34:48 -0300 Subject: test: Album.trackSections (unit) --- test/unit/data/things/album.js | 68 ++++++++++++++++++++++++++++++++++++++++++ test/unit/data/things/track.js | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js index 63d787e5..3593074a 100644 --- a/test/unit/data/things/album.js +++ b/test/unit/data/things/album.js @@ -143,3 +143,71 @@ t.test(`Album.tracks`, t => { t.same(album.tracks, [track1, track2, track3], `Album.tracks #4: filters out references without matches`); }); + +t.test(`Album.trackSections`, t => { + t.plan(5); + + const album = new Album(); + const track1 = stubTrack('track1'); + const track2 = stubTrack('track2'); + const track3 = stubTrack('track3'); + const track4 = stubTrack('track4'); + + linkAndBindWikiData({ + albumData: [album], + trackData: [track1, track2, track3, track4], + }); + + album.trackSections = [ + {tracks: ['track:track1', 'track:track2']}, + {tracks: ['track:track3', 'track:track4']}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1, track2]}, + {tracks: [track3, track4]}, + ], `Album.trackSections #1: exposes tracks`); + + t.match(album.trackSections, [ + {tracks: [track1, track2], startIndex: 0}, + {tracks: [track3, track4], startIndex: 2}, + ], `Album.trackSections #2: exposes startIndex`); + + album.color = '#123456'; + + album.trackSections = [ + {tracks: ['track:track1'], color: null}, + {tracks: ['track:track2'], color: '#abcdef'}, + {tracks: ['track:track3'], color: null}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1], color: '#123456'}, + {tracks: [track2], color: '#abcdef'}, + {tracks: [track3], color: '#123456'}, + ], `Album.trackSections #3: exposes color, inherited from album`); + + album.trackSections = [ + {tracks: ['track:track1'], dateOriginallyReleased: null}, + {tracks: ['track:track2'], dateOriginallyReleased: new Date('2009-04-11')}, + {tracks: ['track:track3'], dateOriginallyReleased: null}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1], dateOriginallyReleased: null}, + {tracks: [track2], dateOriginallyReleased: new Date('2009-04-11')}, + {tracks: [track3], dateOriginallyReleased: null}, + ], `Album.trackSections #4: exposes dateOriginallyReleased, if present`); + + album.trackSections = [ + {tracks: ['track:track1'], isDefaultTrackSection: true}, + {tracks: ['track:track2'], isDefaultTrackSection: false}, + {tracks: ['track:track3'], isDefaultTrackSection: null}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1], isDefaultTrackSection: true}, + {tracks: [track2], isDefaultTrackSection: false}, + {tracks: [track3], isDefaultTrackSection: false}, + ], `Album.trackSections #5: exposes isDefaultTrackSection, defaults to false`); +}); diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 6597c2f9..8aecf789 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -383,7 +383,7 @@ t.test(`Track.hasUniqueCoverArt`, t => { `hasUniqueCoverArt #7: is false if track's coverArtistContribs resolve empty`); }); -t.only(`Track.originalReleaseTrack`, t => { +t.test(`Track.originalReleaseTrack`, t => { t.plan(3); const {track: track1, album: album1} = stubTrackAndAlbum('track1'); -- cgit 1.3.0-6-gf8a5 From 4ed5649e83e344615eb0e710c7a942d0dea8fa22 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 12:35:22 -0300 Subject: data: update Album.trackSections --- src/data/things/album.js | 121 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 37 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 76e0f638..01f52c2d 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,12 +1,15 @@ import find from '#find'; -import {empty} from '#sugar'; +import {stitchArrays} from '#sugar'; import {isDate, isDimensions, isTrackSectionList} from '#validators'; import { compositeFrom, exitWithoutDependency, + exitWithoutUpdateValue, exposeDependency, exposeUpdateValueOrContinue, + withFlattenedArray, + withUnflattenedArray, } from '#composite'; import Thing, { @@ -73,40 +76,87 @@ export class Album extends Thing { data: 'artTagData', }), - trackSections: { - flags: {update: true, expose: true}, + trackSections: compositeFrom(`Album.trackSections`, [ + exitWithoutDependency({dependency: 'trackData', value: []}), + exitWithoutUpdateValue({value: [], mode: 'empty'}), - update: { - validate: isTrackSectionList, + { + transform: (trackSections, continuation) => + continuation(trackSections, { + '#sectionTrackRefs': + trackSections.map(section => section.tracks), + + '#sectionDateOriginallyReleased': + trackSections + .map(({dateOriginallyReleased}) => dateOriginallyReleased ?? null), + + '#sectionIsDefaultTrackSection': + trackSections + .map(({isDefaultTrackSection}) => isDefaultTrackSection ?? false), + }), }, - expose: { - dependencies: ['color', 'trackData'], - transform(trackSections, { - color: albumColor, - trackData, - }) { - let startIndex = 0; - return trackSections?.map(section => ({ - name: section.name ?? null, - color: section.color ?? albumColor ?? null, - dateOriginallyReleased: section.dateOriginallyReleased ?? null, - isDefaultTrackSection: section.isDefaultTrackSection ?? false, - - startIndex: ( - startIndex += section.tracks.length, - startIndex - section.tracks.length - ), - - tracks: - (trackData && section.tracks - ?.map(ref => find.track(ref, trackData, {mode: 'quiet'})) - .filter(Boolean)) ?? - [], - })); + { + dependencies: ['color'], + transform: (trackSections, {color: albumColor}, continuation) => + continuation(trackSections, { + '#sectionColor': + trackSections + .map(({color: sectionColor}) => sectionColor ?? albumColor), + }), + }, + + withFlattenedArray({ + from: '#sectionTrackRefs', + into: '#trackRefs', + intoIndices: '#sectionStartIndex', + }), + + withResolvedReferenceList({ + list: '#trackRefs', + data: 'trackData', + mode: 'null', + find: find.track, + into: '#tracks', + }), + + withUnflattenedArray({ + from: '#tracks', + fromIndices: '#sectionStartIndex', + into: '#sectionTracks', + }), + + { + flags: {update: true, expose: true}, + + update: {validate: isTrackSectionList}, + + expose: { + dependencies: [ + '#sectionTracks', + '#sectionColor', + '#sectionDateOriginallyReleased', + '#sectionIsDefaultTrackSection', + '#sectionStartIndex', + ], + + transform: (trackSections, { + '#sectionTracks': tracks, + '#sectionColor': color, + '#sectionDateOriginallyReleased': dateOriginallyReleased, + '#sectionIsDefaultTrackSection': isDefaultTrackSection, + '#sectionStartIndex': startIndex, + }) => + stitchArrays({ + tracks, + color, + dateOriginallyReleased, + isDefaultTrackSection, + startIndex, + }), }, }, - }, + ]), coverArtFileExtension: compositeFrom(`Album.coverArtFileExtension`, [ withResolvedContribs({from: 'coverArtistContribs'}), @@ -149,15 +199,12 @@ export class Album extends Thing { hasBannerArt: contribsPresent('bannerArtistContribs'), tracks: compositeFrom(`Album.tracks`, [ - exitWithoutDependency({ - dependency: 'trackSections', - mode: 'empty', - value: [], - }), + exitWithoutDependency({dependency: 'trackData', value: []}), + exitWithoutDependency({dependency: 'trackSections', mode: 'empty', value: []}), { - dependencies: ['trackSections', 'trackData'], - compute: ({trackSections, trackData}, continuation) => + dependencies: ['trackSections'], + compute: ({trackSections}, continuation) => continuation({ '#trackRefs': trackSections .flatMap(section => section.tracks ?? []), -- cgit 1.3.0-6-gf8a5 From a8718915ffcb9b3977ccab479817c8bd8d9b20c6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 15:39:40 -0300 Subject: test: Track.commentatorArtists (unit) --- test/unit/data/things/track.js | 68 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 8aecf789..5e7fd829 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -32,14 +32,20 @@ function stubTrack(directory = 'foo') { function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') { const track = stubTrack(trackDirectory); const album = stubAlbum([track], albumDirectory); + return {track, album}; } -function stubArtistAndContribs() { +function stubArtist(artistName = `Test Artist`) { const artist = new Artist(); - artist.name = `Test Artist`; + artist.name = artistName; + + return artist; +} - const contribs = [{who: `Test Artist`, what: null}]; +function stubArtistAndContribs(artistName = `Test Artist`) { + const artist = stubArtist(artistName); + const contribs = [{who: artistName, what: null}]; const badContribs = [{who: `Figment of Your Imagination`, what: null}]; return {artist, contribs, badContribs}; @@ -160,6 +166,62 @@ t.test(`Track.color`, t => { `color #5: must be set to valid color`); }); +t.test(`Track.commentatorArtists`, t => { + t.plan(6); + + const track = new Track(); + const artist1 = stubArtist(`SnooPING`); + const artist2 = stubArtist(`ASUsual`); + const artist3 = stubArtist(`Icy`); + + linkAndBindWikiData({ + trackData: [track], + artistData: [artist1, artist2, artist3], + }); + + track.commentary = + `SnooPING:\n` + + `Wow.\n`; + + t.same(track.commentatorArtists, [artist1], + `Track.commentatorArtists #1: works with one commentator`); + + track.commentary += + `ASUsual:\n` + + `Yes!\n`; + + t.same(track.commentatorArtists, [artist1, artist2], + `Track.commentatorArtists #2: works with two commentators`); + + track.commentary += + `Icy:\n` + + `Incredible.\n`; + + t.same(track.commentatorArtists, [artist1, artist2, artist3], + `Track.commentatorArtists #3: works with boldface name`); + + track.commentary = + `Icy: (project manager)\n` + + `Very good track.\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #4: works with parenthical accent`); + + track.commentary += + `SNooPING ASUsual Icy:\n` + + `WITH ALL THREE POWERS COMBINED...`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #5: ignores artist names not found`); + + track.commentary += + `Icy:\n` + + `I'm back!\n`; + + t.same(track.commentatorArtists, [artist3], + `Track.commentatorArtists #6: ignores duplicate artist`); +}); + t.test(`Track.coverArtDate`, t => { t.plan(8); -- cgit 1.3.0-6-gf8a5 From 15b0b5422a3de8da52e14666909418405bdb8c39 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 16:09:16 -0300 Subject: data: update commentatorArtists --- src/data/things/thing.js | 55 +++++++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 79d8ae0e..9e7f940f 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -5,7 +5,7 @@ import {inspect} from 'node:util'; import {colors} from '#cli'; import find from '#find'; -import {empty, stitchArrays} from '#sugar'; +import {empty, stitchArrays, unique} from '#sugar'; import {filterMultipleArrays, getKebabCase} from '#wiki-data'; import { @@ -334,29 +334,40 @@ export function wikiData(thingClass) { // This one's kinda tricky: it parses artist "references" from the // commentary content, and finds the matching artist for each reference. // This is mostly useful for credits and listings on artist pages. -export function commentatorArtists(){ - return { - flags: {expose: true}, +export function commentatorArtists() { + return compositeFrom(`commentatorArtists`, [ + exitWithoutDependency({dependency: 'commentary', mode: 'falsy', value: []}), - expose: { - dependencies: ['artistData', 'commentary'], - - compute: ({artistData, commentary}) => - artistData && commentary - ? Array.from( - new Set( - Array.from( - commentary - .replace(/<\/?b>/g, '') - .matchAll(/(?.*?):<\/i>/g) - ).map(({groups: {who}}) => - find.artist(who, artistData, {mode: 'quiet'}) - ) - ) - ) - : [], + { + dependencies: ['commentary'], + compute: ({commentary}, continuation) => + continuation({ + '#artistRefs': + Array.from( + commentary + .replace(/<\/?b>/g, '') + .matchAll(/(?.*?):<\/i>/g)) + .map(({groups: {who}}) => who), + }), }, - }; + + withResolvedReferenceList({ + list: '#artistRefs', + data: 'artistData', + into: '#artists', + find: find.artist, + }), + + { + flags: {expose: true}, + + expose: { + dependencies: ['#artists'], + compute: ({'#artists': artists}) => + unique(artists), + }, + }, + ]); } // Compositional utilities -- cgit 1.3.0-6-gf8a5 From f39164ed44fe5c86f1f1911514d38a5549e51f92 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 16:17:05 -0300 Subject: data: rearrange track properties --- src/data/things/track.js | 153 +++++++++++++++++++++++------------------------ 1 file changed, 75 insertions(+), 78 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 8263d399..0cd39dca 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -62,12 +62,6 @@ export class Track extends Thing { urls: urls(), dateFirstReleased: simpleDate(), - artTags: referenceList({ - class: ArtTag, - find: find.artTag, - data: 'artTagData', - }), - color: compositeFrom(`Track.color`, [ exposeUpdateValueOrContinue(), withContainingTrackSection(), @@ -139,90 +133,28 @@ export class Track extends Thing { }), ]), + commentary: commentary(), + lyrics: simpleString(), + + additionalFiles: additionalFiles(), + sheetMusicFiles: additionalFiles(), + midiProjectFiles: additionalFiles(), + originalReleaseTrack: singleReference({ class: Track, find: find.track, data: 'trackData', }), - // Note - this is an internal property used only to help identify a track. - // It should not be assumed in general that the album and dataSourceAlbum match - // (i.e. a track may dynamically be moved from one album to another, at - // which point dataSourceAlbum refers to where it was originally from, and is - // not generally relevant information). It's also not guaranteed that - // dataSourceAlbum is available (depending on the Track creator to optionally - // provide this property's update value). + // Internal use only - for directly identifying an album inside a track's + // util.inspect display, if it isn't indirectly available (by way of being + // included in an album's track list). dataSourceAlbum: singleReference({ class: Album, find: find.album, data: 'albumData', }), - commentary: commentary(), - lyrics: simpleString(), - additionalFiles: additionalFiles(), - sheetMusicFiles: additionalFiles(), - midiProjectFiles: additionalFiles(), - - // Update only - - albumData: wikiData(Album), - artistData: wikiData(Artist), - artTagData: wikiData(ArtTag), - flashData: wikiData(Flash), - trackData: wikiData(Track), - - // Expose only - - commentatorArtists: commentatorArtists(), - - album: compositeFrom(`Track.album`, [ - withAlbum(), - exposeDependency({dependency: '#album'}), - ]), - - date: compositeFrom(`Track.date`, [ - exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), - withAlbumProperty({property: 'date'}), - exposeDependency({dependency: '#album.date'}), - ]), - - // Whether or not the track has "unique" cover artwork - a cover which is - // specifically associated with this track in particular, rather than with - // the track's album as a whole. This is typically used to select between - // displaying the track artwork and a fallback, such as the album artwork - // or a placeholder. (This property is named hasUniqueCoverArt instead of - // the usual hasCoverArt to emphasize that it does not inherit from the - // album.) - hasUniqueCoverArt: compositeFrom(`Track.hasUniqueCoverArt`, [ - withHasUniqueCoverArt(), - exposeDependency({dependency: '#hasUniqueCoverArt'}), - ]), - - otherReleases: compositeFrom(`Track.otherReleases`, [ - exitWithoutDependency({dependency: 'trackData', mode: 'empty'}), - withOriginalRelease({selfIfOriginal: true}), - - { - flags: {expose: true}, - expose: { - dependencies: ['this', 'trackData', '#originalRelease'], - compute: ({ - this: thisTrack, - trackData, - '#originalRelease': originalRelease, - }) => - (originalRelease === thisTrack - ? [] - : [originalRelease]) - .concat(trackData.filter(track => - track !== originalRelease && - track !== thisTrack && - track.originalReleaseTrack === originalRelease)), - }, - }, - ]), - artistContribs: compositeFrom(`Track.artistContribs`, [ inheritFromOriginalRelease({property: 'artistContribs'}), @@ -283,6 +215,71 @@ export class Track extends Thing { }), ]), + artTags: referenceList({ + class: ArtTag, + find: find.artTag, + data: 'artTagData', + }), + + // Update only + + albumData: wikiData(Album), + artistData: wikiData(Artist), + artTagData: wikiData(ArtTag), + flashData: wikiData(Flash), + trackData: wikiData(Track), + + // Expose only + + commentatorArtists: commentatorArtists(), + + album: compositeFrom(`Track.album`, [ + withAlbum(), + exposeDependency({dependency: '#album'}), + ]), + + date: compositeFrom(`Track.date`, [ + exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), + withAlbumProperty({property: 'date'}), + exposeDependency({dependency: '#album.date'}), + ]), + + // Whether or not the track has "unique" cover artwork - a cover which is + // specifically associated with this track in particular, rather than with + // the track's album as a whole. This is typically used to select between + // displaying the track artwork and a fallback, such as the album artwork + // or a placeholder. (This property is named hasUniqueCoverArt instead of + // the usual hasCoverArt to emphasize that it does not inherit from the + // album.) + hasUniqueCoverArt: compositeFrom(`Track.hasUniqueCoverArt`, [ + withHasUniqueCoverArt(), + exposeDependency({dependency: '#hasUniqueCoverArt'}), + ]), + + otherReleases: compositeFrom(`Track.otherReleases`, [ + exitWithoutDependency({dependency: 'trackData', mode: 'empty'}), + withOriginalRelease({selfIfOriginal: true}), + + { + flags: {expose: true}, + expose: { + dependencies: ['this', 'trackData', '#originalRelease'], + compute: ({ + this: thisTrack, + trackData, + '#originalRelease': originalRelease, + }) => + (originalRelease === thisTrack + ? [] + : [originalRelease]) + .concat(trackData.filter(track => + track !== originalRelease && + track !== thisTrack && + track.originalReleaseTrack === originalRelease)), + }, + }, + ]), + // Specifically exclude re-releases from this list - while it's useful to // get from a re-release to the tracks it references, re-releases aren't // generally relevant from the perspective of the tracks being referenced. -- cgit 1.3.0-6-gf8a5 From e01b73d286fbb11ac8ded59b4c23738dff195171 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 16:22:10 -0300 Subject: data: dimensions utility --- src/data/things/album.js | 8 +++----- src/data/things/thing.js | 10 ++++++++++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 01f52c2d..5e281f06 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,6 +1,6 @@ import find from '#find'; import {stitchArrays} from '#sugar'; -import {isDate, isDimensions, isTrackSectionList} from '#validators'; +import {isDate, isTrackSectionList} from '#validators'; import { compositeFrom, @@ -19,6 +19,7 @@ import Thing, { commentatorArtists, contribsPresent, contributionList, + dimensions, directory, fileExtension, flag, @@ -171,10 +172,7 @@ export class Album extends Thing { bannerStyle: simpleString(), bannerFileExtension: fileExtension('jpg'), - bannerDimensions: { - flags: {update: true, expose: true}, - update: {validate: isDimensions}, - }, + bannerDimensions: dimensions(), hasTrackNumbers: flag(true), isListedOnHomepage: flag(true), diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 9e7f940f..0484b589 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -25,6 +25,7 @@ import { isColor, isContributionList, isDate, + isDimensions, isDirectory, isFileExtension, isName, @@ -122,6 +123,15 @@ export function fileExtension(defaultFileExtension = null) { }; } +// Plain ol' image dimensions. This is a two-item array of positive integers, +// corresponding to width and height respectively. +export function dimensions() { + return { + flags: {update: true, expose: true}, + update: {validate: isDimensions}, + }; +} + // Straightforward flag descriptor for a variety of property purposes. // Provide a default value, true or false! export function flag(defaultValue = false) { -- cgit 1.3.0-6-gf8a5 From cd3e2ae7384d82f0f2758beb0ae38ce0fe9f5e09 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 16:25:22 -0300 Subject: data: duration utility --- src/data/things/thing.js | 10 ++++++++++ src/data/things/track.js | 8 ++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 0484b589..169fc1ca 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -27,6 +27,7 @@ import { isDate, isDimensions, isDirectory, + isDuration, isFileExtension, isName, isString, @@ -132,6 +133,15 @@ export function dimensions() { }; } +// Duration! This is a number of seconds, possibly floating point, always +// at minimum zero. +export function duration() { + return { + flags: {update: true, expose: true}, + update: {validate: isDuration}, + }; +} + // Straightforward flag descriptor for a variety of property purposes. // Provide a default value, true or false! export function flag(defaultValue = false) { diff --git a/src/data/things/track.js b/src/data/things/track.js index 0cd39dca..53798cda 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -19,7 +19,6 @@ import { isColor, isContributionList, isDate, - isDuration, isFileExtension, } from '#validators'; @@ -31,6 +30,7 @@ import Thing, { commentatorArtists, contributionList, directory, + duration, flag, name, referenceList, @@ -54,11 +54,7 @@ export class Track extends Thing { name: name('Unnamed Track'), directory: directory(), - duration: { - flags: {update: true, expose: true}, - update: {validate: isDuration}, - }, - + duration: duration(), urls: urls(), dateFirstReleased: simpleDate(), -- cgit 1.3.0-6-gf8a5 From 6fe22802d8220b983a488f4efee1834bacbdb166 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Fri, 8 Sep 2023 17:20:48 -0300 Subject: data: cleaner withResolvedReferenceList notFoundMode implementation --- src/data/things/thing.js | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 169fc1ca..96ac9b12 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -514,29 +514,45 @@ export function withResolvedReferenceList({ }), { - options: {findFunction, notFoundMode}, mapDependencies: {list, data}, - mapContinuation: {matches: into}, + options: {findFunction}, - compute({list, data, '#options': {findFunction, notFoundMode}}, continuation) { - let matches = - list.map(ref => findFunction(ref, data, {mode: 'quiet'})); + compute: ({list, data, '#options': {findFunction}}, continuation) => + continuation({ + '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})), + }), + }, - if (matches.every(match => match)) { - return continuation.raise({matches}); - } + { + dependencies: ['#matches'], + mapContinuation: {into}, - switch (notFoundMode) { - case 'filter': - matches = matches.filter(match => match); - return continuation.raise({matches}); + compute: ({'#matches': matches}, continuation) => + (matches.every(match => match) + ? continuation.raise({into: matches}) + : continuation()), + }, + + { + dependencies: ['#matches'], + options: {notFoundMode}, + mapContinuation: {into}, + compute({ + '#matches': matches, + '#options': {notFoundMode}, + }, continuation) { + switch (notFoundMode) { case 'exit': return continuation.exit([]); + case 'filter': + matches = matches.filter(match => match); + return continuation.raise({into: matches}); + case 'null': matches = matches.map(match => match ?? null); - return continuation.raise({matches}); + return continuation.raise({into: matches}); } }, }, -- cgit 1.3.0-6-gf8a5 From c82784ebb4e5141bfe97664f3252303b3e833863 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 08:13:44 -0300 Subject: data: withPropertyFrom{Object,List}, fillMissingListItems utils --- src/data/things/album.js | 66 +++++++++------------- src/data/things/composite.js | 127 +++++++++++++++++++++++++++++++++++++++++++ src/data/things/thing.js | 11 +--- src/data/things/track.js | 16 +----- 4 files changed, 159 insertions(+), 61 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 5e281f06..288caa04 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -8,8 +8,11 @@ import { exitWithoutUpdateValue, exposeDependency, exposeUpdateValueOrContinue, + fillMissingListItems, withFlattenedArray, + withPropertyFromList, withUnflattenedArray, + withUpdateValueAsDependency, } from '#composite'; import Thing, { @@ -81,50 +84,35 @@ export class Album extends Thing { exitWithoutDependency({dependency: 'trackData', value: []}), exitWithoutUpdateValue({value: [], mode: 'empty'}), - { - transform: (trackSections, continuation) => - continuation(trackSections, { - '#sectionTrackRefs': - trackSections.map(section => section.tracks), - - '#sectionDateOriginallyReleased': - trackSections - .map(({dateOriginallyReleased}) => dateOriginallyReleased ?? null), - - '#sectionIsDefaultTrackSection': - trackSections - .map(({isDefaultTrackSection}) => isDefaultTrackSection ?? false), - }), - }, + withUpdateValueAsDependency({into: '#sections'}), - { - dependencies: ['color'], - transform: (trackSections, {color: albumColor}, continuation) => - continuation(trackSections, { - '#sectionColor': - trackSections - .map(({color: sectionColor}) => sectionColor ?? albumColor), - }), - }, + withPropertyFromList({list: '#sections', property: 'tracks', into: '#sections.trackRefs'}), + withPropertyFromList({list: '#sections', property: 'dateOriginallyReleased'}), + withPropertyFromList({list: '#sections', property: 'isDefaultTrackSection'}), + withPropertyFromList({list: '#sections', property: 'color'}), + + fillMissingListItems({list: '#sections.trackRefs', value: []}), + fillMissingListItems({list: '#sections.isDefaultTrackSection', value: false}), + fillMissingListItems({list: '#sections.color', dependency: 'color'}), withFlattenedArray({ - from: '#sectionTrackRefs', + from: '#sections.trackRefs', into: '#trackRefs', - intoIndices: '#sectionStartIndex', + intoIndices: '#sections.startIndex', }), withResolvedReferenceList({ list: '#trackRefs', data: 'trackData', - mode: 'null', + notFoundMode: 'null', find: find.track, into: '#tracks', }), withUnflattenedArray({ from: '#tracks', - fromIndices: '#sectionStartIndex', - into: '#sectionTracks', + fromIndices: '#sections.startIndex', + into: '#sections.tracks', }), { @@ -134,19 +122,19 @@ export class Album extends Thing { expose: { dependencies: [ - '#sectionTracks', - '#sectionColor', - '#sectionDateOriginallyReleased', - '#sectionIsDefaultTrackSection', - '#sectionStartIndex', + '#sections.tracks', + '#sections.color', + '#sections.dateOriginallyReleased', + '#sections.isDefaultTrackSection', + '#sections.startIndex', ], transform: (trackSections, { - '#sectionTracks': tracks, - '#sectionColor': color, - '#sectionDateOriginallyReleased': dateOriginallyReleased, - '#sectionIsDefaultTrackSection': isDefaultTrackSection, - '#sectionStartIndex': startIndex, + '#sections.tracks': tracks, + '#sections.color': color, + '#sections.dateOriginallyReleased': dateOriginallyReleased, + '#sections.isDefaultTrackSection': isDefaultTrackSection, + '#sections.startIndex': startIndex, }) => stitchArrays({ tracks, diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 1f6482f6..a5adc3e9 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1089,6 +1089,133 @@ export function withUpdateValueAsDependency({ }; } +// Gets a property of some object (in a dependency) and provides that value. +// If the object itself is null, the provided dependency will also always be +// null. By default, it'll also be null if the object doesn't have the listed +// property (or its value is undefined/null); provide a value on {missing} to +// default to something else here. +export function withPropertyFromObject({ + object, + property, + into = null, +}) { + into ??= + (object.startsWith('#') + ? `${object}.${property}` + : `#${object}.${property}`); + + return { + annotation: `withPropertyFromObject`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {object}, + mapContinuation: {into}, + options: {property}, + + compute({object, '#options': {property}}, continuation) { + if (object === null || object === undefined) return continuation({into: null}); + if (!Object.hasOwn(object, property)) return continuation({into: null}); + return continuation({into: object[property] ?? null}); + }, + }, + }; +} + +// Gets a property from each of a list of objects (in a dependency) and +// provides the results. This doesn't alter any list indices, so positions +// which were null in the original list are kept null here. Objects which don't +// have the specified property are also included in-place as null, by default; +// provide a value on {missing} to default to something else here. +export function withPropertyFromList({ + list, + property, + into = null, + missing = null, +}) { + into ??= + (list.startsWith('#') + ? `${list}.${property}` + : `#${list}.${property}`); + + return { + annotation: `withPropertyFromList`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + mapContinuation: {into}, + options: {property}, + + compute({list, '#options': {property}}, continuation) { + if (list === undefined || empty(list)) { + return continuation({into: []}); + } + + return continuation({ + into: + list.map(item => { + if (item === null || item === undefined) return null; + if (!Object.hasOwn(item, property)) return missing; + return item[property] ?? missing; + }), + }); + }, + }, + }; +} + +// Replaces items of a list, which are null or undefined, with some fallback +// value, either a constant (set {value}) or from a dependency ({dependency}). +// By default, this replaces the passed dependency. +export function fillMissingListItems({ + list, + value, + dependency, + into = list, +}) { + if (value !== undefined && dependency !== undefined) { + throw new TypeError(`Don't provide both value and dependency`); + } + + if (value === undefined && dependency === undefined) { + throw new TypeError(`Missing value or dependency`); + } + + if (dependency) { + return { + annotation: `fillMissingListItems.fromDependency`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list, dependency}, + mapContinuation: {into}, + + compute: ({list, dependency}, continuation) => + continuation({ + into: list.map(item => item ?? dependency), + }), + }, + }; + } else { + return { + annotation: `fillMissingListItems.fromValue`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + mapContinuation: {into}, + options: {value}, + + compute: ({list, '#options': {value}}, continuation) => + continuation({ + into: list.map(item => item ?? value), + }), + }, + }; + } +} + // Flattens an array with one level of nested arrays, providing as dependencies // both the flattened array as well as the original starting indices of each // successive source array. diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 96ac9b12..a87e6ed6 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -15,6 +15,7 @@ import { exposeDependency, exposeDependencyOrContinue, raiseWithoutDependency, + withPropertyFromList, withUpdateValueAsDependency, } from '#composite'; @@ -408,14 +409,8 @@ export function withResolvedContribs({ raise: {into: []}, }), - { - mapDependencies: {from}, - compute: ({from}, continuation) => - continuation({ - '#artistRefs': from.map(({who}) => who), - '#what': from.map(({what}) => what), - }), - }, + withPropertyFromList({list: from, property: 'who', into: '#artistRefs'}), + withPropertyFromList({list: from, property: 'what', into: '#what'}), withResolvedReferenceList({ list: '#artistRefs', diff --git a/src/data/things/track.js b/src/data/things/track.js index 53798cda..a307fda9 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -11,6 +11,7 @@ import { exposeDependency, exposeDependencyOrContinue, exposeUpdateValueOrContinue, + withPropertyFromObject, withResultOfAvailabilityCheck, withUpdateValueAsDependency, } from '#composite'; @@ -430,20 +431,7 @@ function withAlbumProperty({ }) { return compositeFrom(`withAlbumProperty`, [ withAlbum({notFoundMode}), - - { - dependencies: ['#album'], - options: {property}, - mapContinuation: {into}, - - compute: ({ - '#album': album, - '#options': {property}, - }, continuation) => - (album - ? continuation.raise({into: album[property]}) - : continuation.raise({into: null})), - }, + withPropertyFromObject({object: '#album', property, into}), ]); } -- cgit 1.3.0-6-gf8a5 From 57d07a308dfee6d16b49f7c009853b1789597e82 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 08:17:44 -0300 Subject: data: withAlbumProperty -> withPropertyFromAlbum Also remove withAlbumProperties, since it's not used anywhere and mostly serves as reference code. --- src/data/things/track.js | 59 ++++++++---------------------------------------- 1 file changed, 10 insertions(+), 49 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index a307fda9..5e553b48 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -75,7 +75,7 @@ export class Track extends Thing { : continuation()), }, - withAlbumProperty({property: 'color'}), + withPropertyFromAlbum({property: 'color'}), exposeDependency({ dependency: '#album.color', @@ -103,7 +103,7 @@ export class Track extends Thing { exposeUpdateValueOrContinue(), // Expose album's trackCoverArtFileExtension if no update value set. - withAlbumProperty({property: 'trackCoverArtFileExtension'}), + withPropertyFromAlbum({property: 'trackCoverArtFileExtension'}), exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), // Fallback to 'jpg'. @@ -123,7 +123,7 @@ export class Track extends Thing { exposeUpdateValueOrContinue(), - withAlbumProperty({property: 'trackArtDate'}), + withPropertyFromAlbum({property: 'trackArtDate'}), exposeDependency({ dependency: '#album.trackArtDate', update: {validate: isDate}, @@ -159,7 +159,7 @@ export class Track extends Thing { withResolvedContribs({from: '#updateValue', into: '#artistContribs'}), exposeDependencyOrContinue({dependency: '#artistContribs'}), - withAlbumProperty({property: 'artistContribs'}), + withPropertyFromAlbum({property: 'artistContribs'}), exposeDependency({ dependency: '#album.artistContribs', update: {validate: isContributionList}, @@ -187,7 +187,7 @@ export class Track extends Thing { withResolvedContribs({from: '#updateValue', into: '#coverArtistContribs'}), exposeDependencyOrContinue({dependency: '#coverArtistContribs'}), - withAlbumProperty({property: 'trackCoverArtistContribs'}), + withPropertyFromAlbum({property: 'trackCoverArtistContribs'}), exposeDependency({ dependency: '#album.trackCoverArtistContribs', update: {validate: isContributionList}, @@ -237,7 +237,7 @@ export class Track extends Thing { date: compositeFrom(`Track.date`, [ exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), - withAlbumProperty({property: 'date'}), + withPropertyFromAlbum({property: 'date'}), exposeDependency({dependency: '#album.date'}), ]), @@ -424,56 +424,17 @@ function withAlbum({ // property name prefixed with '#album.' (by default). If the track's album // isn't available, then by default, the property will be provided as null; // set {notFoundMode: 'exit'} to early exit instead. -function withAlbumProperty({ +function withPropertyFromAlbum({ property, into = '#album.' + property, notFoundMode = 'null', }) { - return compositeFrom(`withAlbumProperty`, [ + return compositeFrom(`withPropertyFromAlbum`, [ withAlbum({notFoundMode}), withPropertyFromObject({object: '#album', property, into}), ]); } -// Gets the listed properties from this track's album, providing them as -// dependencies (by default) with '#album.' prefixed before each property -// name. If the track's album isn't available, then by default, the same -// dependency names will be provided as null; set {notFoundMode: 'exit'} -// to early exit instead. -function withAlbumProperties({ - properties, - prefix = '#album', - notFoundMode = 'null', -}) { - return compositeFrom(`withAlbumProperties`, [ - withAlbum({notFoundMode}), - - { - dependencies: ['#album'], - options: {properties, prefix}, - - compute({ - '#album': album, - '#options': {properties, prefix}, - }, continuation) { - const raise = {}; - - if (album) { - for (const property of properties) { - raise[prefix + '.' + property] = album[property]; - } - } else { - for (const property of properties) { - raise[prefix + '.' + property] = null; - } - } - - return continuation.raise(raise); - }, - }, - ]); -} - // Gets the track section containing this track from its album's track list. // If notFoundMode is set to 'exit', this will early exit if the album can't be // found or if none of its trackSections includes the track for some reason. @@ -486,7 +447,7 @@ function withContainingTrackSection({ } return compositeFrom(`withContainingTrackSection`, [ - withAlbumProperty({property: 'trackSections', notFoundMode}), + withPropertyFromAlbum({property: 'trackSections', notFoundMode}), { dependencies: ['this', '#album.trackSections'], @@ -585,7 +546,7 @@ function withHasUniqueCoverArt({ : continuation.raise({into: true})), }, - withAlbumProperty({property: 'trackCoverArtistContribs'}), + withPropertyFromAlbum({property: 'trackCoverArtistContribs'}), { dependencies: ['#album.trackCoverArtistContribs'], -- cgit 1.3.0-6-gf8a5 From 726118e7e8eefa9002562ca2dd0a4f6deb8a05b9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 08:26:33 -0300 Subject: data: refactor {missing} out of withPropertyFrom{Object,List} --- src/data/things/composite.js | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index a5adc3e9..b37b8e31 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1090,10 +1090,8 @@ export function withUpdateValueAsDependency({ } // Gets a property of some object (in a dependency) and provides that value. -// If the object itself is null, the provided dependency will also always be -// null. By default, it'll also be null if the object doesn't have the listed -// property (or its value is undefined/null); provide a value on {missing} to -// default to something else here. +// If the object itself is null, or the object doesn't have the listed property, +// the provided dependency will also be null. export function withPropertyFromObject({ object, property, @@ -1113,11 +1111,10 @@ export function withPropertyFromObject({ mapContinuation: {into}, options: {property}, - compute({object, '#options': {property}}, continuation) { - if (object === null || object === undefined) return continuation({into: null}); - if (!Object.hasOwn(object, property)) return continuation({into: null}); - return continuation({into: object[property] ?? null}); - }, + compute: ({object, '#options': {property}}, continuation) => + (object === null || object === undefined + ? continuation({into: null}) + : continuation({into: object[property] ?? null})), }, }; } @@ -1125,13 +1122,11 @@ export function withPropertyFromObject({ // Gets a property from each of a list of objects (in a dependency) and // provides the results. This doesn't alter any list indices, so positions // which were null in the original list are kept null here. Objects which don't -// have the specified property are also included in-place as null, by default; -// provide a value on {missing} to default to something else here. +// have the specified property are retained in-place as null. export function withPropertyFromList({ list, property, into = null, - missing = null, }) { into ??= (list.startsWith('#') @@ -1154,11 +1149,10 @@ export function withPropertyFromList({ return continuation({ into: - list.map(item => { - if (item === null || item === undefined) return null; - if (!Object.hasOwn(item, property)) return missing; - return item[property] ?? missing; - }), + list.map(item => + (item === null || item === undefined + ? null + : item[property] ?? null)), }); }, }, -- cgit 1.3.0-6-gf8a5 From 7a21c665d888b0db4c47c72049f7649bf1dabcde Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 08:47:38 -0300 Subject: data: withPropertiesFrom{Object,List} --- src/data/things/album.js | 19 +++++++----- src/data/things/composite.js | 72 ++++++++++++++++++++++++++++++++++++++++++++ src/data/things/thing.js | 17 ++++++----- 3 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 288caa04..e11d0909 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -10,7 +10,7 @@ import { exposeUpdateValueOrContinue, fillMissingListItems, withFlattenedArray, - withPropertyFromList, + withPropertiesFromList, withUnflattenedArray, withUpdateValueAsDependency, } from '#composite'; @@ -86,17 +86,22 @@ export class Album extends Thing { withUpdateValueAsDependency({into: '#sections'}), - withPropertyFromList({list: '#sections', property: 'tracks', into: '#sections.trackRefs'}), - withPropertyFromList({list: '#sections', property: 'dateOriginallyReleased'}), - withPropertyFromList({list: '#sections', property: 'isDefaultTrackSection'}), - withPropertyFromList({list: '#sections', property: 'color'}), + withPropertiesFromList({ + list: '#sections', + properties: [ + 'tracks', + 'dateOriginallyReleased', + 'isDefaultTrackSection', + 'color', + ], + }), - fillMissingListItems({list: '#sections.trackRefs', value: []}), + fillMissingListItems({list: '#sections.tracks', value: []}), fillMissingListItems({list: '#sections.isDefaultTrackSection', value: false}), fillMissingListItems({list: '#sections.color', dependency: 'color'}), withFlattenedArray({ - from: '#sections.trackRefs', + from: '#sections.tracks', into: '#trackRefs', intoIndices: '#sections.startIndex', }), diff --git a/src/data/things/composite.js b/src/data/things/composite.js index b37b8e31..e3225563 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1119,6 +1119,39 @@ export function withPropertyFromObject({ }; } +// Gets the listed properties from some object, providing each property's value +// as a dependency prefixed with the same name as the object (by default). +// If the object itself is null, all provided dependencies will be null; +// if it's missing only select properties, those will be provided as null. +export function withPropertiesFromObject({ + object, + properties, + prefix = + (object.startsWith('#') + ? object + : `#${object}`), +}) { + return { + annotation: `withPropertiesFromObject`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {object}, + options: {prefix, properties}, + + compute: ({object, '#options': {prefix, properties}}, continuation) => + continuation( + Object.fromEntries( + properties.map(property => [ + `${prefix}.${property}`, + (object === null || object === undefined + ? null + : object[property] ?? null), + ]))), + }, + }; +} + // Gets a property from each of a list of objects (in a dependency) and // provides the results. This doesn't alter any list indices, so positions // which were null in the original list are kept null here. Objects which don't @@ -1159,6 +1192,45 @@ export function withPropertyFromList({ }; } +// Gets the listed properties from each of a list of objects, providing lists +// of property values each into a dependency prefixed with the same name as the +// list (by default). Like withPropertyFromList, this doesn't alter indices. +export function withPropertiesFromList({ + list, + properties, + prefix = + (list.startsWith('#') + ? list + : `#${list}`), +}) { + return { + annotation: `withPropertiesFromList`, + flags: {expose: true, compose: true}, + + expose: { + mapDependencies: {list}, + options: {prefix, properties}, + + compute({list, '#options': {prefix, properties}}, continuation) { + const lists = + Object.fromEntries( + properties.map(property => [`${prefix}.${property}`, []])); + + for (const item of list) { + for (const property of properties) { + lists[`${prefix}.${property}`].push( + (item === null || item === undefined + ? null + : item[property] ?? null)); + } + } + + return continuation(lists); + } + } + } +} + // Replaces items of a list, which are null or undefined, with some fallback // value, either a constant (set {value}) or from a dependency ({dependency}). // By default, this replaces the passed dependency. diff --git a/src/data/things/thing.js b/src/data/things/thing.js index a87e6ed6..52f0b773 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -15,7 +15,7 @@ import { exposeDependency, exposeDependencyOrContinue, raiseWithoutDependency, - withPropertyFromList, + withPropertiesFromList, withUpdateValueAsDependency, } from '#composite'; @@ -409,21 +409,24 @@ export function withResolvedContribs({ raise: {into: []}, }), - withPropertyFromList({list: from, property: 'who', into: '#artistRefs'}), - withPropertyFromList({list: from, property: 'what', into: '#what'}), + withPropertiesFromList({ + list: from, + properties: ['who', 'what'], + prefix: '#contribs', + }), withResolvedReferenceList({ - list: '#artistRefs', + list: '#contribs.who', data: 'artistData', - into: '#who', + into: '#contribs.who', find: find.artist, notFoundMode: 'null', }), { - dependencies: ['#who', '#what'], + dependencies: ['#contribs.who', '#contribs.what'], mapContinuation: {into}, - compute({'#who': who, '#what': what}, continuation) { + compute({'#contribs.who': who, '#contribs.what': what}, continuation) { filterMultipleArrays(who, what, (who, _what) => who); return continuation({ into: stitchArrays({who, what}), -- cgit 1.3.0-6-gf8a5 From ceaed5fef3ce2c5d59a6606a6318164b93294f2b Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 09:01:09 -0300 Subject: data: clean up some track property implementations --- src/data/things/track.js | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/src/data/things/track.js b/src/data/things/track.js index 5e553b48..25d316eb 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -61,22 +61,12 @@ export class Track extends Thing { color: compositeFrom(`Track.color`, [ exposeUpdateValueOrContinue(), - withContainingTrackSection(), - { - dependencies: ['#trackSection'], - compute: ({'#trackSection': trackSection}, continuation) => - // Album.trackSections guarantees the track section will have a - // color property (inheriting from the album's own color), but only - // if it's actually present! Color will be inherited directly from - // album otherwise. - (trackSection - ? trackSection.color - : continuation()), - }, + withContainingTrackSection(), + withPropertyFromObject({object: '#trackSection', property: 'color'}), + exposeDependencyOrContinue({dependency: '#trackSection.color'}), withPropertyFromAlbum({property: 'color'}), - exposeDependency({ dependency: '#album.color', update: {validate: isColor}, @@ -94,19 +84,13 @@ export class Track extends Thing { // of the album's main artwork. It does inherit trackCoverArtFileExtension, // if present on the album. coverArtFileExtension: compositeFrom(`Track.coverArtFileExtension`, [ - // No cover art file extension if the track doesn't have unique artwork - // in the first place. - withHasUniqueCoverArt(), - exitWithoutDependency({dependency: '#hasUniqueCoverArt', mode: 'falsy'}), + exitWithoutUniqueCoverArt(), - // Expose custom coverArtFileExtension update value first. exposeUpdateValueOrContinue(), - // Expose album's trackCoverArtFileExtension if no update value set. withPropertyFromAlbum({property: 'trackCoverArtFileExtension'}), exposeDependencyOrContinue({dependency: '#album.trackCoverArtFileExtension'}), - // Fallback to 'jpg'. exposeConstant({ value: 'jpg', update: {validate: isFileExtension}, @@ -175,13 +159,7 @@ export class Track extends Thing { // typically varies by release and isn't defined by the musical qualities // of the track. coverArtistContribs: compositeFrom(`Track.coverArtistContribs`, [ - { - dependencies: ['disableUniqueCoverArt'], - compute: ({disableUniqueCoverArt}, continuation) => - (disableUniqueCoverArt - ? null - : continuation()), - }, + exitWithoutUniqueCoverArt(), withUpdateValueAsDependency(), withResolvedContribs({from: '#updateValue', into: '#coverArtistContribs'}), @@ -559,6 +537,21 @@ function withHasUniqueCoverArt({ ]); } +// Shorthand for checking if the track has unique cover art and exposing a +// fallback value if it isn't. +function exitWithoutUniqueCoverArt({ + value = null, +} = {}) { + return compositeFrom(`exitWithoutUniqueCoverArt`, [ + withHasUniqueCoverArt(), + exitWithoutDependency({ + dependency: '#hasUniqueCoverArt', + mode: 'falsy', + value, + }), + ]); +} + function trackReverseReferenceList({ property: refListProperty, }) { -- cgit 1.3.0-6-gf8a5 From 7b32066dd9629bbb220c2e2425b5294070b5a0db Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 09:16:50 -0300 Subject: infra, data: cut unneeded boilerplate from top-level compositions --- src/data/things/album.js | 17 ++++++++--------- src/data/things/index.js | 13 +++++++++++-- src/data/things/track.js | 48 ++++++++++++++++++++++++------------------------ 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index e11d0909..07859537 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -3,7 +3,6 @@ import {stitchArrays} from '#sugar'; import {isDate, isTrackSectionList} from '#validators'; import { - compositeFrom, exitWithoutDependency, exitWithoutUpdateValue, exposeDependency, @@ -51,7 +50,7 @@ export class Album extends Thing { trackArtDate: simpleDate(), dateAddedToWiki: simpleDate(), - coverArtDate: compositeFrom(`Album.coverArtDate`, [ + coverArtDate: [ withResolvedContribs({from: 'coverArtistContribs'}), exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}), @@ -60,7 +59,7 @@ export class Album extends Thing { dependency: 'date', update: {validate: isDate}, }), - ]), + ], artistContribs: contributionList(), coverArtistContribs: contributionList(), @@ -80,7 +79,7 @@ export class Album extends Thing { data: 'artTagData', }), - trackSections: compositeFrom(`Album.trackSections`, [ + trackSections: [ exitWithoutDependency({dependency: 'trackData', value: []}), exitWithoutUpdateValue({value: [], mode: 'empty'}), @@ -150,13 +149,13 @@ export class Album extends Thing { }), }, }, - ]), + ], - coverArtFileExtension: compositeFrom(`Album.coverArtFileExtension`, [ + coverArtFileExtension: [ withResolvedContribs({from: 'coverArtistContribs'}), exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}), fileExtension('jpg'), - ]), + ], trackCoverArtFileExtension: fileExtension('jpg'), @@ -189,7 +188,7 @@ export class Album extends Thing { hasWallpaperArt: contribsPresent('wallpaperArtistContribs'), hasBannerArt: contribsPresent('bannerArtistContribs'), - tracks: compositeFrom(`Album.tracks`, [ + tracks: [ exitWithoutDependency({dependency: 'trackData', value: []}), exitWithoutDependency({dependency: 'trackSections', mode: 'empty', value: []}), @@ -209,7 +208,7 @@ export class Album extends Thing { }), exposeDependency({dependency: '#resolvedReferenceList'}), - ]), + ], }); static [Thing.getSerializeDescriptors] = ({ diff --git a/src/data/things/index.js b/src/data/things/index.js index 3b73a772..4d8d9d1f 100644 --- a/src/data/things/index.js +++ b/src/data/things/index.js @@ -2,6 +2,7 @@ import * as path from 'node:path'; import {fileURLToPath} from 'node:url'; import {logError} from '#cli'; +import {compositeFrom} from '#composite'; import * as serialize from '#serialize'; import {openAggregate, showAggregate} from '#sugar'; @@ -130,8 +131,16 @@ function evaluatePropertyDescriptors() { throw new Error(`Missing [Thing.getPropertyDescriptors] function`); } - constructor.propertyDescriptors = - constructor[Thing.getPropertyDescriptors](opts); + const results = constructor[Thing.getPropertyDescriptors](opts); + + for (const [key, value] of Object.entries(results)) { + if (Array.isArray(value)) { + results[key] = compositeFrom(`${constructor.name}.${key}`, value); + continue; + } + } + + constructor.propertyDescriptors = results; }, showFailedClasses(failedClasses) { diff --git a/src/data/things/track.js b/src/data/things/track.js index 25d316eb..a8d59023 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -59,7 +59,7 @@ export class Track extends Thing { urls: urls(), dateFirstReleased: simpleDate(), - color: compositeFrom(`Track.color`, [ + color: [ exposeUpdateValueOrContinue(), withContainingTrackSection(), @@ -71,7 +71,7 @@ export class Track extends Thing { dependency: '#album.color', update: {validate: isColor}, }), - ]), + ], // Disables presenting the track as though it has its own unique artwork. // This flag should only be used in select circumstances, i.e. to override @@ -83,7 +83,7 @@ export class Track extends Thing { // track's unique cover artwork, if any, and does not inherit the extension // of the album's main artwork. It does inherit trackCoverArtFileExtension, // if present on the album. - coverArtFileExtension: compositeFrom(`Track.coverArtFileExtension`, [ + coverArtFileExtension: [ exitWithoutUniqueCoverArt(), exposeUpdateValueOrContinue(), @@ -95,13 +95,13 @@ export class Track extends Thing { value: 'jpg', update: {validate: isFileExtension}, }), - ]), + ], // Date of cover art release. Like coverArtFileExtension, this represents // only the track's own unique cover artwork, if any. This exposes only as // the track's own coverArtDate or its album's trackArtDate, so if neither // is specified, this value is null. - coverArtDate: compositeFrom(`Track.coverArtDate`, [ + coverArtDate: [ withHasUniqueCoverArt(), exitWithoutDependency({dependency: '#hasUniqueCoverArt', mode: 'falsy'}), @@ -112,7 +112,7 @@ export class Track extends Thing { dependency: '#album.trackArtDate', update: {validate: isDate}, }), - ]), + ], commentary: commentary(), lyrics: simpleString(), @@ -136,7 +136,7 @@ export class Track extends Thing { data: 'albumData', }), - artistContribs: compositeFrom(`Track.artistContribs`, [ + artistContribs: [ inheritFromOriginalRelease({property: 'artistContribs'}), withUpdateValueAsDependency(), @@ -148,17 +148,17 @@ export class Track extends Thing { dependency: '#album.artistContribs', update: {validate: isContributionList}, }), - ]), + ], - contributorContribs: compositeFrom(`Track.contributorContribs`, [ + contributorContribs: [ inheritFromOriginalRelease({property: 'contributorContribs'}), contributionList(), - ]), + ], // Cover artists aren't inherited from the original release, since it // typically varies by release and isn't defined by the musical qualities // of the track. - coverArtistContribs: compositeFrom(`Track.coverArtistContribs`, [ + coverArtistContribs: [ exitWithoutUniqueCoverArt(), withUpdateValueAsDependency(), @@ -170,25 +170,25 @@ export class Track extends Thing { dependency: '#album.trackCoverArtistContribs', update: {validate: isContributionList}, }), - ]), + ], - referencedTracks: compositeFrom(`Track.referencedTracks`, [ + referencedTracks: [ inheritFromOriginalRelease({property: 'referencedTracks'}), referenceList({ class: Track, find: find.track, data: 'trackData', }), - ]), + ], - sampledTracks: compositeFrom(`Track.sampledTracks`, [ + sampledTracks: [ inheritFromOriginalRelease({property: 'sampledTracks'}), referenceList({ class: Track, find: find.track, data: 'trackData', }), - ]), + ], artTags: referenceList({ class: ArtTag, @@ -208,16 +208,16 @@ export class Track extends Thing { commentatorArtists: commentatorArtists(), - album: compositeFrom(`Track.album`, [ + album: [ withAlbum(), exposeDependency({dependency: '#album'}), - ]), + ], - date: compositeFrom(`Track.date`, [ + date: [ exposeDependencyOrContinue({dependency: 'dateFirstReleased'}), withPropertyFromAlbum({property: 'date'}), exposeDependency({dependency: '#album.date'}), - ]), + ], // Whether or not the track has "unique" cover artwork - a cover which is // specifically associated with this track in particular, rather than with @@ -226,12 +226,12 @@ export class Track extends Thing { // or a placeholder. (This property is named hasUniqueCoverArt instead of // the usual hasCoverArt to emphasize that it does not inherit from the // album.) - hasUniqueCoverArt: compositeFrom(`Track.hasUniqueCoverArt`, [ + hasUniqueCoverArt: [ withHasUniqueCoverArt(), exposeDependency({dependency: '#hasUniqueCoverArt'}), - ]), + ], - otherReleases: compositeFrom(`Track.otherReleases`, [ + otherReleases: [ exitWithoutDependency({dependency: 'trackData', mode: 'empty'}), withOriginalRelease({selfIfOriginal: true}), @@ -253,7 +253,7 @@ export class Track extends Thing { track.originalReleaseTrack === originalRelease)), }, }, - ]), + ], // Specifically exclude re-releases from this list - while it's useful to // get from a re-release to the tracks it references, re-releases aren't -- cgit 1.3.0-6-gf8a5 From a9b96deeca6b2dacb7fac309c47e7bc6289270e6 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 09:29:51 -0300 Subject: data: be more permissive of steps w/ no special expose behavior --- src/data/things/composite.js | 30 +++++++++++------------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index e3225563..2dd92f17 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -432,13 +432,8 @@ export function compositeFrom(firstArg, secondArg) { ? step.expose : step); - const stepComputes = !!expose.compute; - const stepTransforms = !!expose.transform; - - if (!stepComputes && !stepTransforms) { - push(new TypeError(`Steps must provide compute or transform (or both)`)); - return; - } + const stepComputes = !!expose?.compute; + const stepTransforms = !!expose?.transform; if ( stepTransforms && !stepComputes && @@ -459,7 +454,7 @@ export function compositeFrom(firstArg, secondArg) { // Unmapped dependencies are exposed on the final composition only if // they're "public", i.e. pointing to update values of other properties // on the CacheableObject. - for (const dependency of expose.dependencies ?? []) { + for (const dependency of expose?.dependencies ?? []) { if (typeof dependency === 'string' && dependency.startsWith('#')) { continue; } @@ -470,22 +465,14 @@ export function compositeFrom(firstArg, secondArg) { // Mapped dependencies are always exposed on the final composition. // These are explicitly for reading values which are named outside of // the current compositional step. - for (const dependency of Object.values(expose.mapDependencies ?? {})) { + for (const dependency of Object.values(expose?.mapDependencies ?? {})) { exposeDependencies.add(dependency); } }); } - if (!baseComposes) { - if (baseUpdates) { - if (!anyStepsTransform) { - aggregate.push(new TypeError(`Expected at least one step to transform`)); - } - } else { - if (!anyStepsCompute) { - aggregate.push(new TypeError(`Expected at least one step to compute`)); - } - } + if (!baseComposes && !baseUpdates && !anyStepsCompute) { + aggregate.push(new TypeError(`Expected at least one step to compute`)); } aggregate.close(); @@ -615,6 +602,11 @@ export function compositeFrom(firstArg, secondArg) { ? step.expose : step); + if (!expose) { + debug(() => `step #${i+1} - no expose description, nothing to do for this step`); + continue; + } + const callingTransformForThisStep = expectingTransform && expose.transform; -- cgit 1.3.0-6-gf8a5 From 9109356037ce98af378765302841c957cc96b8d8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 09:33:04 -0300 Subject: data: exitWithoutContribs utility --- src/data/things/album.js | 9 +++------ src/data/things/thing.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 07859537..dc8d3189 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -23,6 +23,7 @@ import Thing, { contributionList, dimensions, directory, + exitWithoutContribs, fileExtension, flag, name, @@ -31,7 +32,6 @@ import Thing, { simpleString, urls, wikiData, - withResolvedContribs, withResolvedReferenceList, } from './thing.js'; @@ -51,9 +51,7 @@ export class Album extends Thing { dateAddedToWiki: simpleDate(), coverArtDate: [ - withResolvedContribs({from: 'coverArtistContribs'}), - exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}), - + exitWithoutContribs({contribs: 'coverArtistContribs'}), exposeUpdateValueOrContinue(), exposeDependency({ dependency: 'date', @@ -152,8 +150,7 @@ export class Album extends Thing { ], coverArtFileExtension: [ - withResolvedContribs({from: 'coverArtistContribs'}), - exitWithoutDependency({dependency: '#resolvedContribs', mode: 'empty'}), + exitWithoutContribs({contribs: 'coverArtistContribs'}), fileExtension('jpg'), ], diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 52f0b773..fe9000b4 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -436,6 +436,23 @@ export function withResolvedContribs({ ]); } +// Shorthand for exiting if the contribution list (usually a property's update +// value) resolves to empty - ensuring that the later computed results are only +// returned if these contributions are present. +export function exitWithoutContribs({ + contribs, + value = null, +}) { + return compositeFrom(`exitWithoutContribs`, [ + withResolvedContribs({from: contribs}), + exitWithoutDependency({ + dependency: '#resolvedContribs', + mode: 'empty', + value, + }), + ]); +} + // Resolves a reference by using the provided find function to match it // within the provided thingData dependency. This will early exit if the // data dependency is null, or, if notFoundMode is set to 'exit', if the find -- cgit 1.3.0-6-gf8a5 From 3083e006fb8be524ca8e37c3194b78b0bf37861f Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 09:33:14 -0300 Subject: data: rearrange Album properties, use exitWithoutContribs more --- src/data/things/album.js | 84 +++++++++++++++++++++++++++++------------------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index dc8d3189..2a8c59ed 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -59,23 +59,44 @@ export class Album extends Thing { }), ], - artistContribs: contributionList(), - coverArtistContribs: contributionList(), - trackCoverArtistContribs: contributionList(), - wallpaperArtistContribs: contributionList(), - bannerArtistContribs: contributionList(), + coverArtFileExtension: [ + exitWithoutContribs({contribs: 'coverArtistContribs'}), + fileExtension('jpg'), + ], - groups: referenceList({ - class: Group, - find: find.group, - data: 'groupData', - }), + trackCoverArtFileExtension: fileExtension('jpg'), - artTags: referenceList({ - class: ArtTag, - find: find.artTag, - data: 'artTagData', - }), + wallpaperFileExtension: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + fileExtension('jpg'), + ], + + bannerFileExtension: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + fileExtension('jpg'), + ], + + wallpaperStyle: [ + exitWithoutContribs({contribs: 'wallpaperArtistContribs'}), + simpleString(), + ], + + bannerStyle: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + simpleString(), + ], + + bannerDimensions: [ + exitWithoutContribs({contribs: 'bannerArtistContribs'}), + dimensions(), + ], + + hasTrackNumbers: flag(true), + isListedOnHomepage: flag(true), + isListedInGalleries: flag(true), + + commentary: commentary(), + additionalFiles: additionalFiles(), trackSections: [ exitWithoutDependency({dependency: 'trackData', value: []}), @@ -149,26 +170,23 @@ export class Album extends Thing { }, ], - coverArtFileExtension: [ - exitWithoutContribs({contribs: 'coverArtistContribs'}), - fileExtension('jpg'), - ], - - trackCoverArtFileExtension: fileExtension('jpg'), - - wallpaperStyle: simpleString(), - wallpaperFileExtension: fileExtension('jpg'), - - bannerStyle: simpleString(), - bannerFileExtension: fileExtension('jpg'), - bannerDimensions: dimensions(), + artistContribs: contributionList(), + coverArtistContribs: contributionList(), + trackCoverArtistContribs: contributionList(), + wallpaperArtistContribs: contributionList(), + bannerArtistContribs: contributionList(), - hasTrackNumbers: flag(true), - isListedOnHomepage: flag(true), - isListedInGalleries: flag(true), + groups: referenceList({ + class: Group, + find: find.group, + data: 'groupData', + }), - commentary: commentary(), - additionalFiles: additionalFiles(), + artTags: referenceList({ + class: ArtTag, + find: find.artTag, + data: 'artTagData', + }), // Update only -- cgit 1.3.0-6-gf8a5 From f242d1dec3cd905e74eec6ce518781843d5f65d9 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 09:40:15 -0300 Subject: data: update contribsPresent syntax & implementation --- src/data/things/album.js | 6 +++--- src/data/things/thing.js | 15 ++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 01f52c2d..fb0c3427 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -194,9 +194,9 @@ export class Album extends Thing { commentatorArtists: commentatorArtists(), - hasCoverArt: contribsPresent('coverArtistContribs'), - hasWallpaperArt: contribsPresent('wallpaperArtistContribs'), - hasBannerArt: contribsPresent('bannerArtistContribs'), + hasCoverArt: contribsPresent({contribs: 'coverArtistContribs'}), + hasWallpaperArt: contribsPresent({contribs: 'wallpaperArtistContribs'}), + hasBannerArt: contribsPresent({contribs: 'bannerArtistContribs'}), tracks: compositeFrom(`Album.tracks`, [ exitWithoutDependency({dependency: 'trackData', value: []}), diff --git a/src/data/things/thing.js b/src/data/things/thing.js index 79d8ae0e..0f47dc90 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -15,6 +15,7 @@ import { exposeDependency, exposeDependencyOrContinue, raiseWithoutDependency, + withResultOfAvailabilityCheck, withUpdateValueAsDependency, } from '#composite'; @@ -297,15 +298,11 @@ export function singleReference({ // Nice 'n simple shorthand for an exposed-only flag which is true when any // contributions are present in the specified property. -export function contribsPresent(contribsProperty) { - return { - flags: {expose: true}, - expose: { - dependencies: [contribsProperty], - compute: ({[contribsProperty]: contribs}) => - !empty(contribs), - }, - }; +export function contribsPresent({contribs}) { + return compositeFrom(`contribsPresent`, [ + withResultOfAvailabilityCheck({fromDependency: contribs, mode: 'empty'}), + exposeDependency({dependency: '#availability'}), + ]); } // Neat little shortcut for "reversing" the reference lists stored on other -- cgit 1.3.0-6-gf8a5 From 272a2f47102451a277d099d032e6f4d0ad673d80 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 18:20:00 -0300 Subject: data: handle missing expose specially in base This is for better compatibility with an updating base that doesn't transform its update value, but attempts to behave reasonably for non-transforming contexts as well. --- src/data/things/composite.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 2dd92f17..3a63f22d 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -603,7 +603,31 @@ export function compositeFrom(firstArg, secondArg) { : step); if (!expose) { - debug(() => `step #${i+1} - no expose description, nothing to do for this step`); + if (!isBase) { + debug(() => `step #${i+1} - no expose description, nothing to do for this step`); + continue; + } + + if (expectingTransform) { + debug(() => `step #${i+1} (base) - no expose description, returning so-far update value:`, valueSoFar); + if (continuationIfApplicable) { + debug(() => colors.bright(`end composition - raise (inferred - composing)`)); + return continuationIfApplicable(valueSoFar); + } else { + debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); + return valueSoFar; + } + } else { + debug(() => `step #${i+1} (base) - no expose description, nothing to continue with`); + if (continuationIfApplicable) { + debug(() => colors.bright(`end composition - raise (inferred - composing)`)); + return continuationIfApplicable(); + } else { + debug(() => colors.bright(`end composition - exit (inferred - not composing)`)); + return null; + } + } + continue; } -- cgit 1.3.0-6-gf8a5 From 666ce1d6c2e1b93e34222c2b2b999ff32a1c6ca8 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 18:24:53 -0300 Subject: test: Album.{banner,wallpaper}{FileExtension,Style} Also Album.bannerDimensions. --- test/unit/data/things/album.js | 160 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js index 3593074a..240150b0 100644 --- a/test/unit/data/things/album.js +++ b/test/unit/data/things/album.js @@ -26,6 +26,101 @@ function stubTrack(directory = 'foo') { return track; } +t.test(`Album.bannerDimensions`, t => { + t.plan(4); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.bannerDimensions, null, + `Album.bannerDimensions #1: defaults to null`); + + album.bannerDimensions = [1200, 275]; + + t.equal(album.bannerDimensions, null, + `Album.bannerDimensions #2: is null if bannerArtistContribs empty`); + + album.bannerArtistContribs = badContribs; + + t.equal(album.bannerDimensions, null, + `Album.bannerDimensions #3: is null if bannerArtistContribs resolves empty`); + + album.bannerArtistContribs = contribs; + + t.same(album.bannerDimensions, [1200, 275], + `Album.bannerDimensions #4: is own value`); +}); + +t.test(`Album.bannerFileExtension`, t => { + t.plan(5); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.bannerFileExtension, null, + `Album.bannerFileExtension #1: defaults to null`); + + album.bannerFileExtension = 'png'; + + t.equal(album.bannerFileExtension, null, + `Album.bannerFileExtension #2: is null if bannerArtistContribs empty`); + + album.bannerArtistContribs = badContribs; + + t.equal(album.bannerFileExtension, null, + `Album.bannerFileExtension #3: is null if bannerArtistContribs resolves empty`); + + album.bannerArtistContribs = contribs; + + t.equal(album.bannerFileExtension, 'png', + `Album.bannerFileExtension #4: is own value`); + + album.bannerFileExtension = null; + + t.equal(album.bannerFileExtension, 'jpg', + `Album.bannerFileExtension #5: defaults to jpg`); +}); + +t.test(`Album.bannerStyle`, t => { + t.plan(4); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.bannerStyle, null, + `Album.bannerStyle #1: defaults to null`); + + album.bannerStyle = `opacity: 0.5`; + + t.equal(album.bannerStyle, null, + `Album.bannerStyle #2: is null if bannerArtistContribs empty`); + + album.bannerArtistContribs = badContribs; + + t.equal(album.bannerStyle, null, + `Album.bannerStyle #3: is null if bannerArtistContribs resolves empty`); + + album.bannerArtistContribs = contribs; + + t.equal(album.bannerStyle, `opacity: 0.5`, + `Album.bannerStyle #4: is own value`); +}); + t.test(`Album.coverArtDate`, t => { t.plan(6); @@ -211,3 +306,68 @@ t.test(`Album.trackSections`, t => { {tracks: [track3], isDefaultTrackSection: false}, ], `Album.trackSections #5: exposes isDefaultTrackSection, defaults to false`); }); + +t.test(`Album.wallpaperFileExtension`, t => { + t.plan(5); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.wallpaperFileExtension, null, + `Album.wallpaperFileExtension #1: defaults to null`); + + album.wallpaperFileExtension = 'png'; + + t.equal(album.wallpaperFileExtension, null, + `Album.wallpaperFileExtension #2: is null if wallpaperArtistContribs empty`); + + album.wallpaperArtistContribs = contribs; + + t.equal(album.wallpaperFileExtension, 'png', + `Album.wallpaperFileExtension #3: is own value`); + + album.wallpaperFileExtension = null; + + t.equal(album.wallpaperFileExtension, 'jpg', + `Album.wallpaperFileExtension #4: defaults to jpg`); + + album.wallpaperArtistContribs = badContribs; + + t.equal(album.wallpaperFileExtension, null, + `Album.wallpaperFileExtension #5: is null if wallpaperArtistContribs resolves empty`); +}); + +t.test(`Album.wallpaperStyle`, t => { + t.plan(4); + + const album = new Album(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + }); + + t.equal(album.wallpaperStyle, null, + `Album.wallpaperStyle #1: defaults to null`); + + album.wallpaperStyle = `opacity: 0.5`; + + t.equal(album.wallpaperStyle, null, + `Album.wallpaperStyle #2: is null if wallpaperArtistContribs empty`); + + album.wallpaperArtistContribs = badContribs; + + t.equal(album.wallpaperStyle, null, + `Album.wallpaperStyle #3: is null if wallpaperArtistContribs resolves empty`); + + album.wallpaperArtistContribs = contribs; + + t.equal(album.wallpaperStyle, `opacity: 0.5`, + `Album.wallpaperStyle #4: is own value`); +}); -- cgit 1.3.0-6-gf8a5 From b06c194fc02da22564bcb165db33282f411859a3 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 18:31:09 -0300 Subject: data, test: filter out empty track sections Also test unmatched track references. --- src/data/things/album.js | 16 +++++++++++----- test/unit/data/things/album.js | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 9ca662a0..7569eb80 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -1,6 +1,7 @@ import find from '#find'; -import {stitchArrays} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; import {isDate, isTrackSectionList} from '#validators'; +import {filterMultipleArrays} from '#wiki-data'; import { exitWithoutDependency, @@ -152,20 +153,25 @@ export class Album extends Thing { '#sections.startIndex', ], - transform: (trackSections, { + transform(trackSections, { '#sections.tracks': tracks, '#sections.color': color, '#sections.dateOriginallyReleased': dateOriginallyReleased, '#sections.isDefaultTrackSection': isDefaultTrackSection, '#sections.startIndex': startIndex, - }) => - stitchArrays({ + }) { + filterMultipleArrays( + tracks, color, dateOriginallyReleased, isDefaultTrackSection, startIndex, + tracks => !empty(tracks)); + + return stitchArrays({ tracks, color, dateOriginallyReleased, isDefaultTrackSection, startIndex, - }), + }); + } }, }, ], diff --git a/test/unit/data/things/album.js b/test/unit/data/things/album.js index 240150b0..0695fdb6 100644 --- a/test/unit/data/things/album.js +++ b/test/unit/data/things/album.js @@ -240,7 +240,7 @@ t.test(`Album.tracks`, t => { }); t.test(`Album.trackSections`, t => { - t.plan(5); + t.plan(6); const album = new Album(); const track1 = stubTrack('track1'); @@ -305,6 +305,20 @@ t.test(`Album.trackSections`, t => { {tracks: [track2], isDefaultTrackSection: false}, {tracks: [track3], isDefaultTrackSection: false}, ], `Album.trackSections #5: exposes isDefaultTrackSection, defaults to false`); + + album.trackSections = [ + {tracks: ['track:track1', 'track:track2', 'track:snooping'], color: '#112233'}, + {tracks: ['track:track3', 'track:as-usual'], color: '#334455'}, + {tracks: [], color: '#bbbbba'}, + {tracks: ['track:icy', 'track:chilly', 'track:frigid'], color: '#556677'}, + {tracks: ['track:track4'], color: '#778899'}, + ]; + + t.match(album.trackSections, [ + {tracks: [track1, track2], color: '#112233'}, + {tracks: [track3], color: '#334455'}, + {tracks: [track4], color: '#778899'}, + ], `Album.trackSections #6: filters out references without matches & empty sections`); }); t.test(`Album.wallpaperFileExtension`, t => { -- cgit 1.3.0-6-gf8a5 From 14329ec8eedb7ad5dcb6a3308a26686bd381ab36 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 19:04:04 -0300 Subject: data, test: ArtTag.nameShort --- src/data/things/art-tag.js | 19 +++++++---- test/unit/data/things/art-tag.js | 71 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 test/unit/data/things/art-tag.js diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index 3d65b578..7e466555 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -1,4 +1,6 @@ +import {exposeUpdateValueOrContinue} from '#composite'; import {sortAlbumsTracksChronologically} from '#wiki-data'; +import {isName} from '#validators'; import Thing, { color, @@ -19,15 +21,20 @@ export class ArtTag extends Thing { color: color(), isContentWarning: flag(false), - nameShort: { - flags: {update: true, expose: true}, + nameShort: [ + exposeUpdateValueOrContinue(), - expose: { + { dependencies: ['name'], - transform: (value, {name}) => - value ?? name.replace(/ \(.*?\)$/, ''), + compute: ({name}) => + name.replace(/ \([^)]*?\)$/, ''), }, - }, + + { + flags: {update: true, expose: true}, + validate: {isName}, + }, + ], // Update only diff --git a/test/unit/data/things/art-tag.js b/test/unit/data/things/art-tag.js new file mode 100644 index 00000000..561c93ef --- /dev/null +++ b/test/unit/data/things/art-tag.js @@ -0,0 +1,71 @@ +import t from 'tap'; + +import {linkAndBindWikiData} from '#test-lib'; +import thingConstructors from '#things'; + +const { + Album, + Artist, + ArtTag, + Track, +} = thingConstructors; + +function stubAlbum(tracks, directory = 'bar') { + const album = new Album(); + album.directory = directory; + + const trackRefs = tracks.map(t => Thing.getReference(t)); + album.trackSections = [{tracks: trackRefs}]; + + return album; +} + +function stubTrack(directory = 'foo') { + const track = new Track(); + track.directory = directory; + + return track; +} + +function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') { + const track = stubTrack(trackDirectory); + const album = stubAlbum([track], albumDirectory); + + return {track, album}; +} + +function stubArtist(artistName = `Test Artist`) { + const artist = new Artist(); + artist.name = artistName; + + return artist; +} + +function stubArtistAndContribs(artistName = `Test Artist`) { + const artist = stubArtist(artistName); + const contribs = [{who: artistName, what: null}]; + const badContribs = [{who: `Figment of Your Imagination`, what: null}]; + + return {artist, contribs, badContribs}; +} + +t.test(`ArtTag.nameShort`, t => { + t.plan(3); + + const artTag = new ArtTag(); + + artTag.name = `Dave Strider`; + + t.equal(artTag.nameShort, `Dave Strider`, + `ArtTag #1: defaults to name`); + + artTag.name = `Dave Strider (Homestuck)`; + + t.equal(artTag.nameShort, `Dave Strider`, + `ArtTag #2: trims parenthical part at end`); + + artTag.name = `This (And) That (Then)`; + + t.equal(artTag.nameShort, `This (And) That`, + `ArtTag #2: doesn't trim midlde parenthical part`); +}); -- cgit 1.3.0-6-gf8a5 From c4f6c41a248ba9ef4f802cc03c20757d417540e4 Mon Sep 17 00:00:00 2001 From: "(quasar) nebula" Date: Sat, 9 Sep 2023 21:08:06 -0300 Subject: data: WIP cached composition nonsense --- src/data/things/album.js | 8 ++++ src/data/things/composite.js | 111 +++++++++++++++++++++++++++++++++++++++---- src/data/things/thing.js | 25 ++++++---- src/upd8.js | 14 +++++- src/util/wiki-data.js | 68 ++++++++++++++++++++++++++ 5 files changed, 209 insertions(+), 17 deletions(-) diff --git a/src/data/things/album.js b/src/data/things/album.js index 7569eb80..b134b78d 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -125,6 +125,14 @@ export class Album extends Thing { intoIndices: '#sections.startIndex', }), + { + dependencies: ['#trackRefs'], + compute: ({'#trackRefs': tracks}, continuation) => { + console.log(tracks); + return continuation(); + } + }, + withResolvedReferenceList({ list: '#trackRefs', data: 'trackData', diff --git a/src/data/things/composite.js b/src/data/things/composite.js index 3a63f22d..26124b56 100644 --- a/src/data/things/composite.js +++ b/src/data/things/composite.js @@ -1,6 +1,7 @@ import {inspect} from 'node:util'; import {colors} from '#cli'; +import {TupleMap} from '#wiki-data'; import { empty, @@ -341,6 +342,8 @@ import { // syntax as for other compositional steps, and it'll work out cleanly! // +const globalCompositeCache = {}; + export function compositeFrom(firstArg, secondArg) { const debug = fn => { if (compositeFrom.debug === true) { @@ -567,8 +570,8 @@ export function compositeFrom(firstArg, secondArg) { return {continuation, continuationStorage}; } - const continuationSymbol = Symbol('continuation symbol'); - const noTransformSymbol = Symbol('no-transform symbol'); + const continuationSymbol = Symbol.for('compositeFrom: continuation symbol'); + const noTransformSymbol = Symbol.for('compositeFrom: no-transform symbol'); function _computeOrTransform(initialValue, initialDependencies, continuationIfApplicable) { const expectingTransform = initialValue !== noTransformSymbol; @@ -634,21 +637,83 @@ export function compositeFrom(firstArg, secondArg) { const callingTransformForThisStep = expectingTransform && expose.transform; + let continuationStorage; + const filteredDependencies = _filterDependencies(availableDependencies, expose); - const {continuation, continuationStorage} = _prepareContinuation(callingTransformForThisStep); debug(() => [ `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, `with dependencies:`, filteredDependencies]); - const result = + let result; + + const getExpectedEvaluation = () => (callingTransformForThisStep ? (filteredDependencies - ? expose.transform(valueSoFar, filteredDependencies, continuation) - : expose.transform(valueSoFar, continuation)) + ? ['transform', valueSoFar, filteredDependencies] + : ['transform', valueSoFar]) : (filteredDependencies - ? expose.compute(filteredDependencies, continuation) - : expose.compute(continuation))); + ? ['compute', filteredDependencies] + : ['compute'])); + + const naturalEvaluate = () => { + const [name, ...args] = getExpectedEvaluation(); + let continuation; + ({continuation, continuationStorage} = _prepareContinuation(callingTransformForThisStep)); + return expose[name](...args, continuation); + } + + switch (step.cache) { + // Warning! Highly WIP! + case 'aggressive': { + const hrnow = () => { + const hrTime = process.hrtime(); + return hrTime[0] * 1000000000 + hrTime[1]; + }; + + const [name, ...args] = getExpectedEvaluation(); + + let cache = globalCompositeCache[step.annotation]; + if (!cache) { + cache = globalCompositeCache[step.annotation] = { + transform: new TupleMap(), + compute: new TupleMap(), + times: { + read: [], + evaluate: [], + }, + }; + } + + const tuplefied = args + .flatMap(arg => [ + Symbol.for('compositeFrom: tuplefied arg divider'), + ...(typeof arg !== 'object' || Array.isArray(arg) + ? [arg] + : Object.entries(arg).flat()), + ]); + + const readTime = hrnow(); + const cacheContents = cache[name].get(tuplefied); + cache.times.read.push(hrnow() - readTime); + + if (cacheContents) { + ({result, continuationStorage} = cacheContents); + } else { + const evaluateTime = hrnow(); + result = naturalEvaluate(); + cache.times.evaluate.push(hrnow() - evaluateTime); + cache[name].set(tuplefied, {result, continuationStorage}); + } + + break; + } + + default: { + result = naturalEvaluate(); + break; + } + } if (result !== continuationSymbol) { debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); @@ -775,6 +840,7 @@ export function compositeFrom(firstArg, secondArg) { if (baseComposes) { if (anyStepsTransform) expose.transform = transformFn; if (anyStepsCompute) expose.compute = computeFn; + if (base.cacheComposition) expose.cache = base.cacheComposition; } else if (baseUpdates) { expose.transform = transformFn; } else { @@ -785,6 +851,35 @@ export function compositeFrom(firstArg, secondArg) { return constructedDescriptor; } +export function displayCompositeCacheAnalysis() { + const showTimes = (cache, key) => { + const times = cache.times[key].slice().sort(); + + const all = times; + const worst10pc = times.slice(-times.length / 10); + const best10pc = times.slice(0, times.length / 10); + const middle50pc = times.slice(times.length / 4, -times.length / 4); + const middle80pc = times.slice(times.length / 10, -times.length / 10); + + const fmt = val => `${(val / 1000).toFixed(2)}ms`.padStart(9); + const avg = times => times.reduce((a, b) => a + b, 0) / times.length; + + const left = ` - ${key}: `; + const indn = ' '.repeat(left.length); + console.log(left + `${fmt(avg(all))} (all ${all.length})`); + console.log(indn + `${fmt(avg(worst10pc))} (worst 10%)`); + console.log(indn + `${fmt(avg(best10pc))} (best 10%)`); + console.log(indn + `${fmt(avg(middle80pc))} (middle 80%)`); + console.log(indn + `${fmt(avg(middle50pc))} (middle 50%)`); + }; + + for (const [annotation, cache] of Object.entries(globalCompositeCache)) { + console.log(`Cached ${annotation}:`); + showTimes(cache, 'evaluate'); + showTimes(cache, 'read'); + } +} + // Evaluates a function with composite debugging enabled, turns debugging // off again, and returns the result of the function. This is mostly syntax // sugar, but also helps avoid unit tests avoid accidentally printing debug diff --git a/src/data/things/thing.js b/src/data/things/thing.js index b1a9a802..19954b19 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -512,7 +512,7 @@ export function withResolvedReferenceList({ throw new TypeError(`Expected notFoundMode to be filter, exit, or null`); } - return compositeFrom(`withResolvedReferenceList`, [ + const composite = compositeFrom(`withResolvedReferenceList`, [ exitWithoutDependency({ dependency: data, value: [], @@ -526,13 +526,19 @@ export function withResolvedReferenceList({ }), { - mapDependencies: {list, data}, - options: {findFunction}, - - compute: ({list, data, '#options': {findFunction}}, continuation) => - continuation({ - '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})), - }), + cache: 'aggressive', + annotation: `withResolvedReferenceList.getMatches`, + flags: {expose: true, compose: true}, + + compute: { + mapDependencies: {list, data}, + options: {findFunction}, + + compute: ({list, data, '#options': {findFunction}}, continuation) => + continuation({ + '#matches': list.map(ref => findFunction(ref, data, {mode: 'quiet'})), + }), + }, }, { @@ -569,6 +575,9 @@ export function withResolvedReferenceList({ }, }, ]); + + console.log(composite.expose); + return composite; } // Check out the info on reverseReferenceList! diff --git a/src/upd8.js b/src/upd8.js index f6091ca2..7f423271 100755 --- a/src/upd8.js +++ b/src/upd8.js @@ -38,6 +38,7 @@ import {fileURLToPath} from 'node:url'; import wrap from 'word-wrap'; +import {displayCompositeCacheAnalysis} from '#composite'; import {processLanguageFile} from '#language'; import {isMain, traverse} from '#node-utils'; import bootRepl from '#repl'; @@ -612,6 +613,10 @@ async function main() { // which are only available after the initial linking. sortWikiDataArrays(wikiData); + console.log( + CacheableObject.getUpdateValue(wikiData.albumData[0], 'trackSections'), + wikiData.albumData[0].trackSections); + if (precacheData) { progressCallAll('Caching all data values', Object.entries(wikiData) .filter(([key]) => @@ -625,6 +630,11 @@ async function main() { .map(thing => () => CacheableObject.cacheAllExposedProperties(thing))); } + if (noBuild) { + displayCompositeCacheAnalysis(); + if (precacheData) return; + } + const internalDefaultLanguage = await processLanguageFile( path.join(__dirname, DEFAULT_STRINGS_FILE)); @@ -754,7 +764,9 @@ async function main() { logInfo`Done preloading filesizes!`; - if (noBuild) return; + if (noBuild) { + return; + } const developersComment = ``; - return selectedBuildMode.go({ - cliOptions, - dataPath, - mediaPath, - queueSize, - srcRootPath: __dirname, - - defaultLanguage: finalDefaultLanguage, - languages, - missingImagePaths, - thumbsCache, - urls, - urlSpec, - wikiData, - - cachebust: '?' + CACHEBUST, - developersComment, - getSizeOfAdditionalFile, - getSizeOfImagePath, - niceShowAggregate, - }); + stepStatusSummary.performBuild.status = STATUS_STARTED_NOT_DONE; + + let buildModeResult; + + try { + buildModeResult = await selectedBuildMode.go({ + cliOptions, + dataPath, + mediaPath, + queueSize, + srcRootPath: __dirname, + + defaultLanguage: finalDefaultLanguage, + languages, + missingImagePaths, + thumbsCache, + urls, + urlSpec, + wikiData, + + cachebust: '?' + CACHEBUST, + developersComment, + getSizeOfAdditionalFile, + getSizeOfImagePath, + niceShowAggregate, + }); + } catch (error) { + console.error(error); + + logError`There was a JavaScript error performing the build.`; + fileIssue(); + + Object.assign(stepStatusSummary.performBuild, { + status: STATUS_FATAL_ERROR, + message: `javascript error - view log for details`, + }); + + return false; + } + + if (buildModeResult !== true) { + Object.assign(stepStatusSummary.performBuild, { + status: STATUS_HAS_WARNINGS, + message: `may not have completed - view log for details`, + }); + + return false; + } + + stepStatusSummary.performBuild.status = STATUS_DONE_CLEAN; + + return true; } // TODO: isMain detection isn't consistent across platforms here @@ -834,6 +1103,65 @@ if (true || isMain(import.meta.url) || path.basename(process.argv[1]) === 'hsmus } } + if (showStepStatusSummary) { + console.error(colors.bright(`Step summary:`)); + + const longestNameLength = + Math.max(... + Object.values(stepStatusSummary) + .map(({name}) => name.length)); + + const anyStepsNotClean = + Object.values(stepStatusSummary) + .some(({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}`; + if (annotation) { + message += ` (${annotation})`; + } + + switch (status) { + case STATUS_DONE_CLEAN: + console.error(colors.green(message)); + break; + + case STATUS_NOT_STARTED: + case STATUS_NOT_APPLICABLE: + console.error(colors.dim(message)); + break; + + case STATUS_HAS_WARNINGS: + case STATUS_STARTED_NOT_DONE: + console.error(colors.yellow(message)); + break; + + case STATUS_FATAL_ERROR: + console.error(colors.red(message)); + break; + + default: + console.error(message); + break; + } + } + + if (result === true) { + if (anyStepsNotClean) { + console.error(colors.bright(`Final output is true, but some steps aren't clean.`)); + process.exit(1); + return; + } else { + console.error(colors.bright(`Final output is true and all steps are clean.`)); + } + } else { + console.error(colors.bright(`Final output is not true (${result}).`)); + } + } + if (result !== true) { process.exit(1); return; -- cgit 1.3.0-6-gf8a5