diff options
| author | Leonardo Bishop <me@leonardobishop.net> | 2025-09-17 20:14:09 +0100 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.net> | 2025-09-17 20:14:09 +0100 |
| commit | 61e27eef33da20a9f174d2debee151cb8b100389 (patch) | |
| tree | 454fcbe8fdac7c9e306a7df3c7bcaa907eb5a65f | |
Initial commit
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | Makefile | 4 | ||||
| -rw-r--r-- | background.js | 84 | ||||
| -rw-r--r-- | content.js | 23 | ||||
| -rw-r--r-- | icons/ready.png | bin | 0 -> 20274 bytes | |||
| -rw-r--r-- | icons/saved.png | bin | 0 -> 24631 bytes | |||
| -rw-r--r-- | icons/wait.png | bin | 0 -> 20497 bytes | |||
| -rw-r--r-- | manifest.json | 35 | ||||
| -rw-r--r-- | options.html | 33 | ||||
| -rw-r--r-- | options.js | 21 | ||||
| -rw-r--r-- | popup.html | 64 | ||||
| -rw-r--r-- | popup.js | 202 | ||||
| -rw-r--r-- | util.js | 14 |
13 files changed, 482 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4919457 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +stash-webext.zip + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..36dbb5c --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +all: build + +build: + zip -r stash-webext.zip . --exclude '.git/*' --exclude '.gitignore' --exclude 'Makefile'
\ No newline at end of file diff --git a/background.js b/background.js new file mode 100644 index 0000000..0f6a3a9 --- /dev/null +++ b/background.js @@ -0,0 +1,84 @@ +import { setIcon } from "./util.js"; + +let urls = new Set(); +let lastUrlsUpdateTime = 0; +let updating = false; + +function updateUrls() { + return browser.storage.local + .get(["apiUrl", "authToken"]) + .then(async ({ apiUrl, authToken }) => { + if (!apiUrl) { + console.warn("API URL not configured."); + setIcon(tabId, "default"); + return; + } + + await fetch(`${apiUrl}/entry`, { + headers: { + Authorization: authToken ? `Bearer ${authToken}` : undefined, + }, + }) + .then((response) => response.json()) + .then((data) => { + urls = new Set(data.data); + lastUrlsUpdateTime = Date.now(); + }) + .catch((error) => { + console.error(error); + }); + }); +} + +async function handleTabUpdate(tabId, url) { + setIcon(tabId, "wait"); + + if (updating) { + return; + } + + if (Date.now() - lastUrlsUpdateTime > 10 * 60 * 1000) { + updating = true; + await updateUrls(); + updating = false; + } + + if (urls.has(url)) { + setIcon(tabId, "saved"); + } else { + setIcon(tabId, "ready"); + } +} + +browser.tabs.onActivated.addListener(async (activeInfo) => { + const tab = await browser.tabs.get(activeInfo.tabId); + if (tab.url && tab.url.startsWith("http")) { + handleTabUpdate(tab.id, tab.url); + } +}); + +browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if ( + changeInfo.status === "loading" && + tab.active && + tab.url?.startsWith("http") + ) { + setIcon(tabId, "wait"); + } + + if ( + changeInfo.status === "complete" && + tab.active && + tab.url?.startsWith("http") + ) { + handleTabUpdate(tabId, tab.url); + } +}); + +browser.runtime.onMessage.addListener(function (request, sender, sendResponse) { + if (request.action === "addSavedUrl") { + urls.add(request.data.url); + } else if (request.action === "removeSavedUrl") { + urls.delete(request.data.url); + } +}); diff --git a/content.js b/content.js new file mode 100644 index 0000000..545fa6d --- /dev/null +++ b/content.js @@ -0,0 +1,23 @@ +function getMetaValue(propName) { + const meta = Array.from(document.getElementsByTagName("meta")).find( + (meta) => + meta.getAttribute("property") === propName || + meta.getAttribute("name") === propName + ); + return meta ? meta.getAttribute("content") : undefined; +} + +function extractMetadata() { + const title = document.title; + const url = window.location.href; + const description = + getMetaValue("og:description") || getMetaValue("description"); + + return { title, url, description }; +} + +browser.runtime.onMessage.addListener(function (request, sender, sendResponse) { + if (request.action === "getMetadata") { + sendResponse(extractMetadata()); + } +}); diff --git a/icons/ready.png b/icons/ready.png Binary files differnew file mode 100644 index 0000000..91f8e87 --- /dev/null +++ b/icons/ready.png diff --git a/icons/saved.png b/icons/saved.png Binary files differnew file mode 100644 index 0000000..f0f3929 --- /dev/null +++ b/icons/saved.png diff --git a/icons/wait.png b/icons/wait.png Binary files differnew file mode 100644 index 0000000..6f9b66b --- /dev/null +++ b/icons/wait.png diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..eb58268 --- /dev/null +++ b/manifest.json @@ -0,0 +1,35 @@ +{ + "manifest_version": 2, + "name": "stash-webext", + "description": "Companion web extension for Stash", + "version": "0.0.1", + "permissions": [ + "tabs", + "storage", + "<all_urls>" + ], + "background": { + "scripts": ["background.js"], + "persistent": true, + "type": "module" + }, + "content_scripts": [ + { + "matches": ["<all_urls>"], + "js": ["content.js"] + } + ], + "browser_action": { + "default_icon": "icons/ready.png", + "default_popup": "popup.html" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": false + }, + "browser_specific_settings": { + "gecko": { + "id": "@stash-webext" + } + } +} diff --git a/options.html b/options.html new file mode 100644 index 0000000..8db9394 --- /dev/null +++ b/options.html @@ -0,0 +1,33 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>Stash Web Extension Settings</title> + <style> + body { + font-family: sans-serif; + padding: 20px; + width: 300px; + } + input { + width: 100%; + margin-bottom: 10px; + } + </style> +</head> +<body> + <h2>Configuration</h2> + <label> + Stash base URL: + <input type="text" id="apiUrl" placeholder="URL"> + </label> + <label> + Token: + <input type="text" id="authToken" placeholder="Token"> + </label> + <button id="saveBtn">Save</button> + <p id="status"></p> + + <script src="options.js"></script> +</body> +</html> diff --git a/options.js b/options.js new file mode 100644 index 0000000..8bf846d --- /dev/null +++ b/options.js @@ -0,0 +1,21 @@ +document.addEventListener("DOMContentLoaded", () => { + const apiUrlInput = document.getElementById("apiUrl"); + const authTokenInput = document.getElementById("authToken"); + const saveBtn = document.getElementById("saveBtn"); + const status = document.getElementById("status"); + + browser.storage.local.get(["apiUrl", "authToken"]).then((result) => { + if (result.apiUrl) apiUrlInput.value = result.apiUrl; + if (result.authToken) authTokenInput.value = result.authToken; + }); + + saveBtn.addEventListener("click", () => { + const apiUrl = apiUrlInput.value.trim(); + const authToken = authTokenInput.value.trim(); + + browser.storage.local.set({ apiUrl, authToken }).then(() => { + status.textContent = "Configuration saved"; + setTimeout(() => (status.textContent = ""), 2000); + }); + }); +}); diff --git a/popup.html b/popup.html new file mode 100644 index 0000000..21aba86 --- /dev/null +++ b/popup.html @@ -0,0 +1,64 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Stash</title> + <style> + body { + font-family: sans-serif; + padding: 1rem; + width: 300px; + } + button { + width: 100%; + cursor: pointer; + } + button:disabled { + cursor: not-allowed; + } + #controls { + display: flex; + gap: 0.5rem; + margin-bottom: 10px; + } + #clear { + flex-grow: 0; + width: auto; + } + #status { + margin-bottom: 10px; + } + #metadata { + display: flex; + flex-direction: column; + margin-bottom: 10px; + } + #pageUrl, #pageDescription { + font-size: smaller; + } + </style> +</head> +<body> + <div id="controls"> + <button class="control" id="unread" data-kind="unread" title="Unread" disabled>📚</button> + <button class="control" id="read" data-kind="read" title="Read" disabled>👀</button> + <button class="control" id="starred" data-kind="starred" title="Starred" disabled>⭐</button> + <button class="control" id="clear" title="Clear" disabled>X</button> + </div> + + <p id="status"></p> + + <fieldset id="metadata"> + <legend>Page details</legend> + + <b id="pageTitle"></b> + <a href="#" id="pageUrl"></a> + <span id="pageDescription"></span> + </fieldset> + + <button id="saved">View all saved</button> + + <script type="module" src="popup.js"></script> +</body> +</html> diff --git a/popup.js b/popup.js new file mode 100644 index 0000000..1a5cfc6 --- /dev/null +++ b/popup.js @@ -0,0 +1,202 @@ +import { setIcon } from "./util.js"; + +let pageSaveState; +let pageMetadata; + +function updatePageDescription() { + const pageTitle = document.getElementById("pageTitle"); + const pageUrl = document.getElementById("pageUrl"); + const pageDescription = document.getElementById("pageDescription"); + + const { title, url, description } = pageMetadata; + + pageTitle.textContent = title; + pageUrl.textContent = url; + pageDescription.textContent = description; +} + +function enablePageButtons(kind) { + Array.from(document.getElementsByTagName("button")) + .filter((element) => element.dataset.kind !== kind) + .forEach((element) => element.removeAttribute("disabled")); +} + +function disablePageButtons() { + Array.from(document.getElementsByTagName("button")) + .filter((element) => element.classList.contains("control")) + .forEach((element) => element.setAttribute("disabled", "disabled")); +} + +function loadSaveState(tabId) { + setIcon(tabId, "wait"); + + return browser.storage.local + .get(["apiUrl", "authToken"]) + .then(({ apiUrl, authToken }) => { + if (!apiUrl) { + document.getElementById("status").textContent = + "API URL not configured"; + return; + } + + fetch(`${apiUrl}/entry`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: authToken ? `Bearer ${authToken}` : undefined, + }, + body: JSON.stringify({ url: pageMetadata.url }), + }) + .then((response) => response.json()) + .then((data) => { + document.getElementById("status").textContent = ""; + if (data.code === 200) { + pageSaveState = data.data; + enablePageButtons(data.data["kind_name"]); + setIcon(tabId, "saved"); + } else { + enablePageButtons(); + setIcon(tabId, "ready"); + } + }) + .catch((error) => { + console.log(error); + document.getElementById("status").textContent = + error.status === 404 ? "" : "Request failed"; + }); + }); +} + +function savePage(kind) { + disablePageButtons() + + browser.tabs.query({ active: true, currentWindow: true }, function (tabs) { + const tab = tabs[0]; + + setIcon(tab.id, "wait"); + + return browser.storage.local + .get(["apiUrl", "authToken"]) + .then(({ apiUrl, authToken }) => { + if (!apiUrl) { + document.getElementById("status").textContent = + "API URL not configured"; + return; + } + + let request = pageSaveState === undefined ? { + method: "POST", + body: JSON.stringify({ kind, ...pageMetadata }) + } : { + method: "PUT", + body: JSON.stringify({ id: pageSaveState.id, kind }) + } + request.headers = { + "Content-Type": "application/json", + Authorization: authToken ? `Bearer ${authToken}` : undefined, + }; + + fetch(`${apiUrl}/record`, request) + .then((response) => response.json()) + .then((data) => { + document.getElementById("status").textContent = ""; + if (data.code === 200 || data.code === 201) { + pageSaveState = data.data; + enablePageButtons(kind); + setIcon(tab.id, "saved"); + } else { + enablePageButtons(); + setIcon(tab.id, "ready"); + } + browser.runtime.sendMessage({ action: "addSavedUrl", data: { url: pageMetadata.url } }); + }) + .catch((error) => { + console.log(error); + document.getElementById("status").textContent = + error.status === 404 ? "" : "Request failed"; + }); + }); + }); +} + +function clearPage() { + disablePageButtons() + + browser.tabs.query({ active: true, currentWindow: true }, function (tabs) { + const tab = tabs[0]; + + setIcon(tab.id, "wait"); + + return browser.storage.local + .get(["apiUrl", "authToken"]) + .then(({ apiUrl, authToken }) => { + if (!apiUrl) { + document.getElementById("status").textContent = + "API URL not configured"; + return; + } + + fetch(`${apiUrl}/record`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: authToken ? `Bearer ${authToken}` : undefined, + }, + body: JSON.stringify({ id: pageSaveState.id }) + }) + .then((response) => response.json()) + .then(() => { + pageSaveState = undefined; + enablePageButtons(); + setIcon(tab.id, "ready"); + browser.runtime.sendMessage({ action: "removeSavedUrl", data: { url: pageMetadata.url } }); + }) + .catch((error) => { + console.log(error); + document.getElementById("status").textContent = "Request failed"; + }); + }); + }); +} + +document.addEventListener("DOMContentLoaded", () => { + Array.from(document.getElementsByTagName("button")) + .filter((element) => element.dataset.kind !== undefined) + .forEach((element) => element.addEventListener('click', () => savePage(element.dataset.kind))); + document.getElementById("clear").addEventListener('click', () => clearPage()); + document.getElementById("saved").addEventListener('click', () => { + browser.storage.local + .get(["apiUrl"]) + .then(({ apiUrl }) => { + if (!apiUrl) { + document.getElementById("status").textContent = + "API URL not configured"; + return; + } + + window.open(apiUrl + "/html", "_blank").focus(); + window.close(); + }); + }); + + browser.tabs.query({ active: true, currentWindow: true }, function (tabs) { + const tab = tabs[0]; + + browser.tabs.sendMessage( + tab.id, + { action: "getMetadata" }, + function (metadata) { + if (!metadata) { + document.getElementById("status").textContent = + "Error extracting metadata"; + return; + } + + pageMetadata = metadata; + + updatePageDescription(); + loadSaveState(tab.id); + } + ); + }); +}); @@ -0,0 +1,14 @@ +export function setIcon(tabId, status) { + const iconMap = { + wait: "icons/wait.png", + ready: "icons/ready.png", + saved: "icons/saved.png", + }; + + const path = iconMap[status] || iconMap.wait; + + browser.browserAction.setIcon({ + tabId, + path, + }); +} |
