« 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/yaml.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/data/yaml.js')
-rw-r--r--src/data/yaml.js746
1 files changed, 682 insertions, 64 deletions
diff --git a/src/data/yaml.js b/src/data/yaml.js
index 7e470531..50317238 100644
--- a/src/data/yaml.js
+++ b/src/data/yaml.js
@@ -13,6 +13,7 @@ import Thing from '#thing';
 import thingConstructors from '#things';
 
 import {
+  aggregateThrows,
   annotateErrorWithFile,
   decorateErrorWithIndex,
   decorateErrorWithAnnotation,
@@ -30,8 +31,10 @@ import {
   atOffset,
   empty,
   filterProperties,
+  getNestedProp,
   stitchArrays,
   typeAppearance,
+  unique,
   withEntries,
 } from '#sugar';
 
@@ -86,6 +89,10 @@ function makeProcessDocument(thingConstructor, {
   // A or B.
   //
   invalidFieldCombinations = [],
+
+  // Bouncing function used to process subdocuments: this is a function which
+  // in turn calls the appropriate *result of* makeProcessDocument.
+  processDocument: bouncer,
 }) {
   if (!thingConstructor) {
     throw new Error(`Missing Thing class`);
@@ -95,6 +102,10 @@ function makeProcessDocument(thingConstructor, {
     throw new Error(`Expected fields to be provided`);
   }
 
+  if (!bouncer) {
+    throw new Error(`Missing processDocument bouncer`);
+  }
+
   const knownFields = Object.keys(fieldSpecs);
 
   const ignoredFields =
@@ -142,9 +153,12 @@ function makeProcessDocument(thingConstructor, {
         : `document`);
 
     const aggregate = openAggregate({
+      ...aggregateThrows(ProcessDocumentError),
       message: `Errors processing ${constructorPart}` + namePart,
     });
 
+    const thing = Reflect.construct(thingConstructor, []);
+
     const documentEntries = Object.entries(document)
       .filter(([field]) => !ignoredFields.includes(field));
 
@@ -192,13 +206,50 @@ function makeProcessDocument(thingConstructor, {
 
     const fieldValues = {};
 
+    const subdocSymbol = Symbol('subdoc');
+    const subdocLayouts = {};
+
+    const isSubdocToken = value =>
+      typeof value === 'object' &&
+      value !== null &&
+      Object.hasOwn(value, subdocSymbol);
+
+    const transformUtilities = {
+      ...thingConstructors,
+
+      subdoc(documentType, data, {
+        bindInto = null,
+        provide = null,
+      } = {}) {
+        if (!documentType)
+          throw new Error(`Expected document type, got ${typeAppearance(documentType)}`);
+        if (!data)
+          throw new Error(`Expected data, got ${typeAppearance(data)}`);
+        if (typeof data !== 'object' || data === null)
+          throw new Error(`Expected data to be an object, got ${typeAppearance(data)}`);
+        if (typeof bindInto !== 'string' && bindInto !== null)
+          throw new Error(`Expected bindInto to be a string, got ${typeAppearance(bindInto)}`);
+        if (typeof provide !== 'object' && provide !== null)
+          throw new Error(`Expected provide to be an object, got ${typeAppearance(provide)}`);
+
+        return {
+          [subdocSymbol]: {
+            documentType,
+            data,
+            bindInto,
+            provide,
+          },
+        };
+      },
+    };
+
     for (const [field, documentValue] of documentEntries) {
       if (skippedFields.has(field)) continue;
 
       // This variable would like to certify itself as "not into capitalism".
       let propertyValue =
         (fieldSpecs[field].transform
-          ? fieldSpecs[field].transform(documentValue)
+          ? fieldSpecs[field].transform(documentValue, transformUtilities)
           : documentValue);
 
       // Completely blank items in a YAML list are read as null.
@@ -221,10 +272,99 @@ function makeProcessDocument(thingConstructor, {
         }
       }
 
+      if (isSubdocToken(propertyValue)) {
+        subdocLayouts[field] = propertyValue[subdocSymbol];
+        continue;
+      }
+
+      if (Array.isArray(propertyValue) && propertyValue.every(isSubdocToken)) {
+        subdocLayouts[field] =
+          propertyValue
+            .map(token => token[subdocSymbol]);
+        continue;
+      }
+
       fieldValues[field] = propertyValue;
     }
 
-    const thing = Reflect.construct(thingConstructor, []);
+    const subdocErrors = [];
+
+    const followSubdocSetup = setup => {
+      let error = null;
+
+      let subthing;
+      try {
+        const result = bouncer(setup.data, setup.documentType);
+        subthing = result.thing;
+        result.aggregate.close();
+      } catch (caughtError) {
+        error = caughtError;
+      }
+
+      if (subthing) {
+        if (setup.bindInto) {
+          subthing[setup.bindInto] = thing;
+        }
+
+        if (setup.provide) {
+          Object.assign(subthing, setup.provide);
+        }
+      }
+
+      return {error, subthing};
+    };
+
+    for (const [field, layout] of Object.entries(subdocLayouts)) {
+      if (Array.isArray(layout)) {
+        const subthings = [];
+        let anySucceeded = false;
+        let anyFailed = false;
+
+        for (const [index, setup] of layout.entries()) {
+          const {subthing, error} = followSubdocSetup(setup);
+          if (error) {
+            subdocErrors.push(new SubdocError(
+              {field, index},
+              setup,
+              {cause: error}));
+          }
+
+          if (subthing) {
+            subthings.push(subthing);
+            anySucceeded = true;
+          } else {
+            anyFailed = true;
+          }
+        }
+
+        if (anySucceeded) {
+          fieldValues[field] = subthings;
+        } else if (anyFailed) {
+          skippedFields.add(field);
+        }
+      } else {
+        const setup = layout;
+        const {subthing, error} = followSubdocSetup(setup);
+
+        if (error) {
+          subdocErrors.push(new SubdocError(
+            {field},
+            setup,
+            {cause: error}));
+        }
+
+        if (subthing) {
+          fieldValues[field] = subthing;
+        } else {
+          skippedFields.add(field);
+        }
+      }
+    }
+
+    if (!empty(subdocErrors)) {
+      aggregate.push(new SubdocAggregateError(
+        subdocErrors, thingConstructor));
+    }
 
     const fieldValueErrors = [];
 
@@ -258,6 +398,8 @@ function makeProcessDocument(thingConstructor, {
   });
 }
 
+export class ProcessDocumentError extends AggregateError {}
+
 export class UnknownFieldsError extends Error {
   constructor(fields) {
     super(`Unknown fields ignored: ${fields.map(field => colors.red(field)).join(', ')}`);
@@ -345,12 +487,46 @@ export class SkippedFieldsSummaryError extends Error {
         : `${entries.length} fields`);
 
     super(
-      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:\n`)) +
+      colors.bright(colors.yellow(`Altogether, skipped ${numFieldsText}:`)) + '\n' +
       lines.join('\n') + '\n' +
       colors.bright(colors.yellow(`See above errors for details.`)));
   }
 }
 
+export class SubdocError extends Error {
+  constructor({field, index = null}, setup, options) {
+    const fieldText =
+      (index === null
+        ? colors.green(`"${field}"`)
+        : colors.yellow(`#${index + 1}`) + ' in ' +
+          colors.green(`"${field}"`));
+
+    const constructorText =
+      setup.documentType.name;
+
+    if (options.cause instanceof ProcessDocumentError) {
+      options.cause[Symbol.for('hsmusic.aggregate.translucent')] = true;
+    }
+
+    super(
+      `Errors processing ${constructorText} for ${fieldText} field`,
+      options);
+  }
+}
+
+export class SubdocAggregateError extends AggregateError {
+  [Symbol.for('hsmusic.aggregate.translucent')] = true;
+
+  constructor(errors, thingConstructor) {
+    const constructorText =
+      colors.green(thingConstructor.name);
+
+    super(
+      errors,
+      `Errors processing subdocuments for ${constructorText}`);
+  }
+}
+
 export function parseDate(date) {
   return new Date(date);
 }
@@ -416,6 +592,9 @@ export function parseContributors(entries) {
       return {
         artist: item['Artist'],
         annotation: item['Annotation'] ?? null,
+
+        countInContributionTotals: item['Count In Contribution Totals'] ?? null,
+        countInDurationTotals: item['Count In Duration Totals'] ?? null,
       };
 
     if (typeof item !== 'string') return item;
@@ -444,8 +623,11 @@ export function parseAdditionalFiles(entries) {
 
 export function parseAdditionalNames(entries) {
   return parseArrayEntries(entries, item => {
-    if (typeof item === 'object' && item['Name'])
-      return {name: item['Name'], annotation: item['Annotation'] ?? null};
+    if (typeof item === 'object' && typeof item['Name'] === 'string')
+      return {
+        name: item['Name'],
+        annotation: item['Annotation'] ?? null,
+      };
 
     if (typeof item !== 'string') return item;
 
@@ -459,6 +641,35 @@ export function parseAdditionalNames(entries) {
   });
 }
 
+export function parseSerieses(entries) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item !== 'object') return item;
+
+    return {
+      name: item['Name'],
+      description: item['Description'] ?? null,
+      albums: item['Albums'] ?? null,
+
+      showAlbumArtists: item['Show Album Artists'] ?? null,
+    };
+  });
+}
+
+export function parseWallpaperParts(entries) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item !== 'object') return item;
+
+    return {
+      asset:
+        (item['Asset'] === 'none'
+          ? null
+          : item['Asset'] ?? null),
+
+      style: item['Style'] ?? null,
+    };
+  });
+}
+
 export function parseDimensions(string) {
   // It's technically possible to pass an array like [30, 40] through here.
   // That's not really an issue because if it isn't of the appropriate shape,
@@ -482,6 +693,137 @@ export function parseDimensions(string) {
   return nums;
 }
 
+export const contributionPresetYAMLSpec = [
+  {from: 'Album', to: 'album', fields: [
+    {from: 'Artists', to: 'artistContribs'},
+  ]},
+
+  {from: 'Flash', to: 'flash', fields: [
+    {from: 'Contributors', to: 'contributorContribs'},
+  ]},
+
+  {from: 'Track', to: 'track', fields: [
+    {from: 'Artists', to: 'artistContribs'},
+    {from: 'Contributors', to: 'contributorContribs'},
+  ]},
+];
+
+export function parseContributionPresetContext(context) {
+  if (!Array.isArray(context)) {
+    return context;
+  }
+
+  const [target, ...fields] = context;
+
+  const targetEntry =
+    contributionPresetYAMLSpec
+      .find(({from}) => from === target);
+
+  if (!targetEntry) {
+    return context;
+  }
+
+  const properties =
+    fields.map(field => {
+      const fieldEntry =
+        targetEntry.fields
+          .find(({from}) => from === field);
+
+      if (!fieldEntry) return field;
+
+      return fieldEntry.to;
+    });
+
+  return [targetEntry.to, ...properties];
+}
+
+export function parseContributionPresets(list) {
+  if (!Array.isArray(list)) return list;
+
+  return list.map(item => {
+    if (typeof item !== 'object') return item;
+
+    return {
+      annotation:
+        item['Annotation'] ?? null,
+
+      context:
+        parseContributionPresetContext(
+          item['Context'] ?? null),
+
+      countInContributionTotals:
+        item['Count In Contribution Totals'] ?? null,
+
+      countInDurationTotals:
+        item['Count In Duration Totals'] ?? null,
+    };
+  });
+}
+
+export function parseAnnotatedReferences(entries, {
+  referenceField = 'References',
+  annotationField = 'Annotation',
+  referenceProperty = 'reference',
+  annotationProperty = 'annotation',
+} = {}) {
+  return parseArrayEntries(entries, item => {
+    if (typeof item === 'object' && item[referenceField])
+      return {
+        [referenceProperty]: item[referenceField],
+        [annotationProperty]: item[annotationField] ?? null,
+      };
+
+    if (typeof item !== 'string') return item;
+
+    const match = item.match(extractAccentRegex);
+    if (!match)
+      return {
+        [referenceProperty]: item,
+        [annotationProperty]: null,
+      };
+
+    return {
+      [referenceProperty]: match.groups.main,
+      [annotationProperty]: match.groups.accent ?? null,
+    };
+  });
+}
+
+export function parseArtwork({
+  single = false,
+  dimensionsFromThingProperty = null,
+  fileExtensionFromThingProperty = null,
+  dateFromThingProperty = null,
+  artistContribsFromThingProperty = null,
+  artistContribsArtistProperty = null,
+  artTagsFromThingProperty = null,
+  referencedArtworksFromThingProperty = null,
+}) {
+  const provide = {
+    dimensionsFromThingProperty,
+    fileExtensionFromThingProperty,
+    dateFromThingProperty,
+    artistContribsFromThingProperty,
+    artistContribsArtistProperty,
+    artTagsFromThingProperty,
+    referencedArtworksFromThingProperty,
+  };
+
+  const parseSingleEntry = (entry, {subdoc, Artwork}) =>
+    subdoc(Artwork, entry, {bindInto: 'thing', provide});
+
+  const transform = (value, ...args) =>
+    (Array.isArray(value)
+      ? value.map(entry => parseSingleEntry(entry, ...args))
+   : single
+      ? parseSingleEntry(value, ...args)
+      : [parseSingleEntry(value, ...args)]);
+
+  transform.provide = provide;
+
+  return transform;
+}
+
 // documentModes: Symbols indicating sets of behavior for loading and processing
 // data files.
 export const documentModes = {
@@ -563,10 +905,17 @@ export function getAllDataSteps() {
 
   const steps = [];
 
+  const seenLoadingFns = new Set();
+
   for (const thingConstructor of Object.values(thingConstructors)) {
     const getSpecFn = thingConstructor[Thing.getYamlLoadingSpec];
     if (!getSpecFn) continue;
 
+    // Subclasses can expose literally the same static properties
+    // by inheritence. We don't want to double-count those!
+    if (seenLoadingFns.has(getSpecFn)) continue;
+    seenLoadingFns.add(getSpecFn);
+
     steps.push(getSpecFn({
       documentModes,
       thingConstructors,
@@ -759,7 +1108,7 @@ export function processThingsFromDataStep(documents, dataStep) {
         throw new Error(`Class "${thingClass.name}" doesn't specify Thing.yamlDocumentSpec`);
       }
 
-      fn = makeProcessDocument(thingClass, spec);
+      fn = makeProcessDocument(thingClass, {...spec, processDocument});
       submap.set(thingClass, fn);
     }
 
@@ -774,15 +1123,23 @@ export function processThingsFromDataStep(documents, dataStep) {
       const aggregate = openAggregate({message: `Errors processing documents`});
 
       documents.forEach(
-        decorateErrorWithIndex(document => {
+        decorateErrorWithIndex((document, index) => {
           const {thing, aggregate: subAggregate} =
             processDocument(document, dataStep.documentThing);
 
+          thing[Thing.yamlSourceDocument] = document;
+          thing[Thing.yamlSourceDocumentPlacement] =
+            [documentModes.allInOne, index];
+
           result.push(thing);
           aggregate.call(subAggregate.close);
         }));
 
-      return {aggregate, result};
+      return {
+        aggregate,
+        result,
+        things: result,
+      };
     }
 
     case documentModes.oneDocumentTotal: {
@@ -792,7 +1149,15 @@ export function processThingsFromDataStep(documents, dataStep) {
       const {thing, aggregate} =
         processDocument(documents[0], dataStep.documentThing);
 
-      return {aggregate, result: thing};
+      thing[Thing.yamlSourceDocument] = documents[0];
+      thing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.oneDocumentTotal];
+
+      return {
+        aggregate,
+        result: thing,
+        things: [thing],
+      };
     }
 
     case documentModes.headerAndEntries: {
@@ -807,6 +1172,10 @@ export function processThingsFromDataStep(documents, dataStep) {
       const {thing: headerThing, aggregate: headerAggregate} =
         processDocument(headerDocument, dataStep.headerDocumentThing);
 
+      headerThing[Thing.yamlSourceDocument] = headerDocument;
+      headerThing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.headerAndEntries, 'header'];
+
       try {
         headerAggregate.close();
       } catch (caughtError) {
@@ -820,6 +1189,10 @@ export function processThingsFromDataStep(documents, dataStep) {
         const {thing: entryThing, aggregate: entryAggregate} =
           processDocument(entryDocument, dataStep.entryDocumentThing);
 
+        entryThing[Thing.yamlSourceDocument] = entryDocument;
+        entryThing[Thing.yamlSourceDocumentPlacement] =
+          [documentModes.headerAndEntries, 'entry', index];
+
         entryThings.push(entryThing);
 
         try {
@@ -836,6 +1209,7 @@ export function processThingsFromDataStep(documents, dataStep) {
           header: headerThing,
           entries: entryThings,
         },
+        things: [headerThing, ...entryThings],
       };
     }
 
@@ -849,7 +1223,15 @@ export function processThingsFromDataStep(documents, dataStep) {
       const {thing, aggregate} =
         processDocument(documents[0], dataStep.documentThing);
 
-      return {aggregate, result: thing};
+      thing[Thing.yamlSourceDocument] = documents[0];
+      thing[Thing.yamlSourceDocumentPlacement] =
+        [documentModes.onePerFile];
+
+      return {
+        aggregate,
+        result: thing,
+        things: [thing],
+      };
     }
 
     default:
@@ -949,9 +1331,16 @@ export async function processThingsFromDataSteps(documentLists, fileLists, dataS
           file: files,
           documents: documentLists,
         }).map(({file, documents}) => {
-            const {result, aggregate} =
+            const {result, aggregate, things} =
               processThingsFromDataStep(documents, dataStep);
 
+            for (const thing of things) {
+              thing[Thing.yamlSourceFilename] =
+                path.relative(dataPath, file)
+                  .split(path.sep)
+                  .join(path.posix.sep);
+            }
+
             const close = decorateErrorWithFileFromDataPath(aggregate.close, {dataPath});
             aggregate.close = () => close({file});
 
@@ -1046,7 +1435,25 @@ export function saveThingsFromDataSteps(thingLists, dataSteps) {
     })
     .filter(Boolean)
     .forEach(saveResult => {
-      Object.assign(wikiData, saveResult);
+      for (const [saveKey, saveValue] of Object.entries(saveResult)) {
+        if (Object.hasOwn(wikiData, saveKey)) {
+          if (Array.isArray(wikiData[saveKey])) {
+            if (Array.isArray(saveValue)) {
+              wikiData[saveKey].push(...saveValue);
+            } else {
+              throw new Error(`${saveKey} already present, expected array of items to push`);
+            }
+          } else {
+            if (Array.isArray(saveValue)) {
+              throw new Error(`${saveKey} already present and not an array, refusing to overwrite`);
+            } else {
+              throw new Error(`${saveKey} already present, refusing to overwrite`);
+            }
+          }
+        } else {
+          wikiData[saveKey] = saveValue;
+        }
+      }
     });
 
   return {aggregate, result: wikiData};
@@ -1076,81 +1483,97 @@ export async function loadAndProcessDataDocuments(dataSteps, {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 and other expected behavior).
-export function linkWikiDataArrays(wikiData) {
+export function linkWikiDataArrays(wikiData, {bindFind, bindReverse}) {
   const linkWikiDataSpec = new Map([
-    [wikiData.albumData, [
-      'artTagData',
-      'artistData',
-      'groupData',
-    ]],
+    // entries must be present here even without any properties to explicitly
+    // link if the 'find' or 'reverse' properties will be implicitly linked
 
-    [wikiData.artTagData, [
-      'albumData',
-      'trackData',
+    ['albumData', [
+      'artworkData',
+      'wikiInfo',
     ]],
 
-    [wikiData.artistData, [
-      'albumData',
-      'artistData',
-      'flashData',
-      'trackData',
-    ]],
+    ['artTagData', [/* reverse */]],
 
-    [wikiData.flashData, [
-      'artistData',
-      'flashActData',
-      'trackData',
-    ]],
+    ['artistData', [/* find, reverse */]],
 
-    [wikiData.flashActData, [
-      'flashData',
-      'flashSideData',
-    ]],
+    ['artworkData', ['artworkData']],
 
-    [wikiData.flashSideData, [
-      'flashActData',
+    ['flashData', [
+      'wikiInfo',
     ]],
 
-    [wikiData.groupData, [
-      'albumData',
-      'groupCategoryData',
-    ]],
+    ['flashActData', [/* find, reverse */]],
 
-    [wikiData.groupCategoryData, [
-      'groupData',
-    ]],
+    ['flashSideData', [/* find */]],
 
-    [wikiData.homepageLayout?.rows, [
-      'albumData',
-      'groupData',
-    ]],
+    ['groupData', [/* find, reverse */]],
+
+    ['groupCategoryData', [/* find */]],
+
+    ['homepageLayout.sections.rows', [/* find */]],
 
-    [wikiData.trackData, [
-      'albumData',
-      'artTagData',
-      'artistData',
-      'flashData',
+    ['trackData', [
+      'artworkData',
       'trackData',
+      'wikiInfo',
     ]],
 
-    [[wikiData.wikiInfo], [
-      'groupData',
-    ]],
+    ['trackSectionData', [/* reverse */]],
+
+    ['wikiInfo', [/* find */]],
   ]);
 
-  for (const [things, keys] of linkWikiDataSpec.entries()) {
-    if (things === undefined) continue;
+  const constructorHasFindMap = new Map();
+  const constructorHasReverseMap = new Map();
+
+  const boundFind = bindFind(wikiData);
+  const boundReverse = bindReverse(wikiData);
+
+  for (const [thingDataProp, keys] of linkWikiDataSpec.entries()) {
+    const thingData = getNestedProp(wikiData, thingDataProp);
+    const things =
+      (Array.isArray(thingData)
+        ? thingData.flat(Infinity)
+        : [thingData]);
+
     for (const thing of things) {
       if (thing === undefined) continue;
+
+      let hasFind;
+      if (constructorHasFindMap.has(thing.constructor)) {
+        hasFind = constructorHasFindMap.get(thing.constructor);
+      } else {
+        hasFind = 'find' in thing;
+        constructorHasFindMap.set(thing.constructor, hasFind);
+      }
+
+      if (hasFind) {
+        thing.find = boundFind;
+      }
+
+      let hasReverse;
+      if (constructorHasReverseMap.has(thing.constructor)) {
+        hasReverse = constructorHasReverseMap.get(thing.constructor);
+      } else {
+        hasReverse = 'reverse' in thing;
+        constructorHasReverseMap.set(thing.constructor, hasReverse);
+      }
+
+      if (hasReverse) {
+        thing.reverse = boundReverse;
+      }
+
       for (const key of keys) {
         if (!(key in wikiData)) continue;
+
         thing[key] = wikiData[key];
       }
     }
   }
 }
 
-export function sortWikiDataArrays(dataSteps, wikiData) {
+export function sortWikiDataArrays(dataSteps, wikiData, {bindFind, bindReverse}) {
   for (const [key, value] of Object.entries(wikiData)) {
     if (!Array.isArray(value)) continue;
     wikiData[key] = value.slice();
@@ -1166,7 +1589,7 @@ export function sortWikiDataArrays(dataSteps, wikiData) {
   // slices instead of the original arrays) - this is so that the object
   // caching system understands that it's working with a new ordering.
   // We still need to actually provide those updated arrays over again!
-  linkWikiDataArrays(wikiData);
+  linkWikiDataArrays(wikiData, {bindFind, bindReverse});
 }
 
 // Utility function for loading all wiki data from the provided YAML data
@@ -1176,7 +1599,9 @@ export function sortWikiDataArrays(dataSteps, wikiData) {
 // where reporting info about data loading isn't as relevant as during the
 // main wiki build process.
 export async function quickLoadAllFromYAML(dataPath, {
+  find,
   bindFind,
+  bindReverse,
   getAllFindSpecs,
 
   showAggregate: customShowAggregate = showAggregate,
@@ -1201,7 +1626,7 @@ export async function quickLoadAllFromYAML(dataPath, {
     }
   }
 
-  linkWikiDataArrays(wikiData);
+  linkWikiDataArrays(wikiData, {bindFind, bindReverse});
 
   try {
     reportDirectoryErrors(wikiData, {getAllFindSpecs});
@@ -1212,7 +1637,7 @@ export async function quickLoadAllFromYAML(dataPath, {
   }
 
   try {
-    filterReferenceErrors(wikiData, {bindFind}).close();
+    filterReferenceErrors(wikiData, {find, bindFind}).close();
     logInfo`No reference errors found. (complete data)`;
   } catch (error) {
     showAggregate(error);
@@ -1227,7 +1652,200 @@ export async function quickLoadAllFromYAML(dataPath, {
     logWarn`Content text errors found.`;
   }
 
-  sortWikiDataArrays(dataSteps, wikiData);
+  sortWikiDataArrays(dataSteps, wikiData, {bindFind, bindReverse});
 
   return wikiData;
 }
+
+export function cruddilyGetAllThings(wikiData) {
+  const allThings = [];
+
+  for (const v of Object.values(wikiData)) {
+    if (Array.isArray(v)) {
+      allThings.push(...v);
+    } else {
+      allThings.push(v);
+    }
+  }
+
+  return allThings;
+}
+
+export function getThingLayoutForFilename(filename, wikiData) {
+  const things =
+    cruddilyGetAllThings(wikiData)
+      .filter(thing =>
+        thing[Thing.yamlSourceFilename] === filename);
+
+  if (empty(things)) {
+    return null;
+  }
+
+  const allDocumentModes =
+    unique(things.map(thing =>
+      thing[Thing.yamlSourceDocumentPlacement][0]));
+
+  if (allDocumentModes.length > 1) {
+    throw new Error(`More than one document mode for documents from ${filename}`);
+  }
+
+  const documentMode = allDocumentModes[0];
+
+  switch (documentMode) {
+    case documentModes.allInOne: {
+      return {
+        documentMode,
+        things:
+          things.sort((a, b) =>
+            a[Thing.yamlSourceDocumentPlacement][1] -
+            b[Thing.yamlSourceDocumentPlacement][1]),
+      };
+    }
+
+    case documentModes.oneDocumentTotal:
+    case documentModes.onePerFile: {
+      if (things.length > 1) {
+        throw new Error(`More than one document for ${filename}`);
+      }
+
+      return {
+        documentMode,
+        thing: things[0],
+      };
+    }
+
+    case documentModes.headerAndEntries: {
+      const headerThings =
+        things.filter(thing =>
+          thing[Thing.yamlSourceDocumentPlacement][1] === 'header');
+
+      if (headerThings.length > 1) {
+        throw new Error(`More than one header document for ${filename}`);
+      }
+
+      return {
+        documentMode,
+        headerThing: headerThings[0] ?? null,
+        entryThings:
+          things
+            .filter(thing =>
+              thing[Thing.yamlSourceDocumentPlacement][1] === 'entry')
+            .sort((a, b) =>
+              a[Thing.yamlSourceDocumentPlacement][2] -
+              b[Thing.yamlSourceDocumentPlacement][2]),
+      };
+    }
+
+    default: {
+      return {documentMode};
+    }
+  }
+}
+
+export function flattenThingLayoutToDocumentOrder(layout) {
+  switch (layout.documentMode) {
+    case documentModes.oneDocumentTotal:
+    case documentModes.onePerFile: {
+      if (layout.thing) {
+        return [0];
+      } else {
+        return [];
+      }
+    }
+
+    case documentModes.allInOne: {
+      const indices =
+        layout.things
+          .map(thing => thing[Thing.yamlSourceDocumentPlacement][1]);
+
+      return indices;
+    }
+
+    case documentModes.headerAndEntries: {
+      const entryIndices =
+        layout.entryThings
+          .map(thing => thing[Thing.yamlSourceDocumentPlacement][2])
+          .map(index => index + 1);
+
+      if (layout.headerThing) {
+        return [0, ...entryIndices];
+      } else {
+        return entryIndices;
+      }
+    }
+
+    default: {
+      throw new Error(`Unknown document mode`);
+    }
+  }
+}
+
+export function* splitDocumentsInYAMLSourceText(sourceText) {
+  const dividerRegex = /^-{3,}\n?/gm;
+  let previousDivider = '';
+
+  while (true) {
+    const {lastIndex} = dividerRegex;
+    const match = dividerRegex.exec(sourceText);
+    if (match) {
+      const nextDivider = match[0].trim();
+
+      yield {
+        previousDivider,
+        nextDivider,
+        text: sourceText.slice(lastIndex, match.index),
+      };
+
+      previousDivider = nextDivider;
+    } else {
+      const nextDivider = '';
+
+      yield {
+        previousDivider,
+        nextDivider,
+        text: sourceText.slice(lastIndex).replace(/(?<!\n)$/, '\n'),
+      };
+
+      return;
+    }
+  }
+}
+
+export function recombineDocumentsIntoYAMLSourceText(documents) {
+  const dividers =
+    unique(
+      documents
+        .flatMap(d => [d.previousDivider, d.nextDivider])
+        .filter(Boolean));
+
+  const divider = dividers[0];
+
+  if (dividers.length > 1) {
+    // TODO: Accommodate mixed dividers as best as we can lol
+    logWarn`Found multiple dividers in this file, using only ${divider}`;
+  }
+
+  let sourceText = '';
+
+  for (const document of documents) {
+    if (sourceText) {
+      sourceText += divider + '\n';
+    }
+
+    sourceText += document.text;
+  }
+
+  return sourceText;
+}
+
+export function reorderDocumentsInYAMLSourceText(sourceText, order) {
+  const sourceDocuments =
+    Array.from(splitDocumentsInYAMLSourceText(sourceText));
+
+  const sortedDocuments =
+    Array.from(
+      order,
+      sourceIndex => sourceDocuments[sourceIndex]);
+
+  return recombineDocumentsIntoYAMLSourceText(sortedDocuments);
+}