mirror of
https://github.com/vale981/tridactyl
synced 2025-03-05 09:31:41 -05:00
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:
parent
d3e248391b
commit
fde956df53
10 changed files with 300 additions and 8 deletions
|
@ -22,6 +22,7 @@ import * as convert from './convert'
|
||||||
import * as config from './config'
|
import * as config from './config'
|
||||||
import * as dom from './dom'
|
import * as dom from './dom'
|
||||||
import * as hinting_background from './hinting_background'
|
import * as hinting_background from './hinting_background'
|
||||||
|
import * as download_background from './download_background'
|
||||||
import * as gobble_mode from './parsers/gobblemode'
|
import * as gobble_mode from './parsers/gobblemode'
|
||||||
import * as input_mode from './parsers/inputmode'
|
import * as input_mode from './parsers/inputmode'
|
||||||
import * as itertools from './itertools'
|
import * as itertools from './itertools'
|
||||||
|
@ -39,6 +40,7 @@ import * as webext from './lib/webext'
|
||||||
config,
|
config,
|
||||||
dom,
|
dom,
|
||||||
hinting_background,
|
hinting_background,
|
||||||
|
download_background,
|
||||||
gobble_mode,
|
gobble_mode,
|
||||||
input_mode,
|
input_mode,
|
||||||
itertools,
|
itertools,
|
||||||
|
|
|
@ -77,6 +77,10 @@ const DEFAULTS = o({
|
||||||
";y": "hint -y",
|
";y": "hint -y",
|
||||||
";p": "hint -p",
|
";p": "hint -p",
|
||||||
";r": "hint -r",
|
";r": "hint -r",
|
||||||
|
";s": "hint -s",
|
||||||
|
";S": "hint -S",
|
||||||
|
";a": "hint -a",
|
||||||
|
";A": "hint -A",
|
||||||
";;": "hint -;",
|
";;": "hint -;",
|
||||||
";#": "hint -#",
|
";#": "hint -#",
|
||||||
"I": "mode ignore",
|
"I": "mode ignore",
|
||||||
|
|
83
src/download_background.ts
Normal file
83
src/download_background.ts
Normal 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,
|
||||||
|
}))
|
|
@ -1318,6 +1318,10 @@ export function hint(option?: string, selectors="") {
|
||||||
else if (option === "-i") hinting.hintImage(false)
|
else if (option === "-i") hinting.hintImage(false)
|
||||||
else if (option === "-I") hinting.hintImage(true)
|
else if (option === "-I") hinting.hintImage(true)
|
||||||
else if (option === "-k") hinting.hintKill()
|
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.hintFocus()
|
||||||
else if (option === "-#") hinting.hintPageAnchorYank()
|
else if (option === "-#") hinting.hintPageAnchorYank()
|
||||||
else if (option === "-c") hinting.hintPageSimple(selectors)
|
else if (option === "-c") hinting.hintPageSimple(selectors)
|
||||||
|
|
|
@ -15,9 +15,10 @@ import {log} from './math'
|
||||||
import {permutationsWithReplacement, islice, izip, map} from './itertools'
|
import {permutationsWithReplacement, islice, izip, map} from './itertools'
|
||||||
import {hasModifiers} from './keyseq'
|
import {hasModifiers} from './keyseq'
|
||||||
import state from './state'
|
import state from './state'
|
||||||
import {messageActiveTab} from './messaging'
|
import {messageActiveTab, message} from './messaging'
|
||||||
import * as config from './config'
|
import * as config from './config'
|
||||||
import * as TTS from './text_to_speech'
|
import * as TTS from './text_to_speech'
|
||||||
|
import {HintSaveType} from './hinting_background'
|
||||||
|
|
||||||
/** Simple container for the state of a single frame's hints. */
|
/** Simple container for the state of a single frame's hints. */
|
||||||
class HintState {
|
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
|
/** Get array of images in the viewport
|
||||||
*/
|
*/
|
||||||
function hintableImages() {
|
function hintableImages() {
|
||||||
|
@ -296,6 +303,12 @@ article,
|
||||||
summary
|
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'
|
import {activeTab, browserBg, l, firefoxVersionAtLeast} from './lib/webext'
|
||||||
|
|
||||||
async function openInBackground(url: string) {
|
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() {
|
function selectFocusedHint() {
|
||||||
console.log("Selecting hint.", state.mode)
|
console.log("Selecting hint.", state.mode)
|
||||||
const focused = modeState.focusedHint
|
const focused = modeState.focusedHint
|
||||||
|
@ -432,4 +475,5 @@ addListener('hinting_content', attributeCaller({
|
||||||
hintFocus,
|
hintFocus,
|
||||||
hintRead,
|
hintRead,
|
||||||
hintKill,
|
hintKill,
|
||||||
|
hintSave,
|
||||||
}))
|
}))
|
||||||
|
|
|
@ -48,6 +48,19 @@ export async function hintKill() {
|
||||||
return await messageActiveTab('hinting_content', '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'
|
import {MsgSafeKeyboardEvent} from './msgsafe'
|
||||||
|
|
||||||
/** At some point, this might be turned into a real keyseq parser
|
/** At some point, this might be turned into a real keyseq parser
|
||||||
|
|
|
@ -42,6 +42,7 @@
|
||||||
"contextMenus",
|
"contextMenus",
|
||||||
"clipboardWrite",
|
"clipboardWrite",
|
||||||
"clipboardRead",
|
"clipboardRead",
|
||||||
|
"downloads",
|
||||||
"history",
|
"history",
|
||||||
"sessions",
|
"sessions",
|
||||||
"storage",
|
"storage",
|
||||||
|
|
|
@ -9,7 +9,8 @@ export type TabMessageType =
|
||||||
export type NonTabMessageType =
|
export type NonTabMessageType =
|
||||||
"keydown_background" |
|
"keydown_background" |
|
||||||
"commandline_background" |
|
"commandline_background" |
|
||||||
"browser_proxy_background"
|
"browser_proxy_background" |
|
||||||
|
"download_background"
|
||||||
export type MessageType = TabMessageType | NonTabMessageType
|
export type MessageType = TabMessageType | NonTabMessageType
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/** Some tests for URL utilities */
|
/** Some tests for URL utilities */
|
||||||
|
|
||||||
import {incrementUrl, getUrlRoot, getUrlParent} from './url_util'
|
import * as UrlUtil from './url_util'
|
||||||
|
|
||||||
function test_increment() {
|
function test_increment() {
|
||||||
|
|
||||||
|
@ -21,8 +21,8 @@ function test_increment() {
|
||||||
|
|
||||||
for (let [step, input, output] of cases) {
|
for (let [step, input, output] of cases) {
|
||||||
|
|
||||||
test(`${input} + ${step} --> ${output}`,
|
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) {
|
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}`,
|
test(`root of ${url} --> ${exp_root}`,
|
||||||
() => expect(root ? root.href : root).toEqual(exp_root)
|
() => expect(root ? root.href : root).toEqual(exp_root)
|
||||||
|
@ -69,7 +69,7 @@ function test_parent() {
|
||||||
]
|
]
|
||||||
|
|
||||||
for (let [url, exp_parent] of cases) {
|
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}`,
|
test (`parent of ${url} --> ${exp_parent}`,
|
||||||
() => expect(parent ? parent.href : parent).toEqual(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_increment()
|
||||||
test_root()
|
test_root()
|
||||||
test_parent()
|
test_parent()
|
||||||
|
test_download_filename()
|
||||||
|
|
|
@ -61,7 +61,7 @@ export function getUrlRoot(url) {
|
||||||
* @return the parent of the URL, or null if there is no parent
|
* @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.)
|
* @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.
|
// Helper function.
|
||||||
function gup(parent, count) {
|
function gup(parent, count) {
|
||||||
|
@ -110,3 +110,100 @@ export function getUrlParent(url, count) {
|
||||||
let parent = new URL(url)
|
let parent = new URL(url)
|
||||||
return gup(parent, count)
|
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"
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue