Forráskód Böngészése

added option upload saved pages to Google Drive (cf #220)

Former-commit-id: 2aa88c06544cfb094fd180cf6bf2361ef7134250
Gildas 6 éve
szülő
commit
ff6a2ecf86

+ 4 - 0
_locales/de/messages.json

@@ -371,6 +371,10 @@
 		"message": "In die Zwischenablage speichern",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "In Google Drive speichern",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "Hilfe",
 		"description": "Options help link"

+ 4 - 0
_locales/en/messages.json

@@ -371,6 +371,10 @@
 		"message": "save to clipboard",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "save to Google Drive",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "help",
 		"description": "Options help link"

+ 4 - 0
_locales/es/messages.json

@@ -371,6 +371,10 @@
 		"message": "guardar en el portapapeles",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "save en Google Drive",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "ayuda",
 		"description": "Options help link"

+ 5 - 1
_locales/fr/messages.json

@@ -368,9 +368,13 @@
 		"description": "Options page label: 'save raw page'"
 	},
 	"optionSaveToClipboard": {
-		"message": "enregistrer dans le presse-papiers",
+		"message": "sauvegarder dans le presse-papiers",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "sauvegarder dans Google Drive",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "aide (anglais)",
 		"description": "Options help link"

+ 4 - 0
_locales/ja/messages.json

@@ -371,6 +371,10 @@
 		"message": "クリップボードに保存する",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "save to Google Drive",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "ヘルプ",
 		"description": "Options help link"

+ 4 - 0
_locales/pl/messages.json

@@ -371,6 +371,10 @@
 		"message": "zapisuj do schowka",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "save to Google Drive",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "pomoc (w języku angielskim)",
 		"description": "Options help link"

+ 4 - 0
_locales/ru/messages.json

@@ -371,6 +371,10 @@
 		"message": "сохранить в буфер обмена",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "save to Google Drive",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "помощь",
 		"description": "Options help link"

+ 4 - 0
_locales/uk/messages.json

@@ -371,6 +371,10 @@
 		"message": "зберегти в буфер обміну",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "save to Google Drive",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "допомога",
 		"description": "Options help link"

+ 4 - 0
_locales/zh_CN/messages.json

@@ -371,6 +371,10 @@
 		"message": "保存到剪切板",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "save to Google Drive",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "帮助",
 		"description": "选项页帮助链接"

+ 4 - 0
_locales/zh_TW/messages.json

@@ -371,6 +371,10 @@
 		"message": "保存到剪切板",
 		"description": "Options page label: 'save to clipboard'"
 	},
+	"optionSaveToGDrive": {
+		"message": "save to Google Drive",
+		"description": "Options page label: 'save to Google Drive'"
+	},
 	"optionsHelpLink": {
 		"message": "幫助",
 		"description": "選項頁幫助鏈接"

+ 11 - 3
build-extension.sh

@@ -1,8 +1,16 @@
 #!/bin/sh
 rm singlefile-extension-firefox.zip singlefile-extension-chromium.zip
 cp manifest.json manifest.copy.json
-jq "del(.options_page,.background.persistent)" manifest.copy.json > manifest.json
+cp extension/core/bg/downloads.js downloads.copy.js
+sed -i 's/207618107333-bktohpfmdfnv5hfavi1ll18h74gqi27v/207618107333-8fpm0a5h0lho1svrhdj21sbri3via774/g' extension/core/bg/downloads.js
+
+jq "del(.options_page,.background.persistent,.optional_permissions[0],.oauth2)" manifest.copy.json > manifest.json
+sed -i 's/207618107333-bktohpfmdfnv5hfavi1ll18h74gqi27v/207618107333-8fpm0a5h0lho1svrhdj21sbri3via774/g' manifest.json
 zip -r singlefile-extension-firefox.zip manifest.json common extension lib _locales
-jq "del(.applications,.permissions[0],.options_ui.browser_style)" manifest.copy.json > manifest.json
+
+jq "del(.applications,.permissions[0],.permissions[1],.permissions[2],.applications,.options_ui.browser_style)" manifest.copy.json > manifest.json
+sed -i 's/207618107333-bktohpfmdfnv5hfavi1ll18h74gqi27v/207618107333-8fpm0a5h0lho1svrhdj21sbri3via774/g' manifest.json
 zip -r singlefile-extension-chromium.zip manifest.json common extension lib _locales
-mv manifest.copy.json manifest.json
+
+mv manifest.copy.json manifest.json
+mv downloads.copy.js extension/core/bg/downloads.js

+ 12 - 1
extension/core/bg/config.js

@@ -78,6 +78,7 @@ singlefile.extension.core.bg.config = (() => {
 		groupDuplicateImages: true,
 		saveRawPage: false,
 		saveToClipboard: false,
+		saveToGDrive: false,
 		resolveFragmentIdentifierURLs: false,
 		userScriptEnabled: false,
 		openEditor: false
@@ -94,7 +95,9 @@ singlefile.extension.core.bg.config = (() => {
 		getProfiles,
 		onMessage,
 		updateRule,
-		addRule
+		addRule,
+		getAuthInfo,
+		setAuthInfo
 	};
 
 	async function upgrade() {
@@ -354,6 +357,14 @@ singlefile.extension.core.bg.config = (() => {
 		await browser.storage.local.set({ rules: config.rules });
 	}
 
+	async function getAuthInfo() {
+		return (await browser.storage.local.get(["authInfo"])).authInfo;
+	}
+
+	async function setAuthInfo(authInfo) {
+		await browser.storage.local.set({ authInfo });
+	}
+
 	async function resetProfiles() {
 		await pendingUpgradePromise;
 		const tabsData = await singlefile.extension.core.bg.tabsData.get();

+ 93 - 9
extension/core/bg/downloads.js

@@ -21,7 +21,7 @@
  *   Source.
  */
 
-/* global browser, singlefile, Blob, URL, document */
+/* global browser, singlefile, Blob, URL, document, GDrive */
 
 singlefile.extension.core.bg.downloads = (() => {
 
@@ -36,7 +36,21 @@ singlefile.extension.core.bg.downloads = (() => {
 	const ERROR_INCOGNITO_GECKO_ALT = "\"incognito\"";
 	const ERROR_INVALID_FILENAME_GECKO = "illegal characters";
 	const ERROR_INVALID_FILENAME_CHROMIUM = "invalid filename";
+	const CLIENT_ID = "207618107333-bktohpfmdfnv5hfavi1ll18h74gqi27v.apps.googleusercontent.com";
+	const SCOPES = ["https://www.googleapis.com/auth/drive.file"];
 
+	const manifest = browser.runtime.getManifest();
+	const gDrive = new GDrive(CLIENT_ID, SCOPES);
+
+	if (browser.alarms) {
+		browser.alarms.onAlarm.addListener(async alarmInfo => {
+			if (alarmInfo.name == "refreshAuthToken") {
+				const authInfo = await gDrive.refreshAuthToken();
+				await singlefile.extension.core.bg.config.setAuthInfo(authInfo);
+				browser.alarms.create("refreshAuthToken", { when: Date.now() + Math.max(60000, authInfo.expirationDate - 60000) });
+			}
+		});
+	}
 	return {
 		onMessage,
 		download,
@@ -61,9 +75,11 @@ singlefile.extension.core.bg.downloads = (() => {
 			}
 			if (!message.truncated || message.finished) {
 				if (message.openEditor) {
+					singlefile.extension.ui.bg.main.onEnd(sender.tab.id);
 					await singlefile.extension.core.bg.editor.open({ filename: message.filename, content: contents.join("") }, {
 						backgroundSave: message.backgroundSave,
 						saveToClipboard: message.saveToClipboard,
+						saveToGDrive: message.saveToGDrive,
 						confirmFilename: message.confirmFilename,
 						incognito: sender.tab.incognito,
 						filenameConflictAction: message.filenameConflictAction,
@@ -75,25 +91,45 @@ singlefile.extension.core.bg.downloads = (() => {
 						message.content = contents.join("");
 						saveToClipboard(message);
 					} else {
-						message.url = URL.createObjectURL(new Blob([contents], { type: MIMETYPE_HTML }));
+						const blob = new Blob([contents], { type: MIMETYPE_HTML });
 						try {
-							await downloadPage(message, {
-								confirmFilename: message.confirmFilename,
-								incognito: sender.tab.incognito,
-								filenameConflictAction: message.filenameConflictAction,
-								filenameReplacementCharacter: message.filenameReplacementCharacter
-							});
+							if (message.saveToGDrive) {
+								await uploadPage(message.filename, blob, sender.tab.id);
+							} else {
+								message.url = URL.createObjectURL(blob);
+								await downloadPage(message, {
+									confirmFilename: message.confirmFilename,
+									incognito: sender.tab.incognito,
+									filenameConflictAction: message.filenameConflictAction,
+									filenameReplacementCharacter: message.filenameReplacementCharacter
+								});
+							}
+							singlefile.extension.ui.bg.main.onEnd(sender.tab.id);
 						} catch (error) {
 							console.error(error); // eslint-disable-line no-console
 							singlefile.extension.ui.bg.main.onError(sender.tab.id);
 						} finally {
-							URL.revokeObjectURL(message.url);
+							if (message.url) {
+								URL.revokeObjectURL(message.url);
+							}
 						}
 					}
 				}
 			}
 			return {};
 		}
+		if (message.method.endsWith(".enableGDrive")) {
+			if (!manifest.permissions || !manifest.permissions.includes("identity")) {
+				await browser.permissions.request({ permissions: ["identity"] });
+			}
+			return {};
+		}
+		if (message.method.endsWith(".disableGDrive")) {
+			const authInfo = await singlefile.extension.core.bg.config.getAuthInfo();
+			await gDrive.revokeAuthToken(authInfo.accessToken);
+			singlefile.extension.core.bg.config.setAuthInfo({});
+			return {};
+		}
 		if (message.method.endsWith(".end")) {
 			if (message.autoClose) {
 				singlefile.extension.core.bg.tabs.remove(sender.tab.id);
@@ -102,6 +138,54 @@ singlefile.extension.core.bg.downloads = (() => {
 		}
 	}
 
+	async function getAuthInfo(force) {
+		let code, cancelled, authInfo = await singlefile.extension.core.bg.config.getAuthInfo();
+		const options = { interactive: true, auto: true };
+		gDrive.setAuthInfo(authInfo);
+		if (force || gDrive.managedToken()) {
+			try {
+				if (options.auto && !gDrive.managedToken()) {
+					singlefile.extension.core.bg.tabs.getAuthCode(gDrive.getAuthURL(options))
+						.then(authCode => code = authCode)
+						.catch(() => { cancelled = true; });
+				}
+				authInfo = await gDrive.auth(options);
+			} catch (error) {
+				if (!cancelled && error.message == "code_required") {
+					if (!code) {
+						code = await singlefile.extension.core.bg.tabs.promptValue("Please enter the access code for Google Drive");
+					}
+				}
+				if (code) {
+					options.code = code;
+					authInfo = await gDrive.auth(options);
+				} else {
+					throw error;
+				}
+			}
+			await singlefile.extension.core.bg.config.setAuthInfo(authInfo);
+		}
+		if (!gDrive.managedToken()) {
+			browser.alarms.create("refreshAuthToken", { when: Math.max(Date.now() + 60000, authInfo.expirationDate - 60000) });
+		}
+		return authInfo;
+	}
+
+	async function uploadPage(filename, blob, tabId) {
+		try {
+			await getAuthInfo();
+			await gDrive.upload(filename, blob);
+		}
+		catch (error) {
+			if (error.message == "invalid_token") {
+				await getAuthInfo(true);
+				await uploadPage(filename, blob, tabId);
+			} else {
+				throw error;
+			}
+		}
+	}
+
 	async function downloadPage(pageData, options) {
 		const downloadInfo = {
 			url: pageData.url,

+ 41 - 2
extension/core/bg/tabs.js

@@ -24,6 +24,8 @@
 
 singlefile.extension.core.bg.tabs = (() => {
 
+	const pendingPrompts = new Map();
+
 	browser.tabs.onCreated.addListener(tab => onTabCreated(tab));
 	browser.tabs.onActivated.addListener(activeInfo => onTabActivated(activeInfo));
 	browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => onTabUpdated(tabId, changeInfo, tab));
@@ -55,10 +57,47 @@ singlefile.extension.core.bg.tabs = (() => {
 			});
 		},
 		sendMessage: (tabId, message, options) => browser.tabs.sendMessage(tabId, message, options),
-		remove: tabId => browser.tabs.remove(tabId)
+		remove: tabId => browser.tabs.remove(tabId),
+		promptValue: async promptMessage => {
+			const tabs = await browser.tabs.query({ currentWindow: true, active: true });
+			return new Promise(async (resolve, reject) => {
+				const tabId = tabs[0].id;
+				pendingPrompts.set(tabId, { resolve, reject });
+				browser.tabs.sendMessage(tabId, { method: "common.promptValueRequest", promptMessage });
+			});
+		},
+		getAuthCode: authURL => {
+			return new Promise((resolve, reject) => {
+				let authTabId;
+				browser.tabs.onUpdated.addListener(onTabUpdated);
+				browser.tabs.onRemoved.addListener(onTabRemoved);
+
+				function onTabUpdated(tabId, changeInfo) {
+					if (changeInfo && changeInfo.url == authURL) {
+						authTabId = tabId;
+					}
+					if (authTabId == tabId && changeInfo && changeInfo.title && changeInfo.title.startsWith("Success code=")) {
+						browser.tabs.onUpdated.removeListener(onTabUpdated);
+						browser.tabs.onUpdated.removeListener(onTabRemoved);
+						resolve(changeInfo.title.substring(13, changeInfo.title.length - 49));
+					}
+				}
+
+				function onTabRemoved(tabId) {
+					if (tabId == authTabId) {
+						browser.tabs.onUpdated.removeListener(onTabUpdated);
+						browser.tabs.onUpdated.removeListener(onTabRemoved);
+						reject();
+					}
+				}
+			});
+		}
 	};
 
-	async function onMessage(message) {
+	async function onMessage(message, sender) {
+		if (message.method.endsWith(".promptValueResponse")) {
+			pendingPrompts.get(sender.tab.id).resolve(message.value);
+		}
 		if (message.method.endsWith(".getOptions")) {
 			return singlefile.extension.core.bg.config.getOptions(message.url);
 		}

+ 6 - 2
extension/core/content/content-bootstrap.js

@@ -21,7 +21,7 @@
  *   Source.
  */
 
-/* global browser, window, addEventListener, removeEventListener, document, location, setTimeout */
+/* global browser, window, addEventListener, removeEventListener, document, location, setTimeout, prompt */
 
 this.singlefile.extension.core.content.bootstrap = this.singlefile.extension.core.content.bootstrap || (async () => {
 
@@ -36,7 +36,7 @@ this.singlefile.extension.core.content.bootstrap = this.singlefile.extension.cor
 		autoSaveEnabled = message.autoSaveEnabled;
 		refresh();
 	});
-	browser.runtime.onMessage.addListener(message => { onMessage(message); });
+	browser.runtime.onMessage.addListener(message => onMessage(message));
 	browser.runtime.sendMessage({ method: "ui.processInit" });
 	addEventListener(PUSH_STATE_NOTIFICATION_EVENT_NAME, () => browser.runtime.sendMessage({ method: "ui.processInit" }));
 	return {};
@@ -59,10 +59,14 @@ this.singlefile.extension.core.content.bootstrap = this.singlefile.extension.cor
 			options = message.options;
 			autoSaveEnabled = message.autoSaveEnabled;
 			refresh();
+			return {};
 		}
 		if (message.method == "devtools.resourceCommitted") {
 			singlefile.extension.core.content.updatedResources[message.url] = { content: message.content, type: message.type, encoding: message.encoding };
 		}
+		if (message.method == "common.promptValueRequest") {
+			browser.runtime.sendMessage({ method: "tabs.promptValueResponse", value: prompt("SingleFile: " + message.promptMessage) });
+		}
 	}
 
 	async function autoSavePage() {

+ 3 - 1
extension/core/content/content-download.js

@@ -35,7 +35,7 @@ this.singlefile.extension.core.content.download = this.singlefile.extension.core
 		if (options.includeInfobar) {
 			await singlefile.common.ui.content.infobar.includeScript(pageData);
 		}
-		if (options.backgroundSave || options.openEditor) {
+		if (options.backgroundSave || options.openEditor || options.saveToGDrive) {
 			for (let blockIndex = 0; blockIndex * MAX_CONTENT_SIZE < pageData.content.length; blockIndex++) {
 				const message = {
 					method: "downloads.download",
@@ -43,6 +43,7 @@ this.singlefile.extension.core.content.download = this.singlefile.extension.core
 					filenameConflictAction: options.filenameConflictAction,
 					filename: pageData.filename,
 					saveToClipboard: options.saveToClipboard,
+					saveToGDrive: options.saveToGDrive,
 					filenameReplacementCharacter: options.filenameReplacementCharacter,
 					openEditor: options.openEditor,
 					compressHTML: options.compressHTMLEdit,
@@ -63,6 +64,7 @@ this.singlefile.extension.core.content.download = this.singlefile.extension.core
 			} else {
 				downloadPageForeground(pageData);
 			}
+			browser.runtime.sendMessage({ method: "ui.processEnd" });
 		}
 		await browser.runtime.sendMessage({ method: "downloads.end", autoClose: options.autoClose });
 	}

+ 2 - 4
extension/core/content/content-main.js

@@ -78,8 +78,8 @@ this.singlefile.extension.core.content.main = this.singlefile.extension.core.con
 				try {
 					const pageData = await processPage(options);
 					if (pageData) {
-						if (!options.backgroundSave && !options.saveToClipboard && options.confirmFilename) {
-							pageData.filename = ui.prompt("File name", pageData.filename);
+						if (((!options.backgroundSave && !options.saveToClipboard) || options.saveToGDrive) && options.confirmFilename) {
+							pageData.filename = ui.prompt("Save as", pageData.filename) || pageData.filename;
 						}
 						await singlefile.extension.core.content.download.downloadPage(pageData, options);
 					}
@@ -142,8 +142,6 @@ this.singlefile.extension.core.content.main = this.singlefile.extension.core.con
 					}
 					browser.runtime.sendMessage({ method: "ui.processProgress", index, maxIndex });
 					ui.onLoadResource(index, maxIndex, options);
-				} if (event.type == event.PAGE_ENDED) {
-					browser.runtime.sendMessage({ method: "ui.processEnd" });
 				} else if (!event.detail.frame) {
 					if (event.type == event.PAGE_LOADING) {
 						ui.onPageLoading();

+ 53 - 0
extension/lib/single-file/browser-polyfill/chrome-browser-polyfill.js

@@ -28,6 +28,12 @@
 	if (!this.browser && this.chrome) {
 		const nativeAPI = this.chrome;
 		this.__defineGetter__("browser", () => ({
+			alarms: nativeAPI.alarms && {
+				create: (name, alarmInfo) => nativeAPI.alarms.create(name, alarmInfo),
+				onAlarm: {
+					addListener: listener => nativeAPI.alarms.onAlarm.addListener(listener)
+				}
+			},
 			browserAction: {
 				onClicked: {
 					addListener: listener => nativeAPI.browserAction.onClicked.addListener(listener)
@@ -148,6 +154,41 @@
 			i18n: {
 				getMessage: (messageName, substitutions) => nativeAPI.i18n.getMessage(messageName, substitutions)
 			},
+			identity: {
+				get getAuthToken() {
+					return nativeAPI.identity && nativeAPI.identity.getAuthToken && (details => new Promise((resolve, reject) =>
+						nativeAPI.identity.getAuthToken(details, token => {
+							if (nativeAPI.runtime.lastError) {
+								reject(nativeAPI.runtime.lastError);
+							} else {
+								resolve(token);
+							}
+						})
+					));
+				},
+				get launchWebAuthFlow() {
+					return nativeAPI.identity && nativeAPI.identity.launchWebAuthFlow && (options => new Promise((resolve, reject) => {
+						nativeAPI.identity.launchWebAuthFlow(options, responseUrl => {
+							if (nativeAPI.runtime.lastError) {
+								reject(nativeAPI.runtime.lastError);
+							} else {
+								resolve(responseUrl);
+							}
+						});
+					}));
+				},
+				get removeCachedAuthToken() {
+					return nativeAPI.identity && nativeAPI.identity.removeCachedAuthToken && (details => new Promise((resolve, reject) =>
+						nativeAPI.identity.removeCachedAuthToken(details, () => {
+							if (nativeAPI.runtime.lastError) {
+								reject(nativeAPI.runtime.lastError);
+							} else {
+								resolve();
+							}
+						})
+					));
+				}
+			},
 			menus: {
 				onClicked: {
 					addListener: listener => nativeAPI.contextMenus.onClicked.addListener(listener)
@@ -172,7 +213,19 @@
 					});
 				})
 			},
+			permissions: {
+				request: permissions => new Promise((resolve, reject) => {
+					nativeAPI.permissions.request(permissions, () => {
+						if (nativeAPI.runtime.lastError) {
+							reject(nativeAPI.runtime.lastError);
+						} else {
+							resolve();
+						}
+					});
+				})
+			},
 			runtime: {
+				getManifest: () => nativeAPI.runtime.getManifest(),
 				onMessage: {
 					addListener: listener => nativeAPI.runtime.onMessage.addListener((message, sender, sendResponse) => {
 						const response = listener(message, sender);

+ 5 - 1
extension/ui/bg/ui-editor.js

@@ -21,7 +21,7 @@
  *   Source.
  */
 
-/* global browser, singlefile, window, document */
+/* global browser, singlefile, window, document, prompt */
 
 singlefile.extension.ui.bg.editor = (() => {
 
@@ -192,6 +192,10 @@ singlefile.extension.ui.bg.editor = (() => {
 			browser.runtime.sendMessage({ method: "ui.processInit" });
 			return {};
 		}
+		if (message.method == "common.promptValueRequest") {
+			browser.runtime.sendMessage({ method: "tabs.promptValueResponse", value: prompt(message.promptMessage) });
+			return {};
+		}
 	});
 
 	function savePage() {

+ 17 - 2
extension/ui/bg/ui-options.js

@@ -36,6 +36,7 @@
 	const removeScriptsLabel = document.getElementById("removeScriptsLabel");
 	const saveRawPageLabel = document.getElementById("saveRawPageLabel");
 	const saveToClipboardLabel = document.getElementById("saveToClipboardLabel");
+	const saveToGDriveLabel = document.getElementById("saveToGDriveLabel");
 	const compressHTMLLabel = document.getElementById("compressHTMLLabel");
 	const compressCSSLabel = document.getElementById("compressCSSLabel");
 	const loadDeferredImagesLabel = document.getElementById("loadDeferredImagesLabel");
@@ -103,6 +104,7 @@
 	const removeScriptsInput = document.getElementById("removeScriptsInput");
 	const saveRawPageInput = document.getElementById("saveRawPageInput");
 	const saveToClipboardInput = document.getElementById("saveToClipboardInput");
+	const saveToGDriveInput = document.getElementById("saveToGDriveInput");
 	const compressHTMLInput = document.getElementById("compressHTMLInput");
 	const compressCSSInput = document.getElementById("compressCSSInput");
 	const loadDeferredImagesInput = document.getElementById("loadDeferredImagesInput");
@@ -342,6 +344,13 @@
 			removeUnusedStylesInput.checked = false;
 		}
 	}, false);
+	saveToGDriveInput.addEventListener("click", async () => {
+		if (saveToGDriveInput.checked) {
+			await browser.runtime.sendMessage({ method: "downloads.enableGDrive" });
+		} else {
+			await browser.runtime.sendMessage({ method: "downloads.disableGDrive" });
+		}
+	}, false);
 	document.body.onchange = async event => {
 		let target = event.target;
 		if (target != ruleUrlInput && target != ruleProfileInput && target != ruleAutoSaveProfileInput && target != ruleEditUrlInput && target != ruleEditProfileInput && target != ruleEditAutoSaveProfileInput && target != showAutoSaveProfileInput) {
@@ -378,6 +387,7 @@
 	removeScriptsLabel.textContent = browser.i18n.getMessage("optionRemoveScripts");
 	saveRawPageLabel.textContent = browser.i18n.getMessage("optionSaveRawPage");
 	saveToClipboardLabel.textContent = browser.i18n.getMessage("optionSaveToClipboard");
+	saveToGDriveLabel.textContent = browser.i18n.getMessage("optionSaveToGDrive");
 	compressHTMLLabel.textContent = browser.i18n.getMessage("optionCompressHTML");
 	compressCSSLabel.textContent = browser.i18n.getMessage("optionCompressCSS");
 	loadDeferredImagesLabel.textContent = browser.i18n.getMessage("optionLoadDeferredImages");
@@ -554,7 +564,10 @@
 		removeImportsInput.checked = profileOptions.removeImports;
 		removeScriptsInput.checked = profileOptions.removeScripts;
 		saveRawPageInput.checked = profileOptions.saveRawPage;
-		saveToClipboardInput.checked = profileOptions.saveToClipboard;
+		saveToClipboardInput.checked = profileOptions.saveToClipboard && !profileOptions.saveToGDrive;
+		saveToClipboardInput.disabled = profileOptions.saveToGDrive;
+		saveToGDriveInput.checked = profileOptions.saveToGDrive && !profileOptions.saveToClipboard;
+		saveToGDriveInput.disabled = profileOptions.saveToClipboard;
 		compressHTMLInput.checked = profileOptions.compressHTML;
 		compressCSSInput.checked = profileOptions.compressCSS;
 		loadDeferredImagesInput.checked = profileOptions.loadDeferredImages && !profileOptions.saveRawPage;
@@ -571,12 +584,13 @@
 		confirmFilenameInput.checked = profileOptions.confirmFilename;
 		confirmFilenameInput.disabled = profileOptions.saveToClipboard;
 		filenameConflictActionInput.value = profileOptions.filenameConflictAction;
-		filenameConflictActionInput.disabled = profileOptions.saveToClipboard;
+		filenameConflictActionInput.disabled = profileOptions.saveToClipboard || profileOptions.saveToGDrive;
 		removeAudioSrcInput.checked = profileOptions.removeAudioSrc;
 		removeVideoSrcInput.checked = profileOptions.removeVideoSrc;
 		displayInfobarInput.checked = profileOptions.displayInfobar;
 		displayStatsInput.checked = profileOptions.displayStats;
 		backgroundSaveInput.checked = profileOptions.backgroundSave;
+		backgroundSaveInput.disabled = profileOptions.saveToGDrive;
 		autoSaveDelayInput.value = profileOptions.autoSaveDelay;
 		autoSaveDelayInput.disabled = !profileOptions.autoSaveLoadOrUnload && !profileOptions.autoSaveLoad;
 		autoSaveLoadInput.checked = !profileOptions.autoSaveLoadOrUnload && profileOptions.autoSaveLoad;
@@ -623,6 +637,7 @@
 				removeScripts: removeScriptsInput.checked,
 				saveRawPage: saveRawPageInput.checked,
 				saveToClipboard: saveToClipboardInput.checked,
+				saveToGDrive: saveToGDriveInput.checked,
 				compressHTML: compressHTMLInput.checked,
 				compressCSS: compressCSSInput.checked,
 				loadDeferredImages: loadDeferredImagesInput.checked,

+ 10 - 2
extension/ui/pages/help.html

@@ -360,8 +360,16 @@
 						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
 					</li>
 					<li data-options-label="saveToClipboardLabel"> <span class="option">Option: save to clipboard</span>
-						<p>Check this option to copy the page to the clipboard instead of downloading it. Checking this
-							option will force the "file name" options to be disabled.</p>
+						<p>Check this option to copy the page to the clipboard instead of downloading it on your
+							computer. Checking this option will force the "file name" options to be disabled.</p>
+						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
+					</li>
+					<li data-options-label="saveToGDriveLabel"> <span class="option">Option: save to Google Drive</span>
+						<p>Check this option to save the page on Google Drive instead of downloading it on your
+							computer. Checking this option will force some "file name" options to be disabled.
+							However, you can change the value of the "filename template" option to save files into
+							sub-folders.
+						</p>
 						<p class="notice">It is recommended to <u>uncheck</u> this option</p>
 					</li>
 				</ul>

+ 4 - 0
extension/ui/pages/options.html

@@ -210,6 +210,10 @@
 				<label for="saveToClipboardInput" id="saveToClipboardLabel"></label>
 				<input type="checkbox" id="saveToClipboardInput">
 			</div>
+			<div class="option">
+				<label for="saveToGDriveInput" id="saveToGDriveLabel"></label>
+				<input type="checkbox" id="saveToGDriveInput">
+			</div>
 		</details>
 		<details>
 			<summary id="autoSettingsLabel"></summary>

+ 334 - 0
lib/gdrive/gdrive.js

@@ -0,0 +1,334 @@
+/*
+ * Copyright 2010-2019 Gildas Lormeau
+ * contact : gildas.lormeau <at> gmail.com
+ * 
+ * This file is part of SingleFile.
+ *
+ *   The code in this file is free software: you can redistribute it and/or 
+ *   modify it under the terms of the GNU Affero General Public License 
+ *   (GNU AGPL) as published by the Free Software Foundation, either version 3
+ *   of the License, or (at your option) any later version.
+ * 
+ *   The code in this file is distributed in the hope that it will be useful, 
+ *   but WITHOUT ANY WARRANTY; without even the implied warranty of 
+ *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero 
+ *   General Public License for more details.
+ *
+ *   As additional permission under GNU AGPL version 3 section 7, you may 
+ *   distribute UNMODIFIED VERSIONS OF THIS file without the copy of the GNU 
+ *   AGPL normally required by section 4, provided you include this license 
+ *   notice and a URL through which recipients can access the Corresponding 
+ *   Source.
+ */
+
+/* global browser, fetch, setInterval */
+
+this.GDrive = this.GDrive || (() => {
+
+	"use strict";
+
+	const TOKEN_URL = "https://oauth2.googleapis.com/token";
+	const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
+	const REVOKE_ACCESS_URL = "https://accounts.google.com/o/oauth2/revoke";
+	const GDRIVE_URL = "https://www.googleapis.com/drive/v3/files";
+	const GDRIVE_UPLOAD_URL = "https://www.googleapis.com/upload/drive/v3/files";
+
+	class GDrive {
+		constructor(clientId, scopes) {
+			this.clientId = clientId;
+			this.scopes = scopes;
+			this.folderIds = new Map();
+			setInterval(() => this.folderIds.clear(), 60 * 1000);
+		}
+		async auth(options = {}) {
+			try {
+				await browser.permissions.request({ permissions: ["identity"] });
+			}
+			catch (error) {
+				// ignored;
+			}
+			if (this.managedToken()) {
+				const token = await browser.identity.getAuthToken({ interactive: options.interactive });
+				if (token) {
+					this.accessToken = token;
+					return { accessToken: this.accessToken };
+				}
+			} else {
+				this.getAuthURL(options);
+				return options.code ? authFromCode(this, options) : initAuth(this, options);
+			}
+		}
+		managedToken() {
+			return Boolean(browser.identity.getAuthToken);
+		}
+		setAuthInfo(authInfo) {
+			if (!browser.identity.getAuthToken) {
+				this.accessToken = authInfo.accessToken;
+				this.refreshToken = authInfo.refreshToken;
+				this.expirationDate = authInfo.expirationDate;
+			}
+		}
+		getAuthURL(options = {}) {
+			this.redirectURI = encodeURIComponent("urn:ietf:wg:oauth:2.0:oob" + (options.auto ? ":auto" : ""));
+			this.authURL = AUTH_URL +
+				"?client_id=" + this.clientId +
+				"&response_type=code" +
+				"&access_type=offline" +
+				"&redirect_uri=" + this.redirectURI +
+				"&scope=" + this.scopes.join(" ");
+			return this.authURL;
+		}
+		async refreshAuthToken() {
+			if (this.clientId && this.refreshToken) {
+				const httpResponse = await fetch(TOKEN_URL, {
+					method: "POST",
+					headers: { "Content-Type": "application/x-www-form-urlencoded" },
+					body: "client_id=" + this.clientId +
+						"&refresh_token=" + this.refreshToken +
+						"&grant_type=refresh_token"
+				});
+				const response = await getJSON(httpResponse);
+				this.accessToken = response.access_token;
+				this.refreshToken = response.refresh_token;
+				this.expirationDate = Date.now() + (response.expires_in * 1000);
+				return { accessToken: this.accessToken, refreshToken: this.refreshToken, expirationDate: this.expirationDate };
+			}
+		}
+		async revokeAuthToken(accessToken) {
+			if (accessToken) {
+				if (this.managedToken()) {
+					await browser.identity.removeCachedAuthToken({ token: accessToken });
+				}
+				const httpResponse = await fetch(REVOKE_ACCESS_URL, {
+					method: "POST",
+					headers: { "Content-Type": "application/x-www-form-urlencoded" },
+					body: "token=" + accessToken
+				});
+				try {
+					await getJSON(httpResponse);
+				}
+				catch (error) {
+					if (error.message != "invalid_token") {
+						throw error;
+					}
+				}
+				finally {
+					delete this.accessToken;
+					delete this.refreshToken;
+					delete this.expirationDate;
+				}
+			}
+		}
+		async upload(fullFilename, blob, retry = true) {
+			const parentFolderId = await getParentFolderId(this, fullFilename);
+			const fileParts = fullFilename.split("/");
+			const filename = fileParts.pop();
+			const uploader = new MediaUploader({
+				token: this.accessToken,
+				file: blob,
+				parents: [parentFolderId],
+				filename
+			});
+			try {
+				return await uploader.upload();
+			}
+			catch (error) {
+				if (error.message == "path_not_found" && retry) {
+					this.folderIds.clear();
+					return this.upload(fullFilename, blob, false);
+				} else {
+					throw error;
+				}
+			}
+		}
+	}
+
+	class MediaUploader {
+		constructor(options) {
+			this.file = options.file;
+			this.contentType = this.file.type || "application/octet-stream";
+			this.metadata = {
+				name: options.filename,
+				mimeType: this.contentType,
+				parents: options.parents || ["root"]
+			};
+			this.token = options.token;
+			this.offset = 0;
+			this.chunkSize = options.chunkSize || 5 * 1024 * 1024;
+		}
+		async upload() {
+			const httpResponse = getResponse(await fetch(GDRIVE_UPLOAD_URL + "?uploadType=resumable", {
+				method: "POST",
+				headers: {
+					"Authorization": "Bearer " + this.token,
+					"Content-Type": "application/json",
+					"X-Upload-Content-Length": this.file.size,
+					"X-Upload-Content-Type": this.contentType
+				},
+				body: JSON.stringify(this.metadata)
+			}));
+			const location = httpResponse.headers.get("Location");
+			this.url = location;
+			return sendFile(this);
+		}
+	}
+
+	return GDrive;
+
+	async function authFromCode(gdrive, options) {
+		const httpResponse = await fetch(TOKEN_URL, {
+			method: "POST",
+			headers: { "Content-Type": "application/x-www-form-urlencoded" },
+			body: "client_id=" + gdrive.clientId +
+				"&grant_type=authorization_code" +
+				"&code=" + options.code +
+				"&redirect_uri=" + gdrive.redirectURI
+		});
+		const response = await getJSON(httpResponse);
+		gdrive.accessToken = response.access_token;
+		gdrive.refreshToken = response.refresh_token;
+		gdrive.expirationDate = Date.now() + (response.expires_in * 1000);
+		return { accessToken: gdrive.accessToken, refreshToken: gdrive.refreshToken, expirationDate: gdrive.expirationDate };
+	}
+
+	async function initAuth(gdrive, options) {
+		try {
+			return browser.identity.launchWebAuthFlow({
+				interactive: options.interactive,
+				url: gdrive.authURL
+			});
+		}
+		catch (error) {
+			if (error.message && error.message.includes("access")) {
+				throw new Error("code_required");
+			} else {
+				throw error;
+			}
+		}
+	}
+
+	async function getParentFolderId(gdrive, filename, retry = true) {
+		const fileParts = filename.split("/");
+		fileParts.pop();
+		const folderId = gdrive.folderIds.get(fileParts.join("/"));
+		if (folderId) {
+			return folderId;
+		}
+		let parentFolderId = "root";
+		if (fileParts.length) {
+			let fullFolderName = "";
+			for (const folderName of fileParts) {
+				if (fullFolderName) {
+					fullFolderName += "/";
+				}
+				fullFolderName += folderName;
+				const folderId = gdrive.folderIds.get(fullFolderName);
+				if (folderId) {
+					parentFolderId = folderId;
+				} else {
+					try {
+						parentFolderId = await getOrCreateFolder(gdrive, folderName, parentFolderId);
+						gdrive.folderIds.set(fullFolderName, parentFolderId);
+					} catch (error) {
+						if (error.message == "path_not_found" && retry) {
+							gdrive.folderIds.clear();
+							return getParentFolderId(gdrive, filename, false);
+						} else {
+							throw error;
+						}
+					}
+				}
+			}
+		}
+		return parentFolderId;
+	}
+
+	async function getOrCreateFolder(gdrive, folderName, parentFolderId) {
+		const response = await getFolder(gdrive, folderName, parentFolderId);
+		if (response.files.length) {
+			return response.files[0].id;
+		} else {
+			const response = await createFolder(gdrive, folderName, parentFolderId);
+			return response.id;
+		}
+	}
+
+	async function getFolder(gdrive, folderName, parentFolderId) {
+		const httpResponse = await fetch(GDRIVE_URL + "?q=mimeType = 'application/vnd.google-apps.folder' and name = '" + folderName + "' and trashed != true and '" + parentFolderId + "' in parents", {
+			headers: {
+				"Authorization": "Bearer " + gdrive.accessToken
+			}
+		});
+		return getJSON(httpResponse);
+	}
+
+	async function createFolder(gdrive, folderName, parentFolderId) {
+		const httpResponse = await fetch(GDRIVE_URL, {
+			method: "POST",
+			headers: {
+				"Authorization": "Bearer " + gdrive.accessToken,
+				"Content-Type": "application/json"
+			},
+			body: JSON.stringify({
+				name: folderName,
+				parents: [parentFolderId],
+				mimeType: "application/vnd.google-apps.folder"
+			})
+		});
+		return getJSON(httpResponse);
+	}
+
+	async function sendFile(mediaUploader) {
+		let content = mediaUploader.file, end = mediaUploader.file.size;
+		if (mediaUploader.offset || mediaUploader.chunkSize) {
+			if (mediaUploader.chunkSize) {
+				end = Math.min(mediaUploader.offset + mediaUploader.chunkSize, mediaUploader.file.size);
+			}
+			content = content.slice(mediaUploader.offset, end);
+		}
+		const httpResponse = await fetch(mediaUploader.url, {
+			method: "PUT",
+			headers: {
+				"Authorization": "Bearer " + mediaUploader.token,
+				"Content-Type": mediaUploader.contentType,
+				"Content-Range": "bytes " + mediaUploader.offset + "-" + (end - 1) + "/" + mediaUploader.file.size,
+				"X-Upload-Content-Type": mediaUploader.contentType
+			},
+			body: content
+		});
+		if (httpResponse.status == 200 || httpResponse.status == 201) {
+			return httpResponse.json();
+		} else if (httpResponse.status == 308) {
+			const range = httpResponse.headers.get("Range");
+			if (range) {
+				mediaUploader.offset = parseInt(range.match(/\d+/g).pop(), 10) + 1;
+			}
+			sendFile(mediaUploader);
+		} else {
+			getResponse(httpResponse);
+		}
+	}
+
+	async function getJSON(httpResponse) {
+		httpResponse = getResponse(httpResponse);
+		const response = await httpResponse.json();
+		if (response.error) {
+			throw new Error(response.error);
+		} else {
+			return response;
+		}
+	}
+
+	function getResponse(httpResponse) {
+		if (httpResponse.status == 200) {
+			return httpResponse;
+		} else if (httpResponse.status == 404) {
+			throw new Error("path_not_found");
+		} else if (httpResponse.status == 401) {
+			throw new Error("invalid_token");
+		} else {
+			throw new Error("unknown_error (" + httpResponse.status + ")");
+		}
+	}
+
+})();

+ 1 - 1
lib/single-file/single-file-core.js

@@ -479,7 +479,7 @@ this.singlefile.lib.core = this.singlefile.lib.core || (() => {
 			if (!this.options.backgroundSave) {
 				filename = filename.replace(/\//g, replacementCharacter);
 			}
-			if (util.getContentSize(filename) > this.options.filenameMaxLength) {
+			if (!this.options.saveToGDrive && util.getContentSize(filename) > this.options.filenameMaxLength) {
 				const extensionMatch = filename.match(/(\.[^.]{3,4})$/);
 				const extension = extensionMatch && extensionMatch[0] && extensionMatch[0].length > 1 ? extensionMatch[0] : "";
 				filename = await util.truncateText(filename, this.options.filenameMaxLength - extension.length);

+ 12 - 0
manifest.json

@@ -74,6 +74,7 @@
 			"lib/single-file/modules/html-images-alt-minifier.js",
 			"lib/single-file/single-file-core.js",
 			"lib/single-file/single-file.js",
+			"lib/gdrive/gdrive.js",
 			"common/index.js",
 			"common/ui/content/content-infobar.js",
 			"extension/lib/single-file/core/bg/scripts.js",
@@ -144,7 +145,15 @@
 		"extension/ui/editor/editor-mask-web.css",
 		"extension/ui/editor/editor-frame-web.css"
 	],
+	"oauth2": {
+		"client_id": "207618107333-bktohpfmdfnv5hfavi1ll18h74gqi27v.apps.googleusercontent.com",
+		"scopes": [
+			"https://www.googleapis.com/auth/drive.file"
+		]
+	},
 	"permissions": [
+		"identity",
+		"alarms",
 		"menus",
 		"clipboardWrite",
 		"contextMenus",
@@ -153,6 +162,9 @@
 		"tabs",
 		"<all_urls>"
 	],
+	"optional_permissions": [
+		"identity"
+	],
 	"applications": {
 		"gecko": {
 			"id": "{531906d3-e22f-4a6c-a102-8057b88a1a63}"