aboutsummaryrefslogtreecommitdiff
path: root/modules/calibredb-epub-config.el
blob: 4243e509a4f296b4bd94af4520716572d99b59ac (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
;;; calibredb-epub-config --- Functionality for Ebook Management and Display -*- lexical-binding: t; coding: utf-8; -*-
;; author Craig Jennings <c@cjennings.net>

;;; Commentary:
;;
;; Layer: 4 (Optional).
;; Category: O/D/P.
;; Load shape: eager.
;; Eager reason: none; optional ebook workflow, a command-loaded deferral
;;   candidate for Phase 4.
;; Top-level side effects: one add-hook, one advice-add, package config.
;; Runtime requires: user-constants, subr-x.
;; Direct test load: yes.
;;
;; This module provides a comprehensive ebook management and reading experience
;; within Emacs, integrating CalibreDB for library management and Nov for EPUB
;; reading.
;;
;; FEATURES:
;; - CalibreDB integration for managing your Calibre ebook library
;; - Nov mode for reading EPUB files with customized typography and layout
;; - Seamless navigation between Nov reading buffers and CalibreDB entries
;; - Image centering in EPUB documents without modifying buffer text
;; - Quick filtering and searching within your ebook library
;;
;; KEY BINDINGS:
;; - M-B: Open CalibreDB library browser
;; - In CalibreDB search mode:
;;   - l: Filter by tag
;;   - L: Clear all filters
;; - In Nov mode:
;;   - z: Open current EPUB in external viewer (zathura)
;;   - C-c C-b: Jump to CalibreDB entry for current book
;;   - m: Set bookmark
;;   - b: List bookmarks
;;
;; WORKFLOW:
;; 1. Press M-B to browse your Calibre library
;; 2. Use filters (l for tags, L to clear) to narrow results
;; 3. Open an EPUB to read it in Nov with optimized typography
;; 4. While reading, use C-c C-b to jump back to the book's metadata
;; 5. Use z to open in external reader when needed
;;
;; CONFIGURATION NOTES:
;; - Prefers EPUB format when available, falls back to PDF
;; - Centers images in EPUB documents using display properties
;; - Applies custom typography with larger fonts for comfortable reading
;; - Uses visual-fill-column for centered text with appropriate margins

;;; Code:

(require 'user-constants)  ;; for books-dir
(require 'subr-x)

;; Declare functions from lazy-loaded packages
(declare-function calibredb-find-create-search-buffer "calibredb" ())
(declare-function calibredb-search-keyword-filter "calibredb" (keyword))
(declare-function cj/open-file-with-command "system-utils" (command))
(declare-function nov-render-document "nov" ())
(defvar nov-text-width)                 ; from nov.el; set buffer-local here

;; -------------------------- CalibreDB Ebook Manager --------------------------

(defun cj/calibredb-clear-filters ()
  "Clear active filters and show all results."
  (interactive)
  (setq calibredb-tag-filter-p nil
		calibredb-favorite-filter-p nil
		calibredb-author-filter-p nil
		calibredb-date-filter-p nil
		calibredb-format-filter-p nil
		calibredb-search-current-page 1)
  ;; empty string resets keyword filter and refreshes listing
  (calibredb-search-keyword-filter ""))

(use-package calibredb
  :commands calibredb
  :bind
  ("M-S-b" . calibredb)  ;; was M-B, overrides backward-word
  ;; use built-in filter by tag, add clear-filters
  (:map calibredb-search-mode-map
		("l" . calibredb-filter-by-tag)
		("L" . cj/calibredb-clear-filters))
  :config
  ;; basic config
  (setq calibredb-root-dir books-dir)
  (setq calibredb-db-dir (expand-file-name "metadata.db" calibredb-root-dir))
  (setq calibredb-program "/usr/bin/calibredb")
  (setq calibredb-preferred-format "epub")
  (setq calibredb-search-page-max-rows 500)

  ;; search window display
  (setq calibredb-size-show nil)
  (setq calibredb-order "asc")
  (setq calibredb-id-width 7)
  (setq calibredb-favorite-icon "🔖")
  (setq calibredb-favorite-keyword "in-progress"))

;; ------------------------------ Nov Epub Reader ------------------------------

(defvar cj/nov-margin-percent 10
  "Percent of the window's natural width used as a margin on each side in epubs.
10 leaves 80% of the columns for text.  Clamped to 0..25, so the text column
runs from 50% (margin 25) to 100% (margin 0) of the window.
Adjust it live with `cj/nov-widen-text' and `cj/nov-narrow-text'.")

(defvar cj/nov-min-text-width 40
  "Minimum text width in columns for Nov reading buffers.")

(defvar cj/nov-margin-step 2
  "Percentage points each `cj/nov-widen-text'/`cj/nov-narrow-text' press changes.")

;; Prevent magic-fallback-mode-alist from opening epub as archive-mode
;; Advise set-auto-mode to force nov-mode for .epub files before magic-fallback runs
(defun cj/force-nov-mode-for-epub (orig-fun &rest args)
  "Force nov-mode for .epub files, bypassing archive-mode detection."
  (if (and buffer-file-name
           (string-match-p "\\.epub\\'" buffer-file-name))
      (progn
        ;; Load nov if not already loaded
        (unless (featurep 'nov)
          (require 'nov nil t))
        ;; Call nov-mode if available, otherwise fallback to default behavior
        (if (fboundp 'nov-mode)
            (nov-mode)
          (apply orig-fun args)))
    (apply orig-fun args)))

(advice-add 'set-auto-mode :around #'cj/force-nov-mode-for-epub)

;; Define helper functions before use-package so they're available for hooks
(defun cj/forward-paragraph-and-center ()
  "Forward one paragraph and center the page."
  (interactive)
  (forward-paragraph)
  (recenter))

(defun cj/nov--text-width (total-cols)
  "Return the Nov text-column width for TOTAL-COLS of usable window width.
`cj/nov-margin-percent' is clamped to 0..25 and taken off each side; the
result is at least `cj/nov-min-text-width'."
  (let* ((margin-percent (max 0 (min 25 cj/nov-margin-percent)))
         (text-width-ratio (- 1.0 (* 2 (/ margin-percent 100.0)))))
    (max cj/nov-min-text-width
         (floor (* text-width-ratio total-cols)))))

(defun cj/nov--natural-window-width (&optional window)
  "Return WINDOW's column count, adding back any display margins already set.
WINDOW defaults to the window showing the current buffer on any frame; 80 if
there is none.  Adding the margins back makes the value stable under repeated
layout passes -- each pass narrows the body width but not the natural width."
  (if-let ((win (or window (get-buffer-window (current-buffer) t))))
      (let ((m (window-margins win)))
        (+ (window-body-width win) (or (car m) 0) (or (cdr m) 0)))
    80))

(defun cj/nov--text-width-for-window (&optional window)
  "Return the preferred EPUB text column count for WINDOW."
  (cj/nov--text-width (cj/nov--natural-window-width window)))

(defun cj/nov-update-layout (&optional _frame)
  "Size the EPUB text column for this buffer and center it in its window.
`nov-text-width' is set so nov's `shr' fills the text to roughly 80% of the
window, and the window's display margins are set to center that block (no
`visual-fill-column' -- its margin-setting wasn't taking effect in nov-mode).
When the width changes the document is re-rendered, restoring the reading
position approximately.  Runs from `window-configuration-change-hook' and is a
command."
  (interactive)
  (when (derived-mode-p 'nov-mode)
    (let* ((win (get-buffer-window (current-buffer) t))
           (total (cj/nov--natural-window-width win))
           (width (cj/nov--text-width total)))
      (unless (eql nov-text-width width)
        (setq-local nov-text-width width)
        (let ((frac (when (> (point-max) (point-min))
                      (/ (float (- (point) (point-min)))
                         (- (point-max) (point-min))))))
          (nov-render-document)
          (when frac
            (goto-char (+ (point-min)
                          (round (* frac (- (point-max) (point-min)))))))))
      (when win
        ;; floor: never let the margins squeeze the text area below WIDTH.
        (let ((margin (max 0 (/ (- total width) 2))))
          (set-window-margins win margin margin))
        ;; Push the fringes out to the window's edge; otherwise they sit between
        ;; the margin and the text and show as thin vertical lines beside it.
        (set-window-fringes win nil nil t)))))

(defun cj/--nov-adjust-margin (delta)
  "Add DELTA to `cj/nov-margin-percent' (clamped 0..25), re-lay-out, and report.
A positive DELTA narrows the text column; a negative DELTA widens it."
  (setq cj/nov-margin-percent
        (max 0 (min 25 (+ cj/nov-margin-percent delta))))
  (cj/nov-update-layout)
  (message "EPUB text width: %d%% of the window (margin %d%% each side)"
           (- 100 (* 2 cj/nov-margin-percent)) cj/nov-margin-percent))

(defun cj/nov-widen-text ()
  "Give the EPUB text column more of the window, up to the full width."
  (interactive)
  (cj/--nov-adjust-margin (- cj/nov-margin-step)))

(defun cj/nov-narrow-text ()
  "Give the EPUB text column less of the window, down to 50%."
  (interactive)
  (cj/--nov-adjust-margin cj/nov-margin-step))

(defun cj/nov-apply-preferences ()
  "Apply preferences after nov-mode has launched."
  (interactive)
  ;; Use Merriweather for comfortable reading with appropriate scaling
  ;; Darker sepia color (#E8DCC0) is easier on the eyes than pure white
  (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0 :foreground "#E8DCC0")
  (face-remap-add-relative 'default :family "Merriweather" :height 180 :foreground "#E8DCC0")
  (face-remap-add-relative 'fixed-pitch :height 180 :foreground "#E8DCC0")
  ;; Enable visual-line-mode for proper text wrapping
  (visual-line-mode 1)
  ;; Set fill-column as a fallback
  (setq-local fill-column 100)
  ;; Have nov's `shr' fill the text to a column count, and `cj/nov-update-layout'
  ;; center it with the window's display margins.  This deliberately does NOT
  ;; use `visual-fill-column' -- its margin-setting wasn't taking effect here.
  (setq-local nov-text-width (cj/nov--text-width-for-window))
  (cj/nov-update-layout)
  ;; Keep the width and centering responsive to splits/resizes.
  (add-hook 'window-configuration-change-hook #'cj/nov-update-layout nil t)
  ;; Drop the centering margins from the window when this EPUB buffer goes away,
  ;; so a later buffer in the same window isn't left indented.
  (add-hook 'kill-buffer-hook
            (lambda ()
              (when-let ((w (get-buffer-window (current-buffer))))
                (set-window-margins w nil)
                (set-window-fringes w nil)))
            nil t)
  ;; Render once now; the window may not exist yet (so this uses the fallback
  ;; width), and `cj/nov-update-layout' on the first window-config change
  ;; re-flows at the real width.
  (nov-render-document))

(defun cj/nov-open-external ()
  "Open the current EPUB with zathura."
  (interactive)
  (cj/open-file-with-command "zathura"))

;; Jump from a Nov buffer to the corresponding CalibreDB entry.
(defun cj/nov--metadata-get (key)
  "Return a metadata value from nov-metadata trying KEY as symbol and string."
  (let* ((v (or (and (boundp 'nov-metadata)
					 (or (alist-get key nov-metadata nil nil #'equal)
						 (alist-get (if (symbolp key) (symbol-name key) key)
									nov-metadata nil nil #'equal)))
				nil)))
	(cond
	 ((and (listp v) (= (length v) 1)) (car v))
	 ((stringp v) v)
	 (t v))))

(defun cj/nov--file-path ()
  "Return the current EPUB file path when in nov-mode, or nil.
Falls back to nov's own `nov-file-name' (set by `nov-mode' from the visited
file) so the function still resolves when `buffer-file-name' has been cleared."
  (when (derived-mode-p 'nov-mode)
	(or buffer-file-name
		(and (boundp 'nov-file-name) nov-file-name))))

(defun cj/nov-jump-to-calibredb ()
  "Open CalibreDB focused on the current EPUB's book entry.
Try to use the Calibre book id from the parent folder name (for example,
\"Title (123)\"). Fall back to a title or author search when no id exists."
  (interactive)
  (require 'calibredb)
  (let* ((file (cj/nov--file-path))
		 (title (or (cj/nov--metadata-get 'title)
					(cj/nov--metadata-get "title")))
		 (author (or (cj/nov--metadata-get 'creator)
					 (cj/nov--metadata-get 'author)
					 (cj/nov--metadata-get "creator")
					 (cj/nov--metadata-get "author")))
		 (id (when file
			   (let* ((parent (file-name-nondirectory
							   (directory-file-name (file-name-directory file)))))
				 (when (string-match " (\\([0-9]+\\))\\'" parent)
				   (match-string 1 parent))))))
	(calibredb)
	(with-current-buffer (calibredb-find-create-search-buffer)
	  (setq calibredb-search-current-page 1)
	  (cond
	   (id
		(calibredb-search-keyword-filter (format "id:%s" id))
		(message "CalibreDB: focused by id:%s" id))
	   ((or title author)
		(let* ((q (string-join
				   (delq nil (list (and title (format "title:\"%s\"" title))
								   (and author (format "authors:\"%s\"" author))))
				   " and ")))
		  (calibredb-search-keyword-filter q)
		  (message "CalibreDB: search %s" (if (string-empty-p q) "<all>" q))))
	   (t
		(calibredb-search-keyword-filter "")
		(message "CalibreDB: no metadata; showing all"))))))

(use-package nov
  :mode
  ("\\.epub\\'" . nov-mode)
  :hook
  (nov-mode . cj/nov-apply-preferences)
  :bind
  (:map nov-mode-map
		("m" . bookmark-set)
		("b" . bookmark-bmenu-list)
		("r" . nov-render-document)
		("l" . recenter-top-bottom)
		("d" . sdcv-search-input)
		("." . cj/forward-paragraph-and-center)
		("<" . nov-history-back)
		(">" . nov-history-forward)
		("," . backward-paragraph)
		;; +/= widen the text column, -/_ narrow it (50%..100% of the window)
		("+" . cj/nov-widen-text)
		("=" . cj/nov-widen-text)
		("-" . cj/nov-narrow-text)
		("_" . cj/nov-narrow-text)
		;; open current EPUB with zathura (same key in pdf-view)
		("z" . cj/nov-open-external)
		("t" . nov-goto-toc)
		("C-c C-b" . cj/nov-jump-to-calibredb)))

(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
negative value can't divide.  When the image is at least as wide as COL-WIDTH
the result is 0 -- no centering is possible."
  (let* ((fw (max 1 font-width-px))
         (img-cols (max 1 (ceiling (/ (float img-px) fw))))
         (pad (/ (- col-width img-cols) 2)))
    (max 0 pad)))

(defun cj/nov-center-images ()
  "Center images in the current Nov buffer without modifying text.

Use `line-prefix' and `wrap-prefix' with a space display property aligned to a
computed column based on the window text area width."
  (let ((inhibit-read-only t))
	;; Clear any prior centering prefixes first (fresh render usually makes this
	;; unnecessary, but it makes the function idempotent).
	(remove-text-properties (point-min) (point-max)
							'(line-prefix nil wrap-prefix nil))
	(save-excursion
	  (goto-char (point-min))
	  ;; Work in the selected window showing this buffer (if any).
	  (when-let* ((win (get-buffer-window (current-buffer) t))
				  (col-width (window-body-width win)) ;; columns
				  (col-px (* col-width (window-font-width win))))
		(while (let ((m (text-property-search-forward
						 'display nil
						 (lambda (_ p) (and (consp p) (eq (car-safe p) 'image))))))
				 (when m
				   (let* ((img (prop-match-value m))
						  (img-px (car (image-size img t)))   ;; pixel width
						  (pad-cols (cj/--nov-image-padding-cols
									 col-width img-px (window-font-width win)))
						  (prefix (propertize " " 'display `(space :align-to ,pad-cols))))
					 (save-excursion
					   (goto-char (prop-match-beginning m))
					   (beginning-of-line)
					   (let ((bol (point))
							 (eol (line-end-position)))
						 (add-text-properties bol eol
											  `(line-prefix ,prefix
															wrap-prefix ,prefix)))))
				   t)))))))

(add-hook 'nov-post-html-render-hook #'cj/nov-center-images)

(provide 'calibredb-epub-config)
;;; calibredb-epub-config.el ends here