aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-06 15:19:20 -0500
committerCraig Jennings <c@cjennings.net>2026-06-06 15:19:20 -0500
commit47940f707aceea6066f8c9860207724c5d30f9e0 (patch)
tree76fafa9e1303c5c3a1d5c766d3693d237b029959
parent480a59f36a7e3f406847a4157f0b2c62d114af7c (diff)
downloaddotemacs-47940f707aceea6066f8c9860207724c5d30f9e0.tar.gz
dotemacs-47940f707aceea6066f8c9860207724c5d30f9e0.zip
fix(nov): name EPUB bookmarks "Author, Title" from the filename
In a nov buffer m is bound to bookmark-set. nov's nov-bookmark-make-record names the record after the buffer name, the raw EPUB filename. So bookmarks read like "Frege_ A Guide for the Perplexed - Edward Kanterian.epub". I advise nov-bookmark-make-record to rebuild the name from the record's filename: split on the last " - " into title and author per Calibre's "<Title> - <Author>.epub" naming, restore the colon Calibre sanitized to "_ ", and reorder to "Author, Title". That book becomes "Edward Kanterian, Frege: A Guide for the Perplexed". I pulled the name from the filename rather than the embedded EPUB metadata on purpose. In real books the embedded metadata is the worse copy (truncated titles, authors in "Last, First" sort form, lost punctuation), while the filenames come from Calibre's curated database. A separate task tracks embedding the good metadata back into the files. cj/--nov-clean-title and cj/--nov-bookmark-name-from-file are pure and carry the logic. The advice is a thin wrapper. 10 ERT tests cover colon restoration, the last-separator split, and the no-separator fallback.
-rw-r--r--modules/calibredb-epub-config.el48
-rw-r--r--tests/test-calibredb-epub-config--bookmark-name.el87
2 files changed, 135 insertions, 0 deletions
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
index 4243e509..9f09e392 100644
--- a/modules/calibredb-epub-config.el
+++ b/modules/calibredb-epub-config.el
@@ -327,6 +327,54 @@ Try to use the Calibre book id from the parent folder name (for example,
("t" . nov-goto-toc)
("C-c C-b" . cj/nov-jump-to-calibredb)))
+;; ------------------------- Nov bookmark naming -------------------------------
+;; In a nov buffer "m" is bound to `bookmark-set' (above). nov's
+;; `nov-bookmark-make-record' names the record after `(buffer-name)' -- the EPUB
+;; filename, extension and all. Rebuild it as "Author, Title" parsed from the
+;; filename: under Calibre's "<Title> - <Author>.epub" naming the filename is
+;; more complete than the EPUB's embedded metadata (which carries truncated
+;; titles and author-sort "Last, First" forms).
+
+(defun cj/--nov-clean-title (s)
+ "Clean a title or author S parsed from an EPUB filename, or nil when blank.
+Restores a colon where Calibre sanitized \":\" to \"_\" (\"Frege_ A Guide\"
+-> \"Frege: A Guide\"), turns any leftover underscore into a space, and
+collapses runs of whitespace."
+ (when (stringp s)
+ (let* ((colon (replace-regexp-in-string "_ " ": " s))
+ (spaced (replace-regexp-in-string "_" " " colon))
+ (out (string-trim (replace-regexp-in-string "[ \t]+" " " spaced))))
+ (and (not (string-empty-p out)) out))))
+
+(defun cj/--nov-bookmark-name-from-file (path)
+ "Return \"Author, Title\" derived from an EPUB PATH's filename, or nil.
+Splits the filename (sans extension) on its last \" - \" into title and
+author per Calibre's \"<Title> - <Author>\" convention, restoring colons and
+reordering to \"Author, Title\". Falls back to the cleaned whole name when
+there is no \" - \" separator."
+ (when (and (stringp path) (not (string-empty-p path)))
+ (let ((base (file-name-sans-extension (file-name-nondirectory path))))
+ (if (string-match "\\`\\(.+\\) - \\(.+\\)\\'" base)
+ (let ((title (cj/--nov-clean-title (match-string 1 base)))
+ (author (cj/--nov-clean-title (match-string 2 base))))
+ (cond ((and author title) (format "%s, %s" author title))
+ (title title)
+ (author author)
+ (t nil)))
+ (cj/--nov-clean-title base)))))
+
+(defun cj/--nov-bookmark-rename-record (record)
+ "Replace RECORD's bookmark name with \"Author, Title\" from its EPUB filename.
+Advice (:filter-return) on `nov-bookmark-make-record'. RECORD is
+\(NAME . ALIST) carrying a `filename'; left unchanged when no name derives."
+ (let ((name (cj/--nov-bookmark-name-from-file
+ (alist-get 'filename (cdr record)))))
+ (if name (cons name (cdr record)) record)))
+
+(with-eval-after-load 'nov
+ (advice-add 'nov-bookmark-make-record :filter-return
+ #'cj/--nov-bookmark-rename-record))
+
(defun cj/--nov-image-padding-cols (col-width img-px font-width-px)
"Return left-padding columns to center an IMG-PX-wide image in COL-WIDTH cols.
FONT-WIDTH-PX is the column width in pixels; clamped up to 1 so a zero or
diff --git a/tests/test-calibredb-epub-config--bookmark-name.el b/tests/test-calibredb-epub-config--bookmark-name.el
new file mode 100644
index 00000000..2e1d253e
--- /dev/null
+++ b/tests/test-calibredb-epub-config--bookmark-name.el
@@ -0,0 +1,87 @@
+;;; test-calibredb-epub-config--bookmark-name.el --- Nov bookmark naming tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the clean "Author, Title" bookmark naming that replaces nov.el's
+;; filename-based default. The name is parsed from the EPUB filename (Calibre's
+;; "<Title> - <Author>.epub" convention), restoring colons that Calibre
+;; sanitized to underscores and reordering to "Author, Title".
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'calibredb-epub-config)
+
+;;; cj/--nov-clean-title
+
+(ert-deftest test-nov-clean-title-passthrough ()
+ "Normal: a clean string is returned unchanged."
+ (should (equal (cj/--nov-clean-title "Agatha Christie") "Agatha Christie"))
+ (should (equal (cj/--nov-clean-title "The A.B.C. Murders") "The A.B.C. Murders")))
+
+(ert-deftest test-nov-clean-title-restores-colon ()
+ "Boundary: Calibre's \"_ \" colon substitution is restored to \": \"."
+ (should (equal (cj/--nov-clean-title "Frege_ A Guide for the Perplexed")
+ "Frege: A Guide for the Perplexed"))
+ (should (equal (cj/--nov-clean-title "The Fool's Progress_ An Honest Novel")
+ "The Fool's Progress: An Honest Novel")))
+
+(ert-deftest test-nov-clean-title-stray-underscore-and-whitespace ()
+ "Boundary: a non-colon underscore becomes a space; whitespace collapses."
+ (should (equal (cj/--nov-clean-title "a_b") "a b"))
+ (should (equal (cj/--nov-clean-title " x y ") "x y")))
+
+(ert-deftest test-nov-clean-title-rejects-blank-and-nonstring ()
+ "Error: nil, empty, all-whitespace, or non-string yields nil."
+ (should-not (cj/--nov-clean-title nil))
+ (should-not (cj/--nov-clean-title ""))
+ (should-not (cj/--nov-clean-title " "))
+ (should-not (cj/--nov-clean-title 42)))
+
+;;; cj/--nov-bookmark-name-from-file
+
+(ert-deftest test-nov-bookmark-name-real-examples ()
+ "Normal: real Calibre filenames become \"Author, Title\" with colons restored."
+ (should (equal (cj/--nov-bookmark-name-from-file
+ "/books/Frege_ A Guide for the Perplexed - Edward Kanterian.epub")
+ "Edward Kanterian, Frege: A Guide for the Perplexed"))
+ (should (equal (cj/--nov-bookmark-name-from-file
+ "/books/The A.B.C. Murders - Agatha Christie.epub")
+ "Agatha Christie, The A.B.C. Murders"))
+ (should (equal (cj/--nov-bookmark-name-from-file
+ "/books/The Fool's Progress_ An Honest Novel - Edward Abbey.epub")
+ "Edward Abbey, The Fool's Progress: An Honest Novel")))
+
+(ert-deftest test-nov-bookmark-name-splits-on-last-separator ()
+ "Boundary: a title containing \" - \" splits on the LAST separator."
+ (should (equal (cj/--nov-bookmark-name-from-file "/b/Title - Part Two - Some Author.epub")
+ "Some Author, Title - Part Two")))
+
+(ert-deftest test-nov-bookmark-name-no-separator ()
+ "Boundary: a filename with no \" - \" falls back to the cleaned whole name."
+ (should (equal (cj/--nov-bookmark-name-from-file "/b/Untitled_ Draft.epub")
+ "Untitled: Draft")))
+
+(ert-deftest test-nov-bookmark-name-nil-and-empty ()
+ "Error: nil or empty path yields nil."
+ (should-not (cj/--nov-bookmark-name-from-file nil))
+ (should-not (cj/--nov-bookmark-name-from-file "")))
+
+;;; cj/--nov-bookmark-rename-record
+
+(ert-deftest test-nov-bookmark-rename-record-replaces-name ()
+ "Normal: the record's name is rebuilt from its filename; the alist is kept."
+ (let* ((record (cons "The A.B.C. Murders - Agatha Christie.epub"
+ '((filename . "/b/The A.B.C. Murders - Agatha Christie.epub")
+ (index . 0))))
+ (out (cj/--nov-bookmark-rename-record record)))
+ (should (equal (car out) "Agatha Christie, The A.B.C. Murders"))
+ (should (equal (cdr out) (cdr record)))))
+
+(ert-deftest test-nov-bookmark-rename-record-keeps-original-without-filename ()
+ "Boundary: a record with no usable filename is returned unchanged."
+ (let ((record (cons "whatever" '((index . 0)))))
+ (should (equal (cj/--nov-bookmark-rename-record record) record))))
+
+(provide 'test-calibredb-epub-config--bookmark-name)
+;;; test-calibredb-epub-config--bookmark-name.el ends here