Browsing Chrome history and bookmarks with fzf #
In this example, you’ll learn how to browse Chrome history and bookmarks from the command-line using fzf.
Getting the input data #
Chrome manages the browsing history in an SQLite database file, and the bookmarks in a JSON file. For example, on macOS, the files are located at:
~/Library/Application Support/Google/Chrome/Default/History
# The file is locked if Chrome is running, so you need to make a copy cp ~/Library/Application\ Support/Google/Chrome/Default/History /tmp/h sqlite3 /tmp/h '.schema urls'
CREATE TABLE urls( id INTEGER PRIMARY KEY AUTOINCREMENT, url LONGVARCHAR, title LONGVARCHAR, visit_count INTEGER DEFAULT 0 NOT NULL, typed_count INTEGER DEFAULT 0 NOT NULL, last_visit_time INTEGER NOT NULL, hidden INTEGER DEFAULT 0 NOT NULL ); CREATE INDEX urls_url_index ON urls (url);
~/Library/Application Support/Google/Chrome/Default/Bookmarks
jq '.roots | keys' ~/Library/Application\ Support/Google/Chrome/Default/Bookmarks
[ "bookmark_bar", "other", "synced" ]
While it’s not impossible to process these files using shell script, it’s challenging and probably not worth the effort, especially because the bookmarks are in hierarchical structure. A scripting language like Ruby, Python, or Perl would be better suited for this task. I’ve chosen Ruby for this example because I’m most comfortable with it, but any language of your choice will do.
See Using fzf in your program to learn how to integrate fzf into your program.
Integration ideas #
- Allow selecting multiple items and open them all at once
--multi
(tab and shift-tab to select multiple items)
- Enable line wrapping so that long URLs are not truncated
--wrap
- Use multi-line feature of fzf to
display the title of the page and the URL in separate lines
--read0
- Print the title and the URL in different colors
--ansi
- Open the selected URLs when you hit
Enter
, but do not close fzf so you can continue browsing--bind enter:execute-silent(...)+deselect-all
- Show the current mode in the border label
--border-label " Chrome::History "
- Provide bindings to switch between history mode and bookmarks mode
--bind ctrl-b:reload(...)+change-border-label(...)+top
--bind ctrl-h:reload(...)+change-border-label(...)+top
- Provide a binding to copy the selected URLs to the clipboard
--bind ctrl-y:execute-silent(...)+deselect-all
Screenshot #
The code #
Store the following code in a file, say chrome.fzf
, put in in your $PATH
,
and make it executable, so you can run it from anywhere.
The script was only tested on macOS, so if you’re on another platform, you may
need to change OPEN_COMMAND
and CLIP_COMMAND
to the commands that work on
your platform.
1#!/usr/bin/env ruby
2# frozen_string_literal: true
3
4require 'bundler/inline'
5require 'rbconfig'
6require 'tempfile'
7require 'json'
8require 'open3'
9require 'shellwords'
10
11gemfile do
12 source 'https://rubygems.org'
13 gem 'sqlite3'
14 gem 'ansi256'
15end
16
17# Chrome fzf integration
18module ChromeFzf
19 extend self
20
21 # Platform-specific constants.
22 # FIXME: Commands for Linux and Windows are not tested.
23 BASE_PATH, OPEN_COMMAND, CLIP_COMMAND =
24 case RbConfig::CONFIG['host_os']
25 when /darwin/
26 ['Library/Application Support/Google/Chrome', 'open {+2}', 'echo -n {+2} | pbcopy']
27 when /linux/
28 ['.config/google-chrome', 'xdg-open {+2}', 'echo -n {+2} | xsel --clipboard --input']
29 else
30 ['AppData\Local\Google\Chrome\User Data', 'start {+2}', 'echo {+2} | clip']
31 end
32
33 def run(type)
34 Open3.popen2(fzf(type)) do |stdin, _stdout|
35 list(type, stdin)
36 end
37 rescue Errno::EPIPE
38 # Ignore broken pipe error
39 end
40
41 def list(type, io = $stdout)
42 method(type).call.each do |title, url, time|
43 format(io, title, url, time)
44 end
45 end
46
47 private
48
49 def path(name) = File.join(Dir.home, BASE_PATH, 'Default', name)
50
51 # Build fzf command
52 def fzf(name)
53 <<~CMD
54 fzf --ansi --read0 --multi --info inline-right --reverse --scheme history \\
55 --highlight-line --cycle --tmux 100% --wrap --wrap-sign ' ↳ ' \\
56 --border --border-label " Chrome::#{name.capitalize} " --delimiter "\n · " \\
57 --header '╱ CTRL-B: Bookmarks ╱ CTRL-H: History ╱ CTRL-Y: Copy to clipboard ╱\n\n' \\
58 --bind 'enter:execute-silent(#{OPEN_COMMAND})+deselect-all' \\
59 --bind 'ctrl-y:execute-silent(#{CLIP_COMMAND})+deselect-all' \\
60 --bind 'ctrl-b:reload(ruby #{__FILE__.shellescape} --list b)+change-border-label( Chrome::Bookmarks )+top' \\
61 --bind 'ctrl-h:reload(ruby #{__FILE__.shellescape} --list h)+change-border-label( Chrome::History )+top'
62 CMD
63 end
64
65 def format(io, title, url, time)
66 time = Time.at(time.to_i / 1_000_000 - 11_644_473_600).strftime('%F %T')
67 io.puts "#{title} (#{time.yellow})".strip
68 io.print " · #{url.blue.dim}\n\x0"
69 end
70
71 def history
72 Tempfile.create('chrome') do |temp|
73 temp.close
74 FileUtils.cp(path('History'), temp.path)
75 SQLite3::Database.open(temp.path) do |db|
76 db.execute('select title, url, last_visit_time from urls order by last_visit_time desc')
77 end
78 end
79 end
80
81 def bookmarks
82 build = lambda do |parent, json|
83 name = [parent, json[:name]].compact.join('/')
84 if json[:type] == 'folder'
85 json[:children].flat_map { |child| build[name, child] }
86 else
87 [[name, json[:url], json.values_at(:date_last_used, :date_added).max]]
88 end
89 end
90
91 JSON.load_file(path('Bookmarks'), symbolize_names: true)
92 .fetch(:roots, {})
93 .values
94 .flat_map { |e| build[nil, e] }
95 .sort_by(&:last)
96 .reverse
97 end
98end
99
100method = ARGV.delete('--list') ? :list : :run
101type = case ARGV[0]&.downcase
102 when 'b' then :bookmarks
103 when 'h', nil then :history
104 else abort "Usage: #{__FILE__} [--list] [b|h]"
105 end
106
107ChromeFzf.send(method, type)
Note
- In both cases, the entries are sorted by the last visit time in descending
order. We use
--scheme history
to give more weight to this ordering. - If you don’t want to keep fzf open after you press enter or CTRL-Y, change
execute-silent(...)+deselect-all
toenter:become(...)
.