diff --git a/package-lock.json b/package-lock.json index aea21b8..e813b13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,9 @@ "version": "1.1.0", "license": "MIT", "dependencies": { - "dompurify": "^2.4.1", - "github-reserved-names": "^2.0.4", + "dompurify": "^3.2.0", + "github-reserved-names": "^2.0.7", + "showdown": "^2.1.0", "uhtml": "^3.1.0" }, "devDependencies": { @@ -6692,6 +6693,31 @@ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "dev": true }, + "node_modules/showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "license": "MIT", + "dependencies": { + "commander": "^9.0.0" + }, + "bin": { + "showdown": "bin/showdown.js" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/tiviesantos" + } + }, + "node_modules/showdown/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -13667,6 +13693,21 @@ "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "dev": true }, + "showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "requires": { + "commander": "^9.0.0" + }, + "dependencies": { + "commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==" + } + } + }, "side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", diff --git a/package.json b/package.json index 634aed7..5422d38 100644 --- a/package.json +++ b/package.json @@ -24,8 +24,9 @@ "contributors": [], "license": "MIT", "dependencies": { - "dompurify": "^2.4.1", - "github-reserved-names": "^2.0.4", + "dompurify": "^3.2.0", + "github-reserved-names": "^2.0.7", + "showdown": "^2.1.0", "uhtml": "^3.1.0" }, "devDependencies": { diff --git a/src/conf.priv.example.js b/src/conf.priv.example.js index e31b824..e12a007 100644 --- a/src/conf.priv.example.js +++ b/src/conf.priv.example.js @@ -19,6 +19,7 @@ const keys = { wolframalpha: "", // https://products.wolframalpha.com/api/ domainr: "", // https://market.mashape.com/domainr/domainr alternativeTo: "", // Extract the x-algolia-api-key sent with requests when typing in search box on alternativeto.net + openai: "", // https://platform.openai.com/api-keys // ****** Google Custom Search Engines ****** // // Can be created at https://cse.google.com/cse diff --git a/src/omnibar.js b/src/omnibar.js new file mode 100644 index 0000000..03b774b --- /dev/null +++ b/src/omnibar.js @@ -0,0 +1,411 @@ +import actions from "./actions.js" +import utils from "./util.js" + +const { Front } = api +const { htmlPurify } = utils +const omnibar = {} + +/** + * Opens a custom Omnibar UI. + * + * @param {object} options - The options for the Omnibar. + * @param {function} options.onEnter - The function to call when an item is selected. + * It can return a string to display as a message, or a list of strings, or a list of objects + * having `text` and `url` properties. The default function opens the selected URL in a new tab + * if it has a URL property. Its arguments are the selected item (an object with `text` and `url`) + * and the options object, containing `newTab` and `active` properties and the `shiftKey` and `ctrlKey` + * modifier flags. It also includes a `listSelect` property to indicate if the item was clicked/selected + * from the list or it comes from the input field. + * @param {function} options.onResultClick - The function to call when a result is clicked. + * It takes two parameters: the click event and the result object. + * By default, it points to `null`, meaning that the `onEnter` function will be called for the clicked result. + * @param {function} options.autocomplete - The function to call to get autocomplete results. + * It can return a string to display as a message, or a list of strings, or a list of objects + * having `text` and `url` properties. Its argument is an object with `text` and `url` properties. + * @param {boolean} options.newTab - Whether to open the selected item in a new tab (default: false). + * @param {boolean} options.active - Whether the new tab should be active (default: true). + * @param {boolean} options.multiline - Whether the input field should be a textarea (default: false). + * @param {string} options.placeholder - The placeholder text for the input field (default: ""). + * @param {number} options.autocompleteStartTimeout - The timeout in milliseconds to wait before + * calling the autocomplete function (default: 1000). + */ +omnibar.open = ( + { + onEnter = (item, props) => { + if (!item.url) { + return "No URL provided" + } + + actions.openLink(item.url, props) + }, + onResultClick = null, + autocomplete = () => [], + newTab = false, + active = true, + multiline = false, + placeholder = "", + autocompleteStartTimeout = 1000, + } = {} +) => { + let results = [], + focusedResult = -1, + listItems = [], + inputText = "", + inputUrl = null, + abortController = null, + autocompleteTimeout = null, + originalHandlers = {} + + var iframe, input, list, cachedPromise, omnibar + const _onResultClick = onResultClick || ( + (e, result) => { + onEnter(result, { + newTab, + active, + shiftKey: e.shiftKey, + ctrlKey: e.ctrlKey, + listSelect: true, + }) + } + ) + + /** + * Creates the custom Omnibar UI. + */ + const init = () => { + iframe = ( + [...document.body.parentElement.children] + .filter((el) => !!el.shadowRoot) + .map((el) => el.shadowRoot.activeElement) + .filter((el) => el.classList?.contains('sk_ui')) + )?.[0] + + if (!iframe) { + console.error("Omnibar not found") + return + } + + const body = iframe.contentWindow.document.body + omnibar = body.querySelector('#sk_omnibar') + input = ( + body.querySelector('#sk_omnibarSearchArea input') || + body.querySelector('#sk_omnibarSearchArea textarea') + ) + list = body.querySelector('#sk_omnibarSearchResult') + + if (!(omnibar && input && list)) { + // XXX: This is a workaround for the Omnibar not being ready yet. + // It should probably be replaced by a reactive callback, but at + // the moment that `Front.openOmnibar` is called the Omnibar DOM + // is not yet ready. + setTimeout(init, 100) + return + } + + // If it's a multiline input, but the Omnibar input element is not a textarea, + // we need to create a new textarea element and replace the input element. + if (multiline) { + let textarea = input + if (input.tagName !== "TEXTAREA") { + textarea = document.createElement("textarea") + input.parentNode.replaceChild(textarea, input) + } + + input = textarea + input.spellcheck = false + } else { + // Otherwise, if it's not a multiline input, make sure it's an input element. + if (input.tagName !== "INPUT") { + let newInput = document.createElement("input") + input.parentNode.replaceChild(newInput, input) + input = newInput + } + } + + setTimeout(() => input.focus(), 100) + originalHandlers = { + onkeydown: input.onkeydown, + onkeyup: input.onkeyup, + oninput: input.oninput, + } + + input.placeholder = placeholder + input.onkeydown = _onKeyDown + input.onkeyup = _onKeyUp + input.oninput = _onInput + reset() + } + + /** + * Closes the Omnibar and resets the state. + */ + const close = () => { + if (omnibar) { + omnibar.style.display = "none" + } + + reset() + Object.entries(originalHandlers).forEach(([key, value]) => { + input[key] = value + }) + } + + /** + * Resets the state of the Omnibar. + */ + const reset = () => { + input.value = "" + inputText = "" + inputUrl = null + results = [] + focusedResult = -1 + listItems = [] + list.innerHTML = "" + cancel() + } + + /** + * Cancels the cached promise. + */ + const cancel = () => { + if (abortController) { + abortController.abort() + abortController = null + } + + if (autocompleteTimeout) { + clearTimeout(autocompleteTimeout) + autocompleteTimeout = null + } + } + + /** + * Selects a result. + * + * @param {object} result - The result to select. If not provided: + * - If a result is focused, selects that result. + * - Otherwise, selects the default result. + */ + const select = (result, { shiftKey = false, ctrlKey = false, listSelect = false } = {}) => { + if (!result) { + result = (focusedResult >= 0 && focusedResult < results.length) + ? results[focusedResult] : { text: inputText, url: inputUrl } + } + + const ret = onEnter(result, { newTab, active, shiftKey, ctrlKey, listSelect }) + const reset = () => { + results = [] + focusedResult = -1 + } + + if (ret == null) { + // If the onEnter function returns null, close the Omnibar. + close() + } else if (ret instanceof Promise) { + reset() + input?.classList?.add("loading") + ret.then((response) => { + if (response == null) { + close() + return + } + + if (!Array.isArray(response)) { + response = [response] + } + + results = response + _renderResults(response) + }).finally(() => { + input?.classList?.remove("loading") + }) + } else { + reset() + results = ret + _renderResults(ret) + } + } + + /** + * Updates the Omnibar with the given input. + * + * @param {list} results - The list of results to display, + * each with a `text` and `url` property, or as a string. + */ + const _renderResults = (results) => { + list.innerHTML = "" + listItems = [] + + if (!results?.length) { + results = [] + } + + results.forEach((result) => { + const ul = document.createElement('ul') + const li = document.createElement('li') + const container = document.createElement('div') + const text = document.createElement('div') + + container.classList.add('text-container') + text.classList.add('text') + + if (result.html) { + text.innerHTML = htmlPurify(result.html) + } else { + text.innerText = result.text || result + } + + li.onclick = (e) => _onResultClick(e, result) + listItems.push(li) + container.appendChild(text) + + if (result.url?.length) { + const url = document.createElement('div') + url.classList.add('url') + url.innerText = result.url + container.appendChild(url) + } + + let iconSrc = result.icon + if (iconSrc?.length) { + const icon = document.createElement('img') + icon.classList.add('icon') + icon.src = iconSrc + li.appendChild(icon) + } + + li.appendChild(container) + ul.appendChild(li) + list.appendChild(ul) + }) + } + + /** + * Focuses the result at the given index. + * + * @param {number} index - The index of the result to focus. + */ + const _focusResult = (index) => { + if (index < 0 || index >= results.length) { + index = -1 + } + + focusedResult = index + listItems.forEach((li, i) => { + if (i === index) { + li.classList.add('focused') + } else { + li.classList.remove('focused') + } + }) + } + + /** + * Handles keydown events for the Omnibar. + * + * @param {KeyboardEvent} e - The keydown event. + */ + const _onKeyDown = (e) => { + if (e.key === "Escape") { + close() + return + } + + // Adjust the height of the textarea to fit the content. + if (multiline) { + e.target.style.height = "" + e.target.style.height = e.target.scrollHeight + "px" + } + } + + /** + * Handles keyup events for the Omnibar. + * + * @param {KeyboardEvent} e - The keyup event. + */ + const _onKeyUp = (e) => { + if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { + if (!results.length) { + return + } + + _focusResult((focusedResult + 1) % results.length) + } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { + if (!results.length) { + return + } + + _focusResult((focusedResult - 1 + results.length) % results.length) + } else if (e.key === "Enter") { + select(results[focusedResult], { + shiftKey: e.shiftKey, + ctrlKey: e.ctrlKey, + listSelect: focusedResult >= 0, + }) + } + } + + /** + * Handles input events for the Omnibar. + * + * @param {InputEvent} e - The input event. + */ + const _onInput = (e) => { + if (e.target.value === inputText) { + return + } + + inputText = e.target.value + try { + inputUrl = new URL(inputText).href + } catch (e) { + inputUrl = null + } + + cancel() + autocompleteTimeout = setTimeout(() => { + const text = inputText.trim() + if (!text) { + return + } + + cachedPromise = new Promise((resolve, reject) => { + const abortListener = () => reject(new Error("Aborted")) + abortController = new AbortController() + abortController.signal.addEventListener("abort", abortListener) + + const ret = autocomplete({ + text, + url: inputUrl, + }) + + if (ret instanceof Promise) { + ret.then(resolve).catch(reject) + } else { + resolve(ret) + } + }) + + cachedPromise.then((response) => { + if (response == null) { + response = [] + } + + if (!Array.isArray(response)) { + response = [response] + } + + results = response + _renderResults(results) + }) + }, autocompleteStartTimeout) + } + + Front.openOmnibar({ + type: "SearchEngine", + }) + + setTimeout(init, 100) +} + +export default omnibar diff --git a/src/plugins/openai.js b/src/plugins/openai.js new file mode 100644 index 0000000..64bd26b --- /dev/null +++ b/src/plugins/openai.js @@ -0,0 +1,119 @@ +import api from "../api.js" +import omnibar from "../omnibar.js" +import priv from "../conf.priv.js" + +const { Clipboard } = api + +// ChatGPT requires the showdown library to convert Markdown to HTML: +// npm install showdown +import showdown from "showdown" + +/** + * Open the Omnibar with OpenAI results. + * + * It requires an API key from OpenAI, to be specified in the priv.js file. + * + * @param {Object} options - Options object. + * @param {string} options.model - The model to use (default: "gpt-3.5-turbo"). + * @param {number} options.maxTokens - The maximum number of tokens to generate (default: 500). + * @param {Object[]} options.context - The initial context (default: []). + * + * Example usage: Add the following in your keys.js file: + +import openaiCallback from "./plugins/openai.js" + +maps.global = [ + // ... + // OpenAI + { + alias: "ai", + category: categories.misc, + description: "Open ChatGPT", + callback: openaiCallback(), + }, + // ... +] + +*/ +export default function( + { + model = "gpt-3.5-turbo", + maxTokens = 500, + context = [ + { + role: "system", + content: "You are a helpful assistant that can answer questions and provide information.", + }, + { + role: "system", + content: "When extra formatting is needed, you can use Markdown.", + }, + ], + } = {} +) { + var _context = [...context] + + return () => { + omnibar.open({ + multiline: true, + autocomplete: () => { + // No autocompletion, predictions are generated only when the user presses Enter + return [] + }, + + onEnter: (item, { ctrlKey, listSelect }) => { + if (listSelect) { + // If the user selected a result, copy it to the clipboard + Clipboard.write(item.text) + return "" + } + + if (!ctrlKey) { + // If the user did not press Ctrl, do not open the result + // (multi-line input, the user may want to add more text) + return "" + } + + const markdown = new showdown.Converter() + _context.push({ role: "user", content: item.text }) + + return new Promise((resolve, reject) => { + fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + "Authorization": `Bearer ${priv.keys.openai}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + messages: _context, + max_tokens: maxTokens, + }), + } + ).then((response) => { + response.json().then((data) => { + const text = data.choices[0].message.content + resolve({ + text, + html: markdown.makeHtml(text), + }) + }).catch((error) => { + console.error('Could not parse response', error) + reject(error) + }) + }).catch((error) => { + console.error('Could not fetch response', error) + reject(error) + }) + }) + }, + + onResultClick: (e, item) => { + e?.preventDefault() + Clipboard.write(item.text) + }, + }) + } +} diff --git a/src/plugins/searx.js b/src/plugins/searx.js new file mode 100644 index 0000000..1ad1943 --- /dev/null +++ b/src/plugins/searx.js @@ -0,0 +1,127 @@ +import actions from "../actions.js" +import omnibar from "../omnibar.js" + +// Customize it to your liking +const searxUrl = "https://search.fabiomanganiello.com" + +/** + * Open the Omnibar with SearX autocompletion. + * + * @param {Object} options - Options object. + * @param {boolean} options.newTab - Open the result in a new tab (default: false). + * @param {boolean} options.active - Make the new tab active (default: true). + * + * Example usage: Add the following in your keys.js file: + +import searxCallback from "./plugins/searx.js" + +maps.global = [ + // ... + // Open URL or search (current tab) + { + alias: "go", + category: categories.pageNav, + description: "Open URL or search (current tab)", + callback: searxCallback({newTab: false}), + }, + + // Open URL or search (new tab) + { + alias: "t", + category: categories.pageNav, + description: "Open URL or search (new tab)", + callback: searxCallback({newTab: true}), + }, + // ... +] + + */ +export default function({ + newTab = false, + active = true, +} = {}) { + return () => { + omnibar.open({ + autocompleteStartTimeout: 1500, + newTab, + active, + autocomplete: (query) => { + if (query.url) { + return [ + { + text: `🔗 ${query.url}`, + url: query.url, + }, + ] + } + + return new Promise((resolve, reject) => { + fetch( + `${searxUrl}/autocompleter`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: `q=${encodeURIComponent(query.text)}`, + } + ).then((response) => { + response.json().then((data) => { + resolve( + data[1]?.map((item) => { + const sanitizedText = item.replace('<', '<').replace('>', '>') + return { + html: `🔍 ${sanitizedText.substring(0, query.text.length)}${sanitizedText.substring(query.text.length)}`, + url: `${searxUrl}/search?q=${encodeURIComponent(item)}`, + } + }) || [] + ) + }).catch((error) => { + console.error('Could not parse autocompletion results', error) + reject(error) + }) + }).catch((error) => { + console.error('Could not fetch autocompletion results', error) + reject(error) + }) + }) + }, + + onEnter: (item, { shiftKey }) => { + if (shiftKey) { + return new Promise((resolve, reject) => { + fetch( + `${searxUrl}/search?q=${encodeURIComponent(item.text)}&categories=general&pageno=1&safesearch=0&format=rss`, + ).then((response) => { + response.text().then((data) => { + const parser = new DOMParser() + const xml = parser.parseFromString(data, "text/xml") + const items = Array.from(xml.querySelectorAll("item")) + const results = items + .filter((item) => item.querySelector("link") && item.querySelector("title")) + .map((item) => ({ + url: item.querySelector("link").textContent, + html: `${item.querySelector("title").textContent}
${item.querySelector("description")?.textContent}`, + })) + + resolve(results) + }).catch((error) => { + console.error('Could not parse RSS results', error) + reject(error) + }) + }).catch((error) => { + console.error('Could not fetch RSS results', error) + reject(error) + }) + }) + } + + if (!item.url) { + item.url = `${searxUrl}/search?q=${encodeURIComponent(item.text)}` + } + + actions.openLink(item.url, { newTab }) + }, + }) + } +} diff --git a/src/theme.js b/src/theme.js index d5f1bb8..3180278 100644 --- a/src/theme.js +++ b/src/theme.js @@ -21,11 +21,28 @@ const commonStyles = ` margin: 0 !important; } + #sk_omnibarSearchResult > ul { + /* + * Make the Omnibar search result list scrollable + * by making it as high as the parent container. + * display: flex would make things easier, but I + * don't want to change too much of the original + * styling. + */ + max-height: 60vh !important; + } + #sk_omnibar li { background: none !important; padding: 0.35rem 0.5rem !important; } + #sk_omnibar li, + #sk_omnibar li > .text-container { + max-height: 60vh !important; + overflow: auto !important; + } + #sk_omnibarSearchResult > ul:nth-child(1) { margin-bottom: 0px !important; padding: 0 !important; @@ -36,6 +53,21 @@ const commonStyles = ` padding-left: 8px !important; } + #sk_omnibar textarea { + width: 100%; + max-height: 20vh; + overflow: auto; + border: none; + outline: none; + font-size: 1.1em; + tab-size: 2; + } + + #sk_omnibar input.loading, + #sk_omnibar textarea.loading { + opacity: 0.5; + } + /* Disable RichHints CSS animation */ .expandRichHints { animation: none;