Recently Written · git

sbm

dmenu bookmarks. Instantly fuzzy search thousands of bookmarks and plumb them. LOOKING FOR SUCKLESS SOFTWARE EDITION? GO TO sbm-suckless INSTEAD

git clone https://github.com/equwal/sbm

Log | Files | Refs


commit ee81288af3c599b8d3f4a9e9a8fc770ab5903193
Spenser Truex <truex@equwal.com>
2026-09-20 22:29:47 -0700

bm-import: read all profiles of a browser, on Linux, macOS and Windows

bm-import read only the Default profile, and only from ~/.config. Now a
browser name reads the Bookmarks file of every profile. bm-import looks
for the browser in ~/.config, in ~/Library/Application Support (macOS),
and in %LOCALAPPDATA% (Windows, through cygpath under Cygwin).

bm --merge skips the known bookmarks, so the same command again syncs
the new browser bookmarks into bm.

 README      |  9 ++++++---
 bm-import   | 66 +++++++++++++++++++++++++++++++++++++++++++++----------------
 test/run.sh | 28 ++++++++++++++++++++++++++
 3 files changed, 83 insertions(+), 20 deletions(-)
diff --git a/README b/README
index 30f3842..2b0d7fe 100644
--- a/README
+++ b/README
@@ -153,9 +153,12 @@ Links that would run script (javascript:, data:) are never written.
     bm-import vivaldi | bm --merge            # or chrome, brave, edge, chromium
     bm-import path/to/Bookmarks | bm --merge  # a Chromium JSON file (needs jq)
 
-Folder names become tags ("Dev Tools" becomes dev-tools). bm --merge skips
-what you already have, so importing twice adds nothing. Look before you
-merge: bm-import prints plain bookmark lines.
+A browser name imports the bookmarks of all profiles of that browser. The
+profiles are in ~/.config, in ~/Library/Application Support on macOS, or in
+%LOCALAPPDATA% on Windows. Folder names become tags ("Dev Tools" becomes
+dev-tools). bm --merge skips what you already have, so importing twice adds
+nothing. To sync new browser bookmarks into bm, run the same command again.
+Look before you merge: bm-import prints plain bookmark lines.
 
 == Dead links ==
     bm-check
diff --git a/bm-import b/bm-import
index bea348d..3ceaa9b 100755
--- a/bm-import
+++ b/bm-import
@@ -3,10 +3,13 @@
 # bm-import: turn a browser's bookmarks into bm's format, on stdout.
 #
 #     bm-import bookmarks.html | bm --merge
+#     bm-import brave | bm --merge
 #
 # Reads the Netscape HTML that every browser exports, or the JSON file that
 # Chromium style browsers keep (this needs jq). Folder names become tags:
 # "Dev Tools" is dev-tools. Only http, https, ftp and file URLs are kept.
+# A browser name reads the JSON files of all profiles of that browser, in
+# the directory that the browser uses on Linux, macOS or Windows (Cygwin).
 
 usage () {
     cat <<'EOF2'
@@ -27,21 +30,39 @@ case $1 in
     -h|--help) usage; exit 0 ;;
 esac
 
-src=$1
-case $src in
-    vivaldi)  src=vivaldi ;;
-    chrome)   src=google-chrome ;;
-    brave)    src=BraveSoftware/Brave-Browser ;;
-    edge)     src=microsoft-edge ;;
-    chromium) src=chromium ;;
-    *)        src= ;;
-esac
-if [ -n "$src" ]; then
-    src=${XDG_CONFIG_HOME:-$HOME/.config}/$src/Default/Bookmarks
+# browser_dir <linux> <macos> <windows>: the first directory of the browser
+# that exists. The names are relative to ~/.config, to ~/Library/Application
+# Support and to %LOCALAPPDATA%. The directory holds one folder per profile.
+browser_dir () {
+    appdata=${LOCALAPPDATA-}
+    if [ -n "$appdata" ] && command -v cygpath >/dev/null 2>&1; then
+        appdata=$(cygpath -u "$appdata")
+    fi
+    for dir in "${XDG_CONFIG_HOME:-$HOME/.config}/$1" \
+               "$HOME/Library/Application Support/$2" \
+               ${appdata:+"$appdata/$3/User Data"}; do
+        if [ -d "$dir" ]; then
+            printf '%s\n' "$dir"
+            return 0
+        fi
+    done
+    return 1
+}
+
+case $1 in
+    vivaldi)  dir=$(browser_dir vivaldi Vivaldi Vivaldi) ;;
+    chrome)   dir=$(browser_dir google-chrome Google/Chrome Google/Chrome) ;;
+    brave)    dir=$(browser_dir BraveSoftware/Brave-Browser \
+                  BraveSoftware/Brave-Browser BraveSoftware/Brave-Browser) ;;
+    edge)     dir=$(browser_dir microsoft-edge 'Microsoft Edge' Microsoft/Edge) ;;
+    chromium) dir=$(browser_dir chromium Chromium Chromium) ;;
+    *)        dir= ;;
+esac || die "found no profiles of $1"
+if [ -n "$dir" ]; then
+    command -v jq >/dev/null 2>&1 || die "reading $1 needs jq"
 else
-    src=$1
+    [ -r "$1" ] || die "cannot read $1"
 fi
-[ -r "$src" ] || die "cannot read $src"
 
 # Chromium JSON. The trash (Vivaldi has one) is left out.
 from_json () {
@@ -103,7 +124,18 @@ from_html () {
     }' "$1"
 }
 
-case $(LC_ALL=C tr -d ' \t\r\n' < "$src" | dd bs=1 count=1 2>/dev/null) in
-    '{') from_json "$src" ;;
-    *)   from_html "$src" ;;
-esac | awk -F'\t' '$1 ~ /^(https?|ftp|file):\/\//'
+# rows <file>: the rows of the file, or of every profile of the browser.
+rows () {
+    if [ -n "$dir" ]; then
+        for file in "$dir"/*/Bookmarks; do
+            if [ -r "$file" ]; then from_json "$file"; fi
+        done
+        return
+    fi
+    case $(LC_ALL=C tr -d ' \t\r\n' < "$1" | dd bs=1 count=1 2>/dev/null) in
+        '{') from_json "$1" ;;
+        *)   from_html "$1" ;;
+    esac
+}
+
+rows "$1" | awk -F'\t' '$1 ~ /^(https?|ftp|file):\/\//'
diff --git a/test/run.sh b/test/run.sh
index d3755b4..b83ecc8 100755
--- a/test/run.sh
+++ b/test/run.sh
@@ -493,6 +493,34 @@ if command -v jq >/dev/null 2>&1; then
             "https://blog.example/feed${TAB}A blog${TAB}news-blogs" \
             "https://deep.example/${TAB}Deep${TAB}news-blogs tech" \
             "http://www.bar.example${TAB}Bar dupe${TAB}")"
+
+    # profiles <dir> <folder>...: a browser directory with these profiles,
+    # each with the fixture as its bookmarks.
+    profiles () {
+        dir=$1
+        shift
+        for folder; do
+            mkdir -p "$dir/$folder"
+            cp "$here/fixtures/chromium.json" "$dir/$folder/Bookmarks"
+        done
+    }
+    json_rows=$(printf '%s\n' \
+        "https://bar.example/${TAB}Bar site${TAB}" \
+        "https://blog.example/feed${TAB}A blog${TAB}news-blogs" \
+        "https://deep.example/${TAB}Deep${TAB}news-blogs tech" \
+        "http://www.bar.example${TAB}Bar dupe${TAB}")
+    profiles "$work/linux/.config/BraveSoftware/Brave-Browser" Default 'Profile 1'
+    eq 'bm-import brave reads the bookmarks of every profile' \
+        "$(HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT brave)" \
+        "$(printf '%s\n%s\n' "$json_rows" "$json_rows")"
+    profiles "$work/mac/Library/Application Support/Google/Chrome" Default 'Profile 2'
+    eq 'bm-import finds a browser in ~/Library/Application Support' \
+        "$(HOME=$work/mac XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT chrome | wc -l | tr -d ' ')" '8'
+    profiles "$work/win/Microsoft/Edge/User Data" Default
+    eq 'bm-import finds a browser in LOCALAPPDATA' \
+        "$(HOME=$work/nohome XDG_CONFIG_HOME='' LOCALAPPDATA=$work/win $IMPORT edge)" "$json_rows"
+    HOME=$work/nohome XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT brave 2>/dev/null
+    eq 'bm-import fails when it finds no profiles of the browser' "$?" '1'
 else
     printf 'skip bm-import json: no jq\n'
 fi