commit 5f825e6db438ce06f8abb759d529ac39068d0819 Spenser Truex <truex@equwal.com> 2026-09-21 00:26:03 -0700 bm-watch: bring new browser bookmarks into bm bm-watch imports the bookmarks of all profiles of the given browsers, and imports them again each time that a Bookmarks file changes. It uses bm-import and bm --merge, so it only adds bookmarks, and it never writes to the browser. It reads the files with one cksum every 5 seconds, and it looks for new profiles once a minute. bm-import --files prints the Bookmarks file of each profile of a browser. The packages list bm-watch with the other tools.
Makefile | 2 +- README | 7 ++ bm-import | 20 ++++- bm-watch | 87 ++++++++++++++++++++++ config.mk | 3 +- packaging/README.md | 2 +- packaging/chocolatey/tools/chocolateyInstall.ps1 | 2 +- packaging/chocolatey/tools/chocolateyUninstall.ps1 | 2 +- packaging/freebsd/deskutils/sbm/pkg-plist | 1 + packaging/rpm/sbm.spec | 1 + packaging/scoop/sbm.json | 5 +- test/run.sh | 44 ++++++++++- 12 files changed, 165 insertions(+), 11 deletions(-)
diff --git a/Makefile b/Makefile index 1ff7f7c..d4dd1c8 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ include config.mk -SCRIPTS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit +SCRIPTS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch TESTS = test/run.sh test/fakemenu test/fakefzf all: diff --git a/README b/README index 4194f55..d0f37b9 100644 --- a/README +++ b/README @@ -23,6 +23,7 @@ other Unix tools, and you install only the ones you use: bm-migrate the old sbm file format -> bookmark lines bm-title a URL -> the page's title bm-commit commit the bookmark file to git + bm-watch browser bookmarks -> bm, now and after each change bm-import bookmarks.html | bm --merge bm --tag code --list | bm-check - @@ -163,6 +164,12 @@ 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. +bm-watch does this import at once, and again each time that the bookmark +file of a profile changes. It only adds bookmarks, and it never writes to +the browser: + + bm-watch brave & + == Dead links == bm-check diff --git a/bm-import b/bm-import index 3ceaa9b..32ba27e 100755 --- a/bm-import +++ b/bm-import @@ -10,10 +10,12 @@ # "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). +# --files prints the names of these files, for bm-watch. usage () { cat <<'EOF2' usage: bm-import <file> | vivaldi | chrome | brave | edge | chromium + bm-import --files vivaldi | chrome | brave | edge | chromium EOF2 } @@ -22,13 +24,15 @@ die () { exit 1 } +files= +case ${1-} in + -h|--help) usage; exit 0 ;; + --files) files=1; shift ;; +esac if [ $# -ne 1 ]; then usage >&2 exit 2 fi -case $1 in - -h|--help) usage; exit 0 ;; -esac # browser_dir <linux> <macos> <windows>: the first directory of the browser # that exists. The names are relative to ~/.config, to ~/Library/Application @@ -58,6 +62,16 @@ case $1 in chromium) dir=$(browser_dir chromium Chromium Chromium) ;; *) dir= ;; esac || die "found no profiles of $1" +if [ -n "$files" ]; then + if [ -z "$dir" ]; then + usage >&2 + exit 2 + fi + for file in "$dir"/*/Bookmarks; do + if [ -r "$file" ]; then printf '%s\n' "$file"; fi + done + exit 0 +fi if [ -n "$dir" ]; then command -v jq >/dev/null 2>&1 || die "reading $1 needs jq" else diff --git a/bm-watch b/bm-watch new file mode 100755 index 0000000..0062bc4 --- /dev/null +++ b/bm-watch @@ -0,0 +1,87 @@ +#!/bin/sh + +# bm-watch: bring the bookmarks of browsers into bm, and keep them coming. +# +# bm-watch brave & +# bm-watch -i 30 brave vivaldi +# +# At the start, and each time that the bookmark file of a profile changes, +# bm-watch runs "bm-import <browser> | bm --merge". So a bookmark that you +# add in the browser comes into bm a few seconds later. bm-watch only adds: +# it does not remove from bm what you delete in the browser, and it never +# writes to the browser. It checks the files every 5 seconds (-i), and it +# looks for new profiles once a minute. -1 checks once and stops. + +usage () { + printf 'usage: bm-watch [-1] [-i seconds] browser...\n' +} + +die () { + printf 'bm-watch: %s\n' "$*" >&2 + exit 1 +} + +interval=5 +once= +while getopts 1i: opt; do + case $opt in + 1) once=1 ;; + i) interval=$OPTARG ;; + *) usage >&2; exit 2 ;; + esac +done +shift $((OPTIND - 1)) +if [ $# -eq 0 ]; then + usage >&2 + exit 2 +fi +case $interval in + ''|*[!0-9]*|0) usage >&2; exit 2 ;; +esac + +# files <browser>...: the bookmark files of all profiles, one per line. +files () { + for browser; do + bm-import --files "$browser" 2>/dev/null + done +} + +# sync <browser>...: merge the bookmarks of the browsers that have profiles. +sync () { + for browser; do + if [ -n "$(bm-import --files "$browser" 2>/dev/null)" ]; then + bm-import "$browser" | bm --merge + fi + done +} + +# stamp <files>: the checksum of each file. One cksum reads them all. +stamp () { + [ -n "$1" ] || return 0 + ( + IFS=' +' + set -f + # shellcheck disable=SC2086 # one file per line, on purpose + cksum $1 2>/dev/null + ) +} + +list=$(files "$@") +[ -n "$list" ] || die "found no profiles of $*" +last= +ticks=0 +while :; do + if [ "$ticks" -ge 60 ]; then + list=$(files "$@") + ticks=0 + fi + now=$(stamp "$list") + if [ "$now" != "$last" ]; then + sync "$@" + last=$now + fi + [ -z "$once" ] || exit 0 + sleep "$interval" + ticks=$((ticks + interval)) +done diff --git a/config.mk b/config.mk index 28ad308..656f6af 100644 --- a/config.mk +++ b/config.mk @@ -11,4 +11,5 @@ MANPREFIX=${PREFIX}/share/man # bm-html the bookmarks as a searchable web page # bm-title lets bm --add suggest the page title as description (curl) # bm-commit lets bm keep the file's history in git -TOOLS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit +# bm-watch brings new browser bookmarks into bm (bm-import, jq) +TOOLS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch diff --git a/packaging/README.md b/packaging/README.md index 9ea3518..13385a6 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -24,7 +24,7 @@ mentions a sync client. `make install` copies the programs in `TOOLS` to `${DESTDIR}${PREFIX}/bin`: - bm bm-migrate bm-import bm-check bm-html bm-title bm-commit + bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch Nothing is compiled. There are no man pages. Every package calls `make DESTDIR=... PREFIX=... install` rather than copying the scripts itself, diff --git a/packaging/chocolatey/tools/chocolateyInstall.ps1 b/packaging/chocolatey/tools/chocolateyInstall.ps1 index 26d3b08..32a08b6 100644 --- a/packaging/chocolatey/tools/chocolateyInstall.ps1 +++ b/packaging/chocolatey/tools/chocolateyInstall.ps1 @@ -6,7 +6,7 @@ $toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition $stage = Join-Path $toolsDir 'staging' # What Makefile's TOOLS installs. -$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit') +$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch') # --------------------------------------------------------------------------- # Fetch and unpack. diff --git a/packaging/chocolatey/tools/chocolateyUninstall.ps1 b/packaging/chocolatey/tools/chocolateyUninstall.ps1 index 2f40f0c..e5788b0 100644 --- a/packaging/chocolatey/tools/chocolateyUninstall.ps1 +++ b/packaging/chocolatey/tools/chocolateyUninstall.ps1 @@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop' $toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition -$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit') +$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch') foreach ($s in $scripts) { $cmdPath = Join-Path $toolsDir "$s.cmd" diff --git a/packaging/freebsd/deskutils/sbm/pkg-plist b/packaging/freebsd/deskutils/sbm/pkg-plist index 6edba24..5447749 100644 --- a/packaging/freebsd/deskutils/sbm/pkg-plist +++ b/packaging/freebsd/deskutils/sbm/pkg-plist @@ -5,6 +5,7 @@ bin/bm-html bin/bm-import bin/bm-migrate bin/bm-title +bin/bm-watch %%PORTDOCS%%%%DOCSDIR%%/README %%PORTDOCS%%%%DOCSDIR%%/TODO %%PORTEXAMPLES%%%%EXAMPLESDIR%%/engines diff --git a/packaging/rpm/sbm.spec b/packaging/rpm/sbm.spec index 773b68c..39359b6 100644 --- a/packaging/rpm/sbm.spec +++ b/packaging/rpm/sbm.spec @@ -81,6 +81,7 @@ make check %{_bindir}/bm-import %{_bindir}/bm-migrate %{_bindir}/bm-title +%{_bindir}/bm-watch %changelog * Sun Sep 20 2026 Spenser Truex <truex@equwal.com> - 0.3-1 diff --git a/packaging/scoop/sbm.json b/packaging/scoop/sbm.json index 36b7243..a846f65 100644 --- a/packaging/scoop/sbm.json +++ b/packaging/scoop/sbm.json @@ -37,7 +37,7 @@ " 'if not defined SBM_SH echo sbm: no POSIX sh found. Install Git: scoop install git 1>&2',", " 'if not defined SBM_SH exit /b 127'", ")", - "foreach ($t in 'bm','bm-migrate','bm-import','bm-check','bm-html','bm-title','bm-commit') {", + "foreach ($t in 'bm','bm-migrate','bm-import','bm-check','bm-html','bm-title','bm-commit','bm-watch') {", " ($lines + ('\"%SBM_SH%\" \"%~dp0' + $t + '\" %*')) | Set-Content -LiteralPath \"$dir\\$t.cmd\" -Encoding ASCII", "}" ], @@ -48,7 +48,8 @@ "bm-check.cmd", "bm-html.cmd", "bm-title.cmd", - "bm-commit.cmd" + "bm-commit.cmd", + "bm-watch.cmd" ], "notes": [ "The menu is fzf. dmenu is an X11 program and does not exist here.", diff --git a/test/run.sh b/test/run.sh index b83ecc8..b61e96f 100755 --- a/test/run.sh +++ b/test/run.sh @@ -308,7 +308,7 @@ for gone in '--import x' --check --migrate '-s x'; do done bad= -for script in bm bm-migrate bm-import bm-check bm-html bm-title bm-commit; do +for script in bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch; do [ "$(sed -n 1p "$top/$script")" = '#!/bin/sh' ] || bad="$bad $script" done eq 'every script asks for /bin/sh' "$bad" '' @@ -521,6 +521,48 @@ if command -v jq >/dev/null 2>&1; then "$(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' + eq 'bm-import --files lists the bookmark file of each profile' \ + "$(HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $IMPORT --files brave | sed "s|^$work/linux/||")" \ + "$(printf '%s\n' '.config/BraveSoftware/Brave-Browser/Default/Bookmarks' \ + '.config/BraveSoftware/Brave-Browser/Profile 1/Bookmarks')" + $IMPORT --files "$here/fixtures/chromium.json" 2>/dev/null + eq 'bm-import --files wants a browser name' "$?" '2' + + # bm-watch runs bm-import and bm by name. + WATCH="${SBM_SH:-sh} $top/bm-watch" + reset + eq 'bm-watch -1 imports the bookmarks of every profile' \ + "$(PATH="$top:$PATH" HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $WATCH -1 brave)" \ + 'added 3, skipped 5 duplicates' + eq 'bm-watch -1 again adds nothing' \ + "$(PATH="$top:$PATH" HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' $WATCH -1 brave)" \ + 'added 0, skipped 8 duplicates' + $WATCH -1 2>/dev/null + eq 'bm-watch wants a browser' "$?" '2' + PATH="$top:$PATH" HOME=$work/nohome XDG_CONFIG_HOME='' LOCALAPPDATA='' $WATCH -1 brave 2>/dev/null + eq 'bm-watch fails when it finds no profiles' "$?" '1' + + reset + PATH="$top:$PATH" HOME=$work/linux XDG_CONFIG_HOME='' LOCALAPPDATA='' \ + $WATCH -i 1 brave >/dev/null 2>&1 & + watcher=$! + trap 'kill "$watcher" 2>/dev/null; rm -rf "$work"' EXIT INT TERM + i=0 + while [ $i -lt 30 ] && ! grep -q deep.example "$BOOKMARKS"; do + sleep 1 + i=$((i + 1)) + done + sed 's|https://deep.example/|https://new.example/|' "$here/fixtures/chromium.json" \ + > "$work/linux/.config/BraveSoftware/Brave-Browser/Profile 1/Bookmarks" + i=0 + while [ $i -lt 30 ] && ! grep -q new.example "$BOOKMARKS"; do + sleep 1 + i=$((i + 1)) + done + kill "$watcher" 2>/dev/null + trap 'rm -rf "$work"' EXIT INT TERM + eq 'bm-watch imports again when a bookmark file changes' \ + "$(grep -c new.example "$BOOKMARKS")" '1' else printf 'skip bm-import json: no jq\n' fi