diff --git a/src/background.ts b/src/background.ts index 446c4cc6..e55d6ef8 100644 --- a/src/background.ts +++ b/src/background.ts @@ -22,6 +22,7 @@ import * as convert from './convert' import * as config from './config' import * as dom from './dom' import * as hinting_background from './hinting_background' +import * as download_background from './download_background' import * as gobble_mode from './parsers/gobblemode' import * as input_mode from './parsers/inputmode' import * as itertools from './itertools' @@ -39,6 +40,7 @@ import * as webext from './lib/webext' config, dom, hinting_background, + download_background, gobble_mode, input_mode, itertools, diff --git a/src/config.ts b/src/config.ts index ea62f787..3c62912c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -77,6 +77,10 @@ const DEFAULTS = o({ ";y": "hint -y", ";p": "hint -p", ";r": "hint -r", + ";s": "hint -s", + ";S": "hint -S", + ";a": "hint -a", + ";A": "hint -A", ";;": "hint -;", ";#": "hint -#", "I": "mode ignore", diff --git a/src/download_background.ts b/src/download_background.ts new file mode 100644 index 00000000..2ae1a6bd --- /dev/null +++ b/src/download_background.ts @@ -0,0 +1,83 @@ +/** + * Background download-related functions + */ + +import {getDownloadFilenameForUrl} from "./url_util" + +/** Construct an object URL string from a given data URL + * + * This is needed because feeding a data URL directly to downloads.download() + * causes "Error: Access denied for URL" + * + * @param dataUrl the URL to make an object URL from + * @return object URL that can be fed to the downloads API + * + */ +function objectUrlFromDataUrl(dataUrl: URL): string { + + const b64 = dataUrl.pathname.split(",", 2)[1] + + const binaryF = atob(b64) + + const dataArray = new Uint8Array(binaryF.length); + + for(let i = 0, len = binaryF.length; i < len; ++i ) { + dataArray[i] = binaryF.charCodeAt(i); + } + + return URL.createObjectURL(new Blob([dataArray])) +} + +/** Download a given URL to disk + * + * Normal URLs are downloaded normally. Data URLs are handled more carefully + * as it's not allowed in WebExt land to just call downloads.download() on + * them + * + * @param url the URL to download + * @param saveAs prompt user for a filename + */ +export async function downloadUrl(url: string, saveAs: boolean) { + + const urlToSave = new URL(url) + + let urlToDownload + + if (urlToSave.protocol === "data:") { + urlToDownload = objectUrlFromDataUrl(urlToSave) + } else { + urlToDownload = urlToSave.href + } + + let fileName = getDownloadFilenameForUrl(urlToSave) + + // Save location limitations: + // - download() can't save outside the downloads dir without popping + // the Save As dialog + // - Even if the dialog is popped, it doesn't seem to be possible to + // feed in the dirctory for next time, and FF doesn't remember it + // itself (like it does if you right-click-save something) + + let downloadPromise = browser.downloads.download({ + url: urlToDownload, + filename: fileName, + saveAs: saveAs + }) + + // TODO: at this point, could give feeback using the promise returned + // by downloads.download(), needs status bar to show it (#90) + downloadPromise.then(id => { + //console.log("Downloaded OK: ", urlToSave) + }, + error => { + console.log("Failed to download: ", urlToDownload, error) + } + ) +} + +import * as Messaging from "./messaging" + +// Get messages from content +Messaging.addListener('download_background', Messaging.attributeCaller({ + downloadUrl, +})) diff --git a/src/excmds.ts b/src/excmds.ts index 180d5dd6..dd891fd2 100644 --- a/src/excmds.ts +++ b/src/excmds.ts @@ -1318,6 +1318,10 @@ export function hint(option?: string, selectors="") { else if (option === "-i") hinting.hintImage(false) else if (option === "-I") hinting.hintImage(true) else if (option === "-k") hinting.hintKill() + else if (option === "-s") hinting.hintSave("link", false) + else if (option === "-S") hinting.hintSave("img", false) + else if (option === "-a") hinting.hintSave("link", true) + else if (option === "-A") hinting.hintSave("img", true) else if (option === "-;") hinting.hintFocus() else if (option === "-#") hinting.hintPageAnchorYank() else if (option === "-c") hinting.hintPageSimple(selectors) diff --git a/src/hinting.ts b/src/hinting.ts index 5d7822c6..45923a47 100644 --- a/src/hinting.ts +++ b/src/hinting.ts @@ -15,9 +15,10 @@ import {log} from './math' import {permutationsWithReplacement, islice, izip, map} from './itertools' import {hasModifiers} from './keyseq' import state from './state' -import {messageActiveTab} from './messaging' +import {messageActiveTab, message} from './messaging' import * as config from './config' import * as TTS from './text_to_speech' +import {HintSaveType} from './hinting_background' /** Simple container for the state of a single frame's hints. */ class HintState { @@ -221,6 +222,12 @@ function elementswithtext() { ) } +/** Returns elements that point to a saveable resource + */ +function saveableElements() { + return DOM.getElemsBySelector(HINTTAGS_saveable, [DOM.isVisible]) +} + /** Get array of images in the viewport */ function hintableImages() { @@ -296,6 +303,12 @@ article, summary ` +/** CSS selector for elements which point to a saveable resource + */ +const HINTTAGS_saveable = ` +[href]:not([href='#']) +` + import {activeTab, browserBg, l, firefoxVersionAtLeast} from './lib/webext' async function openInBackground(url: string) { @@ -411,6 +424,36 @@ function hintKill() { }) } +/** Hint link elements to save + * + * @param hintType the type of elements to hint and save: + * - "link": elements that point to another resource (eg + * links to pages/files) - the link targer is saved + * - "img": image elements + * @param saveAs prompt for save location + */ +function hintSave(hintType: HintSaveType, saveAs: boolean) { + + function saveHintElems(hintType) { + return (hintType === "link") ? saveableElements() : hintableImages() + } + + function urlFromElem(hintType, elem) { + return (hintType === "link") ? elem.href : elem.src + } + + hintPage(saveHintElems(hintType), hint=>{ + + const urlToSave = new URL(urlFromElem(hintType, hint.target), + window.location.href) + + // Pass to background context to allow saving from data URLs. + // Convert to href because can't clone URL across contexts + message('download_background', "downloadUrl", + [urlToSave.href, saveAs]) + }) +} + function selectFocusedHint() { console.log("Selecting hint.", state.mode) const focused = modeState.focusedHint @@ -432,4 +475,5 @@ addListener('hinting_content', attributeCaller({ hintFocus, hintRead, hintKill, + hintSave, })) diff --git a/src/hinting_background.ts b/src/hinting_background.ts index 1e4e69fe..247e2c66 100644 --- a/src/hinting_background.ts +++ b/src/hinting_background.ts @@ -48,6 +48,19 @@ export async function hintKill() { return await messageActiveTab('hinting_content', 'hintKill') } +/** Type for "hint save" actions: + * - "link": elements that point to another resource (eg + * links to pages/files) - the link target is saved + * - "img": image elements + */ +export type HintSaveType = "link" | "img" + +export async function hintSave(hintType: HintSaveType, saveAs: boolean) { + + return await messageActiveTab('hinting_content', 'hintSave', + [hintType, saveAs]) +} + import {MsgSafeKeyboardEvent} from './msgsafe' /** At some point, this might be turned into a real keyseq parser diff --git a/src/manifest.json b/src/manifest.json index 5e00d2e1..6ee5cf17 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -42,6 +42,7 @@ "contextMenus", "clipboardWrite", "clipboardRead", + "downloads", "history", "sessions", "storage", diff --git a/src/messaging.ts b/src/messaging.ts index c3f479f3..aeb0d700 100644 --- a/src/messaging.ts +++ b/src/messaging.ts @@ -9,7 +9,8 @@ export type TabMessageType = export type NonTabMessageType = "keydown_background" | "commandline_background" | - "browser_proxy_background" + "browser_proxy_background" | + "download_background" export type MessageType = TabMessageType | NonTabMessageType export interface Message { diff --git a/src/url_util.test.ts b/src/url_util.test.ts index e2f865f4..6325bd4d 100644 --- a/src/url_util.test.ts +++ b/src/url_util.test.ts @@ -1,6 +1,6 @@ /** Some tests for URL utilities */ -import {incrementUrl, getUrlRoot, getUrlParent} from './url_util' +import * as UrlUtil from './url_util' function test_increment() { @@ -21,8 +21,8 @@ function test_increment() { for (let [step, input, output] of cases) { - test(`${input} + ${step} --> ${output}`, - () => expect(incrementUrl(input, step)).toEqual(output) + test(`${input} + ${step} --> ${output}`, + () => expect(UrlUtil.incrementUrl(input, step)).toEqual(output) ) } } @@ -39,7 +39,7 @@ function test_root() { ] for (let [url, exp_root] of cases) { - let root = getUrlRoot(new URL(url)) + let root = UrlUtil.getUrlRoot(new URL(url)) test(`root of ${url} --> ${exp_root}`, () => expect(root ? root.href : root).toEqual(exp_root) @@ -69,7 +69,7 @@ function test_parent() { ] for (let [url, exp_parent] of cases) { - let parent = getUrlParent(new URL(url), 1) + let parent = UrlUtil.getUrlParent(new URL(url)) test (`parent of ${url} --> ${exp_parent}`, () => expect(parent ? parent.href : parent).toEqual(exp_parent) @@ -77,6 +77,49 @@ function test_parent() { } } +function test_download_filename() { + + let cases = [ + + // simple domain only + ["http://example.com", "example.com"], + ["http://example.com/", "example.com"], + ["http://sub.example.com/", "sub.example.com"], + + + // simple paths + ["http://example.com/path", "path"], + ["http://example.com/path.ext", "path.ext"], + ["http://example.com/path/more", "more"], + + // ends in / + ["http://example.com/path/", "path"], + + // ignore query strings + ["http://example.com/page?q=v", "page"], + ["http://example.com/page/?q=v", "page"], + + // Data urls + + // base 64 with mime + ["data:image/png;base64,dat", "base64-dat.png"], + ["data:image/png;base64,data/data/data/data", "base64-data_data_data_.png"], + // non-base64 + ["data:image/png,dat", "dat.png"], + // unknown mime + ["data:something/wierd,data", "data"], + ] + + for (let [url, exp_fn] of cases) { + let fn = UrlUtil.getDownloadFilenameForUrl(new URL(url)) + + test (`filename for ${url} --> ${exp_fn}`, + () => expect(fn).toEqual(exp_fn) + ) + } +} + test_increment() test_root() test_parent() +test_download_filename() diff --git a/src/url_util.ts b/src/url_util.ts index 33303013..7b5b0d2e 100644 --- a/src/url_util.ts +++ b/src/url_util.ts @@ -61,7 +61,7 @@ export function getUrlRoot(url) { * @return the parent of the URL, or null if there is no parent * @count how many "generations" you wish to go back (1 = parent, 2 = grandparent, etc.) */ -export function getUrlParent(url, count) { +export function getUrlParent(url, count = 1) { // Helper function. function gup(parent, count) { @@ -110,3 +110,100 @@ export function getUrlParent(url, count) { let parent = new URL(url) return gup(parent, count) } + +/** Very incomplete lookup of extension for common mime types that might be + * encountered when saving elements on a page. There are NPM libs for this, + * but this should cover 99% of basic cases + * + * @param mime mime type to get extension for (eg 'image/png') + * + * @return an extension for that mimetype, or undefined if that type is not + * supported + */ +function getExtensionForMimetype(mime: string): string { + + const types = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/gif": ".gif", + "image/x-icon": ".ico", + "image/svg+xml": ".svg", + "image/tiff": ".tiff", + "image/webp": ".webp", + + "text/plain": ".txt", + "text/html": ".html", + "text/css": ".css", + "text/csv": ".csv", + "text/calendar": ".ics", + + "application/octet-stream": ".bin", + "application/javascript": ".js", + "application/xhtml+xml": ".xhtml", + + "font/otf": ".otf", + "font/woff": ".woff", + "font/woff2": ".woff2", + "font/ttf": ".ttf", + } + + return types[mime] || "" +} + +/** Get a suitable default filename for a given URL + * + * If the URL: + * - is a data URL, construct from the data and mimetype + * - has a path, use the last part of that (eg image.png, index.html) + * - otherwise, use the hostname of the URL + * - if that fails, "download" + * + * @param URL the URL to make a filename for + * @return the filename according to the above rules + */ +export function getDownloadFilenameForUrl(url: URL): string { + + // for a data URL, we have no really useful naming data intrinsic to the + // data, so we construct one using the data and guessing an extension + // from any mimetype + if (url.protocol === "data:") { + + // data:[][;base64], + const [prefix, data] = url.pathname.split(",", 2) + + const [mediatype, b64] = prefix.split(";", 2) + + // take a 15-char prefix of the data as a reasonably unique name + // sanitize in a very rough manner + let filename = data.slice(0, 15) + .replace(/[^a-zA-Z0-9_\-]/g, '_') + .replace(/_{2,}/g, '_') + + // add a base64 prefix and the extension + filename = (b64 ? (b64 + "-") : "") + + filename + + getExtensionForMimetype(mediatype) + + return filename + } + + // if there's a useful path, use that directly + if (url.pathname !== "/") { + + let paths = url.pathname.split("/").slice(1) + + // pop off empty pat bh tails + // e.g. https://www.mozilla.org/en-GB/firefox/new/ + while (paths.length && !paths[paths.length - 1]) { + paths.pop() + } + + if (paths.length) { + return paths.slice(-1)[0] + } + } + + // if there's no path, use the domain (otherwise the FF-provided + // default is just "download" + return url.hostname || "download" +}