├── .gitignore ├── Cargo.toml ├── src ├── scripts │ └── clean-tabs.scpt ├── applescript.rs ├── main.rs ├── cli.rs ├── safari.rs └── urls.rs ├── .github └── workflows │ ├── build.yml │ └── upload_binaries.yml ├── conftest.py ├── LICENSE ├── se_referral_autogen.py ├── test_output.py ├── README.md ├── CHANGELOG.md └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | .DS_Store 3 | __pycache__ 4 | .cache 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "safari" 3 | version = "2.3.14" 4 | authors = ["Alex Chan "] 5 | 6 | [dependencies] 7 | dirs = "2.0.2" 8 | docopt = "0.8" 9 | plist = "0.2.2" 10 | reqwest = "0.9.24" 11 | serde = "1.0.8" 12 | serde_derive = "1.0" 13 | tera = "0.10.6" 14 | urlencoding = "1.0.0" 15 | urlparse = "0.7.3" 16 | -------------------------------------------------------------------------------- /src/scripts/clean-tabs.scpt: -------------------------------------------------------------------------------- 1 | tell application "Safari" 2 | repeat with t in tabs of windows 3 | tell t 4 | -- If you open lots of windows in Safari, some of this book- 5 | -- keeping goes wrong. It will try to look up tab N, except 6 | -- tab N was already closed -- error! 7 | -- 8 | -- For safety, we just catch and discard all errors. 9 | {% for condition in conditions %} 10 | try 11 | tell t 12 | if (URL {{ condition}}) then close 13 | end tell 14 | end try 15 | {% endfor %} 16 | end tell 17 | end repeat 18 | end tell 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: build 3 | jobs: 4 | build: 5 | runs-on: macos-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - uses: actions-rs/toolchain@v1 9 | with: 10 | toolchain: stable 11 | - name: build 12 | uses: actions-rs/cargo@v1 13 | with: 14 | command: build 15 | - name: test 16 | uses: actions-rs/cargo@v1 17 | with: 18 | command: test 19 | - name: check formatting 20 | uses: actions-rs/cargo@v1 21 | with: 22 | command: fmt 23 | args: --check 24 | -------------------------------------------------------------------------------- /src/applescript.rs: -------------------------------------------------------------------------------- 1 | use std::process::{Command, ExitStatus}; 2 | 3 | /// The output of a finished process. 4 | /// 5 | /// This varies from Output in std::process in that stdout/stderr are 6 | /// both strings rather than Vec. 7 | pub struct Output { 8 | pub status: ExitStatus, 9 | pub stdout: String, 10 | pub stderr: String, 11 | } 12 | 13 | /// Run an AppleScript. 14 | /// 15 | /// * `script`: The AppleScript code to run. 16 | /// 17 | pub fn run(script: &str) -> Output { 18 | let cmd_result = Command::new("osascript") 19 | .arg("-e") 20 | .arg(script) 21 | .output() 22 | .expect("failed to execute AppleScript"); 23 | 24 | Output { 25 | status: cmd_result.status, 26 | stdout: String::from_utf8(cmd_result.stdout).unwrap(), 27 | stderr: String::from_utf8(cmd_result.stderr).unwrap(), 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 3 | 4 | import collections 5 | import os 6 | import subprocess 7 | import unittest 8 | 9 | 10 | Result = collections.namedtuple('Result', 'rc stdout stderr') 11 | 12 | ROOT = subprocess.check_output([ 13 | 'git', 'rev-parse', '--show-toplevel']).decode('ascii').strip() 14 | 15 | BINARY = os.path.join(ROOT, 'target', 'release', 'safari') 16 | 17 | 18 | subprocess.check_call(['cargo', 'build', '--release'], cwd=ROOT) 19 | 20 | 21 | class BaseTest(unittest.TestCase): 22 | 23 | def run_safari_rs(self, *args): 24 | proc = subprocess.Popen([BINARY] + list(args), 25 | stdout=subprocess.PIPE, 26 | stderr=subprocess.PIPE 27 | ) 28 | stdout, stderr = proc.communicate() 29 | return Result( 30 | rc=proc.returncode, 31 | stdout=stdout.decode('ascii'), 32 | stderr=stderr.decode('ascii') 33 | ) 34 | -------------------------------------------------------------------------------- /.github/workflows/upload_binaries.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.* 7 | 8 | jobs: 9 | create-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: taiki-e/create-gh-release-action@v1 14 | with: 15 | changelog: CHANGELOG.md 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | upload-assets: 20 | strategy: 21 | matrix: 22 | include: 23 | - target: aarch64-apple-darwin 24 | os: macos-11 25 | - target: x86_64-apple-darwin 26 | os: macos-10.15 27 | runs-on: ${{ matrix.os }} 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: taiki-e/upload-rust-binary-action@v1 31 | with: 32 | target: ${{ matrix.target }} 33 | bin: safari 34 | tar: all 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017 Alex Chan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /se_referral_autogen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 3 | """ 4 | This script auto-generates the ``fix_se_referral`` lines used in ``urls.rs``. 5 | 6 | It takes somebody's Stack Exchange account URL, of the form 7 | 8 | https://stackexchange.com/users/:user_id/:user_name 9 | 10 | and spits out a list of ``fix_se_referral`` calls. 11 | """ 12 | 13 | import sys 14 | from urllib.parse import urlparse 15 | 16 | import bs4 17 | import requests 18 | 19 | 20 | se_account_url = sys.argv[1] 21 | resp = requests.get(se_account_url, params={'tab': 'accounts'}) 22 | 23 | soup = bs4.BeautifulSoup(resp.text, 'html.parser') 24 | accounts = [] 25 | for account in soup.find_all('div', attrs={'class': 'account-container'}): 26 | user_page_url = account.find('h2').find('a').attrs['href'] 27 | components = urlparse(user_page_url) 28 | user_id = components.path.split('/')[2] 29 | accounts.append((components.netloc, user_id)) 30 | 31 | for netloc, user_id in sorted(accounts): 32 | print(f'fix_se_referral(&mut parsed_url, "{netloc}", "{user_id}");') 33 | -------------------------------------------------------------------------------- /test_output.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- encoding: utf-8 3 | """ 4 | These are tests of the external behaviour -- feature tests, if you like. 5 | They run the compiled binaries, and make assertions about the return code, 6 | stdout and stderr. 7 | """ 8 | 9 | import unittest 10 | 11 | from conftest import BaseTest 12 | 13 | 14 | class TestSafariRS(BaseTest): 15 | def test_urls_all_flag_is_deprecated(self): 16 | result = self.run_safari_rs("urls-all") 17 | self.assertIn("deprecated", result.stderr) 18 | 19 | def test_list_tabs_flag_is_not_deprecated(self): 20 | result = self.run_safari_rs("list-tabs") 21 | self.assertNotIn("deprecated", result.stderr) 22 | 23 | def test_no_extra_whitespace_on_tidy_url(self): 24 | result = self.run_safari_rs( 25 | "tidy-url", "https://github.com/alexwlchan/safari.rs/issues" 26 | ) 27 | assert result.rc == 0 28 | assert result.stderr == "" 29 | assert result.stdout.strip() == result.stdout 30 | 31 | def _assert_resolve_tco(self, url, expected): 32 | result = self.run_safari_rs("resolve", url) 33 | assert result.rc == 0 34 | assert result.stderr == "" 35 | assert result.stdout == expected 36 | 37 | def test_resolve_single_redirect(self): 38 | self._assert_resolve_tco( 39 | "https://t.co/2pciHpqpwC", 40 | "https://donmelton.com/2013/06/04/remembering-penny/", 41 | ) 42 | 43 | def test_resolve_multiple_redirect(self): 44 | self._assert_resolve_tco( 45 | "https://t.co/oSJaiNlIP6", "https://bitly.com/blog/backlinking-strategy/" 46 | ) 47 | 48 | def test_resolve_no_redirect(self): 49 | self._assert_resolve_tco("https://example.org/", "https://example.org/") 50 | 51 | 52 | if __name__ == "__main__": 53 | unittest.main() 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Archived 11 February 2024:** this was a fun experiment in using Rust, but I'm no longer using or maintaining it – I've replaced it with JavaScript For Automation (JXA) scripts that I run directly. 2 | 3 | --- 4 | 5 | # safari.rs 6 | 7 | safari.rs provides some tools for interacting with Safari on the command-line. 8 | 9 | ## Commands 10 | 11 | 1. Get the URL from a given window or tab: 12 | 13 | ```console 14 | $ # Get the URL of the frontmost tab of the frontmost window 15 | $ safari url 16 | https://github.com 17 | 18 | $ # Get a URL from the second window from the tab 19 | $ safari url --window=2 20 | https://example.com/foo 21 | 22 | $ # Third tab from the left of the second window 23 | $ safari url --window=2 --tab=3 24 | https://example.com/foo 25 | ``` 26 | 27 | I have the first two commands bound to shortcuts `furl` and `2url` for quick access. 28 | 29 | 2. Get a list of URLs from every open tab: 30 | 31 | ```console 32 | $ safari list-tabs 33 | https://github.com 34 | https://example.com/foo 35 | https://crates.io/crates/urlparse 36 | ... 37 | ``` 38 | 39 | 3. Go through and batch close tabs: 40 | 41 | ```console 42 | $ safari clean-tabs youtube.com,twitter.com 43 | ``` 44 | 45 | I find this useful for quickly cutting down my open tabs. 46 | 47 | 4. Get a list of URLs from Reading List: 48 | 49 | ```console 50 | $ safari reading-list 51 | ``` 52 | 53 | 5. Get a list of URLs from all your devices with iCloud Tabs: 54 | 55 | ```console 56 | $ safari icloud-tabs 57 | ``` 58 | 59 | You can get a list of known devices with `icloud-tabs --list-devices`, and filter the URLs with the `--device` flag: 60 | 61 | ```console 62 | $ safari icloud-tabs --device="Alex's iPhone" 63 | ``` 64 | 65 | ## Installation 66 | 67 | You need [Rust installed][rust]. 68 | Then to install: 69 | 70 | ```console 71 | $ cargo install --git https://github.com/alexwlchan/safari.rs 72 | ``` 73 | 74 | It's tested in Travis with the current version of stable Rust, but it only gets tested when it was last modified (at time of writing, that was Rust 1.40.0). 75 | 76 | [rust]: https://www.rust-lang.org/en-US/install.html 77 | 78 | ## URL transformations 79 | 80 | The commands that produce URLs do a bit of cleaning before they return: 81 | 82 | * Convert links for a mobile site to the desktop site – for example, converting `mobile.twitter.com` to `twitter.com`. 83 | * Stripping tracking junk (e.g. UTM tracking parameters) from URLs. 84 | * Removing some extraneous information that isn't generally useful. 85 | 86 | ## Motivation 87 | 88 | I first got the idea for a script to access Safari URLs [from Dr. Drang][dr]. 89 | I've been through several different versions – AppleScript, shell, Python – 90 | gradually adding the cleaning features – and now I've written a new 91 | version in Rust. 92 | 93 | Why Rust? 94 | 95 | * It's really fast. The Rust script returns immediately – when I tried writing this with Python, I had a noticeable delay when typing `;furl`. 96 | This is a tool I use dozens of times a week, so every second counts. 97 | * I like Rust, and I’ve been enjoying playing with it recently. 98 | 99 | [dr]: http://www.leancrew.com/all-this/2009/07/safari-tab-urls-via-textexpander/ 100 | 101 | ## License 102 | 103 | MIT. 104 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![deny(warnings)] 2 | 3 | extern crate dirs; 4 | extern crate docopt; 5 | extern crate plist; 6 | extern crate reqwest; 7 | #[macro_use] 8 | extern crate serde_derive; 9 | extern crate tera; 10 | extern crate urlencoding; 11 | extern crate urlparse; 12 | 13 | use std::io::Write; 14 | use std::process; 15 | 16 | mod applescript; 17 | mod cli; 18 | mod safari; 19 | mod urls; 20 | 21 | const NAME: &str = env!("CARGO_PKG_NAME"); 22 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 23 | 24 | // http://stackoverflow.com/a/27590832/1558022 25 | macro_rules! error( 26 | ($($arg:tt)*) => { 27 | { 28 | let r = writeln!(&mut ::std::io::stderr(), $($arg)*); 29 | r.expect("failed printing to stderr"); 30 | process::exit(1); 31 | } 32 | } 33 | ); 34 | 35 | /// Exits the program if Safari isn't running. 36 | fn assert_safari_is_running() { 37 | if !safari::is_safari_running() { 38 | error!("Safari is not running."); 39 | } 40 | } 41 | 42 | fn main() { 43 | let args = cli::parse_args(NAME); 44 | 45 | if args.flag_version { 46 | println!("{}.rs v{}", NAME, VERSION); 47 | } 48 | 49 | if args.cmd_url { 50 | assert_safari_is_running(); 51 | match safari::get_url(args.flag_window, args.flag_tab) { 52 | Ok(url) => print!("{}", url), 53 | Err(e) => error!("{}", e), 54 | }; 55 | } 56 | 57 | if args.cmd_title { 58 | assert_safari_is_running(); 59 | match safari::get_title(args.flag_window, args.flag_tab) { 60 | Ok(url) => print!("{}", url), 61 | Err(e) => error!("{}", e), 62 | }; 63 | } 64 | 65 | if args.cmd_resolve { 66 | print!("{}", urls::resolve(&args.arg_url)); 67 | } 68 | 69 | if args.cmd_list_tabs { 70 | assert_safari_is_running(); 71 | for url in safari::get_all_urls() { 72 | println!("{}", url); 73 | } 74 | } 75 | 76 | if args.cmd_close_tabs { 77 | assert_safari_is_running(); 78 | let patterns = args.arg_urls_to_close.split(",").collect(); 79 | safari::close_tabs(patterns); 80 | } 81 | 82 | if args.cmd_reading_list { 83 | match safari::get_reading_list_urls() { 84 | Ok(urls) => { 85 | for url in urls { 86 | println!("{}", url); 87 | } 88 | } 89 | Err(e) => error!("{}", e), 90 | }; 91 | } 92 | 93 | if args.cmd_icloud_tabs { 94 | if args.flag_list_devices { 95 | match safari::list_icloud_tabs_devices() { 96 | Ok(devices) => { 97 | for device in devices { 98 | println!("{}", device); 99 | } 100 | } 101 | Err(e) => error!("{}", e), 102 | }; 103 | } else { 104 | let tab_data = match safari::get_icloud_tabs_urls() { 105 | Ok(tab_data) => tab_data, 106 | Err(e) => error!("{}", e), 107 | }; 108 | match args.flag_device { 109 | Some(d) => match tab_data.get(&d) { 110 | Some(urls) => { 111 | for url in urls { 112 | println!("{}", url); 113 | } 114 | } 115 | None => (), 116 | }, 117 | None => { 118 | for urls in tab_data.values() { 119 | for url in urls { 120 | println!("{}", url); 121 | } 122 | } 123 | } 124 | } 125 | } 126 | } 127 | 128 | if args.cmd_tidy_url { 129 | print!("{}", urls::tidy_url(&args.arg_url)); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use docopt::{Docopt, Error}; 4 | 5 | // https://stackoverflow.com/a/27590832/1558022 6 | macro_rules! println_stderr( 7 | ($($arg:tt)*) => { { 8 | let r = writeln!(&mut ::std::io::stderr(), $($arg)*); 9 | r.expect("failed printing to stderr"); 10 | } } 11 | ); 12 | 13 | const USAGE: &str = " 14 | Usage: url [--window= [--tab=]] 15 | title [--window= [--tab=]] 16 | tidy-url 17 | resolve 18 | list-tabs 19 | urls-all 20 | close-tabs 21 | reading-list 22 | icloud-tabs [--list-devices | --device=] 23 | (-h | --help) 24 | --version 25 | 26 | Options: 27 | -h --help Show this screen. 28 | --version Show version. 29 | --window= Which window to choose a URL from. Use 1 for the 30 | frontmost window, 2 for the second window, and so on. 31 | --tab= Which tab to choose a URL from. Use 1 for the leftmost 32 | tab, 2 for second-from-left, and so on. 33 | --list-devices Get a list of all the devices known to iCloud Tabs. 34 | --device= Only get iCloud URLs for this device. 35 | 36 | Commands: 37 | url Print a URL from an open Safari tab. 38 | title Print the title of an open Safari tab. 39 | resolve Follow redirects and print the final location of a URL. 40 | tidy-url Remove tracking junk, mobile, links, etc. from a URL. 41 | list-tabs Prints a list of URLs from every open Safari tab. 42 | urls-all Same as urls-all. Deprecated. 43 | close-tabs Close any tabs with the given URLs. 44 | reading-list Print a list of URLs from Reading List. 45 | icloud-tabs Get a list of URLs from iCloud Tabs. Default is to list URLs 46 | from every device, or you can filter with the --device flag. 47 | "; 48 | 49 | #[derive(Debug, Deserialize)] 50 | pub struct Args { 51 | pub cmd_url: bool, 52 | pub cmd_title: bool, 53 | pub cmd_tidy_url: bool, 54 | pub cmd_resolve: bool, 55 | pub cmd_urls_all: bool, 56 | pub cmd_list_tabs: bool, 57 | pub cmd_close_tabs: bool, 58 | pub cmd_icloud_tabs: bool, 59 | pub cmd_reading_list: bool, 60 | pub flag_window: Option, 61 | pub flag_tab: Option, 62 | pub flag_version: bool, 63 | pub flag_list_devices: bool, 64 | pub flag_device: Option, 65 | pub arg_url: String, 66 | pub arg_urls_to_close: String, 67 | } 68 | 69 | pub fn parse_args(name: &str) -> Args { 70 | let mut args: Args = Docopt::new(str::replace(USAGE, "", name)) 71 | .and_then(|d| d.deserialize()) 72 | .unwrap_or_else(|e| e.exit()); 73 | 74 | // 0 is the default value for the --window and --tab flags, so if we get 75 | // this value then replace it with None. 76 | if args.cmd_url { 77 | match args.flag_window { 78 | Some(v) => { 79 | if v == 0 { 80 | args.flag_window = None; 81 | }; 82 | } 83 | None => {} 84 | }; 85 | match args.flag_tab { 86 | Some(v) => { 87 | if v == 0 { 88 | args.flag_tab = None; 89 | }; 90 | } 91 | None => {} 92 | }; 93 | 94 | if args.flag_tab.is_some() && args.flag_window.is_none() { 95 | Error::Usage("Cannot use --tab without --window.".to_string()).exit(); 96 | } 97 | } 98 | 99 | if args.cmd_urls_all { 100 | println_stderr!("The --urls-all flag is deprecated; please use --list-tabs."); 101 | args.cmd_urls_all = false; 102 | args.cmd_list_tabs = true; 103 | } 104 | 105 | args 106 | } 107 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.3.14 (2023-12-17) 4 | 5 | * URL tweak: remove tracking parameters from Etsy URLs. 6 | 7 | ## v2.3.12 (2023-02-20) 8 | 9 | * URL tweak: remove another parameter from Twitter URLs. 10 | 11 | ## v2.3.11 (2023-02-07) 12 | 13 | * URL tweak: remove more referrer parameters from Twitter URLs. 14 | 15 | ## v2.3.10 (2023-01-02) 16 | 17 | * URL tweak: remove HubSpot tracking parameters. 18 | * Provide binaries for Macs running Apple Silicon. 19 | 20 | ## v2.3.9 (2022-05-14) 21 | 22 | * Start publishing binaries as GitHub Releases. 23 | 24 | ## v2.3.8 (2022-04-17) 25 | 26 | * URL tweaking: remove the `frs` parameter from URLs on Etsy. 27 | 28 | ## v2.3.7 (2021-10-26) 29 | 30 | * URL tweak: remove the same tracking parameters from Amazon Smile URLs as regular Amazon URLs. 31 | 32 | ## v2.3.6 (2021-09-08) 33 | 34 | * URL tweak: remove all tracking parameters from Tiktok URLs. 35 | 36 | ## v2.3.5 (2021-08-08) 37 | 38 | * URL tweak: remove Cloudflare query parameters that start `__cf`. 39 | 40 | ## v2.3.4 (2021-02-05) 41 | 42 | * URL tweak: strip the `pro` parameter from URLs on Etsy. 43 | 44 | ## v2.3.3 (2021-02-05) 45 | 46 | * URL tweak: strip the share parameter from URLs on . 47 | 48 | ## v2.3.2 (2020-11-21) 49 | 50 | * URL tweak: strip a tracking parameter from URLs on . 51 | 52 | ## v2.3.1 (2020-10-02) 53 | 54 | * URL tweak: strip the `source=` parameter from URLs on . 55 | 56 | ## v2.3.0 (2020-07-26) 57 | 58 | * Add a new command `title` to get the title of a Safari window. 59 | 60 | ## v2.2.9 (2020-07-26) 61 | 62 | * Internal refactoring to remove special-case handling for the old Wellcome Images site (`wellcomeimages.org`). 63 | Since that site was shut down over two years ago, the code to handle it can be removed without a user-facing effect. 64 | 65 | ## v2.2.8 (2020-02-09) 66 | 67 | * URL tweak: strip the `ref=` and `sort=` parameters from Redbubble URLs. 68 | 69 | ## v2.2.7 (2020-01-12) 70 | 71 | * URL tweak: strip the `m=` parameter from Blogspot URLs (so links are never mobile links). 72 | 73 | ## v2.2.6 (2019-12-29) 74 | 75 | * URL tweak: strip the `app=` parameter from YouTube URLs. 76 | 77 | ## v2.2.5 (2019-12-28) 78 | 79 | * URL tweak: strip more tracking parameters from Etsy URLs. 80 | 81 | ## v2.2.4 (2019-04-21) 82 | 83 | * URL tweak: strip tracking parameters from Etsy URLs. 84 | 85 | ## v2.2.3 (2018-05-22) 86 | 87 | * URL tweak: links to the files tab of GitHub pull requests are now replaced 88 | by links to the top of the pull request. 89 | 90 | ## v2.2.2 (2017-10-07) 91 | 92 | * URL tweak: mobile.nytimes.com links are now replaced by non-mobile versions. 93 | ([#59](https://github.com/alexwlchan/safari.rs/issues/59)) 94 | 95 | ## v2.2.1 (2017-08-29) 96 | 97 | * URL tweak: Remove the `_ga` tracking parameter from shared URLs. 98 | ([#56](https://github.com/alexwlchan/safari.rs/issues/56)) 99 | 100 | ## v2.2.0 (2017-06-10) 101 | 102 | * New command: the `resolve` command can take a URL as an argument, and print to stdout the final location of that URL after following any redirects. 103 | Useful for working with, e.g., `t.co` or `bit.ly` URLs. 104 | 105 | ## v2.1.2 (2017-06-10) 106 | 107 | * URL tweak: remove tracking parameters from URLs on `telegraph.co.uk`. 108 | ([#48](https://github.com/alexwlchan/safari.rs/issues/48)) 109 | 110 | ## v2.1.1 (2017-06-05) 111 | 112 | * URL tweak: most of `wellcomeimages.org` is loaded entirely in `