;;; pkg-reading.el --- RSS reader, OPDS browser, and ebook tools -*- lexical-binding: t -*- ;; Copyright (C) 2026 Cytrogen ;;; Commentary: ;; This module provides: ;; - elfeed + elfeed-protocol for FreshRSS (Fever API) ;; - Custom OPDS browser for Calibre Content Server ;; - nov.el for EPUB reading ;; - pdf-tools for PDF reading ;; ;; Configuration is stored in ~/.emacs.d/reading-feeds.el ;;; Code: (require 'url) (require 'xml) (require 'dom) ;;; ============================================================ ;;; Configuration Loading ;;; ============================================================ (defvar my/reading-config nil "Reading configuration loaded from external file.") (defvar my/reading-config-file (expand-file-name "reading-feeds.el" user-emacs-directory) "Path to the reading configuration file.") (defun my/reading--load-config () "Load reading configuration from external file." (when (file-exists-p my/reading-config-file) (load-file my/reading-config-file))) ;; Load config immediately (my/reading--load-config) (defun my/reading--get-config (key &optional subkey) "Get configuration value for KEY, optionally SUBKEY." (let ((section (plist-get my/reading-config key))) (if subkey (plist-get section subkey) section))) ;;; ============================================================ ;;; Package Installation ;;; ============================================================ (unless (package-installed-p 'elfeed) (package-refresh-contents) (package-install 'elfeed)) (unless (package-installed-p 'olivetti) (package-install 'olivetti)) (unless (package-installed-p 'elfeed-protocol) (package-install 'elfeed-protocol)) (unless (package-installed-p 'nov) (package-install 'nov)) (unless (package-installed-p 'pdf-tools) (package-install 'pdf-tools)) ;;; ============================================================ ;;; URL Reading Mode ;;; ============================================================ ;; 启用 url-handler-mode,可以直接 C-x C-f 打开 URL (url-handler-mode 1) ;; 配置 olivetti 居中宽度 (setq olivetti-body-width 80) (defun my/url-buffer-p () "Check if current buffer was opened from a URL." (and buffer-file-name (string-match-p "^https?://" buffer-file-name))) (defun my/setup-url-reading-mode () "Setup comfortable reading mode for URL buffers." (when (my/url-buffer-p) (visual-line-mode 1) (olivetti-mode 1) (read-only-mode 1) (message "URL reading mode enabled"))) ;; 打开 URL 文件时自动启用阅读模式 (add-hook 'find-file-hook #'my/setup-url-reading-mode) ;; 手动切换阅读模式的命令 (defun my/toggle-reading-mode () "Toggle reading mode (visual-line + olivetti)." (interactive) (if olivetti-mode (progn (visual-line-mode -1) (olivetti-mode -1) (message "Reading mode disabled")) (visual-line-mode 1) (olivetti-mode 1) (message "Reading mode enabled"))) ;;; ============================================================ ;;; EPUB Reader (nov.el) ;;; ============================================================ (require 'nov) (add-to-list 'auto-mode-alist '("\\.epub\\'" . nov-mode)) (with-eval-after-load 'nov (setq nov-text-width t) (add-hook 'nov-mode-hook 'visual-line-mode) (add-hook 'nov-mode-hook 'olivetti-mode)) ;;; ============================================================ ;;; PDF Reader (pdf-tools) ;;; ============================================================ (pdf-loader-install) (with-eval-after-load 'pdf-tools (setq pdf-view-use-scaling t)) ;;; ============================================================ ;;; RSS Reader (elfeed + FreshRSS) ;;; ============================================================ (require 'elfeed) (require 'elfeed-protocol) (defun my/reading--setup-elfeed () "Setup elfeed with FreshRSS configuration." (my/reading--load-config) (let* ((freshrss (my/reading--get-config :freshrss)) (url (plist-get freshrss :url)) (api-path (plist-get freshrss :api-path)) (user (plist-get freshrss :user)) (password (plist-get freshrss :password))) (when (and url user password) ;; 基础设置 (setq elfeed-use-curl t) (elfeed-set-timeout 36000) (setq elfeed-curl-extra-arguments '("--insecure")) ;; 调试日志 (setq elfeed-protocol-log-trace t) ;; 协议设置 (setq elfeed-protocol-enabled-protocols '(fever)) ;; FreshRSS 兼容性:不提供有效 item ID,需要只更新未读 (setq elfeed-protocol-fever-update-unread-only t) (setq elfeed-protocol-feeds `((,(format "fever+%s://%s@%s" (if (string-prefix-p "https" url) "https" "http") user (replace-regexp-in-string "^https?://" "" url)) :api-url ,(concat url api-path) :password ,password))) ;; 重新启用协议(确保 advice 生效) (when (fboundp 'elfeed-protocol-disable) (elfeed-protocol-disable)) (elfeed-protocol-enable) (message "elfeed configured: %s" (caar elfeed-protocol-feeds))))) ;; 延迟配置 (with-eval-after-load 'elfeed (my/reading--setup-elfeed)) (defun my/elfeed () "Start elfeed and update feeds." (interactive) (my/reading--setup-elfeed) (elfeed) (elfeed-update)) ;;; ============================================================ ;;; elfeed + eww Integration ;;; ============================================================ (defun my/elfeed-show-eww () "View current elfeed article in eww browser." (interactive) (let ((link (elfeed-entry-link elfeed-show-entry))) (when link (eww link)))) (with-eval-after-load 'elfeed-show (define-key elfeed-show-mode-map (kbd "b") #'my/elfeed-show-eww)) (add-hook 'eww-mode-hook #'visual-line-mode) (add-hook 'eww-mode-hook #'olivetti-mode) ;;; ============================================================ ;;; OPDS Browser (Calibre) ;;; ============================================================ (defvar my/opds-current-entries nil "Current OPDS entries being displayed.") (defvar my/opds-history nil "Navigation history for OPDS browser.") (defun my/opds--fetch-url (url &optional callback) "Fetch URL and return parsed XML. If CALLBACK, call it with result." (let* ((opds-config (my/reading--get-config :opds)) (user (plist-get opds-config :user)) (password (plist-get opds-config :password)) (url-request-extra-headers (when (and user password) `(("Authorization" . ,(concat "Basic " (base64-encode-string (format "%s:%s" user password)))))))) (if callback (url-retrieve url (lambda (_status) (goto-char url-http-end-of-headers) (skip-chars-forward "\r\n \t") (set-buffer-multibyte t) (decode-coding-region (point) (point-max) 'utf-8) (let ((xml (libxml-parse-xml-region (point) (point-max)))) (funcall callback xml)))) (with-current-buffer (url-retrieve-synchronously url) (goto-char url-http-end-of-headers) (skip-chars-forward "\r\n \t") (set-buffer-multibyte t) (decode-coding-region (point) (point-max) 'utf-8) (prog1 (libxml-parse-xml-region (point) (point-max)) (kill-buffer)))))) (defvar my/opds--next-page-url nil "URL for the next page of OPDS results.") (defun my/opds--parse-entries (xml) "Parse OPDS XML and return list of entries." ;; 解析下一页链接 (let ((links (dom-by-tag xml 'link))) (setq my/opds--next-page-url (dom-attr (cl-find-if (lambda (link) (string= "next" (dom-attr link 'rel))) links) 'href))) ;; 解析书籍条目 (let ((entries (dom-by-tag xml 'entry))) (mapcar (lambda (entry) (let* ((title (dom-text (dom-by-tag entry 'title))) (author (dom-text (dom-by-tag (dom-by-tag entry 'author) 'name))) (links (dom-by-tag entry 'link)) (acquisition-link (cl-find-if (lambda (link) (let ((rel (dom-attr link 'rel)) (type (dom-attr link 'type))) (or (string-match-p "acquisition" (or rel "")) (string-match-p "epub\\|pdf" (or type ""))))) links)) (nav-link (cl-find-if (lambda (link) (let ((type (dom-attr link 'type))) (string-match-p "atom\\|opds" (or type "")))) links))) `(:title ,title :author ,author :download-url ,(when acquisition-link (dom-attr acquisition-link 'href)) :download-type ,(when acquisition-link (dom-attr acquisition-link 'type)) :nav-url ,(when nav-link (dom-attr nav-link 'href))))) entries))) (defun my/opds--format-entry (entry) "Format ENTRY for display." (let ((title (plist-get entry :title)) (author (plist-get entry :author)) (has-download (plist-get entry :download-url)) (has-nav (plist-get entry :nav-url))) (format "%s%s%s" (or title "Unknown") (if author (format " - %s" author) "") (cond (has-download " [BOOK]") (has-nav " [>]") (t ""))))) (defun my/opds--select-entry (entry) "Handle selection of ENTRY." (let ((download-url (plist-get entry :download-url)) (nav-url (plist-get entry :nav-url))) (cond (download-url (my/opds-download-and-open entry)) (nav-url (push (my/reading--get-config :opds :url) my/opds-history) (my/opds--browse-url nav-url))))) (defun my/opds--browse-url (url) "Browse OPDS catalog at URL." (message "Fetching OPDS catalog...") (let* ((base-url (my/reading--get-config :opds :url)) (full-url (if (string-prefix-p "http" url) url (concat (replace-regexp-in-string "/opds/?$" "" base-url) url)))) (condition-case err (let* ((xml (my/opds--fetch-url full-url)) (entries (my/opds--parse-entries xml))) (setq my/opds-current-entries entries) (let* ((choices (mapcar (lambda (e) (cons (my/opds--format-entry e) e)) entries)) ;; 如果有下一页,添加选项 (choices (if my/opds--next-page-url (append choices '(("▶ 下一页..." . :next-page))) choices)) (selection (completing-read (format "OPDS (%d items%s): " (length entries) (if my/opds--next-page-url " + more" "")) choices nil t))) (when selection (let ((selected (cdr (assoc selection choices)))) (if (eq selected :next-page) (my/opds--browse-url my/opds--next-page-url) (my/opds--select-entry selected)))))) (error (message "OPDS error: %s" (error-message-string err)))))) (defun my/opds-browse () "Browse OPDS catalog." (interactive) (my/reading--load-config) (let ((url (my/reading--get-config :opds :url))) (if url (progn (setq my/opds-history nil) (my/opds--browse-url url)) (message "OPDS URL not configured. Edit reading-feeds.el")))) (defun my/opds-back () "Go back in OPDS navigation history." (interactive) (if my/opds-history (my/opds--browse-url (pop my/opds-history)) (message "No history"))) (defun my/opds-download-and-open (entry) "Download and open ENTRY." (let* ((url (plist-get entry :download-url)) (type (plist-get entry :download-type)) (title (plist-get entry :title)) (download-dir (or (my/reading--get-config :download-dir) "~/Books/")) (ext (cond ((string-match-p "epub" (or type "")) ".epub") ((string-match-p "pdf" (or type "")) ".pdf") (t ".epub"))) (filename (concat (replace-regexp-in-string "[^a-zA-Z0-9]+" "_" (or title "book")) ext)) (filepath (expand-file-name filename download-dir)) (opds-config (my/reading--get-config :opds)) (base-url (plist-get opds-config :url)) (full-url (if (string-prefix-p "http" url) url (concat (replace-regexp-in-string "/opds/?$" "" base-url) url)))) ;; Ensure download directory exists (unless (file-directory-p download-dir) (make-directory download-dir t)) (message "Downloading %s..." title) (let* ((user (plist-get opds-config :user)) (password (plist-get opds-config :password)) (url-request-extra-headers (when (and user password) `(("Authorization" . ,(concat "Basic " (base64-encode-string (format "%s:%s" user password)))))))) (url-copy-file full-url filepath t) (message "Downloaded to %s" filepath) (find-file filepath)))) (defun my/opds-search () "Search OPDS catalog." (interactive) (my/reading--load-config) (let* ((query (read-string "Search books: ")) (base-url (my/reading--get-config :opds :url)) ;; Calibre 搜索格式: /opds/search/关键词 (search-url (format "%s/search/%s" (replace-regexp-in-string "/opds/?$" "/opds" base-url) (url-hexify-string query)))) (my/opds--browse-url search-url))) ;;; ============================================================ ;;; Interactive Commands ;;; ============================================================ (defun my/reading-reload-config () "Reload reading configuration." (interactive) (my/reading--load-config) (when (featurep 'elfeed) (my/reading--setup-elfeed)) (message "Reading configuration reloaded")) ;;; ============================================================ ;;; PDF Tools Enhancement ;;; ============================================================ ;; PDF 页码偏移量 (defvar-local my/pdf-page-offset 0 "当前 PDF 的页码偏移量。书籍页码 = 物理页码 - 偏移量") (defun my/pdf-set-offset () "设置当前 PDF 的页码偏移量。" (interactive) (let* ((current-page (pdf-view-current-page)) (book-page (read-number (format "当前是 PDF 第 %d 页,对应书籍第几页?" current-page))) (offset (- current-page book-page))) (setq my/pdf-page-offset offset) (message "偏移量设为 %d(书籍第1页 = PDF第%d页)" offset (1+ offset)))) (defun my/pdf-goto-page () "跳转到书籍页码(自动应用偏移量)。" (interactive) (let* ((book-page (read-number "跳转到书籍第几页: ")) (pdf-page (+ book-page my/pdf-page-offset))) (pdf-view-goto-page pdf-page))) ;; 绑定快捷键 (with-eval-after-load 'pdf-view (define-key pdf-view-mode-map (kbd "O") 'my/pdf-set-offset) (define-key pdf-view-mode-map (kbd "G") 'my/pdf-goto-page)) (provide 'pkg-reading) ;;; pkg-reading.el ends here