/** * notion-enhancer * (c) 2023 dragonwocky (https://dragonwocky.me/) * (https://notion-enhancer.github.io/) under the MIT license */ import { getState, setState, useState } from "./state.mjs"; import { Button, Description, Sidebar, SidebarSection, SidebarButton, List, Footer, View, Input, Mod, Option, Profile, } from "./components.mjs"; const compatibleMods = (mods) => { const { platform } = globalThis.__enhancerApi; return mods.filter((mod) => { const required = mod.id && mod.name && mod.version && mod.description && mod.thumbnail && mod.authors, compatible = !mod.platforms || mod.platforms.includes(platform); return required && compatible; }); }; const renderSidebar = (items, categories) => { const { html, isEnabled } = globalThis.__enhancerApi, $sidebar = html`<${Sidebar}> ${items.map((item) => { if (typeof item === "object") { const { title, ...props } = item; return html`<${SidebarButton} ...${props}>${title}`; } else return html`<${SidebarSection}>${item}`; })} `; for (const { title, mods } of categories) { const $title = html`<${SidebarSection}>${title}`, $mods = mods.map((mod) => [ mod.id, html`<${SidebarButton} id=${mod.id}>${mod.name}`, ]); $sidebar.append($title, ...$mods.map(([, $btn]) => $btn)); useState(["rerender"], async () => { let sectionVisible = false; for (const [id, $btn] of $mods) { if (await isEnabled(id)) { $btn.style.display = ""; sectionVisible = true; } else $btn.style.display = "none"; } $title.style.display = sectionVisible ? "" : "none"; }); } return $sidebar; }, renderList = async (id, mods, description) => { const { html, isEnabled, setEnabled } = globalThis.__enhancerApi; mods = mods.map(async (mod) => { const _get = () => isEnabled(mod.id), _set = async (enabled) => { await setEnabled(mod.id, enabled); setState({ rerender: true, databaseUpdated: true }); }; return html`<${Mod} ...${{ ...mod, _get, _set }} />`; }); return html`<${List} ...${{ id, description }}> ${await Promise.all(mods)} `; }, renderOptions = async (mod) => { const { html, platform, modDatabase } = globalThis.__enhancerApi; let options = mod.options.reduce((options, opt) => { if (!opt.key && (opt.type !== "heading" || !opt.label)) return options; if (opt.platforms && !opt.platforms.includes(platform)) return options; const prevOpt = options[options.length - 1]; // no consective headings if (opt.type === "heading" && prevOpt?.type === opt.type) { options[options.length - 1] = opt; } else options.push(opt); return options; }, []); // no empty/end headings e.g. if section is platform-specific if (options[options.length - 1]?.type === "heading") options.pop(); options = options.map(async (opt) => { if (opt.type === "heading") return html`<${Option} ...${opt} />`; const _get = async () => (await modDatabase(mod.id)).get(opt.key), _set = async (value) => { await (await modDatabase(mod.id)).set(opt.key, value); setState({ rerender: true, databaseUpdated: true }); }; return html`<${Option} ...${{ ...opt, _get, _set }} />`; }); return Promise.all(options); }, renderProfiles = async () => { let profileIds; const { html, initDatabase, getProfile } = globalThis.__enhancerApi, db = initDatabase(), $list = html``, renderProfile = (id) => { const profile = initDatabase([id]), isActive = async () => id === (await getProfile()), deleteProfile = async () => { const keys = Object.keys(await profile.export()); profileIds.splice(profileIds.indexOf(id), 1); await db.set("profileIds", profileIds); await profile.remove(keys); if (isActive()) { await db.remove("activeProfile"); setState({ databaseUpdated: true }); } setState({ rerender: true }); }; return html`<${Profile} id=${id} getName=${async () => (await profile.get("profileName")) ?? (id === "default" ? "default" : "")} setName=${(name) => profile.set("profileName", name)} isActive=${isActive} setActive=${async () => { await db.set("activeProfile", id); setState({ rerender: true, databaseUpdated: true }); }} exportJson=${async () => JSON.stringify(await profile.export())} importJson=${async (json) => { try { await profile.import(JSON.parse(json)); setState({ rerender: true, databaseUpdated: true }); // success } catch { // error } }} deleteProfile=${deleteProfile} />`; }, refreshProfiles = async () => { profileIds = await db.get("profileIds"); if (!profileIds?.length) profileIds = ["default"]; for (const $profile of $list.children) { const exists = profileIds.includes($profile.id); if (!exists) $profile.remove(); } for (let i = 0; i < profileIds.length; i++) { const id = profileIds[i]; if (document.getElementById(id)) continue; const $profile = await renderProfile(id), $next = document.getElementById(profileIds[i + 1]); if ($next) $list.insertBefore($profile, $next); else $list.append($profile); } }, addProfile = async (name) => { const id = crypto.randomUUID(); await db.set("profileIds", [...profileIds, id]); const profile = initDatabase([id]); await profile.set("profileName", name); refreshProfiles(); }; useState(["rerender"], () => refreshProfiles()); const $input = html`<${Input} size="md" type="text" icon="file-cog" onkeydown=${(event) => { if (event.key === "Enter") { if (!$input.children[0].value) return; addProfile($input.children[0].value); $input.children[0].value = ""; } }} />`; return html`
${$list}
${$input} <${Button} size="sm" icon="plus" onclick=${() => { if (!$input.children[0].value) return; addProfile($input.children[0].value); $input.children[0].value = ""; }} > Add Profile
`; }, renderMods = async (mods) => { const { html, isEnabled, setEnabled } = globalThis.__enhancerApi; mods = mods .filter((mod) => { return mod.options?.filter((opt) => opt.type !== "heading").length; }) .map(async (mod) => { const _get = () => isEnabled(mod.id), _set = async (enabled) => { await setEnabled(mod.id, enabled); setState({ rerender: true, databaseUpdated: true }); }; return html`<${View} id=${mod.id}> <${Mod} ...${{ ...mod, options: [], _get, _set }} /> ${await renderOptions(mod)} `; }); return Promise.all(mods); }; const render = async () => { const { html, reloadApp, getCore } = globalThis.__enhancerApi, { getThemes, getExtensions, getIntegrations } = globalThis.__enhancerApi, [icon, renderStarted] = getState(["icon", "renderStarted"]); if (!html || !getCore || !icon || renderStarted) return; setState({ renderStarted: true }); const categories = [ { icon: "palette", id: "themes", title: "Themes", description: `Themes override Notion's colour schemes. Dark themes require Notion to be in dark mode and light themes require Notion to be in light mode. To switch between dark mode and light mode, go to Settings & members → My notifications & settings → My settings → Appearance.`, mods: compatibleMods(await getThemes()), }, { icon: "zap", id: "extensions", title: "Extensions", description: `Extensions add to the functionality and layout of the Notion client, interacting with and modifying existing interfaces.`, mods: compatibleMods(await getExtensions()), }, { icon: "plug", id: "integrations", title: "Integrations", description: ` Integrations access and modify Notion content. They interact directly with https://www.notion.so/api/v3. Use at your own risk.`, mods: compatibleMods(await getIntegrations()), }, ], sidebar = [ "notion-enhancer", { id: "welcome", title: "Welcome", icon: `notion-enhancer${icon === "Monochrome" ? "?mask" : ""}`, }, { icon: "message-circle", title: "Community", href: "https://discord.gg/sFWPXtA", }, { icon: "clock", title: "Changelog", href: "https://notion-enhancer.github.io/about/changelog/", }, { icon: "book", title: "Documentation", href: "https://notion-enhancer.github.io/", }, { icon: "github", title: "Source Code", href: "https://github.com/notion-enhancer", }, { icon: "coffee", title: "Sponsor", href: "https://github.com/sponsors/dragonwocky", }, "Settings", { id: "core", title: "Core", icon: "sliders-horizontal", }, ...categories.map((c) => ({ id: c.id, title: c.title, icon: c.icon })), ]; // view wrapper necessary for transitions const $views = html`
<${View} id="welcome">welcome <${View} id="core"> ${await renderOptions(await getCore())} <${Option} type="heading" label="Profiles" /> <${Description}> Profiles can be used to preserve and switch between notion-enhancer configurations. ${await renderProfiles()}
`; for (const { id, description, mods } of categories) { const $list = await renderList(id, mods, description), $mods = await renderMods(mods); $views.append(html`<${View} id=${id}>${$list}`, ...$mods); } // footer appears only if buttons are visible // - the matching category button appears on a mod's options page // - the reload button appears if any options are changed categories.forEach((c) => { c.button = html`<${Button} icon="chevron-left" onclick=${() => setState({ transition: "slide-to-left", view: c.id })} > ${c.title} `; }); const $reload = html`<${Button} class="ml-auto" variant="primary" icon="refresh-cw" onclick=${() => reloadApp()} style="display: none" > Reload & Apply Changes `, $footer = html`<${Footer}>${categories.map((c) => c.button)}${$reload}`, $main = html`
${$views} ${$footer}
`, updateFooter = () => { const buttons = [...$footer.children], renderFooter = buttons.some(($el) => $el.style.display === ""); $main.style.height = renderFooter ? "100%" : "calc(100% + 33px)"; }; useState(["view"], ([view]) => { for (const { mods, button } of categories) { const renderButton = mods.some((mod) => mod.id === view); button.style.display = renderButton ? "" : "none"; updateFooter(); } }); useState(["databaseUpdated"], ([databaseUpdated]) => { if (!databaseUpdated) return; $reload.style.display = ""; updateFooter(); }); const $skeleton = document.querySelector("#skeleton"); $skeleton.replaceWith(renderSidebar(sidebar, categories), $main); }; window.addEventListener("focus", () => setState({ rerender: true })); window.addEventListener("message", (event) => { if (event.data?.namespace !== "notion-enhancer") return; const [hotkey, theme, icon] = getState(["hotkey", "theme", "icon"]); setState({ rerender: true, hotkey: event.data?.hotkey ?? hotkey, theme: event.data?.theme ?? theme, icon: event.data?.icon ?? icon, }); }); useState(["hotkey"], ([hotkey]) => { const { addKeyListener } = globalThis.__enhancerApi ?? {}, [hotkeyRegistered] = getState(["hotkeyRegistered"]); if (!hotkey || !addKeyListener || hotkeyRegistered) return; setState({ hotkeyRegistered: true }); addKeyListener(hotkey, (event) => { event.preventDefault(); const msg = { namespace: "notion-enhancer", action: "open-menu" }; parent?.postMessage(msg, "*"); }); addKeyListener("Escape", () => { const [popupOpen] = getState(["popupOpen"]); if (!popupOpen) { const msg = { namespace: "notion-enhancer", action: "close-menu" }; parent?.postMessage(msg, "*"); } else setState({ rerender: true }); }); }); useState(["theme"], ([theme]) => { if (theme === "dark") document.body.classList.add("dark"); if (theme === "light") document.body.classList.remove("dark"); }); useState(["rerender"], async () => { const [theme, icon] = getState(["theme", "icon"]); if (!theme || !icon) return; // chrome extensions run in an isolated execution context // but extension:// pages can access chrome apis // ∴ notion-enhancer api is imported directly if (typeof globalThis.__enhancerApi === "undefined") { await import("../../api/browser.js"); // in electron this isn't necessary, as a) scripts are // not running in an isolated execution context and b) // the notion:// protocol csp bypass allows scripts to // set iframe globals via $iframe.contentWindow } // load stylesheets from enabled themes await import("../../load.mjs"); // wait for api globals to be available requestIdleCallback(() => render()); });