Files
omnivore/pkg/extension/src/scripts/background.js
Jackson Harper 44e85ea295 Merge
2023-09-30 12:48:57 +08:00

826 lines
21 KiB
JavaScript

/* global
ACTIONS
ENV_IS_FIREFOX
ENV_IS_EDGE
browserApi
browserActionApi
browserScriptingApi
fetch
XMLHttpRequest
*/
'use strict'
import { v4 as uuidv4 } from 'uuid'
import { nanoid } from 'nanoid'
class TaskQueue {
constructor() {
this.queue = []
this.isRunning = false
this.isReady = false
}
enqueue(task) {
this.queue.push(task)
// Only run the next task if the queue is ready
if (this.isReady) {
this.runNext()
}
}
async runNext() {
if (this.isRunning || this.queue.length === 0 || !this.isReady) return
this.isRunning = true
const task = this.queue.shift()
try {
await task()
} catch (err) {
console.error('Task failed:', err)
} finally {
this.isRunning = false
if (this.isReady) {
this.runNext()
}
}
}
setReady() {
this.isReady = true
this.runNext()
}
}
let authToken = undefined
const queue = new TaskQueue()
const omnivoreURL = process.env.OMNIVORE_URL
const omnivoreGraphqlURL = process.env.OMNIVORE_GRAPHQL_URL
let completedRequests = {}
function getCurrentTab() {
return new Promise((resolve) => {
browserApi.tabs.query(
{
active: true,
currentWindow: true,
},
function (tabs) {
resolve(tabs[0] || null)
}
)
})
}
function uploadFile({ id, uploadSignedUrl }, contentType, contentObjUrl) {
return fetch(contentObjUrl)
.then((r) => r.blob())
.then((blob) => {
return fetch(uploadSignedUrl, {
method: 'PUT',
headers: {
'Content-Type': contentType,
},
body: blob,
})
})
.catch((err) => {
console.error('error uploading file', err)
return undefined
})
}
async function uploadFileRequest(url, contentType) {
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,
},
},
})
const field = 'uploadFileRequest'
const result = await gqlRequest(omnivoreGraphqlURL + 'graphql', data)
if (result[field]['errorCodes']) {
if (result[field]['errorCodes'][0] === 'UNAUTHORIZED') {
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.UpdateStatus,
payload: {
target: 'logged_out',
status: 'logged_out',
message: 'You are not logged in.',
ctx: toolbarCtx,
},
})
clearClickCompleteState()
} else {
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.UpdateStatus,
payload: {
status: 'failure',
message: 'Unable to save page.',
ctx: toolbarCtx,
},
})
}
return undefined
}
return result.uploadFileRequest
}
async function savePdfFile(
currentTab,
url,
requestId,
contentType,
contentObjUrl
) {
const toolbarCtx = {
omnivoreURL,
originalURL: url,
requestId: requestId,
}
completedRequests[toolbarCtx.requestId] = undefined
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.ShowToolbar,
payload: {
type: 'loading',
ctx: toolbarCtx,
},
})
const uploadRequestResult = await uploadFileRequest(url, contentType)
console.log('done uploading pdf', uploadRequestResult)
const uploadFileResult = await uploadFile(
uploadRequestResult,
contentType,
contentObjUrl
)
URL.revokeObjectURL(contentObjUrl)
if (uploadFileResult && uploadRequestResult.createdPageId) {
completedRequests[toolbarCtx.requestId] = {
requestId: toolbarCtx.requestId,
responseId: uploadRequestResult.createdPageId,
}
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.UpdateStatus,
payload: {
status: 'success',
target: 'page',
ctx: {
requestId: toolbarCtx.requestId,
responseId: uploadRequestResult.createdPageId,
},
},
})
}
return uploadFileResult
}
function clearClickCompleteState() {
getStorageItem('postInstallClickComplete').then(
(postInstallClickComplete) => {
if (postInstallClickComplete) {
removeStorage('postInstallClickComplete')
}
}
)
}
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) {
const toolbarCtx = {
omnivoreURL,
originalURL: input.url,
requestId: input.clientRequestId,
}
completedRequests[toolbarCtx.requestId] = undefined
const requestBody = JSON.stringify({
query,
variables: {
input,
},
})
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.ShowToolbar,
payload: {
type: 'loading',
ctx: toolbarCtx,
},
})
try {
const result = await gqlRequest(omnivoreGraphqlURL + 'graphql', requestBody)
if (result[field]['errorCodes']) {
if (result[field]['errorCodes'][0] === 'UNAUTHORIZED') {
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.UpdateStatus,
payload: {
target: 'logged_out',
status: 'logged_out',
message: 'You are not logged in.',
ctx: toolbarCtx,
},
})
clearClickCompleteState()
} else {
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.UpdateStatus,
payload: {
status: 'failure',
message: 'Unable to save page.',
ctx: toolbarCtx,
},
})
}
return
}
const url = result[field] ? result[field]['url'] : undefined
const requestId = result[field]
? result[field]['clientRequestId']
: undefined
browserApi.tabs.sendMessage(currentTab.id, {
action: ACTIONS.UpdateStatus,
payload: {
status: 'success',
target: 'page',
ctx: {
readerURL: url,
responseId: requestId,
requestId: toolbarCtx.requestId,
},
},
})
completedRequests[toolbarCtx.requestId] = {
readerURL: url,
responseId: requestId,
requestId: toolbarCtx.requestId,
}
} catch (err) {
console.log('error saving: ', err)
}
queue.setReady()
}
function updateClientStatus(tabId, target, status, message) {
browserApi.tabs.sendMessage(tabId, {
action: ACTIONS.UpdateStatus,
payload: {
target,
status,
message,
},
})
}
async function editTitleRequest(tabId, request, completedResponse) {
return updatePageTitle(
omnivoreGraphqlURL + 'graphql',
completedResponse.responseId,
request.title
)
.then(() => {
updateClientStatus(tabId, 'title', 'success', 'Title updated.')
return true
})
.catch((err) => {
console.log('caught error updating title: ', err)
updateClientStatus(tabId, 'title', 'failure', 'Error updating title.')
return true
})
}
async function addNoteRequest(tabId, request, completedResponse) {
const noteId = uuidv4()
const shortId = nanoid(8)
return addNote(
omnivoreGraphqlURL + 'graphql',
completedResponse.responseId,
noteId,
shortId,
request.note
)
.then(() => {
updateClientStatus(tabId, 'note', 'success', 'Note updated.')
return true
})
.catch((err) => {
console.log('caught error updating title: ', err)
updateClientStatus(tabId, 'note', 'failure', 'Error adding note.')
return true
})
}
async function setLabelsRequest(tabId, request, completedResponse) {
return setLabels(
omnivoreGraphqlURL + 'graphql',
completedResponse.responseId,
request.labels
)
.then(() => {
updateClientStatus(tabId, 'labels', 'success', 'Labels updated.')
return true
})
.then(() => {
browserApi.tabs.sendMessage(tabId, {
action: ACTIONS.LabelCacheUpdated,
payload: {},
})
})
.catch(() => {
updateClientStatus(tabId, 'labels', 'failure', 'Error updating labels.')
return true
})
}
async function archiveRequest(tabId, request, completedResponse) {
return archive(omnivoreGraphqlURL + 'graphql', completedResponse.responseId)
.then(() => {
updateClientStatus(tabId, 'extra', 'success', 'Archived')
return true
})
.catch(() => {
updateClientStatus(tabId, 'extra', 'failure', 'Error archiving')
return true
})
}
async function deleteRequest(tabId, request, completedResponse) {
return deleteItem(
omnivoreGraphqlURL + 'graphql',
completedResponse.responseId
)
.then(() => {
updateClientStatus(tabId, 'extra', 'success', 'Deleted')
return true
})
.catch(() => {
updateClientStatus(tabId, 'extra', 'failure', 'Error deleting')
return true
})
}
async function processEditTitleRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
handled = await editTitleRequest(tabId, pr, completed)
console.log('processEditTitleRequest: ', handled)
return handled
}
async function processAddNoteRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
const handled = await addNoteRequest(tabId, pr, completed)
console.log('processAddNoteRequest: ', handled)
return handled
}
async function processSetLabelsRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
const handled = await setLabelsRequest(tabId, pr, completed)
console.log('processSetLabelsRequest: ', handled)
return handled
}
async function processArchiveRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
const handled = await archiveRequest(tabId, pr, completed)
console.log('processArchiveRequest: ', handled)
return handled
}
async function processDeleteRequest(tabId, pr) {
const completed = completedRequests[pr.clientRequestId]
const handled = await deleteRequest(tabId, pr, completed)
console.log('processDeleteRequest: ', handled)
return handled
}
async function saveArticle(tab, createHighlight) {
browserApi.tabs.sendMessage(
tab.id,
{
action: ACTIONS.GetContent,
payload: {
createHighlight: createHighlight,
},
},
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),
requestId,
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 extensionSaveCurrentPage(tabId, createHighlight) {
createHighlight = createHighlight ? true : false
/* 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, createHighlight)
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, createHighlight)
})
},
(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)
}
)
})
}
function checkAuthOnFirstClickPostInstall(tabId) {
return Promise.resolve(true)
}
function handleActionClick() {
executeAction(function (currentTab) {
extensionSaveCurrentPage(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 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 init() {
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)
}
})
}
checkCurrentTab()
})
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.EditTitle) {
queue.enqueue(() =>
processEditTitleRequest(sender.tab.id, {
id: uuidv4(),
type: 'EDIT_TITLE',
tabId: sender.tab.id,
title: request.payload.title,
clientRequestId: request.payload.ctx.requestId,
})
)
}
if (request.action === ACTIONS.Archive) {
queue.enqueue(() =>
processArchiveRequest(sender.tab.id, {
id: uuidv4(),
type: 'ARCHIVE',
tabId: sender.tab.id,
clientRequestId: request.payload.ctx.requestId,
})
)
}
if (request.action === ACTIONS.Delete) {
queue.enqueue(() =>
processDeleteRequest(sender.tab.id, {
type: 'DELETE',
tabId: sender.tab.id,
clientRequestId: request.payload.ctx.requestId,
})
)
}
if (request.action === ACTIONS.AddNote) {
queue.enqueue(() =>
processAddNoteRequest(sender.tab.id, {
id: uuidv4(),
type: 'ADD_NOTE',
tabId: sender.tab.id,
note: request.payload.note,
clientRequestId: request.payload.ctx.requestId,
})
)
}
if (request.action === ACTIONS.SetLabels) {
queue.enqueue(() =>
processSetLabelsRequest(sender.tab.id, {
id: uuidv4(),
type: 'SET_LABELS',
tabId: sender.tab.id,
labels: request.payload.labels,
clientRequestId: request.payload.ctx.requestId,
})
)
}
})
browserApi.contextMenus.create({
id: 'save-link-selection',
title: 'Save this link to Omnivore',
contexts: ['link'],
onclick: async function (obj) {
executeAction(async function (currentTab) {
await saveUrl(currentTab, obj.linkUrl)
})
},
})
browserApi.contextMenus.create({
id: 'save-page-selection',
title: 'Save this page to Omnivore',
contexts: ['page'],
onclick: async function (obj) {
executeAction(function (currentTab) {
extensionSaveCurrentPage(currentTab.id)
})
},
})
browserApi.contextMenus.create({
id: 'save-text-selection',
title: 'Create Highlight and Save to Omnivore',
contexts: ['selection'],
onclick: async function (obj) {
executeAction(function (currentTab) {
extensionSaveCurrentPage(currentTab.id, true)
})
},
})
}
init()