summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--Makefile4
-rw-r--r--background.js84
-rw-r--r--content.js23
-rw-r--r--icons/ready.pngbin0 -> 20274 bytes
-rw-r--r--icons/saved.pngbin0 -> 24631 bytes
-rw-r--r--icons/wait.pngbin0 -> 20497 bytes
-rw-r--r--manifest.json35
-rw-r--r--options.html33
-rw-r--r--options.js21
-rw-r--r--popup.html64
-rw-r--r--popup.js202
-rw-r--r--util.js14
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
new file mode 100644
index 0000000..91f8e87
--- /dev/null
+++ b/icons/ready.png
Binary files differ
diff --git a/icons/saved.png b/icons/saved.png
new file mode 100644
index 0000000..f0f3929
--- /dev/null
+++ b/icons/saved.png
Binary files differ
diff --git a/icons/wait.png b/icons/wait.png
new file mode 100644
index 0000000..6f9b66b
--- /dev/null
+++ b/icons/wait.png
Binary files 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",
+ "<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);
+ }
+ );
+ });
+});
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,
+ });
+}