Add save link/img hint submode (;s, ;S, ;a, ;A)

This adds the ability to save link targets or images. The save location
can be default, or the save as dialog can be invoked.

Somewhat sensible default filenames are provided.

Data URLs are also supported (though they need quite a bit of massaging
to get past the WebExt security limitations). Specificially, they need
to be round-tripped though a Blob, and must be saved from the background
context.
This commit is contained in:
John Beard 2017-11-27 14:43:01 +00:00 committed by Colin Caine
parent d3e248391b
commit fde956df53
10 changed files with 300 additions and 8 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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,
}))

View file

@ -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)

View file

@ -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,
}))

View file

@ -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

View file

@ -42,6 +42,7 @@
"contextMenus",
"clipboardWrite",
"clipboardRead",
"downloads",
"history",
"sessions",
"storage",

View file

@ -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 {

View file

@ -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() {
@ -22,7 +22,7 @@ function test_increment() {
for (let [step, input, output] of cases) {
test(`${input} + ${step} --> ${output}`,
() => expect(incrementUrl(input, step)).toEqual(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()

View file

@ -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:[<mediatype>][;base64],<data>
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"
}