diff --git a/extension/CHANGELOG.md b/extension/CHANGELOG.md index 7fc6111..357c0a4 100644 --- a/extension/CHANGELOG.md +++ b/extension/CHANGELOG.md @@ -9,7 +9,6 @@ a complete rework of the enhancer including a port to the browser as a chrome ex - new: notifications sourced from an online endpoint for sending global user alerts. - improved: a redesigned menu with a better overview of all mods and separate pages for options and documentation. - improved: theming variables that are more specific, less laggy, and less complicated. -- improved: switched from fontawesome to [feather](https://feathericons.com/) icons. // todo diff --git a/extension/api.js b/extension/api.js deleted file mode 100644 index fd14c5d..0000000 --- a/extension/api.js +++ /dev/null @@ -1,872 +0,0 @@ -/* - * notion-enhancer - * (c) 2021 dragonwocky (https://dragonwocky.me/) - * (https://notion-enhancer.github.io/) under the MIT license - */ - -/** - * @module notion-enhancer/api - * @version 0.11.0 - */ - -'use strict'; - -/** - * environment-specific methods and constants - * @namespace env - */ -export const env = {}; -/** - * an error constant used in validation, distinct from null or undefined - * @constant {Symbol} - */ -env.ERROR = Symbol(); -/** - * the environment/platform name code is currently being executed in - * @constant {string} - */ -env.name = 'extension'; -/** - * all environments/platforms currently supported by the enhancer - * @constant {array} - */ -env.supported = ['linux', 'win32', 'darwin', 'extension']; -/** - * the current version of the enhancer - * @constant {string} - */ -env.version = chrome.runtime.getManifest().version; -/** open the enhancer's menu */ -env.openEnhancerMenu = () => chrome.runtime.sendMessage({ action: 'openEnhancerMenu' }); -/** focus an active notion tab */ -env.focusNotion = () => chrome.runtime.sendMessage({ action: 'focusNotion' }); -/** reload all notion and enhancer menu tabs to apply changes */ -env.reloadTabs = () => chrome.runtime.sendMessage({ action: 'reloadTabs' }); - -/** - * environment-specific data persistence - * @namespace storage - */ -export const storage = {}; -/** - * get data persisted within an enhancer store - * @param {string} namespace - the name of the store, e.g. a mod id - * @param {string} [key] - the key being looked up - * @param {*} [fallback] - a default value if the key does not exist - * @returns {Promise} value ?? fallback - */ -storage.get = (namespace, key = undefined, fallback = undefined) => - new Promise((res, rej) => - chrome.storage.sync.get([namespace], async (values) => { - const defaults = await registry.defaults(namespace); - values = - values[namespace] && - Object.getOwnPropertyNames(values[namespace]).length && - (!key || Object.getOwnPropertyNames(values[namespace]).includes(key)) - ? values[namespace] - : defaults; - res((key ? values[key] : values) ?? fallback); - }) - ); -/** - * persist data to an enhancer store - * @param {string} namespace - the name of the store, e.g. a mod id - * @param {string} key - the key associated with the value - * @param {*} value - the data to save - */ -storage._queue = []; -storage.set = (namespace, key, value) => { - const precursor = storage._queue[storage._queue.length - 1] || undefined, - interaction = new Promise(async (res, rej) => { - if (precursor !== undefined) { - await precursor; - storage._queue.shift(); - } - const values = await storage.get(namespace, undefined, {}); - if (values.hasOwnProperty(key)) delete values[key]; - storage._onChangeListeners.forEach((listener) => - listener({ type: 'set', namespace, key, new: value, old: values[key] }) - ); - chrome.storage.sync.set({ [namespace]: { ...values, [key]: value } }, res); - }); - storage._queue.push(interaction); - return interaction; -}; -/** - * clear data from an enhancer store - * @param {string} namespace - the name of the store, e.g. a mod id - */ -storage.reset = (namespace) => { - storage._onChangeListeners.forEach((listener) => - listener({ type: 'reset', namespace, key: undefined, new: undefined, old: undefined }) - ); - return new Promise((res, rej) => chrome.storage.sync.set({ [namespace]: undefined }, res)); -}; -storage._onChangeListeners = []; -/** - * add an event listener for changes in storage - * @param {onStorageChangeCallback} callback - called whenever a change in - * storage is initiated from the current process - */ -storage.addChangeListener = (callback) => { - storage._onChangeListeners.push(callback); -}; -/** - * remove a listener added with storage.addChangeListener - * @param {onStorageChangeCallback} callback - */ -storage.removeChangeListener = (callback) => { - storage._onChangeListeners = storage._onChangeListeners.filter( - (listener) => listener !== callback - ); -}; -/** - * @callback onStorageChangeCallback - * @param {object} event - * @param {string} event.type - 'set' or 'reset' - * @param {string} event.namespace- the name of the store, e.g. a mod id - * @param {string} [event.key] - the key associated with the changed value - * @param {string} [event.new] - the new value being persisted to the store - * @param {string} [event.old] - the previous value associated with the key - */ - -/** - * environment-specific filesystem reading - * @namespace fs - */ -export const fs = {}; -/** - * fetch and parse a json file's contents - * @param {string} path - a url or within-the-enhancer filepath - * @returns {object} the json value of the requested file as a js object - */ -fs.getJSON = (path) => - fetch(path.startsWith('https://') ? path : chrome.runtime.getURL(path)).then((res) => - res.json() - ); -/** - * fetch a text file's contents - * @param {string} path - a url or within-the-enhancer filepath - * @returns {object} the text content of the requested file - */ -fs.getText = (path) => - fetch(path.startsWith('https://') ? path : chrome.runtime.getURL(path)).then((res) => - res.text() - ); -/** - * check if a file exists - * @param {string} path - a url or within-the-enhancer filepath - * @returns {boolean} whether or not the file exists - */ -fs.isFile = async (path) => { - try { - await fetch(path.startsWith('https://') ? path : chrome.runtime.getURL(path)); - return true; - } catch { - return false; - } -}; - -/** - * helpers for manipulation of a webpage - * @namespace web - */ -export const web = {}; -/** - * wait until a page is loaded and ready for modification - * @param {array} [selectors=[]] - wait for the existence fo elements that match these css selectors - * @returns {Promise} a promise that will resolve when the page is ready - */ -web.whenReady = (selectors = []) => { - return new Promise((res, rej) => { - function onLoad() { - let isReadyInt; - isReadyInt = setInterval(isReadyTest, 100); - function isReadyTest() { - if (selectors.every((selector) => document.querySelector(selector))) { - clearInterval(isReadyInt); - res(true); - } - } - isReadyTest(); - } - if (document.readyState !== 'complete') { - document.addEventListener('readystatechange', (event) => { - if (document.readyState === 'complete') onLoad(); - }); - } else onLoad(); - }); -}; -/** - * loads/applies a css stylesheet to the page - * @param {string} path - a url or within-the-enhancer filepath - */ -web.loadStyleset = (path) => { - document.head.appendChild( - web.createElement( - web.html`` - ) - ); - return true; -}; -/** - * fetch an icon from the icons folder - * @param {string} path - the path to the icon within the folder - * @returns {string} the content of an svg file - */ -web.getIcon = (path) => fs.getText(`icons/${path}.svg`); -/** replace all [data-icon] elems with matching svgs from the icons folder */ -web.loadIcons = () => { - document.querySelectorAll('[data-icon]:not(svg:not(:empty))').forEach(async (icon) => { - const svg = web.createElement(await web.getIcon(icon.dataset.icon)); - for (const attr of icon.attributes) { - svg.setAttribute(attr.name, attr.value); - } - icon.replaceWith(svg); - }); -}; -/** - * create a html fragment (collection of nodes) from a string - * @param {string} html - a valid html string - * @returns {Element} the constructed html fragment - */ -web.createFragment = (html = '') => { - return document.createRange().createContextualFragment( - html.includes(' line.trim()) - .filter((line) => line.length) - .join(' ') - ); -}; -/** - * create a single html element from a string (instead of separately - * creating the element and then applying attributes and appending children) - * @param {string} html - the full html of an element inc. attributes and children - * @returns {Element} the constructed html element - */ -web.createElement = (html) => { - return web.createFragment(html).children[0]; -}; -/** - * replace special html characters with escaped versions - * @param {string} str - * @returns {string} escaped string - */ -web.escapeHtml = (str) => - str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/'/g, ''') - .replace(/"/g, '"'); -/** - * a tagged template processor for syntax higlighting purposes - * (https://marketplace.visualstudio.com/items?itemName=bierner.lit-html) - * @example - * const el = web.html`

hello

`; // = '

hello

' - * document.body.append(web.createElement(el)); - */ -web.html = (html, ...templates) => html.map((str) => str + (templates.shift() ?? '')).join(''); -web._hotkeyEventListeners = []; -/** - * register a hotkey listener to the page - * @param {array} keys - the combination of keys that will trigger the hotkey. - * key codes can be tested at http://keycode.info/. - * available modifiers are 'alt', 'ctrl', 'meta', and 'shift' - * @param {function} callback - called whenever the keys are pressed - */ -web.addHotkeyListener = (keys, callback) => { - if (typeof keys === 'string') keys = keys.split('+'); - if (!web._hotkeyEvent) { - web._hotkeyEvent = document.addEventListener('keyup', (event) => { - for (const hotkey of web._hotkeyEventListeners) { - const matchesEvent = hotkey.keys.every((key) => { - const modifiers = { - altKey: 'alt', - ctrlKey: 'ctrl', - metaKey: 'meta', - shiftKey: 'shift', - }; - for (const modifier in modifiers) { - if (key.toLowerCase() === modifiers[modifier] && event[modifier]) return true; - } - const pressedKeycode = [event.key.toLowerCase(), event.code.toLowerCase()]; - if (pressedKeycode.includes(key.toLowerCase())) return true; - }); - if (matchesEvent) hotkey.callback(); - } - }); - } - web._hotkeyEventListeners.push({ keys, callback }); -}; -/** - * remove a listener added with web.addHotkeyListener - * @param {function} callback - */ -web.removeHotkeyListener = (callback) => { - web._hotkeyEventListeners = web._hotkeyEventListeners.filter( - (listener) => listener.callback !== callback - ); -}; -web._documentObserverListeners = []; -web._documentObserverEvents = []; -/** - * add a listener to watch for changes to the dom - * @param {onDocumentObservedCallback} callback - * @param {array} [selectors] - */ -web.addDocumentObserver = (callback, selectors = []) => { - if (!web._documentObserver) { - const handle = (queue) => { - while (queue.length) { - const event = queue.shift(); - for (const listener of web._documentObserverListeners) { - if ( - !listener.selectors.length || - listener.selectors.some( - (selector) => - event.target.matches(selector) || event.target.matches(`${selector} *`) - ) - ) { - listener.callback(event); - } - } - } - }; - web._documentObserver = new MutationObserver((list, observer) => { - if (!web._documentObserverEvents.length) - requestIdleCallback(() => handle(web._documentObserverEvents)); - web._documentObserverEvents.push(...list); - }); - web._documentObserver.observe(document.body, { - childList: true, - subtree: true, - attributes: true, - }); - } - web._documentObserverListeners.push({ callback, selectors }); -}; -/** - * remove a listener added with web.addDocumentObserver - * @param {onDocumentObservedCallback} callback - */ -web.removeDocumentObserver = (callback) => { - web._documentObserverListeners = web._documentObserverListeners.filter( - (listener) => listener.callback !== callback - ); -}; -/** - * @callback onDocumentObservedCallback - * @param {MutationRecord} event - the observed dom mutation event - */ -/** - * add a tooltip to show extra information on hover - * @param {HTMLElement} $element - the element that will trigger the tooltip when hovered - * @param {string} text - the markdown content of the tooltip - */ -web.addTooltip = ($element, text) => { - if (!web._$tooltip) { - web._$tooltip = web.createElement(web.html`
`); - document.body.append(web._$tooltip); - } - text = fmt.md.render(text); - $element.addEventListener('mouseover', (event) => { - web._$tooltip.innerHTML = text; - web._$tooltip.style.display = 'block'; - }); - $element.addEventListener('mousemove', (event) => { - web._$tooltip.style.top = event.clientY - web._$tooltip.clientHeight + 'px'; - web._$tooltip.style.left = - event.clientX < window.innerWidth / 2 ? event.clientX + 20 + 'px' : ''; - }); - $element.addEventListener('mouseout', (event) => { - web._$tooltip.style.display = ''; - }); -}; - -/** - * helpers for formatting or parsing text - * @namespace fmt - */ -export const fmt = {}; -import './dep/jscolor.min.js'; -/** color picker with alpha channel using https://jscolor.com/ */ -fmt.JSColor = JSColor; -import './dep/prism.js'; -/** syntax highlighting using https://prismjs.com/ */ -fmt.Prism = Prism; -fmt.Prism.manual = true; -fmt.Prism.hooks.add('complete', async (event) => { - event.element.parentElement.removeAttribute('tabindex'); - event.element.parentElement.parentElement - .querySelector('.copy-to-clipboard-button') - .prepend(web.createElement(await web.getIcon('fa/regular/copy'))); - // if (!fmt.Prism._stylesheetLoaded) { - // web.loadStyleset('./dep/prism.css'); - // fmt.Prism._stylesheetLoaded = true; - // } -}); -// delete memberThis['Prism']; -import './dep/markdown-it.min.js'; -/** markdown -> html using https://github.com/markdown-it/markdown-it/ */ -fmt.md = new markdownit({ - linkify: true, - highlight: (str, lang) => - web.html`
${web.escapeHtml(
-      str
-    )}
`, -}); -fmt.md.renderer.rules.code_block = (tokens, idx, options, env, slf) => { - const attrIdx = tokens[idx].attrIndex('class'); - if (attrIdx === -1) { - tokens[idx].attrPush(['class', 'match-braces language-plaintext']); - } else tokens[idx].attrs[attrIdx][1] = 'match-braces language-plaintext'; - return web.html`${web.escapeHtml( - tokens[idx].content - )}\n`; -}; -fmt.md.core.ruler.push( - 'heading_ids', - function (md, state) { - const slugs = new Set(); - state.tokens.forEach(function (token, i) { - if (token.type === 'heading_open') { - const text = md.renderer.render(state.tokens[i + 1].children, md.options), - slug = fmt.slugger(text, slugs); - slugs.add(slug); - const attrIdx = token.attrIndex('id'); - if (attrIdx === -1) { - token.attrPush(['id', slug]); - } else token.attrs[attrIdx][1] = slug; - } - }); - }.bind(null, fmt.md) -); -// delete memberThis['markdownit']; -/** - * transform a heading into a slug (a lowercase alphanumeric string separated by dashes), - * e.g. for use as an anchor id - * @param {string} heading - the original heading to be slugified - * @param {Set} [slugs] - a list of pre-generated slugs to avoid duplicates - * @returns {string} the generated slug - */ -fmt.slugger = (heading, slugs = new Set()) => { - heading = heading - .replace(/\s/g, '-') - .replace(/[^A-Za-z0-9-_]/g, '') - .toLowerCase(); - let i = 0, - slug = heading; - while (slugs.has(slug)) { - i++; - slug = `${heading}-${i}`; - } - return slug; -}; - -/** - * pattern validators - * @namespace regexers - */ -export const regexers = {}; -/** - * check for a valid uuid (8-4-4-4-12 hexadecimal digits) - * @param {string} str - the string to test - * @param {function} err - a callback to execute if the test fails - * @returns {boolean | env.ERROR} true or the env.ERROR constant - */ -regexers.uuid = (str, err = () => {}) => { - 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; - err(`invalid uuid ${str}`); - return env.ERROR; -}; -/** - * check for a valid semver (MAJOR.MINOR.PATCH) - * @param {string} str - the string to test - * @param {function} err - a callback to execute if the test fails - * @returns {boolean | env.ERROR} true or the env.ERROR constant - */ -regexers.semver = (str, err = () => {}) => { - 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; - err(`invalid semver ${str}`); - return env.ERROR; -}; -/** - * check for a valid email (someone@somewhere.domain) - * @param {string} str - the string to test - * @param {function} err - a callback to execute if the test fails - * @returns {boolean | env.ERROR} true or the env.ERROR constant - */ -regexers.email = (str, err = () => {}) => { - 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; - err(`invalid email ${str}`); - return env.ERROR; -}; -/** - * check for a valid url (https://example.com/path) - * @param {string} str - the string to test - * @param {function} err - a callback to execute if the test fails - * @returns {boolean | env.ERROR} true or the env.ERROR constant - */ -regexers.url = (str, err = () => {}) => { - 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; - err(`invalid url ${str}`); - return env.ERROR; -}; -/** - * check for a valid color (https://regexr.com/39cgj) - * @param {string} str - the string to test - * @param {function} err - a callback to execute if the test fails - * @returns {boolean | env.ERROR} true or the env.ERROR constant - */ -regexers.color = (str, err = () => {}) => { - const match = str.match(/^(?:#|0x)(?:[a-f0-9]{3}|[a-f0-9]{6})\b|(?:rgb|hsl)a?\([^\)]*\)$/i); - if (match && match.length) return true; - err(`invalid color ${str}`); - return env.ERROR; -}; - -/** - * an api for interacting with the enhancer's repository of mods - * @namespace registry - */ -export const registry = {}; -/** mod ids whitelisted as part of the enhancer's core, permanently enabled */ -registry.CORE = [ - 'a6621988-551d-495a-97d8-3c568bca2e9e', - '0f0bf8b6-eae6-4273-b307-8fc43f2ee082', -]; -/** - * internally used to validate mod.json files and provide helpful errors - * @private - * @param {object} mod - a mod's mod.json in object form - * @param {*} err - a callback to execute if a test fails - * @param {*} check - a function to test a condition - * @returns {array} the results of the validation - */ -registry.validate = async (mod, err, check) => { - let conditions = [ - check('name', mod.name, typeof mod.name === 'string'), - check('id', mod.id, typeof mod.id === 'string').then((id) => - id === env.ERROR ? env.ERROR : regexers.uuid(id, err) - ), - check('version', mod.version, typeof mod.version === 'string').then((version) => - version === env.ERROR ? env.ERROR : regexers.semver(version, err) - ), - check('description', mod.description, typeof mod.description === 'string'), - check( - 'preview', - mod.preview, - mod.preview === undefined || typeof mod.preview === 'string' - ).then((preview) => - preview ? (preview === env.ERROR ? env.ERROR : regexers.url(preview, err)) : undefined - ), - check('tags', mod.tags, Array.isArray(mod.tags)).then((tags) => - tags === env.ERROR - ? env.ERROR - : tags.map((tag) => check('tag', tag, typeof tag === 'string')) - ), - check('authors', mod.authors, Array.isArray(mod.authors)).then((authors) => - authors === env.ERROR - ? env.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 === env.ERROR ? env.ERROR : regexers.email(email, err)) - ), - check('author.url', author.url, typeof author.url === 'string').then((url) => - url === env.ERROR ? env.ERROR : regexers.url(url, err) - ), - check('author.icon', author.icon, typeof author.icon === 'string').then((icon) => - icon === env.ERROR ? env.ERROR : regexers.url(icon, err) - ), - ]) - ), - check( - 'environments', - mod.environments, - !mod.environments || Array.isArray(mod.environments) - ).then((environments) => - environments - ? environments === env.ERROR - ? env.ERROR - : environments.map((environment) => - check('environment', environment, env.supported.includes(environment)) - ) - : undefined - ), - check( - 'css', - mod.css, - mod.css && typeof mod.css === 'object' && !Array.isArray(mod.css) - ).then((css) => - css - ? css === env.ERROR - ? env.ERROR - : ['frame', 'client', 'menu'] - .filter((dest) => css[dest]) - .map(async (dest) => - check(`css.${dest}`, css[dest], Array.isArray(css[dest])).then((files) => - files === env.ERROR - ? env.ERROR - : files.map(async (file) => - check( - `css.${dest} file`, - file, - await fs.isFile(`repo/${mod._dir}/${file}`, '.css') - ) - ) - ) - ) - : undefined - ), - check('js', mod.js, mod.js && typeof mod.js === 'object' && !Array.isArray(mod.js)).then( - async (js) => { - if (js === env.ERROR) return env.ERROR; - if (!js) return undefined; - return [ - check('js.client', js.client, !js.client || Array.isArray(js.client)).then( - (client) => { - if (client === env.ERROR) return env.ERROR; - if (!client) return undefined; - return client.map(async (file) => - check( - 'js.client file', - file, - await fs.isFile(`repo/${mod._dir}/${file}`, '.js') - ) - ); - } - ), - check('js.electron', js.electron, !js.electron || Array.isArray(js.electron)).then( - (electron) => { - if (electron === env.ERROR) return env.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 === env.ERROR - ? env.ERROR - : [ - check( - 'js.electron file source', - file.source, - await fs.isFile(`repo/${mod._dir}/${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, Array.isArray(mod.options)).then((options) => - options === env.ERROR - ? env.ERROR - : options.map((option) => { - const conditions = []; - switch (option.type) { - case 'toggle': - conditions.push( - check('option.value', option.value, typeof option.value === 'boolean') - ); - break; - case 'select': - conditions.push( - check('option.values', option.values, Array.isArray(option.values)).then( - (value) => - value === env.ERROR - ? env.ERROR - : value.map((option) => - check('option.values option', option, typeof option === 'string') - ) - ) - ); - break; - case 'text': - conditions.push( - check('option.value', option.value, typeof option.value === 'string') - ); - break; - case 'number': - conditions.push( - check('option.value', option.value, typeof option.value === 'number') - ); - break; - case 'color': - conditions.push( - check('option.value', option.value, typeof option.value === 'string').then( - (color) => (color === env.ERROR ? env.ERROR : regexers.color(color, err)) - ) - ); - break; - case 'file': - conditions.push( - check( - 'option.extensions', - option.extensions, - !option.extensions || Array.isArray(option.extensions) - ).then((extensions) => - extensions - ? extensions === env.ERROR - ? env.ERROR - : extensions.map((ext) => - check('option.extension', ext, typeof ext === 'string') - ) - : undefined - ) - ); - break; - default: - return check('option.type', option.type, false); - } - return [ - conditions, - check( - 'option.key', - option.key, - typeof option.key === 'string' && !option.key.match(/\s/) - ), - check('option.label', option.label, typeof option.label === 'string'), - check( - 'option.tooltip', - option.tooltip, - !option.tooltip || typeof option.tooltip === 'string' - ), - check( - 'option.environments', - option.environments, - !option.environments || Array.isArray(option.environments) - ).then((environments) => - environments - ? environments === env.ERROR - ? env.ERROR - : environments.map((environment) => - check( - 'option.environment', - environment, - env.supported.includes(environment) - ) - ) - : undefined - ), - ]; - }) - ), - ]; - do { - conditions = await Promise.all(conditions.flat(Infinity)); - } while (conditions.some((condition) => Array.isArray(condition))); - return conditions; -}; -/** - * get the default values of a mod's options according to its mod.json - * @param {string} id - the uuid of the mod - * @returns {object} the mod's default values - */ -registry.defaults = async (id) => { - const mod = - regexers.uuid(id) !== env.ERROR - ? (await registry.get()).find((mod) => mod.id === id) - : undefined; - if (!mod || !mod.options) return {}; - const defaults = {}; - for (const opt of mod.options) { - switch (opt.type) { - case 'toggle': - case 'text': - case 'number': - case 'color': - defaults[opt.key] = opt.value; - break; - case 'select': - defaults[opt.key] = opt.values[0]; - break; - case 'file': - defaults[opt.key] = undefined; - break; - } - } - return defaults; -}; -/** - * get all available enhancer mods in the repo - * @param {function} filter - a function to filter out mods - * @returns {array} the filtered and validated list of mod.json objects - * @example - * // will only get mods that are enabled in the current environment - * await registry.get((mod) => registry.isEnabled(mod.id)) - */ -registry.get = async (filter = (mod) => true) => { - if (!registry._errors) registry._errors = []; - if (!registry._list || !registry._list.length) { - registry._list = []; - for (const dir of await fs.getJSON('repo/registry.json')) { - const err = (message) => [registry._errors.push({ source: dir, message }), env.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 ?? {}; - mod.options = mod.options ?? []; - 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 !== env.ERROR)) registry._list.push(mod); - } catch { - err('invalid mod.json'); - } - } - } - const list = []; - for (const mod of registry._list) if (await filter(mod)) list.push(mod); - return list; -}; -/** - * gets a list of errors encountered while validating the mod.json files - * @returns {object} - {source: directory, message: string } - */ -registry.errors = async () => { - if (!registry._errors) await registry.get(); - return registry._errors; -}; -/** - * checks if a mod is core whitelisted, environment disabled or menu enabled - * @param {string} id - the uuid of the mod - * @returns {boolean} whether or not the mod is enabled - */ -registry.isEnabled = async (id) => { - const mod = (await registry.get()).find((mod) => mod.id === id); - if (mod.environments && !mod.environments.includes(env.name)) return false; - if (registry.CORE.includes(id)) return true; - return await storage.get('_mods', id, false); -}; diff --git a/extension/api/_.mjs b/extension/api/_.mjs new file mode 100644 index 0000000..60c0556 --- /dev/null +++ b/extension/api/_.mjs @@ -0,0 +1,22 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** @module notion-enhancer/api */ + +/** environment-specific methods and constants */ +export * as env from './env.mjs'; +/** helpers for formatting or parsing text */ +export * as fmt from './fmt.mjs'; +/** environment-specific filesystem reading */ +export * as fs from './fs.mjs'; +/** pattern validators */ +export * as regex from './regex.mjs'; +/** interactions with the enhancer's repository of mods */ +// export * as registry from './registry.mjs'; +/** environment-specific data persistence */ +export * as storage from './storage.mjs'; diff --git a/extension/api/env.mjs b/extension/api/env.mjs new file mode 100644 index 0000000..62037c8 --- /dev/null +++ b/extension/api/env.mjs @@ -0,0 +1,46 @@ +/* + * 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 + */ + +/** + * an error constant used in validation, distinct from null or undefined + * @constant {Symbol} + */ +export const ERROR = Symbol(); + +/** + * the environment/platform name code is currently being executed in + * @constant {string} + */ +export const name = 'extension'; + +/** + * all environments/platforms currently supported by the enhancer + * @constant {array} + */ +export const supported = ['linux', 'win32', 'darwin', 'extension']; + +/** + * the current version of the enhancer + * @constant {string} + */ +export const version = chrome.runtime.getManifest().version; + +/** open the enhancer's menu */ +export const openEnhancerMenu = () => + chrome.runtime.sendMessage({ action: 'openEnhancerMenu' }); + +/** focus an active notion tab */ +export const focusNotion = () => chrome.runtime.sendMessage({ action: 'focusNotion' }); + +/** reload all notion and enhancer menu tabs to apply changes */ +export const reloadTabs = () => chrome.runtime.sendMessage({ action: 'reloadTabs' }); diff --git a/extension/api/fmt.mjs b/extension/api/fmt.mjs new file mode 100644 index 0000000..743cc6b --- /dev/null +++ b/extension/api/fmt.mjs @@ -0,0 +1,80 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * helpers for formatting or parsing text + * @module notion-enhancer/api/fmt + */ + +import '../dep/prism.min.js'; +/** syntax highlighting using https://prismjs.com/ */ +export const prism = Prism; +Prism.manual = true; +Prism.hooks.add('complete', async (event) => { + event.element.parentElement.removeAttribute('tabindex'); + event.element.parentElement.parentElement + .querySelector('.copy-to-clipboard-button') + .prepend(web.createElement(await web.getIcon('fa/regular/copy'))); +}); + +import '../dep/markdown-it.min.js'; +/** markdown -> html using https://github.com/markdown-it/markdown-it/ */ +export const md = new markdownit({ + linkify: true, + highlight: (str, lang) => + web.html`
${web.escapeHtml(
+      str
+    )}
`, +}); +md.renderer.rules.code_block = (tokens, idx, options, env, slf) => { + const attrIdx = tokens[idx].attrIndex('class'); + if (attrIdx === -1) { + tokens[idx].attrPush(['class', 'match-braces language-plaintext']); + } else tokens[idx].attrs[attrIdx][1] = 'match-braces language-plaintext'; + return web.html`${web.escapeHtml( + tokens[idx].content + )}\n`; +}; +md.core.ruler.push( + 'heading_ids', + function (md, state) { + const slugs = new Set(); + state.tokens.forEach(function (token, i) { + if (token.type === 'heading_open') { + const text = md.renderer.render(state.tokens[i + 1].children, md.options), + slug = fmt.slugger(text, slugs); + slugs.add(slug); + const attrIdx = token.attrIndex('id'); + if (attrIdx === -1) { + token.attrPush(['id', slug]); + } else token.attrs[attrIdx][1] = slug; + } + }); + }.bind(null, md) +); + +/** + * transform a heading into a slug (a lowercase alphanumeric string separated by dashes), + * e.g. for use as an anchor id + * @param {string} heading - the original heading to be slugified + * @param {Set} [slugs] - a list of pre-generated slugs to avoid duplicates + * @returns {string} the generated slug + */ +export const slugger = (heading, slugs = new Set()) => { + heading = heading + .replace(/\s/g, '-') + .replace(/[^A-Za-z0-9-_]/g, '') + .toLowerCase(); + let i = 0, + slug = heading; + while (slugs.has(slug)) { + i++; + slug = `${heading}-${i}`; + } + return slug; +}; diff --git a/extension/api/fs.mjs b/extension/api/fs.mjs new file mode 100644 index 0000000..aaa0209 --- /dev/null +++ b/extension/api/fs.mjs @@ -0,0 +1,49 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * environment-specific filesystem reading + * @module notion-enhancer/api/fs + */ + +/** + * transform a path relative to the enhancer root directory into an absolute path + * @param {string} path - a url or within-the-enhancer filepath + * @returns {string} an absolute filepath + */ +export const localPath = chrome.runtime.getURL; + +/** + * fetch and parse a json file's contents + * @param {string} path - a url or within-the-enhancer filepath + * @returns {object} the json value of the requested file as a js object + */ +export const getJSON = (path) => + fetch(path.startsWith('http') ? path : localPath(path)).then((res) => res.json()); + +/** + * fetch a text file's contents + * @param {string} path - a url or within-the-enhancer filepath + * @returns {string} the text content of the requested file + */ +export const getText = (path) => + fetch(path.startsWith('http') ? path : localPath(path)).then((res) => res.text()); + +/** + * check if a file exists + * @param {string} path - a url or within-the-enhancer filepath + * @returns {boolean} whether or not the file exists + */ +export const isFile = async (path) => { + try { + await fetch(path.startsWith('http') ? path : localPath(path)); + return true; + } catch { + return false; + } +}; diff --git a/extension/api/regex.mjs b/extension/api/regex.mjs new file mode 100644 index 0000000..3ea1545 --- /dev/null +++ b/extension/api/regex.mjs @@ -0,0 +1,62 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * pattern validators + * @module notion-enhancer/api/regex + */ + +const patterns = { + 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 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); diff --git a/extension/api/registry.mjs b/extension/api/registry.mjs new file mode 100644 index 0000000..a884850 --- /dev/null +++ b/extension/api/registry.mjs @@ -0,0 +1,342 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (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 regex from './regex.mjs'; + +/** 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', +]; + +/** + * internally used to validate mod.json files and provide helpful errors + * @private + * @param {object} mod - a mod's mod.json in object form + * @param {*} err - a callback to execute if a test fails + * @param {*} check - a function to test a condition + * @returns {array} the results of the validation + */ +registry.validate = async (mod, err, check) => { + let conditions = [ + check('name', mod.name, typeof mod.name === 'string'), + check('id', mod.id, typeof mod.id === 'string').then((id) => + id === env.ERROR ? env.ERROR : regex.uuid(id, err) + ), + check('version', mod.version, typeof mod.version === 'string').then((version) => + version === env.ERROR ? env.ERROR : regex.semver(version, err) + ), + check('description', mod.description, typeof mod.description === 'string'), + check( + 'preview', + mod.preview, + mod.preview === undefined || typeof mod.preview === 'string' + ).then((preview) => + preview ? (preview === env.ERROR ? env.ERROR : regex.url(preview, err)) : undefined + ), + check('tags', mod.tags, Array.isArray(mod.tags)).then((tags) => + tags === env.ERROR + ? env.ERROR + : tags.map((tag) => check('tag', tag, typeof tag === 'string')) + ), + check('authors', mod.authors, Array.isArray(mod.authors)).then((authors) => + authors === env.ERROR + ? env.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 === env.ERROR ? env.ERROR : regex.email(email, err)) + ), + check('author.url', author.url, typeof author.url === 'string').then((url) => + url === env.ERROR ? env.ERROR : regex.url(url, err) + ), + check('author.icon', author.icon, typeof author.icon === 'string').then((icon) => + icon === env.ERROR ? env.ERROR : regex.url(icon, err) + ), + ]) + ), + check( + 'environments', + mod.environments, + !mod.environments || Array.isArray(mod.environments) + ).then((environments) => + environments + ? environments === env.ERROR + ? env.ERROR + : environments.map((environment) => + check('environment', environment, env.supported.includes(environment)) + ) + : undefined + ), + check( + 'css', + mod.css, + mod.css && typeof mod.css === 'object' && !Array.isArray(mod.css) + ).then((css) => + css + ? css === env.ERROR + ? env.ERROR + : ['frame', 'client', 'menu'] + .filter((dest) => css[dest]) + .map(async (dest) => + check(`css.${dest}`, css[dest], Array.isArray(css[dest])).then((files) => + files === env.ERROR + ? env.ERROR + : files.map(async (file) => + check( + `css.${dest} file`, + file, + await fs.isFile(`repo/${mod._dir}/${file}`, '.css') + ) + ) + ) + ) + : undefined + ), + check('js', mod.js, mod.js && typeof mod.js === 'object' && !Array.isArray(mod.js)).then( + async (js) => { + if (js === env.ERROR) return env.ERROR; + if (!js) return undefined; + return [ + check('js.client', js.client, !js.client || Array.isArray(js.client)).then( + (client) => { + if (client === env.ERROR) return env.ERROR; + if (!client) return undefined; + return client.map(async (file) => + check( + 'js.client file', + file, + await fs.isFile(`repo/${mod._dir}/${file}`, '.js') + ) + ); + } + ), + check('js.electron', js.electron, !js.electron || Array.isArray(js.electron)).then( + (electron) => { + if (electron === env.ERROR) return env.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 === env.ERROR + ? env.ERROR + : [ + check( + 'js.electron file source', + file.source, + await fs.isFile(`repo/${mod._dir}/${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, Array.isArray(mod.options)).then((options) => + options === env.ERROR + ? env.ERROR + : options.map((option) => { + const conditions = []; + switch (option.type) { + case 'toggle': + conditions.push( + check('option.value', option.value, typeof option.value === 'boolean') + ); + break; + case 'select': + conditions.push( + check('option.values', option.values, Array.isArray(option.values)).then( + (value) => + value === env.ERROR + ? env.ERROR + : value.map((option) => + check('option.values option', option, typeof option === 'string') + ) + ) + ); + break; + case 'text': + conditions.push( + check('option.value', option.value, typeof option.value === 'string') + ); + break; + case 'number': + conditions.push( + check('option.value', option.value, typeof option.value === 'number') + ); + break; + case 'color': + conditions.push( + check('option.value', option.value, typeof option.value === 'string').then( + (color) => (color === env.ERROR ? env.ERROR : regex.color(color, err)) + ) + ); + break; + case 'file': + conditions.push( + check( + 'option.extensions', + option.extensions, + !option.extensions || Array.isArray(option.extensions) + ).then((extensions) => + extensions + ? extensions === env.ERROR + ? env.ERROR + : extensions.map((ext) => + check('option.extension', ext, typeof ext === 'string') + ) + : undefined + ) + ); + break; + default: + return check('option.type', option.type, false); + } + return [ + conditions, + check( + 'option.key', + option.key, + typeof option.key === 'string' && !option.key.match(/\s/) + ), + check('option.label', option.label, typeof option.label === 'string'), + check( + 'option.tooltip', + option.tooltip, + !option.tooltip || typeof option.tooltip === 'string' + ), + check( + 'option.environments', + option.environments, + !option.environments || Array.isArray(option.environments) + ).then((environments) => + environments + ? environments === env.ERROR + ? env.ERROR + : environments.map((environment) => + check( + 'option.environment', + environment, + env.supported.includes(environment) + ) + ) + : undefined + ), + ]; + }) + ), + ]; + do { + conditions = await Promise.all(conditions.flat(Infinity)); + } while (conditions.some((condition) => Array.isArray(condition))); + return conditions; +}; + +/** + * get the default values of a mod's options according to its mod.json + * @param {string} id - the uuid of the mod + * @returns {object} the mod's default values + */ +export const defaults = async (id) => { + const mod = regex.uuid(id) ? (await registry.get()).find((mod) => mod.id === id) : undefined; + if (!mod || !mod.options) return {}; + const defaults = {}; + for (const opt of mod.options) { + switch (opt.type) { + case 'toggle': + case 'text': + case 'number': + case 'color': + defaults[opt.key] = opt.value; + break; + case 'select': + defaults[opt.key] = opt.values[0]; + break; + case 'file': + defaults[opt.key] = undefined; + break; + } + } + return defaults; +}; + +/** + * get all available mods in the repo + * @param {function} filter - a function to filter out mods + * @returns {array} the filtered and validated list of mod.json objects + * @example + * // will only get mods that are enabled in the current environment + * await registry.get((mod) => registry.isEnabled(mod.id)) + */ +export const get = async (filter = (mod) => true) => { + if (!registry._errors) registry._errors = []; + if (!registry._list || !registry._list.length) { + registry._list = []; + for (const dir of await fs.getJSON('repo/registry.json')) { + const err = (message) => [registry._errors.push({ source: dir, message }), env.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 ?? {}; + mod.options = mod.options ?? []; + 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 !== env.ERROR)) registry._list.push(mod); + } catch { + err('invalid mod.json'); + } + } + } + const list = []; + for (const mod of registry._list) if (await filter(mod)) list.push(mod); + return list; +}; + +/** + * gets a list of errors encountered while validating the mod.json files + * @returns {object} - {source: directory, message: string } + */ +registry.errors = async () => { + if (!registry._errors) await registry.get(); + return registry._errors; +}; + +/** + * 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 + */ +registry.isEnabled = async (id) => { + const mod = (await registry.get()).find((mod) => mod.id === id); + if (mod.environments && !mod.environments.includes(env.name)) return false; + if (registry.CORE.includes(id)) return true; + return await storage.get('_mods', id, false); +}; diff --git a/extension/api/storage.mjs b/extension/api/storage.mjs new file mode 100644 index 0000000..6778845 --- /dev/null +++ b/extension/api/storage.mjs @@ -0,0 +1,106 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * environment-specific data persistence + * @module notion-enhancer/api/storage + */ + +const _queue = [], + _onChangeListeners = []; + +/** + * get persisted data + * @param {array} path - the path of keys to the value being fetched + * @param {*} [fallback] - a default value if the path is not matched + * @returns {Promise} value ?? fallback + */ +export const get = (path, fallback = undefined) => { + if (!path.length) return fallback; + const namespace = path.shift(); + return new Promise((res, rej) => + chrome.storage.sync.get([namespace], async (values) => { + let value = values[namespace]; + while (path.length) { + value = value[path.shift()]; + if (path.length && !value) { + value = fallback; + break; + } + } + res(value ?? fallback); + }) + ); +}; + +/** + * persist data + * @param {array} path - the path of keys to the value being set + * @param {*} value - the data to save + */ +export const set = (path, value) => { + if (!path.length) return undefined; + const precursor = _queue[_queue.length - 1] || undefined, + interaction = new Promise(async (res, rej) => { + if (precursor !== undefined) { + await precursor; + _queue.shift(); + } + const pathClone = [...path], + namespace = path.shift(); + chrome.storage.sync.get([namespace], async (values) => { + const update = values[namespace] ?? {}; + let pointer = update, + old; + while (true) { + const key = path.shift(); + if (!path.length) { + old = pointer[key]; + pointer[key] = value; + break; + } else if (!pointer[key]) pointer[key] = {}; + pointer = pointer[key]; + } + chrome.storage.sync.set({ [namespace]: update }, () => { + _onChangeListeners.forEach((listener) => + listener({ type: 'set', path: pathClone, new: value, old }) + ); + res(value); + }); + }); + }); + _queue.push(interaction); + return interaction; +}; + +/** + * add an event listener for changes in storage + * @param {onStorageChangeCallback} callback - called whenever a change in + * storage is initiated from the current process + */ +export const addChangeListener = (callback) => { + _onChangeListeners.push(callback); +}; + +/** + * remove a listener added with storage.addChangeListener + * @param {onStorageChangeCallback} callback + */ +export const removeChangeListener = (callback) => { + _onChangeListeners = _onChangeListeners.filter((listener) => listener !== callback); +}; + +/** + * @callback onStorageChangeCallback + * @param {object} event + * @param {string} event.type - 'set' or 'reset' + * @param {string} event.namespace- the name of the store, e.g. a mod id + * @param {string} [event.key] - the key associated with the changed value + * @param {string} [event.new] - the new value being persisted to the store + * @param {string} [event.old] - the previous value associated with the key + */ diff --git a/extension/api/web.mjs b/extension/api/web.mjs new file mode 100644 index 0000000..645fc92 --- /dev/null +++ b/extension/api/web.mjs @@ -0,0 +1,305 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * helpers for manipulation of a webpage + * @module notion-enhancer/api/env + */ + +import { localPath } from './fs.mjs'; +import { md } from './fmt.mjs'; + +const _hotkeyEventListeners = [], + _documentObserverListeners = [], + _documentObserverEvents = []; + +let _$featherStylesheet, _$tooltip, _$tooltipStylesheet, _hotkeyEvent, _documentObserver; + +import '../dep/jscolor.min.js'; +/** color picker with alpha channel using https://jscolor.com/ */ +export const jscolor = JSColor; + +/** + * wait until a page is loaded and ready for modification + * @param {array} [selectors=[]] - wait for the existence of elements that match these css selectors + * @returns {Promise} a promise that will resolve when the page is ready + */ +export const whenReady = (selectors = []) => { + return new Promise((res, rej) => { + function onLoad() { + let isReadyInt; + isReadyInt = setInterval(isReadyTest, 100); + function isReadyTest() { + if (selectors.every((selector) => document.querySelector(selector))) { + clearInterval(isReadyInt); + res(true); + } + } + isReadyTest(); + } + if (document.readyState !== 'complete') { + document.addEventListener('readystatechange', (event) => { + if (document.readyState === 'complete') onLoad(); + }); + } else onLoad(); + }); +}; + +/** + * replace special html characters with escaped versions + * @param {string} str + * @returns {string} escaped string + */ +export const escape = (str) => + str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"') + .replace(/\\/g, '\'); + +/** + * a tagged template processor for raw html: + * stringifies, minifies, and syntax highlights + * @example web.raw`

hello

` + * @returns {string} the processed html + */ +export const raw = (str, ...templates) => { + const html = str + .map( + (chunk) => + chunk + + (['string', 'number'].includes(typeof templates[0]) + ? templates.shift() + : escape(JSON.stringify(templates.shift(), null, 2) ?? '')) + ) + .join(''); + return html.includes(' line.trim()) + .filter((line) => line.length) + .join(' '); +}; + +/** + * create a single html element inc. attributes and children from a string + * @example web.html`

hello

` + * @returns {Element} the constructed html element + */ +export const html = (str, ...templates) => { + const $fragment = document.createRange().createContextualFragment(raw(str, ...templates)); + return $fragment.children.length === 1 ? $fragment.children[0] : $fragment.children; +}; + +/** + * appends a list of html elements to a parent + * @param $container - the parent element + * @param $elems - the elements to be appended + * @returns {Element} the updated $container + */ +export const render = ($container, ...$elems) => { + $elems = $elems + .map(($elem) => ($elem instanceof HTMLCollection ? [...$elem] : $elem)) + .flat(Infinity) + .filter(($elem) => $elem); + $container.append(...$elems); + return $container; +}; + +/** + * removes all children from an element without deleting them/their behaviours + * @param $container - the parent element + * @returns {Element} the updated $container + */ +export const empty = ($container) => { + while ($container.firstChild && $container.removeChild($container.firstChild)); + return $container; +}; + +/** + * loads/applies a css stylesheet to the page + * @param {string} path - a url or within-the-enhancer filepath + */ +export const stylesheet = (path) => { + render( + document.head, + html`` + ); + return true; +}; + +/** + * generate an icon from the feather icons set + * @param {string} name - the name/id of the icon + * @param {object} attrs - an object of attributes to apply to the icon e.g. classes + * @returns {string} an svg string + */ +export const icon = (name, attrs = {}) => { + if (!_$featherStylesheet) { + _$featherStylesheet = html``; + render(document.head, _$featherStylesheet); + } + attrs.class = ((attrs.class || '') + ' enhancer--feather').trim(); + return ` `${escape(key)}="${escape(val)}"` + )}>`; +}; + +/** + * add a tooltip to show extra information on hover + * @param {HTMLElement} $ref - the element that will trigger the tooltip when hovered + * @param {string} text - the markdown content of the tooltip + */ +export const tooltip = ($ref, text) => { + if (!_$tooltip) { + _$tooltip = html`
`; + _$tooltipStylesheet = html``; + render(document.head, _$tooltip, _$tooltipStylesheet); + } + text = md.render(text); + $ref.addEventListener('mouseover', (event) => { + _$tooltip.innerHTML = text; + _$tooltip.style.display = 'block'; + }); + $ref.addEventListener('mousemove', (event) => { + _$tooltip.style.top = event.clientY - _$tooltip.clientHeight + 'px'; + _$tooltip.style.left = + event.clientX < window.innerWidth / 2 ? event.clientX + 20 + 'px' : ''; + }); + $ref.addEventListener('mouseout', (event) => { + _$tooltip.style.display = ''; + }); +}; + +/** + * register a hotkey listener to the page + * @param {array} keys - the combination of keys that will trigger the hotkey. + * key codes can be tested at http://keycode.info/ and are case-insensitive. + * available modifiers are 'alt', 'ctrl', 'meta', and 'shift'. + * @param {function} callback - called whenever the keys are pressed + */ +export const addHotkeyListener = (keys, callback) => { + if (typeof keys === 'string') keys = keys.split('+'); + if (!_hotkeyEvent) { + _hotkeyEvent = document.addEventListener('keyup', (event) => { + for (const hotkey of _hotkeyEventListeners) { + const matchesEvent = hotkey.keys.every((key) => { + const modifiers = { + altKey: 'alt', + ctrlKey: 'ctrl', + metaKey: 'meta', + shiftKey: 'shift', + }; + for (const modifier in modifiers) { + if (key.toLowerCase() === modifiers[modifier] && event[modifier]) return true; + } + if (key.toLowerCase() === event.key.toLowerCase()) return true; + }); + if (matchesEvent) hotkey.callback(); + } + }); + } + _hotkeyEventListeners.push({ keys, callback }); +}; +/** + * remove a listener added with web.addHotkeyListener + * @param {function} callback + */ +export const removeHotkeyListener = (callback) => { + _hotkeyEventListeners = _hotkeyEventListeners.filter( + (listener) => listener.callback !== callback + ); +}; + +/** + * add a listener to watch for changes to the dom + * @param {onDocumentObservedCallback} callback + * @param {array} [selectors] + */ +export const addDocumentObserver = (callback, selectors = []) => { + if (!_documentObserver) { + const handle = (queue) => { + while (queue.length) { + const event = queue.shift(); + for (const listener of _documentObserverListeners) { + if ( + !listener.selectors.length || + listener.selectors.some( + (selector) => + event.target.matches(selector) || event.target.matches(`${selector} *`) + ) + ) { + listener.callback(event); + } + } + } + }; + _documentObserver = new MutationObserver((list, observer) => { + if (!_documentObserverEvents.length) + requestIdleCallback(() => handle(_documentObserverEvents)); + _documentObserverEvents.push(...list); + }); + _documentObserver.observe(document.body, { + childList: true, + subtree: true, + attributes: true, + }); + } + _documentObserverListeners.push({ callback, selectors }); +}; + +/** + * remove a listener added with web.addDocumentObserver + * @param {onDocumentObservedCallback} callback + */ +export const removeDocumentObserver = (callback) => { + _documentObserverListeners = _documentObserverListeners.filter( + (listener) => listener.callback !== callback + ); +}; + +/** + * @callback onDocumentObservedCallback + * @param {MutationRecord} event - the observed dom mutation event + */ diff --git a/extension/launcher.js b/extension/launcher.js index d800d15..9018607 100644 --- a/extension/launcher.js +++ b/extension/launcher.js @@ -6,24 +6,26 @@ 'use strict'; +import(chrome.runtime.getURL('api/_.mjs')); + // only load if user is logged into notion and viewing a page -if ( - localStorage['LRU:KeyValueStore2:current-user-id'] && - location.pathname.split(/[/-]/g).reverse()[0].length === 32 -) { - import(chrome.runtime.getURL('api.js')).then(async ({ web, registry }) => { - for (const mod of await registry.get((mod) => registry.isEnabled(mod.id))) { - for (const sheet of mod.css?.client || []) { - web.loadStyleset(`repo/${mod._dir}/${sheet}`); - } - for (const script of mod.js?.client || []) { - import(chrome.runtime.getURL(`repo/${mod._dir}/${script}`)); - } - } - const errors = await registry.errors(); - if (errors.length) { - console.log('notion-enhancer errors:'); - console.table(errors); - } - }); -} +// if ( +// localStorage['LRU:KeyValueStore2:current-user-id'] && +// location.pathname.split(/[/-]/g).reverse()[0].length === 32 +// ) { +// import(chrome.runtime.getURL('api.js')).then(async ({ web, registry }) => { +// for (const mod of await registry.get((mod) => registry.isEnabled(mod.id))) { +// for (const sheet of mod.css?.client || []) { +// web.loadStylesheet(`repo/${mod._dir}/${sheet}`); +// } +// for (const script of mod.js?.client || []) { +// import(chrome.runtime.getURL(`repo/${mod._dir}/${script}`)); +// } +// } +// const errors = await registry.errors(); +// if (errors.length) { +// console.log('notion-enhancer errors:'); +// console.table(errors); +// } +// }); +// } diff --git a/extension/manifest.json b/extension/manifest.json index e772872..9b3101a 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,24 +1,24 @@ { "manifest_version": 3, "name": "notion-enhancer", - "version": "0.11.0-dev", + "version": "0.11.0", "author": "dragonwocky (https://dragonwocky.me/)", "description": "an enhancer/customiser for the all-in-one productivity workspace notion.so", "homepage_url": "https://notion-enhancer.github.io", "icons": { - "16": "icons/colour-x16.png", - "32": "icons/colour-x32.png", - "48": "icons/colour-x48.png", - "128": "icons/colour-x128.png", - "256": "icons/colour-x256.png", - "512": "icons/colour-x512.png" + "16": "icon/colour-x16.png", + "32": "icon/colour-x32.png", + "48": "icon/colour-x48.png", + "128": "icon/colour-x128.png", + "256": "icon/colour-x256.png", + "512": "icon/colour-x512.png" }, "action": {}, "background": { "service_worker": "worker.js" }, "options_page": "repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.html", "web_accessible_resources": [ { - "resources": ["api.js", "repo/*", "icons/*", "dep/*"], + "resources": ["api/*", "dep/*", "icon/*", "repo/*"], "matches": ["https://*.notion.so/*"] } ], diff --git a/extension/repo/bypass-preview@cb6fd684-f113-4a7a-9423-8f0f0cff069f/client.js b/extension/repo/bypass-preview@cb6fd684-f113-4a7a-9423-8f0f0cff069f/client.js index e4e9f3f..b8ff0a7 100644 --- a/extension/repo/bypass-preview@cb6fd684-f113-4a7a-9423-8f0f0cff069f/client.js +++ b/extension/repo/bypass-preview@cb6fd684-f113-4a7a-9423-8f0f0cff069f/client.js @@ -6,7 +6,7 @@ 'use strict'; -import { web } from '../../api.js'; +import { web } from '../../api/_.mjs'; web.whenReady().then(async () => { const openAsPage = document.querySelector( diff --git a/extension/repo/calendar-scroll@b1c7db33-dfee-489a-a76c-0dd66f7ed29a/client.js b/extension/repo/calendar-scroll@b1c7db33-dfee-489a-a76c-0dd66f7ed29a/client.js index d442e06..96c893a 100644 --- a/extension/repo/calendar-scroll@b1c7db33-dfee-489a-a76c-0dd66f7ed29a/client.js +++ b/extension/repo/calendar-scroll@b1c7db33-dfee-489a-a76c-0dd66f7ed29a/client.js @@ -6,7 +6,7 @@ 'use strict'; -import { web } from '../../api.js'; +import { web } from '../../api/_.mjs'; const $button = web.createElement( web.html`` diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/client.js b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/client.js index 6705055..fdbd364 100644 --- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/client.js +++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/client.js @@ -7,7 +7,7 @@ 'use strict'; const _id = 'a6621988-551d-495a-97d8-3c568bca2e9e'; -import { env, storage, web, fs, registry } from '../../api.js'; +import { env, storage, web, fs, registry } from '../../api/_.mjs'; const sidebarSelector = '#notion-app > div > div.notion-cursor-listener > div.notion-sidebar-container > div > div > div > div:nth-child(4)'; diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.js b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.js index 05f91a1..50e3b88 100644 --- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.js +++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/menu.js @@ -7,11 +7,11 @@ 'use strict'; const _id = 'a6621988-551d-495a-97d8-3c568bca2e9e'; -import { env, storage, web, fmt, fs, registry, regexers } from '../../api.js'; +import { env, storage, web, fmt, fs, registry, regexers } from '../../api/_.mjs'; for (const mod of await registry.get((mod) => registry.isEnabled(mod.id))) { for (const sheet of mod.css?.menu || []) { - web.loadStyleset(`repo/${mod._dir}/${sheet}`); + web.loadStylesheet(`repo/${mod._dir}/${sheet}`); } } async function loadTheme() { diff --git a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/router.js b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/router.js index d9b9c15..229be2e 100644 --- a/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/router.js +++ b/extension/repo/menu@a6621988-551d-495a-97d8-3c568bca2e9e/router.js @@ -6,7 +6,7 @@ 'use strict'; -import { web } from '../../api.js'; +import { web } from '../../api/_.mjs'; export const getSearch = () => new Map( diff --git a/extension/repo/theming@0f0bf8b6-eae6-4273-b307-8fc43f2ee082/client.js b/extension/repo/theming@0f0bf8b6-eae6-4273-b307-8fc43f2ee082/client.js index 18f5cd3..ea246ad 100644 --- a/extension/repo/theming@0f0bf8b6-eae6-4273-b307-8fc43f2ee082/client.js +++ b/extension/repo/theming@0f0bf8b6-eae6-4273-b307-8fc43f2ee082/client.js @@ -6,7 +6,7 @@ 'use strict'; -import { web } from '../../api.js'; +import { web } from '../../api/_.mjs'; const $root = document.querySelector(':root'); web.addDocumentObserver((mutation) => { diff --git a/extension/repo/tweaks@5174a483-c88d-4bf8-a95f-35cd330b76e2/client.js b/extension/repo/tweaks@5174a483-c88d-4bf8-a95f-35cd330b76e2/client.js index 0324b98..42ae21c 100644 --- a/extension/repo/tweaks@5174a483-c88d-4bf8-a95f-35cd330b76e2/client.js +++ b/extension/repo/tweaks@5174a483-c88d-4bf8-a95f-35cd330b76e2/client.js @@ -7,7 +7,7 @@ 'use strict'; const _id = '5174a483-c88d-4bf8-a95f-35cd330b76e2'; -import { env, storage, web } from '../../api.js'; +import { env, storage, web } from '../../api/_.mjs'; web.whenReady().then(async () => { const cssInsert = await storage.get(_id, '_file.insert.css');