/* 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 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 browserApi.tabs.sendMessage(tab.id, { action: ACTIONS.UpdateStatus, payload: { status: 'success', target: 'page', requestId: createdPageId, }, }) 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) console.log('response data: ', data) 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.UpdateStatus, payload: { status: 'success', target: 'page', link: url ?? omnivoreURL + '/home', }, }) 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.ShowToolbar, payload: { type: 'loading', requestId: input.clientRequestId, }, }) 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) try { await updateLabelsCache(omnivoreGraphqlURL + 'graphql', tab) browserApi.tabs.sendMessage(tab.id, { action: ACTIONS.LabelCacheUpdated, payload: {}, }) } catch (err) { console.error('error fetching labels', err, omnivoreGraphqlURL) return undefined } } }) } /* 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 updateLabelsCache(tab) { // const query = JSON.stringify({ // query: `query GetLabels { // labels { // ... on LabelsSuccess { // labels { // ...LabelFields // } // } // ... on LabelsError { // errorCodes // } // } // } // fragment LabelFields on Label { // id // name // color // description // createdAt // } // `, // }) // return fetch(omnivoreGraphqlURL + 'graphql', { // method: 'POST', // redirect: 'follow', // credentials: 'include', // mode: 'cors', // headers: { // Accept: 'application/json', // 'Content-Type': 'application/json', // }, // body: query, // }) // .then((response) => response.json()) // .then((data) => { // const result = data.data.labels.labels // return result // }) // .then((labels) => { // setStorage({ // labels: labels, // labelsLastUpdated: new Date().toISOString(), // }) // return labels // }) // .then((labels) => { // browserApi.tabs.sendMessage(tab.id, { // action: ACTIONS.LabelCacheUpdated, // payload: {}, // }) // }) // .catch((err) => { // console.error('error fetching labels', err, omnivoreGraphqlURL) // return undefined // }) // } 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) } if (request.action === ACTIONS.EditTitle) { updatePageTitle( omnivoreGraphqlURL + 'graphql', request.payload.pageId, request.payload.title ).then(() => { browserApi.tabs.sendMessage(sender.tab.id, { action: ACTIONS.UpdateStatus, payload: { target: 'title', status: 'success', }, }) }) } if (request.action === ACTIONS.SetLabels) { setLabels( omnivoreGraphqlURL + 'graphql', request.payload.pageId, request.payload.labelIds ).then(() => { browserApi.tabs.sendMessage(sender.tab.id, { action: ACTIONS.UpdateStatus, payload: { target: 'labels', status: 'success', }, }) }) } }) // 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()