;;; pkg-reading.el --- RSS reader, OPDS browser, and ebook tools -*- lexical-binding: t -*-
;; Copyright (C) 2024 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 'elfeed-protocol)
(package-install 'elfeed-protocol))
(unless (package-installed-p 'nov)
(package-install 'nov))
(unless (package-installed-p 'pdf-tools)
(package-install 'pdf-tools))
;;; ============================================================
;;; 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))
;;; ============================================================
;;; 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))
;;; ============================================================
;;; 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))))))
(defun my/opds--parse-entries (xml)
"Parse OPDS XML and return list of entries."
(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))
(selection (completing-read "OPDS: " choices nil t)))
(when selection
(my/opds--select-entry (cdr (assoc selection choices))))))
(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))
(search-url (format "%s/search?query=%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"))
(provide 'pkg-reading)
;;; pkg-reading.el ends here