From 05760882b5ccb54d1944190397df296c3ea75213 Mon Sep 17 00:00:00 2001 From: dragonwocky Date: Sat, 17 Apr 2021 02:29:53 +1000 Subject: [PATCH] transfer validation to at runtime for better module dev + add buttons to open the menu --- extension/icons/blackwhite.svg | 30 +++++ extension/icons/colour.svg | 64 +++++++++ extension/manifest.json | 7 +- extension/registry.json | 1 - extension/repo/registry.json | 1 + extension/scan.js | 207 ---------------------------- extension/src/client.css | 46 +++++++ extension/src/gui.html | 2 +- extension/src/helpers.js | 237 +++++++++++++++++++++++++++++++-- extension/src/launcher.js | 26 +++- extension/worker.js | 27 ++++ 11 files changed, 414 insertions(+), 234 deletions(-) create mode 100644 extension/icons/blackwhite.svg create mode 100644 extension/icons/colour.svg delete mode 100644 extension/registry.json create mode 100644 extension/repo/registry.json delete mode 100644 extension/scan.js create mode 100644 extension/src/client.css diff --git a/extension/icons/blackwhite.svg b/extension/icons/blackwhite.svg new file mode 100644 index 0000000..12da3b0 --- /dev/null +++ b/extension/icons/blackwhite.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extension/icons/colour.svg b/extension/icons/colour.svg new file mode 100644 index 0000000..958c24b --- /dev/null +++ b/extension/icons/colour.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/extension/manifest.json b/extension/manifest.json index 131c7b2..7740325 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -11,15 +11,13 @@ "128": "icons/colour-x128.png" }, "manifest_version": 3, + "action": {}, "background": { "service_worker": "worker.js" }, - "action": { - "default_popup": "src/gui.html" - }, "web_accessible_resources": [ { - "resources": ["registry.json", "src/*", "repo/*"], + "resources": ["icons/*", "src/*", "repo/*"], "matches": ["https://*.notion.so/*"] } ], @@ -29,6 +27,5 @@ "js": ["content-loader.js"] } ], - "permissions": ["activeTab"], "host_permissions": ["https://*.notion.so/*"] } diff --git a/extension/registry.json b/extension/registry.json deleted file mode 100644 index 27c16db..0000000 --- a/extension/registry.json +++ /dev/null @@ -1 +0,0 @@ -[{"name":"theming","id":"0f0bf8b6-eae6-4273-b307-8fc43f2ee082","description":"the default theme variables, required by other themes & extensions.","version":"0.11.0","tags":["core","theme"],"authors":[{"name":"dragonwocky","email":"thedragonring.bod@gmail.com","url":"https://dragonwocky.me/","icon":"https://dragonwocky.me/avatar.jpg"}],"css":{"client":["client.css"]},"js":{"client":["test.js"]},"dir":"theming@0f0bf8b6-eae6-4273-b307-8fc43f2ee082"}] \ No newline at end of file diff --git a/extension/repo/registry.json b/extension/repo/registry.json new file mode 100644 index 0000000..8e458ec --- /dev/null +++ b/extension/repo/registry.json @@ -0,0 +1 @@ +["theming@0f0bf8b6-eae6-4273-b307-8fc43f2ee082"] diff --git a/extension/scan.js b/extension/scan.js deleted file mode 100644 index 588b155..0000000 --- a/extension/scan.js +++ /dev/null @@ -1,207 +0,0 @@ -/* - * notion-enhancer - * (c) 2021 dragonwocky (https://dragonwocky.me/) - * (https://notion-enhancer.github.io/) under the MIT license - */ - -// used to validate mod.json files available in a local repository, -// the options those files reference, & then generate a registry.json from that - -// it also enforces the name@id naming scheme for mod dirs - -const fs = require('fs'), - fsp = fs.promises, - colour = require('chalk'); - -let currentFolder = ''; -const errors = []; - -const prefix = (status = '') => - colour.whiteBright(``); -function error(msg) { - const err = `${msg} in ${colour.italic(currentFolder)}`; - console.error(`${prefix(colour.red('error'))} ${err}`); - errors.push(err); -} -const isFile = (filepath, extension = '') => - typeof filepath === 'string' && - filepath.endsWith(extension) && - fs.existsSync(`./repo/${currentFolder}/${filepath}`, 'file'); - -const regexers = { - uuid(str) { - const match = str.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); - if (match && match.length) return true; - error(`invalid uuid ${str}`); - return false; - }, - semver(str) { - const match = str.match( - /^(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 - ); - if (match && match.length) return true; - error(`invalid semver ${str}`); - return false; - }, - email(str) { - const match = str.match( - /^(([^<>()\[\]\\.,;:\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 - ); - if (match && match.length) return true; - error(`invalid email ${str}`); - return false; - }, - url(str) { - const match = str.match( - /^[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/i - ); - if (match && match.length) return true; - error(`invalid url ${str}`); - return false; - }, -}; - -async function validate(mod) { - mod.tags = mod.tags ?? []; - mod.css = mod.css ?? []; - mod.js = mod.js ?? {}; - const check = (prop, value, condition) => - new Promise((res, rej) => - condition ? res(value) : error(`invalid ${prop} ${JSON.stringify(value)}`) - ); - return Promise.all([ - check('name', mod.name, typeof mod.name === 'string'), - check('id', mod.id, typeof mod.id === 'string').then((id) => regexers.uuid(id)), - check('description', mod.description, typeof mod.description === 'string'), - check('version', mod.version, typeof mod.version === 'string').then((version) => - regexers.semver(version) - ), - check('tags', mod.tags, Array.isArray(mod.tags)).then((tags) => - Promise.all(tags.map((tag) => check('tag', tag, typeof tag === 'string'))) - ), - check('authors', mod.authors, Array.isArray(mod.authors)).then((authors) => - Promise.all( - authors - .map((author) => [ - check('author.name', author.name, typeof author.name === 'string'), - check( - 'author.email', - author.email, - typeof author.email === 'string' - ).then((email) => regexers.email(email)), - check('author.url', author.url, typeof author.url === 'string').then((url) => - regexers.url(url) - ), - check('author.icon', author.icon, typeof author.icon === 'string').then((icon) => - regexers.url(icon) - ), - ]) - .flat() - ) - ), - check( - 'css', - mod.css, - !!mod.css && typeof mod.css === 'object' && !Array.isArray(mod.css) - ).then(async (css) => { - for (const dest of ['frame', 'client', 'gui']) { - const destFiles = css[dest]; - if (destFiles) { - await check(`css.${dest}`, destFiles, Array.isArray(destFiles)).then((files) => - Promise.all( - files.map(async (file) => - check(`css.${dest} file`, file, await isFile(file, '.css')) - ) - ) - ); - } - } - }), - check('js', mod.js, !!mod.js && typeof mod.js === 'object' && !Array.isArray(mod.js)).then( - async (js) => { - const client = js.client; - if (client) { - await check('js.client', client, Array.isArray(client)).then((files) => - Promise.all( - files.map(async (file) => - check('js.client file', file, await isFile(file, '.js')) - ) - ) - ); - } - const electron = js.electron; - if (electron) { - await check('js.electron', electron, Array.isArray(electron)).then((files) => - Promise.all( - files.map((file) => - check( - 'js.electron file', - file, - !!file && typeof file === 'object' && !Array.isArray(file) - ).then(async (file) => { - const source = file.source; - await check('js.electron file source', source, await isFile(source, '.js')); - // referencing the file within the electron app - // existence can't be validated, so only format is - const target = file.target; - await check( - 'js.electron file target', - target, - typeof target === 'string' && target.endsWith('.js') - ); - }) - ) - ) - ); - } - } - ), - check('options', mod.options, !mod.options || (await isFile(mod.options, '.json'))).then( - async (filepath) => { - if (!filepath) return; - let options; - try { - options = JSON.parse(await fsp.readFile(`./repo/${currentFolder}/${filepath}`)); - } catch { - error(`invalid options ${filepath}`); - } - // todo: validate options - } - ), - ]); -} - -async function generate() { - const mods = []; - for (const folder of await fsp.readdir('./repo')) { - let mod; - try { - mod = JSON.parse(await fsp.readFile(`./repo/${folder}/mod.json`)); - mod.dir = folder; - currentFolder = folder; - await validate(mod); - mods.push(mod); - } catch { - error('invalid mod.json'); - } - } - if (!errors.length) { - for (const mod of mods) { - const oldDir = `./repo/${mod.dir}`; - mod.dir = `${mod.name.replace(/[^A-Za-z0-9]/, '-')}@${mod.id}`; - await fsp.rename(oldDir, `./repo/${mod.dir}`); - } - await fsp.writeFile('./registry.json', JSON.stringify(mods)); - console.info( - `${prefix( - colour.green('success') - )} all mod configuration valid, registry saved to ./registry.json & folder naming scheme enforced` - ); - } -} - -if (fs.existsSync('./repo', 'dir')) { - generate(); -} else { - console.error(`${prefix(colour.red('error'))} no repo folder found`); -} diff --git a/extension/src/client.css b/extension/src/client.css new file mode 100644 index 0000000..2e4ecc7 --- /dev/null +++ b/extension/src/client.css @@ -0,0 +1,46 @@ +/* + * notion-enhancer + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +.enhancer--sidebarMenuTrigger { + user-select: none; + transition: background 20ms ease-in 0s; + cursor: pointer; +} +.enhancer--sidebarMenuTrigger:hover { + background: var(--theme--button-hover); +} +.enhancer--sidebarMenuTrigger svg { + width: 16px; + height: 16px; + margin-left: 2px; +} +.enhancer--sidebarMenuTrigger > div { + display: flex; + align-items: center; + min-height: 27px; + font-size: 14px; + padding: 2px 14px; + width: 100%; +} +.enhancer--sidebarMenuTrigger > div > div:first-child { + flex-shrink: 0; + flex-grow: 0; + border-radius: 3px; + color: var(--theme--text_sidebar); + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 8px; +} +.enhancer--sidebarMenuTrigger > div > div:last-child { + flex: 1 1 auto; + white-space: nowrap; + min-width: 0px; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/extension/src/gui.html b/extension/src/gui.html index 20131c5..9edc684 100644 --- a/extension/src/gui.html +++ b/extension/src/gui.html @@ -1,7 +1,7 @@ - Hello, World! + notion-enhancer

Hello, World!

diff --git a/extension/src/helpers.js b/extension/src/helpers.js index fb8809c..32f421c 100644 --- a/extension/src/helpers.js +++ b/extension/src/helpers.js @@ -5,25 +5,28 @@ */ 'use strict'; - -const registry = fetch(chrome.runtime.getURL('/registry.json')).then((response) => - response.json() -); +const ERROR = Symbol(); const web = {}; -web.whenReady = (func = () => {}) => { +web.whenReady = (selectors = [], callback = () => {}) => { return new Promise((res, rej) => { - if (document.readyState !== 'complete') { - document.addEventListener('readystatechange', (event) => { - if (document.readyState === 'complete') { - func(); + function onLoad() { + let isReadyInt; + isReadyInt = setInterval(isReadyTest, 100); + function isReadyTest() { + if (selectors.every((selector) => document.querySelector(selector))) { + clearInterval(isReadyInt); + callback(); res(true); } - }); - } else { - func(); - res(true); + } + isReadyTest(); } + if (document.readyState !== 'complete') { + document.addEventListener('readystatechange', (event) => { + if (document.readyState === 'complete') onLoad(); + }); + } else onLoad(); }); }; web.createElement = (html) => { @@ -38,4 +41,210 @@ web.loadStyleset = (sheet) => { return true; }; -export { registry, web }; +// + +const fs = {}; + +fs.getJSON = (path) => fetch(chrome.runtime.getURL(path)).then((res) => res.json()); +fs.getText = (path) => fetch(chrome.runtime.getURL(path)).then((res) => res.text()); + +fs.isFile = async (path) => { + try { + await fetch(chrome.runtime.getURL(`/repo/${path}`)); + return true; + } catch { + return false; + } +}; + +// + +const regexers = { + uuid(str) { + const match = str.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + if (match && match.length) return true; + error(`invalid uuid ${str}`); + return false; + }, + semver(str) { + const match = str.match( + /^(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 + ); + if (match && match.length) return true; + error(`invalid semver ${str}`); + return false; + }, + email(str) { + const match = str.match( + /^(([^<>()\[\]\\.,;:\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 + ); + if (match && match.length) return true; + error(`invalid email ${str}`); + return false; + }, + url(str) { + const match = str.match( + /^[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/i + ); + if (match && match.length) return true; + error(`invalid url ${str}`); + return false; + }, +}; + +// + +const registry = {}; + +registry.validate = async (mod, err, check) => + Promise.all( + [ + check('name', mod.name, typeof mod.name === 'string'), + check('id', mod.id, typeof mod.id === 'string').then((id) => + id === ERROR ? ERROR : regexers.uuid(id) + ), + check('description', mod.description, typeof mod.description === 'string'), + check('version', mod.version, typeof mod.version === 'string').then((version) => + version === ERROR ? ERROR : regexers.semver(version) + ), + check('tags', mod.tags, Array.isArray(mod.tags)).then((tags) => + tags === ERROR ? ERROR : tags.map((tag) => check('tag', tag, typeof tag === 'string')) + ), + check('authors', mod.authors, Array.isArray(mod.authors)).then((authors) => + authors === ERROR + ? ERROR + : authors.map((author) => [ + check('author.name', author.name, typeof author.name === 'string'), + check( + 'author.email', + author.email, + typeof author.email === 'string' + ).then((email) => (email === ERROR ? ERROR : regexers.email(email))), + check('author.url', author.url, typeof author.url === 'string').then((url) => + url === ERROR ? ERROR : regexers.url(url) + ), + check('author.icon', author.icon, typeof author.icon === 'string').then((icon) => + icon === ERROR ? ERROR : regexers.url(icon) + ), + ]) + ), + check( + 'css', + mod.css, + !!mod.css && typeof mod.css === 'object' && !Array.isArray(mod.css) + ).then((css) => { + if (css === ERROR) return ERROR; + if (!css) return undefined; + return ['frame', 'client', 'gui'] + .filter((dest) => css[dest]) + .map(async (dest) => + check(`css.${dest}`, css[dest], Array.isArray(css[dest])).then((files) => + files === ERROR + ? ERROR + : files.map(async (file) => + check( + `css.${dest} file`, + file, + await fs.isFile(`${mod._dir}/${file}`, '.css') + ) + ) + ) + ); + }), + check( + 'js', + mod.js, + !!mod.js && typeof mod.js === 'object' && !Array.isArray(mod.js) + ).then(async (js) => { + if (js === ERROR) return ERROR; + if (!js) return undefined; + return [ + check('js.client', js.client, !js.client ?? Array.isArray(js.client)).then( + (client) => { + if (client === ERROR) return ERROR; + if (!client) return undefined; + return client.map(async (file) => + check('js.client file', file, await fs.isFile(file, '.js')) + ); + } + ), + check('js.electron', js.electron, !js.electron ?? Array.isArray(js.electron)).then( + (electron) => { + if (electron === ERROR) return ERROR; + if (!electron) return undefined; + return electron.map((file) => + check( + 'js.electron file', + file, + !!file && typeof file === 'object' && !Array.isArray(file) + ).then(async (file) => + file === ERROR + ? ERROR + : [ + check( + 'js.electron file source', + file.source, + await fs.isFile(file.source, '.js') + ), + // referencing the file within the electron app + // existence can't be validated, so only format is + check( + 'js.electron file target', + file.target, + typeof file.target === 'string' && file.target.endsWith('.js') + ), + ] + ) + ); + } + ), + ]; + }), + check( + 'options', + mod.options, + !mod.options ?? (await fs.isFile(mod.options, '.json')) + ).then(async (filepath) => { + if (filepath === ERROR) return ERROR; + if (!filepath) return undefined; + try { + const options = await fs.getJSON(`/repo/${mod._dir}/${mod.options}`); + // todo: validate options + } catch { + err(`invalid options ${filepath}`); + } + }), + ].flat(Infinity) + ); + +registry.get = async (callback = () => {}) => { + registry._list = []; + if (!registry._errors) registry._errors = []; + for (const dir of await fs.getJSON('/repo/registry.json')) { + const err = (message) => [registry._errors.push({ source: dir, message }), ERROR][1]; + try { + const mod = await fs.getJSON(`/repo/${dir}/mod.json`); + mod._dir = dir; + mod.tags = mod.tags ?? []; + mod.css = mod.css ?? []; + mod.js = mod.js ?? {}; + + const check = (prop, value, condition) => + Promise.resolve(condition ? value : err(`invalid ${prop} ${JSON.stringify(value)}`)), + validation = await registry.validate(mod, err, check); + if (validation.every((condition) => condition !== ERROR)) registry._list.push(mod); + } catch (e) { + err('invalid mod.json'); + } + } + callback(registry._list); + return registry._list; +}; + +registry.errors = async (callback = () => {}) => { + if (!registry._errors) await registry.get(); + callback(registry._errors); + return registry._errors; +}; + +export { web, fs, regexers, registry }; diff --git a/extension/src/launcher.js b/extension/src/launcher.js index 823042c..ef576ef 100644 --- a/extension/src/launcher.js +++ b/extension/src/launcher.js @@ -6,17 +6,31 @@ 'use strict'; -import { registry, web } from './helpers.js'; +import { web, fs, registry } from './helpers.js'; -export default async () => { - web.whenReady().then(async () => { - for (let mod of await registry) { +export default () => { + web.whenReady([], async () => { + web.loadStyleset('/src/client.css'); + for (let mod of await registry.get()) { for (let sheet of mod.css?.client || []) { - web.loadStyleset(`repo/${mod.dir}/${sheet}`); + web.loadStyleset(`/repo/${mod._dir}/${sheet}`); } for (let script of mod.js?.client || []) { - import(chrome.runtime.getURL(`repo/${mod.dir}/${script}`)); + import(chrome.runtime.getURL(`/repo/${mod._dir}/${script}`)); } } }); + + const sidebarSelector = + '#notion-app > div > div.notion-cursor-listener > div.notion-sidebar-container > div > div > div > div:nth-child(4)'; + web.whenReady([sidebarSelector], async () => { + const enhancerIcon = await fs.getText('/icons/colour.svg'), + enhancerSidebarElement = web.createElement( + `
${enhancerIcon}
notion-enhancer
` + ); + enhancerSidebarElement.addEventListener('click', (event) => + chrome.runtime.sendMessage({ type: 'openEnhancerMenu' }) + ); + document.querySelector(sidebarSelector).appendChild(enhancerSidebarElement); + }); }; diff --git a/extension/worker.js b/extension/worker.js index 2b07ef6..1dca318 100644 --- a/extension/worker.js +++ b/extension/worker.js @@ -3,3 +3,30 @@ * (c) 2021 dragonwocky (https://dragonwocky.me/) * (https://notion-enhancer.github.io/) under the MIT license */ + +'use strict'; + +let _enhancerMenuTab; +async function openEnhancerMenu() { + if (!_enhancerMenuTab) { + _enhancerMenuTab = await new Promise((res, rej) => { + chrome.tabs.create( + { + url: chrome.runtime.getURL('/src/gui.html'), + }, + res + ); + }); + } + chrome.tabs.highlight({ 'tabs': _enhancerMenuTab.index }, function () {}); +} +chrome.action.onClicked.addListener(openEnhancerMenu); + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + switch (request.type) { + case 'openEnhancerMenu': + openEnhancerMenu(); + break; + } + return true; +});