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;