diff --git a/.gitmodules b/.gitmodules index e69de29..7fe5a85 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,16 @@ +[submodule "api"] + path = extension/api + url = git@github.com:notion-enhancer/api.git + branch = dev +[submodule "repo"] + path = extension/repo + url = git@github.com:notion-enhancer/repo.git + branch = dev +[submodule "media"] + path = extension/media + url = git@github.com:notion-enhancer/media.git + branch = main +[submodule "dep"] + path = extension/dep + url = git@github.com:notion-enhancer/dep.git + branch = main diff --git a/extension/.github/workflows/submodules.yml b/extension/.github/workflows/submodules.yml new file mode 100644 index 0000000..463aa5e --- /dev/null +++ b/extension/.github/workflows/submodules.yml @@ -0,0 +1,20 @@ +name: 'update submodules' + +on: + workflow_dispatch: + +jobs: + sync: + name: 'update submodules' + runs-on: ubuntu-latest + steps: + - name: checkout repo + uses: actions/checkout@v2 + with: + submodules: true + - name: pull updates + run: | + git pull --recurse-submodules + git submodule update --remote --recursive + - name: commit changes + uses: stefanzweifel/git-auto-commit-action@v4 diff --git a/extension/LICENSE b/extension/LICENSE new file mode 100644 index 0000000..b503961 --- /dev/null +++ b/extension/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 dragonwocky (https://dragonwocky.me/) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extension/README.md b/extension/README.md new file mode 100644 index 0000000..420700e --- /dev/null +++ b/extension/README.md @@ -0,0 +1,5 @@ +# notion-enhancer/extension + +an enhancer/customiser for the all-in-one productivity workspace notion.so (browser) + +[read the docs online](https://notion-enhancer.github.io/) diff --git a/extension/api b/extension/api new file mode 160000 index 0000000..9815d73 --- /dev/null +++ b/extension/api @@ -0,0 +1 @@ +Subproject commit 9815d73b9277e96864654a8d8dd48762039c9845 diff --git a/extension/dep b/extension/dep new file mode 160000 index 0000000..1a47625 --- /dev/null +++ b/extension/dep @@ -0,0 +1 @@ +Subproject commit 1a4762550fe185706be26678f734b0475066c3e4 diff --git a/extension/env/env.mjs b/extension/env/env.mjs new file mode 100644 index 0000000..17c905d --- /dev/null +++ b/extension/env/env.mjs @@ -0,0 +1,41 @@ +/* + * 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 */ + +/** + * the environment/platform name code is currently being executed in + * @constant + * @type {string} + */ +export const name = 'extension'; + +/** + * the current version of the enhancer + * @constant + * @type {string} + */ +export const version = chrome.runtime.getManifest().version; + +/** + * open the enhancer's menu + * @type {function} + */ +export const focusMenu = () => chrome.runtime.sendMessage({ action: 'focusMenu' }); + +/** + * focus an active notion tab + * @type {function} + */ +export const focusNotion = () => chrome.runtime.sendMessage({ action: 'focusNotion' }); + +/** + * reload all notion and enhancer menu tabs to apply changes + * @type {function} + */ +export const reload = () => chrome.runtime.sendMessage({ action: 'reload' }); diff --git a/extension/env/fs.mjs b/extension/env/fs.mjs new file mode 100644 index 0000000..020a37d --- /dev/null +++ b/extension/env/fs.mjs @@ -0,0 +1,48 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** environment-specific file reading */ + +/** + * 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 + * @param {object=} opts - the second argument of a fetch() request + * @returns {object} the json value of the requested file as a js object + */ +export const getJSON = (path, opts = {}) => + fetch(path.startsWith('http') ? path : localPath(path), opts).then((res) => res.json()); + +/** + * fetch a text file's contents + * @param {string} path - a url or within-the-enhancer filepath + * @param {object=} opts - the second argument of a fetch() request + * @returns {string} the text content of the requested file + */ +export const getText = (path, opts = {}) => + fetch(path.startsWith('http') ? path : localPath(path), opts).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/env/storage.mjs b/extension/env/storage.mjs new file mode 100644 index 0000000..0018513 --- /dev/null +++ b/extension/env/storage.mjs @@ -0,0 +1,116 @@ +/* + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** environment-specific data persistence */ + +const _queue = [], + _onChangeListeners = []; + +/** + * get persisted data + * @param {string[]} path - the path of keys to the value being fetched + * @param {unknown=} 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; + return new Promise((res, rej) => + chrome.storage.local.get(async (values) => { + let value = values; + while (path.length) { + if (value === undefined) { + value = fallback; + break; + } + value = value[path.shift()]; + } + res(value ?? fallback); + }) + ); +}; + +/** + * persist data + * @param {string[]} path - the path of keys to the value being set + * @param {unknown} value - the data to save + * @returns {Promise} resolves when data has been saved + */ +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[0]; + chrome.storage.local.get(async (values) => { + let pointer = values, + old; + while (path.length) { + const key = path.shift(); + if (!path.length) { + old = pointer[key]; + pointer[key] = value; + break; + } + pointer[key] = pointer[key] ?? {}; + pointer = pointer[key]; + } + chrome.storage.local.set({ [namespace]: values[namespace] }, () => { + _onChangeListeners.forEach((listener) => + listener({ path: pathClone, new: value, old }) + ); + res(value); + }); + }); + }); + _queue.push(interaction); + return interaction; +}; + +/** + * create a wrapper for accessing a partition of the storage + * @param {string[]} namespace - the path of keys to prefix all storage requests with + * @param {function=} get - the storage get function to be wrapped + * @param {function=} set - the storage set function to be wrapped + * @returns {object} an object with the wrapped get/set functions + */ +export const db = (namespace, getFunc = get, setFunc = set) => { + if (typeof namespace === 'string') namespace = [namespace]; + return { + get: (path = [], fallback = undefined) => getFunc([...namespace, ...path], fallback), + set: (path, value) => setFunc([...namespace, ...path], value), + }; +}; + +/** + * 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.path- the path of keys to 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/init.js b/extension/init.js new file mode 100644 index 0000000..e8de939 --- /dev/null +++ b/extension/init.js @@ -0,0 +1,35 @@ +/* + * notion-enhancer + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +(async () => { + const site = location.host.endsWith('.notion.site'), + page = location.pathname.split(/[/-]/g).reverse()[0].length === 32, + whitelisted = ['/', '/onboarding'].includes(location.pathname), + signedIn = localStorage['LRU:KeyValueStore2:current-user-id']; + + if (site || page || (whitelisted && signedIn)) { + const api = await import(chrome.runtime.getURL('api/index.mjs')), + { fs, registry, web } = api; + + for (const mod of await registry.list((mod) => registry.enabled(mod.id))) { + for (const sheet of mod.css?.client || []) { + web.loadStylesheet(`repo/${mod._dir}/${sheet}`); + } + for (let script of mod.js?.client || []) { + script = await import(fs.localPath(`repo/${mod._dir}/${script}`)); + script.default(api, await registry.db(mod.id)); + } + } + + const errors = await registry.errors(); + if (errors.length) { + console.log('[notion-enhancer] registry errors:'); + console.table(errors); + } + } +})(); diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..4d4d969 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,30 @@ +{ + "manifest_version": 2, + "name": "notion-enhancer", + "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": "media/colour-x16.png", + "32": "media/colour-x32.png", + "48": "media/colour-x48.png", + "128": "media/colour-x128.png", + "256": "media/colour-x256.png", + "512": "media/colour-x512.png" + }, + "browser_action": {}, + "background": { "scripts": ["worker.js"] }, + "options_ui": { + "page": "repo/menu/menu.html", + "open_in_tab": true + }, + "web_accessible_resources": ["env/*", "api/*", "dep/*", "media/*", "repo/*"], + "content_scripts": [ + { + "matches": ["https://*.notion.so/*", "https://*.notion.site/*"], + "js": ["init.js"] + } + ], + "permissions": ["tabs", "storage", "clipboardRead", "clipboardWrite", "unlimitedStorage"] +} diff --git a/extension/media b/extension/media new file mode 160000 index 0000000..2a0a179 --- /dev/null +++ b/extension/media @@ -0,0 +1 @@ +Subproject commit 2a0a17998385f1d86148b9213451b3a5deff6bae diff --git a/extension/repo b/extension/repo new file mode 160000 index 0000000..3a67243 --- /dev/null +++ b/extension/repo @@ -0,0 +1 @@ +Subproject commit 3a67243fd5caec24b484276e563bdb8da7a0adcd diff --git a/extension/worker.js b/extension/worker.js new file mode 100644 index 0000000..48f5833 --- /dev/null +++ b/extension/worker.js @@ -0,0 +1,60 @@ +/* + * notion-enhancer + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +function focusMenu() { + chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT }, (tabs) => { + const url = chrome.runtime.getURL('repo/menu/menu.html'), + menu = tabs.find((tab) => tab.url.startsWith(url)); + if (menu) { + chrome.tabs.highlight({ 'tabs': menu.index }); + } else chrome.tabs.create({ url }); + }); +} +chrome.browserAction.onClicked.addListener(focusMenu); + +function focusNotion() { + chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT }, (tabs) => { + const notion = tabs.find((tab) => { + const url = new URL(tab.url), + matches = url.host.endsWith('.notion.so') || url.host.endsWith('.notion.site'); + return matches; + }); + if (notion) { + chrome.tabs.highlight({ 'tabs': notion.index }); + } else chrome.tabs.create({ url: 'https://notion.so/' }); + }); +} + +function reload() { + chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT }, (tabs) => { + const menu = chrome.runtime.getURL('repo/menu/menu.html'); + tabs.forEach((tab) => { + const url = new URL(tab.url), + matches = + url.host.endsWith('.notion.so') || + url.host.endsWith('.notion.site') || + tab.url.startsWith(menu); + if (matches) chrome.tabs.reload(tab.id); + }); + }); +} + +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + switch (request.action) { + case 'focusMenu': + focusMenu(); + break; + case 'focusNotion': + focusNotion(); + break; + case 'reload': + reload(); + break; + } + return true; +});