707 lines
19 KiB
JavaScript
707 lines
19 KiB
JavaScript
/* global
|
||
ACTIONS
|
||
CREATE_ARTICLE_QUERY
|
||
CREATE_ARTICLE_SAVING_REQUEST_QUERY
|
||
ENV_IS_FIREFOX
|
||
ENV_IS_EDGE
|
||
browserApi
|
||
browserActionApi
|
||
browserScriptingApi
|
||
fetch
|
||
XMLHttpRequest
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
|
||
let authToken = undefined;
|
||
const omnivoreURL = process.env.OMNIVORE_URL;
|
||
const omnivoreGraphqlURL = process.env.OMNIVORE_GRAPHQL_URL;
|
||
|
||
/* storage helper functions */
|
||
function getStorage (keyOrKeys) {
|
||
return new Promise((resolve) => {
|
||
browserApi.storage.local.get(keyOrKeys || null, (result) => {
|
||
resolve(result || {});
|
||
});
|
||
});
|
||
}
|
||
|
||
function getStorageItem (singleKey) {
|
||
return new Promise((resolve) => {
|
||
browserApi.storage.local.get(singleKey, (result) => {
|
||
const finalResult = (result && result[singleKey]) || null;
|
||
resolve(finalResult);
|
||
});
|
||
});
|
||
}
|
||
|
||
function setStorage (itemsToSet) {
|
||
return new Promise((resolve) => {
|
||
browserApi.storage.local.set(itemsToSet, resolve);
|
||
});
|
||
}
|
||
|
||
function removeStorage (itemsToRemove) {
|
||
return new Promise((resolve) => {
|
||
browserApi.storage.local.remove(itemsToRemove, resolve);
|
||
});
|
||
}
|
||
|
||
function getCurrentTab () {
|
||
return new Promise((resolve) => {
|
||
browserApi.tabs.query({
|
||
active: true,
|
||
currentWindow: true
|
||
}, function (tabs) {
|
||
resolve(tabs[0] || null);
|
||
});
|
||
});
|
||
}
|
||
|
||
function setupConnection(xhr) {
|
||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||
if (authToken) {
|
||
xhr.setRequestHeader('Authorization', authToken);
|
||
}
|
||
}
|
||
|
||
/* other code */
|
||
function uploadFile ({ id, uploadSignedUrl }, contentType, contentObjUrl) {
|
||
return fetch(contentObjUrl)
|
||
.then((r) => r.blob())
|
||
.then((blob) => {
|
||
return new Promise((resolve) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('PUT', uploadSignedUrl, true);
|
||
xhr.setRequestHeader('Content-Type', contentType);
|
||
|
||
xhr.onerror = () => {
|
||
resolve(undefined);
|
||
};
|
||
xhr.onload = () => {
|
||
// Uploaded.
|
||
resolve({ id });
|
||
};
|
||
xhr.send(blob);
|
||
});
|
||
})
|
||
.catch((err) => {
|
||
console.error('error uploading file', err)
|
||
return undefined
|
||
});
|
||
}
|
||
|
||
function savePdfFile(tab, url, contentType, contentObjUrl) {
|
||
return new Promise(resolve => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.onreadystatechange = async function () {
|
||
if (xhr.readyState === 4) {
|
||
if (xhr.status === 200) {
|
||
const { data } = JSON.parse(xhr.response);
|
||
if ('errorCodes' in data.uploadFileRequest) {
|
||
if (data.uploadFileRequest.errorCodes[0] === 'UNAUTHORIZED') {
|
||
clearClickCompleteState();
|
||
browserApi.tabs.sendMessage(tab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
text: 'Unable to save page',
|
||
type: 'error',
|
||
errorCode: 401,
|
||
url: omnivoreURL
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!data.uploadFileRequest || !data.uploadFileRequest.id || !data.uploadFileRequest.createdPageId || 'errorCodes' in data.uploadFileRequest) {
|
||
browserApi.tabs.sendMessage(tab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
text: 'Unable to save page',
|
||
type: 'error'
|
||
}
|
||
});
|
||
} else {
|
||
const result = await uploadFile(data.uploadFileRequest, contentType, contentObjUrl);
|
||
URL.revokeObjectURL(contentObjUrl);
|
||
|
||
if (!result) {
|
||
return undefined
|
||
}
|
||
|
||
const createdPageId = data.uploadFileRequest.createdPageId
|
||
const url = omnivoreURL + '/article/sr/' + createdPageId
|
||
|
||
browserApi.tabs.sendMessage(tab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
text: 'Saved to Omnivore',
|
||
link: url,
|
||
linkText: 'Read Now',
|
||
type: 'success'
|
||
}
|
||
})
|
||
return resolve(data.uploadFileRequest);
|
||
}
|
||
} else if (xhr.status === 400) {
|
||
browserApi.tabs.sendMessage(tab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
text: 'Unable to save page',
|
||
type: 'error'
|
||
}
|
||
});
|
||
}
|
||
resolve(false);
|
||
}
|
||
};
|
||
|
||
const data = JSON.stringify({
|
||
query: `mutation UploadFileRequest($input: UploadFileRequestInput!) {
|
||
uploadFileRequest(input:$input) {
|
||
... on UploadFileRequestError {
|
||
errorCodes
|
||
}
|
||
... on UploadFileRequestSuccess {
|
||
id
|
||
createdPageId
|
||
uploadSignedUrl
|
||
}
|
||
}
|
||
}`,
|
||
variables: {
|
||
input: {
|
||
url,
|
||
contentType,
|
||
createPageEntry: true,
|
||
}
|
||
}
|
||
});
|
||
|
||
xhr.open('POST', omnivoreGraphqlURL + 'graphql', true);
|
||
setupConnection(xhr);
|
||
|
||
xhr.send(data);
|
||
});
|
||
}
|
||
|
||
function clearClickCompleteState () {
|
||
getStorageItem('postInstallClickComplete').then((postInstallClickComplete) => {
|
||
if (postInstallClickComplete) {
|
||
removeStorage('postInstallClickComplete');
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleSaveResponse(tab, field, xhr) {
|
||
if (xhr.readyState === 4) {
|
||
if (xhr.status === 200) {
|
||
const { data } = JSON.parse(xhr.response);
|
||
const item = data[field]
|
||
if (!item) {
|
||
return undefined
|
||
}
|
||
|
||
if ('errorCodes' in item) {
|
||
const messagePayload = {
|
||
text: descriptions[data.createArticle.errorCodes[0]] || 'Unable to save page',
|
||
type: 'error'
|
||
}
|
||
|
||
if (item.errorCodes[0] === 'UNAUTHORIZED') {
|
||
messagePayload.errorCode = 401
|
||
messagePayload.url = omnivoreURL
|
||
clearClickCompleteState()
|
||
}
|
||
|
||
browserApi.tabs.sendMessage(tab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: messagePayload
|
||
})
|
||
|
||
return undefined
|
||
}
|
||
|
||
const url = item['url']
|
||
browserApi.tabs.sendMessage(tab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
text: 'Saved to Omnivore',
|
||
link: url ?? omnivoreURL + '/home',
|
||
linkText: 'Read Now',
|
||
type: 'success'
|
||
}
|
||
})
|
||
|
||
return item
|
||
} else if (xhr.status === 400) {
|
||
browserApi.tabs.sendMessage(tab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
text: 'Unable to save page',
|
||
type: 'error'
|
||
}
|
||
})
|
||
return undefined
|
||
}
|
||
}
|
||
}
|
||
|
||
async function saveUrl(currentTab, url) {
|
||
const requestId = uuidv4()
|
||
await saveApiRequest(currentTab, SAVE_URL_QUERY, 'saveUrl', {
|
||
source: 'extension',
|
||
clientRequestId: requestId,
|
||
url: encodeURI(url),
|
||
})
|
||
}
|
||
|
||
async function saveApiRequest(currentTab, query, field, input) {
|
||
browserApi.tabs.sendMessage(currentTab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
type: 'loading',
|
||
text: 'Saving...'
|
||
}
|
||
});
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const xhr = new XMLHttpRequest()
|
||
xhr.open('POST', omnivoreGraphqlURL + 'graphql', true)
|
||
setupConnection(xhr)
|
||
|
||
xhr.onerror = (err) => { reject(err) }
|
||
|
||
xhr.onload = () => {
|
||
try {
|
||
const res = handleSaveResponse(currentTab, field, xhr)
|
||
if (!res) {
|
||
return reject()
|
||
}
|
||
resolve(res);
|
||
} catch (err) {
|
||
reject(err)
|
||
}
|
||
}
|
||
|
||
const data = JSON.stringify({
|
||
query,
|
||
variables: {
|
||
input
|
||
}
|
||
})
|
||
|
||
xhr.send(data);
|
||
})
|
||
.catch((err) => {
|
||
console.log('error saving page', err)
|
||
browserApi.tabs.sendMessage(currentTab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
text: 'Unable to save page',
|
||
type: 'error',
|
||
}
|
||
});
|
||
return undefined
|
||
});
|
||
}
|
||
|
||
async function saveArticle (tab) {
|
||
browserApi.tabs.sendMessage(tab.id, {
|
||
action: ACTIONS.GetContent
|
||
}, async (response) => {
|
||
if (!response || typeof response !== 'object') {
|
||
// In the case of an invalid response, we attempt
|
||
// to just save the URL. This can happen in Firefox
|
||
// with PDF URLs
|
||
await saveUrl(tab, tab.url)
|
||
return;
|
||
}
|
||
|
||
const requestId = uuidv4()
|
||
var { type } = response;
|
||
const { pageInfo, doc, uploadContentObjUrl } = response;
|
||
|
||
if (type == 'html' && handleBackendUrl(tab.url)) {
|
||
type = 'url'
|
||
}
|
||
|
||
switch(type) {
|
||
case 'html': {
|
||
await saveApiRequest(tab, SAVE_PAGE_QUERY, 'savePage', {
|
||
source: 'extension',
|
||
clientRequestId: requestId,
|
||
originalContent: doc,
|
||
title: pageInfo.title,
|
||
url: encodeURI(tab.url),
|
||
})
|
||
break
|
||
}
|
||
case 'url': {
|
||
await saveApiRequest(tab, SAVE_URL_QUERY, 'saveUrl', {
|
||
source: 'extension',
|
||
clientRequestId: requestId,
|
||
url: encodeURI(tab.url),
|
||
})
|
||
break
|
||
}
|
||
case 'pdf': {
|
||
const uploadResult = await savePdfFile(tab, encodeURI(tab.url), pageInfo.contentType, uploadContentObjUrl);
|
||
if (!uploadResult || !uploadResult.id) {
|
||
// If the upload failed for any reason, try to save the PDF URL instead
|
||
await saveApiRequest(tab, SAVE_URL_QUERY, 'saveUrl', {
|
||
source: 'extension',
|
||
clientRequestId: requestId,
|
||
url: encodeURI(tab.url),
|
||
})
|
||
return;
|
||
}
|
||
break
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// credit: https://stackoverflow.com/questions/21535233/injecting-multiple-scripts-through-executescript-in-google-chrome
|
||
function executeScripts (tabId, scriptsArray, onCompleteCallback) {
|
||
function createCallback (tabId, injectDetails, innerCallback) {
|
||
return function () {
|
||
browserScriptingApi.executeScript(tabId, injectDetails, innerCallback);
|
||
};
|
||
}
|
||
|
||
// any callback passed by caller is called upon all scripts execute completion
|
||
let callback = onCompleteCallback;
|
||
for (let i = scriptsArray.length - 1; i >= 0; --i) {
|
||
callback = createCallback(tabId, { file: scriptsArray[i] }, callback);
|
||
}
|
||
if (callback !== null) {
|
||
callback(); // execute outermost function
|
||
}
|
||
}
|
||
|
||
function cleanupTabState (tabId) {
|
||
getStorage().then(function (result) {
|
||
const itemsToRemove = [];
|
||
const keys = Object.keys(result);
|
||
const keyPrefix = tabId + '_saveInProgress';
|
||
for (let i = 0; i < keys.length; i++) {
|
||
const key = keys[i];
|
||
if (key.startsWith(keyPrefix)) {
|
||
itemsToRemove.push(key);
|
||
}
|
||
}
|
||
if (itemsToRemove.length === 0) return;
|
||
removeStorage(itemsToRemove);
|
||
});
|
||
}
|
||
|
||
/* setup an interval timer and a timeout timer (failsave) to clear interval timer after a timeout */
|
||
function setupTimedInterval (timerCallback, timeoutCallback, callback, delay = 1000, timeout = 10500) {
|
||
const intervalId = setInterval(timerCallback, delay);
|
||
const timeoutId = setTimeout(() => {
|
||
clearInterval(intervalId);
|
||
timeoutCallback();
|
||
}, timeout);
|
||
|
||
if (callback && typeof callback === 'function') {
|
||
callback(intervalId, timeoutId);
|
||
}
|
||
}
|
||
|
||
async function clearPreviousIntervalTimer (tabId) {
|
||
const prevIntervalId = await getStorageItem(tabId + '_saveInProgress');
|
||
if (!prevIntervalId) return;
|
||
|
||
clearInterval(prevIntervalId);
|
||
|
||
const intervalTimeoutId = await getStorageItem(tabId + '_saveInProgressTimeoutId_' + prevIntervalId);
|
||
if (!intervalTimeoutId) return;
|
||
|
||
clearTimeout(intervalTimeoutId);
|
||
}
|
||
|
||
function onExtensionClick (tabId) {
|
||
/* clear any previous timers on each click */
|
||
clearPreviousIntervalTimer(tabId);
|
||
|
||
/* Method to check tab loading state prior to save */
|
||
function checkTabLoadingState (onSuccess, onPending) {
|
||
browserApi.tabs.get(tabId, async (tab) => {
|
||
if (tab.status !== 'complete') {
|
||
// show message to user on page yet to complete load
|
||
browserApi.tabs.sendMessage(tab.id, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
type: 'loading',
|
||
text: 'Page loading...'
|
||
}
|
||
});
|
||
if (onPending && typeof onPending === 'function') {
|
||
onPending();
|
||
}
|
||
} else {
|
||
if (onSuccess && typeof onSuccess === 'function') {
|
||
onSuccess();
|
||
}
|
||
await saveArticle(tab);
|
||
}
|
||
});
|
||
}
|
||
|
||
/* call above method immediately, and if tab in loading state then setup timer */
|
||
checkTabLoadingState(null, () => {
|
||
setupTimedInterval(() => {
|
||
/* interval timer to check tab/page loading state and save */
|
||
checkTabLoadingState(() => {
|
||
clearPreviousIntervalTimer(tabId);
|
||
});
|
||
}, () => {
|
||
/* timeout handling, clear timer and show timeout msg */
|
||
clearPreviousIntervalTimer(tabId);
|
||
|
||
browserApi.tabs.get(tabId, async (tab) => {
|
||
/*
|
||
* post timeout, we proceed to save as some sites (people.com) take a
|
||
* long time to reach complete state and remain in interactive state.
|
||
*/
|
||
await saveArticle(tab);
|
||
});
|
||
}, (intervalId, timeoutId) => {
|
||
/* Track interval timer and timeout timer in browser storage keyed by tabId */
|
||
const itemsToSet = {};
|
||
itemsToSet[tabId + '_saveInProgress'] = intervalId;
|
||
itemsToSet[tabId + '_saveInProgressTimeoutId_' + intervalId] = timeoutId;
|
||
setStorage(itemsToSet);
|
||
});
|
||
});
|
||
}
|
||
|
||
/* After installing extension, if user hasn’t logged into Omnivore, then we show the splash popup */
|
||
function checkAuthOnFirstClickPostInstall (tabId) {
|
||
return getStorageItem('postInstallClickComplete').then(async (postInstallClickComplete) => {
|
||
if (postInstallClickComplete) return true;
|
||
|
||
if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendNativeMessage) {
|
||
const response = await browser.runtime.sendNativeMessage("omnivore", {message: ACTIONS.GetAuthToken})
|
||
if (response.authToken) {
|
||
authToken = response.authToken;
|
||
}
|
||
}
|
||
|
||
return new Promise((resolve) => {
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.onreadystatechange = function () {
|
||
if (xhr.readyState === 4 && xhr.status === 200) {
|
||
const { data } = JSON.parse(xhr.response);
|
||
if (!data.me) {
|
||
browserApi.tabs.sendMessage(tabId, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
type: 'loading',
|
||
text: 'Loading...'
|
||
}
|
||
});
|
||
browserApi.tabs.sendMessage(tabId, {
|
||
action: ACTIONS.ShowMessage,
|
||
payload: {
|
||
text: '',
|
||
type: 'error',
|
||
errorCode: 401,
|
||
url: omnivoreURL
|
||
}
|
||
});
|
||
resolve(null);
|
||
} else {
|
||
setStorage({
|
||
postInstallClickComplete: true
|
||
});
|
||
resolve(true);
|
||
}
|
||
}
|
||
};
|
||
|
||
const query = '{me{id}}';
|
||
const data = JSON.stringify({
|
||
query
|
||
});
|
||
xhr.open('POST', omnivoreGraphqlURL + 'graphql', true);
|
||
setupConnection(xhr);
|
||
|
||
xhr.send(data);
|
||
});
|
||
});
|
||
}
|
||
|
||
function handleActionClick () {
|
||
executeAction(function (currentTab) {
|
||
onExtensionClick(currentTab.id);
|
||
})
|
||
}
|
||
|
||
function executeAction(action) {
|
||
getCurrentTab().then((currentTab) => {
|
||
browserApi.tabs.sendMessage(currentTab.id, {
|
||
action: ACTIONS.Ping
|
||
}, async function (response) {
|
||
if (response && response.pong) {
|
||
// Content script ready
|
||
const isSignedUp = await checkAuthOnFirstClickPostInstall(currentTab.id);
|
||
if (isSignedUp) {
|
||
action(currentTab);
|
||
}
|
||
} else {
|
||
const extensionManifest = browserApi.runtime.getManifest();
|
||
const contentScripts = extensionManifest.content_scripts;
|
||
|
||
// No listener on the other end, inject content scripts
|
||
const scriptFiles = [
|
||
...contentScripts[0].js,
|
||
...contentScripts[1].js
|
||
];
|
||
executeScripts(currentTab.id, scriptFiles, async function () {
|
||
const isSignedUp = await checkAuthOnFirstClickPostInstall(currentTab.id);
|
||
if (isSignedUp) {
|
||
action(currentTab);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function getIconPath (active, dark) {
|
||
let iconPath = '/images/toolbar/icon';
|
||
if (ENV_IS_FIREFOX) {
|
||
iconPath += '_firefox';
|
||
} else if (ENV_IS_EDGE) {
|
||
iconPath += '_edge';
|
||
}
|
||
if (!active) {
|
||
iconPath += '_inactive';
|
||
}
|
||
/* we have to evaluate this every time as the onchange is not
|
||
* fired inside background pages, due to https://crbug.com/968651 */
|
||
const useDarkIcon = typeof dark === 'boolean'
|
||
? dark
|
||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||
|
||
if (useDarkIcon) {
|
||
iconPath += '_dark';
|
||
}
|
||
if (ENV_IS_FIREFOX) {
|
||
return iconPath + '.svg';
|
||
}
|
||
|
||
const iconSizes = ['16', '24', '32', '48'];
|
||
if (!ENV_IS_EDGE) {
|
||
iconSizes.push('19', '38');
|
||
}
|
||
const iconPaths = {};
|
||
for (let i = 0; i < iconSizes.length; i++) {
|
||
const iconSize = iconSizes[i];
|
||
iconPaths[iconSize] = iconPath + '-' + iconSize + '.png';
|
||
}
|
||
return iconPaths;
|
||
}
|
||
|
||
function updateActionIcon (tabId, active, dark) {
|
||
browserActionApi.setIcon({
|
||
path: getIconPath(active, dark),
|
||
tabId: tabId
|
||
});
|
||
}
|
||
|
||
function getActionableState (tab) {
|
||
if (tab.status !== 'complete') return false;
|
||
|
||
const tabUrl = tab.pendingUrl || tab.url;
|
||
if (!tabUrl) return false;
|
||
|
||
if (
|
||
!tabUrl.startsWith('https://') &&
|
||
!tabUrl.startsWith('http://')
|
||
) return false;
|
||
|
||
if (
|
||
tabUrl.startsWith('https://omnivore.app/') &&
|
||
tabUrl.startsWith('https://dev.omnivore.app/')
|
||
) return false;
|
||
|
||
return true;
|
||
}
|
||
|
||
function reflectIconState (tab) {
|
||
const tabId = tab && tab.id;
|
||
if (!tabId) return;
|
||
|
||
const active = getActionableState(tab);
|
||
|
||
updateActionIcon(tabId, active);
|
||
}
|
||
|
||
function init () {
|
||
/* Extension icon switcher on page/tab load status */
|
||
browserApi.tabs.onActivated.addListener(({ tabId }) => {
|
||
// Due to a chrome bug, chrome.tabs.* may run into an error because onActivated is triggered too fast.
|
||
function checkCurrentTab () {
|
||
browserApi.tabs.get(tabId, function (tab) {
|
||
if (browserApi.runtime.lastError) {
|
||
setTimeout(checkCurrentTab, 150);
|
||
}
|
||
reflectIconState(tab);
|
||
});
|
||
}
|
||
|
||
checkCurrentTab();
|
||
});
|
||
|
||
/* Extension icon switcher on page/tab load status */
|
||
browserApi.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
||
/* Not an update while this tab is active so we skip updating icon */
|
||
if (!changeInfo.status || !tab || !tab.active) return;
|
||
|
||
reflectIconState(tab);
|
||
});
|
||
|
||
browserApi.tabs.onRemoved.addListener((tabId) => {
|
||
/* cleanup any previous saveInProgress state for the tab */
|
||
cleanupTabState(tabId);
|
||
});
|
||
|
||
browserActionApi.onClicked.addListener(handleActionClick);
|
||
|
||
// forward messages from grab-iframe-content.js script to tabs
|
||
browserApi.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
||
if (request.forwardToTab) {
|
||
delete request.forwardToTab;
|
||
browserApi.tabs.sendRequest(sender.tab.id, request);
|
||
return;
|
||
}
|
||
|
||
if (request.action === ACTIONS.RefreshDarkMode) {
|
||
updateActionIcon(sender.tab.id, request.payload.value);
|
||
}
|
||
});
|
||
|
||
// set initial extension icon
|
||
browserActionApi.setIcon({
|
||
path: getIconPath(true)
|
||
});
|
||
|
||
browserApi.contextMenus.create({
|
||
id: "save-selection",
|
||
title: "Save to Omnivore",
|
||
contexts: ["link"],
|
||
onclick: async function(obj) {
|
||
executeAction(async function (currentTab) {
|
||
await saveUrl(currentTab, obj.linkUrl)
|
||
})
|
||
},
|
||
});
|
||
}
|
||
|
||
init();
|