mirror of
				https://github.com/notion-enhancer/notion-enhancer.git
				synced 2025-10-30 21:58:08 +11:00 
			
		
		
		
	
		
			
				
	
	
		
			428 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			428 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /*
 | |
|  * notion-enhancer
 | |
|  * (c) 2021 dragonwocky <thedragonring.bod@gmail.com> (https://dragonwocky.me/)
 | |
|  * (https://notion-enhancer.github.io/) under the MIT license
 | |
|  */
 | |
| 
 | |
| 'use strict';
 | |
| 
 | |
| export const ERROR = Symbol();
 | |
| 
 | |
| export const env = {};
 | |
| env.name = 'browser';
 | |
| 
 | |
| env.openEnhancerMenu = () => chrome.runtime.sendMessage({ type: 'enhancerMenu.open' });
 | |
| 
 | |
| /** - */
 | |
| 
 | |
| export const storage = {};
 | |
| 
 | |
| storage.set = (id, key, value) =>
 | |
|   new Promise((res, rej) => chrome.storage.sync.set({ [`[${id}]${key}`]: value }, res));
 | |
| storage.get = (id, key) =>
 | |
|   new Promise((res, rej) =>
 | |
|     chrome.storage.sync.get([`[${id}]${key}`], (values) => res(values[`[${id}]${key}`]))
 | |
|   );
 | |
| 
 | |
| /** - */
 | |
| 
 | |
| export const web = {};
 | |
| 
 | |
| web.whenReady = (selectors = [], callback = () => {}) => {
 | |
|   return new Promise((res, rej) => {
 | |
|     function onLoad() {
 | |
|       let isReadyInt;
 | |
|       isReadyInt = setInterval(isReadyTest, 100);
 | |
|       function isReadyTest() {
 | |
|         if (selectors.every((selector) => document.querySelector(selector))) {
 | |
|           clearInterval(isReadyInt);
 | |
|           callback();
 | |
|           res(true);
 | |
|         }
 | |
|       }
 | |
|       isReadyTest();
 | |
|     }
 | |
|     if (document.readyState !== 'complete') {
 | |
|       document.addEventListener('readystatechange', (event) => {
 | |
|         if (document.readyState === 'complete') onLoad();
 | |
|       });
 | |
|     } else onLoad();
 | |
|   });
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * @param {string} html
 | |
|  * @returns HTMLElement
 | |
|  */
 | |
| web.createElement = (html) => {
 | |
|   const template = document.createElement('template');
 | |
|   template.innerHTML = html.includes('<pre')
 | |
|     ? html.trim()
 | |
|     : html
 | |
|         .split(/\n/)
 | |
|         .map((line) => line.trim())
 | |
|         .join(' ');
 | |
|   return template.content.firstElementChild;
 | |
| };
 | |
| web.escapeHtml = (str) =>
 | |
|   str
 | |
|     .replace(/&(?![^\s]+;)/g, '&')
 | |
|     .replace(/</g, '<')
 | |
|     .replace(/>/g, '>')
 | |
|     .replace(/'/g, ''')
 | |
|     .replace(/"/g, '"');
 | |
| 
 | |
| // why a tagged template? because it syntax highlights
 | |
| // https://marketplace.visualstudio.com/items?itemName=bierner.lit-html
 | |
| web.html = (html, ...templates) => html.map((str) => str + (templates.shift() || '')).join('');
 | |
| 
 | |
| /**
 | |
|  * @param {string} sheet
 | |
|  */
 | |
| web.loadStyleset = (sheet) => {
 | |
|   document.head.appendChild(
 | |
|     web.createElement(`<link rel="stylesheet" href="${chrome.runtime.getURL(sheet)}">`)
 | |
|   );
 | |
|   return true;
 | |
| };
 | |
| 
 | |
| /**
 | |
|  * @param {array} keys
 | |
|  * @param {function} callback
 | |
|  */
 | |
| web.hotkeyListener = (keys, callback) => {
 | |
|   if (!web._hotkeyListener) {
 | |
|     web._hotkeys = [];
 | |
|     web._hotkeyListener = document.addEventListener('keyup', (event) => {
 | |
|       for (let hotkey of web._hotkeys) {
 | |
|         const matchesEvent = hotkey.keys.every((key) => {
 | |
|           const modifiers = {
 | |
|             altKey: 'alt',
 | |
|             ctrlKey: 'ctrl',
 | |
|             metaKey: 'meta',
 | |
|             shiftKey: 'shift',
 | |
|           };
 | |
|           for (let 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._hotkeys.push({ keys, callback });
 | |
| };
 | |
| 
 | |
| /** - */
 | |
| 
 | |
| export const fmt = {};
 | |
| 
 | |
| import './dep/prism.js';
 | |
| fmt.Prism = Prism;
 | |
| fmt.Prism.manual = true;
 | |
| fmt.Prism.hooks.add('complete', (event) => {
 | |
|   if (!fmt.Prism._stylesheetLoaded) {
 | |
|     web.loadStyleset('./dep/prism.css');
 | |
|     fmt.Prism._stylesheetLoaded = true;
 | |
|   }
 | |
| });
 | |
| // delete globalThis['Prism'];
 | |
| 
 | |
| import './dep/markdown-it.min.js';
 | |
| fmt.md = new markdownit({
 | |
|   linkify: true,
 | |
|   highlight: (str, lang) =>
 | |
|     web.html`<pre${lang ? ` class="language-${lang}"` : ''}><code>${web.escapeHtml(
 | |
|       str
 | |
|     )}</code></pre>`,
 | |
| });
 | |
| fmt.md.renderer.rules.code_block = (tokens, idx, options, env, slf) =>
 | |
|   web.html`<pre${slf.renderAttrs(tokens[idx])}><code>${web.escapeHtml(
 | |
|     tokens[idx].content
 | |
|   )}</code></pre>\n`;
 | |
| // delete globalThis['markdownit'];
 | |
| 
 | |
| /** - */
 | |
| 
 | |
| export const fs = {};
 | |
| 
 | |
| /**
 | |
|  * @param {string} path
 | |
|  */
 | |
| 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(path));
 | |
|     return true;
 | |
|   } catch {
 | |
|     return false;
 | |
|   }
 | |
| };
 | |
| 
 | |
| /** - */
 | |
| 
 | |
| export 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;
 | |
|   },
 | |
| };
 | |
| 
 | |
| /** - */
 | |
| 
 | |
| export const registry = {};
 | |
| 
 | |
| 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 === ERROR ? ERROR : regexers.uuid(id)
 | |
|     ),
 | |
|     check('version', mod.version, typeof mod.version === 'string').then((version) =>
 | |
|       version === ERROR ? ERROR : regexers.semver(version)
 | |
|     ),
 | |
|     check('description', mod.description, typeof mod.description === 'string'),
 | |
|     check(
 | |
|       'preview',
 | |
|       mod.preview,
 | |
|       mod.preview === undefined || typeof mod.preview === 'string'
 | |
|     ).then((preview) =>
 | |
|       preview ? (preview === ERROR ? ERROR : regexers.url(preview)) : undefined
 | |
|     ),
 | |
|     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) =>
 | |
|       css
 | |
|         ? css === ERROR
 | |
|           ? ERROR
 | |
|           : ['frame', 'client', 'menu']
 | |
|               .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(`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 === 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(`repo/${mod._dir}/${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(`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 === ERROR
 | |
|         ? 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 === ERROR
 | |
|                       ? 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 'file':
 | |
|                 conditions.push(
 | |
|                   check(
 | |
|                     'option.extensions',
 | |
|                     option.extensions,
 | |
|                     !option.extensions || Array.isArray(option.extensions)
 | |
|                   ).then((extensions) =>
 | |
|                     extensions
 | |
|                       ? extensions === ERROR
 | |
|                         ? 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.description',
 | |
|                 option.description,
 | |
|                 !option.description || typeof option.description === 'string'
 | |
|               ),
 | |
|             ];
 | |
|           })
 | |
|     ),
 | |
|   ];
 | |
|   do {
 | |
|     conditions = await Promise.all(conditions.flat(Infinity));
 | |
|   } while (conditions.some((condition) => Array.isArray(condition)));
 | |
|   return conditions;
 | |
| };
 | |
| 
 | |
| 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 ?? {};
 | |
|       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 !== 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;
 | |
| };
 |