fzf: Chrome history

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 to enter:become(...).
Last modified: Aug 1, 2024
Copyright © 2024 Junegunn Choi