« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src/data
diff options
context:
space:
mode:
Diffstat (limited to 'src/data')
-rw-r--r--src/data/composite/wiki-properties/additionalNameList.js13
-rw-r--r--src/data/composite/wiki-properties/index.js1
-rw-r--r--src/data/language.js14
-rw-r--r--src/data/things/language.js40
-rw-r--r--src/data/things/track.js2
-rw-r--r--src/data/things/validators.js114
-rw-r--r--src/data/yaml.js97
7 files changed, 225 insertions, 56 deletions
diff --git a/src/data/composite/wiki-properties/additionalNameList.js b/src/data/composite/wiki-properties/additionalNameList.js
new file mode 100644
index 00000000..d1302224
--- /dev/null
+++ b/src/data/composite/wiki-properties/additionalNameList.js
@@ -0,0 +1,13 @@
+// A list of additional names! These can be used for a variety of purposes,
+// e.g. providing extra searchable titles, localizations, romanizations or
+// original titles, and so on. Each item has a name and, optionally, a
+// descriptive annotation.
+
+import {isAdditionalNameList} from '#validators';
+
+export default function() {
+  return {
+    flags: {update: true, expose: true},
+    update: {validate: isAdditionalNameList},
+  };
+}
diff --git a/src/data/composite/wiki-properties/index.js b/src/data/composite/wiki-properties/index.js
index 3a8b51d5..17d51bb8 100644
--- a/src/data/composite/wiki-properties/index.js
+++ b/src/data/composite/wiki-properties/index.js
@@ -4,6 +4,7 @@
 // #composite/data, and #composite/wiki-data.
 
 export {default as additionalFiles} from './additionalFiles.js';
+export {default as additionalNameList} from './additionalNameList.js';
 export {default as color} from './color.js';
 export {default as commentary} from './commentary.js';
 export {default as commentatorArtists} from './commentatorArtists.js';
diff --git a/src/data/language.js b/src/data/language.js
index 3fc14da7..6f774f27 100644
--- a/src/data/language.js
+++ b/src/data/language.js
@@ -7,15 +7,11 @@ import chokidar from 'chokidar';
 import he from 'he'; // It stands for "HTML Entities", apparently. Cursed.
 import yaml from 'js-yaml';
 
-import T from '#things';
+import {externalLinkSpec} from '#external-links';
 import {colors, logWarn} from '#cli';
-
-import {
-  annotateError,
-  annotateErrorWithFile,
-  showAggregate,
-  withAggregate,
-} from '#sugar';
+import {annotateError, annotateErrorWithFile, showAggregate, withAggregate}
+  from '#sugar';
+import T from '#things';
 
 const {Language} = T;
 
@@ -114,6 +110,8 @@ export function initializeLanguageObject() {
   language.escapeHTML = string =>
     he.encode(string, {useNamedReferences: true});
 
+  language.externalLinkSpec = externalLinkSpec;
+
   return language;
 }
 
diff --git a/src/data/things/language.js b/src/data/things/language.js
index 646eb6d1..70481299 100644
--- a/src/data/things/language.js
+++ b/src/data/things/language.js
@@ -1,5 +1,13 @@
-import {Tag} from '#html';
 import {isLanguageCode} from '#validators';
+import {Tag} from '#html';
+
+import {
+  getExternalLinkStringOfStyleFromDescriptors,
+  getExternalLinkStringsFromDescriptors,
+  isExternalLinkContext,
+  isExternalLinkSpec,
+  isExternalLinkStyle,
+} from '#external-links';
 
 import {
   externalFunction,
@@ -72,6 +80,13 @@ export class Language extends Thing {
       update: {validate: (t) => typeof t === 'object'},
     },
 
+    // List of descriptors for providing to external link utilities when using
+    // language.formatExternalLink - refer to util/external-links.js for info.
+    externalLinkSpec: {
+      flags: {update: true, expose: true},
+      update: {validate: isExternalLinkSpec},
+    },
+
     // Update only
 
     escapeHTML: externalFunction(),
@@ -299,6 +314,29 @@ export class Language extends Thing {
       : duration;
   }
 
+  formatExternalLink(url, {
+    style = 'normal',
+    context = 'generic',
+  } = {}) {
+    if (!this.externalLinkSpec) {
+      throw new TypeError(`externalLinkSpec unavailable`);
+    }
+
+    isExternalLinkContext(context);
+
+    if (style === 'all') {
+      return getExternalLinkStringsFromDescriptors(url, this.externalLinkSpec, {
+        language: this,
+        context,
+      });
+    }
+
+    return getExternalLinkStringOfStyleFromDescriptors(url, style, this.externalLinkSpec, {
+      language: this,
+      context,
+    });
+  }
+
   formatIndex(value) {
     this.assertIntlAvailable('intl_pluralOrdinal');
     return this.formatString('count.index.' + this.intl_pluralOrdinal.select(value), {index: value});
diff --git a/src/data/things/track.js b/src/data/things/track.js
index 8d310611..f6320677 100644
--- a/src/data/things/track.js
+++ b/src/data/things/track.js
@@ -24,6 +24,7 @@ import {
 
 import {
   additionalFiles,
+  additionalNameList,
   commentary,
   commentatorArtists,
   contributionList,
@@ -63,6 +64,7 @@ export class Track extends Thing {
 
     name: name('Unnamed Track'),
     directory: directory(),
+    additionalNames: additionalNameList(),
 
     duration: duration(),
     urls: urls(),
diff --git a/src/data/things/validators.js b/src/data/things/validators.js
index f60c363c..55eedbcf 100644
--- a/src/data/things/validators.js
+++ b/src/data/things/validators.js
@@ -1,7 +1,12 @@
 import {inspect as nodeInspect} from 'node:util';
 
+// Heresy.
+import printable_characters from 'printable-characters';
+const {strlen} = printable_characters;
+
 import {colors, ENABLE_COLOR} from '#cli';
-import {empty, typeAppearance, withAggregate} from '#sugar';
+import {cut, empty, typeAppearance, withAggregate} from '#sugar';
+import {commentaryRegex} from '#wiki-data';
 
 function inspect(value) {
   return nodeInspect(value, {colors: ENABLE_COLOR});
@@ -96,7 +101,10 @@ export function isStringNonEmpty(value) {
 }
 
 export function optional(validator) {
-  return value => value === null || value === undefined || validator(value);
+  return value =>
+    value === null ||
+    value === undefined ||
+    validator(value);
 }
 
 // Complex types (non-primitives)
@@ -166,29 +174,42 @@ export function is(...values) {
 }
 
 function validateArrayItemsHelper(itemValidator) {
-  return (item, index) => {
+  return (item, index, array) => {
     try {
-      const value = itemValidator(item);
+      const value = itemValidator(item, index, array);
 
       if (value !== true) {
         throw new Error(`Expected validator to return true`);
       }
     } catch (error) {
-      error.message = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)}) ${error.message}`;
+      const annotation = `(index: ${colors.yellow(`${index}`)}, item: ${inspect(item)})`;
+
+      error.message =
+        (error.message.includes('\n') || strlen(annotation) > 20
+          ? annotation + '\n' +
+            error.message
+              .split('\n')
+              .map(line => `  ${line}`)
+              .join('\n')
+          : `${annotation} ${error}`);
+
       error[Symbol.for('hsmusic.decorate.indexInSourceArray')] = index;
+
       throw error;
     }
   };
 }
 
 export function validateArrayItems(itemValidator) {
-  const fn = validateArrayItemsHelper(itemValidator);
+  const helper = validateArrayItemsHelper(itemValidator);
 
   return (array) => {
     isArray(array);
 
-    withAggregate({message: 'Errors validating array items'}, ({wrap}) => {
-      array.forEach(wrap(fn));
+    withAggregate({message: 'Errors validating array items'}, ({call}) => {
+      for (let index = 0; index < array.length; index++) {
+        call(helper, array[index], index, array);
+      }
     });
 
     return true;
@@ -200,12 +221,12 @@ export function strictArrayOf(itemValidator) {
 }
 
 export function sparseArrayOf(itemValidator) {
-  return validateArrayItems(item => {
+  return validateArrayItems((item, index, array) => {
     if (item === false || item === null) {
       return true;
     }
 
-    return itemValidator(item);
+    return itemValidator(item, index, array);
   });
 }
 
@@ -231,18 +252,56 @@ export function isColor(color) {
   throw new TypeError(`Unknown color format`);
 }
 
-export function isCommentary(commentary) {
-  isString(commentary);
+export function isCommentary(commentaryText) {
+  isString(commentaryText);
 
-  const [firstLine] = commentary.match(/.*/);
-  if (!firstLine.replace(/<\/b>/g, '').includes(':</i>')) {
-    throw new TypeError(`Missing commentary citation: "${
-      firstLine.length > 40
-        ? firstLine.slice(0, 40) + '...'
-        : firstLine
-    }"`);
+  const rawMatches =
+    Array.from(commentaryText.matchAll(commentaryRegex));
+
+  if (empty(rawMatches)) {
+    throw new TypeError(`Expected at least one commentary heading`);
   }
 
+  const niceMatches =
+    rawMatches.map(match => ({
+      position: match.index,
+      length: match[0].length,
+    }));
+
+  validateArrayItems(({position, length}, index) => {
+    if (index === 0 && position > 0) {
+      throw new TypeError(`Expected first commentary heading to be at top`);
+    }
+
+    const ownInput = commentaryText.slice(position, position + length);
+    const restOfInput = commentaryText.slice(position + length);
+    const nextLineBreak = restOfInput.indexOf('\n');
+    const upToNextLineBreak = restOfInput.slice(0, nextLineBreak);
+
+    if (/\S/.test(upToNextLineBreak)) {
+      throw new TypeError(
+        `Expected commentary heading to occupy entire line, got extra text:\n` +
+        `${colors.green(`"${cut(ownInput, 40)}"`)} (<- heading)\n` +
+        `(extra on same line ->) ${colors.red(`"${cut(upToNextLineBreak, 30)}"`)}\n` +
+        `(Check for missing "|-" in YAML, or a misshapen annotation)`);
+    }
+
+    const nextHeading =
+      (index === niceMatches.length - 1
+        ? commentaryText.length
+        : niceMatches[index + 1].position);
+
+    const upToNextHeading =
+      commentaryText.slice(position + length, nextHeading);
+
+    if (!/\S/.test(upToNextHeading)) {
+      throw new TypeError(
+        `Expected commentary entry to have body text, only got a heading`);
+    }
+
+    return true;
+  })(niceMatches);
+
   return true;
 }
 
@@ -285,20 +344,14 @@ export function validateProperties(spec) {
 
 export const isContribution = validateProperties({
   who: isArtistRef,
-  what: (value) =>
-    value === undefined ||
-    value === null ||
-    isStringNonEmpty(value),
+  what: optional(isStringNonEmpty),
 });
 
 export const isContributionList = validateArrayItems(isContribution);
 
 export const isAdditionalFile = validateProperties({
   title: isString,
-  description: (value) =>
-    value === undefined ||
-    value === null ||
-    isString(value),
+  description: optional(isStringNonEmpty),
   files: validateArrayItems(isString),
 });
 
@@ -376,6 +429,13 @@ export function isURL(string) {
   return true;
 }
 
+export const isAdditionalName = validateProperties({
+  name: isName,
+  annotation: optional(isStringNonEmpty),
+});
+
+export const isAdditionalNameList = validateArrayItems(isAdditionalName);
+
 export function validateReference(type = 'track') {
   return (ref) => {
     isStringNonEmpty(ref);
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 0734d539..2c600341 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -38,8 +38,8 @@ import {
 
 // --> General supporting stuff
 
-function inspect(value) {
-  return nodeInspect(value, {colors: ENABLE_COLOR});
+function inspect(value, opts = {}) {
+  return nodeInspect(value, {colors: ENABLE_COLOR, ...opts});
 }
 
 // --> YAML data repository structure constants
@@ -308,7 +308,12 @@ export class FieldCombinationError extends Error {
   constructor(fields, message) {
     const fieldNames = Object.keys(fields);
 
-    const mainMessage = `Don't combine ${fieldNames.map(field => colors.red(field)).join(', ')}`;
+    const fieldNamesText =
+      fieldNames
+        .map(field => colors.red(field))
+        .join(', ');
+
+    const mainMessage = `Don't combine ${fieldNamesText}`;
 
     const causeMessage =
       (typeof message === 'function'
@@ -329,8 +334,15 @@ export class FieldCombinationError extends Error {
 }
 
 export class FieldValueAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
   constructor(thingConstructor, errors) {
-    super(errors, `Errors processing field values for ${colors.green(thingConstructor.name)}`);
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing field values for ${constructorText}`);
   }
 }
 
@@ -341,8 +353,17 @@ export class FieldValueError extends Error {
         ? caughtError.cause
         : caughtError);
 
+    const fieldText =
+      colors.green(`"${field}"`);
+
+    const propertyText =
+      colors.green(property);
+
+    const valueText =
+      inspect(value, {maxStringLength: 40});
+
     super(
-      `Failed to set ${colors.green(`"${field}"`)} field (${colors.green(property)}) to ${inspect(value)}`,
+      `Failed to set ${fieldText} field (${propertyText}) to ${valueText}`,
       {cause});
   }
 }
@@ -354,13 +375,18 @@ export class SkippedFieldsSummaryError extends Error {
     const lines =
       entries.map(([field, value]) =>
         ` - ${field}: ` +
-        inspect(value)
+        inspect(value, {maxStringLength: 70})
           .split('\n')
           .map((line, index) => index === 0 ? line : `   ${line}`)
           .join('\n'));
 
+    const numFieldsText =
+      (entries.length === 1
+        ? `1 field`
+        : `${entries.length} fields`);
+
     super(
-      colors.bright(colors.yellow(`Altogether, skipped ${entries.length === 1 ? `1 field` : `${entries.length} fields`}:\n`)) +
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
       lines.join('\n') + '\n' +
       colors.bright(colors.yellow(`See above errors for details.`)));
   }
@@ -436,6 +462,7 @@ export const processTrackSectionDocument = makeProcessDocument(T.TrackSectionHel
 
 export const processTrackDocument = makeProcessDocument(T.Track, {
   fieldTransformations: {
+    'Additional Names': parseAdditionalNames,
     'Duration': parseDuration,
 
     'Date First Released': (value) => new Date(value),
@@ -457,6 +484,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, {
   propertyFieldMapping: {
     name: 'Track',
     directory: 'Directory',
+    additionalNames: 'Additional Names',
     duration: 'Duration',
     color: 'Color',
     urls: 'URLs',
@@ -717,26 +745,52 @@ export function parseAdditionalFiles(array) {
   }));
 }
 
-export function parseContributors(contributors) {
+const extractAccentRegex =
+  /^(?<main>.*?)(?: \((?<accent>.*)\))?$/;
+
+export function parseContributors(contributionStrings) {
   // If this isn't something we can parse, just return it as-is.
   // The Thing object's validators will handle the data error better
   // than we're able to here.
-  if (!Array.isArray(contributors)) {
-    return contributors;
+  if (!Array.isArray(contributionStrings)) {
+    return contributionStrings;
   }
 
-  contributors = contributors.map((contrib) => {
-    if (typeof contrib !== 'string') return contrib;
+  return contributionStrings.map(item => {
+    if (typeof item === 'object' && item['Who'])
+      return {who: item['Who'], what: item['What'] ?? null};
+
+    if (typeof item !== 'string') return item;
 
-    const match = contrib.match(/^(.*?)( \((.*)\))?$/);
-    if (!match) return contrib;
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
 
-    const who = match[1];
-    const what = match[3] || null;
-    return {who, what};
+    return {
+      who: match.groups.main,
+      what: match.groups.accent ?? null,
+    };
   });
+}
+
+export function parseAdditionalNames(additionalNameStrings) {
+  if (!Array.isArray(additionalNameStrings)) {
+    return additionalNameStrings;
+  }
+
+  return additionalNameStrings.map(item => {
+    if (typeof item === 'object' && item['Name'])
+      return {name: item['Name'], annotation: item['Annotation'] ?? null};
 
-  return contributors;
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match) return item;
+
+    return {
+      name: match.groups.main,
+      annotation: match.groups.accent ?? null,
+    };
+  });
 }
 
 function parseDimensions(string) {
@@ -1138,7 +1192,10 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
   for (const dataStep of dataSteps) {
     await processDataAggregate.nestAsync(
-      {message: `Errors during data step: ${colors.bright(dataStep.title)}`},
+      {
+        message: `Errors during data step: ${colors.bright(dataStep.title)}`,
+        translucent: true,
+      },
       async ({call, callAsync, map, mapAsync, push}) => {
         const {documentMode} = dataStep;
 
@@ -1383,7 +1440,7 @@ export async function loadAndProcessDataDocuments({dataPath}) {
 
         switch (documentMode) {
           case documentModes.headerAndEntries:
-            map(yamlResults, {message: `Errors processing documents in data files`},
+            map(yamlResults, {message: `Errors processing documents in data files`, translucent: true},
               decorateErrorWithFile(({documents}) => {
                 const headerDocument = documents[0];
                 const entryDocuments = documents.slice(1).filter(Boolean);