diff --git a/extension/api/_.mjs b/extension/api/_.mjs index b56733e..dc8ce08 100644 --- a/extension/api/_.mjs +++ b/extension/api/_.mjs @@ -9,17 +9,18 @@ /** @module notion-enhancer/api */ /** environment-specific methods and constants */ -export * as env from './env.mjs'; -/** environment-specific filesystem reading */ -export * as fs from './fs.mjs'; -/** environment-specific data persistence */ -export * as storage from './storage.mjs'; +import * as env from './env.mjs'; -/** helpers for formatting or parsing text */ -export * as fmt from './fmt.mjs'; +/** environment-specific filesystem reading */ +const fs = env.name === 'extension' ? await import('./extension-fs.mjs') : {}; +/** environment-specific data persistence */ +const storage = env.name === 'extension' ? await import('./extension-storage.mjs') : {}; + +/** helpers for formatting, validating and parsing values */ +import * as fmt from './fmt.mjs'; /** interactions with the enhancer's repository of mods */ -export * as registry from './registry.mjs'; -/** pattern and type validators */ -export * as validation from './validation.mjs'; +import * as registry from './registry.mjs'; /** helpers for manipulation of a webpage */ -export * as web from './web.mjs'; +import * as web from './web.mjs'; + +export { env, fs, storage, fmt, registry, web }; diff --git a/extension/api/env.mjs b/extension/api/env.mjs index db7be08..1a33040 100644 --- a/extension/api/env.mjs +++ b/extension/api/env.mjs @@ -11,39 +11,36 @@ * @module notion-enhancer/api/env */ -/** - * the environment/platform name code is currently being executed in - * @constant {string} - */ -export const name = 'extension'; +import env from '../env.mjs'; /** - * all environments/platforms currently supported by the enhancer - * @constant {array} + * the environment/platform name code is currently being executed in + * @constant + * @type {string} */ -export const supported = ['linux', 'win32', 'darwin', 'extension']; +export const name = env.name; /** * the current version of the enhancer - * @constant {string} + * @constant + * @type {string} */ -export const version = chrome.runtime.getManifest().version; +export const version = env.version; -/** open the enhancer's menu */ -export const focusMenu = () => chrome.runtime.sendMessage({ action: 'focusMenu' }); +/** + * open the enhancer's menu + * @type {function} + */ +export const focusMenu = env.focusMenu; -/** focus an active notion tab */ -export const focusNotion = () => chrome.runtime.sendMessage({ action: 'focusNotion' }); +/** + * focus an active notion tab + * @type {function} + */ +export const focusNotion = env.focusNotion; -/** reload all notion and enhancer menu tabs to apply changes */ -export const reload = () => chrome.runtime.sendMessage({ action: 'reload' }); - -/** a notification displayed when the menu is opened for the first time */ -export const welcomeNotification = { - id: '84e2d49b-c3dc-44b4-a154-cf589676bfa0', - color: 'purple', - icon: 'message-circle', - message: 'Welcome! Come chat with us on Discord.', - link: 'https://discord.gg/sFWPXtA', - version, -}; +/** + * reload all notion and enhancer menu tabs to apply changes + * @type {function} + */ +export const reload = env.reload; diff --git a/extension/api/fs.mjs b/extension/api/extension-fs.mjs similarity index 100% rename from extension/api/fs.mjs rename to extension/api/extension-fs.mjs diff --git a/extension/api/storage.mjs b/extension/api/extension-storage.mjs similarity index 100% rename from extension/api/storage.mjs rename to extension/api/extension-storage.mjs diff --git a/extension/api/fmt.mjs b/extension/api/fmt.mjs index 10d5092..0c75932 100644 --- a/extension/api/fmt.mjs +++ b/extension/api/fmt.mjs @@ -11,7 +11,7 @@ * @module notion-enhancer/api/fmt */ -import * as web from './web.mjs'; +import { web, fs } from './_.mjs'; import '../dep/prism.min.js'; /** syntax highlighting using https://prismjs.com/ */ @@ -80,3 +80,53 @@ export const slugger = (heading, slugs = new Set()) => { } return slug; }; + +const patterns = { + alphanumeric: /^[\w\.-]+$/, + uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + semver: + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/i, + email: + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i, + url: /^[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/i, + color: /^(?:#|0x)(?:[a-f0-9]{3}|[a-f0-9]{6})\b|(?:rgb|hsl)a?\([^\)]*\)$/i, +}; +function test(str, pattern) { + const match = str.match(pattern); + return !!(match && match.length); +} + +/** + * test the type of a value. unifies builtin, regex, and environment/api checks + * @param {*} value - the value to check + * @param {string|array} type - the type the value should be or a list of allowed values + * @returns {boolean} whether or not the value matches the type + */ +export const is = async (value, type, { extension = '' } = {}) => { + extension = !value || !value.endsWith || value.endsWith(extension); + if (Array.isArray(type)) { + return type.includes(value); + } + switch (type) { + case 'array': + return Array.isArray(value); + case 'object': + return value && typeof value === 'object' && !Array.isArray(value); + case 'undefined': + case 'boolean': + case 'number': + return typeof value === type && extension; + case 'string': + return typeof value === type && value.length && extension; + case 'alphanumeric': + case 'uuid': + case 'semver': + case 'email': + case 'url': + case 'color': + return typeof value === 'string' && test(value, patterns[type]) && extension; + case 'file': + return typeof value === 'string' && value && (await fs.isFile(value)) && extension; + } + return false; +}; diff --git a/extension/api/registry-validation.mjs b/extension/api/registry-validation.mjs new file mode 100644 index 0000000..c4c2072 --- /dev/null +++ b/extension/api/registry-validation.mjs @@ -0,0 +1,221 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +import { fmt, registry } from './_.mjs'; + +/** + * 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 + */ +export async function validate(mod) { + const check = async ( + key, + value, + types, + { + extension = '', + error = `invalid ${key} (${extension ? `${extension} ` : ''}${types}): ${JSON.stringify( + value + )}`, + optional = false, + } = {} + ) => { + let test; + for (const type of Array.isArray(types) ? [types] : types.split('|')) { + if (type === 'file') { + test = + value && !value.startsWith('http') + ? await fmt.is(`repo/${mod._dir}/${value}`, type, { extension }) + : false; + } else test = await fmt.is(value, type, { extension }); + if (test) break; + } + if (!test) { + if (optional && (await fmt.is(value, 'undefined'))) return true; + if (error) registry._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', { optional: true }).then((passed) => { + if (!passed) return false; + if (!mod.environments) { + mod.environments = registry.supportedEnvs; + return true; + } + return mod.environments.map((tag) => + check('environments.env', tag, registry.supportedEnvs) + ); + }), + check('description', mod.description, 'string'), + check('preview', mod.preview, 'file|url', { optional: true }), + check('tags', mod.tags, 'array').then((passed) => { + if (!passed) return false; + const containsCategory = mod.tags.filter((tag) => + ['core', 'extension', 'theme'].includes(tag) + ).length; + if (!containsCategory) { + registry._errors.push({ + source: mod._dir, + message: `invalid tags (must contain at least one of 'core', 'extension', or 'theme'): ${JSON.stringify( + mod.tags + )}`, + }); + return false; + } + if ( + (mod.tags.includes('theme') && + !(mod.tags.includes('light') || mod.tags.includes('dark'))) || + (mod.tags.includes('light') && mod.tags.includes('dark')) + ) { + registry._errors.push({ + source: mod._dir, + message: `invalid tags (themes must be either 'light' or 'dark', not neither or both): ${JSON.stringify( + mod.tags + )}`, + }); + return false; + } + return mod.tags.map((tag) => check('tags.tag', tag, 'string')); + }), + 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, registry.optionTypes).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 = registry.supportedEnvs; + return true; + } + return option.environments.map((environment) => + check('options.option.environments.env', environment, registry.supportedEnvs) + ); + }), + ]; + 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': + case 'hotkey': + 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); +} diff --git a/extension/api/registry.mjs b/extension/api/registry.mjs index 6ed47a0..d55e0dd 100644 --- a/extension/api/registry.mjs +++ b/extension/api/registry.mjs @@ -11,238 +11,57 @@ * @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'; +import { env, fs, storage } from './_.mjs'; +import { validate } from './registry-validation.mjs'; -const _cache = [], +export const _cache = [], _errors = []; -/** mod ids whitelisted as part of the enhancer's core, permanently enabled */ +/** + * mod ids whitelisted as part of the enhancer's core, permanently enabled + * @constant + * @type {array} + */ 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', 'hotkey']; - -/** the name of the active configuration profile */ -export const profileName = await storage.get(['currentprofile'], 'default'); - -/** the root database for the current profile */ -export const profileDB = storage.db(['profiles', profileName]); +/** + * all environments/platforms currently supported by the enhancer + * @constant + * @type {array} + */ +export const supportedEnvs = ['linux', 'win32', 'darwin', 'extension']; /** - * 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 + * all available configuration types + * @constant + * @type {array} */ -async function validate(mod) { - const check = async ( - key, - value, - types, - { - extension = '', - error = `invalid ${key} (${extension ? `${extension} ` : ''}${types}): ${JSON.stringify( - value - )}`, - optional = false, - } = {} - ) => { - let test; - for (const type of types.split('|')) { - if (type === 'file') { - test = - value && !value.startsWith('http') - ? await is(`repo/${mod._dir}/${value}`, type, { extension }) - : false; - } else test = await is(value, type, { extension }); - if (test) break; - } - 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', { optional: true }).then((passed) => { - if (!passed) return false; - if (!mod.environments) { - mod.environments = env.supported; - return true; - } - return mod.environments.map((tag) => check('environments.env', tag, 'env')); - }), - check('description', mod.description, 'string'), - check('preview', mod.preview, 'file|url', { optional: true }), - check('tags', mod.tags, 'array').then((passed) => { - if (!passed) return false; - const containsCategory = mod.tags.filter((tag) => - ['core', 'extension', 'theme'].includes(tag) - ).length; - if (!containsCategory) { - _errors.push({ - source: mod._dir, - message: `invalid tags (must contain at least one of 'core', 'extension', or 'theme'): ${JSON.stringify( - mod.tags - )}`, - }); - return false; - } - if ( - (mod.tags.includes('theme') && - !(mod.tags.includes('light') || mod.tags.includes('dark'))) || - (mod.tags.includes('light') && mod.tags.includes('dark')) - ) { - _errors.push({ - source: mod._dir, - message: `invalid tags (themes must be either 'light' or 'dark', not neither or both): ${JSON.stringify( - mod.tags - )}`, - }); - return false; - } - return mod.tags.map((tag) => check('tags.tag', tag, 'string')); - }), - 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': - case 'hotkey': - 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); -} +export const optionTypes = ['toggle', 'select', 'text', 'number', 'color', 'file', 'hotkey']; + +/** + * the name of the active configuration profile + * @returns {string} + */ +export const profileName = async () => storage.get(['currentprofile'], 'default'); + +/** + * the root database for the current profile + * @returns {object} the get/set functions for the profile's storage + */ +export const profileDB = async () => storage.db(['profiles', await profileName()]); + +/** a notification displayed when the menu is opened for the first time */ +export const welcomeNotification = { + id: '84e2d49b-c3dc-44b4-a154-cf589676bfa0', + color: 'purple', + icon: 'message-circle', + message: 'Welcome! Come chat with us on Discord.', + link: 'https://discord.gg/sFWPXtA', + version: env.version, +}; /** * list all available mods in the repo @@ -251,12 +70,13 @@ async function validate(mod) { */ export const list = async (filter = (mod) => true) => { if (!_cache.length) { - for (const dir of await getJSON('repo/registry.json')) { + for (const dir of await fs.getJSON('repo/registry.json')) { try { - const mod = await getJSON(`repo/${dir}/mod.json`); + const mod = await fs.getJSON(`repo/${dir}/mod.json`); mod._dir = dir; if (await validate(mod)) _cache.push(mod); - } catch { + } catch (e) { + console.log(e); _errors.push({ source: dir, message: 'invalid mod.json' }); } } @@ -295,7 +115,7 @@ 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 profileDB.get(['_mods', id], false); + return (await profileDB()).get(['_mods', id], false); }; /** @@ -328,6 +148,7 @@ export const optionDefault = async (id, key) => { * @returns {object} an object with the wrapped get/set functions */ export const db = async (id) => { + const db = await profileDB(); return storage.db( [id], async (path, fallback = undefined) => { @@ -335,8 +156,8 @@ export const db = async (id) => { // profiles -> profile -> mod -> option fallback = (await optionDefault(id, path[1])) ?? fallback; } - return profileDB.get(path, fallback); + return db.get(path, fallback); }, - profileDB.set + db.set ); }; diff --git a/extension/api/validation.mjs b/extension/api/validation.mjs deleted file mode 100644 index 9612ae4..0000000 --- a/extension/api/validation.mjs +++ /dev/null @@ -1,109 +0,0 @@ -/* - * notion-enhancer: api - * (c) 2021 dragonwocky (https://dragonwocky.me/) - * (https://notion-enhancer.github.io/) under the MIT license - */ - -'use strict'; - -/** - * pattern and type validators - * @module notion-enhancer/api/validation - */ - -import { supported } from './env.mjs'; -import { optionTypes } from './registry.mjs'; -import { isFile } from './fs.mjs'; - -const patterns = { - alphanumeric: /^[\w\.-]+$/, - uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, - semver: - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/i, - email: - /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i, - url: /^[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/i, - color: /^(?:#|0x)(?:[a-f0-9]{3}|[a-f0-9]{6})\b|(?:rgb|hsl)a?\([^\)]*\)$/i, -}; -function test(str, pattern) { - const match = str.match(pattern); - return !!(match && match.length); -} - -/** - * check is string is alphanumeric-only (letters, numbers, underscores, dots, dashes) - * @param {string} str - the string to test - * @returns {boolean} whether or not the test passed successfully - */ -export const alphanumeric = (str) => test(str, patterns.alphanumeric); - -/** - * check for a valid uuid (8-4-4-4-12 hexadecimal digits) - * @param {string} str - the string to test - * @returns {boolean} whether or not the test passed successfully - */ -export const uuid = (str) => test(str, patterns.uuid); - -/** - * check for a valid semver (MAJOR.MINOR.PATCH) - * @param {string} str - the string to test - * @returns {boolean} whether or not the test passed successfully - */ -export const semver = (str) => test(str, patterns.semver); - -/** - * check for a valid email (e.g. someone@somewhere.domain) - * @param {string} str - the string to test - * @returns {boolean} whether or not the test passed successfully - */ -export const email = (str) => test(str, patterns.email); - -/** - * check for a valid url (e.g. https://example.com/path) - * @param {string} str - the string to test - * @returns {boolean} whether or not the test passed successfully - */ -export const url = (str) => test(str, patterns.url); - -/** - * check for a valid color (https://regexr.com/39cgj) - * @param {string} str - the string to test - * @returns {boolean} whether or not the test passed successfully - */ -export const color = (str) => test(str, patterns.color); - -/** - * test the type of a value. unifies builtin, regex, and environment/api checks. - * @param {*} value - the value to check - * @param {string} type - the type the value should be - * @returns {boolean} whether or not the value matches the type - */ -export const is = async (value, type, { extension = '' } = {}) => { - extension = !value || !value.endsWith || value.endsWith(extension); - switch (type) { - case 'array': - return Array.isArray(value); - case 'object': - return value && typeof value === 'object' && !Array.isArray(value); - case 'undefined': - case 'boolean': - case 'number': - return typeof value === type && extension; - case 'string': - return typeof value === type && value.length && extension; - case 'alphanumeric': - case 'uuid': - case 'semver': - case 'email': - case 'url': - case 'color': - return typeof value === 'string' && test(value, patterns[type]) && extension; - case 'file': - return typeof value === 'string' && value && (await isFile(value)) && extension; - case 'env': - return supported.includes(value); - case 'optionType': - return optionTypes.includes(value); - } - return false; -}; diff --git a/extension/api/web.mjs b/extension/api/web.mjs index 2439cf4..ad8d32e 100644 --- a/extension/api/web.mjs +++ b/extension/api/web.mjs @@ -11,8 +11,7 @@ * @module notion-enhancer/api/web */ -import { localPath } from './fs.mjs'; -import { md } from './fmt.mjs'; +import { fs, fmt } from './_.mjs'; const _hotkeyEventListeners = [], _documentObserverListeners = [], @@ -139,7 +138,7 @@ export const loadStylesheet = (path) => { document.head, html`` ); return true; @@ -158,7 +157,7 @@ export const icon = (name, attrs = {}) => { ).trim(); return ` `${escape(key)}="${escape(val)}"`) - .join(' ')}>`; + .join(' ')}>`; }; /** @@ -193,7 +192,7 @@ export const tooltip = ($ref, text) => { render(document.head, _$tooltipStylesheet); render(document.body, _$tooltip); } - text = md.render(text); + text = fmt.md.render(text); $ref.addEventListener('mouseover', (event) => { _$tooltip.innerHTML = text; _$tooltip.style.display = 'block'; diff --git a/extension/env.mjs b/extension/env.mjs new file mode 100644 index 0000000..6d75f6e --- /dev/null +++ b/extension/env.mjs @@ -0,0 +1,24 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * environment-specific methods and constants + * @module notion-enhancer/api/env + */ + +const focusMenu = () => chrome.runtime.sendMessage({ action: 'focusMenu' }), + focusNotion = () => chrome.runtime.sendMessage({ action: 'focusNotion' }), + reload = () => chrome.runtime.sendMessage({ action: 'reload' }); + +export default { + name: 'extension', + version: chrome.runtime.getManifest().version, + focusMenu, + focusNotion, + reload, +}; diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/client.mjs b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/client.mjs index a536473..ed48bc0 100644 --- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/client.mjs +++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/client.mjs @@ -33,7 +33,7 @@ export default async function (api, db) { const notifications = { cache: await storage.get(['notifications'], []), provider: [ - env.welcomeNotification, + registry.welcomeNotification, ...(await fs.getJSON('https://notion-enhancer.github.io/notifications.json')), ], count: (await registry.errors()).length, diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/components.mjs b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/components.mjs index f160ea5..af203fd 100644 --- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/components.mjs +++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/components.mjs @@ -6,9 +6,9 @@ 'use strict'; -import { fmt, registry, web } from '../../api/_.mjs'; - +import { fmt, web } from '../../api/_.mjs'; import { notifications } from './notifications.mjs'; +import { profileDB } from './menu.mjs'; export const components = { preview: (url) => web.html` { - const checked = await registry.profileDB.get([mod.id, opt.key], opt.value), + const checked = await profileDB.get([mod.id, opt.key], opt.value), $toggle = components.toggle(opt.label, checked), $tooltip = web.html`${web.icon('info', { class: 'input-tooltip' })}`, $label = $toggle.children[0], @@ -66,13 +66,13 @@ export const options = { web.tooltip($tooltip, opt.tooltip); } $input.addEventListener('change', async (event) => { - await registry.profileDB.set([mod.id, opt.key], $input.checked); + await profileDB.set([mod.id, opt.key], $input.checked); notifications.onChange(); }); return $toggle; }, select: async (mod, opt) => { - const value = await registry.profileDB.get([mod.id, opt.key], opt.values[0]), + const value = await profileDB.get([mod.id, opt.key], opt.values[0]), $tooltip = web.html`${web.icon('info', { class: 'input-tooltip' })}`, $label = web.render( web.html``, @@ -91,13 +91,13 @@ export const options = { $icon = web.html`${web.icon('chevron-down', { class: 'input-icon' })}`; if (opt.tooltip) web.tooltip($tooltip, opt.tooltip); $select.addEventListener('change', async (event) => { - await registry.profileDB.set([mod.id, opt.key], $select.value); + await profileDB.set([mod.id, opt.key], $select.value); notifications.onChange(); }); return web.render($label, $select, $icon); }, text: async (mod, opt) => { - const value = await registry.profileDB.get([mod.id, opt.key], opt.value), + const value = await profileDB.get([mod.id, opt.key], opt.value), $tooltip = web.html`${web.icon('info', { class: 'input-tooltip' })}`, $label = web.render( web.html``, @@ -107,13 +107,13 @@ export const options = { $icon = web.html`${web.icon('type', { class: 'input-icon' })}`; if (opt.tooltip) web.tooltip($tooltip, opt.tooltip); $input.addEventListener('change', async (event) => { - await registry.profileDB.set([mod.id, opt.key], $input.value); + await profileDB.set([mod.id, opt.key], $input.value); notifications.onChange(); }); return web.render($label, $input, $icon); }, number: async (mod, opt) => { - const value = await registry.profileDB.get([mod.id, opt.key], opt.value), + const value = await profileDB.get([mod.id, opt.key], opt.value), $tooltip = web.html`${web.icon('info', { class: 'input-tooltip' })}`, $label = web.render( web.html``, @@ -123,13 +123,13 @@ export const options = { $icon = web.html`${web.icon('hash', { class: 'input-icon' })}`; if (opt.tooltip) web.tooltip($tooltip, opt.tooltip); $input.addEventListener('change', async (event) => { - await registry.profileDB.set([mod.id, opt.key], $input.value); + await profileDB.set([mod.id, opt.key], $input.value); notifications.onChange(); }); return web.render($label, $input, $icon); }, color: async (mod, opt) => { - const value = await registry.profileDB.get([mod.id, opt.key], opt.value), + const value = await profileDB.get([mod.id, opt.key], opt.value), $tooltip = web.html`${web.icon('info', { class: 'input-tooltip' })}`, $label = web.render( web.html``, @@ -155,14 +155,14 @@ export const options = { }); if (opt.tooltip) web.tooltip($tooltip, opt.tooltip); $input.addEventListener('change', async (event) => { - await registry.profileDB.set([mod.id, opt.key], $input.value); + await profileDB.set([mod.id, opt.key], $input.value); notifications.onChange(); }); paint(); return web.render($label, $input, $icon); }, file: async (mod, opt) => { - const { filename } = (await registry.profileDB.get([mod.id, opt.key], {})) || {}, + const { filename } = (await profileDB.get([mod.id, opt.key], {})) || {}, $tooltip = web.html`${web.icon('info', { class: 'input-tooltip' })}`, $label = web.render( web.html``, @@ -181,7 +181,7 @@ export const options = { reader = new FileReader(); reader.onload = async (progress) => { $filename.innerText = file.name; - await registry.profileDB.set([mod.id, opt.key], { + await profileDB.set([mod.id, opt.key], { filename: file.name, content: progress.currentTarget.result, }); @@ -191,7 +191,7 @@ export const options = { }); $latest.addEventListener('click', (event) => { $filename.innerText = 'none'; - registry.profileDB.set([mod.id, opt.key], {}); + profileDB.set([mod.id, opt.key], {}); }); return web.render( web.html`
`, @@ -200,7 +200,7 @@ export const options = { ); }, hotkey: async (mod, opt) => { - const value = await registry.profileDB.get([mod.id, opt.key], opt.value), + const value = await profileDB.get([mod.id, opt.key], opt.value), $tooltip = web.html`${web.icon('info', { class: 'input-tooltip' })}`, $label = web.render( web.html``, @@ -229,7 +229,7 @@ export const options = { pressed.push(key); } $input.value = pressed.join('+'); - await registry.profileDB.set([mod.id, opt.key], $input.value); + await profileDB.set([mod.id, opt.key], $input.value); notifications.onChange(); }); return web.render($label, $input, $icon); diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.mjs b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.mjs index 518554b..1208f52 100644 --- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.mjs +++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.mjs @@ -7,7 +7,10 @@ 'use strict'; import { env, fs, storage, registry, web } from '../../api/_.mjs'; -const db = await registry.db('a6621988-551d-495a-97d8-3c568bca2e9e'); + +export const db = await registry.db('a6621988-551d-495a-97d8-3c568bca2e9e'), + profileName = await registry.profileName(), + profileDB = await registry.profileDB(); import './styles.mjs'; import { notifications } from './notifications.mjs'; @@ -35,12 +38,12 @@ window.addEventListener('beforeunload', (event) => { const $main = web.html`
`, $sidebar = web.html``, - $profile = web.html``, $options = web.html`

Select a mod to view and configure its options.

-
`; + `, + $profile = web.html``; let _$profileConfig; $profile.addEventListener('click', async (event) => { @@ -51,14 +54,14 @@ $profile.addEventListener('click', async (event) => { const profileNames = [ ...new Set([ ...Object.keys(await storage.get(['profiles'], { default: {} })), - registry.profileName, + profileName, ]), ], $options = profileNames.map( (profile) => web.raw`` ), $select = web.html``, $export = web.html`