From 8d28c14133437f4ea0bf05331198cd3cb5a3a5d9 Mon Sep 17 00:00:00 2001 From: zadam Date: Fri, 19 Jul 2019 20:35:53 +0200 Subject: [PATCH 01/80] initial import --- .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/workspace.xml | 10 + README.md | 1 + background.js | 313 ++++++ bin/build.sh | 20 + bin/release.sh | 68 ++ build.js | 1 + content.js | 299 ++++++ icons/32-dev.png | Bin 0 -> 4720 bytes icons/32.png | Bin 0 -> 2157 bytes icons/48.png | Bin 0 -> 3477 bytes icons/96.png | Bin 0 -> 18281 bytes lib/JSDOMParser.js | 1190 +++++++++++++++++++++ lib/Readability-readerable.js | 99 ++ lib/Readability.js | 1854 +++++++++++++++++++++++++++++++++ lib/browser-polyfill.js | 1187 +++++++++++++++++++++ lib/cash.min.js | 37 + lib/toast.js | 266 +++++ manifest.json | 55 + options/options.html | 67 ++ options/options.js | 140 +++ popup/popup.css | 47 + popup/popup.html | 46 + popup/popup.js | 138 +++ trilium-web-clipper.iml | 9 + trilium_server_facade.js | 166 +++ utils.js | 10 + 28 files changed, 6037 insertions(+) create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/workspace.xml create mode 100644 README.md create mode 100644 background.js create mode 100755 bin/build.sh create mode 100755 bin/release.sh create mode 100644 build.js create mode 100644 content.js create mode 100644 icons/32-dev.png create mode 100644 icons/32.png create mode 100644 icons/48.png create mode 100644 icons/96.png create mode 100644 lib/JSDOMParser.js create mode 100644 lib/Readability-readerable.js create mode 100644 lib/Readability.js create mode 100644 lib/browser-polyfill.js create mode 100644 lib/cash.min.js create mode 100644 lib/toast.js create mode 100644 manifest.json create mode 100644 options/options.html create mode 100644 options/options.js create mode 100644 popup/popup.css create mode 100644 popup/popup.html create mode 100644 popup/popup.js create mode 100644 trilium-web-clipper.iml create mode 100644 trilium_server_facade.js create mode 100644 utils.js diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..639900d13 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..ebf785642 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 000000000..cf6a334d8 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 000000000..8c47f372f --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# Trilium Web Clipper diff --git a/background.js b/background.js new file mode 100644 index 000000000..74b3227b7 --- /dev/null +++ b/background.js @@ -0,0 +1,313 @@ +function cropImage(newArea, dataUrl) { + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = function () { + const canvas = document.createElement('canvas'); + canvas.width = newArea.width; + canvas.height = newArea.height; + + const ctx = canvas.getContext('2d'); + + ctx.drawImage(img, newArea.x, newArea.y, newArea.width, newArea.height, 0, 0, newArea.width, newArea.height); + + resolve(canvas.toDataURL()); + }; + + img.src = dataUrl; + }); +} + +async function takeScreenshot(cropRect) { + const activeTab = await getActiveTab(); + + const zoom = await browser.tabs.getZoom(activeTab.id); + const newArea = Object.assign({}, cropRect); + newArea.x *= zoom; + newArea.y *= zoom; + newArea.width *= zoom; + newArea.height *= zoom; + + const dataUrl = await browser.tabs.captureVisibleTab(null, { format: 'png' }); + + return await cropImage(newArea, dataUrl); +} + +browser.runtime.onInstalled.addListener(() => { + if (isDevEnv()) { + browser.browserAction.setIcon({ + path: 'icons/32-dev.png', + }); + } +}); + +browser.contextMenus.create({ + id: "trilium-save-selection", + title: "Save selection to Trilium", + contexts: ["selection"] +}); + +browser.contextMenus.create({ + id: "trilium-save-screenshot", + title: "Clip screenshot to Trilium", + contexts: ["page"] +}); + +browser.contextMenus.create({ + id: "trilium-save-page", + title: "Save whole page to Trilium", + contexts: ["page"] +}); + +browser.contextMenus.create({ + id: "trilium-save-link", + title: "Save link to Trilium", + contexts: ["link"] +}); + +browser.contextMenus.create({ + id: "trilium-save-image", + title: "Save image to Trilium", + contexts: ["image"] +}); + +async function getActiveTab() { + const tabs = await browser.tabs.query({ + active: true, + currentWindow: true + }); + + return tabs[0]; +} + +async function sendMessageToActiveTab(message) { + const activeTab = await getActiveTab(); + + if (!activeTab) { + throw new Error("No active tab."); + } + + try { + return await browser.tabs.sendMessage(activeTab.id, message); + } + catch (e) { + console.error("Sending message to active tab failed, you might need to refresh the page after updating the extension.", e); + } +} + +function toast(message, noteId = null) { + sendMessageToActiveTab({ + name: 'toast', + message: message, + noteId: noteId + }); +} + +function blob2base64(blob) { + return new Promise(resolve => { + const reader = new FileReader(); + reader.onloadend = function() { + resolve(reader.result); + }; + reader.readAsDataURL(blob); + }); +} + +async function fetchImage(url) { + const resp = await fetch(url); + const blob = await resp.blob(); + + return await blob2base64(blob); +} + +async function postProcessImage(image) { + if (image.src.startsWith("data:image/")) { + image.dataUrl = image.src; + image.src = "inline." + image.src.substr(11, 3); // this should extract file type - png/jpg + + return; + } + + image.dataUrl = await fetchImage(image.src, image); +} + +async function postProcessImages(resp) { + if (resp.images) { + for (const image of resp.images) { + await postProcessImage(image); + } + } +} + +async function saveSelection() { + const payload = await sendMessageToActiveTab({name: 'trilium-save-selection'}); + + await postProcessImages(payload); + + const resp = await triliumServerFacade.callService('POST', 'clippings', payload); + + if (!resp) { + return; + } + + toast("Selection has been saved to Trilium.", resp.noteId); +} + +async function getImagePayloadFromSrc(src, pageUrl) { + const image = { + imageId: randomString(20), + src: src + }; + + await postProcessImage(image); + + const activeTab = await getActiveTab(); + + return { + title: activeTab.title, + content: ``, + images: [image], + pageUrl: pageUrl + }; +} + +async function saveScreenshot(pageUrl) { + const cropRect = await sendMessageToActiveTab({name: 'trilium-save-screenshot'}); + + const src = await takeScreenshot(cropRect); + + const payload = await getImagePayloadFromSrc(src, pageUrl); + + const resp = await triliumServerFacade.callService("POST", "clippings", payload); + + if (!resp) { + return; + } + + toast("Screenshot has been saved to Trilium.", resp.noteId); +} + +async function saveImage(srcUrl, pageUrl) { + const payload = await getImagePayloadFromSrc(srcUrl, pageUrl); + + const resp = await triliumServerFacade.callService("POST", "clippings", payload); + + if (!resp) { + return; + } + + toast("Image has been saved to Trilium.", resp.noteId); +} + +async function saveWholePage() { + const payload = await sendMessageToActiveTab({name: 'trilium-save-page'}); + + await postProcessImages(payload); + + const resp = await triliumServerFacade.callService('POST', 'notes', payload); + + if (!resp) { + return; + } + + toast("Page has been saved to Trilium.", resp.noteId); +} + +async function saveNote(title, content) { + const resp = await triliumServerFacade.callService('POST', 'notes', { + title: title, + content: content + }); + + if (!resp) { + return false; + } + + toast("Note has been saved to Trilium.", resp.noteId); + + return true; +} + +browser.contextMenus.onClicked.addListener(async function(info, tab) { + if (info.menuItemId === 'trilium-save-selection') { + await saveSelection(); + } + else if (info.menuItemId === 'trilium-save-screenshot') { + await saveScreenshot(info.pageUrl); + } + else if (info.menuItemId === 'trilium-save-image') { + await saveImage(info.srcUrl, info.pageUrl); + } + else if (info.menuItemId === 'trilium-save-link') { + const link = document.createElement("a"); + link.href = info.linkUrl; + // linkText might be available only in firefox + link.appendChild(document.createTextNode(info.linkText || info.linkUrl)); + + const activeTab = await getActiveTab(); + + const resp = await triliumServerFacade.callService('POST', 'clippings', { + title: activeTab.title, + content: link.outerHTML, + pageUrl: info.pageUrl + }); + + if (!resp) { + return; + } + + toast("Link has been saved to Trilium.", resp.noteId); + } + else if (info.menuItemId === 'trilium-save-page') { + await saveWholePage(); + } + else { + console.log("Unrecognized menuItemId", info.menuItemId); + } +}); + +browser.runtime.onMessage.addListener(async request => { + if (request.name === 'openNoteInTrilium') { + const resp = await triliumServerFacade.callService('POST', 'open/' + request.noteId); + + if (!resp) { + return; + } + + // desktop app is not available so we need to open in browser + if (resp.result === 'open-in-browser') { + const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl"); + + if (triliumServerUrl) { + const noteUrl = triliumServerUrl + '/#' + request.noteId; + + console.log("Opening new tab in browser", noteUrl); + + browser.tabs.create({ + url: noteUrl + }); + } + else { + console.error("triliumServerUrl not found in local storage."); + } + } + } + else if (request.name === 'load-script') { + return await browser.tabs.executeScript({file: request.file}); + } + else if (request.name === 'save-screenshot') { + return await saveScreenshot(); + } + else if (request.name === 'save-whole-page') { + return await saveWholePage(); + } + else if (request.name === 'save-note') { + return await saveNote(request.title, request.content); + } + else if (request.name === 'trigger-trilium-search') { + triliumServerFacade.triggerSearchForTrilium(); + } + else if (request.name === 'send-trilium-search-status') { + triliumServerFacade.sendTriliumSearchStatusToPopup(); + } +}); \ No newline at end of file diff --git a/bin/build.sh b/bin/build.sh new file mode 100755 index 000000000..341448e90 --- /dev/null +++ b/bin/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +VERSION=$(jq -r ".version" manifest.json) + +ARTIFACT_NAME=trilium-web-clipper-${VERSION} +BUILD_DIR=dist/$ARTIFACT_NAME + +rm -rf dist +mkdir -p "$BUILD_DIR" + +cp -r icons lib options popup *.js manifest.json "$BUILD_DIR" + +cd dist/"${ARTIFACT_NAME}" || exit + +jq '.name = "Trilium Web Clipper"' manifest.json | sponge manifest.json + +zip -r ../"${ARTIFACT_NAME}".zip * + +cd .. +rm -r "${ARTIFACT_NAME}" \ No newline at end of file diff --git a/bin/release.sh b/bin/release.sh new file mode 100755 index 000000000..984331efc --- /dev/null +++ b/bin/release.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +export GITHUB_REPO=trilium-webclipper + +if [[ $# -eq 0 ]] ; then + echo "Missing argument of new version" + exit 1 +fi + +VERSION=$1 + +if ! [[ ${VERSION} =~ ^[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,2}(-.+)?$ ]] ; +then + echo "Version ${VERSION} isn't in format X.Y.Z" + exit 1 +fi + +if ! git diff-index --quiet HEAD --; then + echo "There are uncommitted changes" + exit 1 +fi + +echo "Releasing Trilium Web Clipper $VERSION" + +jq '.version = "'"$VERSION"'"' manifest.json | sponge manifest.json + +git add manifest.json + +echo 'module.exports = { buildDate:"'$(date --iso-8601=seconds)'", buildRevision: "'$(git log -1 --format="%H")'" };' > build.js + +git add build.js + +TAG=v$VERSION + +echo "Committing package.json version change" + +git commit -m "release $VERSION" +git push + +echo "Tagging commit with $TAG" + +git tag "$TAG" +git push origin "$TAG" + +bin/build.sh + +BUILD=trilium-web-clipper-$VERSION.zip + +echo "Creating release in GitHub" + +EXTRA= + +if [[ $TAG == *"beta"* ]]; then + EXTRA=--pre-release +fi + +github-release release \ + --tag "$TAG" \ + --name "$TAG release" $EXTRA + +echo "Uploading build package" + +github-release upload \ + --tag "$TAG" \ + --name "$BUILD" \ + --file "dist/$BUILD" + +echo "Release finished!" \ No newline at end of file diff --git a/build.js b/build.js new file mode 100644 index 000000000..adacd51cb --- /dev/null +++ b/build.js @@ -0,0 +1 @@ +module.exports = { buildDate:"2019-07-19T19:49:14+02:00", buildRevision: "a047e83b8c8746a20018644c3660ad52d79c09a1" }; diff --git a/content.js b/content.js new file mode 100644 index 000000000..70044fd58 --- /dev/null +++ b/content.js @@ -0,0 +1,299 @@ +function absoluteUrl(url) { + if (!url) { + return url; + } + + const protocol = url.toLowerCase().split(':')[0]; + if (['http', 'https', 'file'].indexOf(protocol) >= 0) { + return url; + } + + if (url.indexOf('//') === 0) { + return location.protocol + url; + } else if (url[0] === '/') { + return location.protocol + '//' + location.host + url; + } else { + return getBaseUrl() + '/' + url; + } +} + +function pageTitle() { + const titleElements = document.getElementsByTagName("title"); + + return titleElements.length ? titleElements[0].text.trim() : document.title.trim(); +} + +function getPageLocationOrigin() { + // location.origin normally returns the protocol + domain + port (eg. https://example.com:8080) + // but for file:// protocol this is browser dependant and in particular Firefox returns "null" in this case. + return location.protocol === 'file:' ? 'file://' : location.origin; +} + +function getBaseUrl() { + let output = getPageLocationOrigin() + location.pathname; + + if (output[output.length - 1] !== '/') { + output = output.split('/'); + output.pop(); + output = output.join('/'); + } + + return output; +} + +function getReadableDocument() { + // Readability directly change the passed document so clone it so as + // to preserve the original web page. + const documentCopy = document.cloneNode(true); + const readability = new Readability(documentCopy); + const article = readability.parse(); + + if (!article) { + throw new Error('Could not parse HTML document with Readability'); + } + + return { + title: article.title, + body: article.articleContent, + } +} + +function getRectangleArea() { + return new Promise((resolve, reject) => { + const overlay = document.createElement('div'); + overlay.style.opacity = '0.6'; + overlay.style.background = 'black'; + overlay.style.width = '100%'; + overlay.style.height = '100%'; + overlay.style.zIndex = 99999999; + overlay.style.top = 0; + overlay.style.left = 0; + overlay.style.position = 'fixed'; + + document.body.appendChild(overlay); + + const messageComp = document.createElement('div'); + + const messageCompWidth = 300; + messageComp.style.position = 'fixed'; + messageComp.style.opacity = '0.95'; + messageComp.style.fontSize = '14px'; + messageComp.style.width = messageCompWidth + 'px'; + messageComp.style.maxWidth = messageCompWidth + 'px'; + messageComp.style.border = '1px solid black'; + messageComp.style.background = 'white'; + messageComp.style.color = 'black'; + messageComp.style.top = '10px'; + messageComp.style.textAlign = 'center'; + messageComp.style.padding = '10px'; + messageComp.style.left = Math.round(document.body.clientWidth / 2 - messageCompWidth / 2) + 'px'; + messageComp.style.zIndex = overlay.style.zIndex + 1; + + messageComp.textContent = 'Drag and release to capture a screenshot'; + + document.body.appendChild(messageComp); + + const selection = document.createElement('div'); + selection.style.opacity = '0.5'; + selection.style.border = '1px solid red'; + selection.style.background = 'white'; + selection.style.border = '2px solid black'; + selection.style.zIndex = overlay.style.zIndex - 1; + selection.style.top = 0; + selection.style.left = 0; + selection.style.position = 'fixed'; + + document.body.appendChild(selection); + + let isDragging = false; + let draggingStartPos = null; + let selectionArea = {}; + + function updateSelection() { + selection.style.left = selectionArea.x + 'px'; + selection.style.top = selectionArea.y + 'px'; + selection.style.width = selectionArea.width + 'px'; + selection.style.height = selectionArea.height + 'px'; + } + + function setSelectionSizeFromMouse(event) { + if (event.clientX < draggingStartPos.x) { + selectionArea.x = event.clientX; + } + + if (event.clientY < draggingStartPos.y) { + selectionArea.y = event.clientY; + } + + selectionArea.width = Math.max(1, Math.abs(event.clientX - draggingStartPos.x)); + selectionArea.height = Math.max(1, Math.abs(event.clientY - draggingStartPos.y)); + updateSelection(); + } + + function selection_mouseDown(event) { + selectionArea = {x: event.clientX, y: event.clientY, width: 0, height: 0}; + draggingStartPos = {x: event.clientX, y: event.clientY}; + isDragging = true; + updateSelection(); + } + + function selection_mouseMove(event) { + if (!isDragging) return; + setSelectionSizeFromMouse(event); + } + + function selection_mouseUp(event) { + setSelectionSizeFromMouse(event); + + isDragging = false; + + overlay.removeEventListener('mousedown', selection_mouseDown); + overlay.removeEventListener('mousemove', selection_mouseMove); + overlay.removeEventListener('mouseup', selection_mouseUp); + + document.body.removeChild(overlay); + document.body.removeChild(selection); + document.body.removeChild(messageComp); + + console.info('selectionArea:', selectionArea); + + if (!selectionArea || !selectionArea.width || !selectionArea.height) { + return; + } + + // Need to wait a bit before taking the screenshot to make sure + // the overlays have been removed and don't appear in the + // screenshot. 10ms is not enough. + setTimeout(() => resolve(selectionArea), 100); + } + + overlay.addEventListener('mousedown', selection_mouseDown); + overlay.addEventListener('mousemove', selection_mouseMove); + overlay.addEventListener('mouseup', selection_mouseUp); + }); +} + +function makeLinksAbsolute(container) { + for (const link of container.getElementsByTagName('a')) { + if (link.href) { + link.href = absoluteUrl(link.href); + } + } +} + +function getImages(container) { + const images = []; + + for (const img of container.getElementsByTagName('img')) { + if (!img.src) { + continue; + } + + const imageId = randomString(20); + + images.push({ + imageId: imageId, + src: img.src + }); + + img.src = imageId; + } + + return images; +} + +async function prepareMessageResponse(message) { + console.info('Message: ' + message.name); + + if (message.name === "toast") { + let messageText; + + if (message.noteId) { + messageText = document.createElement('span'); + messageText.appendChild(document.createTextNode(message.message + " ")); + + const link = document.createElement('a'); + link.href = "javascript:"; + link.style.color = "lightskyblue"; + link.appendChild(document.createTextNode("Open in Trilium.")); + link.addEventListener("click", () => { + browser.runtime.sendMessage(null, { + name: 'openNoteInTrilium', + noteId: message.noteId + }) + }); + + messageText.appendChild(link); + } + else { + messageText = message.message; + } + + await requireLib('/lib/toast.js'); + + showToast(messageText, { + settings: { + duration: 5000 + } + }); + } + else if (message.name === "trilium-save-selection") { + const container = document.createElement('div'); + + const selection = window.getSelection(); + + for (let i = 0; i < selection.rangeCount; i++) { + const range = selection.getRangeAt(i); + + container.appendChild(range.cloneContents()); + } + + makeLinksAbsolute(container); + + const images = getImages(container); + + return { + title: pageTitle(), + content: container.innerHTML, + images: images, + pageUrl: getPageLocationOrigin() + location.pathname + location.search + }; + + } + else if (message.name === 'trilium-save-screenshot') { + return getRectangleArea(); + } + else if (message.name === "trilium-save-page") { + await requireLib("/lib/JSDOMParser.js"); + await requireLib("/lib/Readability.js"); + await requireLib("/lib/Readability-readerable.js"); + + const {title, body} = getReadableDocument(); + + makeLinksAbsolute(body); + + const images = getImages(body); + + return { + title: title, + content: body.innerHTML, + images: images, + pageUrl: getPageLocationOrigin() + location.pathname + location.search + }; + } + else { + throw new Error('Unknown command: ' + JSON.stringify(message)); + } +} + +browser.runtime.onMessage.addListener(prepareMessageResponse); + +const loadedLibs = []; + +async function requireLib(libPath) { + if (!loadedLibs.includes(libPath)) { + loadedLibs.push(libPath); + + await browser.runtime.sendMessage({name: 'load-script', file: libPath}); + } +} \ No newline at end of file diff --git a/icons/32-dev.png b/icons/32-dev.png new file mode 100644 index 0000000000000000000000000000000000000000..105f340b58b2e93ccd865103a05ce537ca0777a7 GIT binary patch literal 4720 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 literal 0 HcmV?d00001 diff --git a/icons/48.png b/icons/48.png new file mode 100644 index 0000000000000000000000000000000000000000..d214d1221277cc2be1b26fb37bfab4891ff23a58 GIT binary patch literal 3477 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 literal 0 HcmV?d00001 diff --git a/icons/96.png b/icons/96.png new file mode 100644 index 0000000000000000000000000000000000000000..fbc5ec92ab33ea6f054ecd23d7167ef20e542292 GIT binary patch literal 18281 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 A!2kdN literal 0 HcmV?d00001 diff --git a/lib/JSDOMParser.js b/lib/JSDOMParser.js new file mode 100644 index 000000000..dd7cb3a0a --- /dev/null +++ b/lib/JSDOMParser.js @@ -0,0 +1,1190 @@ +// https://github.com/mozilla/readability/tree/814f0a3884350b6f1adfdebb79ca3599e9806605 + +/*eslint-env es6:false*/ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This is a relatively lightweight DOMParser that is safe to use in a web + * worker. This is far from a complete DOM implementation; however, it should + * contain the minimal set of functionality necessary for Readability.js. + * + * Aside from not implementing the full DOM API, there are other quirks to be + * aware of when using the JSDOMParser: + * + * 1) Properly formed HTML/XML must be used. This means you should be extra + * careful when using this parser on anything received directly from an + * XMLHttpRequest. Providing a serialized string from an XMLSerializer, + * however, should be safe (since the browser's XMLSerializer should + * generate valid HTML/XML). Therefore, if parsing a document from an XHR, + * the recommended approach is to do the XHR in the main thread, use + * XMLSerializer.serializeToString() on the responseXML, and pass the + * resulting string to the worker. + * + * 2) Live NodeLists are not supported. DOM methods and properties such as + * getElementsByTagName() and childNodes return standard arrays. If you + * want these lists to be updated when nodes are removed or added to the + * document, you must take care to manually update them yourself. + */ +(function (global) { + + // XML only defines these and the numeric ones: + + var entityTable = { + "lt": "<", + "gt": ">", + "amp": "&", + "quot": '"', + "apos": "'", + }; + + var reverseEntityTable = { + "<": "<", + ">": ">", + "&": "&", + '"': """, + "'": "'", + }; + + function encodeTextContentHTML(s) { + return s.replace(/[&<>]/g, function(x) { + return reverseEntityTable[x]; + }); + } + + function encodeHTML(s) { + return s.replace(/[&<>'"]/g, function(x) { + return reverseEntityTable[x]; + }); + } + + function decodeHTML(str) { + return str.replace(/&(quot|amp|apos|lt|gt);/g, function(match, tag) { + return entityTable[tag]; + }).replace(/&#(?:x([0-9a-z]{1,4})|([0-9]{1,4}));/gi, function(match, hex, numStr) { + var num = parseInt(hex || numStr, hex ? 16 : 10); // read num + return String.fromCharCode(num); + }); + } + + // When a style is set in JS, map it to the corresponding CSS attribute + var styleMap = { + "alignmentBaseline": "alignment-baseline", + "background": "background", + "backgroundAttachment": "background-attachment", + "backgroundClip": "background-clip", + "backgroundColor": "background-color", + "backgroundImage": "background-image", + "backgroundOrigin": "background-origin", + "backgroundPosition": "background-position", + "backgroundPositionX": "background-position-x", + "backgroundPositionY": "background-position-y", + "backgroundRepeat": "background-repeat", + "backgroundRepeatX": "background-repeat-x", + "backgroundRepeatY": "background-repeat-y", + "backgroundSize": "background-size", + "baselineShift": "baseline-shift", + "border": "border", + "borderBottom": "border-bottom", + "borderBottomColor": "border-bottom-color", + "borderBottomLeftRadius": "border-bottom-left-radius", + "borderBottomRightRadius": "border-bottom-right-radius", + "borderBottomStyle": "border-bottom-style", + "borderBottomWidth": "border-bottom-width", + "borderCollapse": "border-collapse", + "borderColor": "border-color", + "borderImage": "border-image", + "borderImageOutset": "border-image-outset", + "borderImageRepeat": "border-image-repeat", + "borderImageSlice": "border-image-slice", + "borderImageSource": "border-image-source", + "borderImageWidth": "border-image-width", + "borderLeft": "border-left", + "borderLeftColor": "border-left-color", + "borderLeftStyle": "border-left-style", + "borderLeftWidth": "border-left-width", + "borderRadius": "border-radius", + "borderRight": "border-right", + "borderRightColor": "border-right-color", + "borderRightStyle": "border-right-style", + "borderRightWidth": "border-right-width", + "borderSpacing": "border-spacing", + "borderStyle": "border-style", + "borderTop": "border-top", + "borderTopColor": "border-top-color", + "borderTopLeftRadius": "border-top-left-radius", + "borderTopRightRadius": "border-top-right-radius", + "borderTopStyle": "border-top-style", + "borderTopWidth": "border-top-width", + "borderWidth": "border-width", + "bottom": "bottom", + "boxShadow": "box-shadow", + "boxSizing": "box-sizing", + "captionSide": "caption-side", + "clear": "clear", + "clip": "clip", + "clipPath": "clip-path", + "clipRule": "clip-rule", + "color": "color", + "colorInterpolation": "color-interpolation", + "colorInterpolationFilters": "color-interpolation-filters", + "colorProfile": "color-profile", + "colorRendering": "color-rendering", + "content": "content", + "counterIncrement": "counter-increment", + "counterReset": "counter-reset", + "cursor": "cursor", + "direction": "direction", + "display": "display", + "dominantBaseline": "dominant-baseline", + "emptyCells": "empty-cells", + "enableBackground": "enable-background", + "fill": "fill", + "fillOpacity": "fill-opacity", + "fillRule": "fill-rule", + "filter": "filter", + "cssFloat": "float", + "floodColor": "flood-color", + "floodOpacity": "flood-opacity", + "font": "font", + "fontFamily": "font-family", + "fontSize": "font-size", + "fontStretch": "font-stretch", + "fontStyle": "font-style", + "fontVariant": "font-variant", + "fontWeight": "font-weight", + "glyphOrientationHorizontal": "glyph-orientation-horizontal", + "glyphOrientationVertical": "glyph-orientation-vertical", + "height": "height", + "imageRendering": "image-rendering", + "kerning": "kerning", + "left": "left", + "letterSpacing": "letter-spacing", + "lightingColor": "lighting-color", + "lineHeight": "line-height", + "listStyle": "list-style", + "listStyleImage": "list-style-image", + "listStylePosition": "list-style-position", + "listStyleType": "list-style-type", + "margin": "margin", + "marginBottom": "margin-bottom", + "marginLeft": "margin-left", + "marginRight": "margin-right", + "marginTop": "margin-top", + "marker": "marker", + "markerEnd": "marker-end", + "markerMid": "marker-mid", + "markerStart": "marker-start", + "mask": "mask", + "maxHeight": "max-height", + "maxWidth": "max-width", + "minHeight": "min-height", + "minWidth": "min-width", + "opacity": "opacity", + "orphans": "orphans", + "outline": "outline", + "outlineColor": "outline-color", + "outlineOffset": "outline-offset", + "outlineStyle": "outline-style", + "outlineWidth": "outline-width", + "overflow": "overflow", + "overflowX": "overflow-x", + "overflowY": "overflow-y", + "padding": "padding", + "paddingBottom": "padding-bottom", + "paddingLeft": "padding-left", + "paddingRight": "padding-right", + "paddingTop": "padding-top", + "page": "page", + "pageBreakAfter": "page-break-after", + "pageBreakBefore": "page-break-before", + "pageBreakInside": "page-break-inside", + "pointerEvents": "pointer-events", + "position": "position", + "quotes": "quotes", + "resize": "resize", + "right": "right", + "shapeRendering": "shape-rendering", + "size": "size", + "speak": "speak", + "src": "src", + "stopColor": "stop-color", + "stopOpacity": "stop-opacity", + "stroke": "stroke", + "strokeDasharray": "stroke-dasharray", + "strokeDashoffset": "stroke-dashoffset", + "strokeLinecap": "stroke-linecap", + "strokeLinejoin": "stroke-linejoin", + "strokeMiterlimit": "stroke-miterlimit", + "strokeOpacity": "stroke-opacity", + "strokeWidth": "stroke-width", + "tableLayout": "table-layout", + "textAlign": "text-align", + "textAnchor": "text-anchor", + "textDecoration": "text-decoration", + "textIndent": "text-indent", + "textLineThrough": "text-line-through", + "textLineThroughColor": "text-line-through-color", + "textLineThroughMode": "text-line-through-mode", + "textLineThroughStyle": "text-line-through-style", + "textLineThroughWidth": "text-line-through-width", + "textOverflow": "text-overflow", + "textOverline": "text-overline", + "textOverlineColor": "text-overline-color", + "textOverlineMode": "text-overline-mode", + "textOverlineStyle": "text-overline-style", + "textOverlineWidth": "text-overline-width", + "textRendering": "text-rendering", + "textShadow": "text-shadow", + "textTransform": "text-transform", + "textUnderline": "text-underline", + "textUnderlineColor": "text-underline-color", + "textUnderlineMode": "text-underline-mode", + "textUnderlineStyle": "text-underline-style", + "textUnderlineWidth": "text-underline-width", + "top": "top", + "unicodeBidi": "unicode-bidi", + "unicodeRange": "unicode-range", + "vectorEffect": "vector-effect", + "verticalAlign": "vertical-align", + "visibility": "visibility", + "whiteSpace": "white-space", + "widows": "widows", + "width": "width", + "wordBreak": "word-break", + "wordSpacing": "word-spacing", + "wordWrap": "word-wrap", + "writingMode": "writing-mode", + "zIndex": "z-index", + "zoom": "zoom", + }; + + // Elements that can be self-closing + var voidElems = { + "area": true, + "base": true, + "br": true, + "col": true, + "command": true, + "embed": true, + "hr": true, + "img": true, + "input": true, + "link": true, + "meta": true, + "param": true, + "source": true, + "wbr": true + }; + + var whitespace = [" ", "\t", "\n", "\r"]; + + // See http://www.w3schools.com/dom/dom_nodetype.asp + var nodeTypes = { + ELEMENT_NODE: 1, + ATTRIBUTE_NODE: 2, + TEXT_NODE: 3, + CDATA_SECTION_NODE: 4, + ENTITY_REFERENCE_NODE: 5, + ENTITY_NODE: 6, + PROCESSING_INSTRUCTION_NODE: 7, + COMMENT_NODE: 8, + DOCUMENT_NODE: 9, + DOCUMENT_TYPE_NODE: 10, + DOCUMENT_FRAGMENT_NODE: 11, + NOTATION_NODE: 12 + }; + + function getElementsByTagName(tag) { + tag = tag.toUpperCase(); + var elems = []; + var allTags = (tag === "*"); + function getElems(node) { + var length = node.children.length; + for (var i = 0; i < length; i++) { + var child = node.children[i]; + if (allTags || (child.tagName === tag)) + elems.push(child); + getElems(child); + } + } + getElems(this); + return elems; + } + + var Node = function () {}; + + Node.prototype = { + attributes: null, + childNodes: null, + localName: null, + nodeName: null, + parentNode: null, + textContent: null, + nextSibling: null, + previousSibling: null, + + get firstChild() { + return this.childNodes[0] || null; + }, + + get firstElementChild() { + return this.children[0] || null; + }, + + get lastChild() { + return this.childNodes[this.childNodes.length - 1] || null; + }, + + get lastElementChild() { + return this.children[this.children.length - 1] || null; + }, + + appendChild: function (child) { + if (child.parentNode) { + child.parentNode.removeChild(child); + } + + var last = this.lastChild; + if (last) + last.nextSibling = child; + child.previousSibling = last; + + if (child.nodeType === Node.ELEMENT_NODE) { + child.previousElementSibling = this.children[this.children.length - 1] || null; + this.children.push(child); + child.previousElementSibling && (child.previousElementSibling.nextElementSibling = child); + } + this.childNodes.push(child); + child.parentNode = this; + }, + + removeChild: function (child) { + var childNodes = this.childNodes; + var childIndex = childNodes.indexOf(child); + if (childIndex === -1) { + throw "removeChild: node not found"; + } else { + child.parentNode = null; + var prev = child.previousSibling; + var next = child.nextSibling; + if (prev) + prev.nextSibling = next; + if (next) + next.previousSibling = prev; + + if (child.nodeType === Node.ELEMENT_NODE) { + prev = child.previousElementSibling; + next = child.nextElementSibling; + if (prev) + prev.nextElementSibling = next; + if (next) + next.previousElementSibling = prev; + this.children.splice(this.children.indexOf(child), 1); + } + + child.previousSibling = child.nextSibling = null; + child.previousElementSibling = child.nextElementSibling = null; + + return childNodes.splice(childIndex, 1)[0]; + } + }, + + replaceChild: function (newNode, oldNode) { + var childNodes = this.childNodes; + var childIndex = childNodes.indexOf(oldNode); + if (childIndex === -1) { + throw "replaceChild: node not found"; + } else { + // This will take care of updating the new node if it was somewhere else before: + if (newNode.parentNode) + newNode.parentNode.removeChild(newNode); + + childNodes[childIndex] = newNode; + + // update the new node's sibling properties, and its new siblings' sibling properties + newNode.nextSibling = oldNode.nextSibling; + newNode.previousSibling = oldNode.previousSibling; + if (newNode.nextSibling) + newNode.nextSibling.previousSibling = newNode; + if (newNode.previousSibling) + newNode.previousSibling.nextSibling = newNode; + + newNode.parentNode = this; + + // Now deal with elements before we clear out those values for the old node, + // because it can help us take shortcuts here: + if (newNode.nodeType === Node.ELEMENT_NODE) { + if (oldNode.nodeType === Node.ELEMENT_NODE) { + // Both were elements, which makes this easier, we just swap things out: + newNode.previousElementSibling = oldNode.previousElementSibling; + newNode.nextElementSibling = oldNode.nextElementSibling; + if (newNode.previousElementSibling) + newNode.previousElementSibling.nextElementSibling = newNode; + if (newNode.nextElementSibling) + newNode.nextElementSibling.previousElementSibling = newNode; + this.children[this.children.indexOf(oldNode)] = newNode; + } else { + // Hard way: + newNode.previousElementSibling = (function() { + for (var i = childIndex - 1; i >= 0; i--) { + if (childNodes[i].nodeType === Node.ELEMENT_NODE) + return childNodes[i]; + } + return null; + })(); + if (newNode.previousElementSibling) { + newNode.nextElementSibling = newNode.previousElementSibling.nextElementSibling; + } else { + newNode.nextElementSibling = (function() { + for (var i = childIndex + 1; i < childNodes.length; i++) { + if (childNodes[i].nodeType === Node.ELEMENT_NODE) + return childNodes[i]; + } + return null; + })(); + } + if (newNode.previousElementSibling) + newNode.previousElementSibling.nextElementSibling = newNode; + if (newNode.nextElementSibling) + newNode.nextElementSibling.previousElementSibling = newNode; + + if (newNode.nextElementSibling) + this.children.splice(this.children.indexOf(newNode.nextElementSibling), 0, newNode); + else + this.children.push(newNode); + } + } else if (oldNode.nodeType === Node.ELEMENT_NODE) { + // new node is not an element node. + // if the old one was, update its element siblings: + if (oldNode.previousElementSibling) + oldNode.previousElementSibling.nextElementSibling = oldNode.nextElementSibling; + if (oldNode.nextElementSibling) + oldNode.nextElementSibling.previousElementSibling = oldNode.previousElementSibling; + this.children.splice(this.children.indexOf(oldNode), 1); + + // If the old node wasn't an element, neither the new nor the old node was an element, + // and the children array and its members shouldn't need any updating. + } + + + oldNode.parentNode = null; + oldNode.previousSibling = null; + oldNode.nextSibling = null; + if (oldNode.nodeType === Node.ELEMENT_NODE) { + oldNode.previousElementSibling = null; + oldNode.nextElementSibling = null; + } + return oldNode; + } + }, + + __JSDOMParser__: true, + }; + + for (var nodeType in nodeTypes) { + Node[nodeType] = Node.prototype[nodeType] = nodeTypes[nodeType]; + } + + var Attribute = function (name, value) { + this.name = name; + this._value = value; + }; + + Attribute.prototype = { + get value() { + return this._value; + }, + setValue: function(newValue) { + this._value = newValue; + }, + getEncodedValue: function() { + return encodeHTML(this._value); + }, + }; + + var Comment = function () { + this.childNodes = []; + }; + + Comment.prototype = { + __proto__: Node.prototype, + + nodeName: "#comment", + nodeType: Node.COMMENT_NODE + }; + + var Text = function () { + this.childNodes = []; + }; + + Text.prototype = { + __proto__: Node.prototype, + + nodeName: "#text", + nodeType: Node.TEXT_NODE, + get textContent() { + if (typeof this._textContent === "undefined") { + this._textContent = decodeHTML(this._innerHTML || ""); + } + return this._textContent; + }, + get innerHTML() { + if (typeof this._innerHTML === "undefined") { + this._innerHTML = encodeTextContentHTML(this._textContent || ""); + } + return this._innerHTML; + }, + + set innerHTML(newHTML) { + this._innerHTML = newHTML; + delete this._textContent; + }, + set textContent(newText) { + this._textContent = newText; + delete this._innerHTML; + }, + }; + + var Document = function (url) { + this.documentURI = url; + this.styleSheets = []; + this.childNodes = []; + this.children = []; + }; + + Document.prototype = { + __proto__: Node.prototype, + + nodeName: "#document", + nodeType: Node.DOCUMENT_NODE, + title: "", + + getElementsByTagName: getElementsByTagName, + + getElementById: function (id) { + function getElem(node) { + var length = node.children.length; + if (node.id === id) + return node; + for (var i = 0; i < length; i++) { + var el = getElem(node.children[i]); + if (el) + return el; + } + return null; + } + return getElem(this); + }, + + createElement: function (tag) { + var node = new Element(tag); + return node; + }, + + createTextNode: function (text) { + var node = new Text(); + node.textContent = text; + return node; + }, + + get baseURI() { + if (!this.hasOwnProperty("_baseURI")) { + this._baseURI = this.documentURI; + var baseElements = this.getElementsByTagName("base"); + var href = baseElements[0] && baseElements[0].getAttribute("href"); + if (href) { + try { + this._baseURI = (new URL(href, this._baseURI)).href; + } catch (ex) {/* Just fall back to documentURI */} + } + } + return this._baseURI; + }, + }; + + var Element = function (tag) { + // We use this to find the closing tag. + this._matchingTag = tag; + // We're explicitly a non-namespace aware parser, we just pretend it's all HTML. + var lastColonIndex = tag.lastIndexOf(":"); + if (lastColonIndex != -1) { + tag = tag.substring(lastColonIndex + 1); + } + this.attributes = []; + this.childNodes = []; + this.children = []; + this.nextElementSibling = this.previousElementSibling = null; + this.localName = tag.toLowerCase(); + this.tagName = tag.toUpperCase(); + this.style = new Style(this); + }; + + Element.prototype = { + __proto__: Node.prototype, + + nodeType: Node.ELEMENT_NODE, + + getElementsByTagName: getElementsByTagName, + + get className() { + return this.getAttribute("class") || ""; + }, + + set className(str) { + this.setAttribute("class", str); + }, + + get id() { + return this.getAttribute("id") || ""; + }, + + set id(str) { + this.setAttribute("id", str); + }, + + get href() { + return this.getAttribute("href") || ""; + }, + + set href(str) { + this.setAttribute("href", str); + }, + + get src() { + return this.getAttribute("src") || ""; + }, + + set src(str) { + this.setAttribute("src", str); + }, + + get srcset() { + return this.getAttribute("srcset") || ""; + }, + + set srcset(str) { + this.setAttribute("srcset", str); + }, + + get nodeName() { + return this.tagName; + }, + + get innerHTML() { + function getHTML(node) { + var i = 0; + for (i = 0; i < node.childNodes.length; i++) { + var child = node.childNodes[i]; + if (child.localName) { + arr.push("<" + child.localName); + + // serialize attribute list + for (var j = 0; j < child.attributes.length; j++) { + var attr = child.attributes[j]; + // the attribute value will be HTML escaped. + var val = attr.getEncodedValue(); + var quote = (val.indexOf('"') === -1 ? '"' : "'"); + arr.push(" " + attr.name + "=" + quote + val + quote); + } + + if (child.localName in voidElems && !child.childNodes.length) { + // if this is a self-closing element, end it here + arr.push("/>"); + } else { + // otherwise, add its children + arr.push(">"); + getHTML(child); + arr.push(""); + } + } else { + // This is a text node, so asking for innerHTML won't recurse. + arr.push(child.innerHTML); + } + } + } + + // Using Array.join() avoids the overhead from lazy string concatenation. + // See http://blog.cdleary.com/2012/01/string-representation-in-spidermonkey/#ropes + var arr = []; + getHTML(this); + return arr.join(""); + }, + + set innerHTML(html) { + var parser = new JSDOMParser(); + var node = parser.parse(html); + var i; + for (i = this.childNodes.length; --i >= 0;) { + this.childNodes[i].parentNode = null; + } + this.childNodes = node.childNodes; + this.children = node.children; + for (i = this.childNodes.length; --i >= 0;) { + this.childNodes[i].parentNode = this; + } + }, + + set textContent(text) { + // clear parentNodes for existing children + for (var i = this.childNodes.length; --i >= 0;) { + this.childNodes[i].parentNode = null; + } + + var node = new Text(); + this.childNodes = [ node ]; + this.children = []; + node.textContent = text; + node.parentNode = this; + }, + + get textContent() { + function getText(node) { + var nodes = node.childNodes; + for (var i = 0; i < nodes.length; i++) { + var child = nodes[i]; + if (child.nodeType === 3) { + text.push(child.textContent); + } else { + getText(child); + } + } + } + + // Using Array.join() avoids the overhead from lazy string concatenation. + // See http://blog.cdleary.com/2012/01/string-representation-in-spidermonkey/#ropes + var text = []; + getText(this); + return text.join(""); + }, + + getAttribute: function (name) { + for (var i = this.attributes.length; --i >= 0;) { + var attr = this.attributes[i]; + if (attr.name === name) { + return attr.value; + } + } + return undefined; + }, + + setAttribute: function (name, value) { + for (var i = this.attributes.length; --i >= 0;) { + var attr = this.attributes[i]; + if (attr.name === name) { + attr.setValue(value); + return; + } + } + this.attributes.push(new Attribute(name, value)); + }, + + removeAttribute: function (name) { + for (var i = this.attributes.length; --i >= 0;) { + var attr = this.attributes[i]; + if (attr.name === name) { + this.attributes.splice(i, 1); + break; + } + } + }, + + hasAttribute: function (name) { + return this.attributes.some(function (attr) { + return attr.name == name; + }); + }, + }; + + var Style = function (node) { + this.node = node; + }; + + // getStyle() and setStyle() use the style attribute string directly. This + // won't be very efficient if there are a lot of style manipulations, but + // it's the easiest way to make sure the style attribute string and the JS + // style property stay in sync. Readability.js doesn't do many style + // manipulations, so this should be okay. + Style.prototype = { + getStyle: function (styleName) { + var attr = this.node.getAttribute("style"); + if (!attr) + return undefined; + + var styles = attr.split(";"); + for (var i = 0; i < styles.length; i++) { + var style = styles[i].split(":"); + var name = style[0].trim(); + if (name === styleName) + return style[1].trim(); + } + + return undefined; + }, + + setStyle: function (styleName, styleValue) { + var value = this.node.getAttribute("style") || ""; + var index = 0; + do { + var next = value.indexOf(";", index) + 1; + var length = next - index - 1; + var style = (length > 0 ? value.substr(index, length) : value.substr(index)); + if (style.substr(0, style.indexOf(":")).trim() === styleName) { + value = value.substr(0, index).trim() + (next ? " " + value.substr(next).trim() : ""); + break; + } + index = next; + } while (index); + + value += " " + styleName + ": " + styleValue + ";"; + this.node.setAttribute("style", value.trim()); + } + }; + + // For each item in styleMap, define a getter and setter on the style + // property. + for (var jsName in styleMap) { + (function (cssName) { + Style.prototype.__defineGetter__(jsName, function () { + return this.getStyle(cssName); + }); + Style.prototype.__defineSetter__(jsName, function (value) { + this.setStyle(cssName, value); + }); + })(styleMap[jsName]); + } + + var JSDOMParser = function () { + this.currentChar = 0; + + // In makeElementNode() we build up many strings one char at a time. Using + // += for this results in lots of short-lived intermediate strings. It's + // better to build an array of single-char strings and then join() them + // together at the end. And reusing a single array (i.e. |this.strBuf|) + // over and over for this purpose uses less memory than using a new array + // for each string. + this.strBuf = []; + + // Similarly, we reuse this array to return the two arguments from + // makeElementNode(), which saves us from having to allocate a new array + // every time. + this.retPair = []; + + this.errorState = ""; + }; + + JSDOMParser.prototype = { + error: function(m) { + dump("JSDOMParser error: " + m + "\n"); + this.errorState += m + "\n"; + }, + + /** + * Look at the next character without advancing the index. + */ + peekNext: function () { + return this.html[this.currentChar]; + }, + + /** + * Get the next character and advance the index. + */ + nextChar: function () { + return this.html[this.currentChar++]; + }, + + /** + * Called after a quote character is read. This finds the next quote + * character and returns the text string in between. + */ + readString: function (quote) { + var str; + var n = this.html.indexOf(quote, this.currentChar); + if (n === -1) { + this.currentChar = this.html.length; + str = null; + } else { + str = this.html.substring(this.currentChar, n); + this.currentChar = n + 1; + } + + return str; + }, + + /** + * Called when parsing a node. This finds the next name/value attribute + * pair and adds the result to the attributes list. + */ + readAttribute: function (node) { + var name = ""; + + var n = this.html.indexOf("=", this.currentChar); + if (n === -1) { + this.currentChar = this.html.length; + } else { + // Read until a '=' character is hit; this will be the attribute key + name = this.html.substring(this.currentChar, n); + this.currentChar = n + 1; + } + + if (!name) + return; + + // After a '=', we should see a '"' for the attribute value + var c = this.nextChar(); + if (c !== '"' && c !== "'") { + this.error("Error reading attribute " + name + ", expecting '\"'"); + return; + } + + // Read the attribute value (and consume the matching quote) + var value = this.readString(c); + + node.attributes.push(new Attribute(name, decodeHTML(value))); + + return; + }, + + /** + * Parses and returns an Element node. This is called after a '<' has been + * read. + * + * @returns an array; the first index of the array is the parsed node; + * the second index is a boolean indicating whether this is a void + * Element + */ + makeElementNode: function (retPair) { + var c = this.nextChar(); + + // Read the Element tag name + var strBuf = this.strBuf; + strBuf.length = 0; + while (whitespace.indexOf(c) == -1 && c !== ">" && c !== "/") { + if (c === undefined) + return false; + strBuf.push(c); + c = this.nextChar(); + } + var tag = strBuf.join(""); + + if (!tag) + return false; + + var node = new Element(tag); + + // Read Element attributes + while (c !== "/" && c !== ">") { + if (c === undefined) + return false; + while (whitespace.indexOf(this.html[this.currentChar++]) != -1) { + // Advance cursor to first non-whitespace char. + } + this.currentChar--; + c = this.nextChar(); + if (c !== "/" && c !== ">") { + --this.currentChar; + this.readAttribute(node); + } + } + + // If this is a self-closing tag, read '/>' + var closed = false; + if (c === "/") { + closed = true; + c = this.nextChar(); + if (c !== ">") { + this.error("expected '>' to close " + tag); + return false; + } + } + + retPair[0] = node; + retPair[1] = closed; + return true; + }, + + /** + * If the current input matches this string, advance the input index; + * otherwise, do nothing. + * + * @returns whether input matched string + */ + match: function (str) { + var strlen = str.length; + if (this.html.substr(this.currentChar, strlen).toLowerCase() === str.toLowerCase()) { + this.currentChar += strlen; + return true; + } + return false; + }, + + /** + * Searches the input until a string is found and discards all input up to + * and including the matched string. + */ + discardTo: function (str) { + var index = this.html.indexOf(str, this.currentChar) + str.length; + if (index === -1) + this.currentChar = this.html.length; + this.currentChar = index; + }, + + /** + * Reads child nodes for the given node. + */ + readChildren: function (node) { + var child; + while ((child = this.readNode())) { + // Don't keep Comment nodes + if (child.nodeType !== 8) { + node.appendChild(child); + } + } + }, + + discardNextComment: function() { + if (this.match("--")) { + this.discardTo("-->"); + } else { + var c = this.nextChar(); + while (c !== ">") { + if (c === undefined) + return null; + if (c === '"' || c === "'") + this.readString(c); + c = this.nextChar(); + } + } + return new Comment(); + }, + + + /** + * Reads the next child node from the input. If we're reading a closing + * tag, or if we've reached the end of input, return null. + * + * @returns the node + */ + readNode: function () { + var c = this.nextChar(); + + if (c === undefined) + return null; + + // Read any text as Text node + var textNode; + if (c !== "<") { + --this.currentChar; + textNode = new Text(); + var n = this.html.indexOf("<", this.currentChar); + if (n === -1) { + textNode.innerHTML = this.html.substring(this.currentChar, this.html.length); + this.currentChar = this.html.length; + } else { + textNode.innerHTML = this.html.substring(this.currentChar, n); + this.currentChar = n; + } + return textNode; + } + + if (this.match("![CDATA[")) { + var endChar = this.html.indexOf("]]>", this.currentChar); + if (endChar === -1) { + this.error("unclosed CDATA section"); + return null; + } + textNode = new Text(); + textNode.textContent = this.html.substring(this.currentChar, endChar); + this.currentChar = endChar + ("]]>").length; + return textNode; + } + + c = this.peekNext(); + + // Read Comment node. Normally, Comment nodes know their inner + // textContent, but we don't really care about Comment nodes (we throw + // them away in readChildren()). So just returning an empty Comment node + // here is sufficient. + if (c === "!" || c === "?") { + // We're still before the ! or ? that is starting this comment: + this.currentChar++; + return this.discardNextComment(); + } + + // If we're reading a closing tag, return null. This means we've reached + // the end of this set of child nodes. + if (c === "/") { + --this.currentChar; + return null; + } + + // Otherwise, we're looking at an Element node + var result = this.makeElementNode(this.retPair); + if (!result) + return null; + + var node = this.retPair[0]; + var closed = this.retPair[1]; + var localName = node.localName; + + // If this isn't a void Element, read its child nodes + if (!closed) { + this.readChildren(node); + var closingTag = ""; + if (!this.match(closingTag)) { + this.error("expected '" + closingTag + "' and got " + this.html.substr(this.currentChar, closingTag.length)); + return null; + } + } + + // Only use the first title, because SVG might have other + // title elements which we don't care about (medium.com + // does this, at least). + if (localName === "title" && !this.doc.title) { + this.doc.title = node.textContent.trim(); + } else if (localName === "head") { + this.doc.head = node; + } else if (localName === "body") { + this.doc.body = node; + } else if (localName === "html") { + this.doc.documentElement = node; + } + + return node; + }, + + /** + * Parses an HTML string and returns a JS implementation of the Document. + */ + parse: function (html, url) { + this.html = html; + var doc = this.doc = new Document(url); + this.readChildren(doc); + + // If this is an HTML document, remove root-level children except for the + // node + if (doc.documentElement) { + for (var i = doc.childNodes.length; --i >= 0;) { + var child = doc.childNodes[i]; + if (child !== doc.documentElement) { + doc.removeChild(child); + } + } + } + + return doc; + } + }; + + // Attach the standard DOM types to the global scope + global.Node = Node; + global.Comment = Comment; + global.Document = Document; + global.Element = Element; + global.Text = Text; + + // Attach JSDOMParser to the global scope + global.JSDOMParser = JSDOMParser; + +})(this); diff --git a/lib/Readability-readerable.js b/lib/Readability-readerable.js new file mode 100644 index 000000000..1be7c73fb --- /dev/null +++ b/lib/Readability-readerable.js @@ -0,0 +1,99 @@ +// https://github.com/mozilla/readability/tree/814f0a3884350b6f1adfdebb79ca3599e9806605 + +/* eslint-env es6:false */ +/* globals exports */ +/* + * Copyright (c) 2010 Arc90 Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * This code is heavily based on Arc90's readability.js (1.7.1) script + * available at: http://code.google.com/p/arc90labs-readability + */ + +var REGEXPS = { + // NOTE: These two regular expressions are duplicated in + // Readability.js. Please keep both copies in sync. + unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, + okMaybeItsACandidate: /and|article|body|column|main|shadow/i, +}; + +function isNodeVisible(node) { + // Have to null-check node.style to deal with SVG and MathML nodes. + return (!node.style || node.style.display != "none") && !node.hasAttribute("hidden"); +} + +/** + * Decides whether or not the document is reader-able without parsing the whole thing. + * + * @return boolean Whether or not we suspect Readability.parse() will suceeed at returning an article object. + */ +function isProbablyReaderable(doc, isVisible) { + if (!isVisible) { + isVisible = isNodeVisible; + } + + var nodes = doc.querySelectorAll("p, pre"); + + // Get
nodes which have
node(s) and append them into the `nodes` variable. + // Some articles' DOM structures might look like + //
+ // Sentences
+ //
+ // Sentences
+ //
+ var brNodes = doc.querySelectorAll("div > br"); + if (brNodes.length) { + var set = new Set(nodes); + [].forEach.call(brNodes, function(node) { + set.add(node.parentNode); + }); + nodes = Array.from(set); + } + + var score = 0; + // This is a little cheeky, we use the accumulator 'score' to decide what to return from + // this callback: + return [].some.call(nodes, function(node) { + if (!isVisible(node)) + return false; + + var matchString = node.className + " " + node.id; + if (REGEXPS.unlikelyCandidates.test(matchString) && + !REGEXPS.okMaybeItsACandidate.test(matchString)) { + return false; + } + + if (node.matches("li p")) { + return false; + } + + var textContentLength = node.textContent.trim().length; + if (textContentLength < 140) { + return false; + } + + score += Math.sqrt(textContentLength - 140); + + if (score > 20) { + return true; + } + return false; + }); +} + +if (typeof exports === "object") { + exports.isProbablyReaderable = isProbablyReaderable; +} diff --git a/lib/Readability.js b/lib/Readability.js new file mode 100644 index 000000000..a35703b68 --- /dev/null +++ b/lib/Readability.js @@ -0,0 +1,1854 @@ +// https://github.com/mozilla/readability/tree/814f0a3884350b6f1adfdebb79ca3599e9806605 + +/*eslint-env es6:false*/ +/* + * Copyright (c) 2010 Arc90 Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * This code is heavily based on Arc90's readability.js (1.7.1) script + * available at: http://code.google.com/p/arc90labs-readability + */ + +/** + * Public constructor. + * @param {HTMLDocument} doc The document to parse. + * @param {Object} options The options object. + */ +function Readability(doc, options) { + // In some older versions, people passed a URI as the first argument. Cope: + if (options && options.documentElement) { + doc = options; + options = arguments[2]; + } else if (!doc || !doc.documentElement) { + throw new Error("First argument to Readability constructor should be a document object."); + } + options = options || {}; + + this._doc = doc; + this._articleTitle = null; + this._articleByline = null; + this._articleDir = null; + this._articleSiteName = null; + this._attempts = []; + + // Configurable options + this._debug = !!options.debug; + this._maxElemsToParse = options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE; + this._nbTopCandidates = options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES; + this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD; + this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat(options.classesToPreserve || []); + + // Start with all flags set + this._flags = this.FLAG_STRIP_UNLIKELYS | + this.FLAG_WEIGHT_CLASSES | + this.FLAG_CLEAN_CONDITIONALLY; + + var logEl; + + // Control whether log messages are sent to the console + if (this._debug) { + logEl = function(e) { + var rv = e.nodeName + " "; + if (e.nodeType == e.TEXT_NODE) { + return rv + '("' + e.textContent + '")'; + } + var classDesc = e.className && ("." + e.className.replace(/ /g, ".")); + var elDesc = ""; + if (e.id) + elDesc = "(#" + e.id + classDesc + ")"; + else if (classDesc) + elDesc = "(" + classDesc + ")"; + return rv + elDesc; + }; + this.log = function () { + if (typeof dump !== "undefined") { + var msg = Array.prototype.map.call(arguments, function(x) { + return (x && x.nodeName) ? logEl(x) : x; + }).join(" "); + dump("Reader: (Readability) " + msg + "\n"); + } else if (typeof console !== "undefined") { + var args = ["Reader: (Readability) "].concat(arguments); + console.log.apply(console, args); + } + }; + } else { + this.log = function () {}; + } +} + +Readability.prototype = { + FLAG_STRIP_UNLIKELYS: 0x1, + FLAG_WEIGHT_CLASSES: 0x2, + FLAG_CLEAN_CONDITIONALLY: 0x4, + + // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + ELEMENT_NODE: 1, + TEXT_NODE: 3, + + // Max number of nodes supported by this parser. Default: 0 (no limit) + DEFAULT_MAX_ELEMS_TO_PARSE: 0, + + // The number of top candidates to consider when analysing how + // tight the competition is among candidates. + DEFAULT_N_TOP_CANDIDATES: 5, + + // Element tags to score by default. + DEFAULT_TAGS_TO_SCORE: "section,h2,h3,h4,h5,h6,p,td,pre".toUpperCase().split(","), + + // The default number of chars an article must have in order to return a result + DEFAULT_CHAR_THRESHOLD: 500, + + // All of the regular expressions in use within readability. + // Defined up here so we don't instantiate them repeatedly in loops. + REGEXPS: { + // NOTE: These two regular expressions are duplicated in + // Readability-readerable.js. Please keep both copies in sync. + unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, + okMaybeItsACandidate: /and|article|body|column|main|shadow/i, + + positive: /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, + negative: /hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|foot|footer|footnote|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|tool|widget/i, + extraneous: /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i, + byline: /byline|author|dateline|writtenby|p-author/i, + replaceFonts: /<(\/?)font[^>]*>/gi, + normalize: /\s{2,}/g, + videos: /\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i, + nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i, + prevLink: /(prev|earl|old|new|<|«)/i, + whitespace: /^\s*$/, + hasContent: /\S$/, + }, + + DIV_TO_P_ELEMS: [ "A", "BLOCKQUOTE", "DL", "DIV", "IMG", "OL", "P", "PRE", "TABLE", "UL", "SELECT" ], + + ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P"], + + PRESENTATIONAL_ATTRIBUTES: [ "align", "background", "bgcolor", "border", "cellpadding", "cellspacing", "frame", "hspace", "rules", "style", "valign", "vspace" ], + + DEPRECATED_SIZE_ATTRIBUTE_ELEMS: [ "TABLE", "TH", "TD", "HR", "PRE" ], + + // The commented out elements qualify as phrasing content but tend to be + // removed by readability when put into paragraphs, so we ignore them here. + PHRASING_ELEMS: [ + // "CANVAS", "IFRAME", "SVG", "VIDEO", + "ABBR", "AUDIO", "B", "BDO", "BR", "BUTTON", "CITE", "CODE", "DATA", + "DATALIST", "DFN", "EM", "EMBED", "I", "IMG", "INPUT", "KBD", "LABEL", + "MARK", "MATH", "METER", "NOSCRIPT", "OBJECT", "OUTPUT", "PROGRESS", "Q", + "RUBY", "SAMP", "SCRIPT", "SELECT", "SMALL", "SPAN", "STRONG", "SUB", + "SUP", "TEXTAREA", "TIME", "VAR", "WBR" + ], + + // These are the classes that readability sets itself. + CLASSES_TO_PRESERVE: [ "page" ], + + /** + * Run any post-process modifications to article content as necessary. + * + * @param Element + * @return void + **/ + _postProcessContent: function(articleContent) { + // Readability cannot open relative uris so we convert them to absolute uris. + this._fixRelativeUris(articleContent); + + // Remove classes. + this._cleanClasses(articleContent); + }, + + /** + * Iterates over a NodeList, calls `filterFn` for each node and removes node + * if function returned `true`. + * + * If function is not passed, removes all the nodes in node list. + * + * @param NodeList nodeList The nodes to operate on + * @param Function filterFn the function to use as a filter + * @return void + */ + _removeNodes: function(nodeList, filterFn) { + for (var i = nodeList.length - 1; i >= 0; i--) { + var node = nodeList[i]; + var parentNode = node.parentNode; + if (parentNode) { + if (!filterFn || filterFn.call(this, node, i, nodeList)) { + parentNode.removeChild(node); + } + } + } + }, + + /** + * Iterates over a NodeList, and calls _setNodeTag for each node. + * + * @param NodeList nodeList The nodes to operate on + * @param String newTagName the new tag name to use + * @return void + */ + _replaceNodeTags: function(nodeList, newTagName) { + for (var i = nodeList.length - 1; i >= 0; i--) { + var node = nodeList[i]; + this._setNodeTag(node, newTagName); + } + }, + + /** + * Iterate over a NodeList, which doesn't natively fully implement the Array + * interface. + * + * For convenience, the current object context is applied to the provided + * iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return void + */ + _forEachNode: function(nodeList, fn) { + Array.prototype.forEach.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if any of the provided iterate + * function calls returns true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _someNode: function(nodeList, fn) { + return Array.prototype.some.call(nodeList, fn, this); + }, + + /** + * Iterate over a NodeList, return true if all of the provided iterate + * function calls return true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _everyNode: function(nodeList, fn) { + return Array.prototype.every.call(nodeList, fn, this); + }, + + /** + * Concat all nodelists passed as arguments. + * + * @return ...NodeList + * @return Array + */ + _concatNodeLists: function() { + var slice = Array.prototype.slice; + var args = slice.call(arguments); + var nodeLists = args.map(function(list) { + return slice.call(list); + }); + return Array.prototype.concat.apply([], nodeLists); + }, + + _getAllNodesWithTag: function(node, tagNames) { + if (node.querySelectorAll) { + return node.querySelectorAll(tagNames.join(",")); + } + return [].concat.apply([], tagNames.map(function(tag) { + var collection = node.getElementsByTagName(tag); + return Array.isArray(collection) ? collection : Array.from(collection); + })); + }, + + /** + * Removes the class="" attribute from every element in the given + * subtree, except those that match CLASSES_TO_PRESERVE and + * the classesToPreserve array from the options object. + * + * @param Element + * @return void + */ + _cleanClasses: function(node) { + var classesToPreserve = this._classesToPreserve; + var className = (node.getAttribute("class") || "") + .split(/\s+/) + .filter(function(cls) { + return classesToPreserve.indexOf(cls) != -1; + }) + .join(" "); + + if (className) { + node.setAttribute("class", className); + } else { + node.removeAttribute("class"); + } + + for (node = node.firstElementChild; node; node = node.nextElementSibling) { + this._cleanClasses(node); + } + }, + + /** + * Converts each and uri in the given element to an absolute URI, + * ignoring #ref URIs. + * + * @param Element + * @return void + */ + _fixRelativeUris: function(articleContent) { + var baseURI = this._doc.baseURI; + var documentURI = this._doc.documentURI; + function toAbsoluteURI(uri) { + // Leave hash links alone if the base URI matches the document URI: + if (baseURI == documentURI && uri.charAt(0) == "#") { + return uri; + } + // Otherwise, resolve against base URI: + try { + return new URL(uri, baseURI).href; + } catch (ex) { + // Something went wrong, just return the original: + } + return uri; + } + + var links = this._getAllNodesWithTag(articleContent, ["a"]); + this._forEachNode(links, function(link) { + var href = link.getAttribute("href"); + if (href) { + // Replace links with javascript: URIs with text content, since + // they won't work after scripts have been removed from the page. + if (href.indexOf("javascript:") === 0) { + var text = this._doc.createTextNode(link.textContent); + link.parentNode.replaceChild(text, link); + } else { + link.setAttribute("href", toAbsoluteURI(href)); + } + } + }); + + var imgs = this._getAllNodesWithTag(articleContent, ["img"]); + this._forEachNode(imgs, function(img) { + var src = img.getAttribute("src"); + if (src) { + img.setAttribute("src", toAbsoluteURI(src)); + } + }); + }, + + /** + * Get the article title as an H1. + * + * @return void + **/ + _getArticleTitle: function() { + var doc = this._doc; + var curTitle = ""; + var origTitle = ""; + + try { + curTitle = origTitle = doc.title.trim(); + + // If they had an element with id "title" in their HTML + if (typeof curTitle !== "string") + curTitle = origTitle = this._getInnerText(doc.getElementsByTagName("title")[0]); + } catch (e) {/* ignore exceptions setting the title. */} + + var titleHadHierarchicalSeparators = false; + function wordCount(str) { + return str.split(/\s+/).length; + } + + // If there's a separator in the title, first remove the final part + if ((/ [\|\-\\\/>»] /).test(curTitle)) { + titleHadHierarchicalSeparators = / [\\\/>»] /.test(curTitle); + curTitle = origTitle.replace(/(.*)[\|\-\\\/>»] .*/gi, "$1"); + + // If the resulting title is too short (3 words or fewer), remove + // the first part instead: + if (wordCount(curTitle) < 3) + curTitle = origTitle.replace(/[^\|\-\\\/>»]*[\|\-\\\/>»](.*)/gi, "$1"); + } else if (curTitle.indexOf(": ") !== -1) { + // Check if we have an heading containing this exact string, so we + // could assume it's the full title. + var headings = this._concatNodeLists( + doc.getElementsByTagName("h1"), + doc.getElementsByTagName("h2") + ); + var trimmedTitle = curTitle.trim(); + var match = this._someNode(headings, function(heading) { + return heading.textContent.trim() === trimmedTitle; + }); + + // If we don't, let's extract the title out of the original title string. + if (!match) { + curTitle = origTitle.substring(origTitle.lastIndexOf(":") + 1); + + // If the title is now too short, try the first colon instead: + if (wordCount(curTitle) < 3) { + curTitle = origTitle.substring(origTitle.indexOf(":") + 1); + // But if we have too many words before the colon there's something weird + // with the titles and the H tags so let's just use the original title instead + } else if (wordCount(origTitle.substr(0, origTitle.indexOf(":"))) > 5) { + curTitle = origTitle; + } + } + } else if (curTitle.length > 150 || curTitle.length < 15) { + var hOnes = doc.getElementsByTagName("h1"); + + if (hOnes.length === 1) + curTitle = this._getInnerText(hOnes[0]); + } + + curTitle = curTitle.trim().replace(this.REGEXPS.normalize, " "); + // If we now have 4 words or fewer as our title, and either no + // 'hierarchical' separators (\, /, > or ») were found in the original + // title or we decreased the number of words by more than 1 word, use + // the original title. + var curTitleWordCount = wordCount(curTitle); + if (curTitleWordCount <= 4 && + (!titleHadHierarchicalSeparators || + curTitleWordCount != wordCount(origTitle.replace(/[\|\-\\\/>»]+/g, "")) - 1)) { + curTitle = origTitle; + } + + return curTitle; + }, + + /** + * Prepare the HTML document for readability to scrape it. + * This includes things like stripping javascript, CSS, and handling terrible markup. + * + * @return void + **/ + _prepDocument: function() { + var doc = this._doc; + + // Remove all style tags in head + this._removeNodes(doc.getElementsByTagName("style")); + + if (doc.body) { + this._replaceBrs(doc.body); + } + + this._replaceNodeTags(doc.getElementsByTagName("font"), "SPAN"); + }, + + /** + * Finds the next element, starting from the given node, and ignoring + * whitespace in between. If the given node is an element, the same node is + * returned. + */ + _nextElement: function (node) { + var next = node; + while (next + && (next.nodeType != this.ELEMENT_NODE) + && this.REGEXPS.whitespace.test(next.textContent)) { + next = next.nextSibling; + } + return next; + }, + + /** + * Replaces 2 or more successive
elements with a single

. + * Whitespace between
elements are ignored. For example: + *

foo
bar


abc
+ * will become: + *
foo
bar

abc

+ */ + _replaceBrs: function (elem) { + this._forEachNode(this._getAllNodesWithTag(elem, ["br"]), function(br) { + var next = br.nextSibling; + + // Whether 2 or more
elements have been found and replaced with a + //

block. + var replaced = false; + + // If we find a
chain, remove the
s until we hit another element + // or non-whitespace. This leaves behind the first
in the chain + // (which will be replaced with a

later). + while ((next = this._nextElement(next)) && (next.tagName == "BR")) { + replaced = true; + var brSibling = next.nextSibling; + next.parentNode.removeChild(next); + next = brSibling; + } + + // If we removed a
chain, replace the remaining
with a

. Add + // all sibling nodes as children of the

until we hit another
+ // chain. + if (replaced) { + var p = this._doc.createElement("p"); + br.parentNode.replaceChild(p, br); + + next = p.nextSibling; + while (next) { + // If we've hit another

, we're done adding children to this

. + if (next.tagName == "BR") { + var nextElem = this._nextElement(next.nextSibling); + if (nextElem && nextElem.tagName == "BR") + break; + } + + if (!this._isPhrasingContent(next)) + break; + + // Otherwise, make this node a child of the new

. + var sibling = next.nextSibling; + p.appendChild(next); + next = sibling; + } + + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.removeChild(p.lastChild); + } + + if (p.parentNode.tagName === "P") + this._setNodeTag(p.parentNode, "DIV"); + } + }); + }, + + _setNodeTag: function (node, tag) { + this.log("_setNodeTag", node, tag); + if (node.__JSDOMParser__) { + node.localName = tag.toLowerCase(); + node.tagName = tag.toUpperCase(); + return node; + } + + var replacement = node.ownerDocument.createElement(tag); + while (node.firstChild) { + replacement.appendChild(node.firstChild); + } + node.parentNode.replaceChild(replacement, node); + if (node.readability) + replacement.readability = node.readability; + + for (var i = 0; i < node.attributes.length; i++) { + try { + replacement.setAttribute(node.attributes[i].name, node.attributes[i].value); + } catch (ex) { + /* it's possible for setAttribute() to throw if the attribute name + * isn't a valid XML Name. Such attributes can however be parsed from + * source in HTML docs, see https://github.com/whatwg/html/issues/4275, + * so we can hit them here and then throw. We don't care about such + * attributes so we ignore them. + */ + } + } + return replacement; + }, + + /** + * Prepare the article node for display. Clean out any inline styles, + * iframes, forms, strip extraneous

tags, etc. + * + * @param Element + * @return void + **/ + _prepArticle: function(articleContent) { + this._cleanStyles(articleContent); + + // Check for data tables before we continue, to avoid removing items in + // those tables, which will often be isolated even though they're + // visually linked to other content-ful elements (text, images, etc.). + this._markDataTables(articleContent); + + this._fixLazyImages(articleContent); + + // Clean out junk from the article content + this._cleanConditionally(articleContent, "form"); + this._cleanConditionally(articleContent, "fieldset"); + this._clean(articleContent, "object"); + this._clean(articleContent, "embed"); + this._clean(articleContent, "h1"); + this._clean(articleContent, "footer"); + this._clean(articleContent, "link"); + this._clean(articleContent, "aside"); + + // Clean out elements with little content that have "share" in their id/class combinations from final top candidates, + // which means we don't remove the top candidates even they have "share". + + var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD; + + this._forEachNode(articleContent.children, function (topCandidate) { + this._cleanMatchedNodes(topCandidate, function (node, matchString) { + return /share/.test(matchString) && node.textContent.length < shareElementThreshold; + }); + }); + + // If there is only one h2 and its text content substantially equals article title, + // they are probably using it as a header and not a subheader, + // so remove it since we already extract the title separately. + var h2 = articleContent.getElementsByTagName("h2"); + if (h2.length === 1) { + var lengthSimilarRate = (h2[0].textContent.length - this._articleTitle.length) / this._articleTitle.length; + if (Math.abs(lengthSimilarRate) < 0.5) { + var titlesMatch = false; + if (lengthSimilarRate > 0) { + titlesMatch = h2[0].textContent.includes(this._articleTitle); + } else { + titlesMatch = this._articleTitle.includes(h2[0].textContent); + } + if (titlesMatch) { + this._clean(articleContent, "h2"); + } + } + } + + this._clean(articleContent, "iframe"); + this._clean(articleContent, "input"); + this._clean(articleContent, "textarea"); + this._clean(articleContent, "select"); + this._clean(articleContent, "button"); + this._cleanHeaders(articleContent); + + // Do these last as the previous stuff may have removed junk + // that will affect these + this._cleanConditionally(articleContent, "table"); + this._cleanConditionally(articleContent, "ul"); + this._cleanConditionally(articleContent, "div"); + + // Remove extra paragraphs + this._removeNodes(articleContent.getElementsByTagName("p"), function (paragraph) { + var imgCount = paragraph.getElementsByTagName("img").length; + var embedCount = paragraph.getElementsByTagName("embed").length; + var objectCount = paragraph.getElementsByTagName("object").length; + // At this point, nasty iframes have been removed, only remain embedded video ones. + var iframeCount = paragraph.getElementsByTagName("iframe").length; + var totalCount = imgCount + embedCount + objectCount + iframeCount; + + return totalCount === 0 && !this._getInnerText(paragraph, false); + }); + + this._forEachNode(this._getAllNodesWithTag(articleContent, ["br"]), function(br) { + var next = this._nextElement(br.nextSibling); + if (next && next.tagName == "P") + br.parentNode.removeChild(br); + }); + + // Remove single-cell tables + this._forEachNode(this._getAllNodesWithTag(articleContent, ["table"]), function(table) { + var tbody = this._hasSingleTagInsideElement(table, "TBODY") ? table.firstElementChild : table; + if (this._hasSingleTagInsideElement(tbody, "TR")) { + var row = tbody.firstElementChild; + if (this._hasSingleTagInsideElement(row, "TD")) { + var cell = row.firstElementChild; + cell = this._setNodeTag(cell, this._everyNode(cell.childNodes, this._isPhrasingContent) ? "P" : "DIV"); + table.parentNode.replaceChild(cell, table); + } + } + }); + }, + + /** + * Initialize a node with the readability object. Also checks the + * className/id for special names to add to its score. + * + * @param Element + * @return void + **/ + _initializeNode: function(node) { + node.readability = {"contentScore": 0}; + + switch (node.tagName) { + case "DIV": + node.readability.contentScore += 5; + break; + + case "PRE": + case "TD": + case "BLOCKQUOTE": + node.readability.contentScore += 3; + break; + + case "ADDRESS": + case "OL": + case "UL": + case "DL": + case "DD": + case "DT": + case "LI": + case "FORM": + node.readability.contentScore -= 3; + break; + + case "H1": + case "H2": + case "H3": + case "H4": + case "H5": + case "H6": + case "TH": + node.readability.contentScore -= 5; + break; + } + + node.readability.contentScore += this._getClassWeight(node); + }, + + _removeAndGetNext: function(node) { + var nextNode = this._getNextNode(node, true); + node.parentNode.removeChild(node); + return nextNode; + }, + + /** + * Traverse the DOM from node to node, starting at the node passed in. + * Pass true for the second parameter to indicate this node itself + * (and its kids) are going away, and we want the next node over. + * + * Calling this in a loop will traverse the DOM depth-first. + */ + _getNextNode: function(node, ignoreSelfAndKids) { + // First check for kids if those aren't being ignored + if (!ignoreSelfAndKids && node.firstElementChild) { + return node.firstElementChild; + } + // Then for siblings... + if (node.nextElementSibling) { + return node.nextElementSibling; + } + // And finally, move up the parent chain *and* find a sibling + // (because this is depth-first traversal, we will have already + // seen the parent nodes themselves). + do { + node = node.parentNode; + } while (node && !node.nextElementSibling); + return node && node.nextElementSibling; + }, + + _checkByline: function(node, matchString) { + if (this._articleByline) { + return false; + } + + if (node.getAttribute !== undefined) { + var rel = node.getAttribute("rel"); + var itemprop = node.getAttribute("itemprop"); + } + + if ((rel === "author" || (itemprop && itemprop.indexOf("author") !== -1) || this.REGEXPS.byline.test(matchString)) && this._isValidByline(node.textContent)) { + this._articleByline = node.textContent.trim(); + return true; + } + + return false; + }, + + _getNodeAncestors: function(node, maxDepth) { + maxDepth = maxDepth || 0; + var i = 0, ancestors = []; + while (node.parentNode) { + ancestors.push(node.parentNode); + if (maxDepth && ++i === maxDepth) + break; + node = node.parentNode; + } + return ancestors; + }, + + /*** + * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is + * most likely to be the stuff a user wants to read. Then return it wrapped up in a div. + * + * @param page a document to run upon. Needs to be a full document, complete with body. + * @return Element + **/ + _grabArticle: function (page) { + this.log("**** grabArticle ****"); + var doc = this._doc; + var isPaging = (page !== null ? true: false); + page = page ? page : this._doc.body; + + // We can't grab an article if we don't have a page! + if (!page) { + this.log("No body found in document. Abort."); + return null; + } + + var pageCacheHtml = page.innerHTML; + + while (true) { + var stripUnlikelyCandidates = this._flagIsActive(this.FLAG_STRIP_UNLIKELYS); + + // First, node prepping. Trash nodes that look cruddy (like ones with the + // class name "comment", etc), and turn divs into P tags where they have been + // used inappropriately (as in, where they contain no other block level elements.) + var elementsToScore = []; + var node = this._doc.documentElement; + + while (node) { + var matchString = node.className + " " + node.id; + + if (!this._isProbablyVisible(node)) { + this.log("Removing hidden node - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + + // Check to see if this node is a byline, and remove it if it is. + if (this._checkByline(node, matchString)) { + node = this._removeAndGetNext(node); + continue; + } + + // Remove unlikely candidates + if (stripUnlikelyCandidates) { + if (this.REGEXPS.unlikelyCandidates.test(matchString) && + !this.REGEXPS.okMaybeItsACandidate.test(matchString) && + !this._hasAncestorTag(node, "table") && + node.tagName !== "BODY" && + node.tagName !== "A") { + this.log("Removing unlikely candidate - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + } + + // Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe). + if ((node.tagName === "DIV" || node.tagName === "SECTION" || node.tagName === "HEADER" || + node.tagName === "H1" || node.tagName === "H2" || node.tagName === "H3" || + node.tagName === "H4" || node.tagName === "H5" || node.tagName === "H6") && + this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } + + if (this.DEFAULT_TAGS_TO_SCORE.indexOf(node.tagName) !== -1) { + elementsToScore.push(node); + } + + // Turn all divs that don't have children block level elements into p's + if (node.tagName === "DIV") { + // Put phrasing content into paragraphs. + var p = null; + var childNode = node.firstChild; + while (childNode) { + var nextSibling = childNode.nextSibling; + if (this._isPhrasingContent(childNode)) { + if (p !== null) { + p.appendChild(childNode); + } else if (!this._isWhitespace(childNode)) { + p = doc.createElement("p"); + node.replaceChild(p, childNode); + p.appendChild(childNode); + } + } else if (p !== null) { + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.removeChild(p.lastChild); + } + p = null; + } + childNode = nextSibling; + } + + // Sites like http://mobile.slate.com encloses each paragraph with a DIV + // element. DIVs with only a P element inside and no text content can be + // safely converted into plain P elements to avoid confusing the scoring + // algorithm with DIVs with are, in practice, paragraphs. + if (this._hasSingleTagInsideElement(node, "P") && this._getLinkDensity(node) < 0.25) { + var newNode = node.children[0]; + node.parentNode.replaceChild(newNode, node); + node = newNode; + elementsToScore.push(node); + } else if (!this._hasChildBlockElement(node)) { + node = this._setNodeTag(node, "P"); + elementsToScore.push(node); + } + } + node = this._getNextNode(node); + } + + /** + * Loop through all paragraphs, and assign a score to them based on how content-y they look. + * Then add their score to their parent node. + * + * A score is determined by things like number of commas, class names, etc. Maybe eventually link density. + **/ + var candidates = []; + this._forEachNode(elementsToScore, function(elementToScore) { + if (!elementToScore.parentNode || typeof(elementToScore.parentNode.tagName) === "undefined") + return; + + // If this paragraph is less than 25 characters, don't even count it. + var innerText = this._getInnerText(elementToScore); + if (innerText.length < 25) + return; + + // Exclude nodes with no ancestor. + var ancestors = this._getNodeAncestors(elementToScore, 3); + if (ancestors.length === 0) + return; + + var contentScore = 0; + + // Add a point for the paragraph itself as a base. + contentScore += 1; + + // Add points for any commas within this paragraph. + contentScore += innerText.split(",").length; + + // For every 100 characters in this paragraph, add another point. Up to 3 points. + contentScore += Math.min(Math.floor(innerText.length / 100), 3); + + // Initialize and score ancestors. + this._forEachNode(ancestors, function(ancestor, level) { + if (!ancestor.tagName || !ancestor.parentNode || typeof(ancestor.parentNode.tagName) === "undefined") + return; + + if (typeof(ancestor.readability) === "undefined") { + this._initializeNode(ancestor); + candidates.push(ancestor); + } + + // Node score divider: + // - parent: 1 (no division) + // - grandparent: 2 + // - great grandparent+: ancestor level * 3 + if (level === 0) + var scoreDivider = 1; + else if (level === 1) + scoreDivider = 2; + else + scoreDivider = level * 3; + ancestor.readability.contentScore += contentScore / scoreDivider; + }); + }); + + // After we've calculated scores, loop through all of the possible + // candidate nodes we found and find the one with the highest score. + var topCandidates = []; + for (var c = 0, cl = candidates.length; c < cl; c += 1) { + var candidate = candidates[c]; + + // Scale the final candidates score based on link density. Good content + // should have a relatively small link density (5% or less) and be mostly + // unaffected by this operation. + var candidateScore = candidate.readability.contentScore * (1 - this._getLinkDensity(candidate)); + candidate.readability.contentScore = candidateScore; + + this.log("Candidate:", candidate, "with score " + candidateScore); + + for (var t = 0; t < this._nbTopCandidates; t++) { + var aTopCandidate = topCandidates[t]; + + if (!aTopCandidate || candidateScore > aTopCandidate.readability.contentScore) { + topCandidates.splice(t, 0, candidate); + if (topCandidates.length > this._nbTopCandidates) + topCandidates.pop(); + break; + } + } + } + + var topCandidate = topCandidates[0] || null; + var neededToCreateTopCandidate = false; + var parentOfTopCandidate; + + // If we still have no top candidate, just use the body as a last resort. + // We also have to copy the body node so it is something we can modify. + if (topCandidate === null || topCandidate.tagName === "BODY") { + // Move all of the page's children into topCandidate + topCandidate = doc.createElement("DIV"); + neededToCreateTopCandidate = true; + // Move everything (not just elements, also text nodes etc.) into the container + // so we even include text directly in the body: + var kids = page.childNodes; + while (kids.length) { + this.log("Moving child out:", kids[0]); + topCandidate.appendChild(kids[0]); + } + + page.appendChild(topCandidate); + + this._initializeNode(topCandidate); + } else if (topCandidate) { + // Find a better top candidate node if it contains (at least three) nodes which belong to `topCandidates` array + // and whose scores are quite closed with current `topCandidate` node. + var alternativeCandidateAncestors = []; + for (var i = 1; i < topCandidates.length; i++) { + if (topCandidates[i].readability.contentScore / topCandidate.readability.contentScore >= 0.75) { + alternativeCandidateAncestors.push(this._getNodeAncestors(topCandidates[i])); + } + } + var MINIMUM_TOPCANDIDATES = 3; + if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) { + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName !== "BODY") { + var listsContainingThisAncestor = 0; + for (var ancestorIndex = 0; ancestorIndex < alternativeCandidateAncestors.length && listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; ancestorIndex++) { + listsContainingThisAncestor += Number(alternativeCandidateAncestors[ancestorIndex].includes(parentOfTopCandidate)); + } + if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) { + topCandidate = parentOfTopCandidate; + break; + } + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + + // Because of our bonus system, parents of candidates might have scores + // themselves. They get half of the node. There won't be nodes with higher + // scores than our topCandidate, but if we see the score going *up* in the first + // few steps up the tree, that's a decent sign that there might be more content + // lurking in other places that we want to unify in. The sibling stuff + // below does some of that - but only if we've looked high enough up the DOM + // tree. + parentOfTopCandidate = topCandidate.parentNode; + var lastScore = topCandidate.readability.contentScore; + // The scores shouldn't get too low. + var scoreThreshold = lastScore / 3; + while (parentOfTopCandidate.tagName !== "BODY") { + if (!parentOfTopCandidate.readability) { + parentOfTopCandidate = parentOfTopCandidate.parentNode; + continue; + } + var parentScore = parentOfTopCandidate.readability.contentScore; + if (parentScore < scoreThreshold) + break; + if (parentScore > lastScore) { + // Alright! We found a better parent to use. + topCandidate = parentOfTopCandidate; + break; + } + lastScore = parentOfTopCandidate.readability.contentScore; + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + + // If the top candidate is the only child, use parent instead. This will help sibling + // joining logic when adjacent content is actually located in parent's sibling node. + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName != "BODY" && parentOfTopCandidate.children.length == 1) { + topCandidate = parentOfTopCandidate; + parentOfTopCandidate = topCandidate.parentNode; + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + } + + // Now that we have the top candidate, look through its siblings for content + // that might also be related. Things like preambles, content split by ads + // that we removed, etc. + var articleContent = doc.createElement("DIV"); + if (isPaging) + articleContent.id = "readability-content"; + + var siblingScoreThreshold = Math.max(10, topCandidate.readability.contentScore * 0.2); + // Keep potential top candidate's parent node to try to get text direction of it later. + parentOfTopCandidate = topCandidate.parentNode; + var siblings = parentOfTopCandidate.children; + + for (var s = 0, sl = siblings.length; s < sl; s++) { + var sibling = siblings[s]; + var append = false; + + this.log("Looking at sibling node:", sibling, sibling.readability ? ("with score " + sibling.readability.contentScore) : ""); + this.log("Sibling has score", sibling.readability ? sibling.readability.contentScore : "Unknown"); + + if (sibling === topCandidate) { + append = true; + } else { + var contentBonus = 0; + + // Give a bonus if sibling nodes and top candidates have the example same classname + if (sibling.className === topCandidate.className && topCandidate.className !== "") + contentBonus += topCandidate.readability.contentScore * 0.2; + + if (sibling.readability && + ((sibling.readability.contentScore + contentBonus) >= siblingScoreThreshold)) { + append = true; + } else if (sibling.nodeName === "P") { + var linkDensity = this._getLinkDensity(sibling); + var nodeContent = this._getInnerText(sibling); + var nodeLength = nodeContent.length; + + if (nodeLength > 80 && linkDensity < 0.25) { + append = true; + } else if (nodeLength < 80 && nodeLength > 0 && linkDensity === 0 && + nodeContent.search(/\.( |$)/) !== -1) { + append = true; + } + } + } + + if (append) { + this.log("Appending node:", sibling); + + if (this.ALTER_TO_DIV_EXCEPTIONS.indexOf(sibling.nodeName) === -1) { + // We have a node that isn't a common block level element, like a form or td tag. + // Turn it into a div so it doesn't get filtered out later by accident. + this.log("Altering sibling:", sibling, "to div."); + + sibling = this._setNodeTag(sibling, "DIV"); + } + + articleContent.appendChild(sibling); + // siblings is a reference to the children array, and + // sibling is removed from the array when we call appendChild(). + // As a result, we must revisit this index since the nodes + // have been shifted. + s -= 1; + sl -= 1; + } + } + + if (this._debug) + this.log("Article content pre-prep: " + articleContent.innerHTML); + // So we have all of the content that we need. Now we clean it up for presentation. + this._prepArticle(articleContent); + if (this._debug) + this.log("Article content post-prep: " + articleContent.innerHTML); + + if (neededToCreateTopCandidate) { + // We already created a fake div thing, and there wouldn't have been any siblings left + // for the previous loop, so there's no point trying to create a new div, and then + // move all the children over. Just assign IDs and class names here. No need to append + // because that already happened anyway. + topCandidate.id = "readability-page-1"; + topCandidate.className = "page"; + } else { + var div = doc.createElement("DIV"); + div.id = "readability-page-1"; + div.className = "page"; + var children = articleContent.childNodes; + while (children.length) { + div.appendChild(children[0]); + } + articleContent.appendChild(div); + } + + if (this._debug) + this.log("Article content after paging: " + articleContent.innerHTML); + + var parseSuccessful = true; + + // Now that we've gone through the full algorithm, check to see if + // we got any meaningful content. If we didn't, we may need to re-run + // grabArticle with different flags set. This gives us a higher likelihood of + // finding the content, and the sieve approach gives us a higher likelihood of + // finding the -right- content. + var textLength = this._getInnerText(articleContent, true).length; + if (textLength < this._charThreshold) { + parseSuccessful = false; + page.innerHTML = pageCacheHtml; + + if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) { + this._removeFlag(this.FLAG_STRIP_UNLIKELYS); + this._attempts.push({articleContent: articleContent, textLength: textLength}); + } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) { + this._removeFlag(this.FLAG_WEIGHT_CLASSES); + this._attempts.push({articleContent: articleContent, textLength: textLength}); + } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) { + this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY); + this._attempts.push({articleContent: articleContent, textLength: textLength}); + } else { + this._attempts.push({articleContent: articleContent, textLength: textLength}); + // No luck after removing flags, just return the longest text we found during the different loops + this._attempts.sort(function (a, b) { + return b.textLength - a.textLength; + }); + + // But first check if we actually have something + if (!this._attempts[0].textLength) { + return null; + } + + articleContent = this._attempts[0].articleContent; + parseSuccessful = true; + } + } + + if (parseSuccessful) { + // Find out text direction from ancestors of final top candidate. + var ancestors = [parentOfTopCandidate, topCandidate].concat(this._getNodeAncestors(parentOfTopCandidate)); + this._someNode(ancestors, function(ancestor) { + if (!ancestor.tagName) + return false; + var articleDir = ancestor.getAttribute("dir"); + if (articleDir) { + this._articleDir = articleDir; + return true; + } + return false; + }); + return articleContent; + } + } + }, + + /** + * Check whether the input string could be a byline. + * This verifies that the input is a string, and that the length + * is less than 100 chars. + * + * @param possibleByline {string} - a string to check whether its a byline. + * @return Boolean - whether the input string is a byline. + */ + _isValidByline: function(byline) { + if (typeof byline == "string" || byline instanceof String) { + byline = byline.trim(); + return (byline.length > 0) && (byline.length < 100); + } + return false; + }, + + /** + * Attempts to get excerpt and byline metadata for the article. + * + * @return Object with optional "excerpt" and "byline" properties + */ + _getArticleMetadata: function() { + var metadata = {}; + var values = {}; + var metaElements = this._doc.getElementsByTagName("meta"); + + // property is a space-separated list of values + var propertyPattern = /\s*(dc|dcterm|og|twitter)\s*:\s*(author|creator|description|title|site_name)\s*/gi; + + // name is a single value + var namePattern = /^\s*(?:(dc|dcterm|og|twitter|weibo:(article|webpage))\s*[\.:]\s*)?(author|creator|description|title|site_name)\s*$/i; + + // Find description tags. + this._forEachNode(metaElements, function(element) { + var elementName = element.getAttribute("name"); + var elementProperty = element.getAttribute("property"); + var content = element.getAttribute("content"); + if (!content) { + return; + } + var matches = null; + var name = null; + + if (elementProperty) { + matches = elementProperty.match(propertyPattern); + if (matches) { + for (var i = matches.length - 1; i >= 0; i--) { + // Convert to lowercase, and remove any whitespace + // so we can match below. + name = matches[i].toLowerCase().replace(/\s/g, ""); + // multiple authors + values[name] = content.trim(); + } + } + } + if (!matches && elementName && namePattern.test(elementName)) { + name = elementName; + if (content) { + // Convert to lowercase, remove any whitespace, and convert dots + // to colons so we can match below. + name = name.toLowerCase().replace(/\s/g, "").replace(/\./g, ":"); + values[name] = content.trim(); + } + } + }); + + // get title + metadata.title = values["dc:title"] || + values["dcterm:title"] || + values["og:title"] || + values["weibo:article:title"] || + values["weibo:webpage:title"] || + values["title"] || + values["twitter:title"]; + + if (!metadata.title) { + metadata.title = this._getArticleTitle(); + } + + // get author + metadata.byline = values["dc:creator"] || + values["dcterm:creator"] || + values["author"]; + + // get description + metadata.excerpt = values["dc:description"] || + values["dcterm:description"] || + values["og:description"] || + values["weibo:article:description"] || + values["weibo:webpage:description"] || + values["description"] || + values["twitter:description"]; + + // get site name + metadata.siteName = values["og:site_name"]; + + return metadata; + }, + + /** + * Removes script tags from the document. + * + * @param Element + **/ + _removeScripts: function(doc) { + this._removeNodes(doc.getElementsByTagName("script"), function(scriptNode) { + scriptNode.nodeValue = ""; + scriptNode.removeAttribute("src"); + return true; + }); + this._removeNodes(doc.getElementsByTagName("noscript")); + }, + + /** + * Check if this node has only whitespace and a single element with given tag + * Returns false if the DIV node contains non-empty text nodes + * or if it contains no element with given tag or more than 1 element. + * + * @param Element + * @param string tag of child element + **/ + _hasSingleTagInsideElement: function(element, tag) { + // There should be exactly 1 element child with given tag + if (element.children.length != 1 || element.children[0].tagName !== tag) { + return false; + } + + // And there should be no text nodes with real content + return !this._someNode(element.childNodes, function(node) { + return node.nodeType === this.TEXT_NODE && + this.REGEXPS.hasContent.test(node.textContent); + }); + }, + + _isElementWithoutContent: function(node) { + return node.nodeType === this.ELEMENT_NODE && + node.textContent.trim().length == 0 && + (node.children.length == 0 || + node.children.length == node.getElementsByTagName("br").length + node.getElementsByTagName("hr").length); + }, + + /** + * Determine whether element has any children block level elements. + * + * @param Element + */ + _hasChildBlockElement: function (element) { + return this._someNode(element.childNodes, function(node) { + return this.DIV_TO_P_ELEMS.indexOf(node.tagName) !== -1 || + this._hasChildBlockElement(node); + }); + }, + + /*** + * Determine if a node qualifies as phrasing content. + * https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories#Phrasing_content + **/ + _isPhrasingContent: function(node) { + return node.nodeType === this.TEXT_NODE || this.PHRASING_ELEMS.indexOf(node.tagName) !== -1 || + ((node.tagName === "A" || node.tagName === "DEL" || node.tagName === "INS") && + this._everyNode(node.childNodes, this._isPhrasingContent)); + }, + + _isWhitespace: function(node) { + return (node.nodeType === this.TEXT_NODE && node.textContent.trim().length === 0) || + (node.nodeType === this.ELEMENT_NODE && node.tagName === "BR"); + }, + + /** + * Get the inner text of a node - cross browser compatibly. + * This also strips out any excess whitespace to be found. + * + * @param Element + * @param Boolean normalizeSpaces (default: true) + * @return string + **/ + _getInnerText: function(e, normalizeSpaces) { + normalizeSpaces = (typeof normalizeSpaces === "undefined") ? true : normalizeSpaces; + var textContent = e.textContent.trim(); + + if (normalizeSpaces) { + return textContent.replace(this.REGEXPS.normalize, " "); + } + return textContent; + }, + + /** + * Get the number of times a string s appears in the node e. + * + * @param Element + * @param string - what to split on. Default is "," + * @return number (integer) + **/ + _getCharCount: function(e, s) { + s = s || ","; + return this._getInnerText(e).split(s).length - 1; + }, + + /** + * Remove the style attribute on every e and under. + * TODO: Test if getElementsByTagName(*) is faster. + * + * @param Element + * @return void + **/ + _cleanStyles: function(e) { + if (!e || e.tagName.toLowerCase() === "svg") + return; + + // Remove `style` and deprecated presentational attributes + for (var i = 0; i < this.PRESENTATIONAL_ATTRIBUTES.length; i++) { + e.removeAttribute(this.PRESENTATIONAL_ATTRIBUTES[i]); + } + + if (this.DEPRECATED_SIZE_ATTRIBUTE_ELEMS.indexOf(e.tagName) !== -1) { + e.removeAttribute("width"); + e.removeAttribute("height"); + } + + var cur = e.firstElementChild; + while (cur !== null) { + this._cleanStyles(cur); + cur = cur.nextElementSibling; + } + }, + + /** + * Get the density of links as a percentage of the content + * This is the amount of text that is inside a link divided by the total text in the node. + * + * @param Element + * @return number (float) + **/ + _getLinkDensity: function(element) { + var textLength = this._getInnerText(element).length; + if (textLength === 0) + return 0; + + var linkLength = 0; + + // XXX implement _reduceNodeList? + this._forEachNode(element.getElementsByTagName("a"), function(linkNode) { + linkLength += this._getInnerText(linkNode).length; + }); + + return linkLength / textLength; + }, + + /** + * Get an elements class/id weight. Uses regular expressions to tell if this + * element looks good or bad. + * + * @param Element + * @return number (Integer) + **/ + _getClassWeight: function(e) { + if (!this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) + return 0; + + var weight = 0; + + // Look for a special classname + if (typeof(e.className) === "string" && e.className !== "") { + if (this.REGEXPS.negative.test(e.className)) + weight -= 25; + + if (this.REGEXPS.positive.test(e.className)) + weight += 25; + } + + // Look for a special ID + if (typeof(e.id) === "string" && e.id !== "") { + if (this.REGEXPS.negative.test(e.id)) + weight -= 25; + + if (this.REGEXPS.positive.test(e.id)) + weight += 25; + } + + return weight; + }, + + /** + * Clean a node of all elements of type "tag". + * (Unless it's a youtube/vimeo video. People love movies.) + * + * @param Element + * @param string tag to clean + * @return void + **/ + _clean: function(e, tag) { + var isEmbed = ["object", "embed", "iframe"].indexOf(tag) !== -1; + + this._removeNodes(e.getElementsByTagName(tag), function(element) { + // Allow youtube and vimeo videos through as people usually want to see those. + if (isEmbed) { + // First, check the elements attributes to see if any of them contain youtube or vimeo + for (var i = 0; i < element.attributes.length; i++) { + if (this.REGEXPS.videos.test(element.attributes[i].value)) { + return false; + } + } + + // For embed with tag, check inner HTML as well. + if (element.tagName === "object" && this.REGEXPS.videos.test(element.innerHTML)) { + return false; + } + } + + return true; + }); + }, + + /** + * Check if a given node has one of its ancestor tag name matching the + * provided one. + * @param HTMLElement node + * @param String tagName + * @param Number maxDepth + * @param Function filterFn a filter to invoke to determine whether this node 'counts' + * @return Boolean + */ + _hasAncestorTag: function(node, tagName, maxDepth, filterFn) { + maxDepth = maxDepth || 3; + tagName = tagName.toUpperCase(); + var depth = 0; + while (node.parentNode) { + if (maxDepth > 0 && depth > maxDepth) + return false; + if (node.parentNode.tagName === tagName && (!filterFn || filterFn(node.parentNode))) + return true; + node = node.parentNode; + depth++; + } + return false; + }, + + /** + * Return an object indicating how many rows and columns this table has. + */ + _getRowAndColumnCount: function(table) { + var rows = 0; + var columns = 0; + var trs = table.getElementsByTagName("tr"); + for (var i = 0; i < trs.length; i++) { + var rowspan = trs[i].getAttribute("rowspan") || 0; + if (rowspan) { + rowspan = parseInt(rowspan, 10); + } + rows += (rowspan || 1); + + // Now look for column-related info + var columnsInThisRow = 0; + var cells = trs[i].getElementsByTagName("td"); + for (var j = 0; j < cells.length; j++) { + var colspan = cells[j].getAttribute("colspan") || 0; + if (colspan) { + colspan = parseInt(colspan, 10); + } + columnsInThisRow += (colspan || 1); + } + columns = Math.max(columns, columnsInThisRow); + } + return {rows: rows, columns: columns}; + }, + + /** + * Look for 'data' (as opposed to 'layout') tables, for which we use + * similar checks as + * https://dxr.mozilla.org/mozilla-central/rev/71224049c0b52ab190564d3ea0eab089a159a4cf/accessible/html/HTMLTableAccessible.cpp#920 + */ + _markDataTables: function(root) { + var tables = root.getElementsByTagName("table"); + for (var i = 0; i < tables.length; i++) { + var table = tables[i]; + var role = table.getAttribute("role"); + if (role == "presentation") { + table._readabilityDataTable = false; + continue; + } + var datatable = table.getAttribute("datatable"); + if (datatable == "0") { + table._readabilityDataTable = false; + continue; + } + var summary = table.getAttribute("summary"); + if (summary) { + table._readabilityDataTable = true; + continue; + } + + var caption = table.getElementsByTagName("caption")[0]; + if (caption && caption.childNodes.length > 0) { + table._readabilityDataTable = true; + continue; + } + + // If the table has a descendant with any of these tags, consider a data table: + var dataTableDescendants = ["col", "colgroup", "tfoot", "thead", "th"]; + var descendantExists = function(tag) { + return !!table.getElementsByTagName(tag)[0]; + }; + if (dataTableDescendants.some(descendantExists)) { + this.log("Data table because found data-y descendant"); + table._readabilityDataTable = true; + continue; + } + + // Nested tables indicate a layout table: + if (table.getElementsByTagName("table")[0]) { + table._readabilityDataTable = false; + continue; + } + + var sizeInfo = this._getRowAndColumnCount(table); + if (sizeInfo.rows >= 10 || sizeInfo.columns > 4) { + table._readabilityDataTable = true; + continue; + } + // Now just go by size entirely: + table._readabilityDataTable = sizeInfo.rows * sizeInfo.columns > 10; + } + }, + + /* convert images and figures that have properties like data-src into images that can be loaded without JS */ + _fixLazyImages: function (root) { + this._forEachNode(this._getAllNodesWithTag(root, ["img", "picture", "figure"]), function (elem) { + // also check for "null" to work around https://github.com/jsdom/jsdom/issues/2580 + if ((!elem.src && (!elem.srcset || elem.srcset == "null")) || elem.className.toLowerCase().indexOf("lazy") !== -1) { + for (var i = 0; i < elem.attributes.length; i++) { + var attr = elem.attributes[i]; + if (attr.name === "src" || attr.name === "srcset") { + continue; + } + var copyTo = null; + if (/\.(jpg|jpeg|png|webp)\s+\d/.test(attr.value)) { + copyTo = "srcset"; + } else if (/^\s*\S+\.(jpg|jpeg|png|webp)\S*\s*$/.test(attr.value)) { + copyTo = "src"; + } + if (copyTo) { + //if this is an img or picture, set the attribute directly + if (elem.tagName === "IMG" || elem.tagName === "PICTURE") { + elem.setAttribute(copyTo, attr.value); + } else if (elem.tagName === "FIGURE" && !this._getAllNodesWithTag(elem, ["img", "picture"]).length) { + //if the item is a
that does not contain an image or picture, create one and place it inside the figure + //see the nytimes-3 testcase for an example + var img = this._doc.createElement("img"); + img.setAttribute(copyTo, attr.value); + elem.appendChild(img); + } + } + } + } + }); + }, + + /** + * Clean an element of all tags of type "tag" if they look fishy. + * "Fishy" is an algorithm based on content length, classnames, link density, number of images & embeds, etc. + * + * @return void + **/ + _cleanConditionally: function(e, tag) { + if (!this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) + return; + + var isList = tag === "ul" || tag === "ol"; + + // Gather counts for other typical elements embedded within. + // Traverse backwards so we can remove nodes at the same time + // without effecting the traversal. + // + // TODO: Consider taking into account original contentScore here. + this._removeNodes(e.getElementsByTagName(tag), function(node) { + // First check if this node IS data table, in which case don't remove it. + var isDataTable = function(t) { + return t._readabilityDataTable; + }; + + if (tag === "table" && isDataTable(node)) { + return false; + } + + // Next check if we're inside a data table, in which case don't remove it as well. + if (this._hasAncestorTag(node, "table", -1, isDataTable)) { + return false; + } + + var weight = this._getClassWeight(node); + var contentScore = 0; + + this.log("Cleaning Conditionally", node); + + if (weight + contentScore < 0) { + return true; + } + + if (this._getCharCount(node, ",") < 10) { + // If there are not very many commas, and the number of + // non-paragraph elements is more than paragraphs or other + // ominous signs, remove the element. + var p = node.getElementsByTagName("p").length; + var img = node.getElementsByTagName("img").length; + var li = node.getElementsByTagName("li").length - 100; + var input = node.getElementsByTagName("input").length; + + var embedCount = 0; + var embeds = this._concatNodeLists( + node.getElementsByTagName("object"), + node.getElementsByTagName("embed"), + node.getElementsByTagName("iframe")); + + for (var i = 0; i < embeds.length; i++) { + // If this embed has attribute that matches video regex, don't delete it. + for (var j = 0; j < embeds[i].attributes.length; j++) { + if (this.REGEXPS.videos.test(embeds[i].attributes[j].value)) { + return false; + } + } + + // For embed with tag, check inner HTML as well. + if (embeds[i].tagName === "object" && this.REGEXPS.videos.test(embeds[i].innerHTML)) { + return false; + } + + embedCount++; + } + + var linkDensity = this._getLinkDensity(node); + var contentLength = this._getInnerText(node).length; + + var haveToRemove = + (img > 1 && p / img < 0.5 && !this._hasAncestorTag(node, "figure")) || + (!isList && li > p) || + (input > Math.floor(p/3)) || + (!isList && contentLength < 25 && (img === 0 || img > 2) && !this._hasAncestorTag(node, "figure")) || + (!isList && weight < 25 && linkDensity > 0.2) || + (weight >= 25 && linkDensity > 0.5) || + ((embedCount === 1 && contentLength < 75) || embedCount > 1); + return haveToRemove; + } + return false; + }); + }, + + /** + * Clean out elements that match the specified conditions + * + * @param Element + * @param Function determines whether a node should be removed + * @return void + **/ + _cleanMatchedNodes: function(e, filter) { + var endOfSearchMarkerNode = this._getNextNode(e, true); + var next = this._getNextNode(e); + while (next && next != endOfSearchMarkerNode) { + if (filter(next, next.className + " " + next.id)) { + next = this._removeAndGetNext(next); + } else { + next = this._getNextNode(next); + } + } + }, + + /** + * Clean out spurious headers from an Element. Checks things like classnames and link density. + * + * @param Element + * @return void + **/ + _cleanHeaders: function(e) { + for (var headerIndex = 1; headerIndex < 3; headerIndex += 1) { + this._removeNodes(e.getElementsByTagName("h" + headerIndex), function (header) { + return this._getClassWeight(header) < 0; + }); + } + }, + + _flagIsActive: function(flag) { + return (this._flags & flag) > 0; + }, + + _removeFlag: function(flag) { + this._flags = this._flags & ~flag; + }, + + _isProbablyVisible: function(node) { + return (!node.style || node.style.display != "none") && !node.hasAttribute("hidden"); + }, + + /** + * Runs readability. + * + * Workflow: + * 1. Prep the document by removing script tags, css, etc. + * 2. Build readability's DOM tree. + * 3. Grab the article content from the current dom tree. + * 4. Replace the current DOM tree with the new one. + * 5. Read peacefully. + * + * @return void + **/ + parse: function () { + // Avoid parsing too large documents, as per configuration option + if (this._maxElemsToParse > 0) { + var numTags = this._doc.getElementsByTagName("*").length; + if (numTags > this._maxElemsToParse) { + throw new Error("Aborting parsing document; " + numTags + " elements found"); + } + } + + // Remove script tags from the document. + this._removeScripts(this._doc); + + this._prepDocument(); + + var metadata = this._getArticleMetadata(); + this._articleTitle = metadata.title; + + var articleContent = this._grabArticle(); + if (!articleContent) + return null; + + this.log("Grabbed: " + articleContent.innerHTML); + + this._postProcessContent(articleContent); + + // If we haven't found an excerpt in the article's metadata, use the article's + // first paragraph as the excerpt. This is used for displaying a preview of + // the article's content. + if (!metadata.excerpt) { + var paragraphs = articleContent.getElementsByTagName("p"); + if (paragraphs.length > 0) { + metadata.excerpt = paragraphs[0].textContent.trim(); + } + } + + var textContent = articleContent.textContent; + return { + articleContent: articleContent, + title: this._articleTitle, + byline: metadata.byline || this._articleByline, + dir: this._articleDir, + content: articleContent.innerHTML, + textContent: textContent, + length: textContent.length, + excerpt: metadata.excerpt, + siteName: metadata.siteName || this._articleSiteName + }; + } +}; + +if (typeof module === "object") { + module.exports = Readability; +} diff --git a/lib/browser-polyfill.js b/lib/browser-polyfill.js new file mode 100644 index 000000000..0a5442b41 --- /dev/null +++ b/lib/browser-polyfill.js @@ -0,0 +1,1187 @@ +(function (global, factory) { + if (typeof define === "function" && define.amd) { + define("webextension-polyfill", ["module"], factory); + } else if (typeof exports !== "undefined") { + factory(module); + } else { + var mod = { + exports: {} + }; + factory(mod); + global.browser = mod.exports; + } +})(this, function (module) { + /* webextension-polyfill - v0.4.0 - Wed Feb 06 2019 11:58:31 */ + /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ + /* vim: set sts=2 sw=2 et tw=80: */ + /* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + "use strict"; + + if (typeof browser === "undefined" || Object.getPrototypeOf(browser) !== Object.prototype) { + const CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE = "The message port closed before a response was received."; + const SEND_RESPONSE_DEPRECATION_WARNING = "Returning a Promise is the preferred way to send a reply from an onMessage/onMessageExternal listener, as the sendResponse will be removed from the specs (See https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onMessage)"; + + // Wrapping the bulk of this polyfill in a one-time-use function is a minor + // optimization for Firefox. Since Spidermonkey does not fully parse the + // contents of a function until the first time it's called, and since it will + // never actually need to be called, this allows the polyfill to be included + // in Firefox nearly for free. + const wrapAPIs = extensionAPIs => { + // NOTE: apiMetadata is associated to the content of the api-metadata.json file + // at build time by replacing the following "include" with the content of the + // JSON file. + const apiMetadata = { + "alarms": { + "clear": { + "minArgs": 0, + "maxArgs": 1 + }, + "clearAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "bookmarks": { + "create": { + "minArgs": 1, + "maxArgs": 1 + }, + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getChildren": { + "minArgs": 1, + "maxArgs": 1 + }, + "getRecent": { + "minArgs": 1, + "maxArgs": 1 + }, + "getSubTree": { + "minArgs": 1, + "maxArgs": 1 + }, + "getTree": { + "minArgs": 0, + "maxArgs": 0 + }, + "move": { + "minArgs": 2, + "maxArgs": 2 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeTree": { + "minArgs": 1, + "maxArgs": 1 + }, + "search": { + "minArgs": 1, + "maxArgs": 1 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + }, + "browserAction": { + "disable": { + "minArgs": 0, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "enable": { + "minArgs": 0, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "getBadgeBackgroundColor": { + "minArgs": 1, + "maxArgs": 1 + }, + "getBadgeText": { + "minArgs": 1, + "maxArgs": 1 + }, + "getPopup": { + "minArgs": 1, + "maxArgs": 1 + }, + "getTitle": { + "minArgs": 1, + "maxArgs": 1 + }, + "openPopup": { + "minArgs": 0, + "maxArgs": 0 + }, + "setBadgeBackgroundColor": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setBadgeText": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setIcon": { + "minArgs": 1, + "maxArgs": 1 + }, + "setPopup": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setTitle": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + } + }, + "browsingData": { + "remove": { + "minArgs": 2, + "maxArgs": 2 + }, + "removeCache": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeCookies": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeDownloads": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeFormData": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeHistory": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeLocalStorage": { + "minArgs": 1, + "maxArgs": 1 + }, + "removePasswords": { + "minArgs": 1, + "maxArgs": 1 + }, + "removePluginData": { + "minArgs": 1, + "maxArgs": 1 + }, + "settings": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "commands": { + "getAll": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "contextMenus": { + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + }, + "cookies": { + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAllCookieStores": { + "minArgs": 0, + "maxArgs": 0 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "set": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "devtools": { + "inspectedWindow": { + "eval": { + "minArgs": 1, + "maxArgs": 2, + "singleCallbackArg": false + } + }, + "panels": { + "create": { + "minArgs": 3, + "maxArgs": 3, + "singleCallbackArg": true + } + } + }, + "downloads": { + "cancel": { + "minArgs": 1, + "maxArgs": 1 + }, + "download": { + "minArgs": 1, + "maxArgs": 1 + }, + "erase": { + "minArgs": 1, + "maxArgs": 1 + }, + "getFileIcon": { + "minArgs": 1, + "maxArgs": 2 + }, + "open": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "pause": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeFile": { + "minArgs": 1, + "maxArgs": 1 + }, + "resume": { + "minArgs": 1, + "maxArgs": 1 + }, + "search": { + "minArgs": 1, + "maxArgs": 1 + }, + "show": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + } + }, + "extension": { + "isAllowedFileSchemeAccess": { + "minArgs": 0, + "maxArgs": 0 + }, + "isAllowedIncognitoAccess": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "history": { + "addUrl": { + "minArgs": 1, + "maxArgs": 1 + }, + "deleteAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "deleteRange": { + "minArgs": 1, + "maxArgs": 1 + }, + "deleteUrl": { + "minArgs": 1, + "maxArgs": 1 + }, + "getVisits": { + "minArgs": 1, + "maxArgs": 1 + }, + "search": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "i18n": { + "detectLanguage": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAcceptLanguages": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "identity": { + "launchWebAuthFlow": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "idle": { + "queryState": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "management": { + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "getSelf": { + "minArgs": 0, + "maxArgs": 0 + }, + "setEnabled": { + "minArgs": 2, + "maxArgs": 2 + }, + "uninstallSelf": { + "minArgs": 0, + "maxArgs": 1 + } + }, + "notifications": { + "clear": { + "minArgs": 1, + "maxArgs": 1 + }, + "create": { + "minArgs": 1, + "maxArgs": 2 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "getPermissionLevel": { + "minArgs": 0, + "maxArgs": 0 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + }, + "pageAction": { + "getPopup": { + "minArgs": 1, + "maxArgs": 1 + }, + "getTitle": { + "minArgs": 1, + "maxArgs": 1 + }, + "hide": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setIcon": { + "minArgs": 1, + "maxArgs": 1 + }, + "setPopup": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "setTitle": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + }, + "show": { + "minArgs": 1, + "maxArgs": 1, + "fallbackToNoCallback": true + } + }, + "permissions": { + "contains": { + "minArgs": 1, + "maxArgs": 1 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 0 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "request": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "runtime": { + "getBackgroundPage": { + "minArgs": 0, + "maxArgs": 0 + }, + "getBrowserInfo": { + "minArgs": 0, + "maxArgs": 0 + }, + "getPlatformInfo": { + "minArgs": 0, + "maxArgs": 0 + }, + "openOptionsPage": { + "minArgs": 0, + "maxArgs": 0 + }, + "requestUpdateCheck": { + "minArgs": 0, + "maxArgs": 0 + }, + "sendMessage": { + "minArgs": 1, + "maxArgs": 3 + }, + "sendNativeMessage": { + "minArgs": 2, + "maxArgs": 2 + }, + "setUninstallURL": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "sessions": { + "getDevices": { + "minArgs": 0, + "maxArgs": 1 + }, + "getRecentlyClosed": { + "minArgs": 0, + "maxArgs": 1 + }, + "restore": { + "minArgs": 0, + "maxArgs": 1 + } + }, + "storage": { + "local": { + "clear": { + "minArgs": 0, + "maxArgs": 0 + }, + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getBytesInUse": { + "minArgs": 0, + "maxArgs": 1 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "set": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "managed": { + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getBytesInUse": { + "minArgs": 0, + "maxArgs": 1 + } + }, + "sync": { + "clear": { + "minArgs": 0, + "maxArgs": 0 + }, + "get": { + "minArgs": 0, + "maxArgs": 1 + }, + "getBytesInUse": { + "minArgs": 0, + "maxArgs": 1 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "set": { + "minArgs": 1, + "maxArgs": 1 + } + } + }, + "tabs": { + "captureVisibleTab": { + "minArgs": 0, + "maxArgs": 2 + }, + "create": { + "minArgs": 1, + "maxArgs": 1 + }, + "detectLanguage": { + "minArgs": 0, + "maxArgs": 1 + }, + "discard": { + "minArgs": 0, + "maxArgs": 1 + }, + "duplicate": { + "minArgs": 1, + "maxArgs": 1 + }, + "executeScript": { + "minArgs": 1, + "maxArgs": 2 + }, + "get": { + "minArgs": 1, + "maxArgs": 1 + }, + "getCurrent": { + "minArgs": 0, + "maxArgs": 0 + }, + "getZoom": { + "minArgs": 0, + "maxArgs": 1 + }, + "getZoomSettings": { + "minArgs": 0, + "maxArgs": 1 + }, + "highlight": { + "minArgs": 1, + "maxArgs": 1 + }, + "insertCSS": { + "minArgs": 1, + "maxArgs": 2 + }, + "move": { + "minArgs": 2, + "maxArgs": 2 + }, + "query": { + "minArgs": 1, + "maxArgs": 1 + }, + "reload": { + "minArgs": 0, + "maxArgs": 2 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "removeCSS": { + "minArgs": 1, + "maxArgs": 2 + }, + "sendMessage": { + "minArgs": 2, + "maxArgs": 3 + }, + "setZoom": { + "minArgs": 1, + "maxArgs": 2 + }, + "setZoomSettings": { + "minArgs": 1, + "maxArgs": 2 + }, + "update": { + "minArgs": 1, + "maxArgs": 2 + } + }, + "topSites": { + "get": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "webNavigation": { + "getAllFrames": { + "minArgs": 1, + "maxArgs": 1 + }, + "getFrame": { + "minArgs": 1, + "maxArgs": 1 + } + }, + "webRequest": { + "handlerBehaviorChanged": { + "minArgs": 0, + "maxArgs": 0 + } + }, + "windows": { + "create": { + "minArgs": 0, + "maxArgs": 1 + }, + "get": { + "minArgs": 1, + "maxArgs": 2 + }, + "getAll": { + "minArgs": 0, + "maxArgs": 1 + }, + "getCurrent": { + "minArgs": 0, + "maxArgs": 1 + }, + "getLastFocused": { + "minArgs": 0, + "maxArgs": 1 + }, + "remove": { + "minArgs": 1, + "maxArgs": 1 + }, + "update": { + "minArgs": 2, + "maxArgs": 2 + } + } + }; + + if (Object.keys(apiMetadata).length === 0) { + throw new Error("api-metadata.json has not been included in browser-polyfill"); + } + + /** + * A WeakMap subclass which creates and stores a value for any key which does + * not exist when accessed, but behaves exactly as an ordinary WeakMap + * otherwise. + * + * @param {function} createItem + * A function which will be called in order to create the value for any + * key which does not exist, the first time it is accessed. The + * function receives, as its only argument, the key being created. + */ + class DefaultWeakMap extends WeakMap { + constructor(createItem, items = undefined) { + super(items); + this.createItem = createItem; + } + + get(key) { + if (!this.has(key)) { + this.set(key, this.createItem(key)); + } + + return super.get(key); + } + } + + /** + * Returns true if the given object is an object with a `then` method, and can + * therefore be assumed to behave as a Promise. + * + * @param {*} value The value to test. + * @returns {boolean} True if the value is thenable. + */ + const isThenable = value => { + return value && typeof value === "object" && typeof value.then === "function"; + }; + + /** + * Creates and returns a function which, when called, will resolve or reject + * the given promise based on how it is called: + * + * - If, when called, `chrome.runtime.lastError` contains a non-null object, + * the promise is rejected with that value. + * - If the function is called with exactly one argument, the promise is + * resolved to that value. + * - Otherwise, the promise is resolved to an array containing all of the + * function's arguments. + * + * @param {object} promise + * An object containing the resolution and rejection functions of a + * promise. + * @param {function} promise.resolve + * The promise's resolution function. + * @param {function} promise.rejection + * The promise's rejection function. + * @param {object} metadata + * Metadata about the wrapped method which has created the callback. + * @param {integer} metadata.maxResolvedArgs + * The maximum number of arguments which may be passed to the + * callback created by the wrapped async function. + * + * @returns {function} + * The generated callback function. + */ + const makeCallback = (promise, metadata) => { + return (...callbackArgs) => { + if (extensionAPIs.runtime.lastError) { + promise.reject(extensionAPIs.runtime.lastError); + } else if (metadata.singleCallbackArg || callbackArgs.length <= 1 && metadata.singleCallbackArg !== false) { + promise.resolve(callbackArgs[0]); + } else { + promise.resolve(callbackArgs); + } + }; + }; + + const pluralizeArguments = numArgs => numArgs == 1 ? "argument" : "arguments"; + + /** + * Creates a wrapper function for a method with the given name and metadata. + * + * @param {string} name + * The name of the method which is being wrapped. + * @param {object} metadata + * Metadata about the method being wrapped. + * @param {integer} metadata.minArgs + * The minimum number of arguments which must be passed to the + * function. If called with fewer than this number of arguments, the + * wrapper will raise an exception. + * @param {integer} metadata.maxArgs + * The maximum number of arguments which may be passed to the + * function. If called with more than this number of arguments, the + * wrapper will raise an exception. + * @param {integer} metadata.maxResolvedArgs + * The maximum number of arguments which may be passed to the + * callback created by the wrapped async function. + * + * @returns {function(object, ...*)} + * The generated wrapper function. + */ + const wrapAsyncFunction = (name, metadata) => { + return function asyncFunctionWrapper(target, ...args) { + if (args.length < metadata.minArgs) { + throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`); + } + + if (args.length > metadata.maxArgs) { + throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`); + } + + return new Promise((resolve, reject) => { + if (metadata.fallbackToNoCallback) { + // This API method has currently no callback on Chrome, but it return a promise on Firefox, + // and so the polyfill will try to call it with a callback first, and it will fallback + // to not passing the callback if the first call fails. + try { + target[name](...args, makeCallback({ resolve, reject }, metadata)); + } catch (cbError) { + console.warn(`${name} API method doesn't seem to support the callback parameter, ` + "falling back to call it without a callback: ", cbError); + + target[name](...args); + + // Update the API method metadata, so that the next API calls will not try to + // use the unsupported callback anymore. + metadata.fallbackToNoCallback = false; + metadata.noCallback = true; + + resolve(); + } + } else if (metadata.noCallback) { + target[name](...args); + resolve(); + } else { + target[name](...args, makeCallback({ resolve, reject }, metadata)); + } + }); + }; + }; + + /** + * Wraps an existing method of the target object, so that calls to it are + * intercepted by the given wrapper function. The wrapper function receives, + * as its first argument, the original `target` object, followed by each of + * the arguments passed to the original method. + * + * @param {object} target + * The original target object that the wrapped method belongs to. + * @param {function} method + * The method being wrapped. This is used as the target of the Proxy + * object which is created to wrap the method. + * @param {function} wrapper + * The wrapper function which is called in place of a direct invocation + * of the wrapped method. + * + * @returns {Proxy} + * A Proxy object for the given method, which invokes the given wrapper + * method in its place. + */ + const wrapMethod = (target, method, wrapper) => { + return new Proxy(method, { + apply(targetMethod, thisObj, args) { + return wrapper.call(thisObj, target, ...args); + } + }); + }; + + let hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty); + + /** + * Wraps an object in a Proxy which intercepts and wraps certain methods + * based on the given `wrappers` and `metadata` objects. + * + * @param {object} target + * The target object to wrap. + * + * @param {object} [wrappers = {}] + * An object tree containing wrapper functions for special cases. Any + * function present in this object tree is called in place of the + * method in the same location in the `target` object tree. These + * wrapper methods are invoked as described in {@see wrapMethod}. + * + * @param {object} [metadata = {}] + * An object tree containing metadata used to automatically generate + * Promise-based wrapper functions for asynchronous. Any function in + * the `target` object tree which has a corresponding metadata object + * in the same location in the `metadata` tree is replaced with an + * automatically-generated wrapper function, as described in + * {@see wrapAsyncFunction} + * + * @returns {Proxy} + */ + const wrapObject = (target, wrappers = {}, metadata = {}) => { + let cache = Object.create(null); + let handlers = { + has(proxyTarget, prop) { + return prop in target || prop in cache; + }, + + get(proxyTarget, prop, receiver) { + if (prop in cache) { + return cache[prop]; + } + + if (!(prop in target)) { + return undefined; + } + + let value = target[prop]; + + if (typeof value === "function") { + // This is a method on the underlying object. Check if we need to do + // any wrapping. + + if (typeof wrappers[prop] === "function") { + // We have a special-case wrapper for this method. + value = wrapMethod(target, target[prop], wrappers[prop]); + } else if (hasOwnProperty(metadata, prop)) { + // This is an async method that we have metadata for. Create a + // Promise wrapper for it. + let wrapper = wrapAsyncFunction(prop, metadata[prop]); + value = wrapMethod(target, target[prop], wrapper); + } else { + // This is a method that we don't know or care about. Return the + // original method, bound to the underlying object. + value = value.bind(target); + } + } else if (typeof value === "object" && value !== null && (hasOwnProperty(wrappers, prop) || hasOwnProperty(metadata, prop))) { + // This is an object that we need to do some wrapping for the children + // of. Create a sub-object wrapper for it with the appropriate child + // metadata. + value = wrapObject(value, wrappers[prop], metadata[prop]); + } else { + // We don't need to do any wrapping for this property, + // so just forward all access to the underlying object. + Object.defineProperty(cache, prop, { + configurable: true, + enumerable: true, + get() { + return target[prop]; + }, + set(value) { + target[prop] = value; + } + }); + + return value; + } + + cache[prop] = value; + return value; + }, + + set(proxyTarget, prop, value, receiver) { + if (prop in cache) { + cache[prop] = value; + } else { + target[prop] = value; + } + return true; + }, + + defineProperty(proxyTarget, prop, desc) { + return Reflect.defineProperty(cache, prop, desc); + }, + + deleteProperty(proxyTarget, prop) { + return Reflect.deleteProperty(cache, prop); + } + }; + + // Per contract of the Proxy API, the "get" proxy handler must return the + // original value of the target if that value is declared read-only and + // non-configurable. For this reason, we create an object with the + // prototype set to `target` instead of using `target` directly. + // Otherwise we cannot return a custom object for APIs that + // are declared read-only and non-configurable, such as `chrome.devtools`. + // + // The proxy handlers themselves will still use the original `target` + // instead of the `proxyTarget`, so that the methods and properties are + // dereferenced via the original targets. + let proxyTarget = Object.create(target); + return new Proxy(proxyTarget, handlers); + }; + + /** + * Creates a set of wrapper functions for an event object, which handles + * wrapping of listener functions that those messages are passed. + * + * A single wrapper is created for each listener function, and stored in a + * map. Subsequent calls to `addListener`, `hasListener`, or `removeListener` + * retrieve the original wrapper, so that attempts to remove a + * previously-added listener work as expected. + * + * @param {DefaultWeakMap} wrapperMap + * A DefaultWeakMap object which will create the appropriate wrapper + * for a given listener function when one does not exist, and retrieve + * an existing one when it does. + * + * @returns {object} + */ + const wrapEvent = wrapperMap => ({ + addListener(target, listener, ...args) { + target.addListener(wrapperMap.get(listener), ...args); + }, + + hasListener(target, listener) { + return target.hasListener(wrapperMap.get(listener)); + }, + + removeListener(target, listener) { + target.removeListener(wrapperMap.get(listener)); + } + }); + + // Keep track if the deprecation warning has been logged at least once. + let loggedSendResponseDeprecationWarning = false; + + const onMessageWrappers = new DefaultWeakMap(listener => { + if (typeof listener !== "function") { + return listener; + } + + /** + * Wraps a message listener function so that it may send responses based on + * its return value, rather than by returning a sentinel value and calling a + * callback. If the listener function returns a Promise, the response is + * sent when the promise either resolves or rejects. + * + * @param {*} message + * The message sent by the other end of the channel. + * @param {object} sender + * Details about the sender of the message. + * @param {function(*)} sendResponse + * A callback which, when called with an arbitrary argument, sends + * that value as a response. + * @returns {boolean} + * True if the wrapped listener returned a Promise, which will later + * yield a response. False otherwise. + */ + return function onMessage(message, sender, sendResponse) { + let didCallSendResponse = false; + + let wrappedSendResponse; + let sendResponsePromise = new Promise(resolve => { + wrappedSendResponse = function (response) { + if (!loggedSendResponseDeprecationWarning) { + console.warn(SEND_RESPONSE_DEPRECATION_WARNING, new Error().stack); + loggedSendResponseDeprecationWarning = true; + } + didCallSendResponse = true; + resolve(response); + }; + }); + + let result; + try { + result = listener(message, sender, wrappedSendResponse); + } catch (err) { + result = Promise.reject(err); + } + + const isResultThenable = result !== true && isThenable(result); + + // If the listener didn't returned true or a Promise, or called + // wrappedSendResponse synchronously, we can exit earlier + // because there will be no response sent from this listener. + if (result !== true && !isResultThenable && !didCallSendResponse) { + return false; + } + + // A small helper to send the message if the promise resolves + // and an error if the promise rejects (a wrapped sendMessage has + // to translate the message into a resolved promise or a rejected + // promise). + const sendPromisedResult = promise => { + promise.then(msg => { + // send the message value. + sendResponse(msg); + }, error => { + // Send a JSON representation of the error if the rejected value + // is an instance of error, or the object itself otherwise. + let message; + if (error && (error instanceof Error || typeof error.message === "string")) { + message = error.message; + } else { + message = "An unexpected error occurred"; + } + + sendResponse({ + __mozWebExtensionPolyfillReject__: true, + message + }); + }).catch(err => { + // Print an error on the console if unable to send the response. + console.error("Failed to send onMessage rejected reply", err); + }); + }; + + // If the listener returned a Promise, send the resolved value as a + // result, otherwise wait the promise related to the wrappedSendResponse + // callback to resolve and send it as a response. + if (isResultThenable) { + sendPromisedResult(result); + } else { + sendPromisedResult(sendResponsePromise); + } + + // Let Chrome know that the listener is replying. + return true; + }; + }); + + const wrappedSendMessageCallback = ({ reject, resolve }, reply) => { + if (extensionAPIs.runtime.lastError) { + // Detect when none of the listeners replied to the sendMessage call and resolve + // the promise to undefined as in Firefox. + // See https://github.com/mozilla/webextension-polyfill/issues/130 + if (extensionAPIs.runtime.lastError.message === CHROME_SEND_MESSAGE_CALLBACK_NO_RESPONSE_MESSAGE) { + resolve(); + } else { + reject(extensionAPIs.runtime.lastError); + } + } else if (reply && reply.__mozWebExtensionPolyfillReject__) { + // Convert back the JSON representation of the error into + // an Error instance. + reject(new Error(reply.message)); + } else { + resolve(reply); + } + }; + + const wrappedSendMessage = (name, metadata, apiNamespaceObj, ...args) => { + if (args.length < metadata.minArgs) { + throw new Error(`Expected at least ${metadata.minArgs} ${pluralizeArguments(metadata.minArgs)} for ${name}(), got ${args.length}`); + } + + if (args.length > metadata.maxArgs) { + throw new Error(`Expected at most ${metadata.maxArgs} ${pluralizeArguments(metadata.maxArgs)} for ${name}(), got ${args.length}`); + } + + return new Promise((resolve, reject) => { + const wrappedCb = wrappedSendMessageCallback.bind(null, { resolve, reject }); + args.push(wrappedCb); + apiNamespaceObj.sendMessage(...args); + }); + }; + + const staticWrappers = { + runtime: { + onMessage: wrapEvent(onMessageWrappers), + onMessageExternal: wrapEvent(onMessageWrappers), + sendMessage: wrappedSendMessage.bind(null, "sendMessage", { minArgs: 1, maxArgs: 3 }) + }, + tabs: { + sendMessage: wrappedSendMessage.bind(null, "sendMessage", { minArgs: 2, maxArgs: 3 }) + } + }; + const settingMetadata = { + clear: { minArgs: 1, maxArgs: 1 }, + get: { minArgs: 1, maxArgs: 1 }, + set: { minArgs: 1, maxArgs: 1 } + }; + apiMetadata.privacy = { + network: { + networkPredictionEnabled: settingMetadata, + webRTCIPHandlingPolicy: settingMetadata + }, + services: { + passwordSavingEnabled: settingMetadata + }, + websites: { + hyperlinkAuditingEnabled: settingMetadata, + referrersEnabled: settingMetadata + } + }; + + return wrapObject(extensionAPIs, staticWrappers, apiMetadata); + }; + + // The build process adds a UMD wrapper around this file, which makes the + // `module` variable available. + module.exports = wrapAPIs(chrome); + } else { + module.exports = browser; + } +}); +//# sourceMappingURL=browser-polyfill.js.map \ No newline at end of file diff --git a/lib/cash.min.js b/lib/cash.min.js new file mode 100644 index 000000000..39e635b95 --- /dev/null +++ b/lib/cash.min.js @@ -0,0 +1,37 @@ +/* MIT https://github.com/kenwheeler/cash */ +(function(){ +'use strict';var e=document,g=window,k=e.createElement("div"),l=Array.prototype,m=l.filter,n=l.indexOf,aa=l.map,q=l.push,r=l.reverse,u=l.slice,v=l.some,ba=l.splice,ca=/^#[\w-]*$/,da=/^\.[\w-]*$/,ea=/<.+>/,fa=/^\w+$/;function x(a,b){void 0===b&&(b=e);return b&&9===b.nodeType||b&&1===b.nodeType?da.test(a)?b.getElementsByClassName(a.slice(1)):fa.test(a)?b.getElementsByTagName(a):b.querySelectorAll(a):[]} +var y=function(){function a(a,c){void 0===c&&(c=e);if(a){if(a instanceof y)return a;var b=a;if(z(a)){if(b=c instanceof y?c[0]:c,b=ca.test(a)?b.getElementById(a.slice(1)):ea.test(a)?A(a):x(a,b),!b)return}else if(B(a))return this.ready(a);if(b.nodeType||b===g)b=[b];this.length=b.length;a=0;for(c=this.length;aa?a+this.length:a]};y.prototype.eq=function(a){return C(this.get(a))};y.prototype.first=function(){return this.eq(0)};y.prototype.last=function(){return this.eq(-1)};y.prototype.map=function(a){return C(aa.call(this,function(b,c){return a.call(b,c,b)}))};y.prototype.slice=function(){return C(u.apply(this,arguments))};var ha=/-([a-z])/g; +function ia(a,b){return b.toUpperCase()}function D(a){return a.replace(ha,ia)}C.camelCase=D;function E(a,b){for(var c=0,d=a.length;cc?0:1;darguments.length?this[0]&&this[0][a]:this.each(function(c,f){f[a]=b});for(var c in a)this.prop(c,a[c]);return this}};function L(a){return z(a)?function(b,c){return G(c,a)}:B(a)?a:a instanceof y?function(b,c){return a.is(c)}:function(b,c){return c===a}}y.prototype.filter=function(a){if(!a)return C();var b=L(a);return C(m.call(this,function(a,d){return b.call(a,d,a)}))}; +function M(a,b){return b&&a.length?a.filter(b):a}var ja=/\S+/g;function N(a){return z(a)?a.match(ja)||[]:[]}y.prototype.hasClass=function(a){return a&&v.call(this,function(b){return b.classList.contains(a)})};y.prototype.removeAttr=function(a){var b=N(a);return b.length?this.each(function(a,d){E(b,function(a,b){d.removeAttribute(b)})}):this}; +y.prototype.attr=function(a,b){if(a){if(z(a)){if(2>arguments.length){if(!this[0])return;var c=this[0].getAttribute(a);return null===c?void 0:c}return void 0===b?this:null===b?this.removeAttr(a):this.each(function(c,f){f.setAttribute(a,b)})}for(c in a)this.attr(c,a[c]);return this}};y.prototype.toggleClass=function(a,b){var c=N(a),d=void 0!==b;return c.length?this.each(function(a,h){E(c,function(a,c){d?b?h.classList.add(c):h.classList.remove(c):h.classList.toggle(c)})}):this}; +y.prototype.addClass=function(a){return this.toggleClass(a,!0)};y.prototype.removeClass=function(a){return arguments.length?this.toggleClass(a,!1):this.attr("class","")};function O(a){return 1arguments.length)return this[0]&&P(this[0],a,c);if(!a)return this;b=oa(a,b,c);return this.each(function(d,h){h&&1===h.nodeType&&(c?h.style.setProperty(a,b):h.style[a]=b)})}for(var d in a)this.css(d,a[d]);return this};function pa(a,b){a=a.dataset?a.dataset[b]||a.dataset[D(b)]:a.getAttribute("data-"+b);try{return JSON.parse(a)}catch(c){}return a}var qa=/^data-(.+)/; +y.prototype.data=function(a,b){var c=this;if(!a){if(!this[0])return;var d={};E(this[0].attributes,function(a,b){(a=b.name.match(qa))&&(d[a[1]]=c.data(a[1]))});return d}if(z(a))return void 0===b?this[0]&&pa(this[0],a):this.each(function(c,d){c=b;try{c=JSON.stringify(c)}catch(w){}d.dataset?d.dataset[D(a)]=c:d.setAttribute("data-"+a,c)});for(var f in a)this.data(f,a[f]);return this}; +function ra(a,b){return Q(a,"border"+(b?"Left":"Top")+"Width")+Q(a,"padding"+(b?"Left":"Top"))+Q(a,"padding"+(b?"Right":"Bottom"))+Q(a,"border"+(b?"Right":"Bottom")+"Width")}E(["Width","Height"],function(a,b){y.prototype["inner"+b]=function(){if(this[0])return I(this[0])?g["inner"+b]:this[0]["client"+b]}}); +E(["width","height"],function(a,b){y.prototype[b]=function(c){if(!this[0])return void 0===c?void 0:this;if(!arguments.length)return I(this[0])?this[0][D("outer-"+b)]:this[0].getBoundingClientRect()[b]-ra(this[0],!a);var d=parseInt(c,10);return this.each(function(c,h){h&&1===h.nodeType&&(c=P(h,"boxSizing"),h.style[b]=oa(b,d+("border-box"===c?ra(h,!a):0)))})}}); +E(["Width","Height"],function(a,b){y.prototype["outer"+b]=function(c){if(this[0])return I(this[0])?g["outer"+b]:this[0]["offset"+b]+(c?Q(this[0],"margin"+(a?"Top":"Left"))+Q(this[0],"margin"+(a?"Bottom":"Right")):0)}});var T={}; +y.prototype.toggle=function(a){return this.each(function(b,c){if(a=void 0!==a?a:"none"===P(c,"display")){if(c.style.display="","none"===P(c,"display")){b=c.style;c=c.tagName;if(T[c])c=T[c];else{var d=e.createElement(c);e.body.appendChild(d);var f=P(d,"display");e.body.removeChild(d);c=T[c]="none"!==f?f:"block"}b.display=c}}else c.style.display="none"})};y.prototype.hide=function(){return this.toggle(!1)};y.prototype.show=function(){return this.toggle(!0)}; +function sa(a,b){return!b||!v.call(b,function(b){return 0>a.indexOf(b)})}var U={focus:"focusin",blur:"focusout"},ta={mouseenter:"mouseover",mouseleave:"mouseout"},ua=/^(?:mouse|pointer|contextmenu|drag|drop|click|dblclick)/i;function va(a,b,c,d,f){f.guid=f.guid||C.guid++;var h=a.__cashEvents=a.__cashEvents||{};h[b]=h[b]||[];h[b].push([c,d,f]);a.addEventListener(b,f)}function V(a){a=a.split(".");return[a[0],a.slice(1).sort()]} +function W(a,b,c,d,f){var h=a.__cashEvents=a.__cashEvents||{};if(b)h[b]&&(h[b]=h[b].filter(function(h){var p=h[0],ya=h[1];h=h[2];if(f&&h.guid!==f.guid||!sa(p,c)||d&&d!==ya)return!0;a.removeEventListener(b,h)}));else{for(b in h)W(a,b,c,d,f);delete a.__cashEvents}}y.prototype.off=function(a,b,c){var d=this;void 0===a?this.each(function(a,b){return W(b)}):(B(b)&&(c=b,b=""),E(N(a),function(a,h){a=V(ta[h]||U[h]||h);var f=a[0],w=a[1];d.each(function(a,d){return W(d,f,w,b,c)})}));return this}; +y.prototype.on=function(a,b,c,d){var f=this;if(!z(a)){for(var h in a)this.on(h,b,a[h]);return this}B(b)&&(c=b,b="");E(N(a),function(a,h){a=V(ta[h]||U[h]||h);var p=a[0],w=a[1];f.each(function(a,h){a=function za(a){if(!a.namespace||sa(w,a.namespace.split("."))){var f=h;if(b){for(var t=a.target;!G(t,b);){if(t===h)return;t=t.parentNode;if(!t)return}f=t;a.__delegate=!0}a.__delegate&&Object.defineProperty(a,"currentTarget",{configurable:!0,get:function(){return f}});t=c.call(f,a,a.data);d&&W(h,p,w,b,za); +!1===t&&(a.preventDefault(),a.stopPropagation())}};a.guid=c.guid=c.guid||C.guid++;va(h,p,w,b,a)})});return this};y.prototype.one=function(a,b,c){return this.on(a,b,c,!0)};y.prototype.ready=function(a){function b(){return a(C)}"loading"!==e.readyState?setTimeout(b):e.addEventListener("DOMContentLoaded",b);return this}; +y.prototype.trigger=function(a,b){if(z(a)){var c=V(a);a=c[0];c=c[1];var d=ua.test(a)?"MouseEvents":"HTMLEvents";var f=e.createEvent(d);f.initEvent(a,!0,!0);f.namespace=c.join(".")}else f=a;f.data=b;var h=f.type in U;return this.each(function(a,b){if(h&&B(b[f.type]))b[f.type]();else b.dispatchEvent(f)})};function wa(a){return a.multiple?H(m.call(a.options,function(a){return a.selected&&!a.disabled&&!a.parentNode.disabled}),"value"):a.value||""} +var xa=/%20/g,Aa=/file|reset|submit|button|image/i,Ba=/radio|checkbox/i;y.prototype.serialize=function(){var a="";this.each(function(b,c){E(c.elements||[c],function(b,c){c.disabled||!c.name||"FIELDSET"===c.tagName||Aa.test(c.type)||Ba.test(c.type)&&!c.checked||(b=wa(c),void 0!==b&&(b=K(b)?b:[b],E(b,function(b,d){b=a;d="&"+encodeURIComponent(c.name)+"="+encodeURIComponent(d).replace(xa,"+");a=b+d})))})});return a.substr(1)}; +y.prototype.val=function(a){return void 0===a?this[0]&&wa(this[0]):this.each(function(b,c){if("SELECT"===c.tagName){var d=K(a)?a:null===a?[]:[a];E(c.options,function(a,b){b.selected=0<=d.indexOf(b.value)})}else c.value=null===a?"":a})};y.prototype.clone=function(){return this.map(function(a,b){return b.cloneNode(!0)})};y.prototype.detach=function(){return this.each(function(a,b){b.parentNode&&b.parentNode.removeChild(b)})};var Ca=/^\s*<(\w+)[^>]*>/,Da=/^\s*<(\w+)\s*\/?>(?:<\/\1>)?\s*$/,X; +function A(a){if(!X){var b=e.createElement("table"),c=e.createElement("tr");X={"*":k,tr:e.createElement("tbody"),td:c,th:c,thead:b,tbody:b,tfoot:b}}if(!z(a))return[];if(Da.test(a))return[e.createElement(RegExp.$1)];b=Ca.test(a)&&RegExp.$1;b=X[b]||X["*"];b.innerHTML=a;return C(b.childNodes).detach().get()}C.parseHTML=A;y.prototype.empty=function(){return this.each(function(a,b){for(;b.firstChild;)b.removeChild(b.firstChild)})}; +y.prototype.html=function(a){return void 0===a?this[0]&&this[0].innerHTML:this.each(function(b,c){c.innerHTML=a})};y.prototype.remove=function(){return this.detach().off()};y.prototype.text=function(a){return void 0===a?this[0]?this[0].textContent:"":this.each(function(b,c){c.textContent=a})};y.prototype.unwrap=function(){this.parent().each(function(a,b){a=C(b);a.replaceWith(a.children())});return this};var Ea=e.documentElement; +y.prototype.offset=function(){var a=this[0];if(a)return a=a.getBoundingClientRect(),{top:a.top+g.pageYOffset-Ea.clientTop,left:a.left+g.pageXOffset-Ea.clientLeft}};y.prototype.offsetParent=function(){return C(this[0]&&this[0].offsetParent)};y.prototype.position=function(){var a=this[0];if(a)return{left:a.offsetLeft,top:a.offsetTop}};y.prototype.children=function(a){var b=[];this.each(function(a,d){q.apply(b,d.children)});return M(C(O(b)),a)}; +y.prototype.contents=function(){var a=[];this.each(function(b,c){q.apply(a,"IFRAME"===c.tagName?[c.contentDocument]:c.childNodes)});return C(O(a))};y.prototype.find=function(a){for(var b=[],c=0,d=this.length;c\s*$/g; +function Y(a){a=C(a);a.filter("script").add(a.find("script")).each(function(a,c){!c.src&&Fa.test(c.type)&&c.ownerDocument.documentElement.contains(c)&&eval(c.textContent.replace(Ga,""))})}function Z(a,b,c){E(a,function(a,f){E(b,function(b,d){b=a?d.cloneNode(!0):d;c?f.insertBefore(b,c&&f.firstChild):f.appendChild(b);Y(b)})})}y.prototype.append=function(){var a=this;E(arguments,function(b,c){Z(a,C(c))});return this};y.prototype.appendTo=function(a){Z(C(a),this);return this}; +y.prototype.insertAfter=function(a){var b=this;C(a).each(function(a,d){var c=d.parentNode;c&&b.each(function(b,f){b=a?f.cloneNode(!0):f;c.insertBefore(b,d.nextSibling);Y(b)})});return this};y.prototype.after=function(){var a=this;E(r.apply(arguments),function(b,c){r.apply(C(c).slice()).insertAfter(a)});return this};y.prototype.insertBefore=function(a){var b=this;C(a).each(function(a,d){var c=d.parentNode;c&&b.each(function(b,f){b=a?f.cloneNode(!0):f;c.insertBefore(b,d);Y(b)})});return this}; +y.prototype.before=function(){var a=this;E(arguments,function(b,c){C(c).insertBefore(a)});return this};y.prototype.prepend=function(){var a=this;E(arguments,function(b,c){Z(a,C(c),!0)});return this};y.prototype.prependTo=function(a){Z(C(a),r.apply(this.slice()),!0);return this};y.prototype.replaceWith=function(a){return this.before(a).remove()};y.prototype.replaceAll=function(a){C(a).replaceWith(this);return this}; +y.prototype.wrapAll=function(a){if(this[0]){a=C(a);this.first().before(a);for(a=a[0];a.children.length;)a=a.firstElementChild;this.appendTo(a)}return this};y.prototype.wrap=function(a){return this.each(function(b,c){var d=C(a)[0];C(c).wrapAll(b?d.cloneNode(!0):d)})};y.prototype.wrapInner=function(a){return this.each(function(b,c){b=C(c);c=b.contents();c.length?c.wrapAll(a):b.append(a)})}; +y.prototype.has=function(a){var b=z(a)?function(b,d){return!!x(a,d).length}:function(b,d){return d.contains(a)};return this.filter(b)};y.prototype.is=function(a){if(!a||!this[0])return!1;var b=L(a),c=!1;this.each(function(a,f){c=b.call(f,a,f);return!c});return c};y.prototype.next=function(a,b){return M(C(O(H(this,"nextElementSibling",b))),a)};y.prototype.nextAll=function(a){return this.next(a,!0)}; +y.prototype.not=function(a){if(!a||!this[0])return this;var b=L(a);return this.filter(function(a,d){return!b.call(d,a,d)})};y.prototype.parent=function(a){return M(C(O(H(this,"parentNode"))),a)};y.prototype.index=function(a){var b=a?C(a)[0]:this[0];a=a?this:C(b).parent().children();return n.call(a,b)};y.prototype.closest=function(a){if(!a||!this[0])return C();var b=this.filter(a);return b.length?b:this.parent().closest(a)}; +y.prototype.parents=function(a){return M(C(O(H(this,"parentElement",!0))),a)};y.prototype.prev=function(a,b){return M(C(O(H(this,"previousElementSibling",b))),a)};y.prototype.prevAll=function(a){return this.prev(a,!0)};y.prototype.siblings=function(a){var b=[];this.each(function(a,d){q.apply(b,C(d).parent().children(function(a,b){return b!==d}))});return M(C(O(b)),a)};"undefined"!==typeof exports?module.exports=C:g.cash=g.$=C; +})(); \ No newline at end of file diff --git a/lib/toast.js b/lib/toast.js new file mode 100644 index 000000000..c7bb438b6 --- /dev/null +++ b/lib/toast.js @@ -0,0 +1,266 @@ +/*********************************************** + + "toast.js" + + Created by Michael Cheng on 05/31/2015 22:34 + http://michaelcheng.us/ + michael@michaelcheng.us + --All Rights Reserved-- + + ***********************************************/ + +'use strict'; + +/** + * The Toast animation speed; how long the Toast takes to move to and from the screen + * @type {Number} + */ +const TOAST_ANIMATION_SPEED = 400; + +const Transitions = { + SHOW: { + '-webkit-transition': 'opacity ' + TOAST_ANIMATION_SPEED + 'ms, -webkit-transform ' + TOAST_ANIMATION_SPEED + 'ms', + 'transition': 'opacity ' + TOAST_ANIMATION_SPEED + 'ms, transform ' + TOAST_ANIMATION_SPEED + 'ms', + 'opacity': '1', + '-webkit-transform': 'translateY(-100%) translateZ(0)', + 'transform': 'translateY(-100%) translateZ(0)' + }, + + HIDE: { + 'opacity': '0', + '-webkit-transform': 'translateY(150%) translateZ(0)', + 'transform': 'translateY(150%) translateZ(0)' + } +}; + +/** + * The main Toast object + * @param {String} text The text to put inside the Toast + * @param {Object} options Optional; the Toast options. See Toast.prototype.DEFAULT_SETTINGS for more information + * @param {Object} transitions Optional; the Transitions object. This should not be used unless you know what you're doing + */ +function Toast(text, options, transitions) { + if(getToastStage() !== null) { + // If there is already a Toast being shown, put this Toast in the queue to show later + Toast.prototype.toastQueue.push({ + text: text, + options: options, + transitions: transitions + }); + } else { + Toast.prototype.Transitions = transitions || Transitions; + var _options = options || {}; + _options = Toast.prototype.mergeOptions(Toast.prototype.DEFAULT_SETTINGS, _options); + + Toast.prototype.show(text, _options); + + _options = null; + } +} + + +/** + * The toastStage. This is the HTML element in which the toast resides + * Getter and setter methods are available privately + * @type {Element} + */ +var _toastStage = null; +function getToastStage() { + return _toastStage; +} +function setToastStage(toastStage) { + _toastStage = toastStage; +} + + + + +// define some Toast constants + +/** + * The default Toast settings + * @type {Object} + */ +Toast.prototype.DEFAULT_SETTINGS = { + style: { + main: { + 'background': 'rgba(0, 0, 0, .8)', + 'box-shadow': '0 0 10px rgba(0, 0, 0, .8)', + + 'border-radius': '3px', + + 'z-index': '99999', + + 'color': 'rgba(255, 255, 255, .9)', + + 'padding': '10px 15px', + 'max-width': '60%', + 'width': '100%', + 'word-break': 'keep-all', + 'margin': '0 auto', + 'text-align': 'center', + + 'position': 'fixed', + 'left': '0', + 'right': '0', + 'bottom': '0', + + '-webkit-transform': 'translateY(150%) translateZ(0)', + 'transform': 'translateY(150%) translateZ(0)', + '-webkit-filter': 'blur(0)', + 'opacity': '0' + } + }, + + settings: { + duration: 4000 + } +}; + +Toast.prototype.Transitions = {}; + + +/** + * The queue of Toasts waiting to be shown + * @type {Array} + */ +Toast.prototype.toastQueue = []; + + +/** + * The Timeout object for animations. + * This should be shared among the Toasts, because timeouts may be cancelled e.g. on explicit call of hide() + * @type {Object} + */ +Toast.prototype.timeout = null; + + +/** + * Merge the DEFAULT_SETTINGS with the user defined options if specified + * @param {Object} options The user defined options + */ +Toast.prototype.mergeOptions = function(initialOptions, customOptions) { + var merged = customOptions; + for(var prop in initialOptions) { + if(merged.hasOwnProperty(prop)) { + if(initialOptions[prop] !== null && initialOptions[prop].constructor === Object) { + merged[prop] = Toast.prototype.mergeOptions(initialOptions[prop], merged[prop]); + } + } else { + merged[prop] = initialOptions[prop]; + } + } + return merged; +}; + + +/** + * Generate the Toast with the specified text. + * @param {String|Object} text The text to show inside the Toast, can be an HTML element or plain text + * @param {Object} style The style to set for the Toast + */ +Toast.prototype.generate = function(text, style) { + var toastStage = document.createElement('div'); + + + /** + * If the text is a String, create a textNode for appending + */ + if(typeof text === 'string') { + text = document.createTextNode(text); + } + toastStage.appendChild(text); + + + setToastStage(toastStage); + toastStage = null; + + Toast.prototype.stylize(getToastStage(), style); +}; + +/** + * Stylize the Toast. + * @param {Element} element The HTML element to stylize + * @param {Object} styles An object containing the style to apply + * @return Returns nothing + */ +Toast.prototype.stylize = function(element, styles) { + Object.keys(styles).forEach(function(style) { + element.style[style] = styles[style]; + }); +}; + + +/** + * Show the Toast + * @param {String} text The text to show inside the Toast + * @param {Object} options The object containing the options for the Toast + */ +Toast.prototype.show = function(text, options) { + this.generate(text, options.style.main); + + var toastStage = getToastStage(); + document.body.insertBefore(toastStage, document.body.firstChild); + + + + // This is a hack to get animations started. Apparently without explicitly redrawing, it'll just attach the class and no animations would be done + toastStage.offsetHeight; + + + Toast.prototype.stylize(toastStage, Toast.prototype.Transitions.SHOW); + + + toastStage = null; + + + // Hide the Toast after the specified time + clearTimeout(Toast.prototype.timeout); + Toast.prototype.timeout = setTimeout(Toast.prototype.hide, options.settings.duration); +}; + + +/** + * Hide the Toast that's currently shown + */ +Toast.prototype.hide = function() { + var toastStage = getToastStage(); + Toast.prototype.stylize(toastStage, Toast.prototype.Transitions.HIDE); + + // Destroy the Toast element after animations end + clearTimeout(Toast.prototype.timeout); + toastStage.addEventListener('transitionend', Toast.prototype.animationListener); + toastStage = null; +}; + +Toast.prototype.animationListener = function() { + getToastStage().removeEventListener('transitionend', Toast.prototype.animationListener); + Toast.prototype.destroy.call(this); +}; + + +/** + * Clean up after the Toast slides away. Namely, removing the Toast from the DOM. After the Toast is cleaned up, display the next Toast in the queue if any exists + */ +Toast.prototype.destroy = function() { + var toastStage = getToastStage(); + document.body.removeChild(toastStage); + + toastStage = null; + setToastStage(null); + + + if(Toast.prototype.toastQueue.length > 0) { + // Show the rest of the Toasts in the queue if they exist + + var toast = Toast.prototype.toastQueue.shift(); + Toast(toast.text, toast.options, toast.transitions); + + // clean up + toast = null; + } +}; + +window.showToast = Toast; + +"END OF FILE"; // to avoid "result is non-structured-clonable data" \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 000000000..ec320b656 --- /dev/null +++ b/manifest.json @@ -0,0 +1,55 @@ +{ + "manifest_version": 2, + "name": "Trilium Web Clipper (dev)", + "version": "0.1.5", + "description": "Save web clippings to Trilium Notes.", + "homepage_url": "https://github.com/zadam/trilium-web-clipper", + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", + "icons": { + "32": "icons/32.png", + "48": "icons/48.png", + "96": "icons/96.png" + }, + "permissions": [ + "activeTab", + "tabs", + "http://*/", + "https://*/", + "", + "storage", + "contextMenus" + ], + "browser_action": { + "default_icon": "icons/32.png", + "default_title": "Trilium Web Clipper", + "default_popup": "popup/popup.html" + }, + "content_scripts": [ + { + "matches": [ + "" + ], + "js": [ + "lib/browser-polyfill.js", + "utils.js", + "content.js" + ] + } + ], + "background": { + "scripts": [ + "lib/browser-polyfill.js", + "utils.js", + "trilium_server_facade.js", + "background.js" + ] + }, + "options_ui": { + "page": "options/options.html" + }, + "browser_specific_settings": { + "gecko": { + "id": "{1410742d-b377-40e7-a9db-63dc9c6ec99c}" + } + } +} diff --git a/options/options.html b/options/options.html new file mode 100644 index 000000000..4af29c825 --- /dev/null +++ b/options/options.html @@ -0,0 +1,67 @@ + + + + + + + + + + + + + +

Trilium desktop instance

+ +

Web clipper by default tries to find a running desktop instance on ports 37740 - 37749. If you configured your Trilium desktop app to run on a different port, you can specify it here (otherwise keep it empty).

+ +
+

+ + + (normally keep this empty) +

+ + +
+ +

Trilium server instance

+ +

If you have a server instance set up, you can optionally configure it as a fail over target for the clipped notes. Desktop instance will still be given priority, but in cases that the desktop instance is not available (e.g. it's not running), web clipper will send the notes to the server instance instead.

+ + + + + + + + + + + + \ No newline at end of file diff --git a/options/options.js b/options/options.js new file mode 100644 index 000000000..1462d858d --- /dev/null +++ b/options/options.js @@ -0,0 +1,140 @@ +const $triliumServerUrl = $("#trilium-server-url"); +const $triliumServerUsername = $("#trilium-server-username"); +const $triliumServerPassword = $("#trilium-server-password"); + +const $errorMessage = $("#error-message"); +const $successMessage = $("#success-message"); + +function showError(message) { + $errorMessage.html(message).show(); + $successMessage.hide(); +} + +function showSuccess(message) { + $successMessage.html(message).show(); + $errorMessage.hide(); +} + +async function saveTriliumServerSetup(e) { + e.preventDefault(); + + if ($triliumServerUrl.val().trim().length === 0 + || $triliumServerUsername.val().trim().length === 0 + || $triliumServerPassword.val().trim().length === 0) { + showError("One or more mandatory inputs are missing. Please fill in server URL, username and password."); + + return; + } + + let resp; + + try { + resp = await fetch($triliumServerUrl.val() + '/api/login/token', { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: $triliumServerUsername.val(), + password: $triliumServerPassword.val() + }) + }); + } + catch (e) { + showError("Unknown error: " + e.message); + return; + } + + if (resp.status === 401) { + showError("Incorrect credentials."); + } + else if (resp.status !== 200) { + showError("Unrecognised response with status code " + resp.status); + } + else { + const json = await resp.json(); + + showSuccess("Authentication against Trilium server has been successful."); + + $triliumServerUsername.val(''); + $triliumServerPassword.val(''); + + browser.storage.sync.set({ + triliumServerUrl: $triliumServerUrl.val(), + authToken: json.token + }); + + await restoreOptions(); + } +} + +const $triliumServerSetupForm = $("#trilium-server-setup-form"); +const $triliumServerConfiguredDiv = $("#trilium-server-configured"); +const $triliumServerLink = $("#trilium-server-link"); +const $resetTriliumServerSetupLink = $("#reset-trilium-server-setup"); + +$resetTriliumServerSetupLink.on("click", e => { + e.preventDefault(); + + browser.storage.sync.set({ + triliumServerUrl: '', + authToken: '' + }); + + restoreOptions(); +}); + +$triliumServerSetupForm.on("submit", saveTriliumServerSetup); + +const $triliumDesktopPort = $("#trilium-desktop-port"); +const $triilumDesktopSetupForm = $("#trilium-desktop-setup-form"); + +$triilumDesktopSetupForm.on("submit", e => { + e.preventDefault(); + + const port = $triliumDesktopPort.val().trim(); + const portNum = parseInt(port); + + if (port && (isNaN(portNum) || portNum <= 0 || portNum >= 65536)) { + showError(`Please enter valid port number.`); + return; + } + + browser.storage.sync.set({ + triliumDesktopPort: port + }); + + showSuccess(`Port number has been saved.`); +}); + +async function restoreOptions() { + const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl"); + const {authToken} = await browser.storage.sync.get("authToken"); + + $errorMessage.hide(); + $successMessage.hide(); + + $triliumServerUrl.val(''); + $triliumServerUsername.val(''); + $triliumServerPassword.val(''); + + if (triliumServerUrl && authToken) { + $triliumServerSetupForm.hide(); + $triliumServerConfiguredDiv.show(); + + $triliumServerLink + .attr("href", triliumServerUrl) + .text(triliumServerUrl); + } + else { + $triliumServerSetupForm.show(); + $triliumServerConfiguredDiv.hide(); + } + + const {triliumDesktopPort} = await browser.storage.sync.get("triliumDesktopPort"); + + $triliumDesktopPort.val(triliumDesktopPort); +} + +$(restoreOptions); \ No newline at end of file diff --git a/popup/popup.css b/popup/popup.css new file mode 100644 index 000000000..89d7bf23c --- /dev/null +++ b/popup/popup.css @@ -0,0 +1,47 @@ +html, body { + width: 300px; + font-size: 12px; +} + +.button { + margin: 3% auto; + padding: 4px; + text-align: center; + border: 1px solid #ccc; + border-radius: 3px; + background-color: #eee; + cursor: pointer; + color: black; +} + +.wide { + min-width: 8em; +} + +.full { + display: block; + width: 100%; +} + +#create-text-note-wrapper { + display: none; +} + +#create-text-note-textarea { + width: 100%; +} + +#save-button { + border-color: #0062cc; + background-color: #0069d9; + color: white; +} + +#check-connection-button { + float: right; + margin-top: -6px; +} + +button[disabled] { + color: #aaa; +} \ No newline at end of file diff --git a/popup/popup.html b/popup/popup.html new file mode 100644 index 000000000..fe94eed29 --- /dev/null +++ b/popup/popup.html @@ -0,0 +1,46 @@ + + + + + + + + + + +
+

Trilium Web Clipper

+ +
+ + + +
+
+ + + + + +
+ + +
+ + + +
+
+ +
+ + +
Status: unknown
+
+ + + + + + + \ No newline at end of file diff --git a/popup/popup.js b/popup/popup.js new file mode 100644 index 000000000..bb0cdd5d6 --- /dev/null +++ b/popup/popup.js @@ -0,0 +1,138 @@ +async function sendMessage(message) { + try { + return await browser.runtime.sendMessage(message); + } + catch (e) { + console.log("Calling browser runtime failed:", e); + + alert("Calling browser runtime failed. Refreshing page might help."); + } +} + +const $showOptionsButton = $("#show-options-button"); +const $clipScreenShotButton = $("#clip-screenshot-button"); +const $saveWholePageButton = $("#save-whole-page-button"); + +$showOptionsButton.on("click", () => browser.runtime.openOptionsPage()); + +$clipScreenShotButton.on("click", () => sendMessage({name: 'save-screenshot'})); + +$saveWholePageButton.on("click", () => sendMessage({name: 'save-whole-page'})); + +const $createTextNoteWrapper = $("#create-text-note-wrapper"); +const $textNote = $("#create-text-note-textarea"); + +$textNote.on('keypress', function (event) { + if (event.which === 10 || event.which === 13 && event.ctrlKey) { + saveNote(); + return false; + } + + return true; +}); + +$("#create-text-note-button").on("click", () => { + $createTextNoteWrapper.show(); + + $textNote[0].focus(); +}); + +$("#cancel-button").on("click", () => { + $createTextNoteWrapper.hide(); + $textNote.val(""); + + window.close(); +}); + +async function saveNote() { + const textNoteVal = $textNote.val().trim(); + + if (textNoteVal.length === 0) { + alert("Note is empty. Please enter some text"); + return; + } + + const match = /^(.*?)([.?!]\s|\n)/.exec(textNoteVal); + let title, content; + + if (match) { + title = match[0].trim(); + content = textNoteVal.substr(title.length).trim(); + } + else { + title = textNoteVal; + content = ''; + } + + content = escapeHtml(content); + + const result = await sendMessage({name: 'save-note', title, content}); + + if (result) { + $textNote.val(''); + + window.close(); + } +} + +$("#save-button").on("click", saveNote); + +$("#show-help-button").on("click", () => { + window.open("https://github.com/zadam/trilium/wiki/Web-clipper", '_blank'); +}); + +function escapeHtml(string) { + const pre = document.createElement('pre'); + const text = document.createTextNode(string); + pre.appendChild(text); + + const htmlWithPars = pre.innerHTML.replace(/\n/g, "

"); + + return '

' + htmlWithPars + '

'; +} + +const $connectionStatus = $("#connection-status"); +const $needsConnection = $(".needs-connection"); + +browser.runtime.onMessage.addListener(request => { + if (request.name === 'trilium-search-status') { + const {triliumSearch} = request; + + let statusText = triliumSearch.status; + let isConnected; + + if (triliumSearch.status === 'not-found') { + statusText = `Not found`; + isConnected = false; + } + else if (triliumSearch.status === 'found-desktop') { + statusText = `Connected on port ${triliumSearch.port}`; + isConnected = true; + } + else if (triliumSearch.status === 'found-server') { + statusText = `Connected to the server`; + isConnected = true; + } + + $connectionStatus.html(statusText); + + if (isConnected) { + $needsConnection.removeAttr("disabled"); + $needsConnection.removeAttr("title"); + } + else { + $needsConnection.attr("disabled", "disabled"); + $needsConnection.attr("title", "This action can't be performed without active connection to Trilium."); + } + } +}); + +const $checkConnectionButton = $("#check-connection-button"); + +$checkConnectionButton.on("click", () => { + browser.runtime.sendMessage({ + name: "trigger-trilium-search" + }) +}); + +$(() => browser.runtime.sendMessage({name: "send-trilium-search-status"})); \ No newline at end of file diff --git a/trilium-web-clipper.iml b/trilium-web-clipper.iml new file mode 100644 index 000000000..8021953ed --- /dev/null +++ b/trilium-web-clipper.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/trilium_server_facade.js b/trilium_server_facade.js new file mode 100644 index 000000000..fe00e533d --- /dev/null +++ b/trilium_server_facade.js @@ -0,0 +1,166 @@ +function isDevEnv() { + const manifest = browser.runtime.getManifest(); + + return manifest.name.endsWith('(dev)') >= 0; +} + +class TriliumServerFacade { + constructor() { + this.triggerSearchForTrilium(); + + // continually scan for changes (if e.g. desktop app is started after browser) + setInterval(() => this.triggerSearchForTrilium(), 60 * 1000); + } + + async sendTriliumSearchStatusToPopup() { + try { + await browser.runtime.sendMessage({ + name: "trilium-search-status", + triliumSearch: this.triliumSearch + }); + } + catch (e) {} // nothing might be listening + } + + setTriliumSearch(ts) { + this.triliumSearch = ts; + + this.sendTriliumSearchStatusToPopup(); + } + + async triggerSearchForTrilium() { + this.setTriliumSearch({ status: 'searching' }); + + const startingPort = await this.getStartingPort(); + + for (let testedPort = startingPort; testedPort < startingPort + 10; testedPort++) { + try { + console.debug('Trying port ' + testedPort); + + const resp = await fetch(`http://127.0.0.1:${testedPort}/api/clipper/handshake`); + + const text = await resp.text(); + + console.log("Received response:", text); + + const json = JSON.parse(text); + + if (json.appName === 'trilium') { + this.setTriliumSearch({ + status: 'found-desktop', + port: testedPort, + url: 'http://127.0.0.1:' + testedPort + }); + + return; + } + } + catch (error) { + // continue + } + } + + const {triliumServerUrl} = await browser.storage.sync.get("triliumServerUrl"); + const {authToken} = await browser.storage.sync.get("authToken"); + + if (triliumServerUrl && authToken) { + try { + const resp = await fetch(triliumServerUrl + '/api/clipper/handshake', { + headers: { + Authorization: authToken + } + }); + + const text = await resp.text(); + + console.log("Received response:", text); + + const json = JSON.parse(text); + + if (json.appName === 'trilium') { + this.setTriliumSearch({ + status: 'found-server', + url: triliumServerUrl, + token: authToken + }); + return; + } + } + catch (e) { + console.log("Request to the configured server instance failed with:", e); + } + } + + // if all above fails it's not found + this.setTriliumSearch({ status: 'not-found' }); + } + + async waitForTriliumSearch() { + return new Promise((res, rej) => { + const checkStatus = () => { + if (this.triliumSearch.status === "searching") { + setTimeout(checkStatus, 500); + } + else if (this.triliumSearch.status === 'not-found') { + rej(new Error("Trilium instance has not been found.")); + } + else { + res(); + } + }; + + checkStatus(); + }); + } + + async getStartingPort() { + const {triliumDesktopPort} = await browser.storage.sync.get("triliumDesktopPort"); + + if (triliumDesktopPort) { + return parseInt(triliumDesktopPort); + } + else { + return isDevEnv() ? 37740 : 37840; + } + } + + async callService(method, path, body) { + const fetchOptions = { + method: method, + headers: { + 'Content-Type': 'application/json' + }, + }; + + if (body) { + fetchOptions.body = typeof body === 'string' ? body : JSON.stringify(body); + } + + try { + await this.waitForTriliumSearch(); + + fetchOptions.headers.Authorization = this.triliumSearch.token || ""; + + const url = this.triliumSearch.url + "/api/clipper/" + path; + + console.log(`Sending ${method} request to ${url}`); + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + throw new Error(await response.text()); + } + + return await response.json(); + } + catch (e) { + console.log("Sending request to trilium failed", e); + + toast('Your request failed because we could not contact Trilium instance. Please make sure Trilium is running and is accessible.'); + + return null; + } + } +} + +window.triliumServerFacade = new TriliumServerFacade(); \ No newline at end of file diff --git a/utils.js b/utils.js new file mode 100644 index 000000000..40b0ca809 --- /dev/null +++ b/utils.js @@ -0,0 +1,10 @@ +function randomString(len) { + let text = ""; + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (let i = 0; i < len; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + + return text; +} \ No newline at end of file From bc1dae516b36c2aa03f6fe17d9e423ffcbe75cb7 Mon Sep 17 00:00:00 2001 From: zadam Date: Fri, 19 Jul 2019 20:39:25 +0200 Subject: [PATCH 02/80] gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..a6de95822 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/** +.idea/** +*.iml \ No newline at end of file From d18045f905d881abcfef34cdbbab9f3740b66026 Mon Sep 17 00:00:00 2001 From: zadam Date: Fri, 19 Jul 2019 20:40:31 +0200 Subject: [PATCH 03/80] gitignore --- .gitignore | 4 +-- .idea/vcs.xml | 6 ++++ .idea/workspace.xml | 69 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index a6de95822..77738287f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1 @@ -dist/** -.idea/** -*.iml \ No newline at end of file +dist/ \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..94a25f7f4 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml index cf6a334d8..cc98bef0e 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -1,10 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + 1563561154310 + + + 1563561565148 + + + + + + + + + + \ No newline at end of file From 4618520bf957ff22624e995cdc6ce9c014e33744 Mon Sep 17 00:00:00 2001 From: zadam Date: Fri, 19 Jul 2019 21:05:22 +0200 Subject: [PATCH 04/80] font family for popup, different clip types for notes and whole pages --- .idea/inspectionProfiles/Project_Default.xml | 10 ++++++++ .idea/misc.xml | 3 +++ .idea/workspace.xml | 24 ++++++++++++++++---- background.js | 3 ++- content.js | 3 ++- manifest.json | 2 +- popup/popup.css | 1 + 7 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 .idea/inspectionProfiles/Project_Default.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 000000000..146ab09b7 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 639900d13..7e5bdf89f 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,5 +1,8 @@ + + diff --git a/.idea/workspace.xml b/.idea/workspace.xml index cc98bef0e..79fcb737a 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -2,9 +2,11 @@ - - + + + +