diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ecec59f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +tests/pages/ +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 85411e2..17fec2e 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,17 @@ Save a web page/selection as an eBook (.epub format) - a Chrome/Firefox/Opera Web Extension -![alt ex1.png](https://github.com/alexadam/save-as-ebook/blob/master/ex1.png?raw=true) + ![alt ex2.png](https://github.com/alexadam/save-as-ebook/blob/master/ex2.png?raw=true) +![alt ex3.png](https://github.com/alexadam/save-as-ebook/blob/master/ex3.png?raw=true) + ## How to install it -### Chrome (tested on v. 52.0.2743.116) +### From [Chrome Web Store](https://chrome.google.com/webstore/detail/save-as-ebook/haaplkpoiimngbppjihnegfmpejdnffj) + +or manually (tested on v. 52.0.2743.116) ``` 1. Navigate to chrome://extensions/ @@ -16,7 +20,9 @@ Save a web page/selection as an eBook (.epub format) - a Chrome/Firefox/Opera We 3. Select the extension's directory ``` -### Firefox (tested on v. 50.0a2) +### From [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/saveasebook/) + +or manually (tested on v. 50.0a2) ``` 1. Navigate to about:debugging @@ -39,19 +45,91 @@ sudo apt-get install calibre ebook-convert "book.epub" "book.mobi" ``` +## Default Keyboard Shortcuts + +**NOTE** These shortcuts are not fixed and the browser will assign a different shortcut if the default one is taken + +| Shortcut | Description | +| --- | --- | +| Alt + Shift + 1 | Save current page as eBook | +| Alt + Shift + 2 | Save current selection as eBook | +| Alt + Shift + 3 | Add current page as chapter | +| Alt + Shift + 4 | Add current selection as chapter | + +## How to change the default Shortcuts + +in Chrome: + +``` +1. Navigate to chrome://extensions/ +2. Scroll down +3. Click on Keyboard shortcuts +``` + +## Added in 1.4.2 + - Added MIME type to the generated .epub file + +## Added in 1.4.1 + - Remove unnecessary permissions + - Detect image type if the URL doesn't have a file extension (jpg, gif, png) + - Reset the Busy indicator on errors + - Remove hidden elements when style is not included + - Replace iframes with divs + +## Added in 1.4 + - Smaller ebook file size + - Fix for #37 - custom styles not applied + - Fix for #36 - br tag missing from pre blocks + - Fix for #31 - hanging in Busy state + - Other misc bug fixes + +## Added in v1.3.4 + - Fix for MathML - the rendered expression is too large (Issue #26) + - Add translation in Russian (thanks to @ Emil Khalikov) & Brazilian Portuguese (thanks to @welksonramos) + +## Added in v1.3 + - Keyboard shortcuts + - Simplified tool bar menu + - Misc bug fixes + +## Added in v1.2.2 + - fixed & & issue in title; Issue # 10 + +## Added in v1.2.1 + - support for hr/br html tags + +## Added in v1.2 + - BETA: Support for CSS + - BETA: Create / edit custom Styles + - No errors from EPUB Validator (http://validator.idpf.org/) + this should fix the Google Play upload issue + +## Added in v1.1 + - Chapter Editor: option to save changes + - Chapter Editor: option to remove all chapters + - persist Chapter Editor changes & chapters after generating an eBook or after a browser restart + ## To-Do - - fix all 'epubcheck' errors (https://github.com/IDPF/epubcheck) - - * there are some issues with relative URLs + - make the Custom Style Editor more user friendly + - support backup / restore for Custom Styles + - DONE fix all 'epubcheck' errors (https://github.com/IDPF/epubcheck) - clean & optimize code - create tests - - convert svg to png - - base64 imgs - - 'save as image' option (render a selection and save it as image instead of text) - support other formats (mobi, pdf etc.) - - show progress indication (ui/ux) - show confirmations (ui/ux) - display errors (ui/ux) - - add settings & options page (ui/ux) + - DONE support custom style + - add 'remove from ebook' right click menu action + +## Run Tests (Work in progress...) + ``` + cd tests + yarn install # install puppeteer + node test/index.js # should start a chrome instance with Save as eBook loaded + + # it will generate and save the ebook in ./tmp-downloads + + .... + ``` ## Credits - http://ebooks.stackexchange.com/questions/1183/what-is-the-minimum-required-content-for-a-valid-epub @@ -59,4 +137,5 @@ ebook-convert "book.epub" "book.mobi" - https://stuk.github.io/jszip/ - http://johnny.github.io/jquery-sortable/ - https://github.com/eligrey/FileSaver.js/ - - https://www.iconfinder.com/icons/1031371/book_empty_library_reading_icon#size=128 + - https://www.iconfinder.com/icons/753890/book_books_education_library_study_icon#size=128 + - Thanks to [pumpk0n](https://github.com/pumpk0n) and [Francois Bocquet](https://github.com/fbocquet) for helping me with the French translation diff --git a/ex1.png b/ex1.png deleted file mode 100644 index f6de59c..0000000 Binary files a/ex1.png and /dev/null differ diff --git a/ex11.png b/ex11.png new file mode 100644 index 0000000..3d60c53 Binary files /dev/null and b/ex11.png differ diff --git a/ex2.png b/ex2.png index 375eb8f..8967c0d 100644 Binary files a/ex2.png and b/ex2.png differ diff --git a/ex3.png b/ex3.png new file mode 100644 index 0000000..88a153e Binary files /dev/null and b/ex3.png differ diff --git a/tests/index.js b/tests/index.js new file mode 100644 index 0000000..6988655 --- /dev/null +++ b/tests/index.js @@ -0,0 +1,52 @@ +const puppeteer = require('puppeteer'); +const fs = require('fs') + +const CRX_PATH = '../web-extension'; +const REFERENCE_EBOOK_PATH = 'reference-ebook' +const TEST_RESULT_EBOOK_PATH = 'test-result-ebook' +const TEST_EBOOK_FILE_NAME = 'test.epub' + +const testPaths = [ + +] + +puppeteer.launch({ + headless: false, + args: [ + `--disable-extensions-except=${CRX_PATH}`, + `--load-extension=${CRX_PATH}`, + '--user-agent=PuppeteerTestingAgent' + ] +}).then(async browser => { + + prepareTests() + + await runLocalFullPageTests(browser) +}); + +async function runLocalFullPageTests(browser) { + + const testedFileName = 'mathjax' //'canvas' //'svg' //'special-chars' // 'p2' //'p1' + + const testUrl = 'file://'+__dirname+'/pages/'+testedFileName+'/page/index.html' + const resultDownloadPath = './pages/'+testedFileName+'/' + TEST_RESULT_EBOOK_PATH + + const page = await browser.newPage(); + await page._client.send('Page.setDownloadBehavior', {behavior: 'allow', downloadPath: resultDownloadPath}); + await page.setViewport({ width: 1280, height: 800 }) + // await page.goto('https://en.wikipedia.org/wiki/E-book', { waitUntil: 'networkidle0' }); + // await page.goto('file://'+__dirname+'/../pages/p1/E-book - Wikipedia.html', { waitUntil: 'networkidle0' }); + await page.goto(testUrl, { waitUntil: 'networkidle0' }); +} + +function prepareTests() { + const testedFileName = 'p1' + const pathToDelete = './pages/'+testedFileName+'/'+TEST_RESULT_EBOOK_PATH+'/'+TEST_EBOOK_FILE_NAME + try { + if (fs.existsSync(pathToDelete)) { + fs.unlinkSync(pathToDelete) + } + } catch(err) { + console.log('Error while deleting file ', err); + } +} \ No newline at end of file diff --git a/tests/package.json b/tests/package.json new file mode 100644 index 0000000..f4adacd --- /dev/null +++ b/tests/package.json @@ -0,0 +1,9 @@ +{ + "name": "web-extension-test", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "puppeteer": "^1.14.0" + } +} diff --git a/tests/pages.zip b/tests/pages.zip new file mode 100644 index 0000000..7e5ecce Binary files /dev/null and b/tests/pages.zip differ diff --git a/web-extension/_locales/en/messages.json b/web-extension/_locales/en/messages.json new file mode 100644 index 0000000..951c629 --- /dev/null +++ b/web-extension/_locales/en/messages.json @@ -0,0 +1,94 @@ +{ + "extName": { + "message": "Save as eBook", + "description": "Extension name" + }, + "includeStyle": { + "message": "Include (Custom) Style" + }, + "editStyles": { + "message": "Edit Custom Styles ..." + }, + "savePage": { + "message": "Save Page" + }, + "saveSelection": { + "message": "Save Selection" + }, + "pageChapter": { + "message": "Add Page as Chapter" + }, + "selectionChapter": { + "message": "Add Selection as Chapter" + }, + "editChapters": { + "message": "Edit Chapters ..." + }, + "waitMessage": { + "message": "Please Wait ..." + }, + "chapterEditorTitle": { + "message": "Chapter Editor" + }, + "ebookTitleLabel": { + "message": "eBook Title:" + }, + "rawPreview": { + "message": "Raw Preview" + }, + "remove": { + "message": "Remove" + }, + "removeChapters": { + "message": "Remove Chapters" + }, + "removeChaptersConfirm": { + "message": "Do you want to remove all chapters?" + }, + "cancel": { + "message": "Cancel" + }, + "generateEbook": { + "message": "Generate eBook ..." + }, + "saveChanges": { + "message": "Save changes" + }, + "emptyBookWarning": { + "message": "Can't generate an empty eBook!" + }, + "confirmDeleteStyle": { + "message": "Are you sure you want to delete this style?" + }, + "invalidRegex": { + "message": "Invalid regular expression" + }, + "styleSaved": { + "message": "Style saved!" + }, + "styleEditor": { + "message": "Style Editor" + }, + "selectExistingCSS": { + "message": "Select Existing CSS" + }, + "orLabel": { + "message": "or" + }, + "createNewStyle": { + "message": "Create New Style" + }, + "styleNameLabel": { + "message": "Name" + }, + "howToWriteRegexLabel": { + "message": "How to write a regular expression pattern" + }, + "removeStyle": { + "message": "Remove Style" + }, + "saveStyle": { + "message": "Save Style" + } + +} diff --git a/web-extension/_locales/fr/messages.json b/web-extension/_locales/fr/messages.json new file mode 100644 index 0000000..5366e29 --- /dev/null +++ b/web-extension/_locales/fr/messages.json @@ -0,0 +1,99 @@ +{ + "extName": { + "message": "Enregistrer comme eBook", + "description": "Nom de l'extension" + }, + "includeStyle": { + "message": "Inclure le style" + }, + "styleLabel": { + "message": "Style personnalisé :" + }, + "applyStyle": { + "message": "Appliquer" + }, + "editStyles": { + "message": "Éditer les styles..." + }, + "savePage": { + "message": "Enregistrer la page" + }, + "saveSelection": { + "message": "Enregistrer la sélection" + }, + "pageChapter": { + "message": "Ajouter la page en tant que chapitre" + }, + "selectionChapter": { + "message": "Ajouter la sélection en tant que chapitre" + }, + "editChapters": { + "message": "Modifier les chapitres..." + }, + "waitMessage": { + "message": "Merci de patienter..." + }, + "chapterEditorTitle": { + "message": "Éditeur de chapitre" + }, + "ebookTitleLabel": { + "message": "Titre de l'eBook :" + }, + "rawPreview": { + "message": "Aperçu brut" + }, + "remove": { + "message": "Retirer" + }, + "removeChapters": { + "message": "Supprimer les chapitres" + }, + "removeChaptersConfirm": { + "message": "Voulez-vous supprimer tous les chapitres ?" + }, + "cancel": { + "message": "Annuler" + }, + "generateEbook": { + "message": "Générer l'eBook..." + }, + "saveChanges": { + "message": "Enregistrer les modifications" + }, + "emptyBookWarning": { + "message": "Impossible de générer un eBook vide !" + }, + "confirmDeleteStyle": { + "message": "Êtes-vous sûr de vouloir supprimer ce style ?" + }, + "invalidRegex": { + "message": "Expression régulière invalide" + }, + "styleSaved": { + "message": "Style enregistré !" + }, + "styleEditor": { + "message": "Éditeur de style" + }, + "selectExistingCSS": { + "message": "Sélectionner un style existant" + }, + "orLabel": { + "message": "ou" + }, + "createNewStyle": { + "message": "Créer un nouveau style" + }, + "styleNameLabel": { + "message": "Nom" + }, + "howToWriteRegexLabel": { + "message": "Comment écrire une expression régulière" + }, + "removeStyle": { + "message": "Supprimer le style" + }, + "saveStyle": { + "message": "Enregistrer le style" + } +} diff --git a/web-extension/_locales/pt_BR/messages.json b/web-extension/_locales/pt_BR/messages.json new file mode 100644 index 0000000..9c59f7c --- /dev/null +++ b/web-extension/_locales/pt_BR/messages.json @@ -0,0 +1,93 @@ +{ + "extName": { + "message": "Salvar como eBook", + "description": "Nome da extensão" + }, + "includeStyle": { + "message": "Incuir estilo (personalizado)" + }, + "editStyles": { + "message": "Editar estilos personalizados ..." + }, + "savePage": { + "message": "Salvar Página" + }, + "saveSelection": { + "message": "Salvar Selecão" + }, + "pageChapter": { + "message": "Adicionar página como capítulo" + }, + "selectionChapter": { + "message": "Adicionar seleção como capítulo" + }, + "editChapters": { + "message": "Editar Capítulos ..." + }, + "waitMessage": { + "message": "Por favor, aguarde ..." + }, + "chapterEditorTitle": { + "message": "Editor de capítulos" + }, + "ebookTitleLabel": { + "message": "Título do eBook:" + }, + "rawPreview": { + "message": "Pré-visualização" + }, + "remove": { + "message": "Remover" + }, + "removeChapters": { + "message": "Remover capítulos" + }, + "removeChaptersConfirm": { + "message": "Você quer remover todos os capítulos?" + }, + "cancel": { + "message": "Cancelar" + }, + "generateEbook": { + "message": "Gerar eBook ..." + }, + "saveChanges": { + "message": "Salvar alterações" + }, + "emptyBookWarning": { + "message": "Não é possível gerar um eBook vazio!" + }, + "confirmDeleteStyle": { + "message": "Tem certeza de que deseja excluir este estilo?" + }, + "invalidRegex": { + "message": "Expressão regular inválida" + }, + "styleSaved": { + "message": "Estilo salvo!" + }, + "styleEditor": { + "message": "Editor de estilo" + }, + "selectExistingCSS": { + "message": "Selecionar CSS Existente" + }, + "orLabel": { + "message": "ou" + }, + "createNewStyle": { + "message": "Criar Novo Estilo" + }, + "styleNameLabel": { + "message": "Nome" + }, + "howToWriteRegexLabel": { + "message": "Como escrever um padrão de expressão regular" + }, + "removeStyle": { + "message": "Remover Estilo" + }, + "saveStyle": { + "message": "Salvar Estilo" + } +} \ No newline at end of file diff --git a/web-extension/_locales/ru/messages.json b/web-extension/_locales/ru/messages.json new file mode 100644 index 0000000..7726ba9 --- /dev/null +++ b/web-extension/_locales/ru/messages.json @@ -0,0 +1,93 @@ +{ + "extName": { + "message": "Save as eBook", + "description": "Extension name" + }, + "includeStyle": { + "message": "Подключить CSS" + }, + "editStyles": { + "message": "Редактировать CSS ..." + }, + "savePage": { + "message": "Сохранить страницу" + }, + "saveSelection": { + "message": "Сохранить выделение" + }, + "pageChapter": { + "message": "Добавить страницу как главу" + }, + "selectionChapter": { + "message": "Добавить выделение как главу" + }, + "editChapters": { + "message": "Редактировать главы ..." + }, + "waitMessage": { + "message": "Пожалуйста ожидайте ..." + }, + "chapterEditorTitle": { + "message": "Редактор глав" + }, + "ebookTitleLabel": { + "message": "Заголовок книги:" + }, + "rawPreview": { + "message": "Предпросмотр" + }, + "remove": { + "message": "Удалить" + }, + "removeChapters": { + "message": "Удалить главы" + }, + "removeChaptersConfirm": { + "message": "Вы точно хотите удалить все главы?" + }, + "cancel": { + "message": "Отмена" + }, + "generateEbook": { + "message": "Создать книгу ..." + }, + "saveChanges": { + "message": "Сохранить изменения" + }, + "emptyBookWarning": { + "message": "Невозможно создать пустую книгу" + }, + "confirmDeleteStyle": { + "message": "Вы точно хотите удалить этот стиль?" + }, + "invalidRegex": { + "message": "Ошибка в регулярном выражении" + }, + "styleSaved": { + "message": "Стиль сохранен!" + }, + "styleEditor": { + "message": "Редактор стилей" + }, + "selectExistingCSS": { + "message": "Выбрать существующий стиль" + }, + "orLabel": { + "message": "или" + }, + "createNewStyle": { + "message": "Создать новый стиль" + }, + "styleNameLabel": { + "message": "Название" + }, + "howToWriteRegexLabel": { + "message": "Введите регулярное выражение для адреса" + }, + "removeStyle": { + "message": "Удалить стиль" + }, + "saveStyle": { + "message": "Сохранить стиль" + } +} \ No newline at end of file diff --git a/web-extension/background.js b/web-extension/background.js index 60c2fba..a577260 100644 --- a/web-extension/background.js +++ b/web-extension/background.js @@ -1,44 +1,429 @@ -var customStorage = null; -function _getEbookPages() { - try { - // var allPages = localStorage.getItem('ebook'); - var allPages = customStorage; - if (!allPages) { - allPages = []; +/////////////////// +/////////////////// +/////////////////// +/////////////////// +/// Only for testing + +chrome.runtime.onInstalled.addListener(details => { + if (navigator.userAgent === 'PuppeteerTestingAgent') { + let TEST_TIMER = null + chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (TEST_TIMER) { + clearTimeout(TEST_TIMER) + } + + TEST_TIMER = setTimeout(()=> { + executeCommand({type: 'save-page'}) + }, 2000) + }); + } +}); + + + +/////////////////// +/////////////////// +/////////////////// +/////////////////// + +var isBusy = false; +var busyResetTimer = null + +var defaultStyles = [ + { + title: 'Reddit Comments', + url: 'reddit\\.com\\/r\\/[^\\/]+\\/comments', + style: `.side { +display: none; +} +#header { +display: none; +} +.arrow, .expand, .score, .live-timestamp, .flat-list, .buttons, .morecomments, .footer-parent, .icon { +display: none !important; +} +` + },{ + title: 'Wikipedia Article', + url: 'wikipedia\\.org\\/wiki\\/', + style: `#mw-navigation { +display: none; +} +#footer { +display: none; +} +#mw-panel { +display: none; +} +#mw-head { +display: none; +} +` + },{ + title: 'YCombinator News Comments', + url: 'news\\.ycombinator\\.com\\/item\\?id=[0-9]+', + style: `#hnmain > tbody > tr:nth-child(1) > td > table { +display: none; +} +* { +background-color: white; +} +.title, .storylink { +text-align: left; +font-weight: bold; +font-size: 20px; +} +.score { +display: none; +} +.age { +display: none; +} +.hnpast { +display: none; +} +.togg { +display: none; +} +.votelinks, .rank { +display: none; +} +.votearrow { +display: none; +} +.yclinks { +display: none; +} +form { +display: none; +} +a.hnuser { +font-weight: bold; +color: black !important; +padding: 3px; +} +.subtext > span, .subtext > a:not(:nth-child(2)) { +display: none; +} +` + },{ + title: 'Medium Article', + url: 'medium\\.com', + style: `.metabar { +display: none !important; +} +header.container { +display: none; +} +.js-postShareWidget { +display: none; +} +footer, canvas { +display: none !important; +} +.u-fixed, .u-bottom0 { +display: none; +} +` + },{ + title: 'Twitter', + url: 'twitter\\.com\\/.+', + style: `.topbar { +display: none !important; +} +.ProfileCanopy, .ProfileCanopy-inner { +display: none; +} +.ProfileSidebar { +display: none; +} +.ProfileHeading { +display: none !important; +} +.ProfileTweet-actionList { +display: none; +} +` + } + +]; + +chrome.commands.onCommand.addListener((command) => { + executeCommand({type: command}) +}); + +function executeCommand(command) { + if (isBusy) { + chrome.tabs.query({ + currentWindow: true, + active: true + }, (tab) => { + chrome.tabs.sendMessage(tab[0].id, {'alert': 'Work in progress! Please wait until the current eBook is generated!'}, (r) => { + console.log(r); + }); + }) + return; + } + if (command.type === 'save-page') { + dispatch('extract-page', false, []); + } else if (command.type === 'save-selection') { + dispatch('extract-selection', false, []); + } else if (command.type === 'add-page') { + dispatch('extract-page', true, []); + } else if (command.type === 'add-selection') { + dispatch('extract-selection', true, []); + } + + isBusy = true + + // + busyResetTimer = setTimeout(() => { + resetBusy() + }, 20000) +} + +function dispatch(action, justAddToBuffer, appliedStyles) { + if (!justAddToBuffer) { + _execRequest({type: 'remove'}); + } + chrome.browserAction.setBadgeBackgroundColor({color:"red"}); + chrome.browserAction.setBadgeText({text: "Busy"}); + + chrome.tabs.query({ + currentWindow: true, + active: true + }, (tab) => { + + isIncludeStyles((result) =>{ + let isIncludeStyle = result.includeStyle + prepareStyles(tab, isIncludeStyle, appliedStyles, (tmpAppliedStyles) => { + applyAction(tab, action, justAddToBuffer, isIncludeStyle, tmpAppliedStyles, () => { + alert('done') + }) + }) + }) + }); +} + +function isIncludeStyles(callback) { + chrome.storage.local.get('includeStyle', (data) => { + if (!data) { + callback({includeStyle: false}); } else { - allPages = JSON.parse(allPages); + callback({includeStyle: data.includeStyle}); } - return allPages; - } catch (e) { - alert(e); - return []; + }); +} + +function prepareStyles(tab, includeStyle, appliedStyles, callback) { + if (!includeStyle) { + callback(appliedStyles) + return + } + + chrome.storage.local.get('styles', (data) => { + let styles = defaultStyles; + if (data && data.styles) { + styles = data.styles; + } + let currentUrl = tab[0].url; + let currentStyle = null; + + if (!styles) { + callback(appliedStyles) + return + } + + if (styles.length === 0) { + callback(appliedStyles) + return + } + + let allMatchingStyles = []; + + for (let i = 0; i < styles.length; i++) { + currentUrl = currentUrl.replace(/(http[s]?:\/\/|www\.)/i, '').toLowerCase(); + let styleUrl = styles[i].url; + let styleUrlRegex = null; + + try { + styleUrlRegex = new RegExp(styleUrl, 'i'); + } catch (e) { + } + + if (styleUrlRegex && styleUrlRegex.test(currentUrl)) { + allMatchingStyles.push({ + index: i, + length: styleUrl.length + }); + } + } + + if (allMatchingStyles.length === 0) { + callback(appliedStyles) + return + } + + allMatchingStyles.sort((a, b) => b.length - a.length); + let selStyle = allMatchingStyles[0]; + + if (!selStyle) { + callback(appliedStyles) + return + } + + currentStyle = styles[selStyle.index]; + + if (!currentStyle) { + callback(appliedStyles) + return + } + + if (!currentStyle.style) { + callback(appliedStyles) + return + } + + chrome.tabs.insertCSS(tab[0].id, { code: currentStyle.style }, () => { + appliedStyles.push(currentStyle); + callback(appliedStyles) + }); + }); +} + +function applyAction(tab, action, justAddToBuffer, includeStyle, appliedStyles, callback) { + chrome.tabs.sendMessage(tab[0].id, { + type: action, + includeStyle: includeStyle, + appliedStyles: appliedStyles + }, (response) => { + + if (!response) { + resetBusy() + chrome.tabs.sendMessage(tab[0].id, {'alert': 'Save as eBook does not work on this web site!'}, (r) => {}); + return; + } + + if (response.content.trim() === '') { + resetBusy() + if (justAddToBuffer) { + chrome.tabs.sendMessage(tab[0].id, {'alert': 'Cannot add an empty selection as chapter!'}, (r) => {}); + } else { + chrome.tabs.sendMessage(tab[0].id, {'alert': 'Cannot generate the eBook from an empty selection!'}, (r) => {}); + } + return; + } + if (!justAddToBuffer) { + chrome.tabs.sendMessage(tab[0].id, {'shortcut': 'build-ebook', response: [response]}, (r) => {}); + } else { + chrome.storage.local.get('allPages', (data) => { + if (!data || !data.allPages) { + data.allPages = []; + } + data.allPages.push(response); + chrome.storage.local.set({'allPages': data.allPages}); + resetBusy() + chrome.tabs.sendMessage(tab[0].id, {'alert': 'Page or selection added as chapter!'}, (r) => {}); + }) + } + }); +} + +function resetBusy() { + isBusy = false + + if (busyResetTimer) { + clearTimeout(busyResetTimer) + busyResetTimer = null + } + + chrome.browserAction.setBadgeText({text: ""}) + + let popups = chrome.extension.getViews({type: "popup"}); + if (popups && popups.length > 0) { + popups[0].close() } } -function _saveEbookPages(pages) { - try { - // localStorage.setItem('ebook', JSON.stringify(pages)); - customStorage = JSON.stringify(pages); - } catch (e) { - alert(e); - } -} +chrome.runtime.onMessage.addListener(_execRequest); -function _removeEbook() { - // localStorage.removeItem('ebook'); - customStorage = null; -} - -chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { +function _execRequest(request, sender, sendResponse) { if (request.type === 'get') { - sendResponse({allPages: _getEbookPages()}); + chrome.storage.local.get('allPages', function (data) { + if (!data || !data.allPages) { + sendResponse({allPages: []}); + } + sendResponse({allPages: data.allPages}); + }) } if (request.type === 'set') { - _saveEbookPages(request.pages); + chrome.storage.local.set({'allPages': request.pages}); } if (request.type === 'remove') { - _removeEbook(); + chrome.storage.local.remove('allPages'); + chrome.storage.local.remove('title'); + } + if (request.type === 'get title') { + chrome.storage.local.get('title', function (data) { + if (!data || !data.title || data.title.trim().length === 0) { + sendResponse({title: 'eBook'}); + } else { + sendResponse({title: data.title}); + } + }) + } + if (request.type === 'set title') { + chrome.storage.local.set({'title': request.title}); + } + if (request.type === 'get styles') { + chrome.storage.local.get('styles', function (data) { + if (!data || !data.styles) { + sendResponse({styles: defaultStyles}); + } else { + sendResponse({styles: data.styles}); + } + }); + } + if (request.type === 'set styles') { + chrome.storage.local.set({'styles': request.styles}); + } + if (request.type === 'get current style') { + chrome.storage.local.get('currentStyle', function (data) { + if (!data || !data.currentStyle) { + sendResponse({currentStyle: 0}); + } else { + sendResponse({currentStyle: data.currentStyle}); + } + }); + } + if (request.type === 'set current style') { + chrome.storage.local.set({'currentStyle': request.currentStyle}); + } + if (request.type === 'get include style') { + chrome.storage.local.get('includeStyle', function (data) { + if (!data) { + sendResponse({includeStyle: false}); + } else { + sendResponse({includeStyle: data.includeStyle}); + } + }); + } + if (request.type === 'set include style') { + chrome.storage.local.set({'includeStyle': request.includeStyle}); + } + if (request.type === 'is busy?') { + sendResponse({isBusy: isBusy}) + } + if (request.type === 'set is busy') { + isBusy = request.isBusy + } + if (request.type === 'save-page' || request.type === 'save-selection' || + request.type === 'add-page' || request.type === 'add-selection') { + executeCommand({type: request.type}) + } + if (request.type === 'done') { + resetBusy() } return true; -}); +} diff --git a/web-extension/chapterEditor.css b/web-extension/chapterEditor.css index 86e9e57..e3d42e2 100644 --- a/web-extension/chapterEditor.css +++ b/web-extension/chapterEditor.css @@ -12,24 +12,13 @@ ul.chapterEditor-chapters-list li.placeholder { padding: 0; border: solid 10px #f5f5f5; } -/*ul.chapterEditor-chapters-list li.placeholder:before { - position: absolute; - content: ""; - margin-top: -5px; - left: -5px; - top: -4px; - border: 5px solid black; - border-left-color: red; - border-right: none; -}*/ - #chapterEditor-Title { font-size: 20px; font-weight: bold; float: left; display: inline-block; - font-family: "sans-serif"; + font-family: sans-serif; } #chapterEditor-ebookTitleHolder { @@ -46,7 +35,7 @@ ul.chapterEditor-chapters-list li.placeholder { #chapterEditor-ebookTitle { padding: 5px; font-size: 15px; - font-family: "sans-serif"; + font-family: sans-serif; width: 85%; } ul, ul.chapterEditor-chapters-list { @@ -77,7 +66,7 @@ ul, ul.chapterEditor-chapters-list { .chapterEditor-chapter-item > input[type="text"] { padding: 5px; font-size: 15px; - font-family: "sans-serif"; + font-family: sans-serif; width: 80%; border: none; border-bottom: solid 1px #aaa; @@ -90,9 +79,6 @@ ul, ul.chapterEditor-chapters-list { } #chapterEditor-modalList { display: block; - /*width: 100%;*/ - /*border-top: solid 1px black;*/ - /*border-bottom: solid 1px black;*/ padding: 20px; padding-top: 0; } @@ -111,7 +97,7 @@ ul, ul.chapterEditor-chapters-list { .chapterEditor-text-button { border: none; font-size: 15px; - font-family: "sans-serif"; + font-family: sans-serif; padding: 5px; background-color: rgba(0, 0, 0, 0); margin: 0 3px; @@ -134,21 +120,48 @@ ul, ul.chapterEditor-chapters-list { } .chapterEditor-footer-button { + position: relative; + overflow: hidden; padding: 18px 20px; margin: 0; font-size: 18px; border: none; background-color: rgba(0, 0, 0, 0); + display: inline-block; } .chapterEditor-footer-button:hover { color: white; background-color: rgba(0, 0, 0, 1); cursor: pointer; } -.chapterEditor-generate-button { +.chapterEditor-footer-button:after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 5px; + height: 5px; + background: rgba(255, 255, 255, .5); + opacity: 0; + border-radius: 100%; + transform: scale(1, 1) translate(-50%); + transform-origin: 50% 50%; +} +@keyframes ripple { + 0% { transform: scale( 0, 0); opacity: 1; } + 20% { transform: scale(25, 25); opacity: 1; } + 100% { transform: scale(40, 40); opacity: 0; } +} +.chapterEditor-footer-button:focus:not(:active)::after { + animation: ripple 1s ease-out; +} +.chapterEditor-footer-button.chapterEditor-generate-button { background-color: yellow; } -.chapterEditor-cancel-button { +.chapterEditor-footer-button.chapterEditor-generate-button:hover { + background-color: rgba(0, 0, 0, 1); +} +.chapterEditor-footer-button.chapterEditor-cancel-button { /*background-color: black;*/ } diff --git a/web-extension/chapterEditor.js b/web-extension/chapterEditor.js index d91445a..d54e4f3 100644 --- a/web-extension/chapterEditor.js +++ b/web-extension/chapterEditor.js @@ -26,7 +26,7 @@ function showEditor() { // Header var title = document.createElement('span'); title.id = "chapterEditor-Title"; - title.innerText = "Chapter Editor"; + title.innerText = chrome.i18n.getMessage('chapterEditorTitle'); var upperCloseButton = document.createElement('button'); modalHeader.appendChild(title); upperCloseButton.onclick = closeModal; @@ -41,13 +41,15 @@ function showEditor() { var ebookTilteLabel = document.createElement('span'); ebookTilteLabel.id = 'chapterEditor-ebookTitleLabel'; - ebookTilteLabel.innerText = 'eBook Title: '; + ebookTilteLabel.innerText = chrome.i18n.getMessage('ebookTitleLabel'); titleHolder.appendChild(ebookTilteLabel); var ebookTilte = document.createElement('input'); ebookTilte.id = 'chapterEditor-ebookTitle'; ebookTilte.type = 'text'; - ebookTilte.value = 'eBook'; + getEbookTitle(function (title) { + ebookTilte.value = title; + }); titleHolder.appendChild(ebookTilte); modalList.appendChild(titleHolder); @@ -79,12 +81,12 @@ function showEditor() { var buttons = document.createElement('span'); var previewButton = document.createElement('button'); - previewButton.innerText = 'Raw Preview'; + previewButton.innerText = chrome.i18n.getMessage('rawPreview'); previewButton.className = 'chapterEditor-text-button'; previewButton.onclick = previewListItem(i); var removeButton = document.createElement('button'); - removeButton.innerText = 'Remove'; + removeButton.innerText = chrome.i18n.getMessage('remove'); removeButton.className = 'chapterEditor-text-button chapterEditor-text-red'; removeButton.onclick = removeListItem(i); @@ -106,18 +108,40 @@ function showEditor() { // Footer var buttons = document.createElement('div'); var closeButton = document.createElement('button'); - closeButton.innerText = 'Cancel'; + closeButton.innerText = chrome.i18n.getMessage('cancel'); closeButton.className = 'chapterEditor-footer-button chapterEditor-float-left chapterEditor-cancel-button'; closeButton.onclick = closeModal; buttons.appendChild(closeButton); + var removeButton = document.createElement('button'); + removeButton.innerText = chrome.i18n.getMessage('removeChapters'); + removeButton.className = 'chapterEditor-footer-button hapterEditor-float-left'; + removeButton.onclick = function() { + var result = confirm(chrome.i18n.getMessage('removeChaptersConfirm')); + if (result) { + removeEbook(); + closeModal(); + } + }; + buttons.appendChild(removeButton); + var saveButton = document.createElement('button'); saveButton.onclick = function() { - prepareEbook(); + var newChapters = saveChanges(); + prepareEbook(newChapters); }; - saveButton.innerText = 'Generate eBook ...'; + saveButton.innerText = chrome.i18n.getMessage('generateEbook'); saveButton.className = 'chapterEditor-footer-button chapterEditor-float-right chapterEditor-generate-button'; buttons.appendChild(saveButton); + + var saveChangesButton = document.createElement('button'); + saveChangesButton.onclick = function() { + saveChanges(); + }; + saveChangesButton.innerText = chrome.i18n.getMessage('saveChanges'); + saveChangesButton.className = 'chapterEditor-footer-button chapterEditor-float-right'; + buttons.appendChild(saveChangesButton); + modalFooter.appendChild(buttons); ///////////////////// @@ -182,11 +206,23 @@ function showEditor() { function previewListItem(atIndex) { return function() { - alert(allPagesRef[atIndex].content.trim().substring(0, 500).replace(/<[^>]+>/gi, '') + ' ...'); + alert(allPagesRef[atIndex].content.trim().replace(/<[^>]+>/gi, '').replace(/\s+/g, ' ').substring(0, 1000) + ' ...'); }; } - function prepareEbook() { + function prepareEbook(newChapters) { + try { + if (newChapters.length === 0) { + alert(chrome.i18n.getMessage('emptyBookWarning')); + return; + } + buildEbookFromChapters(); + } catch (e) { + console.log('Error:', e); + } + } + + function saveChanges() { var newChapters = []; var newEbookTitle = ebookTilte.value; if (newEbookTitle.trim() === '') { @@ -194,35 +230,27 @@ function showEditor() { } try { - var tmpChaptersList = document.getElementsByClassName('chapterEditor-chapter-item'); if (!tmpChaptersList || !allPagesRef) { return; } for (var i = 0; i < tmpChaptersList.length; i++) { - var listIndex = Number(tmpChaptersList[i].id.replace('li', '')); + var tmpChapterItem = tmpChaptersList[i]; + var listIndex = Number(tmpChapterItem.id.replace('li', '')); if (allPagesRef[listIndex].removed === false) { + var newChapterTitle = tmpChapterItem.children.namedItem('text'+listIndex).value; + allPagesRef[listIndex].title = newChapterTitle; newChapters.push(allPagesRef[listIndex]); } } - if (newChapters.length === 0) { - alert('Can\'t generate an empty eBook!'); - return; - } - - newChapters.splice(0, 0, { - type: 'title', - title: newEbookTitle - }); - + saveEbookTitle(newEbookTitle); saveEbookPages(newChapters); - buildEbookFromChapters(); + return newChapters; } catch (e) { console.log('Error:', e); } - } ///////////////////// diff --git a/web-extension/cssEditor.css b/web-extension/cssEditor.css new file mode 100644 index 0000000..88ed70f --- /dev/null +++ b/web-extension/cssEditor.css @@ -0,0 +1,190 @@ +#cssEditor-removeStyle { + display: none; +} +#cssEditor-saveStyle { + display: none; +} +#cssEditor-selectStyle { + padding: 3px; + cursor: pointer; + font-size: 15px; + font-family: sans-serif; +} +#cssEditor-selectStyle > option { + padding: 3px; + font-size: 1em; + cursor: pointer; + font-size: 15px; + font-family: sans-serif; +} +#cssEditor-orLabel { + padding: 3px; + margin: 0 3px; + font-size: 15px; + font-family: sans-serif; +} +#cssEditor-createNewStyle { + padding: 3px; + cursor: pointer; + font-size: 15px; + font-family: sans-serif; +} +.cssEditor-field-label-holder { + margin-top: 10px; + font-size: 15px; + font-family: sans-serif; + display: flex; + justify-content: space-between; + width: 90%; +} +.cssEditor-field-holder { + padding: 3px; + width: 90%; +} +.cssEditor-field-label { + padding: 0 3px; + margin-top: 5px; +} + +#cssEditor-styleEditor { + display: flex; + flex-flow: row; +} + +.cssEditor-left-panel { + width: 48%; + display: flex; + flex-flow: column; +} + +.cssEditor-right-panel { + width: 48%; + display: flex; + flex-flow: column; +} + +#cssEditor-styleName { + width: 100%; + min-width: 100%; + padding: 5px; + border: solid rgba(0, 0, 0, 0.25) 1px; + font-size: 15px; + font-family: sans-serif; +} +#cssEditor-matchUrl { + width: 100%; + min-width: 100%; + padding: 5px; + border: solid rgba(0, 0, 0, 0.25) 1px; + font-size: 15px; + font-family: sans-serif; +} +#cssEditor-styleContent { + width: 100%; + height: 150px; + padding: 5px; + border: solid rgba(0, 0, 0, 0.25) 1px; + font-size: 15px; + font-family: sans-serif; +} + +#cssEditor-Title { + font-size: 20px; + font-weight: bold; + float: left; + display: inline-block; + font-family: sans-serif; +} + +#cssEditor-ebookTitleHolder { + background-color: #eee; + padding: 10px 20px; + margin-bottom: 10px; +} + +#cssEditor-modalHeader { + display: block; + overflow: hidden; + padding: 20px; +} +#cssEditor-modalList { + display: block; + padding: 20px; + padding-top: 0; +} +#cssEditor-modalFooter { + display: block; + overflow: hidden; +} + +.cssEditor-text-button { + border: none; + font-size: 15px; + font-family: sans-serif; + padding: 5px; + background-color: rgba(0, 0, 0, 0); + margin: 0 3px; + outline: none; + cursor: pointer; +} +.cssEditor-text-button:hover { + background-color: #000; + color: #fff; +} +.cssEditor-text-red { + color: red; +} + +.cssEditor-float-left { + float: left; +} +.cssEditor-float-right { + float: right; +} + +.cssEditor-footer-button { + padding: 18px 20px; + margin: 0; + font-size: 18px; + border: none; + background-color: rgba(0, 0, 0, 0); + display: inline-block; +} +.cssEditor-footer-button:hover { + color: white; + background-color: rgba(0, 0, 0, 1); + cursor: pointer; +} +.cssEditor-save-button { + background-color: yellow; +} +.cssEditor-cancel-button { + /*background-color: black;*/ +} + +@media (max-width: 1700px) { + .cssEditor-text-button { + font-size: 12px; + padding: 3px; + margin: 0 2px; + } + .cssEditor-footer-button { + padding: 15px 20px; + margin: 0; + font-size: 15px; + border: none; + background-color: rgba(0, 0, 0, 0); + } +} + +@media (max-width: 1100px) { + .cssEditor-chapter-item { + line-height: 30px; + height: 60px; + padding: 4px 0; + font-size: 14px; + } + .cssEditor-chapter-item > input[type="text"] { + width: 90%; + } +} diff --git a/web-extension/cssEditor.js b/web-extension/cssEditor.js new file mode 100644 index 0000000..ea28993 --- /dev/null +++ b/web-extension/cssEditor.js @@ -0,0 +1,379 @@ +for (var i=0; i 0) { + allStyles = allStyles.concat(allStylesTmp); + } + + while (existingStyles.hasChildNodes() && existingStyles.childElementCount > 1) { + existingStyles.removeChild(existingStyles.lastChild); + } + + for (var i = 0; i < allStyles.length; i++) { + var listItem = document.createElement('option'); + listItem.id = 'option_' + i; + listItem.className = 'cssEditor-chapter-item'; + listItem.value = 'option_' + i; + listItem.innerText = allStyles[i].title; + if (currentStyle && (allStyles[i].title === currentStyle.title)) { + listItem.selected = 'selected'; + } + existingStyles.appendChild(listItem); + } + } + + function editCurrentStyle() { + if (!currentStyle) { + return; + } + + showStyleEditor(); + showRemoveStyle(); + showSaveStyle(); + + document.getElementById('cssEditor-styleName').value = currentStyle.title; + document.getElementById('cssEditor-matchUrl').value = currentStyle.url; + document.getElementById('cssEditor-styleContent').value = currentStyle.style; + + } + + function resetFields() { + document.getElementById('cssEditor-styleName').value = ''; + document.getElementById('cssEditor-matchUrl').value = ''; + document.getElementById('cssEditor-styleContent').value = ''; + } + + function hideStyleEditor() { + document.getElementById('cssEditor-styleEditor').style.display = 'none'; + } + + function showStyleEditor() { + document.getElementById('cssEditor-styleEditor').style.display = 'flex'; + } + + function showRemoveStyle() { + document.getElementById('cssEditor-removeStyle').style.display = 'inline-block'; + } + + function hideRemoveStyle() { + document.getElementById('cssEditor-removeStyle').style.display = 'none'; + } + + function showSaveStyle() { + document.getElementById('cssEditor-saveStyle').style.display = 'inline-block'; + } + + function hideSaveStyle() { + document.getElementById('cssEditor-saveStyle').style.display = 'none'; + } + + function saveStyle() { + var isRegexValid = checkRegex(); + if (!isRegexValid) { + alert(chrome.i18n.getMessage('invalidRegex')); + return; + } + var tmpValue = { + title: document.getElementById('cssEditor-styleName').value, + url: document.getElementById('cssEditor-matchUrl').value, + style: document.getElementById('cssEditor-styleContent').value + } + if (currentStyle === null) { + allStyles.push(tmpValue); + currentStyle = tmpValue; + currentStyleIndex = allStyles.length - 1; + } else { + currentStyle = tmpValue; + allStyles[currentStyleIndex] = currentStyle; + } + setStyles(allStyles); + createStyleList(); + showRemoveStyle(); + alert(chrome.i18n.getMessage('styleSaved')); + } + + function checkRegex() { + var regexContent = document.getElementById('cssEditor-matchUrl').value; + var isValid = true; + try { + new RegExp(regexContent); + } catch(e) { + isValid = false; + } + return isValid; + } + + function removeStyle() { + if (confirm(chrome.i18n.getMessage('confirmDeleteStyle')) == true) { + allStyles.splice(currentStyleIndex, 1); + setStyles(allStyles); + hideSaveStyle(); + hideRemoveStyle(); + hideStyleEditor(); + createStyleList(); + } + } + + + ///////////////////// + + var modal = document.createElement('div'); + modal.id = 'cssEditor-Modal'; + + modalContent.appendChild(modalHeader); + modalContent.appendChild(modalList); + modalContent.appendChild(modalFooter); + modal.appendChild(modalContent); + + body.appendChild(modal); + + modal.style.display = "none"; + modal.style.position = 'fixed'; + modal.style.zIndex = '1'; + modal.style.left = '0'; + modal.style.top = '0'; + modal.style.width = '100%'; + modal.style.height = '100%'; + modal.style.overflow = 'auto'; + modal.style.backgroundColor = 'rgba(210, 210, 210, 1)'; + + modalContent.style.zIndex = '2'; + modalContent.style.backgroundColor = '#fff'; + modalContent.style.margin = '5% auto'; + modalContent.style.padding = '0'; + modalContent.style.width = '70%'; + + window.onclick = function(event) { + if (event.target == modal) { + closeModal(); + } + }; + + modal.style.display = "block"; + + document.onkeydown = function(evt) { + evt = evt || window.event; + if (evt.keyCode == 27) { + closeModal(); + } + }; + + function closeModal() { + for (var i=0; i src links that don't have a file extension +// If the image src doesn't have a file type: +// 1. Create a dummy link +// 2. Detect image type from the binary data & create new links +// 3. Replace all the dummy links in tmpGlobalContent with the new links +var tmpGlobalContent = null + var allImages = []; -var maxNrOfElements = 10000; +var extractedImages = []; +var allowedTags = [ + 'address', 'article', 'aside', 'footer', 'header', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'hgroup', 'nav', 'section', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure', 'hr', 'li', + 'main', 'ol', 'p', 'pre', 'ul', 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data', + 'dfn', 'em', 'i', 'img', 'kbd', 'mark', 'q', 'rb', 'rp', 'rt', 'rtc', 'ruby', 's', 'samp', 'small', 'span', + 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr', 'del', 'ins', 'caption', 'col', 'colgroup', + 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr', + 'math', 'maction', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', + 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'msgroup', 'mlongdiv', 'mscarries', + 'mscarry', 'mstack', 'semantics' + // TODO ? + // ,'form', 'button' + + // TODO svg support ? + // , 'svg', 'g', 'path', 'line', 'circle', 'text' +]; +// const svgTags = ['svg', 'g', 'path', 'line', 'circle', 'text'] +var mathMLTags = [ + 'math', 'maction', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', + 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'msgroup', 'mlongdiv', 'mscarries', + 'mscarry', 'mstack', 'semantics' +] +var cssClassesToTmpIds = {}; +var tmpIdsToNewCss = {}; +var tmpIdsToNewCssSTRING = {}; + +// src: https://idpf.github.io/a11y-guidelines/content/style/reference.html +var supportedCss = [ + 'background-color', + 'border', + 'color', + 'font', + 'line-height', + 'list-style', + 'padding', + 'text-align', +]; ////// function getImageSrc(srcTxt) { if (!srcTxt) { return ''; } - allImgSrc[srcTxt] = 'img-' + (Math.floor(Math.random()*1000000)) + '.' + getFileExtension(srcTxt); - return '../images/' + allImgSrc[srcTxt]; + srcTxt = srcTxt.trim(); + if (srcTxt === '') { + return ''; + } + + // TODO move + srcTxt = srcTxt.replace(/&/g, '&') + + // TODO - convert with svg sources to jpeg OR add support for svg + + let fileExtension = getFileExtension(srcTxt); + if (fileExtension === '') { + fileExtension = "TODO-EXTRACT" + } + let newImgFileName = 'img-' + generateRandomNumber(true) + '.' + fileExtension; + + let isB64Img = isBase64Img(srcTxt); + if (isB64Img) { + extractedImages.push({ + filename: newImgFileName, // TODO name + data: getBase64ImgData(srcTxt) + }); + } else { + allImages.push({ + originalUrl: getImgDownloadUrl(srcTxt), + filename: newImgFileName, // TODO name + }); + } + + return '../images/' + newImgFileName; } -function generateRandomTag() { - var text = ""; - var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; - - for(var i = 0; i < 5; i++) - text += possible.charAt(Math.floor(Math.random() * possible.length)); - - return text; +// tested +function extractMathMl($htmlObject) { + $htmlObject.find('span[id^="MathJax-Element-"]').each(function (i, el) { + $(el).replaceWith('' + el.getAttribute('data-mathml') + ''); + }); } - -// function force3(dirty) { -// var tagOpen = '@@@';// + generateRandomTag(); -// var tagClose = '###';// + generateRandomTag(); -// var removeElements = ['script', 'style', 'svg', 'canvas', 'noscript']; -// var inlineElements = ['h1', 'h2', 'h3', 'sup', 'b', 'i', 'em', 'code', 'pre', 'p']; -// var replaceElements = [['li', 'p'], ['tr', 'p']]; -// -// // var bodyClone = document.getElementsByTagName('body')[0].cloneNode(true); -// -// var bodyClone = document.createElement('div'); -// bodyClone.innerHTML = dirty; -// -// -// ///// -// -// var imgs = bodyClone.getElementsByTagName('img'); -// for (var i = 0; i < imgs.length; i++) { -// var newImg = document.createElement('span'); -// newImg.innerHTML = tagOpen + 'img src="' + getImageSrc(imgs[i].getAttribute('src')) + '"' + tagClose + tagOpen + '/img' + tagClose; -// imgs[i].parentNode.replaceChild(newImg, imgs[i]); -// } -// -// var links = bodyClone.getElementsByTagName('a'); -// for (i = 0; i < links.length; i++) { -// var newLink = document.createElement('span'); -// newLink.innerHTML = tagOpen + 'a href="' + getHref(links[i].getAttribute('href')) + '"' + tagClose + links[i].innerHTML + tagOpen + '/a' + tagClose; -// links[i].parentNode.replaceChild(newLink, links[i]); -// } -// -// for (i = 0; i < inlineElements.length; i++) { -// var tagName = inlineElements[i]; -// var miscElements = bodyClone.getElementsByTagName(tagName); -// for (var j = 0; j < miscElements.length; j++) { -// var elemToBeReplaced = miscElements[j]; -// var newElement = document.createElement('span'); -// newElement.innerHTML = tagOpen + tagName + tagClose + elemToBeReplaced.innerHTML + tagOpen + '/' + tagName + tagClose; -// elemToBeReplaced.parentNode.replaceChild(newElement, elemToBeReplaced); -// } -// } -// -// for (i = 0; i < replaceElements.length; i++) { -// var crtTagPair = replaceElements[i]; -// var searchForTag = crtTagPair[0]; -// var replaceWithTag = crtTagPair[1]; -// var miscElements = bodyClone.getElementsByTagName(searchForTag); -// for (var j = 0; j < miscElements.length; j++) { -// var elemToBeReplaced = miscElements[j]; -// var newElement = document.createElement('span'); -// newElement.innerHTML = tagOpen + replaceWithTag + tagClose + elemToBeReplaced.innerHTML + tagOpen + '/' + replaceWithTag + tagClose; -// elemToBeReplaced.parentNode.replaceChild(newElement, elemToBeReplaced); -// } -// } -// -// var contentString = bodyClone.innerText; -// -// var tagOpenRegex = new RegExp(tagOpen, 'gi'); -// var tagCloseRegex = new RegExp(tagClose, 'gi'); -// contentString = contentString.replace(tagOpenRegex, '<'); -// contentString = contentString.replace(tagCloseRegex, '>'); -// contentString = contentString.replace(/&/gi, '&'); -// contentString = contentString.replace(/&/gi, '&'); -// -// return contentString; -// -// } - -function force(contentString) { - try { - var tagOpen = '@@@' + generateRandomTag(); - var tagClose = '###' + generateRandomTag(); - var inlineElements = ['h1', 'h2', 'h3', 'sup', 'b', 'i', 'em', 'code', 'pre', 'p']; - var replaceElements = [['li', 'p'], ['tr', 'p']]; - - var $content = $(contentString); - - $content.find('img').each(function (index, elem) { - $(elem).replaceWith('' + tagOpen + 'img src="' + getImageSrc($(elem).attr('src')) + '"' + tagClose + tagOpen + '/img' + tagClose + ''); - }); - - $content.find('a').each(function (index, elem) { - $(elem).replaceWith('' + tagOpen + 'a href="' + getHref($(elem).attr('href')) + '"' + tagClose + $(elem).html() + tagOpen + '/a' + tagClose + ''); - }); - - if ($('*').length < maxNrOfElements) { - replaceElements.forEach(function (replacePair) { - var searchFor = replacePair[0]; - var tagName = replacePair[1]; - var tmpElems = $content.find(searchFor); - while (tmpElems.length > 0) { - $tmpElem = $(tmpElems[0]); - $tmpElem.replaceWith('' + tagOpen + tagName + tagClose + $tmpElem.html() + tagOpen + '/' + tagName + tagClose + ''); - tmpElems = $content.find(searchFor); - } - }); - - inlineElements.forEach(function (tagName) { - var tmpElems = $content.find(tagName); - while (tmpElems.length > 0) { - $tmpElem = $(tmpElems[0]); - $tmpElem.replaceWith('' + tagOpen + tagName + tagClose + $tmpElem.html() + tagOpen + '/' + tagName + tagClose + ''); - tmpElems = $content.find(tagName); - } - }); +// tested +function extractCanvasToImg($htmlObject) { + $htmlObject.find('canvas').each(function (index, elem) { + try { + let imgUrl = docEl.toDataURL('image/jpeg'); + $(elem).replaceWith(''); + } catch (e) { + console.log(e) } + }); +} - contentString = $content.text(); +// tested +function extractSvgToImg($htmlObject) { + let serializer = new XMLSerializer(); + $htmlObject.find('svg').each(function (index, elem) { + // add width & height because the result image was too big + let bbox = elem.getBoundingClientRect() + let newWidth = bbox.width + let newHeight = bbox.height + let svgXml = serializer.serializeToString(elem); + let imgSrc = 'data:image/svg+xml;base64,' + window.btoa(svgXml); + $(elem).replaceWith('' + ''); + }); +} - var tagOpenRegex = new RegExp(tagOpen, 'gi'); - var tagCloseRegex = new RegExp(tagClose, 'gi'); - contentString = contentString.replace(tagOpenRegex, '<'); - contentString = contentString.replace(tagCloseRegex, '>'); - contentString = contentString.replace(/&/gi, '&'); - contentString = contentString.replace(/&/gi, '&'); - - return contentString; - } catch (e) { - console.log('Error:', e); +// replaces all iframes by divs with the same innerHTML content +function extractIFrames() { + let allIframes = document.getElementsByTagName('iframe') + let changeIFrames = [] + let newDivs = [] + for (let iFrame of allIframes) { + if (!iFrame.contentDocument || !iFrame.contentDocument.body) { + continue + } + let bodyContent = iFrame.contentDocument.body.innerHTML + let bbox = iFrame.getBoundingClientRect() + let newDiv = document.createElement('div') + newDiv.style.width = bbox.width + newDiv.style.height = bbox.height + newDiv.innerHTML = bodyContent + changeIFrames.push(iFrame) + newDivs.push(newDiv) + } + for (let i = 0; i < newDivs.length; i++) { + let newDiv = newDivs[i] + let iFrame = changeIFrames[i] + let iframeParent = iFrame.parentNode + iframeParent.replaceChild(newDiv, iFrame) } } -// https://github.com/blowsie/Pure-JavaScript-HTML5-Parser -function sanitize(rawContentString) { - allImgSrc = {}; - var srcTxt = ''; - var dirty = null; +function preProcess($htmlObject) { + // TODO + // $htmlObject.find('script, style, noscript, iframe').remove(); + // $('body').find('script, style, noscript, iframe').remove() + // $('body').find('script, style, noscript, iframe').contents().remove() + // $('body').find('iframe').remove() + // $('body').find('*:empty').not('img').not('br').not('hr').remove(); + // formatPreCodeElements($('body')); + + extractMathMl($htmlObject); + extractCanvasToImg($htmlObject); + extractSvgToImg($htmlObject); +} + +function parseHTML(rawContentString) { + allImages = []; + extractedImages = []; + let results = ''; + let lastFragment = ''; + let lastTag = ''; + try { - // dirty = getHtmlAsString(rawContent); - wdirty = $.parseHTML(rawContentString); - $wdirty = $(wdirty); - $wdirty.find('script, style, svg, canvas, noscript').remove(); - $wdirty.find('*:empty').not('img').remove(); - - dirty = '
' + $wdirty.html() + '
'; - - //////////////// - if ($('*').length > maxNrOfElements) { - return force(dirty); - } - - var results = ''; - var lastFragment = ''; - var lastTag = ''; - var inList = false; - var allowedTags = ['div', 'p', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'span', 'blockquote', - 'img', 'a', 'ol', 'ul', 'li', 'b', 'i', 'sup', 'strong', 'strike', - 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'pre', 'em' - ]; - var allowedTextTags = ['h4', 'h5', 'h6', 'span']; - - HTMLParser(dirty, { + HTMLParser(rawContentString, { start: function(tag, attrs, unary) { lastTag = tag; if (allowedTags.indexOf(tag) < 0) { return; } - if (tag === 'ol' || tag === 'ul') { - inList = true; - } - if (tag === 'li' && !inList) { - tag = 'p'; - } - - var tattrs = null; if (tag === 'img') { - tattrs = attrs.filter(function(attr) { - return attr.name === 'src'; - }).map(function(attr) { - return getImageSrc(attr.escaped); - }); - lastFragment = tattrs.length === 0 ? '' : ''; + let tmpAttrsTxt = ''; + let tmpSrc = '' + for (let i = 0; i < attrs.length; i++) { + if (attrs[i].name === 'src') { + tmpSrc = getImageSrc(attrs[i].value) + tmpAttrsTxt += ' src="' + tmpSrc + '"'; + } else if (attrs[i].name === 'data-class') { + tmpAttrsTxt += ' class="' + attrs[i].value + '"'; + } else if (attrs[i].name === 'width') { + // used when converting svg to img - the result image was too big + tmpAttrsTxt += ' width="' + attrs[i].value + '"'; + } else if (attrs[i].name === 'height') { + // used when converting svg to img - the result image was too big + tmpAttrsTxt += ' height="' + attrs[i].value + '"'; + } + } + if (tmpSrc === '') { + // ignore imgs without source + lastFragment = '' + } else { + lastFragment = tmpAttrsTxt.length === 0 ? '' : ''; + } } else if (tag === 'a') { - tattrs = attrs.filter(function(attr) { - return attr.name === 'href'; - }).map(function(attr) { - return getHref(attr.escaped); - }); - lastFragment = tattrs.length === 0 ? '' : ''; + let tmpAttrsTxt = ''; + for (let i = 0; i < attrs.length; i++) { + if (attrs[i].name === 'href') { + tmpAttrsTxt += ' href="' + getHref(attrs[i].value) + '"'; + } else if (attrs[i].name === 'data-class') { + tmpAttrsTxt += ' class="' + attrs[i].value + '"'; + } + } + lastFragment = tmpAttrsTxt.length === 0 ? '' : ''; + } else if (tag === 'br' || tag === 'hr') { + let tmpAttrsTxt = ''; + for (let i = 0; i < attrs.length; i++) { + if (attrs[i].name === 'data-class') { + tmpAttrsTxt += ' class="' + attrs[i].value + '"'; + } + } + lastFragment = '<' + tag + tmpAttrsTxt + '>'; + } else if (tag === 'math') { + let tmpAttrsTxt = ''; + tmpAttrsTxt += ' xmlns="http://www.w3.org/1998/Math/MathML"'; + for (let i = 0; i < attrs.length; i++) { + if (attrs[i].name === 'alttext') { + tmpAttrsTxt += ' alttext="' + attrs[i].value + '"'; + } + } + lastFragment = '<' + tag + tmpAttrsTxt + '>'; } else { - lastFragment = '<' + tag + '>'; + let tmpAttrsTxt = ''; + for (let i = 0; i < attrs.length; i++) { + if (attrs[i].name === 'data-class') { + tmpAttrsTxt += ' class="' + attrs[i].value + '"'; + } + } + lastFragment = '<' + tag + tmpAttrsTxt + '>'; } results += lastFragment; lastFragment = ''; }, end: function(tag) { - if (allowedTags.indexOf(tag) < 0 || tag === 'img') { + if (allowedTags.indexOf(tag) < 0 || tag === 'img' || tag === 'br' || tag === 'hr') { return; } - if (tag === 'ol' || tag === 'ul') { - inList = false; - } - if (tag === 'li' && !inList) { - tag = 'p'; - } - - results += "\n"; + results += ""; }, chars: function(text) { if (lastTag !== '' && allowedTags.indexOf(lastTag) < 0) { @@ -232,49 +252,42 @@ function sanitize(rawContentString) { } }); - // results = results.replace(/&[a-z]+;/gim, ''); - results = results.replace(/&/gi, '&'); - results = results.replace(/&/gi, '&'); + // TODO - (re)move + results = results.replace(/ /gi, ' '); return results; } catch (e) { console.log('Error:', e); - return force(dirty); + return 'Error: ' + e //+" " + force($(rawContentString)) } } function getContent(htmlContent) { try { - var tmp = document.createElement('div'); + // TODO - move; called multiple times on selection + preProcess($('body')) + let tmp = document.createElement('div'); tmp.appendChild(htmlContent.cloneNode(true)); - var dirty = '
' + tmp.innerHTML + '
'; - return sanitize(dirty); + let tmpHtml = '
' + tmp.innerHTML + '
'; + return parseHTML(tmpHtml); } catch (e) { console.log('Error:', e); - return ''; + return htmlContent; } } ///// -function getPageUrl(url) { - return url.toLowerCase().replace(/\s+/g,'_').replace(/[^a-z0-9_]/g,'') + Math.floor(Math.random() * 10000) + '.xhtml'; -} - -function getPageTitle(title) { //TODO - return title; -} - function getSelectedNodes() { // if (document.selection) { // return document.selection.createRange().parentElement(); // return document.selection.createRange(); // } - var selection = window.getSelection(); - var docfrag = []; - for (var i = 0; i < selection.rangeCount; i++) { + let selection = window.getSelection(); + let docfrag = []; + for (let i = 0; i < selection.rangeCount; i++) { docfrag.push(selection.getRangeAt(i).cloneContents()); } return docfrag; @@ -282,67 +295,174 @@ function getSelectedNodes() { ///// -function deferredAddZip(url, filename, zip) { - var deferred = $.Deferred(); - JSZipUtils.getBinaryContent(url, function(err, data) { +function extractCss(includeStyle, appliedStyles) { + if (includeStyle) { + $('body').find('*').each((i, pre) => { + let $pre = $(pre); + + if (allowedTags.indexOf(pre.tagName.toLowerCase()) < 0) return; + if (mathMLTags.indexOf(pre.tagName.toLowerCase()) > -1) return; + + if (!$pre.is(':visible')) { + $pre.replaceWith(''); + } else { + if (pre.tagName.toLowerCase() === 'svg') return; + + let classNames = pre.getAttribute('class'); + if (!classNames) { + classNames = pre.getAttribute('id'); + if (!classNames) { + classNames = pre.tagName + '-' + generateRandomNumber(); + } + } + let tmpName = cssClassesToTmpIds[classNames]; + let tmpNewCss = tmpIdsToNewCss[tmpName]; + if (!tmpName) { + // TODO - collision between class names when multiple pages + tmpName = generateRandomTag(2) + i + cssClassesToTmpIds[classNames] = tmpName; + } + if (!tmpNewCss) { + tmpNewCss = {}; + + for (let cssTagName of supportedCss) { + let cssValue = $pre.css(cssTagName); + if (cssValue && cssValue.length > 0) { + tmpNewCss[cssTagName] = cssValue; + } + } + + // Reuse CSS - if the same css code was generated for another element, reuse it's class name + + let tcss = JSON.stringify(tmpNewCss) + let found = false + + if (Object.keys(tmpIdsToNewCssSTRING).length === 0) { + tmpIdsToNewCssSTRING[tmpName] = tcss; + tmpIdsToNewCss[tmpName] = tmpNewCss; + } else { + for (const key in tmpIdsToNewCssSTRING) { + if (tmpIdsToNewCssSTRING[key] === tcss) { + tmpName = key + found = true + break + } + } + if (!found) { + tmpIdsToNewCssSTRING[tmpName] = tcss; + tmpIdsToNewCss[tmpName] = tmpNewCss; + } + } + } + pre.setAttribute('data-class', tmpName); + } + }); + return jsonToCss(tmpIdsToNewCss); + } else { + // remove hidden elements when style is not included + $('body').find('*').each((i, pre) => { + let $pre = $(pre) + if (!$pre.is(':visible')) { + $pre.replaceWith('') + } + }) + let mergedCss = ''; + if (appliedStyles && appliedStyles.length > 0) { + for (let i = 0; i < appliedStyles.length; i++) { + mergedCss += appliedStyles[i].style; + } + return mergedCss; + } + } + return null +} + +///// + +function deferredAddZip(url, filename) { + let deferred = $.Deferred(); + JSZipUtils.getBinaryContent(url, function(err, data) { if (err) { // deferred.reject(err); TODO + console.log('Error:', err); deferred.resolve(); } else { - var tmpImg = { + // TODO - move to utils.js + if (filename.endsWith("TODO-EXTRACT")) { + let oldFilename = filename + let arr = (new Uint8Array(data)).subarray(0, 4); + let header = ""; + for(let i = 0; i < arr.length; i++) { + header += arr[i].toString(16); + } + if (header.startsWith("89504e47")) { + filename = filename.replace("TODO-EXTRACT", "png") + } else if (header.startsWith("47494638")) { + filename = filename.replace("TODO-EXTRACT", "gif") + } else if (header.startsWith("ffd8ff")) { + filename = filename.replace("TODO-EXTRACT", "jpg") + } else { + // ERROR + console.log("Error! Unable to extract the image type!"); + deferred.resolve(); + } + tmpGlobalContent = tmpGlobalContent.replace(oldFilename, filename) + } + + extractedImages.push({ filename: filename, + // TODO - must be JSON serializable data: base64ArrayBuffer(data) - }; - allImages.push(tmpImg); + }); + deferred.resolve(); } }); return deferred; } -chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { - console.log('Extract Html...'); - var imgsPromises = []; - allImgSrc = {}; - allImages = []; - var result = {}; - var pageSrc = ''; - var tmpContent = ''; +chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + let imgsPromises = []; + let result = {}; + let pageSrc = ''; + let tmpContent = ''; + let styleFile = null; + + extractIFrames() if (request.type === 'extract-page') { + styleFile = extractCss(request.includeStyle, request.appliedStyles) pageSrc = document.getElementsByTagName('body')[0]; tmpContent = getContent(pageSrc); } else if (request.type === 'extract-selection') { + styleFile = extractCss(request.includeStyle, request.appliedStyles) pageSrc = getSelectedNodes(); - pageSrc.forEach(function (page) { + pageSrc.forEach((page) => { tmpContent += getContent(page); }); } - if (tmpContent.trim() === '') { - return; - } + tmpGlobalContent = tmpContent - Object.keys(allImgSrc).forEach(function(imgSrc, index) { - try { - var tmpDeffered = deferredAddZip(getImgDownloadUrl(imgSrc), allImgSrc[imgSrc]); - imgsPromises.push(tmpDeffered); - } catch (e) { - console.log('Error:', e); - } + allImages.forEach((tmpImg) => { + imgsPromises.push(deferredAddZip(tmpImg.originalUrl, tmpImg.filename)); }); - $.when.apply($, imgsPromises).done(function() { + $.when.apply($, imgsPromises).done(() => { + let tmpTitle = getPageTitle(document.title); result = { - url: getPageUrl(document.title), - title: getPageTitle(document.title), + url: getPageUrl(tmpTitle), + title: tmpTitle, baseUrl: getCurrentUrl(), - images: allImages, - content: tmpContent + styleFileContent: styleFile, + styleFileName: 'style' + generateRandomNumber() + '.css', + images: extractedImages, + content: tmpGlobalContent }; sendResponse(result); - }).fail(function(e) { + }).fail((e) => { console.log('Error:', e); + sendResponse(null) }); return true; diff --git a/web-extension/filesaver.js b/web-extension/filesaver.js deleted file mode 100644 index 239db12..0000000 --- a/web-extension/filesaver.js +++ /dev/null @@ -1,188 +0,0 @@ -/* FileSaver.js - * A saveAs() FileSaver implementation. - * 1.3.2 - * 2016-06-16 18:25:19 - * - * By Eli Grey, http://eligrey.com - * License: MIT - * See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md - */ - -/*global self */ -/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */ - -/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */ - -var saveAs = saveAs || (function(view) { - "use strict"; - // IE <10 is explicitly unsupported - if (typeof view === "undefined" || typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) { - return; - } - var - doc = view.document - // only get URL when necessary in case Blob.js hasn't overridden it yet - , get_URL = function() { - return view.URL || view.webkitURL || view; - } - , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a") - , can_use_save_link = "download" in save_link - , click = function(node) { - var event = new MouseEvent("click"); - node.dispatchEvent(event); - } - , is_safari = /constructor/i.test(view.HTMLElement) - , is_chrome_ios =/CriOS\/[\d]+/.test(navigator.userAgent) - , throw_outside = function(ex) { - (view.setImmediate || view.setTimeout)(function() { - throw ex; - }, 0); - } - , force_saveable_type = "application/octet-stream" - // the Blob API is fundamentally broken as there is no "downloadfinished" event to subscribe to - , arbitrary_revoke_timeout = 1000 * 40 // in ms - , revoke = function(file) { - var revoker = function() { - if (typeof file === "string") { // file is an object URL - get_URL().revokeObjectURL(file); - } else { // file is a File - file.remove(); - } - }; - setTimeout(revoker, arbitrary_revoke_timeout); - } - , dispatch = function(filesaver, event_types, event) { - event_types = [].concat(event_types); - var i = event_types.length; - while (i--) { - var listener = filesaver["on" + event_types[i]]; - if (typeof listener === "function") { - try { - listener.call(filesaver, event || filesaver); - } catch (ex) { - throw_outside(ex); - } - } - } - } - , auto_bom = function(blob) { - // prepend BOM for UTF-8 XML and text/* types (including HTML) - // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF - if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { - return new Blob([String.fromCharCode(0xFEFF), blob], {type: blob.type}); - } - return blob; - } - , FileSaver = function(blob, name, no_auto_bom) { - if (!no_auto_bom) { - blob = auto_bom(blob); - } - // First try a.download, then web filesystem, then object URLs - var - filesaver = this - , type = blob.type - , force = type === force_saveable_type - , object_url - , dispatch_all = function() { - dispatch(filesaver, "writestart progress write writeend".split(" ")); - } - // on any filesys errors revert to saving with object URLs - , fs_error = function() { - if ((is_chrome_ios || (force && is_safari)) && view.FileReader) { - // Safari doesn't allow downloading of blob urls - var reader = new FileReader(); - reader.onloadend = function() { - var url = is_chrome_ios ? reader.result : reader.result.replace(/^data:[^;]*;/, 'data:attachment/file;'); - var popup = view.open(url, '_blank'); - if(!popup) view.location.href = url; - url=undefined; // release reference before dispatching - filesaver.readyState = filesaver.DONE; - dispatch_all(); - }; - reader.readAsDataURL(blob); - filesaver.readyState = filesaver.INIT; - return; - } - // don't create more object URLs than needed - if (!object_url) { - object_url = get_URL().createObjectURL(blob); - } - if (force) { - view.location.href = object_url; - } else { - var opened = view.open(object_url, "_blank"); - if (!opened) { - // Apple does not allow window.open, see https://developer.apple.com/library/safari/documentation/Tools/Conceptual/SafariExtensionGuide/WorkingwithWindowsandTabs/WorkingwithWindowsandTabs.html - view.location.href = object_url; - } - } - filesaver.readyState = filesaver.DONE; - dispatch_all(); - revoke(object_url); - } - ; - filesaver.readyState = filesaver.INIT; - - if (can_use_save_link) { - object_url = get_URL().createObjectURL(blob); - setTimeout(function() { - save_link.href = object_url; - save_link.download = name; - click(save_link); - dispatch_all(); - revoke(object_url); - filesaver.readyState = filesaver.DONE; - }); - return; - } - - fs_error(); - } - , FS_proto = FileSaver.prototype - , saveAs = function(blob, name, no_auto_bom) { - return new FileSaver(blob, name || blob.name || "download", no_auto_bom); - } - ; - // IE 10+ (native saveAs) - if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) { - return function(blob, name, no_auto_bom) { - name = name || blob.name || "download"; - - if (!no_auto_bom) { - blob = auto_bom(blob); - } - return navigator.msSaveOrOpenBlob(blob, name); - }; - } - - FS_proto.abort = function(){}; - FS_proto.readyState = FS_proto.INIT = 0; - FS_proto.WRITING = 1; - FS_proto.DONE = 2; - - FS_proto.error = - FS_proto.onwritestart = - FS_proto.onprogress = - FS_proto.onwrite = - FS_proto.onabort = - FS_proto.onerror = - FS_proto.onwriteend = - null; - - return saveAs; -}( - typeof self !== "undefined" && self - || typeof window !== "undefined" && window - || this.content -)); -// `self` is undefined in Firefox for Android content script context -// while `this` is nsIContentFrameMessageManager -// with an attribute `content` that corresponds to the window - -if (typeof module !== "undefined" && module.exports) { - module.exports.saveAs = saveAs; -} else if ((typeof define !== "undefined" && define !== null) && (define.amd !== null)) { - define([], function() { - return saveAs; - }); -} diff --git a/web-extension/icons/book128.png b/web-extension/icons/book128.png index 3b5d8fd..14ae99c 100644 Binary files a/web-extension/icons/book128.png and b/web-extension/icons/book128.png differ diff --git a/web-extension/icons/book32.png b/web-extension/icons/book32.png index 81d30eb..84ee1f9 100644 Binary files a/web-extension/icons/book32.png and b/web-extension/icons/book32.png differ diff --git a/web-extension/icons/book48.png b/web-extension/icons/book48.png index ada2f39..d06befb 100644 Binary files a/web-extension/icons/book48.png and b/web-extension/icons/book48.png differ diff --git a/web-extension/libs/cssjson.js b/web-extension/libs/cssjson.js new file mode 100644 index 0000000..01b6332 --- /dev/null +++ b/web-extension/libs/cssjson.js @@ -0,0 +1,297 @@ +/** + * CSS-JSON Converter for JavaScript + * Converts CSS to JSON and back. + * Version 2.1 + * + * Released under the MIT license. + * + * Copyright (c) 2013 Aram Kocharyan, http://aramk.com/ + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated + documentation files (the "Software"), to deal in the Software without restriction, including without limitation + the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and + to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial portions + of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO + THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +var CSSJSON = new function () { + + var base = this; + + base.init = function () { + // String functions + String.prototype.trim = function () { + return this.replace(/^\s+|\s+$/g, ''); + }; + + String.prototype.repeat = function (n) { + return new Array(1 + n).join(this); + }; + }; + base.init(); + + var selX = /([^\s\;\{\}][^\;\{\}]*)\{/g; + var endX = /\}/g; + var lineX = /([^\;\{\}]*)\;/g; + var commentX = /\/\*[\s\S]*?\*\//g; + var lineAttrX = /([^\:]+):([^\;]*);/; + + // This is used, a concatenation of all above. We use alternation to + // capture. + var altX = /(\/\*[\s\S]*?\*\/)|([^\s\;\{\}][^\;\{\}]*(?=\{))|(\})|([^\;\{\}]+\;(?!\s*\*\/))/gmi; + + // Capture groups + var capComment = 1; + var capSelector = 2; + var capEnd = 3; + var capAttr = 4; + + var isEmpty = function (x) { + return typeof x == 'undefined' || x.length == 0 || x == null; + }; + + var isCssJson = function (node) { + return !isEmpty(node) ? (node.attributes && node.children) : false; + } + + /** + * Input is css string and current pos, returns JSON object + * + * @param cssString + * The CSS string. + * @param args + * An optional argument object. ordered: Whether order of + * comments and other nodes should be kept in the output. This + * will return an object where all the keys are numbers and the + * values are objects containing "name" and "value" keys for each + * node. comments: Whether to capture comments. split: Whether to + * split each comma separated list of selectors. + */ + base.toJSON = function (cssString, args) { + var node = { + children: {}, + attributes: {} + }; + var match = null; + var count = 0; + + if (typeof args == 'undefined') { + var args = { + ordered: false, + comments: false, + stripComments: false, + split: false + }; + } + if (args.stripComments) { + args.comments = false; + cssString = cssString.replace(commentX, ''); + } + + while ((match = altX.exec(cssString)) != null) { + if (!isEmpty(match[capComment]) && args.comments) { + // Comment + var add = match[capComment].trim(); + node[count++] = add; + } else if (!isEmpty(match[capSelector])) { + // New node, we recurse + var name = match[capSelector].trim(); + // This will return when we encounter a closing brace + var newNode = base.toJSON(cssString, args); + if (args.ordered) { + var obj = {}; + obj['name'] = name; + obj['value'] = newNode; + // Since we must use key as index to keep order and not + // name, this will differentiate between a Rule Node and an + // Attribute, since both contain a name and value pair. + obj['type'] = 'rule'; + node[count++] = obj; + } else { + if (args.split) { + var bits = name.split(','); + } else { + var bits = [name]; + } + for (i in bits) { + var sel = bits[i].trim(); + if (sel in node.children) { + for (var att in newNode.attributes) { + node.children[sel].attributes[att] = newNode.attributes[att]; + } + } else { + node.children[sel] = newNode; + } + } + } + } else if (!isEmpty(match[capEnd])) { + // Node has finished + return node; + } else if (!isEmpty(match[capAttr])) { + var line = match[capAttr].trim(); + var attr = lineAttrX.exec(line); + if (attr) { + // Attribute + var name = attr[1].trim(); + var value = attr[2].trim(); + if (args.ordered) { + var obj = {}; + obj['name'] = name; + obj['value'] = value; + obj['type'] = 'attr'; + node[count++] = obj; + } else { + if (name in node.attributes) { + var currVal = node.attributes[name]; + if (!(currVal instanceof Array)) { + node.attributes[name] = [currVal]; + } + node.attributes[name].push(value); + } else { + node.attributes[name] = value; + } + } + } else { + // Semicolon terminated line + node[count++] = line; + } + } + } + + return node; + }; + + /** + * @param node + * A JSON node. + * @param depth + * The depth of the current node; used for indentation and + * optional. + * @param breaks + * Whether to add line breaks in the output. + */ + base.toCSS = function (node, depth, breaks) { + var cssString = ''; + if (typeof depth == 'undefined') { + depth = 0; + } + if (typeof breaks == 'undefined') { + breaks = false; + } + if (node.attributes) { + for (i in node.attributes) { + var att = node.attributes[i]; + if (att instanceof Array) { + for (var j = 0; j < att.length; j++) { + cssString += strAttr(i, att[j], depth); + } + } else { + cssString += strAttr(i, att, depth); + } + } + } + if (node.children) { + var first = true; + for (i in node.children) { + if (breaks && !first) { + cssString += '\n'; + } else { + first = false; + } + cssString += strNode(i, node.children[i], depth); + } + } + return cssString; + }; + + /** + * @param data + * You can pass css string or the CSSJS.toJSON return value. + * @param id (Optional) + * To identify and easy removable of the style element + * @param replace (Optional. defaults to TRUE) + * Whether to remove or simply do nothing + * @return HTMLLinkElement + */ + base.toHEAD = function (data, id, replace) { + var head = document.getElementsByTagName('head')[0]; + var xnode = document.getElementById(id); + var _xnodeTest = (xnode !== null && xnode instanceof HTMLStyleElement); + + if (isEmpty(data) || !(head instanceof HTMLHeadElement)) return; + if (_xnodeTest) { + if (replace === true || isEmpty(replace)) { + xnode.removeAttribute('id'); + } else return; + } + if (isCssJson(data)) { + data = base.toCSS(data); + } + + var node = document.createElement('style'); + node.type = 'text/css'; + + if (!isEmpty(id)) { + node.id = id; + } else { + node.id = 'cssjson_' + timestamp(); + } + if (node.styleSheet) { + node.styleSheet.cssText = data; + } else { + node.appendChild(document.createTextNode(data)); + } + + head.appendChild(node); + + if (isValidStyleNode(node)) { + if (_xnodeTest) { + xnode.parentNode.removeChild(xnode); + } + } else { + node.parentNode.removeChild(node); + if (_xnodeTest) { + xnode.setAttribute('id', id); + node = xnode; + } else return; + } + + return node; + }; + + // Alias + + if (typeof window != 'undefined') { + window.createCSS = base.toHEAD; + } + + // Helpers + + var isValidStyleNode = function (node) { + return (node instanceof HTMLStyleElement) && node.sheet.cssRules.length > 0; + } + + var timestamp = function () { + return Date.now() || +new Date(); + }; + + var strAttr = function (name, value, depth) { + return '\t'.repeat(depth) + name + ': ' + value + ';\n'; + }; + + var strNode = function (name, value, depth) { + var cssString = '\t'.repeat(depth) + name + ' {\n'; + cssString += base.toCSS(value, depth + 1); + cssString += '\t'.repeat(depth) + '}\n'; + return cssString; + }; + +}; diff --git a/web-extension/libs/filesaver.js b/web-extension/libs/filesaver.js new file mode 100644 index 0000000..5d204ae --- /dev/null +++ b/web-extension/libs/filesaver.js @@ -0,0 +1,171 @@ +/* +* FileSaver.js +* A saveAs() FileSaver implementation. +* +* By Eli Grey, http://eligrey.com +* +* License : https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md (MIT) +* source : http://purl.eligrey.com/github/FileSaver.js +*/ + +// The one and only way of getting global scope in all environments +// https://stackoverflow.com/q/3277182/1008999 +var _global = typeof window === 'object' && window.window === window + ? window : typeof self === 'object' && self.self === self + ? self : typeof global === 'object' && global.global === global + ? global + : this + +function bom (blob, opts) { + if (typeof opts === 'undefined') opts = { autoBom: false } + else if (typeof opts !== 'object') { + console.warn('Deprecated: Expected third argument to be a object') + opts = { autoBom: !opts } + } + + // prepend BOM for UTF-8 XML and text/* types (including HTML) + // note: your browser will automatically convert UTF-16 U+FEFF to EF BB BF + if (opts.autoBom && /^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) { + return new Blob([String.fromCharCode(0xFEFF), blob], { type: blob.type }) + } + return blob +} + +function download (url, name, opts) { + var xhr = new XMLHttpRequest() + xhr.open('GET', url) + xhr.responseType = 'blob' + xhr.onload = function () { + saveAs(xhr.response, name, opts) + } + xhr.onerror = function () { + console.error('could not download file') + } + xhr.send() +} + +function corsEnabled (url) { + var xhr = new XMLHttpRequest() + // use sync to avoid popup blocker + xhr.open('HEAD', url, false) + try { + xhr.send() + } catch (e) {} + return xhr.status >= 200 && xhr.status <= 299 +} + +// `a.click()` doesn't work for all browsers (#465) +function click (node) { + try { + node.dispatchEvent(new MouseEvent('click')) + } catch (e) { + var evt = document.createEvent('MouseEvents') + evt.initMouseEvent('click', true, true, window, 0, 0, 0, 80, + 20, false, false, false, false, 0, null) + node.dispatchEvent(evt) + } +} + +// Detect WebView inside a native macOS app by ruling out all browsers +// We just need to check for 'Safari' because all other browsers (besides Firefox) include that too +// https://www.whatismybrowser.com/guides/the-latest-user-agent/macos +var isMacOSWebView = _global.navigator && /Macintosh/.test(navigator.userAgent) && /AppleWebKit/.test(navigator.userAgent) && !/Safari/.test(navigator.userAgent) + +var saveAs = _global.saveAs || ( + // probably in some web worker + (typeof window !== 'object' || window !== _global) + ? function saveAs () { /* noop */ } + + // Use download attribute first if possible (#193 Lumia mobile) unless this is a macOS WebView + : ('download' in HTMLAnchorElement.prototype && !isMacOSWebView) + ? function saveAs (blob, name, opts) { + var URL = _global.URL || _global.webkitURL + var a = document.createElement('a') + name = name || blob.name || 'download' + + a.download = name + a.rel = 'noopener' // tabnabbing + + // TODO: detect chrome extensions & packaged apps + // a.target = '_blank' + + if (typeof blob === 'string') { + // Support regular links + a.href = blob + if (a.origin !== location.origin) { + corsEnabled(a.href) + ? download(blob, name, opts) + : click(a, a.target = '_blank') + } else { + click(a) + } + } else { + // Support blobs + a.href = URL.createObjectURL(blob) + setTimeout(function () { URL.revokeObjectURL(a.href) }, 4E4) // 40s + setTimeout(function () { click(a) }, 0) + } + } + + // Use msSaveOrOpenBlob as a second approach + : 'msSaveOrOpenBlob' in navigator + ? function saveAs (blob, name, opts) { + name = name || blob.name || 'download' + + if (typeof blob === 'string') { + if (corsEnabled(blob)) { + download(blob, name, opts) + } else { + var a = document.createElement('a') + a.href = blob + a.target = '_blank' + setTimeout(function () { click(a) }) + } + } else { + navigator.msSaveOrOpenBlob(bom(blob, opts), name) + } + } + + // Fallback to using FileReader and a popup + : function saveAs (blob, name, opts, popup) { + // Open a popup immediately do go around popup blocker + // Mostly only available on user interaction and the fileReader is async so... + popup = popup || open('', '_blank') + if (popup) { + popup.document.title = + popup.document.body.innerText = 'downloading...' + } + + if (typeof blob === 'string') return download(blob, name, opts) + + var force = blob.type === 'application/octet-stream' + var isSafari = /constructor/i.test(_global.HTMLElement) || _global.safari + var isChromeIOS = /CriOS\/[\d]+/.test(navigator.userAgent) + + if ((isChromeIOS || (force && isSafari) || isMacOSWebView) && typeof FileReader !== 'undefined') { + // Safari doesn't allow downloading of blob URLs + var reader = new FileReader() + reader.onloadend = function () { + var url = reader.result + url = isChromeIOS ? url : url.replace(/^data:[^;]*;/, 'data:attachment/file;') + if (popup) popup.location.href = url + else location = url + popup = null // reverse-tabnabbing #460 + } + reader.readAsDataURL(blob) + } else { + var URL = _global.URL || _global.webkitURL + var url = URL.createObjectURL(blob) + if (popup) popup.location = url + else location.href = url + popup = null // reverse-tabnabbing #460 + setTimeout(function () { URL.revokeObjectURL(url) }, 4E4) // 40s + } + } +) + +_global.saveAs = saveAs.saveAs = saveAs + +if (typeof module !== 'undefined') { + module.exports = saveAs; +} diff --git a/web-extension/jquery-sortable.js b/web-extension/libs/jquery-sortable.js similarity index 100% rename from web-extension/jquery-sortable.js rename to web-extension/libs/jquery-sortable.js diff --git a/web-extension/jquery.js b/web-extension/libs/jquery.js similarity index 100% rename from web-extension/jquery.js rename to web-extension/libs/jquery.js diff --git a/web-extension/jszip-utils.js b/web-extension/libs/jszip-utils.js similarity index 100% rename from web-extension/jszip-utils.js rename to web-extension/libs/jszip-utils.js diff --git a/web-extension/jszip.js b/web-extension/libs/jszip.js similarity index 100% rename from web-extension/jszip.js rename to web-extension/libs/jszip.js diff --git a/web-extension/pure-parser.js b/web-extension/libs/pure-parser.js similarity index 100% rename from web-extension/pure-parser.js rename to web-extension/libs/pure-parser.js diff --git a/web-extension/manifest.json b/web-extension/manifest.json index d0a90e3..0b65008 100644 --- a/web-extension/manifest.json +++ b/web-extension/manifest.json @@ -1,39 +1,60 @@ { - "manifest_version": 2, - "name": "Save As eBook", - "version": "1.0", - + "name": "__MSG_extName__", + "version": "1.4.2", + "default_locale": "en", + "author": "Alex Adam", + "homepage_url": "https://github.com/alexadam/save-as-ebook", "description": "Save a web page or selection as eBook (.epub)", - "icons": { "48": "icons/book48.png" }, - "content_scripts": [{ "matches": [""], - "js": ["jquery.js", "jszip.js", "jszip-utils.js", "utils.js", "pure-parser.js", "extractHtml.js"] + "js": ["./libs/jquery.js", "./libs/jszip.js", "./libs/jszip-utils.js", + "./libs/pure-parser.js", "./libs/cssjson.js", "./libs/filesaver.js", + "saveEbook.js", "extractHtml.js", "utils.js"] }], - "background": { "scripts": ["background.js"] }, - "browser_action": { "default_icon": "icons/book32.png", "default_title": "Save as eBook", "default_popup": "menu.html" }, - "permissions": [ "contextMenus", "activeTab", "storage", - "tabs", + "unlimitedStorage", "notifications", - "http://*/*", - "https://*/*", "" - ] - + ], + "commands": { + "save-page": { + "suggested_key": { + "default": "Alt+Shift+1" + }, + "description": "Save current page as eBook" + }, + "save-selection": { + "suggested_key": { + "default": "Alt+Shift+2" + }, + "description": "Save current selection as eBook" + }, + "add-page": { + "suggested_key": { + "default": "Alt+Shift+3" + }, + "description": "Add current page as chapter" + }, + "add-selection": { + "suggested_key": { + "default": "Alt+Shift+4" + }, + "description": "Add current selection as chapter" + } + } } diff --git a/web-extension/menu.html b/web-extension/menu.html index 7973fba..0906d7b 100644 --- a/web-extension/menu.html +++ b/web-extension/menu.html @@ -2,57 +2,162 @@ - - + .optionContainer { + font-family: sans-serif; + font-size: 14px; + padding: 6px 0; + } + + button { + font-family: sans-serif; + font-size: 14px; + width: 100%; + padding: 6px 0; + padding-left: 8px; + border: none; + background-color: white; + text-align: left; + outline: none; + } + + button:hover { + background-color: #007fff; + color: white; + cursor: pointer; + } + + #busy { + background-color: rgba(0, 0, 0, 0.75); + width: 100%; + height: 100%; + display: none; + position: fixed; + top: 0; + margin: 0; + padding: 0; + } + + .spinner { + height: 28px; + width: 28px; + animation: rotate 0.8s infinite linear; + border: 8px solid #fff; + border-right-color: transparent; + border-radius: 50%; + left: 40%; + top: 40%; + position: absolute; + } + + .wait-message { + font-size: 20px; + color: white; + padding: 5px; + } + + @keyframes rotate { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + #includeStyle { + font-family: sans-serif; + font-size: 14px; + padding: 0; + padding-bottom: 8px; + background-color: white; + text-align: left; + } + + select { + font-family: sans-serif; + font-size: 14px; + padding: 2px; + outline: none; + } + + input[type="checkbox"] { + outline: none; + font-family: sans-serif; + font-size: 14px; + } + #includeStyle { + outline: none; + font-family: sans-serif; + font-size: 14px; + } + .shortcut { + font-size: 10px; + color: gray; + } + -