« get me outta code hell

hsmusic-wiki - HSMusic - static wiki software cataloguing collaborative creation
about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/thing/album.js43
-rw-r--r--src/thing/structures.js17
-rw-r--r--src/thing/thing.js66
-rw-r--r--src/util/sugar.js112
4 files changed, 238 insertions, 0 deletions
diff --git a/src/thing/album.js b/src/thing/album.js
new file mode 100644
index 0000000..1915ab8
--- /dev/null
+++ b/src/thing/album.js
@@ -0,0 +1,43 @@
+import Thing from './thing.js';
+
+import {
+    validateReference
+} from './structures.js';
+
+import {
+    showAggregate,
+    withAggregate
+} from '../util/sugar.js';
+
+export default class Album extends Thing {
+    #tracks;
+
+    static updateError = {
+        tracks: Thing.extendPropertyError('tracks')
+    };
+
+    update(source) {
+        withAggregate(({ wrap, call, map }) => {
+            if (source.tracks) {
+                this.#tracks = map(source.tracks, validateReference('track'), {
+                    errorClass: this.constructor.updateError.tracks
+                });
+            }
+        });
+    }
+}
+
+const album = new Album();
+
+try {
+    album.update({
+        tracks: [
+            'lol',
+            123,
+            'track:oh-yeah',
+            'group:what-am-i-doing-here'
+        ]
+    });
+} catch (error) {
+    showAggregate(error);
+}
diff --git a/src/thing/structures.js b/src/thing/structures.js
new file mode 100644
index 0000000..e6b9fd4
--- /dev/null
+++ b/src/thing/structures.js
@@ -0,0 +1,17 @@
+// Generic structure utilities common across various Thing types.
+
+export function validateReference(type = '') {
+    return ref => {
+        if (typeof ref !== 'string')
+            throw new TypeError(`Expected a string, got ${ref}`);
+
+        if (type) {
+            if (!ref.includes(':'))
+                throw new TypeError(`Expected ref to begin with "${type}:", but no type specified (ref: ${ref})`);
+
+            const typePart = ref.split(':')[0];
+            if (typePart !== type)
+                throw new TypeError(`Expected ref to begin with "${type}:", got "${typePart}:" (ref: ${ref})`);
+        }
+    };
+}
diff --git a/src/thing/thing.js b/src/thing/thing.js
new file mode 100644
index 0000000..c2465e3
--- /dev/null
+++ b/src/thing/thing.js
@@ -0,0 +1,66 @@
+// Base class for Things. No, we will not come up with a better name.
+// Sorry not sorry! :)
+//
+// NB: Since these methods all involve processing a variety of input data, some
+// of which will pass and some of which may fail, any failures should be thrown
+// together as an AggregateError. See util/sugar.js for utility functions to
+// make writing code around this easier!
+
+export default class Thing {
+    constructor(source, {
+        wikiData
+    } = {}) {
+        if (source) {
+            this.update(source);
+        }
+
+        if (wikiData && this.checkComplete()) {
+            this.postprocess({wikiData});
+        }
+    }
+
+    static PropertyError = class extends AggregateError {
+        #key = this.constructor.key;
+        get key() { return this.#key; }
+
+        constructor(errors) {
+            super(errors, '');
+            this.message = `${errors.length} error(s) in property "${this.#key}"`;
+        }
+    };
+
+    static extendPropertyError(key) {
+        const cls = class extends this.PropertyError {
+            static #key = key;
+            static get key() { return this.#key; }
+        };
+
+        Object.defineProperty(cls, 'name', {value: `PropertyError:${key}`});
+        return cls;
+    }
+
+    // Called when instantiating a thing, and when its data is updated for any
+    // reason. (Which currently includes no reasons, but hey, future-proofing!)
+    //
+    // Don't expect source to be a complete object, even on the first call - the
+    // method checkComplete() will prevent incomplete resources from being mixed
+    // with the rest.
+    update(source) {}
+
+    // Called when collecting the full list of available things of that type
+    // for wiki data; this method determine whether or not to include it.
+    //
+    // This should return whether or not the object is complete enough to be
+    // used across the wiki - not whether every optional attribute is provided!
+    // (That is, attributes required for postprocessing & basic page generation
+    // are all present.)
+    checkComplete() {}
+
+    // Called when adding the thing to the wiki data list, and when its source
+    // data is updated (provided checkComplete() passes).
+    //
+    // This should generate any cached object references, across other wiki
+    // data; for example, building an array of actual track objects
+    // corresponding to an album's track list ('track:cool-track' strings).
+    postprocess({wikiData}) {}
+}
diff --git a/src/util/sugar.js b/src/util/sugar.js
index 79a271b..54b2df0 100644
--- a/src/util/sugar.js
+++ b/src/util/sugar.js
@@ -87,3 +87,115 @@ export function bindOpts(fn, bind) {
 }
 
 bindOpts.bindIndex = Symbol();
+
+// Utility function for providing useful interfaces to the JS AggregateError
+// class.
+//
+// Generally, this works by returning a set of interfaces which operate on
+// functions: wrap() takes a function and returns a new function which passes
+// its arguments through and appends any resulting error to the internal error
+// list; call() simplifies this process by wrapping the provided function and
+// then calling it immediately. Once the process for which errors should be
+// aggregated is complete, close() constructs and throws an AggregateError
+// object containing all caught errors (or doesn't throw anything if there were
+// no errors).
+export function openAggregate({
+    // Constructor to use, defaulting to the builtin AggregateError class.
+    // Anything passed here should probably extend from that! May be used for
+    // letting callers programatically distinguish between multiple aggregate
+    // errors.
+    errorClass = AggregateError,
+
+    // Optional human-readable message to describe the aggregate error, if
+    // constructed.
+    message = '',
+
+    // Value to return when a provided function throws an error. (This is
+    // primarily useful when wrapping a function and then providing it to
+    // another utility, e.g. array.map().)
+    returnOnFail = null
+} = {}) {
+    const errors = [];
+
+    const aggregate = {};
+
+    aggregate.wrap = fn => (...args) => {
+        try {
+            return fn(...args);
+        } catch (error) {
+            errors.push(error);
+            return returnOnFail;
+        }
+    };
+
+    aggregate.call = (fn, ...args) => {
+        return aggregate.wrap(fn)(...args);
+    };
+
+    aggregate.map = (...args) => {
+        const parent = aggregate;
+        const { result, aggregate: child } = mapAggregate(...args);
+        parent.call(child.close);
+        return result;
+    };
+
+    aggregate.close = () => {
+        if (errors.length) {
+            throw Reflect.construct(errorClass, [errors, message]);
+        }
+    };
+
+    return aggregate;
+}
+
+// Performs an ordinary array map with the given function, collating into a
+// results array (with errored inputs filtered out) and an error aggregate.
+//
+// Note the aggregate property is the result of openAggregate(), still unclosed;
+// use aggregate.close() to throw the error. (This aggregate may be passed to a
+// parent aggregate: `parent.call(aggregate.close)`!)
+export function mapAggregate(array, fn, aggregateOpts) {
+    const failureSymbol = Symbol();
+
+    const aggregate = openAggregate({
+        ...aggregateOpts,
+        returnOnFail: failureSymbol
+    });
+
+    const result = array.map(aggregate.wrap(fn))
+        .filter(value => value !== failureSymbol);
+
+    return {result, aggregate};
+}
+
+// Totally sugar function for opening an aggregate, running the provided
+// function with it, then closing the function and returning the result (if
+// there's no throw).
+export function withAggregate(aggregateOpts, fn) {
+    if (typeof aggregateOpts === 'function') {
+        fn = aggregateOpts;
+        aggregateOpts = {};
+    }
+
+    const aggregate = openAggregate(aggregateOpts);
+    const result = fn(aggregate);
+    aggregate.close();
+    return result;
+}
+
+export function showAggregate(topError) {
+    const recursive = error => {
+        const header = `[${error.constructor.name || 'unnamed'}] ${error.message || '(no message)'}`;
+        if (error instanceof AggregateError) {
+            return header + '\n' + (error.errors
+                .map(recursive)
+                .flatMap(str => str.split('\n'))
+                .map(line => ` | ` + line)
+                .join('\n'));
+        } else {
+            return header;
+        }
+    };
+
+    console.log(recursive(topError));
+}