notion-enhancer/extension/api/registry.mjs

278 lines
8.9 KiB
JavaScript

/*
* notion-enhancer: api
* (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
* (https://notion-enhancer.github.io/) under the MIT license
*/
'use strict';
/**
* interactions with the enhancer's repository of mods
* @module notion-enhancer/api/registry
*/
import * as env from './env.mjs';
import { getJSON } from './fs.mjs';
import * as storage from './storage.mjs';
import { is } from './validation.mjs';
const _cache = [],
_errors = [];
/** mod ids whitelisted as part of the enhancer's core, permanently enabled */
export const core = [
'a6621988-551d-495a-97d8-3c568bca2e9e',
'0f0bf8b6-eae6-4273-b307-8fc43f2ee082',
];
/** all available configuration types */
export const optionTypes = ['toggle', 'select', 'text', 'number', 'color', 'file'];
/**
* internally used to validate mod.json files and provide helpful errors
* @private
* @param {object} mod - a mod's mod.json in object form
* @returns {boolean} whether or not the mod has passed validation
*/
async function validate(mod) {
const check = async (
key,
value,
type,
{
extension = '',
error = `invalid ${key} (${extension ? `${extension} ` : ''}${type}): ${JSON.stringify(
value
)}`,
optional = false,
} = {}
) => {
const test = await is(type === 'file' ? `repo/${mod._dir}/${value}` : value, type, {
extension,
});
if (!test) {
if (optional && (await is(value, 'undefined'))) return true;
if (error) _errors.push({ source: mod._dir, message: error });
return false;
}
return true;
};
let conditions = [
check('name', mod.name, 'string'),
check('id', mod.id, 'uuid'),
check('version', mod.version, 'semver'),
check('environments', mod.environments, 'array').then((passed) =>
passed ? mod.environments.map((tag) => check('environments.env', tag, 'env')) : 0
),
check('description', mod.description, 'string'),
// file doubles for url here
check('preview', mod.preview, 'file', { optional: true }),
check('tags', mod.tags, 'array').then((passed) =>
passed ? mod.tags.map((tag) => check('tags.tag', tag, 'string')) : 0
),
check('authors', mod.authors, 'array').then((passed) => {
if (!passed) return false;
return mod.authors.map((author) => [
check('authors.author.name', author.name, 'string'),
check('authors.author.email', author.email, 'email'),
check('authors.author.homepage', author.homepage, 'url'),
check('authors.author.avatar', author.avatar, 'url'),
]);
}),
check('css', mod.css, 'object').then((passed) => {
if (!passed) return false;
const tests = [];
for (let dest of ['frame', 'client', 'menu']) {
if (!mod.css[dest]) continue;
let test = check(`css.${dest}`, mod.css[dest], 'array');
test = test.then((passed) => {
if (!passed) return false;
return mod.css[dest].map((file) =>
check(`css.${dest}.file`, file, 'file', { extension: '.css' })
);
});
tests.push(test);
}
return tests;
}),
check('js', mod.js, 'object').then((passed) => {
if (!passed) return false;
const tests = [];
if (mod.js.client) {
let test = check('js.client', mod.js.client, 'array');
test = test.then((passed) => {
if (!passed) return false;
return mod.js.client.map((file) =>
check('js.client.file', file, 'file', { extension: '.mjs' })
);
});
tests.push(test);
}
if (mod.js.electron) {
let test = check('js.electron', mod.js.electron, 'array');
test = test.then((passed) => {
if (!passed) return false;
return mod.js.electron.map((file) =>
check('js.electron.file', file, 'object').then((passed) => {
if (!passed) return false;
return [
check('js.electron.file.source', file.source, 'file', {
extension: '.mjs',
}),
// referencing the file within the electron app
// existence can't be validated, so only format is
check('js.electron.file.target', file.target, 'string', {
extension: '.js',
}),
];
})
);
});
tests.push(test);
}
return tests;
}),
check('options', mod.options, 'array').then((passed) => {
if (!passed) return false;
return mod.options.map((option) =>
check('options.option.type', option.type, 'optionType').then((passed) => {
if (!passed) return false;
const tests = [
check('options.option.key', option.key, 'alphanumeric'),
check('options.option.label', option.label, 'string'),
check('options.option.tooltip', option.tooltip, 'string', {
optional: true,
}),
check('options.option.environments', option.environments, 'array', {
optional: true,
}).then((passed) => {
if (!passed) return false;
if (!option.environments) {
option.environments = env.supported;
return true;
}
return option.environments.map((env) =>
check('options.option.environments.env', env, 'env')
);
}),
];
switch (option.type) {
case 'toggle':
tests.push(check('options.option.value', option.value, 'boolean'));
break;
case 'select':
tests.push(
check('options.option.values', option.values, 'array').then((passed) => {
if (!passed) return false;
return option.values.map((value) =>
check('options.option.values.value', value, 'string')
);
})
);
break;
case 'text':
tests.push(check('options.option.value', option.value, 'string'));
break;
case 'number':
case 'color':
tests.push(check('options.option.value', option.value, option.type));
break;
case 'file':
tests.push(
check('options.option.extensions', option.extensions, 'array').then(
(passed) => {
if (!passed) return false;
return option.extensions.map((value) =>
check('options.option.extensions.extension', value, 'string')
);
}
)
);
}
return tests;
})
);
}),
];
do {
conditions = await Promise.all(conditions.flat(Infinity));
} while (conditions.some((condition) => Array.isArray(condition)));
return conditions.every((passed) => passed);
}
/**
* list all available mods in the repo
* @returns {array} a validated list of mod.json objects
*/
export const list = async () => {
if (!_cache.length) {
for (const dir of await getJSON('repo/registry.json')) {
try {
const mod = await getJSON(`repo/${dir}/mod.json`);
mod._dir = dir;
if (await validate(mod)) _cache.push(mod);
} catch {
_errors.push({ source: dir, message: 'invalid mod.json' });
}
}
}
return _cache;
};
/**
* list validation errors encountered when loading the repo
* @returns {array<object>} error objects with an error message and a source directory
*/
export const errors = async () => {
if (!_errors.length) await list();
return _errors;
};
/**
* get a single mod from the repo
* @param {string} id - the uuid of the mod
* @returns {object} the mod's mod.json
*/
export const get = async (id) => {
if (!_cache.length) await list();
return _cache.find((mod) => mod.id === id);
};
/**
* checks if a mod is enabled: affected by the core whitelist,
* environment and menu configuration
* @param {string} id - the uuid of the mod
* @returns {boolean} whether or not the mod is enabled
*/
export const enabled = async (id) => {
const mod = await get(id);
if (!mod.environments.includes(env.name)) return false;
if (core.includes(id)) return true;
return await storage.get(
['profiles', await storage.get(['currentprofile'], 'default'), '_mods', id],
false
);
};
/**
* get a default value of a mod's option according to its mod.json
* @param {string} id - the uuid of the mod
* @param {string} key - the key of the option
* @returns {string|number|boolean|undefined} the option's default value
*/
export const optionDefault = async (id, key) => {
const mod = await get(id),
opt = mod.options.find((opt) => opt.key === key);
switch (opt.type) {
case 'toggle':
case 'text':
case 'number':
case 'color':
return opt.value;
case 'select':
return opt.values[0];
case 'file':
}
return undefined;
};