diff --git a/api/.github/workflows/update-parents.yml b/api/.github/workflows/update-parents.yml new file mode 100644 index 0000000..ac428e4 --- /dev/null +++ b/api/.github/workflows/update-parents.yml @@ -0,0 +1,29 @@ +name: 'update parent repositories' + +on: + push: + branches: + - dev + +jobs: + sync: + name: update parent + runs-on: ubuntu-latest + strategy: + matrix: + repo: ['notion-enhancer/extension', 'notion-enhancer/desktop'] + steps: + - name: checkout repo + uses: actions/checkout@v2 + with: + token: ${{ secrets.CI_TOKEN }} + submodules: true + repository: ${{ matrix.repo }} + - name: pull updates + run: | + git pull --recurse-submodules + git submodule update --remote --recursive + - name: commit changes + uses: stefanzweifel/git-auto-commit-action@v4 + with: + commit_message: '[${{ github.event.repository.name }}] ${{ github.event.head_commit.message }}' diff --git a/api/LICENSE b/api/LICENSE new file mode 100644 index 0000000..b503961 --- /dev/null +++ b/api/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/api/README.md b/api/README.md new file mode 100644 index 0000000..ab8ffc8 --- /dev/null +++ b/api/README.md @@ -0,0 +1,5 @@ +# notion-enhancer/api + +the standard api available within the notion-enhancer + +[read the docs online](https://notion-enhancer.github.io/documentation/api) diff --git a/api/components/corner-action.css b/api/components/corner-action.css new file mode 100644 index 0000000..fdff4ac --- /dev/null +++ b/api/components/corner-action.css @@ -0,0 +1,55 @@ +/** + * notion-enhancer: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (c) 2021 CloudHill (https://github.com/CloudHill) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +#enhancer--corner-actions { + position: absolute; + bottom: 26px; + right: 26px; + z-index: 101; + cursor: default; + pointer-events: none; + display: flex; + flex-direction: row-reverse; +} + +#enhancer--corner-actions > div { + position: static !important; + width: 36px; + height: 36px; + margin-left: 12px; + pointer-events: auto; + border-radius: 100%; + font-size: 20px; + + display: flex; + align-items: center; + justify-content: center; + color: var(--theme--icon); + fill: var(--theme--icon); + background: var(--theme--ui_corner_action) !important; + box-shadow: var(--theme--ui_shadow, rgba(15, 15, 15, 0.15)) 0px 0px 0px 1px, + var(--theme--ui_shadow, rgba(15, 15, 15, 0.15)) 0px 2px 4px !important; + + user-select: none; + cursor: pointer; +} +#enhancer--corner-actions > div:hover { + background: var(--theme--ui_corner_action-hover) !important; +} +#enhancer--corner-actions > div:active { + background: var(--theme--ui_corner_action-active) !important; +} +#enhancer--corner-actions > div.hidden { + display: none; +} + +#enhancer--corner-actions > div > svg { + width: 22px; + height: 22px; + color: var(--theme--icon); + fill: var(--theme--icon); +} diff --git a/api/components/corner-action.mjs b/api/components/corner-action.mjs new file mode 100644 index 0000000..bf42f9b --- /dev/null +++ b/api/components/corner-action.mjs @@ -0,0 +1,43 @@ +/** + * notion-enhancer: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (c) 2021 CloudHill (https://github.com/CloudHill) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** shared notion-style elements */ + +import { web } from '../index.mjs'; + +let $stylesheet, $cornerButtonsContainer; + +/** + * adds a button to notion's bottom right corner + * @param {string} icon - an svg string + * @param {function} listener - the function to call when the button is clicked + * @returns {Element} the appended corner action element + */ +export const addCornerAction = async (icon, listener) => { + if (!$stylesheet) { + $stylesheet = web.loadStylesheet('api/components/corner-action.css'); + $cornerButtonsContainer = web.html`
`; + } + + await web.whenReady(['.notion-help-button']); + const $helpButton = document.querySelector('.notion-help-button'), + $onboardingButton = document.querySelector('.onboarding-checklist-button'); + if ($onboardingButton) $cornerButtonsContainer.prepend($onboardingButton); + $cornerButtonsContainer.prepend($helpButton); + web.render( + document.querySelector('.notion-app-inner > .notion-cursor-listener'), + $cornerButtonsContainer + ); + + const $actionButton = web.html`
${icon}
`; + $actionButton.addEventListener('click', listener); + web.render($cornerButtonsContainer, $actionButton); + + return $actionButton; +}; diff --git a/api/components/feather.mjs b/api/components/feather.mjs new file mode 100644 index 0000000..9bd68fd --- /dev/null +++ b/api/components/feather.mjs @@ -0,0 +1,33 @@ +/** + * notion-enhancer: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** shared notion-style elements */ + +import { fs, web } from '../index.mjs'; + +let _$iconSheet; + +/** + * 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 feather = async (name, attrs = {}) => { + if (!_$iconSheet) { + _$iconSheet = web.html`${await fs.getText('dep/feather-sprite.svg')}`; + } + attrs.style = ( + (attrs.style ? attrs.style + ';' : '') + + 'stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;fill:none;' + ).trim(); + attrs.viewBox = '0 0 24 24'; + return ` `${web.escape(key)}="${web.escape(val)}"`) + .join(' ')}>${_$iconSheet.getElementById(name)?.innerHTML}`; +}; diff --git a/api/components/index.mjs b/api/components/index.mjs new file mode 100644 index 0000000..28c52b5 --- /dev/null +++ b/api/components/index.mjs @@ -0,0 +1,55 @@ +/** + * notion-enhancer: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * shared notion-style elements + * @namespace components + */ +import * as _api from '../index.mjs'; // trick jsdoc + +/** + * add a tooltip to show extra information on hover + * @param {HTMLElement} $ref - the element that will trigger the tooltip when hovered + * @param {string|HTMLElement} $content - markdown or element content of the tooltip + * @param {object=} options - configuration of how the tooltip should be displayed + * @param {number=} options.delay - the amount of time in ms the element needs to be hovered over + * for the tooltip to be shown (default: 100) + * @param {string=} options.offsetDirection - which side of the element the tooltip + * should be shown on: 'top', 'bottom', 'left' or 'right' (default: 'bottom') + * @param {number=} options.maxLines - the max number of lines that the content may be wrapped + * to, used to position and size the tooltip correctly (default: 1) + */ +export { addTooltip } from './tooltip.mjs'; + +/** + * 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 { feather } from './feather.mjs'; + +/** + * adds a view to the enhancer's side panel + * @param {object} panel - information used to construct and render the panel + * @param {string} panel.id - a uuid, used to restore the last open view on reload + * @param {string} panel.icon - an svg string + * @param {string} panel.title - the name of the view + * @param {Element} panel.$content - an element containing the content of the view + * @param {function} panel.onBlur - runs when the view is selected/focused + * @param {function} panel.onFocus - runs when the view is unfocused/closed + */ +export { addPanelView } from './panel.mjs'; + +/** + * adds a button to notion's bottom right corner + * @param {string} icon - an svg string + * @param {function} listener - the function to call when the button is clicked + * @returns {Element} the appended corner action element + */ +export { addCornerAction } from './corner-action.mjs'; diff --git a/api/components/panel.css b/api/components/panel.css new file mode 100644 index 0000000..547d484 --- /dev/null +++ b/api/components/panel.css @@ -0,0 +1,221 @@ +/** + * notion-enhancer: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (c) 2021 CloudHill (https://github.com/CloudHill) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +:root { + --component--panel-width: 260px; +} + +#enhancer--panel-hover-trigger { + height: 100vh; + width: 2.5rem; + max-height: 100%; + z-index: 999; + position: absolute; + top: 45px; + right: 0; + flex-grow: 0; + flex-shrink: 0; + transition: width 300ms ease-in-out; +} +#enhancer--panel-hover-trigger[data-enhancer-panel-pinned] { + /* taking up the physical space of the panel to move topbar buttons */ + top: 0; + position: relative; + width: var(--component--panel-width); +} + +.notion-frame { + transition: padding-right 300ms ease-in-out; +} +.notion-frame[data-enhancer-panel-pinned] { + padding-right: var(--component--panel-width); +} +.notion-cursor-listener > div[style*='flex-end'] { + transition: margin-right 300ms ease-in-out; +} +.notion-cursor-listener > div[style*='flex-end'][data-enhancer-panel-pinned], +#enhancer--panel[data-enhancer-panel-pinned] + div[style*='flex-end'] { + margin-right: var(--component--panel-width); +} + +#enhancer--panel { + z-index: 999; + position: absolute; + background: var(--theme--bg_secondary); + width: var(--component--panel-width); + right: calc(-1 * var(--component--panel-width)); + opacity: 0; + height: 100vh; + flex-grow: 0; + flex-shrink: 0; + display: flex; + flex-direction: column; + transition: 300ms ease-in; + + margin-top: 5rem; + max-height: calc(100vh - 10rem); +} +#enhancer--panel-hover-trigger:hover + #enhancer--panel:not([data-enhancer-panel-pinned]), +#enhancer--panel:not([data-enhancer-panel-pinned]):hover { + opacity: 1; + transform: translateX(calc(-1 * var(--component--panel-width))); + box-shadow: var(--theme--ui_shadow, rgba(15, 15, 15, 0.05)) 0px 0px 0px 1px, + var(--theme--ui_shadow, rgba(15, 15, 15, 0.1)) 0px 3px 6px, + var(--theme--ui_shadow, rgba(15, 15, 15, 0.2)) 0px 9px 24px !important; +} +#enhancer--panel[data-enhancer-panel-pinned] { + opacity: 1; + max-height: 100%; + margin-top: 0; + transform: translateX(calc(-1 * var(--component--panel-width))); +} + +.enhancer--panel-view-title { + margin: 0; + height: 1em; + display: flex; + align-items: center; + font-size: 1.1rem; + font-weight: 600; +} +.enhancer--panel-view-title svg, +.enhancer--panel-view-title img { + height: 1em; + width: 1em; +} +.enhancer--panel-view-icon { + margin-bottom: -2px; +} +.enhancer--panel-view-title .enhancer--panel-view-title-text { + font-size: 0.9em; + margin: 0 0 0 0.75em; + padding-bottom: 0.3em; + white-space: nowrap; + overflow: hidden; +} + +#enhancer--panel-header { + font-size: 1.2rem; + font-weight: 600; + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + padding: 0.75rem 0 0.75rem 1rem; + background: var(--theme--bg_secondary); +} +#enhancer--panel-header-title { + max-width: calc(100% - 4.25rem); +} +#enhancer--panel-header-title .enhancer--panel-view-title { + font-size: 1.2rem; +} +#enhancer--panel-header-title .enhancer--panel-view-title-text { + max-width: calc(100% - 1.75em); + position: relative; +} + +#enhancer--panel-switcher-overlay-container { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 999; + overflow: hidden; +} +#enhancer--panel-switcher { + max-width: 320px; + position: relative; + right: 14px; + border-radius: 3px; + padding: 8px 0; + background: var(--theme--bg_card); + box-shadow: var(--theme--ui_shadow, rgba(15, 15, 15, 0.05)) 0px 0px 0px 1px, + var(--theme--ui_shadow, rgba(15, 15, 15, 0.1)) 0px 3px 6px, + var(--theme--ui_shadow, rgba(15, 15, 15, 0.2)) 0px 9px 24px !important; + overflow: hidden; +} +.enhancer--panel-switcher-item { + display: flex; + align-items: center; + width: 100%; + padding: 8px 14px; + user-select: none; + cursor: pointer; + overflow: hidden; + position: relative; + background: var(--theme--bg_card); +} +#enhancer--panel-header:hover, +#enhancer--panel-header:focus-within, +.enhancer--panel-switcher-item:hover, +.enhancer--panel-switcher-item:focus { + background: var(--theme--ui_interactive-hover); +} +#enhancer--panel-header:active, +.enhancer--panel-switcher-item:active { + background: var(--theme--ui_interactive-active); +} + +#enhancer--panel-content { + margin: 0.75rem 1rem; + font-size: 1rem; +} +#enhancer--panel-header-switcher { + padding: 4px; +} +#enhancer--panel-header-toggle { + margin-left: auto; + padding-right: 1rem; + height: 100%; + width: 2.5em; + opacity: 0; + display: flex; +} +#enhancer--panel-header-toggle > div { + margin: auto 0 auto auto; +} +#enhancer--panel-header-switcher, +#enhancer--panel-header-toggle > div { + color: var(--theme--icon_secondary); + height: 1em; + width: 1em; + cursor: pointer; + display: flex; + flex-direction: column; + transition: 300ms ease-in-out; +} +#enhancer--panel #enhancer--panel-header-toggle svg { + transition: 300ms ease-in-out; +} +#enhancer--panel:not([data-enhancer-panel-pinned]) #enhancer--panel-header-toggle svg { + transform: rotateZ(-180deg); +} +#enhancer--panel:hover #enhancer--panel-header-toggle { + opacity: 1; +} + +#enhancer--panel-resize { + position: absolute; + left: -5px; + height: 100%; + width: 10px; +} +#enhancer--panel[data-enhancer-panel-pinned] #enhancer--panel-resize { + cursor: col-resize; +} +#enhancer--panel-resize div { + transition: background 150ms ease-in-out; + background: transparent; + width: 2px; + margin-left: 4px; + height: 100%; +} +#enhancer--panel[data-enhancer-panel-pinned] #enhancer--panel-resize:hover div { + background: var(--theme--ui_divider); +} diff --git a/api/components/panel.mjs b/api/components/panel.mjs new file mode 100644 index 0000000..8f82a58 --- /dev/null +++ b/api/components/panel.mjs @@ -0,0 +1,292 @@ +/** + * notion-enhancer: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (c) 2021 CloudHill (https://github.com/CloudHill) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** shared notion-style elements */ + +import { web, components, registry } from '../index.mjs'; + +const _views = [], + svgExpand = web.raw` + + `; + +let $stylesheet, + db, + // open + close + $notionFrame, + $notionRightSidebar, + $panel, + $hoverTrigger, + // resize + $resizeHandle, + dragStartX, + dragStartWidth, + dragEventsFired, + panelWidth, + // render content + $notionApp, + $pinnedToggle, + $panelTitle, + $header, + $panelContent, + $switcher, + $switcherTrigger, + $switcherOverlayContainer; + +// open + close +const panelPinnedAttr = 'data-enhancer-panel-pinned', + isPinned = () => $panel.hasAttribute(panelPinnedAttr), + togglePanel = () => { + const $elems = [$notionFrame, $notionRightSidebar, $hoverTrigger, $panel].filter( + ($el) => $el + ); + if (isPinned()) { + closeSwitcher(); + for (const $elem of $elems) $elem.removeAttribute(panelPinnedAttr); + } else { + for (const $elem of $elems) $elem.setAttribute(panelPinnedAttr, 'true'); + } + db.set(['panel.pinned'], isPinned()); + }, + // resize + updateWidth = () => { + document.documentElement.style.setProperty('--component--panel-width', panelWidth + 'px'); + db.set(['panel.width'], panelWidth); + }, + resizeDrag = (event) => { + event.preventDefault(); + dragEventsFired = true; + panelWidth = dragStartWidth + (dragStartX - event.clientX); + if (panelWidth < 190) panelWidth = 190; + if (panelWidth > 480) panelWidth = 480; + $panel.style.width = panelWidth + 'px'; + $hoverTrigger.style.width = panelWidth + 'px'; + $notionFrame.style.paddingRight = panelWidth + 'px'; + if ($notionRightSidebar) $notionRightSidebar.style.right = panelWidth + 'px'; + }, + resizeEnd = (_event) => { + $panel.style.width = ''; + $hoverTrigger.style.width = ''; + $notionFrame.style.paddingRight = ''; + if ($notionRightSidebar) $notionRightSidebar.style.right = ''; + updateWidth(); + $resizeHandle.style.cursor = ''; + document.body.removeEventListener('mousemove', resizeDrag); + document.body.removeEventListener('mouseup', resizeEnd); + }, + resizeStart = (event) => { + dragStartX = event.clientX; + dragStartWidth = panelWidth; + $resizeHandle.style.cursor = 'auto'; + document.body.addEventListener('mousemove', resizeDrag); + document.body.addEventListener('mouseup', resizeEnd); + }, + // render content + isSwitcherOpen = () => document.body.contains($switcher), + openSwitcher = () => { + if (!isPinned()) return togglePanel(); + web.render($notionApp, $switcherOverlayContainer); + web.empty($switcher); + for (const view of _views) { + const open = $panelTitle.contains(view.$title), + $item = web.render( + web.html`
`, + web.render( + web.html``, + view.$icon.cloneNode(true), + view.$title.cloneNode(true) + ) + ); + $item.addEventListener('click', () => { + renderView(view); + db.set(['panel.open'], view.id); + }); + web.render($switcher, $item); + } + const rect = $header.getBoundingClientRect(); + web.render( + web.empty($switcherOverlayContainer), + web.render( + web.html`
`, + web.render( + web.html`
`, + $switcher + ) + ) + ); + $switcher.querySelector('[data-open]').focus(); + $switcher.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 }); + document.addEventListener('keydown', switcherKeyListeners); + }, + closeSwitcher = () => { + document.removeEventListener('keydown', switcherKeyListeners); + $switcher.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 200 }).onfinish = () => + $switcherOverlayContainer.remove(); + }, + switcherKeyListeners = (event) => { + if (isSwitcherOpen()) { + switch (event.key) { + case 'Escape': + closeSwitcher(); + event.stopPropagation(); + break; + case 'Enter': + document.activeElement.click(); + event.stopPropagation(); + break; + case 'ArrowUp': { + const $prev = event.target.previousElementSibling; + ($prev || event.target.parentElement.lastElementChild).focus(); + event.stopPropagation(); + break; + } + case 'ArrowDown': { + const $next = event.target.nextElementSibling; + ($next || event.target.parentElement.firstElementChild).focus(); + event.stopPropagation(); + break; + } + } + } + }, + renderView = (view) => { + const prevView = _views.find(({ $content }) => document.contains($content)); + web.render( + web.empty($panelTitle), + web.render( + web.html``, + view.$icon, + view.$title + ) + ); + view.onFocus(); + web.render(web.empty($panelContent), view.$content); + if (prevView) prevView.onBlur(); + }; + +async function createPanel() { + await web.whenReady(['.notion-frame']); + $notionFrame = document.querySelector('.notion-frame'); + + $panel = web.html`
`; + $hoverTrigger = web.html`
`; + $resizeHandle = web.html`
`; + $panelTitle = web.html`
`; + $header = web.render(web.html`
`, $panelTitle); + $panelContent = web.html`
`; + $switcher = web.html`
`; + $switcherTrigger = web.html`
+ ${svgExpand} +
`; + $switcherOverlayContainer = web.html`
`; + + const notionRightSidebarSelector = '.notion-cursor-listener > div[style*="flex-end"]', + detectRightSidebar = () => { + if (!document.contains($notionRightSidebar)) { + $notionRightSidebar = document.querySelector(notionRightSidebarSelector); + if (isPinned() && $notionRightSidebar) { + $notionRightSidebar.setAttribute(panelPinnedAttr, 'true'); + } + } + }; + $notionRightSidebar = document.querySelector(notionRightSidebarSelector); + web.addDocumentObserver(detectRightSidebar, [notionRightSidebarSelector]); + + if (await db.get(['panel.pinned'])) togglePanel(); + web.addHotkeyListener(await db.get(['panel.hotkey']), togglePanel); + $pinnedToggle.addEventListener('click', (event) => { + event.stopPropagation(); + togglePanel(); + }); + web.render( + $panel, + web.render($header, $panelTitle, $switcherTrigger, $pinnedToggle), + $panelContent, + $resizeHandle + ); + + await enablePanelResize(); + await createViews(); + + const cursorListenerSelector = + '.notion-cursor-listener > .notion-sidebar-container ~ [style^="position: absolute"]'; + await web.whenReady([cursorListenerSelector]); + document.querySelector(cursorListenerSelector).before($hoverTrigger, $panel); +} + +async function enablePanelResize() { + panelWidth = await db.get(['panel.width'], 240); + updateWidth(); + $resizeHandle.addEventListener('mousedown', resizeStart); + $resizeHandle.addEventListener('click', () => { + if (dragEventsFired) { + dragEventsFired = false; + } else togglePanel(); + }); +} + +function createViews() { + $notionApp = document.querySelector('.notion-app-inner'); + $header.addEventListener('click', openSwitcher); + $switcherTrigger.addEventListener('click', openSwitcher); + $switcherOverlayContainer.addEventListener('click', closeSwitcher); +} + +/** + * adds a view to the enhancer's side panel + * @param {object} panel - information used to construct and render the panel + * @param {string} panel.id - a uuid, used to restore the last open view on reload + * @param {string} panel.icon - an svg string + * @param {string} panel.title - the name of the view + * @param {Element} panel.$content - an element containing the content of the view + * @param {function} panel.onBlur - runs when the view is selected/focused + * @param {function} panel.onFocus - runs when the view is unfocused/closed + */ +export const addPanelView = async ({ + id, + icon, + title, + $content, + onFocus = () => {}, + onBlur = () => {}, +}) => { + if (!$stylesheet) { + $stylesheet = web.loadStylesheet('api/components/panel.css'); + } + + if (!db) db = await registry.db('36a2ffc9-27ff-480e-84a7-c7700a7d232d'); + if (!$pinnedToggle) { + $pinnedToggle = web.html`
+ ${await components.feather('chevrons-right')} +
`; + } + + const view = { + id, + $icon: web.render( + web.html``, + icon instanceof Element ? icon : web.html`${icon}` + ), + $title: web.render(web.html``, title), + $content, + onFocus, + onBlur, + }; + _views.push(view); + if (_views.length === 1) await createPanel(); + if (_views.length === 1 || (await db.get(['panel.open'])) === id) renderView(view); +}; diff --git a/api/components/tooltip.css b/api/components/tooltip.css new file mode 100644 index 0000000..32664dd --- /dev/null +++ b/api/components/tooltip.css @@ -0,0 +1,31 @@ +/** + * notion-enhancer: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +#enhancer--tooltip { + font-family: var(--theme--font_sans); + background: var(--theme--ui_tooltip); + border-radius: 3px; + box-shadow: var(--theme--ui_shadow) 0px 1px 4px; + color: var(--theme--ui_tooltip-description); + display: none; + font-size: 12px; + font-weight: 500; + line-height: 1.4; + max-width: 20rem; + overflow: hidden; + padding: 4px 8px; + position: absolute; + z-index: 999999999999999999; + pointer-events: none; +} +#enhancer--tooltip p { + margin: 0; +} +#enhancer--tooltip b, +#enhancer--tooltip strong { + font-weight: 500; + color: var(--theme--ui_tooltip-title); +} diff --git a/api/components/tooltip.mjs b/api/components/tooltip.mjs new file mode 100644 index 0000000..80f1d03 --- /dev/null +++ b/api/components/tooltip.mjs @@ -0,0 +1,118 @@ +/** + * notion-enhancer: components + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** shared notion-style elements */ + +import { fs, web } from '../index.mjs'; + +let $stylesheet, _$tooltip; + +const countLines = ($el) => + [...$el.getClientRects()].reduce( + (prev, val) => (prev.some((p) => p.y === val.y) ? prev : [...prev, val]), + [] + ).length, + position = ($ref, offsetDirection, maxLines) => { + _$tooltip.style.top = `0px`; + _$tooltip.style.left = `0px`; + const rect = $ref.getBoundingClientRect(), + { offsetWidth, offsetHeight } = _$tooltip, + pad = 6; + let x = rect.x, + y = Math.floor(rect.y); + + if (['top', 'bottom'].includes(offsetDirection)) { + if (offsetDirection === 'top') y -= offsetHeight + pad; + if (offsetDirection === 'bottom') y += rect.height + pad; + x -= offsetWidth / 2 - rect.width / 2; + _$tooltip.style.left = `${x}px`; + _$tooltip.style.top = `${y}px`; + const testLines = () => countLines(_$tooltip.firstElementChild) > maxLines, + padEdgesX = testLines(); + while (testLines()) { + _$tooltip.style.left = `${window.innerWidth - x > x ? x++ : x--}px`; + } + if (padEdgesX) { + x += window.innerWidth - x > x ? pad : -pad; + _$tooltip.style.left = `${x}px`; + } + _$tooltip.style.textAlign = 'center'; + } + + if (['left', 'right'].includes(offsetDirection)) { + y -= offsetHeight / 2 - rect.height / 2; + if (offsetDirection === 'left') x -= offsetWidth + pad; + if (offsetDirection === 'right') x += rect.width + pad; + _$tooltip.style.left = `${x}px`; + _$tooltip.style.top = `${y}px`; + _$tooltip.style.textAlign = 'start'; + } + + return true; + }; + +/** + * add a tooltip to show extra information on hover + * @param {HTMLElement} $ref - the element that will trigger the tooltip when hovered + * @param {string|HTMLElement} $content - markdown or element content of the tooltip + * @param {object=} options - configuration of how the tooltip should be displayed + * @param {number=} options.delay - the amount of time in ms the element needs to be hovered over + * for the tooltip to be shown (default: 100) + * @param {string=} options.offsetDirection - which side of the element the tooltip + * should be shown on: 'top', 'bottom', 'left' or 'right' (default: 'bottom') + * @param {number=} options.maxLines - the max number of lines that the content may be wrapped + * to, used to position and size the tooltip correctly (default: 1) + */ +export const addTooltip = async ( + $ref, + $content, + { delay = 100, offsetDirection = 'bottom', maxLines = 1 } = {} +) => { + if (!$stylesheet) { + $stylesheet = web.loadStylesheet('api/components/tooltip.css'); + _$tooltip = web.html`
`; + web.render(document.body, _$tooltip); + } + + if (!globalThis.markdownit) await import(fs.localPath('dep/markdown-it.min.js')); + const md = markdownit({ linkify: true }); + + if (!($content instanceof Element)) + $content = web.html`
+ ${$content + .split('\n') + .map((text) => md.renderInline(text)) + .join('
')} +
`; + + let displayDelay; + $ref.addEventListener('mouseover', (_event) => { + if (!displayDelay) { + displayDelay = setTimeout(async () => { + if ($ref.matches(':hover')) { + if (_$tooltip.style.display !== 'block') { + _$tooltip.style.display = 'block'; + web.render(web.empty(_$tooltip), $content); + position($ref, offsetDirection, maxLines); + await _$tooltip.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 65 }) + .finished; + } + } + displayDelay = undefined; + }, delay); + } + }); + + $ref.addEventListener('mouseout', async (_event) => { + displayDelay = undefined; + if (_$tooltip.style.display === 'block' && !$ref.matches(':hover')) { + await _$tooltip.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 65 }).finished; + _$tooltip.style.display = ''; + } + }); +}; diff --git a/api/electron.mjs b/api/electron.mjs new file mode 100644 index 0000000..1492333 --- /dev/null +++ b/api/electron.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'; + +/** + * access to electron renderer apis + * @namespace electron + */ +import * as _api from './index.mjs'; // trick jsdoc + +/** + * access to the electron BrowserWindow instance for the current window + * see https://www.electronjs.org/docs/latest/api/browser-window + * @type {BrowserWindow} + * @process electron (renderer process) + */ +export const browser = globalThis.__enhancerElectronApi?.browser; + +/** + * access to the electron webFrame instance for the current page + * see https://www.electronjs.org/docs/latest/api/web-frame + * @type {webFrame} + * @process electron (renderer process) + */ +export const webFrame = globalThis.__enhancerElectronApi?.webFrame; + +/** + * send a message to the main electron process + * @param {string} channel - the message identifier + * @param {any} data - the data to pass along with the message + * @param {string=} namespace - a prefix for the message to categorise + * it as e.g. enhancer-related. this should not be changed unless replicating + * builtin ipc events. + * @process electron (renderer process) + */ +export const sendMessage = (channel, data, namespace = 'notion-enhancer') => { + if (globalThis.__enhancerElectronApi) { + globalThis.__enhancerElectronApi.ipcRenderer.sendMessage(channel, data, namespace); + } +}; + +/** + * send a message to the webview's parent renderer process + * @param {string} channel - the message identifier + * @param {any} data - the data to pass along with the message + * @param {string=} namespace - a prefix for the message to categorise + * it as e.g. enhancer-related. this should not be changed unless replicating + * builtin ipc events. + * @process electron (renderer process) + */ +export const sendMessageToHost = (channel, data, namespace = 'notion-enhancer') => { + if (globalThis.__enhancerElectronApi) { + globalThis.__enhancerElectronApi.ipcRenderer.sendMessageToHost(channel, data, namespace); + } +}; + +/** + * receive a message from either the main process or + * the webview's parent renderer process + * @param {string} channel - the message identifier to listen for + * @param {function} callback - the message handler, passed the args (event, data) + * @param {string=} namespace - a prefix for the message to categorise + * it as e.g. enhancer-related. this should not be changed unless replicating + * builtin ipc events. + * @process electron (renderer process) + */ +export const onMessage = (channel, callback, namespace = 'notion-enhancer') => { + if (globalThis.__enhancerElectronApi) { + globalThis.__enhancerElectronApi.ipcRenderer.onMessage(channel, callback, namespace); + } +}; + +/** + * require() notion app files + * @param {string} path - within notion/resources/app/ e.g. main/createWindow.js + * @process electron (main process) + */ +export const notionRequire = (path) => { + return globalThis.__enhancerElectronApi + ? globalThis.__enhancerElectronApi.notionRequire(path) + : null; +}; + +/** + * get all available app windows excluding the menu + * @process electron (main process) + */ +export const getNotionWindows = () => { + return globalThis.__enhancerElectronApi + ? globalThis.__enhancerElectronApi.getNotionWindows() + : null; +}; + +/** + * get the currently focused notion window + * @process electron (main process) + */ +export const getFocusedNotionWindow = () => { + return globalThis.__enhancerElectronApi + ? globalThis.__enhancerElectronApi.getFocusedNotionWindow() + : null; +}; diff --git a/api/env.mjs b/api/env.mjs new file mode 100644 index 0000000..27f3ac4 --- /dev/null +++ b/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 + * @namespace env + */ + +import * as env from '../env/env.mjs'; + +/** + * the environment/platform name code is currently being executed in + * @constant + * @type {string} + */ +export const name = env.name; + +/** + * the current version of the enhancer + * @constant + * @type {string} + */ +export const version = env.version; + +/** + * open the enhancer's menu + * @type {function} + */ +export const focusMenu = env.focusMenu; + +/** + * focus an active notion tab + * @type {function} + */ +export const focusNotion = env.focusNotion; + +/** + * reload all notion and enhancer menu tabs to apply changes + * @type {function} + */ +export const reload = env.reload; diff --git a/api/fmt.mjs b/api/fmt.mjs new file mode 100644 index 0000000..57660cb --- /dev/null +++ b/api/fmt.mjs @@ -0,0 +1,137 @@ +/** + * 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 + * @namespace fmt + */ + +import { fs } from './index.mjs'; + +/** + * transform a heading into a slug (a lowercase alphanumeric string separated by hyphens), + * 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; +}; + +/** + * generate a reasonably random uuidv4 string. uses crypto implementation if available + * (from https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid) + * @returns {string} a uuidv4 + */ +export const uuidv4 = () => { + if (crypto?.randomUUID) return crypto.randomUUID(); + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => + (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16) + ); +}; + +/** + * log-based shading of an rgb color, from + * https://stackoverflow.com/questions/5560248/programmatically-lighten-or-darken-a-hex-color-or-rgb-and-blend-colors + * @param {number} shade - a decimal amount to shade the color. + * 1 = white, 0 = the original color, -1 = black + * @param {string} color - the rgb color + * @returns {string} the shaded color + */ +export const rgbLogShade = (shade, color) => { + const int = parseInt, + round = Math.round, + [a, b, c, d] = color.split(','), + t = shade < 0 ? 0 : shade * 255 ** 2, + p = shade < 0 ? 1 + shade : 1 - shade; + return ( + 'rgb' + + (d ? 'a(' : '(') + + round((p * int(a[3] == 'a' ? a.slice(5) : a.slice(4)) ** 2 + t) ** 0.5) + + ',' + + round((p * int(b) ** 2 + t) ** 0.5) + + ',' + + round((p * int(c) ** 2 + t) ** 0.5) + + (d ? ',' + d : ')') + ); +}; + +/** + * pick a contrasting color e.g. for text on a variable color background + * using the hsp (perceived brightness) constants from http://alienryderflex.com/hsp.html + * @param {number} r - red (0-255) + * @param {number} g - green (0-255) + * @param {number} b - blue (0-255) + * @returns {string} the contrasting rgb color, white or black + */ +export const rgbContrast = (r, g, b) => { + return Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)) > 165.75 + ? 'rgb(0,0,0)' + : 'rgb(255,255,255)'; +}; + +const patterns = { + alphanumeric: /^[\w\.-]+$/, + uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + semver: + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/i, + email: + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i, + url: /^[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,64}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/i, + color: /^(?:#|0x)(?:[a-f0-9]{3}|[a-f0-9]{6})\b|(?:rgb|hsl)a?\([^\)]*\)$/i, +}; +function test(str, pattern) { + const match = str.match(pattern); + return !!(match && match.length); +} + +/** + * test the type of a value. unifies builtin, regex, and environment/api checks + * @param {unknown} value - the value to check + * @param {string|string[]} type - the type the value should be or a list of allowed values + * @returns {boolean} whether or not the value matches the type + */ +export const is = async (value, type, { extension = '' } = {}) => { + extension = !value || !value.endsWith || value.endsWith(extension); + if (Array.isArray(type)) { + return type.includes(value); + } + switch (type) { + case 'array': + return Array.isArray(value); + case 'object': + return value && typeof value === 'object' && !Array.isArray(value); + case 'undefined': + case 'boolean': + case 'number': + return typeof value === type && extension; + case 'string': + return typeof value === type && extension; + case 'alphanumeric': + case 'uuid': + case 'semver': + case 'email': + case 'url': + case 'color': + return typeof value === 'string' && test(value, patterns[type]) && extension; + case 'file': + return typeof value === 'string' && value && (await fs.isFile(value)) && extension; + } + return false; +}; diff --git a/api/fs.mjs b/api/fs.mjs new file mode 100644 index 0000000..fa18d7b --- /dev/null +++ b/api/fs.mjs @@ -0,0 +1,55 @@ +/** + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * environment-specific file reading + * @namespace fs + */ + +import * as fs from '../env/fs.mjs'; + +/** + * get an absolute path to files within notion + * @param {string} path - relative to the root notion/resources/app/ e.g. renderer/search.js + * @process electron + */ +export const notionPath = fs.notionPath; + +/** + * transform a path relative to the enhancer root directory into an absolute path + * @type {function} + * @param {string} path - a url or within-the-enhancer filepath + * @returns {string} an absolute filepath + */ +export const localPath = fs.localPath; + +/** + * fetch and parse a json file's contents + * @type {function} + * @param {string} path - a url or within-the-enhancer filepath + * @param {FetchOptions=} opts - the second argument of a fetch() request + * @returns {unknown} the json value of the requested file as a js object + */ +export const getJSON = fs.getJSON; + +/** + * fetch a text file's contents + * @type {function} + * @param {string} path - a url or within-the-enhancer filepath + * @param {FetchOptions=} opts - the second argument of a fetch() request + * @returns {string} the text content of the requested file + */ +export const getText = fs.getText; + +/** + * check if a file exists + * @type {function} + * @param {string} path - a url or within-the-enhancer filepath + * @returns {boolean} whether or not the file exists + */ +export const isFile = fs.isFile; diff --git a/api/index.cjs b/api/index.cjs new file mode 100644 index 0000000..c130ac1 --- /dev/null +++ b/api/index.cjs @@ -0,0 +1,21 @@ +var le=Object.defineProperty;var Xe=e=>le(e,"__esModule",{value:!0});var y=(e,t)=>{Xe(e);for(var n in t)le(e,n,{get:t[n],enumerable:!0})};y(exports,{components:()=>R,electron:()=>V,env:()=>j,fmt:()=>h,fs:()=>u,notion:()=>X,registry:()=>g,storage:()=>_,web:()=>o});var j={};y(j,{focusMenu:()=>Ye,focusNotion:()=>De,name:()=>Ge,reload:()=>et,version:()=>Qe});"use strict";var de=globalThis.__enhancerElectronApi.platform,pe=globalThis.__enhancerElectronApi.version,ue=globalThis.__enhancerElectronApi.focusMenu,he=globalThis.__enhancerElectronApi.focusNotion,fe=globalThis.__enhancerElectronApi.reload;"use strict";var Ge=de,Qe=pe,Ye=ue,De=he,et=fe;var u={};y(u,{getJSON:()=>rt,getText:()=>st,isFile:()=>ot,localPath:()=>nt,notionPath:()=>tt});"use strict";var M=globalThis.__enhancerElectronApi.notionPath,L=e=>`notion://www.notion.so/__notion-enhancer/${e}`,me=(e,t={})=>{if(e=e.replace(/^https:\/\/www\.notion\.so\//,"notion://www.notion.so/"),e.startsWith("http")||e.startsWith("notion://"))return fetch(e,t).then(r=>r.json());try{return globalThis.__enhancerElectronApi.nodeRequire(`notion-enhancer/${e}`)}catch{return fetch(L(e),t).then(s=>s.json())}},ge=(e,t={})=>{if(e=e.replace(/^https:\/\/www\.notion\.so\//,"notion://www.notion.so/"),e.startsWith("http")||e.startsWith("notion://"))return fetch(e,t).then(r=>r.text());try{return globalThis.__enhancerElectronApi.nodeRequire("fs").readFileSync(M(`notion-enhancer/${e}`))}catch{return fetch(L(e),t).then(s=>s.text())}},ye=async e=>{try{let t=globalThis.__enhancerElectronApi.nodeRequire("fs");if(e.startsWith("http"))await fetch(e);else try{t.existsSync(M(`notion-enhancer/${e}`))}catch{await fetch(L(e))}return!0}catch{return!1}};"use strict";var tt=M,nt=L,rt=me,st=ge,ot=ye;var _={};y(_,{addChangeListener:()=>lt,db:()=>ct,get:()=>it,removeChangeListener:()=>dt,set:()=>at});"use strict";var H=(e,t=void 0)=>globalThis.__enhancerElectronApi.db.get(e,t),I=(e,t)=>globalThis.__enhancerElectronApi.db.set(e,t),we=(e,t=H,n=I)=>(typeof e=="string"&&(e=[e]),{get:(r=[],s=void 0)=>t([...e,...r],s),set:(r,s)=>n([...e,...r],s)}),ve=e=>globalThis.__enhancerElectronApi.db.addChangeListener(e),be=e=>globalThis.__enhancerElectronApi.db.removeChangeListener(e);"use strict";var it=H,at=I,ct=we,lt=ve,dt=be;var V={};y(V,{browser:()=>pt,getFocusedNotionWindow:()=>wt,getNotionWindows:()=>yt,notionRequire:()=>gt,onMessage:()=>mt,sendMessage:()=>ht,sendMessageToHost:()=>ft,webFrame:()=>ut});"use strict";var pt=globalThis.__enhancerElectronApi?.browser,ut=globalThis.__enhancerElectronApi?.webFrame,ht=(e,t,n="notion-enhancer")=>{globalThis.__enhancerElectronApi&&globalThis.__enhancerElectronApi.ipcRenderer.sendMessage(e,t,n)},ft=(e,t,n="notion-enhancer")=>{globalThis.__enhancerElectronApi&&globalThis.__enhancerElectronApi.ipcRenderer.sendMessageToHost(e,t,n)},mt=(e,t,n="notion-enhancer")=>{globalThis.__enhancerElectronApi&&globalThis.__enhancerElectronApi.ipcRenderer.onMessage(e,t,n)},gt=e=>globalThis.__enhancerElectronApi?globalThis.__enhancerElectronApi.notionRequire(e):null,yt=()=>globalThis.__enhancerElectronApi?globalThis.__enhancerElectronApi.getNotionWindows():null,wt=()=>globalThis.__enhancerElectronApi?globalThis.__enhancerElectronApi.getFocusedNotionWindow():null;var X={};y(X,{create:()=>xt,get:()=>xe,getPageID:()=>J,getSpaceID:()=>T,getUserID:()=>_e,search:()=>vt,set:()=>bt,sign:()=>$t,upload:()=>_t});"use strict";var m=e=>(e?.length===32&&!e.includes("-")&&(e=e.replace(/([\d\w]{8})([\d\w]{4})([\d\w]{4})([\d\w]{4})([\d\w]{12})/,"$1-$2-$3-$4-$5")),e),xe=async(e,t="block")=>{e=m(e);let n=await u.getJSON("https://www.notion.so/api/v3/getRecordValues",{headers:{"Content-Type":"application/json"},body:JSON.stringify({requests:[{table:t,id:e}]}),method:"POST"});return n?.results?.[0]?.value||n},_e=()=>JSON.parse(localStorage["LRU:KeyValueStore2:current-user-id"]||{}).value,J=()=>m(o.queryParams().get("p")||location.pathname.split(/(-|\/)/g).reverse()[0]),K,T=async()=>(K||(K=(await xe(J())).space_id),K),vt=async(e="",t=20,n=T())=>(n=m(await n),await u.getJSON("https://www.notion.so/api/v3/search",{headers:{"Content-Type":"application/json"},body:JSON.stringify({type:"BlocksInSpace",query:e,spaceId:n,limit:t,filters:{isDeletedOnly:!1,excludeTemplates:!1,isNavigableOnly:!1,requireEditPermissions:!1,ancestors:[],createdBy:[],editedBy:[],lastEditedTime:{},createdTime:{}},sort:"Relevance",source:"quick_find"}),method:"POST"})),bt=async({recordID:e,recordTable:t="block",spaceID:n=T(),path:r=[]},s={})=>{n=m(await n),e=m(e);let c=await u.getJSON("https://www.notion.so/api/v3/saveTransactions",{headers:{"Content-Type":"application/json"},body:JSON.stringify({requestId:h.uuidv4(),transactions:[{id:h.uuidv4(),spaceId:n,operations:[{pointer:{table:t,id:e,spaceId:n},path:r,command:r.length?"set":"update",args:s}]}]}),method:"POST"});return c.errorId?c:!0},xt=async({recordValue:e={},recordTable:t="block"}={},{prepend:n=!1,siblingID:r=void 0,parentID:s=J(),parentTable:c="block",spaceID:a=T(),userID:i=_e()}={})=>{a=m(await a),s=m(s),r=m(r);let d=m(e?.id??h.uuidv4()),x=[],A={type:"text",id:d,version:0,created_time:new Date().getTime(),last_edited_time:new Date().getTime(),parent_id:s,parent_table:c,alive:!0,created_by_table:"notion_user",created_by_id:i,last_edited_by_table:"notion_user",last_edited_by_id:i,space_id:a,permissions:[{type:"user_permission",role:"editor",user_id:i}]};c==="space"?(s=a,A.parent_id=a,x.push("pages"),A.type="page"):c==="collection_view"?(x.push("page_sort"),A.type="page"):x.push("content");let ce=await u.getJSON("https://www.notion.so/api/v3/saveTransactions",{headers:{"Content-Type":"application/json"},body:JSON.stringify({requestId:h.uuidv4(),transactions:[{id:h.uuidv4(),spaceId:a,operations:[{pointer:{table:c,id:s,spaceId:a},path:x,command:n?"listBefore":"listAfter",args:{...r?{after:r}:{},id:d}},{pointer:{table:t,id:d,spaceId:a},path:[],command:"set",args:{...A,...e}}]}]}),method:"POST"});return ce.errorId?ce:d},_t=async(e,{pageID:t=J(),spaceID:n=T()}={})=>{n=m(await n),t=m(t);let r=await u.getJSON("https://www.notion.so/api/v3/getUploadFileUrl",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify({bucket:"secure",name:e.name,contentType:e.type,record:{table:"block",id:t,spaceId:n}})});return r.errorId?r:(fetch(r.signedPutUrl,{method:"PUT",headers:{"content-type":e.type},body:e}),r.url)},$t=(e,t,n="block")=>(e.startsWith("/")&&(e=`https://notion.so${e}`),e.includes("secure.notion-static.com")&&(e=new URL(e),e=`https://www.notion.so/signed/${encodeURIComponent(e.origin+e.pathname)}?table=${n}&id=${t}`),e);var h={};y(h,{is:()=>kt,rgbContrast:()=>Lt,rgbLogShade:()=>jt,slugger:()=>At,uuidv4:()=>Et});"use strict";var At=(e,t=new Set)=>{e=e.replace(/\s/g,"-").replace(/[^A-Za-z0-9-_]/g,"").toLowerCase();let n=0,r=e;for(;t.has(r);)n++,r=`${e}-${n}`;return r},Et=()=>crypto?.randomUUID?crypto.randomUUID():([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,e=>(e^crypto.getRandomValues(new Uint8Array(1))[0]&15>>e/4).toString(16)),jt=(e,t)=>{var n=parseInt,r=Math.round,[s,c,t,a]=t.split(","),i=e<0,d=i?0:e*255**2,i=i?1+e:1-e;return"rgb"+(a?"a(":"(")+r((i*n(s[3]=="a"?s.slice(5):s.slice(4))**2+d)**.5)+","+r((i*n(c)**2+d)**.5)+","+r((i*n(t)**2+d)**.5)+(a?","+a:")")},Lt=(e,t,n)=>Math.sqrt(.299*(e*e)+.587*(t*t)+.114*(n*n))>165.75?"rgb(0,0,0)":"rgb(255,255,255)",Tt={alphanumeric:/^[\w\.-]+$/,uuid:/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,semver:/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/i,email:/^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/i,url:/^[(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,64}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/i,color:/^(?:#|0x)(?:[a-f0-9]{3}|[a-f0-9]{6})\b|(?:rgb|hsl)a?\([^\)]*\)$/i};function St(e,t){let n=e.match(t);return!!(n&&n.length)}var kt=async(e,t,{extension:n=""}={})=>{if(n=!e||!e.endsWith||e.endsWith(n),Array.isArray(t))return t.includes(e);switch(t){case"array":return Array.isArray(e);case"object":return e&&typeof e=="object"&&!Array.isArray(e);case"undefined":case"boolean":case"number":return typeof e===t&&n;case"string":return typeof e===t&&n;case"alphanumeric":case"uuid":case"semver":case"email":case"url":case"color":return typeof e=="string"&&St(e,Tt[t])&&n;case"file":return typeof e=="string"&&e&&await u.isFile(e)&&n}return!1};var g={};y(g,{core:()=>Ae,db:()=>Ut,enabled:()=>zt,errors:()=>Wt,get:()=>ee,list:()=>D,optionDefault:()=>je,optionTypes:()=>Jt,profileDB:()=>G,profileName:()=>Ee,supportedEnvs:()=>Mt});"use strict";var l=async(e,t,n,r,{extension:s="",error:c=`invalid ${t} (${s?`${s} `:""}${r}): ${JSON.stringify(n)}`,optional:a=!1}={})=>{let i;for(let d of Array.isArray(r)?[r]:r.split("|"))if(d==="file"?i=n&&!n.startsWith("http")?await h.is(`repo/${e._dir}/${n}`,d,{extension:s}):!1:i=await h.is(n,d,{extension:s}),i)break;return i||a&&await h.is(n,"undefined")?!0:(c&&e._err(c),!1)},Pt=async e=>(e.environments=e.environments??g.supportedEnvs,await l(e,"environments",e.environments,"array")?e.environments.map(n=>l(e,"environments.env",n,g.supportedEnvs)):!1),Ct=async e=>{if(!await l(e,"tags",e.tags,"array"))return!1;let n=["core","extension","theme","integration"];if(!e.tags.filter(i=>n.includes(i)).length)return e._err(`invalid tags (must contain at least one of 'core', 'extension', 'theme' or 'integration'): + ${JSON.stringify(e.tags)}`),!1;let s=e.tags.includes("theme"),c=e.tags.includes("light")||e.tags.includes("dark"),a=e.tags.includes("light")&&e.tags.includes("dark");return s&&(!c||a)?(e._err(`invalid tags (themes must be either 'light' or 'dark', not neither or both): + ${JSON.stringify(e.tags)}`),!1):e.tags.map(i=>l(e,"tags.tag",i,"string"))},Ot=async e=>await l(e,"authors",e.authors,"array")?e.authors.map(n=>[l(e,"authors.author.name",n.name,"string"),l(e,"authors.author.email",n.email,"email",{optional:!0}),l(e,"authors.author.homepage",n.homepage,"url"),l(e,"authors.author.avatar",n.avatar,"url")]):!1,Nt=async e=>{if(!await l(e,"css",e.css,"object"))return!1;let n=[];for(let r of["frame","client","menu"]){if(!e.css[r])continue;let s=await l(e,`css.${r}`,e.css[r],"array");s&&(s=e.css[r].map(c=>l(e,`css.${r}.file`,c,"file",{extension:".css"}))),n.push(s)}return n},Rt=async e=>{if(!await l(e,"js",e.js,"object"))return!1;let n=[];for(let r of["frame","client","menu"]){if(!e.js[r])continue;let s=await l(e,`js.${r}`,e.js[r],"array");s&&(s=e.js[r].map(c=>l(e,`js.${r}.file`,c,"file",{extension:".mjs"}))),n.push(s)}if(e.js.electron)if(await l(e,"js.electron",e.js.electron,"array"))for(let s of e.js.electron){if(!await l(e,"js.electron.file",s,"object")){n.push(!1);continue}n.push([l(e,"js.electron.file.source",s.source,"file",{extension:".cjs"}),l(e,"js.electron.file.target",s.target,"string",{extension:".js"})])}else n.push(!1);return n},qt=async e=>{if(!await l(e,"options",e.options,"array"))return!1;let n=[];for(let r of e.options){let s="options.option";if(!await l(e,`${s}.type`,r.type,g.optionTypes)){n.push(!1);continue}switch(r.environments=r.environments??g.supportedEnvs,n.push([l(e,`${s}.key`,r.key,"alphanumeric"),l(e,`${s}.label`,r.label,"string"),l(e,`${s}.tooltip`,r.tooltip,"string",{optional:!0}),l(e,`${s}.environments`,r.environments,"array").then(a=>a?r.environments.map(i=>l(e,`${s}.environments.env`,i,g.supportedEnvs)):!1)]),r.type){case"toggle":n.push(l(e,`${s}.value`,r.value,"boolean"));break;case"select":{let a=await l(e,`${s}.values`,r.values,"array");a&&(a=r.values.map(i=>l(e,`${s}.values.value`,i,"string"))),n.push(a);break}case"text":case"hotkey":n.push(l(e,`${s}.value`,r.value,"string"));break;case"number":case"color":n.push(l(e,`${s}.value`,r.value,r.type));break;case"file":{let a=await l(e,`${s}.extensions`,r.extensions,"array");a&&(a=r.extensions.map(i=>l(e,`${s}.extensions.extension`,i,"string"))),n.push(a);break}}}return n};async function $e(e){let t=[l(e,"name",e.name,"string"),l(e,"id",e.id,"uuid"),l(e,"version",e.version,"semver"),Pt(e),l(e,"description",e.description,"string"),l(e,"preview",e.preview,"file|url",{optional:!0}),Ct(e),Ot(e),Nt(e),Rt(e),qt(e)];do t=await Promise.all(t.flat(1/0));while(t.some(n=>Array.isArray(n)));return t.every(n=>n)}"use strict";var Ae=["a6621988-551d-495a-97d8-3c568bca2e9e","0f0bf8b6-eae6-4273-b307-8fc43f2ee082","36a2ffc9-27ff-480e-84a7-c7700a7d232d"],Mt=["linux","win32","darwin","extension"],Jt=["toggle","select","text","number","color","file","hotkey"],Ee=async()=>_.get(["currentprofile"],"default"),G=async()=>_.db(["profiles",await Ee()]),Q,Y=[],D=async(e=t=>!0)=>{Q||(Q=new Promise(async(n,r)=>{let s=[];for(let c of await u.getJSON("repo/registry.json"))try{let a={...await u.getJSON(`repo/${c}/mod.json`),_dir:c,_err:i=>Y.push({source:c,message:i})};await $e(a)&&s.push(a)}catch{Y.push({source:c,message:"invalid mod.json"})}n(s)}));let t=[];for(let n of await Q)await e(n)&&t.push(n);return t},Wt=async()=>(await D(),Y),ee=async e=>(await D(t=>t.id===e))[0],zt=async e=>(await ee(e)).environments.includes(j.name)?Ae.includes(e)?!0:(await G()).get(["_mods",e],!1):!1,je=async(e,t)=>{let n=await ee(e),r=n.options.find(s=>s.key===t);if(!!r)switch(r.type){case"toggle":case"text":case"number":case"color":case"hotkey":return r.value;case"select":return r.values[0];case"file":return}},Ut=async e=>{let t=await G();return _.db([e],async(n,r=void 0)=>(typeof n=="string"&&(n=[n]),n.length===2&&(r=await je(e,n[1])??r),t.get(n,r)),t.set)};var o={};y(o,{addDocumentObserver:()=>Gt,addHotkeyListener:()=>Kt,copyToClipboard:()=>It,empty:()=>Ft,escape:()=>Te,html:()=>ke,loadStylesheet:()=>Ht,queryParams:()=>Bt,raw:()=>Se,readFromClipboard:()=>Vt,removeDocumentObserver:()=>Qt,removeHotkeyListener:()=>Xt,render:()=>Pe,whenReady:()=>Zt});"use strict";var Le=!1,S=[],te,W=[],ne=[],Zt=(e=[])=>new Promise((t,n)=>{let r=()=>{let s=setInterval(c,100);function c(){!e.every(i=>document.querySelector(i))||(clearInterval(s),t(!0))}c()};document.readyState!=="complete"?document.addEventListener("readystatechange",s=>{document.readyState==="complete"&&r()}):r()}),Bt=()=>new URLSearchParams(window.location.search),Te=e=>e.replace(/&/g,"&").replace(//g,">").replace(/'/g,"'").replace(/"/g,""").replace(/\\/g,"\"),Se=(e,...t)=>{let n=e.map(r=>r+(["string","number"].includes(typeof t[0])?t.shift():Te(JSON.stringify(t.shift(),null,2)??""))).join("");return n.includes("r.trim()).filter(r=>r.length).join(" ")},ke=(e,...t)=>{let n=document.createRange().createContextualFragment(Se(e,...t));return n.children.length===1?n.children[0]:n.children},Pe=(e,...t)=>(t=t.map(n=>n instanceof HTMLCollection?[...n]:n).flat(1/0).filter(n=>n),e.append(...t),e),Ft=e=>{for(;e.firstChild&&e.removeChild(e.firstChild););return e},Ht=e=>{let t=ke``;return Pe(document.head,t),t},It=async e=>{try{await navigator.clipboard.writeText(e)}catch{let t=document.createElement("textarea");t.value=e,t.setAttribute("readonly",""),t.style.position="absolute",t.style.left="-9999px",document.body.appendChild(t),t.select(),document.execCommand("copy"),document.body.removeChild(t)}},Vt=()=>navigator.clipboard.readText(),Ce=(e,t)=>{if(document.activeElement.nodeName==="INPUT"&&!t.listenInInput)return;let r={metaKey:["meta","os","win","cmd","command"],ctrlKey:["ctrl","control"],shiftKey:["shift"],altKey:["alt"]};if(!!t.keys.every(c=>{c=c.toLowerCase();for(let a in r)if(r[a].includes(c)&&e[a])return r[a]=[],!0;if(c==="space"&&(c=" "),c==="plus"&&(c="+"),c===e.key.toLowerCase())return!0})){for(let c in r){let a=e[c],i=r[c].length>0;if(a&&i)return}t.callback(e)}},Kt=(e,t,{listenInInput:n=!1,keydown:r=!1}={})=>{typeof e=="string"&&(e=e.split("+")),S.push({keys:e,callback:t,listenInInput:n,keydown:r}),Le||(Le=!0,document.addEventListener("keyup",s=>{for(let c of S.filter(({keydown:a})=>!a))Ce(s,c)}),document.addEventListener("keydown",s=>{for(let c of S.filter(({keydown:a})=>a))Ce(s,c)}))},Xt=e=>{S=S.filter(t=>t.callback!==e)},Gt=(e,t=[])=>{if(!te){let n=r=>{for(;r.length;){let s=r.shift(),c=(i,d)=>i instanceof Element&&(i.matches(d)||i.matches(`${d} *`)||i.querySelector(d)),a=i=>s.target.matches(i)||s.target.matches(`${i} *`)||[...s.addedNodes].some(d=>c(d,i));for(let i of W)(!i.selectors.length||i.selectors.some(a))&&i.callback(s)}};te=new MutationObserver((r,s)=>{ne.length||requestIdleCallback(()=>n(ne)),ne.push(...r)}),te.observe(document.body,{childList:!0,subtree:!0,attributes:!0})}W.push({callback:e,selectors:t})},Qt=e=>{W=W.filter(t=>t.callback!==e)};var R={};y(R,{addCornerAction:()=>Ke,addPanelView:()=>Ie,addTooltip:()=>Ne,feather:()=>Re});"use strict";var Oe,p,Yt=e=>[...e.getClientRects()].reduce((t,n)=>t.some(r=>r.y===n.y)?t:[...t,n],[]).length,Dt=async(e,t,n)=>{p.style.top="0px",p.style.left="0px";let r=e.getBoundingClientRect(),{offsetWidth:s,offsetHeight:c}=p,a=6,i=r.x,d=Math.floor(r.y);if(["top","bottom"].includes(t)){t==="top"&&(d-=c+a),t==="bottom"&&(d+=r.height+a),i-=s/2-r.width/2,p.style.left=`${i}px`,p.style.top=`${d}px`;let x=()=>Yt(p.firstElementChild)>n,A=x();for(;x();)p.style.left=`${window.innerWidth-i>i?i++:i--}px`;A&&(i+=window.innerWidth-i>i?a:-a,p.style.left=`${i}px`),p.style.textAlign="center"}return["left","right"].includes(t)&&(d-=c/2-r.height/2,t==="left"&&(i-=s+a),t==="right"&&(i+=r.width+a),p.style.left=`${i}px`,p.style.top=`${d}px`,p.style.textAlign="start"),!0},Ne=async(e,t,{delay:n=100,offsetDirection:r="bottom",maxLines:s=1}={})=>{Oe||(Oe=o.loadStylesheet("api/components/tooltip.css"),p=o.html`
`,o.render(document.body,p)),globalThis.markdownit||await import(u.localPath("dep/markdown-it.min.js"));let c=markdownit({linkify:!0});t instanceof Element||(t=o.html`
+ ${t.split(` +`).map(i=>c.renderInline(i)).join("
")} +
`);let a;e.addEventListener("mouseover",async i=>{a||(a=setTimeout(async()=>{e.matches(":hover")&&p.style.display!=="block"&&(p.style.display="block",o.render(o.empty(p),t),Dt(e,r,s),await p.animate([{opacity:0},{opacity:1}],{duration:65}).finished),a=void 0},n))}),e.addEventListener("mouseout",async i=>{a=void 0,p.style.display==="block"&&!e.matches(":hover")&&(await p.animate([{opacity:1},{opacity:0}],{duration:65}).finished,p.style.display="")})};"use strict";var re,Re=async(e,t={})=>(re||(re=o.html`${await u.getText("dep/feather-sprite.svg")}`),t.style=((t.style?t.style+";":"")+"stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;fill:none;").trim(),t.viewBox="0 0 24 24",``${o.escape(n)}="${o.escape(r)}"`).join(" ")}>${re.getElementById(e)?.innerHTML}`);"use strict";var k=[],en=o.raw` + + `,qe,v,z,w,$,P,E,Me,Je,se,f,We,U,C,Z,oe,b,ie,O,B="data-enhancer-panel-pinned",F=()=>$.hasAttribute(B),N=()=>{let e=[z,w,P,$].filter(t=>t);if(F()){ae();for(let t of e)t.removeAttribute(B)}else for(let t of e)t.setAttribute(B,"true");v.set(["panel.pinned"],F())},ze=async()=>{document.documentElement.style.setProperty("--component--panel-width",f+"px"),v.set(["panel.width"],f)},Ue=e=>{e.preventDefault(),se=!0,f=Je+(Me-e.clientX),f<190&&(f=190),f>480&&(f=480),$.style.width=f+"px",P.style.width=f+"px",z.style.paddingRight=f+"px",w&&(w.style.right=f+"px")},Ze=e=>{$.style.width="",P.style.width="",z.style.paddingRight="",w&&(w.style.right=""),ze(),E.style.cursor="",document.body.removeEventListener("mousemove",Ue),document.body.removeEventListener("mouseup",Ze)},tn=e=>{Me=e.clientX,Je=f,E.style.cursor="auto",document.body.addEventListener("mousemove",Ue),document.body.addEventListener("mouseup",Ze)},nn=()=>document.body.contains(b),Be=()=>{if(!F())return N();o.render(We,O),o.empty(b);for(let t of k){let n=C.contains(t.$title),r=o.render(o.html`
`,o.render(o.html``,t.$icon.cloneNode(!0),t.$title.cloneNode(!0)));r.addEventListener("click",()=>{He(t),v.set(["panel.open"],t.id)}),o.render(b,r)}let e=Z.getBoundingClientRect();o.render(o.empty(O),o.render(o.html`
`,o.render(o.html`
`,b))),b.querySelector("[data-open]").focus(),b.animate([{opacity:0},{opacity:1}],{duration:200}),document.addEventListener("keydown",Fe)},ae=()=>{document.removeEventListener("keydown",Fe),b.animate([{opacity:1},{opacity:0}],{duration:200}).onfinish=()=>O.remove()},Fe=e=>{if(nn())switch(e.key){case"Escape":ae(),e.stopPropagation();break;case"Enter":document.activeElement.click(),e.stopPropagation();break;case"ArrowUp":(e.target.previousElementSibling||e.target.parentElement.lastElementChild).focus(),e.stopPropagation();break;case"ArrowDown":(e.target.nextElementSibling||e.target.parentElement.firstElementChild).focus(),e.stopPropagation();break}},He=e=>{let t=k.find(({$content:n})=>document.contains(n));o.render(o.empty(C),o.render(o.html``,e.$icon,e.$title)),e.onFocus(),o.render(o.empty(oe),e.$content),t&&t.onBlur()};async function rn(){await o.whenReady([".notion-frame"]),z=document.querySelector(".notion-frame"),$=o.html`
`,P=o.html`
`,E=o.html`
`,C=o.html`
`,Z=o.render(o.html`
`,C),oe=o.html`
`,b=o.html`
`,ie=o.html`
+ ${en} +
`,O=o.html`
`;let e='.notion-cursor-listener > div[style*="flex-end"]',t=()=>{document.contains(w)||(w=document.querySelector(e),F()&&w&&w.setAttribute(B,"true"))};w=document.querySelector(e),o.addDocumentObserver(t,[e]),await v.get(["panel.pinned"])&&N(),o.addHotkeyListener(await v.get(["panel.hotkey"]),N),U.addEventListener("click",r=>{r.stopPropagation(),N()}),o.render($,o.render(Z,C,ie,U),oe,E),await sn(),await on();let n='.notion-cursor-listener > .notion-sidebar-container ~ [style^="position: absolute"]';await o.whenReady([n]),document.querySelector(n).before(P,$)}async function sn(){f=await v.get(["panel.width"],240),ze(),E.addEventListener("mousedown",tn),E.addEventListener("click",()=>{se?se=!1:N()})}async function on(){We=document.querySelector(".notion-app-inner"),Z.addEventListener("click",Be),ie.addEventListener("click",Be),O.addEventListener("click",ae)}var Ie=async({id:e,icon:t,title:n,$content:r,onFocus:s=()=>{},onBlur:c=()=>{}})=>{qe||(qe=o.loadStylesheet("api/components/panel.css")),v||(v=await g.db("36a2ffc9-27ff-480e-84a7-c7700a7d232d")),U||(U=o.html`
+ ${await R.feather("chevrons-right")} +
`);let a={id:e,$icon:o.render(o.html``,t instanceof Element?t:o.html`${t}`),$title:o.render(o.html``,n),$content:r,onFocus:s,onBlur:c};k.push(a),k.length===1&&await rn(),(k.length===1||await v.get(["panel.open"])===e)&&He(a)};"use strict";var Ve,q,Ke=async(e,t)=>{Ve||(Ve=o.loadStylesheet("api/components/corner-action.css"),q=o.html`
`),await o.whenReady([".notion-help-button"]);let n=document.querySelector(".notion-help-button"),r=document.querySelector(".onboarding-checklist-button");r&&q.prepend(r),q.prepend(n),o.render(document.querySelector(".notion-app-inner > .notion-cursor-listener"),q);let s=o.html`
${e}
`;return s.addEventListener("click",t),o.render(q,s),s};"use strict";"use strict"; diff --git a/api/index.mjs b/api/index.mjs new file mode 100644 index 0000000..f3538f8 --- /dev/null +++ b/api/index.mjs @@ -0,0 +1,31 @@ +/** + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +// compiles to .cjs for use in electron: +// npx -y esbuild insert/api/index.mjs --minify --bundle --format=cjs --outfile=insert/api/index.cjs + +/** environment-specific methods and constants */ +export * as env from './env.mjs'; +/** environment-specific file reading */ +export * as fs from './fs.mjs'; +/** environment-specific data persistence */ +export * as storage from './storage.mjs'; + +/** access to electron renderer apis */ +export * as electron from './electron.mjs'; + +/** a basic wrapper around notion's unofficial api */ +export * as notion from './notion.mjs'; +/** helpers for formatting, validating and parsing values */ +export * as fmt from './fmt.mjs'; +/** interactions with the enhancer's repository of mods */ +export * as registry from './registry.mjs'; +/** helpers for manipulation of a webpage */ +export * as web from './web.mjs'; +/** shared notion-style elements */ +export * as components from './components/index.mjs'; diff --git a/api/notion.mjs b/api/notion.mjs new file mode 100644 index 0000000..8757ba4 --- /dev/null +++ b/api/notion.mjs @@ -0,0 +1,367 @@ +/** + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * a basic wrapper around notion's content apis + * @namespace notion + */ + +import { web, fs, fmt } from './index.mjs'; + +const standardiseUUID = (uuid) => { + if (uuid?.length === 32 && !uuid.includes('-')) { + uuid = uuid.replace( + /([\d\w]{8})([\d\w]{4})([\d\w]{4})([\d\w]{4})([\d\w]{12})/, + '$1-$2-$3-$4-$5' + ); + } + return uuid; +}; + +/** + * unofficial content api: get a block by id + * (requires user to be signed in or content to be public). + * why not use the official api? + * 1. cors blocking prevents use on the client + * 2. the majority of blocks are still 'unsupported' + * @param {string} id - uuidv4 record id + * @param {string=} table - record type (default: 'block'). + * may also be 'collection', 'collection_view', 'space', 'notion_user', 'discussion', or 'comment' + * @returns {Promise} record data. type definitions can be found here: + * https://github.com/NotionX/react-notion-x/tree/master/packages/notion-types/src + */ +export const get = async (id, table = 'block') => { + id = standardiseUUID(id); + const json = await fs.getJSON('https://www.notion.so/api/v3/getRecordValues', { + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ requests: [{ table, id }] }), + method: 'POST', + }); + return json?.results?.[0]?.value || json; +}; + +/** + * get the id of the current user (requires user to be signed in) + * @returns {string} uuidv4 user id + */ +export const getUserID = () => + JSON.parse(localStorage['LRU:KeyValueStore2:current-user-id'] || {}).value; + +/** + * get the id of the currently open page + * @returns {string} uuidv4 page id + */ +export const getPageID = () => + standardiseUUID( + web.queryParams().get('p') || location.pathname.split(/(-|\/)/g).reverse()[0] + ); + +let _spaceID; +/** + * get the id of the currently open workspace (requires user to be signed in) + * @returns {string} uuidv4 space id + */ +export const getSpaceID = async () => { + if (!_spaceID) _spaceID = (await get(getPageID())).space_id; + return _spaceID; +}; + +/** + * unofficial content api: search all blocks in a space + * (requires user to be signed in or content to be public). + * why not use the official api? + * 1. cors blocking prevents use on the client + * 2. the majority of blocks are still 'unsupported' + * @param {string=} query - query to search blocks in the space for + * @param {number=} limit - the max number of results to return (default: 20) + * @param {string=} spaceID - uuidv4 workspace id + * @returns {object} the number of total results, the list of matches, and related record values. + * type definitions can be found here: https://github.com/NotionX/react-notion-x/blob/master/packages/notion-types/src/api.ts + */ +export const search = async (query = '', limit = 20, spaceID = getSpaceID()) => { + spaceID = standardiseUUID(await spaceID); + const json = await fs.getJSON('https://www.notion.so/api/v3/search', { + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'BlocksInSpace', + query, + spaceId: spaceID, + limit, + filters: { + isDeletedOnly: false, + excludeTemplates: false, + isNavigableOnly: false, + requireEditPermissions: false, + ancestors: [], + createdBy: [], + editedBy: [], + lastEditedTime: {}, + createdTime: {}, + }, + sort: 'Relevance', + source: 'quick_find', + }), + method: 'POST', + }); + return json; +}; + +/** + * unofficial content api: update a property/the content of an existing record + * (requires user to be signed in or content to be public). + * TEST THIS THOROUGHLY. misuse can corrupt a record, leading the notion client + * to be unable to parse and render content properly and throw errors. + * why not use the official api? + * 1. cors blocking prevents use on the client + * 2. the majority of blocks are still 'unsupported' + * @param {object} pointer - the record being updated + * @param {object} recordValue - the new raw data values to set to the record. + * for examples, use notion.get to fetch an existing block record. + * to use this to update content, set pointer.path to ['properties', 'title] + * and recordValue to an array of rich text segments. a segment is an array + * where the first value is the displayed text and the second value + * is an array of decorations. a decoration is an array where the first value + * is a modifier and the second value specifies it. e.g. + * [ + * ['bold text', [['b']]], + * [' '], + * ['an italicised link', [['i'], ['a', 'https://github.com']]], + * [' '], + * ['highlighted text', [['h', 'pink_background']]], + * ] + * more examples can be creating a block with the desired content/formatting, + * then find the value of blockRecord.properties.title using notion.get. + * type definitions can be found here: https://github.com/NotionX/react-notion-x/blob/master/packages/notion-types/src/core.ts + * @param {string} pointer.recordID - uuidv4 record id + * @param {string=} pointer.recordTable - record type (default: 'block'). + * may also be 'collection', 'collection_view', 'space', 'notion_user', 'discussion', or 'comment' + * @param {string=} pointer.property - the record property to update. + * for record content, it will be the default: 'title'. + * for page properties, it will be the property id (the key used in pageRecord.properties). + * other possible values are unknown/untested + * @param {string=} pointer.spaceID - uuidv4 workspace id + * @param {string=} pointer.path - the path to the key to be set within the record + * (default: [], the root of the record's values) + * @returns {boolean|object} true if success, else an error object + */ +export const set = async ( + { recordID, recordTable = 'block', spaceID = getSpaceID(), path = [] }, + recordValue = {} +) => { + spaceID = standardiseUUID(await spaceID); + recordID = standardiseUUID(recordID); + const json = await fs.getJSON('https://www.notion.so/api/v3/saveTransactions', { + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + requestId: fmt.uuidv4(), + transactions: [ + { + id: fmt.uuidv4(), + spaceId: spaceID, + operations: [ + { + pointer: { + table: recordTable, + id: recordID, + spaceId: spaceID, + }, + path, + command: path.length ? 'set' : 'update', + args: recordValue, + }, + ], + }, + ], + }), + method: 'POST', + }); + return json.errorId ? json : true; +}; + +/** + * unofficial content api: create and add a new block to a page + * (requires user to be signed in or content to be public). + * TEST THIS THOROUGHLY. misuse can corrupt a record, leading the notion client + * to be unable to parse and render content properly and throw errors. + * why not use the official api? + * 1. cors blocking prevents use on the client + * 2. the majority of blocks are still 'unsupported' + * @param {object} insert - the new record. + * @param {object} pointer - where to insert the new block + * for examples, use notion.get to fetch an existing block record. + * type definitions can be found here: https://github.com/NotionX/react-notion-x/blob/master/packages/notion-types/src/block.ts + * may also be 'collection', 'collection_view', 'space', 'notion_user', 'discussion', or 'comment' + * @param {object=} insert.recordValue - the new raw data values to set to the record. + * @param {object=} insert.recordTable - record type (default: 'block'). + * may also be 'collection', 'collection_view', 'space', 'notion_user', 'discussion', or 'comment' + * @param {string=} pointer.prepend - insert before pointer.siblingID. if false, will be appended after + * @param {string=} pointer.siblingID - uuidv4 sibling id. if unset, the record will be + * inserted at the end of the page start (or the start if pointer.prepend is true) + * @param {string=} pointer.parentID - uuidv4 parent id + * @param {string=} pointer.parentTable - parent record type (default: 'block'). + * @param {string=} pointer.spaceID - uuidv4 space id + * @param {string=} pointer.userID - uuidv4 user id + * instead of the end + * @returns {string|object} error object or uuidv4 of the new record + */ +export const create = async ( + { recordValue = {}, recordTable = 'block' } = {}, + { + prepend = false, + siblingID = undefined, + parentID = getPageID(), + parentTable = 'block', + spaceID = getSpaceID(), + userID = getUserID(), + } = {} +) => { + spaceID = standardiseUUID(await spaceID); + parentID = standardiseUUID(parentID); + siblingID = standardiseUUID(siblingID); + const recordID = standardiseUUID(recordValue?.id ?? fmt.uuidv4()), + path = [], + args = { + type: 'text', + id: recordID, + version: 0, + created_time: new Date().getTime(), + last_edited_time: new Date().getTime(), + parent_id: parentID, + parent_table: parentTable, + alive: true, + created_by_table: 'notion_user', + created_by_id: userID, + last_edited_by_table: 'notion_user', + last_edited_by_id: userID, + space_id: spaceID, + permissions: [{ type: 'user_permission', role: 'editor', user_id: userID }], + }; + if (parentTable === 'space') { + parentID = spaceID; + args.parent_id = spaceID; + path.push('pages'); + args.type = 'page'; + } else if (parentTable === 'collection_view') { + path.push('page_sort'); + args.type = 'page'; + } else { + path.push('content'); + } + const json = await fs.getJSON('https://www.notion.so/api/v3/saveTransactions', { + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + requestId: fmt.uuidv4(), + transactions: [ + { + id: fmt.uuidv4(), + spaceId: spaceID, + operations: [ + { + pointer: { + table: parentTable, + id: parentID, + spaceId: spaceID, + }, + path, + command: prepend ? 'listBefore' : 'listAfter', + args: { + ...(siblingID ? { after: siblingID } : {}), + id: recordID, + }, + }, + { + pointer: { + table: recordTable, + id: recordID, + spaceId: spaceID, + }, + path: [], + command: 'set', + args: { + ...args, + ...recordValue, + }, + }, + ], + }, + ], + }), + method: 'POST', + }); + return json.errorId ? json : recordID; +}; + +/** + * unofficial content api: upload a file to notion's aws servers + * (requires user to be signed in or content to be public). + * TEST THIS THOROUGHLY. misuse can corrupt a record, leading the notion client + * to be unable to parse and render content properly and throw errors. + * why not use the official api? + * 1. cors blocking prevents use on the client + * 2. the majority of blocks are still 'unsupported' + * @param {File} file - the file to upload + * @param {object=} pointer - where the file should be accessible from + * @param {string=} pointer.pageID - uuidv4 page id + * @param {string=} pointer.spaceID - uuidv4 space id + * @returns {string|object} error object or the url of the uploaded file + */ +export const upload = async (file, { pageID = getPageID(), spaceID = getSpaceID() } = {}) => { + spaceID = standardiseUUID(await spaceID); + pageID = standardiseUUID(pageID); + const json = await fs.getJSON('https://www.notion.so/api/v3/getUploadFileUrl', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + bucket: 'secure', + name: file.name, + contentType: file.type, + record: { + table: 'block', + id: pageID, + spaceId: spaceID, + }, + }), + }); + if (json.errorId) return json; + fetch(json.signedPutUrl, { + method: 'PUT', + headers: { 'content-type': file.type }, + body: file, + }); + return json.url; +}; + +/** + * redirect through notion to a resource's signed aws url for display outside of notion + * (requires user to be signed in or content to be public) + * @param src source url for file + * @param {string} recordID uuidv4 record/block/file id + * @param {string=} recordTable record type (default: 'block'). + * may also be 'collection', 'collection_view', 'space', 'notion_user', 'discussion', or 'comment' + * @returns {string} url signed if necessary, else string as-is + */ +export const sign = (src, recordID, recordTable = 'block') => { + if (src.startsWith('/')) src = `https://notion.so${src}`; + if (src.includes('secure.notion-static.com')) { + src = new URL(src); + src = `https://www.notion.so/signed/${encodeURIComponent( + src.origin + src.pathname + )}?table=${recordTable}&id=${recordID}`; + } + return src; +}; diff --git a/api/registry-validation.mjs b/api/registry-validation.mjs new file mode 100644 index 0000000..1b032d2 --- /dev/null +++ b/api/registry-validation.mjs @@ -0,0 +1,224 @@ +/** + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +import { fmt, registry } from './index.mjs'; + +const check = async ( + mod, + key, + value, + types, + { + extension = '', + error = `invalid ${key} (${extension ? `${extension} ` : ''}${types}): ${JSON.stringify( + value + )}`, + optional = false, + } = {} +) => { + let test; + for (const type of Array.isArray(types) ? [types] : types.split('|')) { + if (type === 'file') { + test = + value && !value.startsWith('http') + ? await fmt.is(`repo/${mod._dir}/${value}`, type, { extension }) + : false; + } else test = await fmt.is(value, type, { extension }); + if (test) break; + } + if (!test) { + if (optional && (await fmt.is(value, 'undefined'))) return true; + if (error) mod._err(error); + return false; + } + return true; +}; + +const validateEnvironments = async (mod) => { + mod.environments = mod.environments ?? registry.supportedEnvs; + const isArray = await check(mod, 'environments', mod.environments, 'array'); + if (!isArray) return false; + return mod.environments.map((tag) => + check(mod, 'environments.env', tag, registry.supportedEnvs) + ); + }, + validateTags = async (mod) => { + const isArray = await check(mod, 'tags', mod.tags, 'array'); + if (!isArray) return false; + const categoryTags = ['core', 'extension', 'theme', 'integration'], + containsCategory = mod.tags.filter((tag) => categoryTags.includes(tag)).length; + if (!containsCategory) { + mod._err( + `invalid tags (must contain at least one of 'core', 'extension', 'theme' or 'integration'): + ${JSON.stringify(mod.tags)}` + ); + return false; + } + const isTheme = mod.tags.includes('theme'), + hasThemeMode = mod.tags.includes('light') || mod.tags.includes('dark'), + isBothThemeModes = mod.tags.includes('light') && mod.tags.includes('dark'); + if (isTheme && (!hasThemeMode || isBothThemeModes)) { + mod._err( + `invalid tags (themes must be either 'light' or 'dark', not neither or both): + ${JSON.stringify(mod.tags)}` + ); + return false; + } + return mod.tags.map((tag) => check(mod, 'tags.tag', tag, 'string')); + }, + validateAuthors = async (mod) => { + const isArray = await check(mod, 'authors', mod.authors, 'array'); + if (!isArray) return false; + return mod.authors.map((author) => [ + check(mod, 'authors.author.name', author.name, 'string'), + check(mod, 'authors.author.email', author.email, 'email', { optional: true }), + check(mod, 'authors.author.homepage', author.homepage, 'url'), + check(mod, 'authors.author.avatar', author.avatar, 'url'), + ]); + }, + validateCSS = async (mod) => { + const isArray = await check(mod, 'css', mod.css, 'object'); + if (!isArray) return false; + const tests = []; + for (const dest of ['frame', 'client', 'menu']) { + if (!mod.css[dest]) continue; + let test = await check(mod, `css.${dest}`, mod.css[dest], 'array'); + if (test) { + test = mod.css[dest].map((file) => + check(mod, `css.${dest}.file`, file, 'file', { extension: '.css' }) + ); + } + tests.push(test); + } + return tests; + }, + validateJS = async (mod) => { + const isArray = await check(mod, 'js', mod.js, 'object'); + if (!isArray) return false; + const tests = []; + for (const dest of ['frame', 'client', 'menu']) { + if (!mod.js[dest]) continue; + let test = await check(mod, `js.${dest}`, mod.js[dest], 'array'); + if (test) { + test = mod.js[dest].map((file) => + check(mod, `js.${dest}.file`, file, 'file', { extension: '.mjs' }) + ); + } + tests.push(test); + } + if (mod.js.electron) { + const isArray = await check(mod, 'js.electron', mod.js.electron, 'array'); + if (isArray) { + for (const file of mod.js.electron) { + const isObject = await check(mod, 'js.electron.file', file, 'object'); + if (!isObject) { + tests.push(false); + continue; + } + tests.push([ + check(mod, 'js.electron.file.source', file.source, 'file', { + extension: '.cjs', + }), + // referencing the file within the electron app + // existence can't be validated, so only format is + check(mod, 'js.electron.file.target', file.target, 'string', { + extension: '.js', + }), + ]); + } + } else tests.push(false); + } + return tests; + }, + validateOptions = async (mod) => { + const isArray = await check(mod, 'options', mod.options, 'array'); + if (!isArray) return false; + const tests = []; + for (const option of mod.options) { + const key = 'options.option', + optTypeValid = await check(mod, `${key}.type`, option.type, registry.optionTypes); + if (!optTypeValid) { + tests.push(false); + continue; + } + option.environments = option.environments ?? registry.supportedEnvs; + tests.push([ + check(mod, `${key}.key`, option.key, 'alphanumeric'), + check(mod, `${key}.label`, option.label, 'string'), + check(mod, `${key}.tooltip`, option.tooltip, 'string', { + optional: true, + }), + check(mod, `${key}.environments`, option.environments, 'array').then((isArray) => { + if (!isArray) return false; + return option.environments.map((environment) => + check(mod, `${key}.environments.env`, environment, registry.supportedEnvs) + ); + }), + ]); + switch (option.type) { + case 'toggle': + tests.push(check(mod, `${key}.value`, option.value, 'boolean')); + break; + case 'select': { + let test = await check(mod, `${key}.values`, option.values, 'array'); + if (test) { + test = option.values.map((value) => + check(mod, `${key}.values.value`, value, 'string') + ); + } + tests.push(test); + break; + } + case 'text': + case 'hotkey': + tests.push(check(mod, `${key}.value`, option.value, 'string')); + break; + case 'number': + case 'color': + tests.push(check(mod, `${key}.value`, option.value, option.type)); + break; + case 'file': { + let test = await check(mod, `${key}.extensions`, option.extensions, 'array'); + if (test) { + test = option.extensions.map((ext) => + check(mod, `${key}.extensions.extension`, ext, 'string') + ); + } + tests.push(test); + break; + } + } + } + return tests; + }; + +/** + * internally used to validate mod.json files and provide helpful errors + * @private + * @param {object} mod - a mod's mod.json in object form + * @returns {boolean} whether or not the mod has passed validation + */ +export async function validate(mod) { + let conditions = [ + check(mod, 'name', mod.name, 'string'), + check(mod, 'id', mod.id, 'uuid'), + check(mod, 'version', mod.version, 'semver'), + validateEnvironments(mod), + check(mod, 'description', mod.description, 'string'), + check(mod, 'preview', mod.preview, 'file|url', { optional: true }), + validateTags(mod), + validateAuthors(mod), + validateCSS(mod), + validateJS(mod), + validateOptions(mod), + ]; + do { + conditions = await Promise.all(conditions.flat(Infinity)); + } while (conditions.some((condition) => Array.isArray(condition))); + return conditions.every((passed) => passed); +} diff --git a/api/registry.mjs b/api/registry.mjs new file mode 100644 index 0000000..4c2682b --- /dev/null +++ b/api/registry.mjs @@ -0,0 +1,160 @@ +/** + * 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 + * @namespace registry + */ + +import { env, fs, storage } from './index.mjs'; +import { validate } from './registry-validation.mjs'; + +/** + * mod ids whitelisted as part of the enhancer's core, permanently enabled + * @constant + * @type {string[]} + */ +export const core = [ + 'a6621988-551d-495a-97d8-3c568bca2e9e', + '0f0bf8b6-eae6-4273-b307-8fc43f2ee082', + '36a2ffc9-27ff-480e-84a7-c7700a7d232d', +]; + +/** + * all environments/platforms currently supported by the enhancer + * @constant + * @type {string[]} + */ +export const supportedEnvs = ['linux', 'win32', 'darwin', 'extension']; + +/** + * all available configuration types + * @constant + * @type {string[]} + */ +export const optionTypes = ['toggle', 'select', 'text', 'number', 'color', 'file', 'hotkey']; + +/** + * the name of the active configuration profile + * @returns {string} + */ +export const profileName = () => storage.get(['currentprofile'], 'default'); + +/** + * the root database for the current profile + * @returns {object} the get/set functions for the profile's storage + */ +export const profileDB = async () => storage.db(['profiles', await profileName()]); + +let _list; +const _errors = []; +/** + * list all available mods in the repo + * @param {function} filter - a function to filter out mods + * @returns {array} a validated list of mod.json objects + */ +export const list = async (filter = (mod) => true) => { + if (!_list) { + // deno-lint-ignore no-async-promise-executor + _list = new Promise(async (res, _rej) => { + const passed = []; + for (const dir of await fs.getJSON('repo/registry.json')) { + try { + const mod = { + ...(await fs.getJSON(`repo/${dir}/mod.json`)), + _dir: dir, + _err: (message) => _errors.push({ source: dir, message }), + }; + if (await validate(mod)) passed.push(mod); + } catch { + _errors.push({ source: dir, message: 'invalid mod.json' }); + } + } + res(passed); + }); + } + const filtered = []; + for (const mod of await _list) if (await filter(mod)) filtered.push(mod); + return filtered; +}; + +/** + * list validation errors encountered when loading the repo + * @returns {{ source: string, message: string }[]} error objects with an error message and a source directory + */ +export const errors = async () => { + await list(); + return _errors; +}; + +/** + * get a single mod from the repo + * @param {string} id - the uuid of the mod + * @returns {object} the mod's mod.json + */ +export const get = async (id) => { + return (await list((mod) => mod.id === id))[0]; +}; + +/** + * 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 + */ +export const enabled = async (id) => { + const mod = await get(id); + if (!mod.environments.includes(env.name)) return false; + if (core.includes(id)) return true; + return (await profileDB()).get(['_mods', id], false); +}; + +/** + * get a default value of a mod's option according to its mod.json + * @param {string} id - the uuid of the mod + * @param {string} key - the key of the option + * @returns {string|number|boolean|undefined} the option's default value + */ +export const optionDefault = async (id, key) => { + const mod = await get(id), + opt = mod.options.find((opt) => opt.key === key); + if (!opt) return undefined; + switch (opt.type) { + case 'toggle': + case 'text': + case 'number': + case 'color': + case 'hotkey': + return opt.value; + case 'select': + return opt.values[0]; + case 'file': + return undefined; + } +}; + +/** + * access the storage partition of a mod in the current profile + * @param {string} id - the uuid of the mod + * @returns {object} an object with the wrapped get/set functions + */ +export const db = async (id) => { + const db = await profileDB(); + return storage.db( + [id], + async (path, fallback = undefined) => { + if (typeof path === 'string') path = [path]; + if (path.length === 2) { + // profiles -> profile -> mod -> option + fallback = (await optionDefault(id, path[1])) ?? fallback; + } + return db.get(path, fallback); + }, + db.set + ); +}; diff --git a/api/storage.mjs b/api/storage.mjs new file mode 100644 index 0000000..8c7dc31 --- /dev/null +++ b/api/storage.mjs @@ -0,0 +1,65 @@ +/** + * notion-enhancer: api + * (c) 2021 dragonwocky (https://dragonwocky.me/) + * (https://notion-enhancer.github.io/) under the MIT license + */ + +'use strict'; + +/** + * environment-specific data persistence + * @namespace storage + */ + +import * as storage from '../env/storage.mjs'; + +/** + * get persisted data + * @type {function} + * @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 = storage.get; + +/** + * persist data + * @type {function} + * @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 = storage.set; + +/** + * create a wrapper for accessing a partition of the storage + * @type {function} + * @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 = storage.db; + +/** + * add an event listener for changes in storage + * @type {function} + * @param {onStorageChangeCallback} callback - called whenever a change in + * storage is initiated from the current process + */ +export const addChangeListener = storage.addChangeListener; + +/** + * remove a listener added with storage.addChangeListener + * @type {function} + * @param {onStorageChangeCallback} callback + */ +export const removeChangeListener = storage.removeChangeListener; + +/** + * @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/api/web.mjs b/api/web.mjs new file mode 100644 index 0000000..8b201e0 --- /dev/null +++ b/api/web.mjs @@ -0,0 +1,301 @@ +/** + * 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 + * @namespace web + */ + +import { fs } from './index.mjs'; + +let _hotkeyListenersActivated = false, + _hotkeyEventListeners = [], + _documentObserver, + _documentObserverListeners = []; +const _documentObserverEvents = []; + +/** + * 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) => { + const onLoad = () => { + const interval = setInterval(isReady, 100); + function isReady() { + const ready = selectors.every((selector) => document.querySelector(selector)); + if (!ready) return; + clearInterval(interval); + res(true); + } + isReady(); + }; + if (document.readyState !== 'complete') { + document.addEventListener('readystatechange', (_event) => { + if (document.readyState === 'complete') onLoad(); + }); + } else onLoad(); + }); +}; + +/** + * parse the current location search params into a usable form + * @returns {Map} a map of the url search params + */ +export const queryParams = () => new URLSearchParams(window.location.search); + +/** + * 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 loadStylesheet = (path) => { + const $stylesheet = html``; + render(document.head, $stylesheet); + return $stylesheet; +}; + +/** + * copy text to the clipboard + * @param {string} str - the string to copy + * @returns {Promise} + */ +export const copyToClipboard = async (str) => { + try { + await navigator.clipboard.writeText(str); + } catch { + const $el = document.createElement('textarea'); + $el.value = str; + $el.setAttribute('readonly', ''); + $el.style.position = 'absolute'; + $el.style.left = '-9999px'; + document.body.appendChild($el); + $el.select(); + document.execCommand('copy'); + document.body.removeChild($el); + } +}; + +/** + * read text from the clipboard + * @returns {Promise} + */ +export const readFromClipboard = () => { + return navigator.clipboard.readText(); +}; + +const triggerHotkeyListener = (event, hotkey) => { + const inInput = document.activeElement.nodeName === 'INPUT' && !hotkey.listenInInput; + if (inInput) return; + const modifiers = { + metaKey: ['meta', 'os', 'win', 'cmd', 'command'], + ctrlKey: ['ctrl', 'control'], + shiftKey: ['shift'], + altKey: ['alt'], + }, + pressed = hotkey.keys.every((key) => { + key = key.toLowerCase(); + for (const modifier in modifiers) { + const pressed = modifiers[modifier].includes(key) && event[modifier]; + if (pressed) { + // mark modifier as part of hotkey + modifiers[modifier] = []; + return true; + } + } + if (key === 'space') key = ' '; + if (key === 'plus') key = '+'; + if (key === event.key.toLowerCase()) return true; + }); + if (!pressed) return; + // test for modifiers not in hotkey + // e.g. to differentiate ctrl+x from ctrl+shift+x + for (const modifier in modifiers) { + const modifierPressed = event[modifier], + modifierNotInHotkey = modifiers[modifier].length > 0; + if (modifierPressed && modifierNotInHotkey) return; + } + hotkey.callback(event); +}; + +/** + * register a hotkey listener to the page + * @param {array|string} 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'. + * can be provided as a + separated string. + * @param {function} callback - called whenever the keys are pressed + * @param {object=} opts - fine-tuned control over when the hotkey should be triggered + * @param {boolean=} opts.listenInInput - whether the hotkey callback should be triggered + * when an input is focused + * @param {boolean=} opts.keydown - whether to listen for the hotkey on keydown. + * by default, hotkeys are triggered by the keyup event. + */ +export const addHotkeyListener = ( + keys, + callback, + { listenInInput = false, keydown = false } = {} +) => { + if (typeof keys === 'string') keys = keys.split('+'); + _hotkeyEventListeners.push({ keys, callback, listenInInput, keydown }); + + if (!_hotkeyListenersActivated) { + _hotkeyListenersActivated = true; + document.addEventListener('keyup', (event) => { + for (const hotkey of _hotkeyEventListeners.filter(({ keydown }) => !keydown)) { + triggerHotkeyListener(event, hotkey); + } + }); + document.addEventListener('keydown', (event) => { + for (const hotkey of _hotkeyEventListeners.filter(({ keydown }) => keydown)) { + triggerHotkeyListener(event, hotkey); + } + }); + } +}; +/** + * 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 {string[]=} selectors + */ +export const addDocumentObserver = (callback, selectors = []) => { + if (!_documentObserver) { + const handle = (queue) => { + while (queue.length) { + const event = queue.shift(), + matchesAddedNode = ($node, selector) => + $node instanceof Element && + ($node.matches(selector) || + $node.matches(`${selector} *`) || + $node.querySelector(selector)), + matchesTarget = (selector) => + event.target.matches(selector) || + event.target.matches(`${selector} *`) || + [...event.addedNodes].some(($node) => matchesAddedNode($node, selector)); + for (const listener of _documentObserverListeners) { + if (!listener.selectors.length || listener.selectors.some(matchesTarget)) { + 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 + */