From 61e27eef33da20a9f174d2debee151cb8b100389 Mon Sep 17 00:00:00 2001 From: Leonardo Bishop Date: Wed, 17 Sep 2025 20:14:09 +0100 Subject: Initial commit --- .gitignore | 2 + Makefile | 4 ++ background.js | 84 +++++++++++++++++++++++ content.js | 23 +++++++ icons/ready.png | Bin 0 -> 20274 bytes icons/saved.png | Bin 0 -> 24631 bytes icons/wait.png | Bin 0 -> 20497 bytes manifest.json | 35 ++++++++++ options.html | 33 +++++++++ options.js | 21 ++++++ popup.html | 64 ++++++++++++++++++ popup.js | 202 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ util.js | 14 ++++ 13 files changed, 482 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 background.js create mode 100644 content.js create mode 100644 icons/ready.png create mode 100644 icons/saved.png create mode 100644 icons/wait.png create mode 100644 manifest.json create mode 100644 options.html create mode 100644 options.js create mode 100644 popup.html create mode 100644 popup.js create mode 100644 util.js 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 new file mode 100644 index 0000000..91f8e87 Binary files /dev/null and b/icons/ready.png differ diff --git a/icons/saved.png b/icons/saved.png new file mode 100644 index 0000000..f0f3929 Binary files /dev/null and b/icons/saved.png differ diff --git a/icons/wait.png b/icons/wait.png new file mode 100644 index 0000000..6f9b66b Binary files /dev/null and b/icons/wait.png differ 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", + "" + ], + "background": { + "scripts": ["background.js"], + "persistent": true, + "type": "module" + }, + "content_scripts": [ + { + "matches": [""], + "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 @@ + + + + + Stash Web Extension Settings + + + +

Configuration

+ + + +

+ + + + 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 @@ + + + + + + Stash + + + +
+ + + + +
+ +

+ +
+ Page details + + + + +
+ + + + + + 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); + } + ); + }); +}); diff --git a/util.js b/util.js new file mode 100644 index 0000000..4e21013 --- /dev/null +++ b/util.js @@ -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, + }); +} -- cgit v1.2.3-70-g09d2