From 45491157f5b512f9a01a0756d4ee04acc242eada Mon Sep 17 00:00:00 2001 From: msramalho <19508417+msramalho@users.noreply.github.com> Date: Sun, 26 Feb 2023 20:22:37 +0100 Subject: [PATCH] new features --- package-lock.json | 11 ++ package.json | 3 +- source/css/popup.css | 8 +- source/html/options.html | 4 +- source/js/background.js | 260 ++++++++++++++++++++++++++--------- source/js/options-storage.js | 4 +- source/js/options.js | 42 +++--- source/js/popup.js | 3 +- source/js/utils.js | 19 +++ source/manifest.json | 2 +- source/vue/Popup.vue | 180 +++++++++++++++++++----- source/vue/TaskItem.vue | 67 ++++++--- 12 files changed, 459 insertions(+), 144 deletions(-) create mode 100644 source/js/utils.js diff --git a/package-lock.json b/package-lock.json index 868729e..6f14206 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "http-status-codes": "^2.2.0", "material-design-icons": "^3.0.1", "materialize-css": "^1.0.0-rc.2", "vue": "^3.2.45", @@ -4442,6 +4443,11 @@ "entities": "^3.0.1" } }, + "node_modules/http-status-codes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.2.0.tgz", + "integrity": "sha512-feERVo9iWxvnejp3SEfm/+oNG517npqL2/PIA8ORjyOZjGC7TwCRQsZylciLS64i6pJ0wRYz3rkXLRwbtFa8Ng==" + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -12165,6 +12171,11 @@ "entities": "^3.0.1" } }, + "http-status-codes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.2.0.tgz", + "integrity": "sha512-feERVo9iWxvnejp3SEfm/+oNG517npqL2/PIA8ORjyOZjGC7TwCRQsZylciLS64i6pJ0wRYz3rkXLRwbtFa8Ng==" + }, "human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", diff --git a/package.json b/package.json index 75cdc4a..fc914f4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "extends": "stylelint-config-xo" }, "dependencies": { + "http-status-codes": "^2.2.0", "material-design-icons": "^3.0.1", "materialize-css": "^1.0.0-rc.2", "vue": "^3.2.45", @@ -42,7 +43,7 @@ "sourceDir": "distribution", "run": { "startUrl": [ - "https://github.com/fregante/browser-extension-template" + "https://github.com/bellingcat/auto-archiver-extension" ] } }, diff --git a/source/css/popup.css b/source/css/popup.css index 720dd51..ed56c81 100644 --- a/source/css/popup.css +++ b/source/css/popup.css @@ -3,7 +3,7 @@ body { } #app { - min-width: 40em; + min-width: 45em; margin: 15px; } @@ -12,17 +12,17 @@ body { vertical-align: middle; } -#archive-results .row { +table.archive-results .row { /* table-layout: fixed; */ width: 90%; max-width: 100px; } -/* #archive-results td { +/* .archive-results td { width: auto; } -#archive-results td:nth-child(2) { +.archive-results td:nth-child(2) { width: 150px; } */ diff --git a/source/html/options.html b/source/html/options.html index 5d3616e..03bdc70 100644 --- a/source/html/options.html +++ b/source/html/options.html @@ -1,4 +1,4 @@ - + diff --git a/source/js/background.js b/source/js/background.js index 373bf35..884fdce 100644 --- a/source/js/background.js +++ b/source/js/background.js @@ -1,64 +1,142 @@ // Import './options-storage.js'; import optionsStorage from './options-storage.js'; +import { getReasonPhrase } from 'http-status-codes'; // TODO: stable ID https://developer.chrome.com/docs/extensions/mv3/tut_oauth/ const API_ENDPOINT = 'http://localhost:8004/tasks' // const API_ENDPOINT = 'http://134.122.58.133:8004/tasks'; -chrome.runtime.onMessage.addListener(((r, s, sR) => { - processMessages(r, s, sR); +const LOGIN_FAILED = `Could not login, make sure your google account email has been granted access by the developers.`; + +chrome.runtime.onMessage.addListener(((r, s, sendResponse) => { + processMessages(r, s) + //TODO: improve body + .then(response => { + console.log(`SUCCESS (${r.action}): ${JSON.stringify(response)}`) + sendResponse({ status: "success", result: response }) + } + ).catch(error => { + let message = error.message == "Failed to fetch" ? `Unable to call the API: ${error.message}` : error.message; + console.log(`ERROR (${r.action}): ${error}`) + setErrorMessage(message); + sendResponse({ status: "error", result: message }) + }); return true; // Needed for sendResponse to be async })); -async function processMessages(request, sender, sendResponse) { - console.info(`action {${request.action}} from ${sender.tab ? 'content-script (' + sender.tab.url + ')' : 'the extension'}`); - chrome.identity.getAuthToken({ interactive: true }, async accessToken => { +function processMessages(request, sender) { + + return new Promise(async (resolve, reject) => { + console.info(`action {${request.action}} from ${sender.tab ? 'content-script (' + sender.tab.url + ')' : 'the extension'}`); + switch (request.action) { case 'archive': { - archiveUrl(sendResponse, accessToken); + archiveUrl(resolve, reject, request.optionalUrl); break; } case 'search': { - const tasks = await search(request.query, accessToken); - sendResponse(tasks); + search(resolve, reject, request.query); break; } case 'status': { - const taskDb = await getTaskById(request.task.task_id); - if (taskDb?.status === 'SUCCESS' || taskDb?.status === 'FAILURE'|| taskDb?.status === 'REVOKED') { - console.log('ALREADY FINSIHED, NO REQS'); - sendResponse(taskDb); + const taskDb = await getTaskById(request.task.id); + if (taskDb?.status === 'SUCCESS' || taskDb?.status === 'FAILURE' || taskDb?.status === 'REVOKED') { + console.log('ALREADY FINISHED, NO REQS'); + resolve(taskDb); } else { - const taskFresh = await checkTaskStatus(request.task, accessToken); - sendResponse(taskFresh); + resolve(await checkTaskStatus(resolve, reject, request.task)); } break; } case 'getTasks': { - sendResponse(await getAllTasks()); + resolve(await getAllTasks()); + break; + } + case 'syncLocalTasks': { + syncLocalTasks(resolve, reject); + break; + } + case 'getErrorMessage': { + resolve(await getErrorMessage()); + break; + } + case 'setErrorMessage': { + await setErrorMessage(request.errorMessage) + break; + } + case 'getCurrentUrl': { + getUrl(resolve, reject); + break; + } + case 'getProfileEmail': { + getProfileEmail(resolve, reject); + break; + } + case 'oauthLogin': { + oauthLogin(resolve, reject); break; } // No default } + }); } -function archiveUrl(sendResponse, accessToken) { + +function getUrl(resolve, reject) { chrome.tabs.query({ active: true, lastFocusedWindow: true, }, async tabs => { const url = tabs[0].url; - console.log(`url=${url}`); - const response = await searchTask(url, accessToken); - const newArchive = { url, task_id: response.task_id, status: 'PENDING', result: {} }; - await upsertTask(newArchive); - sendResponse(newArchive); + resolve(url); }); } -function searchTask(url, accessToken) { +function getProfileEmail(resolve, reject) { + chrome.identity.getProfileUserInfo({ accountStatus: 'ANY' }, async userInfo => { + resolve(userInfo); + //TODO: reject if bad user info? + }); +} + +function oauthLogin(resolve, reject) { + try { + chrome.identity.getAuthToken({ interactive: true }, async accessToken => { + console.error(`GOT token ${accessToken}`); + resolve(true); + }); + } catch (e) { + // reject(new Error(`LOGIN FAILED: ${e}`)); + console.error(`LOGIN FAILED: ${e}`); + resolve(false); + } +} + +function archiveUrl(resolve, reject, optionalUrl) { + chrome.identity.getAuthToken({ interactive: true }, async accessToken => { + if (accessToken == undefined) { + reject(new Error(LOGIN_FAILED)); + return; + } + chrome.tabs.query({ + active: true, + lastFocusedWindow: true, + }, async tabs => { + console.warn(optionalUrl) + const url = optionalUrl || tabs[0].url; + console.log(`url=${url}`); + submitUrlArchive(url, accessToken).then(async response => { + const newArchive = { url, id: response.id, status: 'PENDING', result: {} }; + await upsertTask(newArchive); + resolve(newArchive); + }).catch(e => reject(e)); + }); + }); +} + +function submitUrlArchive(url, accessToken) { console.log('API: SUBMIT'); return new Promise((resolve, reject) => { fetch(API_ENDPOINT, { @@ -67,72 +145,128 @@ function searchTask(url, accessToken) { 'Content-Type': 'application/json', }, body: JSON.stringify({ url, access_token: accessToken }), - }).then( - response => response.json(), - ).then(response => resolve(response), - ).catch(error => { - console.log(`There was an error: ${error}`); - reject(error); - }); + }) + .then(getJsonOrError) + .then(response => resolve(response)) + .catch(e => reject(e)); }); } -function checkTaskStatus(task, accessToken) { +function checkTaskStatus(resolve, reject, task) { console.log('API: STATUS'); - return new Promise((resolve, reject) => { - fetch(`${API_ENDPOINT}/${task.task_id}?` + new URLSearchParams({ access_token: accessToken }), { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - }, - }).then( - response => response.json(), - ).then(response => { - const new_task = { - url: task.url, - task_id: response.task_id, - status: response.task_status, - result: typeof response.task_result == "object" ? response.task_result : JSON.parse(response.task_result), - }; - console.log(`status ${new_task.url}: ${new_task.task_id}`); - upsertTask(new_task); - resolve(new_task); - }, - ).catch(error => reject(error)); + return new Promise((InnerResolve, innerReject) => { + chrome.identity.getAuthToken({ interactive: true }, async accessToken => { + if (accessToken == undefined) { + reject(new Error(LOGIN_FAILED)); + return; + } + fetch(`${API_ENDPOINT}/${task.id}?` + new URLSearchParams({ access_token: accessToken }), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(getJsonOrError) + .then(response => { + const new_task = { + url: task.url, + id: response.id, + status: response.status, + result: typeof response.result == "object" ? response.result : JSON.parse(response.result), + }; + console.log(`status ${new_task.url}: ${new_task.id}`); + (async () => { + await upsertTask(new_task); + InnerResolve(new_task); + })(); + }) + .catch(e => reject(e)); + }); }); } -function search(query, accessToken) { +function search(resolve, reject, url) { console.log('API: SEARCH'); - return new Promise((resolve, reject) => { - fetch(`${API_ENDPOINT}/search?` + new URLSearchParams({ access_token: accessToken, query }), { + chrome.identity.getAuthToken({ interactive: true }, async accessToken => { + if (accessToken == undefined) { + reject(new Error(LOGIN_FAILED)); + return; + } + fetch(`${API_ENDPOINT}/search-url?` + new URLSearchParams({ access_token: accessToken, url }), { method: 'GET', headers: { 'Content-Type': 'application/json', }, - }).then( - response => response.json(), - ).then(response => resolve(response), - ).catch(error => { - console.log(`There was an error: ${error}`); - reject(error); - }); + }) + .then(getJsonOrError) + .then(jsonResponse => { resolve(jsonResponse.map(t => { t.status = "SUCCESS"; return t; })) }) + .catch(e => reject(e)); }); } +async function syncLocalTasks(resolve, reject) { + console.log('API: SYNC'); + chrome.identity.getAuthToken({ interactive: true }, async accessToken => { + if (accessToken == undefined) { + reject(new Error(LOGIN_FAILED)); + return; + } + fetch(`${API_ENDPOINT}/sync?` + new URLSearchParams({ access_token: accessToken }), { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + .then(getJsonOrError) + .then(async cloudTasks => { + const storage = await optionsStorage.getAll(); + cloudTasks.forEach(cTask => { + storage.archivedUrls[cTask.id] = { + url: cTask.url, + id: cTask.id, + status: "SUCCESS", + result: typeof cTask.result == "object" ? cTask.result : JSON.parse(cTask.result), + }; + }) + await optionsStorage.set(storage); + resolve(storage.archivedUrls) + }) + .catch(e => reject(e)); + }); +} + +async function getJsonOrError(response) { + let additionalErrorInfo = ""; + if (response.status == 401) additionalErrorInfo = `Check that this email has been granted permission.`; + if (response.status != 200) throw new Error(`${response.status}: ${getReasonPhrase(response.status)} ${additionalErrorInfo}`); + return await response.json(); +} + async function getAllTasks() { const storage = await optionsStorage.getAll(); + console.log(`OP: GET_ALL, has ${Object.keys(storage.archivedUrls).length} entries`) return storage.archivedUrls; } -// TODO: improve with less reads from storage async function upsertTask(task) { const storage = await optionsStorage.getAll(); - storage.archivedUrls[task.task_id] = task; + storage.archivedUrls[task.id] = task; await optionsStorage.set(storage); } async function getTaskById(task) { const storage = await optionsStorage.getAll(); - return storage.archivedUrls[task.task_id]; + return storage.archivedUrls[task.id]; +} + +async function getErrorMessage() { + const storage = await optionsStorage.getAll(); + console.log(`OP: GET_ERROR_MESSAGE has '${storage.errorMessage}'`) + return storage.errorMessage; +} + +async function setErrorMessage(errorMessage) { + const storage = await optionsStorage.getAll(); + storage.errorMessage = errorMessage; + await optionsStorage.set(storage); } diff --git a/source/js/options-storage.js b/source/js/options-storage.js index 1ac93e2..33d654c 100644 --- a/source/js/options-storage.js +++ b/source/js/options-storage.js @@ -3,9 +3,11 @@ import OptionsSync from 'webext-options-sync'; export default new OptionsSync({ defaults: { archivedUrls: {}, + errorMessage: "" }, migrations: [ - OptionsSync.migrations.removeUnused, + // OptionsSync.migrations.removeUnused, ], logging: true, + storageType: "local" }); diff --git a/source/js/options.js b/source/js/options.js index b1ac851..267b69c 100644 --- a/source/js/options.js +++ b/source/js/options.js @@ -1,29 +1,29 @@ // eslint-disable-next-line import/no-unassigned-import -import 'webext-base-css'; -import '../css/options.css'; +// import 'webext-base-css'; +// import '../css/options.css'; -import optionsStorage from './options-storage.js'; +// import optionsStorage from './options-storage.js'; -const rangeInputs = [...document.querySelectorAll('input[type="range"][name^="color"]')]; -const numberInputs = [...document.querySelectorAll('input[type="number"][name^="color"]')]; -const output = document.querySelector('.color-output'); +// const rangeInputs = [...document.querySelectorAll('input[type="range"][name^="color"]')]; +// const numberInputs = [...document.querySelectorAll('input[type="number"][name^="color"]')]; +// const output = document.querySelector('.color-output'); -function updateOutputColor() { - output.style.backgroundColor = `rgb(${rangeInputs[0].value}, ${rangeInputs[1].value}, ${rangeInputs[2].value})`; -} +// function updateOutputColor() { +// output.style.backgroundColor = `rgb(${rangeInputs[0].value}, ${rangeInputs[1].value}, ${rangeInputs[2].value})`; +// } -function updateInputField(event) { - numberInputs[rangeInputs.indexOf(event.currentTarget)].value = event.currentTarget.value; -} +// function updateInputField(event) { +// numberInputs[rangeInputs.indexOf(event.currentTarget)].value = event.currentTarget.value; +// } -for (const input of rangeInputs) { - input.addEventListener('input', updateOutputColor); - input.addEventListener('input', updateInputField); -} +// for (const input of rangeInputs) { +// input.addEventListener('input', updateOutputColor); +// input.addEventListener('input', updateInputField); +// } -async function init() { - await optionsStorage.syncForm('#options-form'); - updateOutputColor(); -} +// async function init() { +// await optionsStorage.syncForm('#options-form'); +// updateOutputColor(); +// } -init(); +// init(); diff --git a/source/js/popup.js b/source/js/popup.js index 00ca795..4876e29 100644 --- a/source/js/popup.js +++ b/source/js/popup.js @@ -1,4 +1,4 @@ -import {createApp} from 'vue'; +import { createApp } from 'vue'; import Popup from '../vue/Popup.vue'; import 'materialize-css/dist/css/materialize.min.css'; import 'material-design-icons/iconfont/material-icons.css'; @@ -12,6 +12,7 @@ app.mount('#app'); document.addEventListener('DOMContentLoaded', async () => { // TODO: uncomment if using options // listenForOptionsClick(); + M.Tooltip.init(document.querySelectorAll('.tooltipped'), { enterDelay: 500 }); // enable tooltips }); // Function listenForOptionsClick() { diff --git a/source/js/utils.js b/source/js/utils.js new file mode 100644 index 0000000..4257e2e --- /dev/null +++ b/source/js/utils.js @@ -0,0 +1,19 @@ +// /** +// * +// * +// */ +// export async function callBackground(parameters) { +// try { +// const answer = await chrome.runtime.sendMessage(parameters); +// if (answer.status == "error") { +// console.error(`error: ${answer.result}`) +// //TODO: modal/errors +// return null; +// } else { +// return answer.result; +// } +// } catch (e) { +// console.error(e); +// return null; +// } +// } \ No newline at end of file diff --git a/source/manifest.json b/source/manifest.json index 6d3628e..9c23f9a 100644 --- a/source/manifest.json +++ b/source/manifest.json @@ -15,7 +15,7 @@ "128": "img/ben-archiver.png" }, "permissions": [ - "storage", "tabs", "identity" + "storage", "tabs", "identity", "identity.email" ], "host_permissions": [ ], diff --git a/source/vue/Popup.vue b/source/vue/Popup.vue index e33b511..a263106 100644 --- a/source/vue/Popup.vue +++ b/source/vue/Popup.vue @@ -1,16 +1,23 @@ -