From bfe64b1593ff2b7289f5899b592e7ca952dfdce6 Mon Sep 17 00:00:00 2001 From: Julian Poyourow Date: Thu, 17 Aug 2023 05:51:58 +0000 Subject: [PATCH 1/3] feat: bring back interactive in-page popup for webextension --- nx.json | 4 +- package-lock.json | 21 +- package.json | 2 +- .../webextension-v3/src/action/action.html | 11 + packages/webextension-v3/src/action/action.js | 116 ++-- packages/webextension-v3/src/inject/alert.css | 41 ++ packages/webextension-v3/src/inject/clip.js | 13 + .../webextension-v3/src/inject/clipTool.css | 165 ++++++ packages/webextension-v3/src/inject/inject.js | 497 ++++++++++++++++++ packages/webextension-v3/src/manifest.json | 13 +- packages/webextension-v3/webpack.config.js | 7 +- 11 files changed, 840 insertions(+), 50 deletions(-) create mode 100644 packages/webextension-v3/src/inject/alert.css create mode 100644 packages/webextension-v3/src/inject/clip.js create mode 100644 packages/webextension-v3/src/inject/clipTool.css create mode 100644 packages/webextension-v3/src/inject/inject.js diff --git a/nx.json b/nx.json index 54504b322..18dc7a532 100644 --- a/nx.json +++ b/nx.json @@ -4,7 +4,8 @@ "runner": "nx-cloud", "options": { "cacheableOperations": ["build", "test", "lint", "typecheck"], - "accessToken": "OGY4NDE3OTItYWViMS00YWM0LTk4ODgtYmI2ZWNhYjY1OGMyfHJlYWQtb25seQ==" + "accessToken": "OGY4NDE3OTItYWViMS00YWM0LTk4ODgtYmI2ZWNhYjY1OGMyfHJlYWQtb25seQ==", + "useDaemonProcess": false } } }, @@ -12,6 +13,7 @@ "$schema": "./node_modules/nx/schemas/nx-schema.json", "targetDefaults": { "build": { + "inputs": ["default"], "dependsOn": ["^build"], "outputs": ["{projectRoot}/dist", "{projectRoot}/www"] }, diff --git a/package-lock.json b/package-lock.json index 94f214957..573db9844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,7 @@ "https-proxy-agent": "^5.0.1", "ical-generator": "^3.6.0", "joi": "^17.7.0", - "jsdom": "^21.1.0", + "jsdom": "^22.1.0", "linkify-string": "^4.1.0", "linkifyjs": "^4.1.0", "mdb": "git+https://git@github.com/julianpoy/node-mdb.git", @@ -11942,6 +11942,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", + "dev": true, "dependencies": { "acorn": "^8.1.0", "acorn-walk": "^8.0.2" @@ -11997,6 +11998,7 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, "engines": { "node": ">=0.4.0" } @@ -16539,6 +16541,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz", "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==", + "dev": true, "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", @@ -16560,6 +16563,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "dev": true, "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" @@ -16572,6 +16576,7 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "dev": true, "dependencies": { "deep-is": "~0.1.3", "fast-levenshtein": "~2.0.6", @@ -16588,6 +16593,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "dev": true, "engines": { "node": ">= 0.8.0" } @@ -16596,6 +16602,7 @@ "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, "optional": true, "engines": { "node": ">=0.10.0" @@ -16605,6 +16612,7 @@ "version": "0.3.2", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "dev": true, "dependencies": { "prelude-ls": "~1.1.2" }, @@ -23528,18 +23536,15 @@ } }, "node_modules/jsdom": { - "version": "21.1.2", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-21.1.2.tgz", - "integrity": "sha512-sCpFmK2jv+1sjff4u7fzft+pUh2KSUbUrEHYHyfSIbGTIcmnjyp83qg6qLwdJ/I3LpTXx33ACxeRL7Lsyc6lGQ==", + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", + "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", "dependencies": { "abab": "^2.0.6", - "acorn": "^8.8.2", - "acorn-globals": "^7.0.0", "cssstyle": "^3.0.0", "data-urls": "^4.0.0", "decimal.js": "^10.4.3", "domexception": "^4.0.0", - "escodegen": "^2.0.0", "form-data": "^4.0.0", "html-encoding-sniffer": "^3.0.0", "http-proxy-agent": "^5.0.0", @@ -23560,7 +23565,7 @@ "xml-name-validator": "^4.0.0" }, "engines": { - "node": ">=14" + "node": ">=16" }, "peerDependencies": { "canvas": "^2.5.0" diff --git a/package.json b/package.json index 15e4bde13..64648a1c2 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "https-proxy-agent": "^5.0.1", "ical-generator": "^3.6.0", "joi": "^17.7.0", - "jsdom": "^21.1.0", + "jsdom": "^22.1.0", "linkify-string": "^4.1.0", "linkifyjs": "^4.1.0", "mdb": "git+https://git@github.com/julianpoy/node-mdb.git", diff --git a/packages/webextension-v3/src/action/action.html b/packages/webextension-v3/src/action/action.html index 96e9aefde..0c3ecb9c4 100644 --- a/packages/webextension-v3/src/action/action.html +++ b/packages/webextension-v3/src/action/action.html @@ -85,6 +85,17 @@
+
+
+

+

Import Site

+
+ +   +   + +


diff --git a/packages/webextension-v3/src/action/action.js b/packages/webextension-v3/src/action/action.js index 90b7457f6..e752a6a79 100644 --- a/packages/webextension-v3/src/action/action.js +++ b/packages/webextension-v3/src/action/action.js @@ -1,5 +1,12 @@ const API_BASE = "https://api.recipesage.com/"; +chrome.runtime.onMessage.addListener((request) => { + const clipData = request; + console.log(clipData); + + saveClip(clipData); +}); + let token; const login = async () => { @@ -44,7 +51,7 @@ const login = async () => { window.close(); }, 2000); } else { - tutorial(); + showTutorial(); } }); }); @@ -54,21 +61,35 @@ const login = async () => { } }; -const tutorial = () => { +const showTutorial = () => { document.getElementById("login").style.display = "none"; document.getElementById("tutorial").style.display = "block"; document.getElementById("importing").style.display = "none"; + document.getElementById("start").style.display = "none"; }; -const loading = () => { +const showLoading = () => { document.getElementsByTagName("html")[0].style.display = "initial"; document.getElementById("login").style.display = "none"; document.getElementById("tutorial").style.display = "none"; document.getElementById("importing").style.display = "block"; + document.getElementById("start").style.display = "none"; }; -const launch = (token) => { - newClip(token); +const showStart = () => { + document.getElementsByTagName("html")[0].style.display = "initial"; + document.getElementById("login").style.display = "none"; + document.getElementById("tutorial").style.display = "none"; + document.getElementById("importing").style.display = "none"; + document.getElementById("start").style.display = "block"; +}; + +const showLogin = () => { + document.getElementsByTagName("html")[0].style.display = "initial"; + document.getElementById("login").style.display = "block"; + document.getElementById("tutorial").style.display = "none"; + document.getElementById("importing").style.display = "none"; + document.getElementById("start").style.display = "none"; }; const fetchAndCreateImage = async (imageURL) => { @@ -92,22 +113,47 @@ const fetchAndCreateImage = async (imageURL) => { return imageData.id; }; -const newClip = async () => { - loading(); - +const interactiveClip = async () => { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - let result; + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["inject/inject.js"], + }); + + window.close(); +}; + +const autoClip = async () => { + showLoading(); + try { - [{ result }] = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - func: () => document.documentElement.innerHTML, - }); + await clipWithInject(); } catch (e) { - console.error(e); - window.alert("Failed to fetch page content"); - return; + try { + await clipWithAPI(); + } catch (e) { + window.alert("Failed to fetch page content"); + } } +}; + +const clipWithInject = async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + files: ["inject/clip.js"], + }); +}; + +const clipWithAPI = async () => { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + + const [{ result }] = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => document.documentElement.innerHTML, + }); const clipResponse = await fetch(`${API_BASE}clip?token=${token}`, { method: "POST", @@ -129,6 +175,10 @@ const newClip = async () => { const clipData = await clipResponse.json(); + await saveClip(clipData); +}; + +const saveClip = async (clipData) => { const imageId = clipData.imageURL?.trim() ? await fetchAndCreateImage(clipData.imageURL) : undefined; @@ -180,20 +230,6 @@ const newClip = async () => { }, 500); }; -const tokenFetchPromise = new Promise((resolve, reject) => { - chrome.storage.local.get(["token"], (result) => { - token = result.token; - - // If user is logged in, launch the tool - if (token) { - launch(token); - reject(); - } else { - resolve(); - } - }); -}); - document.addEventListener("DOMContentLoaded", () => { [...document.getElementsByClassName("logo")].forEach( (logo) => @@ -206,12 +242,18 @@ document.addEventListener("DOMContentLoaded", () => { if (event.key === "Enter") login(); }; document.getElementById("tutorial-submit").onclick = () => window.close(); + document.getElementById("auto-import").onclick = autoClip; + document.getElementById("interactive-import").onclick = interactiveClip; - tokenFetchPromise - .then(() => { - document.getElementsByTagName("html")[0].style.display = "initial"; - }) - .catch(() => { - // Do nothing - }); + chrome.storage.local.get(["token"], (result) => { + token = result.token; + + document.getElementsByTagName("html")[0].style.display = "initial"; + + if (token) { + showStart(); + } else { + showLogin(); + } + }); }); diff --git a/packages/webextension-v3/src/inject/alert.css b/packages/webextension-v3/src/inject/alert.css new file mode 100644 index 000000000..2e1aa3c96 --- /dev/null +++ b/packages/webextension-v3/src/inject/alert.css @@ -0,0 +1,41 @@ +:host { + all: initial !important; + contain: content; +} + +.alert { + position: fixed; + bottom: 10px; + right: 10px; + width: 300px; + padding: 20px; + + z-index: 99999999999999999999999999; + background: white; + box-shadow: 1px 1px 12px #666666; + + user-select: none; + + font-size: 14px; + + font-family: Arial, Helvetica, sans-serif; + + color: black; +} + +.headline { + display: flex; + align-items: center; + margin-bottom: 15px; +} + +img { + width: 30px; + height: 30px; + border-radius: 20px; +} + +h3 { + margin: 0 10px; + font-size: 16px; +} diff --git a/packages/webextension-v3/src/inject/clip.js b/packages/webextension-v3/src/inject/clip.js new file mode 100644 index 000000000..64b2ae7ca --- /dev/null +++ b/packages/webextension-v3/src/inject/clip.js @@ -0,0 +1,13 @@ +const RecipeClipper = require("@julianpoy/recipe-clipper"); + +chrome.storage.local.get(["token"], async (result) => { + window.RC_ML_CLASSIFY_ENDPOINT = + "https://api.recipesage.com/proxy/ingredient-instruction-classifier?token=" + + result.token; + + const clip = await RecipeClipper.clipRecipe().catch(() => { + alert("Error while attempting to automatically clip recipe from page"); + }); + + chrome.runtime.sendMessage(clip); +}); diff --git a/packages/webextension-v3/src/inject/clipTool.css b/packages/webextension-v3/src/inject/clipTool.css new file mode 100644 index 000000000..b6c032cfc --- /dev/null +++ b/packages/webextension-v3/src/inject/clipTool.css @@ -0,0 +1,165 @@ +:host { + all: initial !important; + contain: content; +} + +.rs-autoSnipPendingContainer { + z-index: 99999999999999999999999999 !important; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + + display: flex; + align-items: center; + justify-content: center; + + background: rgba(0, 0, 0, 0.4); +} + +.rs-autoSnipPendingContainer .autoSnipPending { + padding: 20px; + background-color: #00a8ff; + color: white; + border-radius: 3px; + text-align: center; + font-size: 16px; + line-height: 18px; + box-shadow: 1px 1px 7px rgba(0, 0, 0, 0.4); +} + +.rs-chrome-container { + position: fixed; + right: 10px; + top: 20%; + width: 300px; + padding: 10px; + + z-index: 9999999999999999999999999 !important; + background: white; + box-shadow: 1px 1px 12px #666666; + + user-select: none; + + font-size: 14px; + + font-family: Arial, Helvetica, sans-serif; + + color: black; +} + +@media only screen and (min-width: 1400px) { + .rs-chrome-container { + width: 325px; + } +} + +@media only screen and (min-width: 1700px) { + .rs-chrome-container { + width: 350px; + } +} + +label { + display: flex; +} + +input, +textarea { + min-width: 0; + flex-grow: 1; + background: none; + border: none; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); + padding: 10px; + transition: 0.3s; + background: transparent; + color: black; + font-family: Arial, Helvetica, sans-serif; + font-size: 12px; +} + +textarea { + height: 30px; + min-height: 16px; + margin-bottom: 5px; +} + +@media only screen and (min-height: 950px) { + textarea { + height: 40px; + } +} + +input:focus, +textarea:focus { + border-bottom: 1px solid #00a8ff; + outline: none; +} + +button { + height: 32px; + padding: 0 11px; + margin: 4px; + + font-size: 14px; + font-weight: 500; + text-transform: uppercase; + + background: #00a8ff; + color: white; + cursor: pointer; + + border: none; + border-radius: 2px; + box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.14), 0 3px 1px -2px rgba(0, 0, 0, 0.2), + 0 1px 5px 0 rgba(0, 0, 0, 0.12); +} + +button.icon-button { + padding: 0 8px; +} + +button.clear { + background: none; + box-shadow: none; + color: #00a8ff; +} + +button i { + font-size: 16px; + vertical-align: bottom; +} + +.logo { + height: 35px; +} + +.tip { + margin-top: 8px; + margin-bottom: 16px; + text-align: center; + font-size: 12px; +} + +.headline { + display: flex; + justify-content: space-between; + align-items: center; +} + +.headline-left { + display: flex; + align-self: flex-start; +} + +.headline-left > .ion-md-move { + margin-right: 5px; + cursor: grab; +} + +.save { + width: 100%; + margin: 5px 0 0; +} diff --git a/packages/webextension-v3/src/inject/inject.js b/packages/webextension-v3/src/inject/inject.js new file mode 100644 index 000000000..e6e850dd9 --- /dev/null +++ b/packages/webextension-v3/src/inject/inject.js @@ -0,0 +1,497 @@ +const RecipeClipper = require("@julianpoy/recipe-clipper"); +var extensionContainerId = "recipeSageBrowserExtensionRootContainer"; + +if (window[extensionContainerId]) { + // Looks like a popup already exists. Try to trigger it + try { + window.recipeSageBrowserExtensionRootTrigger(); + } catch (e) { + // Do nothing + } +} else { + // At this point, we've determined that the popup does not exist + + window[extensionContainerId] = true; // Mark as loading so that we don't create duplicate popups + + console.log("Loading RecipeSage Browser Extension"); + + const fetchToken = () => { + return new Promise((resolve) => { + chrome.storage.local.get(["token"], (result) => { + resolve(result.token); + }); + }); + }; + + let shadowRootContainer = document.createElement("div"); + shadowRootContainer.id = extensionContainerId; + let shadowRoot = shadowRootContainer.attachShadow({ mode: "closed" }); + document.body.appendChild(shadowRootContainer); + + let styles = document.createElement("link"); + styles.href = chrome.runtime.getURL("inject/clipTool.css"); + styles.rel = "stylesheet"; + styles.type = "text/css"; + shadowRoot.appendChild(styles); + + let ionIcons = document.createElement("link"); + ionIcons.href = "https://unpkg.com/ionicons@4.5.5/dist/css/ionicons.min.css"; + ionIcons.rel = "stylesheet"; + ionIcons.type = "text/css"; + document.head.appendChild(ionIcons); + shadowRoot.appendChild(ionIcons.cloneNode()); + + // Grab our preferences + chrome.storage.local.get(["disableAutoSnip"], (preferences) => { + let autoSnipPendingContainer; + let autoSnipPromise = Promise.resolve(); + if (!preferences.disableAutoSnip) { + autoSnipPendingContainer = document.createElement("div"); + autoSnipPendingContainer.className = "rs-autoSnipPendingContainer"; + shadowRoot.appendChild(autoSnipPendingContainer); + + let autoSnipPending = document.createElement("div"); + autoSnipPending.className = "autoSnipPending"; + autoSnipPending.innerText = "Grabbing Recipe Content..."; + autoSnipPendingContainer.appendChild(autoSnipPending); + + autoSnipPromise = fetchToken().then((token) => { + window.RC_ML_CLASSIFY_ENDPOINT = + "https://api.recipesage.com/proxy/ingredient-instruction-classifier?token=" + + token; + + return RecipeClipper.clipRecipe().catch(() => { + alert( + "Error while attempting to automatically clip recipe from page" + ); + }); + }); + } + + autoSnipPromise.then((autoSnipResults) => { + autoSnipResults = autoSnipResults || {}; + + if (autoSnipPendingContainer) { + setTimeout(() => { + // Timeout so that overlay doesn't flash in the case of instant (local only) autosnip + shadowRoot.removeChild(autoSnipPendingContainer); + }, 250); + } + + let snippersByField = {}; + + let container; + let currentSnip = { + url: window.location.href, + }; + if (!preferences.disableAutoSnip) + currentSnip = { ...currentSnip, ...autoSnipResults }; + let isDirty = false; + let imageURLInput; + + const savePreferences = (cb) => { + chrome.storage.local.set(preferences, cb); + }; + + let setField = (field, val) => { + currentSnip[field] = val; + isDirty = true; + }; + + let snip = (field, formatCb) => { + var selectedText = window.getSelection().toString(); + if (formatCb) selectedText = formatCb(selectedText); // Allow for interstitial formatting + setField(field, selectedText); + return selectedText; + }; + + let hide = () => { + isDirty = false; + if (container) container.style.display = "none"; + }; + + let show = () => { + if (!container) init(); + // Wait for DOM paint + setTimeout(() => { + container.style.display = "block"; + }); + }; + + let moveTo = (top, left) => { + if (left < 0) { + container.style.left = "0px"; + } else if (left + container.offsetWidth > window.innerWidth) { + container.style.left = `${ + window.innerWidth - container.offsetWidth + }px`; + } else { + container.style.left = `${left}px`; + } + + if (top < 0) { + container.style.top = "0px"; + } else if (top + container.offsetHeight > window.innerHeight) { + container.style.top = `${ + window.innerHeight - container.offsetHeight + }px`; + } else { + container.style.top = `${top}px`; + } + }; + + let pos = {}; + let moveDrag = (e) => { + let diffX = e.clientX - pos.lastX; + let diffY = e.clientY - pos.lastY; + + moveTo(container.offsetTop + diffY, container.offsetLeft + diffX); + + pos.lastX = e.clientX; + pos.lastY = e.clientY; + }; + + let stopDrag = () => { + window.removeEventListener("mouseup", stopDrag); + window.removeEventListener("mousemove", moveDrag); + }; + + let startDrag = (e) => { + window.addEventListener("mouseup", stopDrag); + window.addEventListener("mousemove", moveDrag); + pos.lastX = e.clientX; + pos.lastY = e.clientY; + }; + + let init = () => { + container = document.createElement("div"); + container.className = "rs-chrome-container"; + container.style.display = "none"; + container.onmousedown = startDrag; + window.onresize = () => + moveTo(container.offsetTop, container.offsetLeft); + shadowRoot.appendChild(container); + + let headline = document.createElement("div"); + headline.className = "headline"; + container.appendChild(headline); + + let leftHeadline = document.createElement("div"); + leftHeadline.className = "headline-left"; + headline.appendChild(leftHeadline); + + let moveButton = document.createElement("i"); + moveButton.className = "icon ion-md-move"; + leftHeadline.appendChild(moveButton); + + let logoLink = document.createElement("a"); + logoLink.href = "https://recipesage.com"; + logoLink.onmousedown = (e) => e.stopPropagation(); + leftHeadline.appendChild(logoLink); + + let logo = document.createElement("img"); + logo.src = chrome.runtime.getURL("images/recipesage-black-trimmed.png"); + logo.className = "logo"; + logo.draggable = false; + logoLink.appendChild(logo); + + let closeButton = document.createElement("button"); + closeButton.innerText = "CLOSE"; + closeButton.onclick = hide; + closeButton.onmousedown = (e) => e.stopPropagation(); + closeButton.className = "close clear"; + headline.appendChild(closeButton); + + let tipContainer = document.createElement("div"); + tipContainer.className = "tip"; + tipContainer.onmousedown = (e) => e.stopPropagation(); + container.appendChild(tipContainer); + + let tipText = document.createElement("a"); + tipText.innerText = "Open Tutorial"; + tipText.href = "https://docs.recipesage.com"; + tipText.target = "_blank"; + tipContainer.appendChild(tipText); + + let preferencesContainer = document.createElement("div"); + preferencesContainer.className = "preferences-container"; + tipContainer.appendChild(preferencesContainer); + + let autoSnipToggle = document.createElement("input"); + autoSnipToggle.className = "enable-autosnip"; + autoSnipToggle.checked = !preferences.disableAutoSnip; + autoSnipToggle.type = "checkbox"; + autoSnipToggle.onchange = () => { + preferences.disableAutoSnip = !autoSnipToggle.checked; + savePreferences(); + displayAlert( + "Preferences saved!", + `Please reload the page for these changes to take effect`, + 4000 + ); + }; + preferencesContainer.appendChild(autoSnipToggle); + + let autoSnipToggleLabel = document.createElement("span"); + autoSnipToggleLabel.innerText = "Enable Auto Field Detection"; + autoSnipToggleLabel.className = "enable-autosnip-label"; + preferencesContainer.appendChild(autoSnipToggleLabel); + + imageURLInput = createSnipper( + "Image URL", + "imageURL", + false, + currentSnip.imageURL, + true + ).input; + createSnipper("Title", "title", false, currentSnip.title); + createSnipper( + "Description", + "description", + false, + currentSnip.description, + false + ); + createSnipper("Yield", "yield", false, currentSnip.yield, false); + createSnipper( + "Active Time", + "activeTime", + false, + currentSnip.activeTime + ); + createSnipper("Total Time", "totalTime", false, currentSnip.totalTime); + createSnipper("Source", "source", false, currentSnip.source); + createSnipper("Source URL", "url", false, currentSnip.url, true); + createSnipper( + "Ingredients", + "ingredients", + true, + currentSnip.ingredients + ); + createSnipper( + "Instructions", + "instructions", + true, + currentSnip.instructions + ); + createSnipper("Notes", "notes", true, currentSnip.notes); + + let save = document.createElement("button"); + save.innerText = "Save"; + save.onclick = submit; + save.onmousedown = (e) => e.stopPropagation(); + save.className = "save"; + container.appendChild(save); + + window.addEventListener("beforeunload", function (e) { + if (!isDirty) return undefined; + + var confirmationMessage = `You've made changes in the RecipeSage editor. + If you leave before saving, your changes will be lost.`; + + (e || window.event).returnValue = confirmationMessage; //Gecko + IE + return confirmationMessage; //Gecko + Webkit, Safari, Chrome etc. + }); + }; + + let createSnipper = ( + title, + field, + isTextArea, + initialValue, + disableSnip, + formatCb + ) => { + let label = document.createElement("label"); + label.onmousedown = (e) => e.stopPropagation(); + container.appendChild(label); + + if (!disableSnip) { + let button = document.createElement("button"); + button.className = "icon-button"; + button.onclick = () => { + input.value = snip(field, formatCb); + }; + label.appendChild(button); + + let buttonIcon = document.createElement("i"); + buttonIcon.className = "icon ion-md-cut"; + button.appendChild(buttonIcon); + } + + let input = document.createElement(isTextArea ? "textarea" : "input"); + input.placeholder = title; + if (initialValue) + input.value = isTextArea + ? initialValue + : initialValue.replace(/\n/g, " "); + input.oninput = () => { + setField(field, input.value); + }; + label.appendChild(input); + + let snipper = { + input: input, + label: label, + }; + + snippersByField[field] = snipper; + + return snipper; + }; + + chrome.runtime.onMessage.addListener(function (request) { + if (request.action === "show") show(); + if (request.action === "hide") hide(); + if (request.action === "snipImage") { + show(); + imageURLInput.value = request.event.srcUrl; + setField("imageURL", request.event.srcUrl); + } + }); + + // =========== Alerts ============ + + let alertShadowRootContainer, alertContainer; + let initAlert = () => { + alertShadowRootContainer = document.createElement("div"); + let shadowRoot = alertShadowRootContainer.attachShadow({ + mode: "closed", + }); + document.body.appendChild(alertShadowRootContainer); + + let alertStyles = document.createElement("link"); + alertStyles.href = chrome.runtime.getURL("inject/alert.css"); + alertStyles.rel = "stylesheet"; + alertStyles.type = "text/css"; + shadowRoot.appendChild(alertStyles); + + alertContainer = document.createElement("div"); + alertContainer.className = "alert"; + shadowRoot.appendChild(alertContainer); + }; + + const destroyAlert = () => { + if (alertShadowRootContainer) { + document.body.removeChild(alertShadowRootContainer); + } + alertShadowRootContainer = null; + alertContainer = null; + }; + + let alertTimeout; + let displayAlert = (title, body, hideAfter, bodyLink) => { + if (alertShadowRootContainer || alertContainer) destroyAlert(); + + initAlert(); + + let headline = document.createElement("div"); + headline.className = "headline"; + alertContainer.appendChild(headline); + + let alertImg = document.createElement("img"); + alertImg.src = chrome.runtime.getURL( + "icons/android-chrome-512x512.png" + ); + headline.appendChild(alertImg); + + let alertTitle = document.createElement("h3"); + alertTitle.innerText = title; + headline.appendChild(alertTitle); + + let alertBody = document.createElement("span"); + if (!bodyLink) { + alertBody.innerText = body; + } else { + let alertBodyLink = document.createElement("a"); + alertBodyLink.target = "_blank"; + alertBodyLink.href = bodyLink; + alertBodyLink.innerText = body; + alertBody.appendChild(alertBodyLink); + } + alertContainer.appendChild(alertBody); + + // Wait for DOM paint + setTimeout(() => { + alertContainer.style.display = "block"; + + if (alertTimeout) clearTimeout(alertTimeout); + alertTimeout = setTimeout(() => { + destroyAlert(); + }, hideAfter || 6000); + }); + }; + + let submit = () => { + fetchToken().then((token) => { + return fetch(`https://api.recipesage.com/recipes?token=${token}`, { + method: "POST", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(currentSnip), + }) + .then((response) => { + if (response.ok) { + response.json().then((data) => { + hide(); + displayAlert( + `Recipe Saved!`, + `Click to open`, + 4000, + `https://recipesage.com/#/recipe/${data.id}` + ); + }); + } else { + switch (response.status) { + case 401: + chrome.storage.local.set({ token: null }, () => { + displayAlert( + "Please Login", + `It looks like you're logged out. Please click the RecipeSage icon to login again.`, + 4000 + ); + }); + break; + case 412: + displayAlert( + `Could Not Save Recipe`, + `A recipe title is required.`, + 4000 + ); + break; + case 415: + displayAlert( + `Could Not Save Recipe`, + `We could not fetch the specified image URL. Please try another image URL, or try uploading the image after creating the recipe.`, + 6000 + ); + break; + default: + displayAlert( + "Could Not Save Recipe", + "An error occurred while saving the recipe. Please try again.", + 4000 + ); + break; + } + } + }) + .catch((e) => { + displayAlert( + "Could Not Save Recipe", + "An error occurred while saving the recipe. Please try again.", + 4000 + ); + console.error(e); + }); + }); + }; + + window.recipeSageBrowserExtensionRootTrigger = show; + show(); + }); + }); +} diff --git a/packages/webextension-v3/src/manifest.json b/packages/webextension-v3/src/manifest.json index d18e0a5ef..c8d86fa24 100644 --- a/packages/webextension-v3/src/manifest.json +++ b/packages/webextension-v3/src/manifest.json @@ -29,5 +29,16 @@ "id": "{1bd6f0af-1e56-4ecd-903b-495f25057d55}", "strict_min_version": "109.0" } - } + }, + "web_accessible_resources": [ + { + "resources": [ + "inject/alert.css", + "inject/clipTool.css", + "icons/android-chrome-512x512.png", + "images/recipesage-black-trimmed.png" + ], + "matches": [""] + } + ] } diff --git a/packages/webextension-v3/webpack.config.js b/packages/webextension-v3/webpack.config.js index 1a0f9df43..dbed14824 100644 --- a/packages/webextension-v3/webpack.config.js +++ b/packages/webextension-v3/webpack.config.js @@ -2,10 +2,13 @@ const path = require("path"); const CopyPlugin = require("copy-webpack-plugin"); module.exports = { - entry: "./src/action/action.js", + entry: { + "inject/inject.js": path.resolve(__dirname, "src/inject/inject.js"), + "inject/clip.js": path.resolve(__dirname, "src/inject/clip.js"), + }, mode: "production", output: { - filename: "action/action.js", + filename: "[name]", path: path.resolve(__dirname, "dist"), }, plugins: [ From 01cd7473e68949b9dda9f6e29c3154a51e36b20b Mon Sep 17 00:00:00 2001 From: Julian Poyourow Date: Thu, 17 Aug 2023 05:53:16 +0000 Subject: [PATCH 2/3] chore: bump webextension-v3 to 2.1.0 --- packages/webextension-v3/src/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webextension-v3/src/manifest.json b/packages/webextension-v3/src/manifest.json index c8d86fa24..446f35e80 100644 --- a/packages/webextension-v3/src/manifest.json +++ b/packages/webextension-v3/src/manifest.json @@ -1,7 +1,7 @@ { "name": "RecipeSage Automatic Recipe Clipper", "short_name": "RecipeSage", - "version": "2.0.1", + "version": "2.1.0", "manifest_version": 3, "description": "An extension for clipping recipes to your RecipeSage account", "homepage_url": "https://recipesage.com", From f618d7e2273e6b8861e4f7db16a7e31318a2a06d Mon Sep 17 00:00:00 2001 From: Julian Poyourow Date: Fri, 18 Aug 2023 23:03:27 +0000 Subject: [PATCH 3/3] fix: image upload, firefox activation --- packages/webextension-v3/src/action/action.js | 26 +-- packages/webextension-v3/src/inject/clip.js | 26 +++ packages/webextension-v3/src/inject/inject.js | 153 +++++++++++------- packages/webextension-v3/src/manifest.json | 4 +- 4 files changed, 134 insertions(+), 75 deletions(-) diff --git a/packages/webextension-v3/src/action/action.js b/packages/webextension-v3/src/action/action.js index e752a6a79..41540a090 100644 --- a/packages/webextension-v3/src/action/action.js +++ b/packages/webextension-v3/src/action/action.js @@ -92,14 +92,9 @@ const showLogin = () => { document.getElementById("start").style.display = "none"; }; -const fetchAndCreateImage = async (imageURL) => { - const imageBlobResponse = await fetch(imageURL); - - if (!imageBlobResponse.ok) return; - - const imageBlob = await imageBlobResponse.blob(); +const createImageFromBlob = async (imageBlob) => { const formData = new FormData(); - formData.append("image", imageBlob, "image"); + formData.append("image", imageBlob); const imageCreateResponse = await fetch(`${API_BASE}images?token=${token}`, { method: "POST", @@ -118,7 +113,7 @@ const interactiveClip = async () => { await chrome.scripting.executeScript({ target: { tabId: tab.id }, - files: ["inject/inject.js"], + files: ["/inject/inject.js"], }); window.close(); @@ -143,7 +138,7 @@ const clipWithInject = async () => { await chrome.scripting.executeScript({ target: { tabId: tab.id }, - files: ["inject/clip.js"], + files: ["/inject/clip.js"], }); }; @@ -179,9 +174,16 @@ const clipWithAPI = async () => { }; const saveClip = async (clipData) => { - const imageId = clipData.imageURL?.trim() - ? await fetchAndCreateImage(clipData.imageURL) - : undefined; + let imageId; + if (clipData.imageBase64) { + try { + const response = await fetch(clipData.imageBase64); + const blob = await response.blob(); + imageId = await createImageFromBlob(blob); + } catch (e) { + console.error(e); + } + } const recipeCreateResponse = await fetch( `${API_BASE}recipes?token=${token}`, diff --git a/packages/webextension-v3/src/inject/clip.js b/packages/webextension-v3/src/inject/clip.js index 64b2ae7ca..36a0c15a2 100644 --- a/packages/webextension-v3/src/inject/clip.js +++ b/packages/webextension-v3/src/inject/clip.js @@ -9,5 +9,31 @@ chrome.storage.local.get(["token"], async (result) => { alert("Error while attempting to automatically clip recipe from page"); }); + clip.url = window.location.href; + + if (clip.imageURL?.trim()) { + try { + const imageBlobResponse = await fetch(clip.imageURL); + + if (!imageBlobResponse.ok) return; + + const imageBlob = await imageBlobResponse.blob(); + + clip.imageBase64 = await new Promise((success, error) => { + try { + const reader = new FileReader(); + reader.onload = function () { + success(this.result); + }; + reader.readAsDataURL(imageBlob); + } catch (e) { + error(e); + } + }); + } catch (e) { + console.error(e); + } + } + chrome.runtime.sendMessage(clip); }); diff --git a/packages/webextension-v3/src/inject/inject.js b/packages/webextension-v3/src/inject/inject.js index e6e850dd9..d61382d79 100644 --- a/packages/webextension-v3/src/inject/inject.js +++ b/packages/webextension-v3/src/inject/inject.js @@ -422,72 +422,101 @@ if (window[extensionContainerId]) { }); }; - let submit = () => { - fetchToken().then((token) => { - return fetch(`https://api.recipesage.com/recipes?token=${token}`, { - method: "POST", - mode: "cors", - cache: "no-cache", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(currentSnip), - }) - .then((response) => { - if (response.ok) { - response.json().then((data) => { - hide(); - displayAlert( - `Recipe Saved!`, - `Click to open`, - 4000, - `https://recipesage.com/#/recipe/${data.id}` - ); - }); - } else { - switch (response.status) { - case 401: - chrome.storage.local.set({ token: null }, () => { - displayAlert( - "Please Login", - `It looks like you're logged out. Please click the RecipeSage icon to login again.`, - 4000 - ); - }); - break; - case 412: - displayAlert( - `Could Not Save Recipe`, - `A recipe title is required.`, - 4000 - ); - break; - case 415: - displayAlert( - `Could Not Save Recipe`, - `We could not fetch the specified image URL. Please try another image URL, or try uploading the image after creating the recipe.`, - 6000 - ); - break; - default: - displayAlert( - "Could Not Save Recipe", - "An error occurred while saving the recipe. Please try again.", - 4000 - ); - break; - } + let submit = async () => { + try { + const token = await fetchToken(); + + let imageId; + try { + const imageResponse = await fetch(currentSnip.imageURL); + const imageBlob = await imageResponse.blob(); + + const formData = new FormData(); + formData.append("image", imageBlob); + + const imageCreateResponse = await fetch( + `https://api.recipesage.com/images?token=${token}`, + { + method: "POST", + body: formData, } - }) - .catch((e) => { + ); + + if (!imageCreateResponse.ok) return; + + const imageData = await imageCreateResponse.json(); + + imageId = imageData.id; + } catch (e) { + console.error("Error creating image", e); + } + + const recipeCreateResponse = await fetch( + `https://api.recipesage.com/recipes?token=${token}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...currentSnip, + imageIds: imageId ? [imageId] : [], + }), + } + ); + + if (recipeCreateResponse.ok) { + recipeCreateResponse.json().then((data) => { + hide(); displayAlert( - "Could Not Save Recipe", - "An error occurred while saving the recipe. Please try again.", - 4000 + `Recipe Saved!`, + `Click to open`, + 4000, + `https://recipesage.com/#/recipe/${data.id}` ); - console.error(e); }); - }); + } else { + switch (recipeCreateResponse.status) { + case 401: + chrome.storage.local.set({ token: null }, () => { + displayAlert( + "Please Login", + `It looks like you're logged out. Please click the RecipeSage icon to login again.`, + 4000 + ); + }); + break; + case 412: + displayAlert( + `Could Not Save Recipe`, + `A recipe title is required.`, + 4000 + ); + break; + case 415: + displayAlert( + `Could Not Save Recipe`, + `We could not fetch the specified image URL. Please try another image URL, or try uploading the image after creating the recipe.`, + 6000 + ); + break; + default: + displayAlert( + "Could Not Save Recipe", + "An error occurred while saving the recipe. Please try again.", + 4000 + ); + break; + } + } + } catch (e) { + displayAlert( + "Could Not Save Recipe", + "An error occurred while saving the recipe. Please try again.", + 4000 + ); + console.error(e); + } }; window.recipeSageBrowserExtensionRootTrigger = show; diff --git a/packages/webextension-v3/src/manifest.json b/packages/webextension-v3/src/manifest.json index 446f35e80..1dbf81249 100644 --- a/packages/webextension-v3/src/manifest.json +++ b/packages/webextension-v3/src/manifest.json @@ -1,7 +1,7 @@ { "name": "RecipeSage Automatic Recipe Clipper", "short_name": "RecipeSage", - "version": "2.1.0", + "version": "2.1.2", "manifest_version": 3, "description": "An extension for clipping recipes to your RecipeSage account", "homepage_url": "https://recipesage.com", @@ -35,6 +35,8 @@ "resources": [ "inject/alert.css", "inject/clipTool.css", + "inject/inject.js", + "inject/clip.js", "icons/android-chrome-512x512.png", "images/recipesage-black-trimmed.png" ],