diff --git a/README.org b/README.org new file mode 100644 index 0000000..930fad2 --- /dev/null +++ b/README.org @@ -0,0 +1,87 @@ +#+TITLE: elfeed-sync + +The package syncs the read and marked status of entries between [[https://github.com/skeeto/elfeed][elfeed]] and [[https://tt-rss.org/][tt-rss]]. + +Supports [[https://github.com/SqrtMinusOne/elfeed-summary][elfeed-summary]]. + +* Installation +The project consists of the tt-rss plugin and the Emacs package. + +If you are using the [[https://git.tt-rss.org/fox/ttrss-docker-compose.git/tree/README.md][tt-rss docker]] setup, the steps are as follows. Change them accordingly if you are not. +1. Mount the =/var/www/html= directory from the container somewhere to the filesystem as described [[https://git.tt-rss.org/fox/ttrss-docker-compose.wiki.git/tree/Home.md#how-do-i-use-dynamic-image-for-development][here]]. +2. Put the repository to the =tt-rss/plugins.local/elfeed_sync= folder: + #+begin_src bash + cd ./html/tt-rss/plugins.local/ + git clone https://github.com/SqrtMinusOne/elfeed-sync.git elfeed_sync + #+end_src +3. Add =elfeed_sync= to the =TTRSS_PLUGINS= environment variable. + #+begin_src dotenv + TTRSS_PLUGINS=auth_internal, auth_remote, nginx_xaccel, elfeed_sync + #+end_src +4. Allow larger request body sizes in nginx. Add the following to the =server= directive: + #+begin_src conf-space + client_max_body_size 10M; + #+end_src + + For me, the sync payload is around 3M. + +5. Increase the read timeout in nginx. Add the following to the php location directive: + #+begin_src conf-space + fastcgi_read_timeout 600; + #+end_src + + Syncing the entries is usually pretty fast, but the first feed sync takes a while. +6. Then restart tt-rss. Check if the plugin appears in the Preferences > Plugins section. +7. Enable "Allows accessing this account through the API" in the Preferences > Preferences. You also may want to disable "Purge unread articles", because elfeed doesn't do that. + +Install the Emacs package however you normally install packages, I prefer use-package and straight.el. Make sure to enable =elfeed-sync-mode=. +#+begin_src emacs-lisp +(use-package elfeed-sync + :straight (:host github :repo "SqrtMinusOne/elfeed-sync") + :after elfeed + :config + (elfeed-sync-mode)) +#+end_src + +Then set up the following variables: +- =elfeed-sync-tt-rss-instance= - point that to your tt-rss instance, e.g. + #+begin_example + https://example.com/tt-rss + #+end_example + (No trailing slash) +- =elfeed-sync-tt-rss-login= +- =elfeed-sync-tt-rss-password= +- =elfeed-sync-unread-tag= - elfeed tag to map the read status in tt-rss. =unread= by default. +- =elfeed-sync-marked-tag= - elfeed tag to map to the marked status in tt-rss. =later= by default. + +* Usage +** Syncing the feed list +The first thing you probably want to do is to sync the feed list. + +It makes little sense to sync tt-rss feeds to elfeed, because people often use projects like [[https://github.com/remyhonig/elfeed-org][elfeed-org]]. But it's possible to sync elfeed feeds to tt-rss. + +The function =M-x elfeed-sync-feeds= does exactly that. If you have [[https://github.com/SqrtMinusOne/elfeed-summary][elfeed-summary]] installed and tt-rss categories enabled, the function will recreate the elfeed-summary tree in tt-rss. + +The first run of the function takes a while because tt-rss has to fetch the feed at the moment of the first subscription. Which is why increasing the timeout may be necessary. + +However, running the function multiple times until it succeeds should also work. +** Syncing the entry list +To sync the entry list, run =M-x elfeed-sync=. The sync usually takes a couple of seconds. + +The sync finishes at the "Sync complete!" message. Check the =*elfeed-log*= buffer for statistics. + +Occasionally, some entries do not match. Here are the possible cases: +- Entry exists in the elfeed database, but not in tt-rss. + Run =M-x elfeed-sync-search-missing= to display such entries. +- Entry exists in the tt-rss database, but not in elfeed: + - Entry appeared in the feed after the last =elfeed-update=. + Run =M-x elfeed-update= and then =M-x elfeed-sync=. + - Entry appeared and disappeared in the feed after the last =elfeed-update=. + Such an entry will never get to the elfeed database. If you want to, run =M-x elfeed-sync= and then =M-x elfeed-sync-read-ttrss-missing= to mark all such entries as read. +- Entry appeared in the feed before =elfeed-sync-look-back=. + Such an entry will never be matched. This is an inconvenience if you have just set up tt-rss, it fetched old entries from the feeds and such entries remain permanently unread because they are untouched by the =M-x elfeed-sync=. + To mark such entries as read, run =M-x elfeed-sync-read-ttrss-old=. +* Implementation details +The heavy-lifting is done on the elisp side because I ran into strange performance issues with associative arrays in PHP. + +Check the =elfeed-sync--do-sync= function for the description of the synchronization algorithm. The tl;dr is to download all entries from tt-rss and match each entry against the elfeed database. In the case of discrepancy update whichever entry has the lower priority. diff --git a/elfeed-sync.el b/elfeed-sync.el index 3c5232b..1618272 100644 --- a/elfeed-sync.el +++ b/elfeed-sync.el @@ -1,4 +1,4 @@ -;;; elfeed-sync.el --- TODO -*- lexical-binding: t -*- +;;; elfeed-sync.el --- Sync elfeed with tt-rss -*- lexical-binding: t -*- ;; Copyright (C) 2022 Korytov Pavel @@ -24,7 +24,33 @@ ;; along with this program. If not, see . ;;; Commentary: -;; TODO +;; Sync the read and marked status of entries between elfeed and +;; tt-rss. Supports elfeed-summary. +;; +;; The package consists of the tt-rss plugin and the Emacs +;; package. Check the package README at +;; for the tt-rss +;; installation details. +;; +;; As for the Emacs part, you have to set the following variables: +;; - `elfeed-sync-tt-rss-instance' - point that to your tt-rss +;; instance. +;; - `elfeed-sync-tt-rss-login' +;; - `elfeed-sync-tt-rss-password' +;; - `elfeed-sync-tt-rss-unread-tag' +;; - `elfeed-sync-tt-rss-marked-tag' +;; +;; Make sure to enable `elfeed-sync-mode'. +;; +;; The sync period is limited by the `elfeed-sync-look-back' variable. +;; +;; To add the elfeed feeds to tt-rss, run `elfeed-sync-feeds'. If you +;; have `elfeed-summary' installed, and tt-rss categories enabled, the +;; function will recreate the `elfeed-summary' tree in tt-rss. The +;; inverse operation is not supported. +;; +;; To sync the entries, run `elfeed-sync'. Check the function +;; docstring on what to do next. ;;; Code: (require 'elfeed) @@ -37,7 +63,9 @@ :group 'elfeed) (defcustom elfeed-sync-tt-rss-instance "http://localhost:8280/tt-rss" - "URL of the tt-rss instance." + "URL of the tt-rss instance. + +Do not add the trailing slash." :group 'elfeed-sync :type 'string) @@ -57,12 +85,12 @@ :type 'number) (defcustom elfeed-sync-unread-tag 'unread - "Unread tag for sync." + "Unread elfeed tag for sync." :group 'elfeed-sync :type 'symbol) (defcustom elfeed-sync-marked-tag 'later - "Marked tag for sync." + "Marked elfeed tag for sync." :group 'elfeed-sync :type 'symbol) @@ -70,7 +98,16 @@ "Session ID.") (defvar elfeed-sync--state nil - "State of the tt-rss sync.") + "State of the tt-rss sync. + +This is an alist with the following keys: +- `:last-sync-time' - time of the last successful sync. +- `:ids-missing-tt-rss' - tt-rss entries that are missing in elfeed. + This is a hash table, where the key is the tt-rss id and the value + is the cons cell: + - The car is the time on which the entry was last updated. + - The cdr is the time of the sync when the entry was put into the + hash table.") (defvar elfeed-sync--start-time nil "Start time of the tt-rss sync.") @@ -81,19 +118,17 @@ (defvar elfeed-sync--tt-rss-missed nil "List of tt-rss entries missed in elfeed.") -(cl-defstruct (elfeed-sync-datum (:constructor elfeed-sync-datum--create)) - id tags) - (defun elfeed-sync--state-empty () - "Create an empty elfeed-sync state." + "Create an empty `elfeed-sync' state." `((:last-sync-time . nil) (:ids-missing-tt-rss . ,(make-hash-table :test #'equal)))) (defun elfeed-sync--state-file () + "Get the location of the state file." (concat elfeed-db-directory "/sync-state")) (defun elfeed-sync--state-load () - "Load elfeed-sync state from the filesystem." + "Load `elfeed-sync' state from the filesystem." (if (not (file-exists-p (elfeed-sync--state-file))) (setf elfeed-sync--state (elfeed-sync--state-empty)) (with-temp-buffer @@ -111,7 +146,7 @@ (when (null elfeed-sync--state) (elfeed-sync--state-load))) (defun elfeed-sync-reset () - "Reset the elfeed sync state" + "Reset the elfeed sync state." (interactive) (setf elfeed-sync--state (elfeed-sync--state-empty))) @@ -138,6 +173,7 @@ with exceptions." (org-journal-tags-db-save))) (defun elfeed-sync--json-read-safe () + "Read JSON from the current buffer, ignoring errors." (condition-case _ (json-read) (with-current-buffer (get-buffer-create "*elfeed-sync-json-read-error*") @@ -149,8 +185,8 @@ with exceptions." "Run CALLBACK with the active tt-rss session. The `elfeed-sync--tt-rss-sid' variable stores the active session ID. -If it's non-nil, CALLBACK is called outright, otherwise CALLBACK is -called only after a succesful login query." +If it's non-nil, CALLBACK is called outright, otherwise, CALLBACK is +called only after a successful login query." (if elfeed-sync--tt-rss-sid (funcall callback) (request (concat elfeed-sync-tt-rss-instance "/api/") @@ -171,13 +207,17 @@ called only after a succesful login query." (message "Error: %S" error-thrown)))))) (defmacro elfeed-sync--session (&rest body) - "A wrapper around `elfeed-sync--with-session'." + "A wrapper around `elfeed-sync--with-session'. + +Pass BODY to the function callback." `(elfeed-sync--with-session (lambda () ,@body))) (defun elfeed-sync--get-tree-summary (&optional data) - "Get list of feeds and categories from elfeed-summary." + "Get the list of feeds and categories from `elfeed-summary'. + +DATA is the output of `elfeed-summary--get-data'." (unless data (setq data (elfeed-summary--get-data))) (cl-loop for datum in data @@ -201,19 +241,27 @@ called only after a succesful login query." (alist-get 'feed (cdr datum)))))))))) (defun elfeed-sync--get-tree-plain () - "Get list of feeds and categories from plain elfeed." + "Get the list of feeds and categories from plain elfeed." (cl-loop for datum in elfeed-feeds collect `(("feed" . (("tags" . ,(cdr datum)) ("url" . ,(car datum))))))) (defun elfeed-sync--get-tree () - "Get list of feeds and categories." + "Get the list of feeds and categories." (if (fboundp #'elfeed-summary--get-data) (elfeed-sync--get-tree-summary) (elfeed-sync--get-tree-plain))) (defmacro elfeed-sync--handler (&rest body) - "Default response handler for tt-rss." + "Default response handler for tt-rss. + +tt-rss often returns a non-zero status key in the response +object instead of an HTTP code. The handler checks for this. + +Also handles the case where the session ID is invalid, and +resets it. + +Execute BODY with the data variable scoped." `(cl-function (lambda (&key data &allow-other-keys) (if (= (alist-get 'status data) 0) @@ -227,7 +275,18 @@ called only after a succesful login query." (setq elfeed-sync--tt-rss-sid nil)))))) (defun elfeed-sync-feeds () - "Sync feeds with tt-rss." + "Sync feeds with tt-rss. + +If `elfeed-summary' is available, it serves as a source of the +feed tree. Otherwise, the `elfeed-feeds' variable is used. + +The first run of the function takes a while because tt-rss has to +fetch the feed at the moment of the first subscription. It is +recommended to increase the server timeout to at least a couple of +minutes. + +However, running the function multiple times until it succeeds +should also work." (interactive) (elfeed-sync--session (request (concat elfeed-sync-tt-rss-instance "/api/") @@ -245,6 +304,9 @@ called only after a succesful login query." (message "Error: %S" error-thrown)))))) (defun elfeed-sync--get-bad-feeds () + "Get feeds that have URLs repeat for different entries. + +Return a hash table with the feed URL as the key." (let ((feed-hash (make-hash-table :test #'equal)) (bad-feeds-hash (make-hash-table :test #'equal))) (with-elfeed-db-visit (entry feed) @@ -262,17 +324,17 @@ called only after a succesful login query." bad-feeds-hash)) (defun elfeed-sync--entry-unread-p (entry) - "Return non-nil if ENTRY is unread." + "Return t if ENTRY is unread." (not (null (member elfeed-sync-unread-tag (elfeed-entry-tags entry))))) (defun elfeed-sync--entry-marked-p (entry) - "Return non-nil if ENTRY is marked." + "Return t if ENTRY is marked." (not (null (member elfeed-sync-marked-tag (elfeed-entry-tags entry))))) (defun elfeed-sync--set-entry-unread (entry status) "Set the unread status of ENTRY to STATUS. -STATUS is a boolean. If nil, the entry is marked as read. ENTRY +STATUS is a boolean. If nil, the entry is marked as read. ENTRY is an instance of `elfeed-entry'." (let ((is-unread (elfeed-sync--entry-unread-p entry))) (when (and is-unread (not status)) @@ -292,6 +354,11 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (elfeed-tag entry elfeed-sync-marked-tag)))) (defun elfeed-sync--ttrss-key (bad-feeds ttrss-entry) + "Return the key for the TTRSS-ENTRY. + +If the feed is in BAD-FEEDS, the key looks like this: +---. Otherwise, the key is the +entry URL." (let ((feed-url (alist-get 'feed_url ttrss-entry))) (if (gethash feed-url bad-feeds) (format "%s---%s" (alist-get 'title ttrss-entry) @@ -299,12 +366,21 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (alist-get 'link ttrss-entry)))) (defun elfeed-sync--elfeed-key (bad-feeds elfeed-entry feed-url) + "Return the key for the ELFEED-ENTRY. + +If the FEED-URL is in BAD-FEEDS, the key looks like this: +---. Otherwise, the key is the +entry URL." (if (gethash feed-url bad-feeds) (format "%s---%s" (elfeed-entry-title elfeed-entry) (floor (elfeed-entry-date elfeed-entry))) (elfeed-entry-link elfeed-entry))) (defun elfeed-sync--ttrss-get-updated-time (ttrss-entry) + "Return the last updated time of the TTRSS-ENTRY. + +It is the last time when the entry was read or marked. Can also +be nil." (if (and (alist-get 'last_read ttrss-entry) (alist-get 'last_marked ttrss-entry)) (max (alist-get 'last_read ttrss-entry) @@ -313,6 +389,20 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (alist-get 'last_marked ttrss-entry)))) (defun elfeed-sync--ttrss-get-last-sync-time (ttrss-id ttrss-time) + "Get the time when the entry with TTRSS-ID was last synced. + +TTRSS-TIME is the time when the entry was last updated. + +If there is a record in the `:ids-missing-tt-rss' value in the +`elfeed-sync--state', that means that the entry has already been +encountered and not found in the elfeed database. In this case, +return the time of the last sync and the moment when that occurred. + +Otherwise (if the entry has not been seen or has been updated), +return the global last sync time. + +The `elfeed-sync--do-sync' has a more detailed description of why +this is necessary." (if ttrss-time (if-let* ((val (gethash ttrss-id @@ -324,6 +414,19 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (alist-get :last-sync-time elfeed-sync--state))) (defun elfeed-sync--update-ttrss-missing (ttrss-entries ttrss-entries-processed) + "Update the `:ids-missing-tt-rss' value in the `elfeed-sync--state'. + +If an entry from TTRSS-ENTRIES is not in TTRSS-ENTRIES-PROCESSED, it +means that the entry has not been found in elfeed. + +It has to be put to the `:ids-missing-tt-rss' value in the +`elfeed-sync--state' if: +- The entry has the update time set (look + `elfeed-sync--ttrss-get-updated-time') +- The entry does not already appear in the `:ids-missing-tt-rss' or + has been updated since the time it appeared. + +Look at `elfeed-sync--do-sync' for the details." (let (all-missing) (maphash (lambda (ttrss-id ttrss-entry) (unless (gethash ttrss-id ttrss-entries-processed) @@ -336,13 +439,64 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (is-equal (= (car old-val) ttrss-time))) t ;; do nothing (puthash ttrss-id (cons ttrss-time - elfeed-sync--start-time) + (or + (alist-get :last-sync-time + elfeed-sync--state) + elfeed-sync--start-time)) (alist-get :ids-missing-tt-rss elfeed-sync--state)))))) ttrss-entries) all-missing)) (defun elfeed-sync--do-sync (entries bad-feeds) + "Sync the ENTRIES with the elfeed database. + +ENTRIES is a list of entries from tt-rss. BAD-FEEDS is a +hashtable of the feeds that have repeating URLs for +entries (`elfeed-sync--get-bad-feeds'). + +The sync process is as follows. Each entry in the elfeed +database is matched against the tt-rss entry from ENTRIES. Two +entries match if their keys (`elfeed-sync--elfeed-key' and +`elfeed-sync--ttrss-key' respectively) match. + +The only things that are synced are the unread status and the +marked status, tags for which are set in the +`elfeed-sync-unread-tag' and `elfeed-sync-marked-tag'. + +Contrary to elfeed entries, tt-rss entries store the time when +their status has been changed, which is retrieved by the +`elfeed-sync--ttrss-get-updated-time' function. If that time is +larger than the last sync time, it means that the entry has +been updated in between the last and the current syncs and thus +the tt-rss entry has the priority. + +One caveat with the last sync time is that the tt-rss entry does +not necessarily exist in elfeed, i.e. the following scenario has +to be addressed: +- tt-rss fetch, the entry exists in tt-rss but not in elfeed +- the entry is read in tt-rss +- tt-rss and elfeed sync, the entry does not exist in elfeed +- elfeed fetch, the entry is finally added to elfeed +- tt-rss and elfeed sync. +Now the entry is read in tt-rss and unread in elfeed, but technically +it has been updated before the last sync, i.e. before the step 3 in +the list above. + +So, to resolve that, the `:ids-missing-tt-rss' value in the +`elfeed-sync--state' stores the cons cell for each such entry, +where the car is the entry update time and the cdr is the time of +the last sync for when the entry is entry encountered. + +Update of that value is done with the +`elfeed-sync--update-ttrss-missing' function. That function also +returns a list of all tt-rss entries that were missing in elfeed, +updated or not. + +The current function returns an alist of the data, which is then +passed to `elfeed-sync--apply-to-ttrss' to propagate the changes to +tt-rss and `elfeed-sync--process-sync-data' to populate the elfeed +log." (let ((ttrss-entries (make-hash-table :test #'equal)) (ttrss-entries-processed (make-hash-table :test #'equal)) (ttrss-toggle-marked nil) @@ -404,6 +558,7 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (hash-table-count ttrss-entries-processed)))))) (defun elfeed-sync--process-sync-data (sync-data) + "Output SYNC-DATA to the elfeed log." (elfeed-log 'info "Total entries in %s: %s" (propertize "elfeed" 'face 'elfeed-log-info-level-face) (alist-get :elfeed-total-entries sync-data)) @@ -432,6 +587,7 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (message "Sync complete!")) (defun elfeed-sync--apply-to-ttrss (sync-data) + "Propagate changes in SYNC-DATA to tt-rss." (message "Propagating changes to tt-rss...") (elfeed-sync--session (request (concat elfeed-sync-tt-rss-instance "/api/") @@ -451,7 +607,46 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (message "Error: %S" error-thrown)))))) (defun elfeed-sync () + "Sync elfeed and tt-rss. + +The function downloads the tt-rss database and matches it against +the elfeed database. Then it synchronizes the matching entry and +records the unmatched ones. Check the `elfeed-sync--do-sync' function +for details. + +The `elfeed-sync-look-back' sets the number of seconds to look +back in the entries database (6 months by default). + +The function synchronizes the unread status (`elfeed-sync-unread-tag') +and the marked status (`elfeed-sync-marked-tag'). + +The sync depends on a persistent state, so make sure to enable +`elfeed-sync-mode': +\(with-eval-after-load 'elfeed + \(elfeed-sync-mode\)\) + +The sync finishes at the \"Sync complete!\" message. Check the +*elfeed-log* buffer for statistics. + +Occasionally, some entries do not match. Here are the possible cases: +- Entry exists in the elfeed database, but not in tt-rss. + Run `elfeed-sync-search-missing' to display such entries. +- Entry exists in the tt-rss database, but not in elfeed: + - Entry appeared in the feed after the last `elfeed-update'. + Run `elfeed-update' and then `elfeed-sync'. + - Entry appeared and disappeared in the feed after the last + `elfeed-update'. + Such an entry will never get to the elfeed database. If you want + to, run `elfeed-sync' and then `elfeed-sync-read-ttrss-missing' to + mark all such entries as read. +- Entry appeared in the feed before `elfeed-sync-look-back'. + Such an entry will never be matched. This is an inconvenience if you + have just set up tt-rss, it fetched old entries from the feeds and + such entries remain permanently unread because they are untouched by + the `elfeed-sync'. + To mark such entries as read, run `elfeed-sync-read-ttrss-old'." (interactive) + (elfeed-sync--state-ensure) (elfeed-sync--session (setq elfeed-sync--start-time (time-convert nil 'integer)) (elfeed-log 'info "Sync start: %s" (format-time-string "%Y-%m-%d %H:%M:%S")) @@ -478,6 +673,9 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (message "Error: %S" error-thrown))))))) (defun elfeed-sync-search-missing () + "Display elfeed entries that were missing in tt-rss. + +Should be run after `elfeed-sync'." (interactive) (switch-to-buffer (elfeed-search-buffer)) (unless (eq major-mode 'elfeed-search-mode) @@ -500,8 +698,11 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (run-hooks 'elfeed-search-update-hook))) (defun elfeed-sync-read-ttrss-missing () + "Mark tt-rss entries missing in elfeed as read. + +Should be run after `elfeed-sync'." (interactive) - (when (y-or-n-p "This will read all the missing tt-rss entries. Are you sure? ") + (when (y-or-n-p "This will read all the missing tt-rss entries. Are you sure? ") (elfeed-sync--session (request (concat elfeed-sync-tt-rss-instance "/api/") :type "POST" @@ -523,8 +724,9 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (message "Error: %S" error-thrown))))))) (defun elfeed-sync-read-ttrss-old () + "Mark tt-rss entries older than `elfeed-sync-look-back' as read." (interactive) - (when (y-or-n-p "This will read all the old tt-rss entries. Are you sure? ") + (when (y-or-n-p "This will read all the old tt-rss entries. Are you sure? ") (elfeed-sync--session (request (concat elfeed-sync-tt-rss-instance "/api/") :type "POST" @@ -541,14 +743,31 @@ unmarked. ENTRY is an instance of `elfeed-entry'." (cl-function (lambda (&key error-thrown &allow-other-keys) (message "Error: %S" error-thrown))))))) +(defun elfeed-sync--header-around (fun &rest args) + "Advise `elfeed-search--header' to show the sync time. + +FUN and ARGS are passed to `apply'." + (elfeed-sync--state-ensure) + (let ((header (apply fun args)) + (last-sync-time (alist-get :last-sync-time elfeed-sync--state))) + (if last-sync-time + (format "%s, Synced at" + header + (format-time-string + "%Y-%m-%d %H:%M:%S" + (time-convert last-sync-time 'integer))) + header))) + ;;;###autoload - (define-minor-mode elfeed-sync-mode - "TODO" - :global t - (if elfeed-sync-mode - (progn - (add-hook 'kill-emacs-hook #'elfeed-sync-state-save-safe)) - (remove-hook 'kill-emacs-hook #'elfeed-sync-state-save-safe))) +(define-minor-mode elfeed-sync-mode + "Persist the sync state." + :global t + (if elfeed-sync-mode + (progn + (add-hook 'kill-emacs-hook #'elfeed-sync-state-save-safe) + (advice-add #'elfeed-search--header :around #'elfeed-sync--header-around)) + (remove-hook 'kill-emacs-hook #'elfeed-sync-state-save-safe) + (advice-remove #'elfeed-search--header #'elfeed-sync--header-around))) (provide 'elfeed-sync)