├── .github └── workflows │ └── melpazoid.yml ├── LICENSE ├── Makefile ├── NEWS.org ├── README.org ├── elfeed-curate.el ├── screenshots ├── elfeed-curate-ann-export.png ├── elfeed-curate-ann-window.png ├── elfeed-curate-export-content.png └── elfeed-curate-search-content.png └── test └── elfeed-curate-tests.el /.github/workflows/melpazoid.yml: -------------------------------------------------------------------------------- 1 | # melpazoid build checks. 2 | 3 | # If your package is on GitHub, enable melpazoid's checks by copying this file 4 | # to .github/workflows/melpazoid.yml and modifying RECIPE and EXIST_OK below. 5 | 6 | name: melpazoid 7 | on: [push, pull_request] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python 3.10 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.10' 18 | - name: Install 19 | run: | 20 | python -m pip install --upgrade pip 21 | sudo apt-get install emacs && emacs --version 22 | git clone https://github.com/riscy/melpazoid.git ~/melpazoid 23 | pip install ~/melpazoid 24 | - name: Run 25 | env: 26 | LOCAL_REPO: ${{ github.workspace }} 27 | # RECIPE is your recipe as written for MELPA: 28 | RECIPE: (elfeed-curate :repo "rnadler/elfeed-curate" :fetcher github) 29 | # set this to false (or remove it) if the package isn't on MELPA: 30 | # EXIST_OK: true 31 | run: echo $GITHUB_REF && make -C ~/melpazoid 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Robert Nadler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .POSIX: 2 | EMACS = emacs 3 | BATCH = $(EMACS) -batch -Q -L . -L test 4 | 5 | EL = elfeed-curate.el 6 | TEST = test/elfeed-curate-tests.el 7 | 8 | # Doom repo(s) 9 | LDFLAGS = -L ~/.emacs.d/.local/straight/repos/elfeed 10 | 11 | compile: $(EL:.el=.elc) $(TEST:.el=.elc) 12 | 13 | check: test 14 | test: $(EL:.el=.elc) $(TEST:.el=.elc) 15 | $(BATCH) $(LDFLAGS) -l $(TEST) -f ert-run-tests-batch 16 | 17 | clean: 18 | rm -f $(EL:.el=.elc) $(TEST:.el=.elc) 19 | 20 | elfeed-curate-tests.elc: elfeed-curate.elc 21 | 22 | .SUFFIXES: .el .elc 23 | 24 | .el.elc: 25 | $(BATCH) $(LDFLAGS) -f batch-byte-compile $< 26 | -------------------------------------------------------------------------------- /NEWS.org: -------------------------------------------------------------------------------- 1 | #+TITLE: Changes 2 | 3 | *** 0.2.1 (2023-11-18) 4 | 5 | - Make tags case insensitive to fix duplicate groups. 6 | 7 | *** 0.2.0 (2023-11-10) 8 | 9 | - Add entry link override functionality (=< text >= annotation). 10 | 11 | *** 0.1.0 (2023-09-22) 12 | 13 | - Initial release 14 | -------------------------------------------------------------------------------- /README.org: -------------------------------------------------------------------------------- 1 | #+TITLE: elfeed-curate.el 2 | #+AUTHOR: Robert Nadler 3 | #+EMAIL: robert.nadler@gmail.com 4 | 5 | [[https://melpa.org/#/elfeed-curate][file:https://melpa.org/packages/elfeed-curate-badge.svg]] [[https://github.com/rnadler/elfeed-curate/actions/workflows/melpazoid.yml/badge.svg]] 6 | 7 | * Description :unfold: 8 | 9 | *elfeed-curate* is an add-on for [[https://github.com/skeeto/elfeed][elfeed]], an RSS reader for 10 | Emacs. 11 | 12 | The purpose of this package is to add the additional functionality needed to 13 | curate RSS feeds. It provides the capability to annotate feed entries and to 14 | publish entries grouped by their subject categories. =elfeed-curate= uses the 15 | available =elfeed= search functionality for marking, tagging, and filtering of 16 | feed entries. The new functions available to support the curation workflow are: 17 | 18 | *** Entry annotation 19 | - =elfeed-curate-edit-entry-annoation= (=a= key) adds the ability to create and 20 | edit annotation text to an entry. Annotations can have org mode formatting and 21 | are saved in the =elfeed= database. This function works in both search and 22 | show mode. 23 | 24 | When an annotation is added a custom tag (=elfeed-curate-annotation-tag=, 25 | default is =ann=) is also added to the entry. This shows which entries have 26 | annotations in the search window and can also be used as a filter (=s +ann=). 27 | *** Entry selection 28 | - =elfeed-curate-toggle-star= (=m= key) is a convenience function that allows 29 | starring entries from the show buffer. See [[https://pragmaticemacs.wordpress.com/2016/09/16/star-and-unstar-articles-in-elfeed/][Star and unstar articles in elfeed]]. 30 | =elfeed-curate-star-tag= (default is =star=) can be used to change the tag 31 | name. 32 | *** Publication (Exporting) 33 | - =elfeed-curate-export-entries= (=x= key) exports entries in the 34 | =*elfeed-search*= buffer to the desired publication format (see 35 | =elfeed-curate-org-export-backend=). The newly generated file is also opened 36 | with the associated application (e.g. a browser for HTML). Exporting directly 37 | to Hugo (Markdown plus Front matter) is also supported. 38 | 39 | The subject categories are based on the entry tag names (see details below). 40 | *** Utilities 41 | - Use =M-x elfeed-curate-reconcile-annotations= to ensure all database entries 42 | have the correct annotation tags. 43 | 44 | * Curation Workflow 45 | My preferred workflow is as follows: 46 | 1. If an entry has *content of interest*, =star= (=m= key) it and add annotation 47 | (=a= key) if desired. 48 | 2. Select all starred entries (=s +star=) and export them (=x= key). This can be 49 | repeated as often as is needed to ensure that the exported content is to your 50 | liking. 51 | 3. Publish exported content. 52 | 4. (optional) Previously published entries can be tracked by tagging all starred 53 | entries with a new date-based publication tag (e.g. =+ pub_DDMMYY=). 54 | 5. Remove all of the star tags (=m= key or =- star=). 55 | 56 | That's it. The Elfeed filtering capabilities can provide many other entry 57 | selection criteria (e.g. time-based), but so far I've found that specifically 58 | tagging selected entries /independent/ of their subject matter to be simple and 59 | efficient. 60 | 61 | This is the elfeed search window showing the starred entries to be exported. The entries with annotations (=ann=) are highlighted. 62 | 63 | [[screenshots/elfeed-curate-search-content.png]] 64 | [[screenshots/elfeed-curate-export-content.png]] 65 | 66 | The exported content (as HTML here) shows how entries are grouped (based on tag name) and some annotation examples. 67 | The same content exported to Hugo is here: [[https://bobonmedicaldevicesoftware.com/coi/posts/21-sep-2023-export/][21-Sep-2023 Content of Interest]]. 68 | 69 | * Prerequisites 70 | 71 | This package only depends on =org= and =elfeed= being installed. 72 | 73 | * Installation 74 | 75 | If you use MELPA, an easy way to install this package is via 76 | =package-install=. Alternatively, download =elfeed-curate.el=, put it in 77 | your =load-path= and =require= it. 78 | 79 | If you use both MELPA and =use-package=, you can use this, too: 80 | 81 | #+begin_src emacs-lisp 82 | (use-package elfeed-curate 83 | :ensure 84 | :after elfeed) 85 | #+end_src 86 | 87 | ** Custom key bindings 88 | 89 | The *annotate* function works in both search and show modes while 90 | the *export* function works only in search mode. The keys in 91 | the =elfeed-search-mode-map= and =elfeed-show-mode-map= maps can 92 | be bound as shown here: 93 | 94 | #+begin_src emacs-lisp 95 | (use-package elfeed-curate 96 | :ensure 97 | :bind (:map elfeed-search-mode-map 98 | ("a" . elfeed-curate-edit-entry-annoation) 99 | ("x" . elfeed-curate-export-entries)) 100 | (:map elfeed-show-mode-map 101 | ("a" . elfeed-curate-edit-entry-annoation) 102 | ("m" . elfeed-curate-toggle-star) 103 | ("q" . kill-buffer-and-window))) 104 | #+end_src 105 | 106 | ** [[https://github.com/doomemacs/doomemacs][Doom]] configuration 107 | 108 | *** =packages.el= 109 | #+begin_src emacs-lisp 110 | ;;... 111 | (package! elfeed-curate) 112 | ;;... 113 | #+end_src 114 | 115 | *** =config.el= 116 | #+begin_src emacs-lisp 117 | (after! elfeed 118 | ;; Your custom Elfeed configuration. 119 | ;; elfeed-curate key bindings: 120 | (define-key elfeed-search-mode-map "a" #'elfeed-curate-edit-entry-annoation) 121 | (define-key elfeed-search-mode-map "x" #'elfeed-curate-export-entries) 122 | (define-key elfeed-search-mode-map "m" #'elfeed-curate-toggle-star) 123 | 124 | (define-key elfeed-show-mode-map "a" #'elfeed-curate-edit-entry-annoation) 125 | (define-key elfeed-show-mode-map "m" #'elfeed-curate-toggle-star) 126 | (define-key elfeed-show-mode-map "q" #'kill-buffer-and-window)) 127 | #+end_src 128 | I had issues closing the show window after the annotation buffer was displayed 129 | there. Not sure what the root cause was (is), but overriding the =q= key with 130 | =kill-buffer-and-window= seems to have solved the problem. This needs more 131 | investigation. 132 | 133 | 134 | ** Disclaimers 135 | 136 | - I have only tested this with Emacs 29.1, both bare-bones and with Doom. The 137 | code is compatible back to Emacs 25.1 and Org/Elfeed are the only 138 | dependencies, so there's a good chance this will work out of the box on most 139 | modern systems. 140 | - Testing of the export backends has been limited to mostly HTML and Markdown. 141 | 142 | * Usage 143 | 144 | ** Annotation Window 145 | 146 | Annotation edit window: 147 | [[screenshots/elfeed-curate-ann-window.png]] 148 | 149 | Exported annotation: 150 | 151 | [[screenshots/elfeed-curate-ann-export.png]] 152 | 153 | The =a= key (=elfeed-curate-edit-entry-annoation=) will display an org-mode 154 | buffer for managing annotation content. Annotation can be added, edited, and 155 | deleted for an entry from both the elfeed search and show windows. The 156 | annotation tag (=ann=) will be added or removed automatically. 157 | 158 | Most org-mode formatting will be exported properly, but may differ depending on 159 | the export format. 160 | 161 | Surrounding annotation text with angle brackets =< text >= allows you to 162 | override the original entry link and author(s). Everything outside of the angle 163 | brackets will be ignored. This is handy for adding an arbitrary link that is not 164 | currently in your feed list. The entry tags remain unchanged so you can decide 165 | which group(s) the entry should be in. A link override example would typically 166 | look like this: 167 | 168 | <[[http://link_url][Link Name]] (Author Name) =Interesting stuff.=> 169 | 170 | The following key combinations are used to exit the annotation buffer: 171 | 172 | | Keys | Action | Notes | 173 | |-----------+--------+---------------------------------------------------------------------------------------------| 174 | | =C-c C-c= | Save | Saves the annotation content. If the annotation buffer is empty, the annotation is removed. | 175 | | =C-c C-d= | Delete | Delete the annotation content. | 176 | | =C-c C-k= | Abort | Exit the annotation buffer without saving changes. | 177 | 178 | ** Export Behavior 179 | 180 | The =x= key (=elfeed-curate-export-entries=) takes the following actions: 181 | 182 | 1. All displayed or selected search entries are grouped based on their *subject matter* tagging. 183 | * Tags are converted to group headings by replacing =_= characters with a 184 | space and capitalizing all words. E.g. the =med_dev= tag becomes "Med Dev". 185 | * Tags to be excluded from the subject categories are specified in 186 | =elfeed-curate-group-exclude-tag-list=. Non-subject group tags should be 187 | added to this list. 188 | * If one or more authors are available from the feed, they will be listed 189 | next to the link in parentheses: (Author 1, Author 2, ...). 190 | * An entry will only be displayed in one group. If the entry is in multiple 191 | groups, the other groups will be shown in bold brackets (*[Group 2, Group 192 | 3,...]*) next to the exported link. 193 | * Use =elfeed-curate-no-group-tag= to determine how entries that do not 194 | belong to any group are treated. I.e. there are no tags left after removing 195 | the excluded list tags (above). By default, they are added to the "No 196 | Category" group. Set to nil to not display these entries. 197 | * By default, the count of each group is included in the group heading. If a 198 | prefix argument is used before the export (=C-u x=), the count will not be 199 | shown. The count can be permanently removed by setting 200 | =elfeed-curate-show-group-count= to =nil=. 201 | 2. The grouped content is exported to an =org= file (*export.org* in the 202 | =elfeed-curate-export-dir= directory). 203 | * Use =elfeed-curate-org-options= to specify custom org file options. 204 | * The =elfeed-curate-org-content-header-function= can be used to customize 205 | all org file header content. 206 | 3. The =export.org= file is then converted to the desired export format 207 | specified by =elfeed-curate-org-export-backend=. A date-stamped export file 208 | with the selected backend extension (=.md=, =.html=, etc.) is created. 209 | 4. The exported content is then displayed. 210 | * If the format is Markdown (=md=) and =elfeed-curate-hugo-base-dir= is 211 | specified the exported date-stamped Markdown file is written to the specified content 212 | section (=elfeed-curate-hugo-section=). The Hugo development server will 213 | automatically detect the change and display the new content. 214 | * In all other cases, the exported content will attempt to be displayed via 215 | =elfeed-curate--open-in-external-app= (=xdg-open= in most cases). 216 | 217 | * Customization 218 | 219 | Here are the variables that can be customized: 220 | 221 | | Variable | Default | Desc. | 222 | |---------------------------------------------+----------------------------------------------------------------------------+---------------------------------------------------------------------------------------------------------------------------------------------------------------| 223 | | =elfeed-curate-title-length= | 60 | Maximum length of the entry title to show in the annotation edit buffer. | 224 | | =elfeed-curate-annotation-key= | :my/annotation | Elfeed database meta data key to store annotations. | 225 | | =elfeed-curate-annotation-tag= | 'ann | Tag used to indicate that annotation has been added to an entry. | 226 | | =elfeed-curate-star-tag= | 'star | Tag used to indicate that annotation has been `starred`. | 227 | | =elfeed-curate-no-group-tag= | 'no_category | Tag used to indicate that an entry has no group tag. The entry will be added to this group in the export. Set to nil to not display these entries. | 228 | | =elfeed-curate-org-content-header-function= | #'elfeed-curate-org-content-header--default | Function used to create the header (options and title) content. The default is for HTML output. | 229 | | =elfeed-curate-org-title= | Content of Note | The TITLE part of the ' ' format. See the =elfeed-curate-org-content-header--default= function. | 230 | | =elfeed-curate-date-format= | "%d-%b-%Y" | The date format used in the title. | 231 | | =elfeed-curate-org-options= | #html-style:nil toc:nil num:nil f:nil html-postamble:nil html-preamble:nil | Set org document format options. Default is for an HTML export: no styles, TOC, section numbering, footer. | 232 | | =elfeed-curate-export-dir= | ~/org | Write the .org file and exported content to this directory. | 233 | | =elfeed-curate-org-export-backend= | 'html | Select export format. Can be one of: | 234 | | | | =ascii= - Export to plain ASCII text. | 235 | | | | =html= - Export to HTML. | 236 | | | | =md= - Export to Markdown. | 237 | | | | =odt= - Export to OpenDocument Text. | 238 | | | | =pdf= - Export to PDF (requires additional setup). | 239 | | =elfeed-curate-group-exclude-tag-list= | (list 'unread elfeed-curate-star-tag elfeed-curate-annotation-tag) | List of tags to exclude from the group list. These are typically non-subject categories. | 240 | | =elfeed-curate-show-group-count= | t | Flag to enable showing the count of each group in the exported output. If a prefix argument is used before the export (=C-u x=), the count will not be shown. | 241 | | =elfeed-curate-hugo-base-dir= | nil | Base directory of the Hugo project. Used for 'md exports. | 242 | | =elfeed-curate-hugo-section= | "posts" | Hugo section name. Posts will be written to elfeed-curate-hugo-base-dir/content/<section>. | 243 | -------------------------------------------------------------------------------- /elfeed-curate.el: -------------------------------------------------------------------------------- 1 | ;;; elfeed-curate.el --- Elfeed entry curation -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023 Robert Nadler <robert.nadler@gmail.com> 4 | 5 | ;; Author: Robert Nadler <robert.nadler@gmail.com> 6 | ;; Version: 0.2.1 7 | ;; Package-Requires: ((emacs "25.1") (elfeed "3.4.1")) 8 | ;; Keywords: news 9 | ;; URL: https://github.com/rnadler/elfeed-curate 10 | 11 | ;; The MIT License (MIT) 12 | 13 | ;; Permission is hereby granted, free of charge, to any person obtaining 14 | ;; a copy of this software and associated documentation files (the 15 | ;; "Software"), to deal in the Software without restriction, including 16 | ;; without limitation the rights to use, copy, modify, merge, publish, 17 | ;; distribute, sublicense, and/or sell copies of the Software, and to 18 | ;; permit persons to whom the Software is furnished to do so, subject to 19 | ;; the following conditions: 20 | 21 | ;; The above copyright notice and this permission notice shall be 22 | ;; included in all copies or substantial portions of the Software. 23 | 24 | ;; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 25 | ;; EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 26 | ;; MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 27 | ;; IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 28 | ;; CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 29 | ;; TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 30 | ;; SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 31 | 32 | ;;; Commentary: 33 | 34 | ;; `elfeed-curate' is an add-on for `elfeed', an RSS reader for 35 | ;; Emacs. This package allows you to annotate and publish curated RSS 36 | ;; feed entries. 37 | ;; 38 | ;; See https://github.com/rnadler/elfeed-curate for usage details. 39 | 40 | (require 'cl-lib) 41 | (require 'elfeed) 42 | (require 'org) 43 | 44 | ;;; Code: 45 | 46 | (defgroup elfeed-curate () 47 | "Curate Elfeed content." 48 | :group 'comm) 49 | 50 | ;;; Customizations: 51 | 52 | (defcustom elfeed-curate-title-length 60 53 | "Maximum length of the entry title to show in the annotation edit buffer." 54 | :group 'elfeed-curate 55 | :type 'integer) 56 | 57 | (defcustom elfeed-curate-annotation-key :my/annotation 58 | "Elfeed database meta data key to store annotations." 59 | :group 'elfeed-curate 60 | :type 'symbol) 61 | 62 | (defcustom elfeed-curate-annotation-tag 'ann 63 | "Tag used to indicate that annotation has been added to an entry." 64 | :group 'elfeed-curate 65 | :type 'symbol) 66 | 67 | (defcustom elfeed-curate-star-tag 'star 68 | "Tag used to indicate that annotation has been `starred`." 69 | :group 'elfeed-curate 70 | :type 'symbol) 71 | 72 | (defcustom elfeed-curate-no-group-tag 'no_category 73 | "Tag used to indicate that an entry has no group tag. 74 | The entry will be added to this group in the export. 75 | Set to nil to not display these entries." 76 | :group 'elfeed-curate 77 | :type 'symbol) 78 | 79 | (defcustom elfeed-curate-org-content-header-function #'elfeed-curate-org-content-header--default 80 | "Function used to create the header (options and title) content. 81 | The default is for HTML output." 82 | :group 'elfeed-curate 83 | :type 'function) 84 | 85 | (defcustom elfeed-curate-org-title "Content of Interest" 86 | "The TITLE part of the 'DATE TITLE' format. 87 | See the `elfeed-curate-org-content-header--default` function." 88 | :group 'elfeed-curate 89 | :type 'string) 90 | 91 | (defcustom elfeed-curate-date-format "%d-%b-%Y" 92 | "The date format used in the title." 93 | :group 'elfeed-curate 94 | :type 'string) 95 | 96 | (defcustom elfeed-curate-org-options "html-style:nil toc:nil num:nil f:nil html-postamble:nil html-preamble:nil" 97 | "Set format options. 98 | Default is for an HTML export: no styles, section numbering, footer." 99 | :group 'elfeed-curate 100 | :type 'string) 101 | 102 | (defcustom elfeed-curate-export-dir "~/org" 103 | "Export the org and exported (e.g. html) content to this directory." 104 | :group 'elfeed-curate 105 | :type 'directory) 106 | 107 | (defcustom elfeed-curate-show-group-count t 108 | "Flag to enable showing the count of each group in the exported output. 109 | If a prefix argument is used before the export (`C-u x`), 110 | the count will not be shown." 111 | :group 'elfeed-curate 112 | :type 'boolean) 113 | 114 | (defcustom elfeed-curate-org-export-backend 'html 115 | "Select export format. Can be one of: 116 | ascii - Export to plain ASCII text. 117 | html - Export to HTML. 118 | latex - Export to LaTeX. 119 | md - Export to Markdown. 120 | odt - Export to OpenDocument Text. 121 | pdf - Export to PDF (requires additional setup)." 122 | :group 'elfeed-curate 123 | :type '(choice (const ascii) (const html) (const latex) (const md) (const odt) (const pdf))) 124 | 125 | (defcustom elfeed-curate-group-exclude-tag-list (list 'unread elfeed-curate-star-tag elfeed-curate-annotation-tag) 126 | "List of tags to exclude from the group list. 127 | These are typically non-subject categories." 128 | :group 'elfeed-curate 129 | :type '(repeat symbol)) 130 | 131 | (defcustom elfeed-curate-hugo-base-dir nil 132 | "Base directory of the Hugo project. Used for Markdown exports." 133 | :group 'elfeed-curate 134 | :type 'directory) 135 | 136 | (defcustom elfeed-curate-hugo-section "posts" 137 | "Hugo section name. 138 | Posts will be written to elfeed-curate-hugo-base-dir/content/<section>." 139 | :group 'elfeed-curate 140 | :type 'string) 141 | 142 | ;;; Variables: 143 | 144 | (defvar elfeed-curate-exit-keys "C-c C-c" 145 | "Save the content from the recursive edit buffer to an entry annotation.") 146 | 147 | (defvar elfeed-curate-delete-keys "C-c C-d" 148 | "Delete the content from the recursive edit buffer and abort the edit session.") 149 | 150 | (defvar elfeed-curate-abort-keys "C-c C-k" 151 | "Abort the recursive edit session without saving the annotation.") 152 | 153 | (defvar elfeed-curate-org-file-name "export.org" 154 | "Generated org file name.") 155 | 156 | (defvar elfeed-curate-capture-buffer-name "*elfeed-curate-annotation*" 157 | "Annotation capture buffer name.") 158 | 159 | ;;; Functions: 160 | 161 | (defun elfeed-curate-plist-keys (plist) 162 | "Return a list of keys from the given property list PLIST." 163 | (let (keys) 164 | (while plist 165 | (push (car plist) keys) 166 | (setq plist (cddr plist))) 167 | (nreverse keys))) 168 | 169 | (defun elfeed-curate-truncate-string (string limit) 170 | "Truncate a STRING to a given LIMIT." 171 | (if (< (length string) limit) 172 | string 173 | (substring string 0 limit))) 174 | 175 | (defun elfeed-curate-export-file-extension () 176 | "Extension of the exported file." 177 | (symbol-name elfeed-curate-org-export-backend)) 178 | 179 | (defun elfeed-curate--org-file-path () 180 | "File path for the generated org file." 181 | (concat (file-name-as-directory elfeed-curate-export-dir) elfeed-curate-org-file-name)) 182 | 183 | (defun elfeed-curate-current-date-string () 184 | "The current date string." 185 | (format-time-string elfeed-curate-date-format (current-time))) 186 | 187 | (defun elfeed-curate--is-hugo? () 188 | "Processing a Hugo md file." 189 | (and (equal elfeed-curate-org-export-backend 'md) 190 | elfeed-curate-hugo-base-dir)) 191 | 192 | (defun elfeed-curate--export-path () 193 | "Export path based on export type and hugo settings." 194 | (let ((path (if (elfeed-curate--is-hugo?) 195 | (format "%scontent/%s" 196 | (file-name-as-directory elfeed-curate-hugo-base-dir) 197 | elfeed-curate-hugo-section) 198 | elfeed-curate-export-dir))) 199 | (file-name-as-directory path))) 200 | 201 | (defun elfeed-curate-export-file-name () 202 | "Exported file name." 203 | (format "%s%s-export.%s" 204 | (elfeed-curate--export-path) 205 | (elfeed-curate-current-date-string) (elfeed-curate-export-file-extension))) 206 | 207 | (defun elfeed-curate--hugo-toml-headers (title) 208 | "Simple toml headers for hugo settings with TITLE." 209 | (if (null elfeed-curate-hugo-base-dir) 210 | "" 211 | (format "+++ 212 | title = '%s %s' 213 | date = '%s' 214 | draft = false 215 | +++\n" 216 | (elfeed-curate-current-date-string) title 217 | (format-time-string "%FT%T%z" (current-time))))) 218 | 219 | (defun elfeed-curate--hugo-post-process (file) 220 | "Add Hugo toml header to md FILE." 221 | (when (elfeed-curate--is-hugo?) 222 | (with-temp-buffer 223 | (insert-file-contents file) 224 | (goto-char (point-min)) 225 | (insert (elfeed-curate--hugo-toml-headers elfeed-curate-org-title)) 226 | (write-file file))) 227 | file) 228 | 229 | (defun elfeed-curate-org-content-header--default (title) 230 | "Get the default header (options and TITLE) content." 231 | (format "#+OPTIONS: %s 232 | #+TITLE: %s %s\n" 233 | elfeed-curate-org-options 234 | (elfeed-curate-current-date-string) title)) 235 | 236 | (defun elfeed-curate-concat-authors (entry) 237 | "Return a string of all authors concatenated for the given ENTRY." 238 | (let ((authors (elfeed-meta entry :authors))) 239 | (mapconcat 240 | (lambda (author) (plist-get author :name)) authors ", "))) 241 | 242 | (defun elfeed-curate-normalize-one-tag (tag) 243 | "Normalize one TAG." 244 | (intern (downcase (symbol-name tag)))) 245 | 246 | (defun elfeed-curate-normalize-tags (tags) 247 | "Return the TAGS list without semantic duplicates." 248 | (delete-dups (mapcar (lambda (tag) (elfeed-curate-normalize-one-tag tag)) tags))) 249 | 250 | (defun elfeed-curate-entry-tags (entry) 251 | "Get normalized tags for ENTRY." 252 | (elfeed-curate-normalize-tags (elfeed-entry-tags entry))) 253 | 254 | (defun elfeed-curate-exclude-list() 255 | "Get normalized exclude list tags." 256 | (elfeed-curate-normalize-tags elfeed-curate-group-exclude-tag-list)) 257 | 258 | (defun elfeed-curate-concat-other-groups (entry group) 259 | "Return a string of all other groups (not GROUP) 260 | concatenated for the given ENTRY." 261 | (let* ((tags (elfeed-curate-entry-tags entry)) 262 | (tags (delq (elfeed-curate-normalize-one-tag group) tags)) 263 | (tags (cl-remove-if (lambda (tag) (memq tag (elfeed-curate-exclude-list))) tags))) 264 | (mapconcat 265 | (lambda (tag) (elfeed-curate-tag-to-group-name tag)) tags ", "))) 266 | 267 | (defun elfeed-curate-get-entry-annotation (entry) 268 | "Get annotation from an ENTRY." 269 | (let ((annotation (elfeed-meta entry elfeed-curate-annotation-key))) 270 | (if annotation annotation ""))) 271 | 272 | (defun elfeed-curate--show-entry (msg entry tag) 273 | "DEBUG: Show an ENTRY with MSG. 274 | Add a hook to either `elfeed-tag-hooks` or `elfeed-untag-hooks`" 275 | (let ((title (if (null entry) "?" (elfeed-entry-title entry))) 276 | (tags (if (null entry) "?" (elfeed-entry-tags entry)))) 277 | (message "%s %s: %s tags: %s" msg tag title tags))) 278 | 279 | (defun elfeed-curate--update-tag (entry tag add-tag) 280 | "Update the TAG on an ENTRY. ADD-TAG determine whether to tag or untag." 281 | (let ((tag-func (if add-tag 'elfeed-tag 'elfeed-untag))) 282 | (funcall tag-func entry tag) 283 | (save-excursion 284 | (with-current-buffer (elfeed-search-buffer) 285 | (elfeed-search-update-entry entry))))) 286 | 287 | (defun elfeed-curate-set-entry-annotation (entry annotation) 288 | "Set ANNOTATION on an ENTRY." 289 | (let ((txt (if (= (length annotation) 0) nil annotation))) 290 | ;;(elfeed-meta--put entry elfeed-curate-annotation-key txt) 291 | (setf (elfeed-entry-meta entry) 292 | (plist-put (elfeed-entry-meta entry) elfeed-curate-annotation-key txt)) 293 | (elfeed-curate--update-tag entry elfeed-curate-annotation-tag txt))) 294 | 295 | (defun elfeed-curate-add-org-entry (entry group) 296 | "Add an elfeed ENTRY in GROUP to the org buffer." 297 | (let* ((annotation (elfeed-curate-get-entry-annotation entry)) 298 | (author-list (elfeed-curate-concat-authors entry)) 299 | (authors-str (if (= (length author-list) 0) "" (concat " (" author-list ")"))) 300 | (other-groups (elfeed-curate-concat-other-groups entry group)) 301 | (groups-str (if (= (length other-groups) 0) "" (concat " **[" other-groups "]**")))) 302 | (if (string-match "<\\(.*\\)>" annotation) 303 | (insert (format "- %s%s\n" (match-string 1 annotation) groups-str)) 304 | (progn 305 | (insert (format "- [[%s][%s]]%s%s\n" 306 | (elfeed-entry-link entry) 307 | (elfeed-entry-title entry) 308 | authors-str groups-str)) 309 | (when (> (length annotation) 0) 310 | ; Try to keep annotation content under the entry link. 311 | (insert (format " %s\n" 312 | (replace-regexp-in-string "\n" "\n " annotation)))))))) 313 | 314 | (defun elfeed-curate-tag-to-group-name (tag) 315 | "Convert TAG to a human readable title string. 316 | Split on '_' and capitalize each word. e.g. tag_name `-->' Tag Name" 317 | (capitalize (replace-regexp-in-string "_" " " (format "%s" tag)))) 318 | 319 | (defun elfeed-curate-add-org-group (group entries show-group-count) 320 | "Add a GROUP of elfeed ENTRIES to the org buffer. 321 | Show the group count if SHOW-GROUP-COUNT is not nil." 322 | (let ((count-str (if show-group-count 323 | (format " (%d)" (length entries)) ""))) 324 | (insert (format "* %s%s\n" (elfeed-curate-tag-to-group-name group) count-str))) 325 | (mapc (lambda (entry) (elfeed-curate-add-org-entry entry group)) entries)) 326 | 327 | (defmacro elfeed-curate--add-entry-to-group (groups entry tag) 328 | "Add an ENTRY to the GROUPS plist with the group TAG." 329 | `(progn 330 | (when (not (plist-member ,groups ,tag)) 331 | (setq ,groups (plist-put ,groups ,tag ()))) 332 | (push ,entry (plist-get ,groups ,tag)))) 333 | 334 | (defun elfeed-curate--find-no-group-entries () 335 | "Utility to find all entries that are not part of a group." 336 | (interactive) 337 | (let ((entry-list ())) 338 | (with-elfeed-db-visit (entry _) 339 | (let ((tags (elfeed-curate-entry-tags entry)) 340 | (pushed)) 341 | (cl-dolist (tag tags) 342 | (when (not (memq tag (elfeed-curate-exclude-list))) 343 | (progn 344 | (setq pushed t) 345 | (cl-return)))) 346 | (when (not pushed) 347 | (push entry entry-list)))) 348 | (message "%d entries not in a group." (length entry-list)))) 349 | 350 | (defun elfeed-curate-group-org-entries (entries) 351 | "Create a plist of grouped ENTRIES." 352 | (let (groups) 353 | (dolist (entry entries) 354 | (let ((tags (elfeed-curate-entry-tags entry)) 355 | (pushed)) 356 | (cl-dolist (tag tags) 357 | (when (not (memq tag (elfeed-curate-exclude-list))) 358 | (progn 359 | (elfeed-curate--add-entry-to-group groups entry tag) 360 | (setq pushed t) 361 | (cl-return)))) ; An entry is only added to one group 362 | (when (and (not pushed) elfeed-curate-no-group-tag) 363 | (elfeed-curate--add-entry-to-group groups entry elfeed-curate-no-group-tag)))) 364 | groups)) 365 | 366 | (defun elfeed-curate--group-entries-count (groups) 367 | "Count total entries in all GROUPS." 368 | (apply #'+ (mapcar (lambda (key) (length (plist-get groups key))) 369 | (elfeed-curate-plist-keys groups)))) 370 | 371 | (defun elfeed-curate--annotation-keymap () 372 | "Create a keymap for the annotation buffer." 373 | (let ((km (make-sparse-keymap))) 374 | (define-key km (kbd elfeed-curate-exit-keys) 'exit-recursive-edit) 375 | (define-key km (kbd elfeed-curate-abort-keys) 'abort-recursive-edit) 376 | (define-key km (kbd elfeed-curate-delete-keys) 377 | (lambda () 378 | (interactive) 379 | (erase-buffer) 380 | (exit-recursive-edit))) 381 | 382 | km)) 383 | 384 | (defmacro elfeed-curate--key-emphasis (keys) 385 | "Propertize the given KEYS with emphasis." 386 | `'(:eval (propertize ,keys 'face 'mode-line-emphasis))) 387 | 388 | (defun elfeed-curate-edit-annotation (title default-string) 389 | "Edit annotation string for the group TITLE with the DEFAULT-STRING. 390 | Returns the annotation buffer content." 391 | (with-temp-buffer 392 | (org-mode) 393 | (setq buffer-read-only nil) 394 | (setq mode-line-format nil) 395 | (outline-show-all) 396 | (rename-buffer elfeed-curate-capture-buffer-name t) 397 | (insert default-string) 398 | (goto-char (point-max)) 399 | (let ((title-str (propertize (concat " '" (elfeed-curate-truncate-string title elfeed-curate-title-length) "'") 400 | 'face 'mode-line-buffer-id))) 401 | (setq header-line-format 402 | (list 403 | (elfeed-curate--key-emphasis "Annotate") 404 | title-str 405 | " --> Save: '" (elfeed-curate--key-emphasis elfeed-curate-exit-keys) 406 | "', Delete: '" (elfeed-curate--key-emphasis elfeed-curate-delete-keys) 407 | "', Abort: '" (elfeed-curate--key-emphasis elfeed-curate-abort-keys) 408 | "'"))) 409 | (switch-to-buffer (current-buffer)) 410 | (use-local-map (elfeed-curate--annotation-keymap)) 411 | (font-lock-mode) 412 | (recursive-edit) 413 | (buffer-substring-no-properties (point-min) (point-max)))) 414 | 415 | (defun elfeed-curate--get-entry () 416 | "Gets the current entry from either the search or show buffer." 417 | (let ((is-search (string-equal (buffer-name) (buffer-name (elfeed-search-buffer))))) 418 | (if is-search (elfeed-search-selected :single) elfeed-show-entry))) 419 | 420 | (defun elfeed-curate--open-in-external-app (fname) 421 | "Open FNAME in external app. 422 | Simplified version of: <http://xahlee.info/emacs/emacs/emacs_dired_open_file_in_ext_apps.html>" 423 | (interactive) 424 | (let ((file-list (list (expand-file-name fname)))) 425 | (cond 426 | ((string-equal system-type "windows-nt") 427 | (let ((out-buf (get-buffer-create "*elfeed-curate open in external app*")) 428 | (cmd-list (list "PowerShell" "-Command" "Invoke-Item" "-LiteralPath"))) 429 | (mapc 430 | (lambda (x) 431 | (message "%s" x) 432 | (apply #'start-process (append (list "xah open in external app" out-buf) cmd-list 433 | (list (format "'%s'" (if (string-match "'" x) (replace-match "`'" t t x) x))) nil))) 434 | file-list))) 435 | ((string-equal system-type "darwin") 436 | (mapc (lambda (file-path) (shell-command (concat "open " (shell-quote-argument file-path)))) file-list)) 437 | ((string-equal system-type "gnu/linux") 438 | (mapc (lambda (file-path) 439 | (call-process shell-file-name nil 0 nil 440 | shell-command-switch 441 | (format "%s %s" "xdg-open" (shell-quote-argument file-path)))) file-list)) 442 | ((string-equal system-type "berkeley-unix") 443 | (mapc (lambda (file-path) (let ((process-connection-type nil)) (start-process "" nil "xdg-open" file-path))) file-list))))) 444 | 445 | ;;;###autoload 446 | (defun elfeed-curate-reconcile-annotations () 447 | "Ensure all database entries have the correct annotation tags. 448 | 1. Add the specified annotation tag if annotation exists. 449 | 2. Remove annotation tag if annotation does not exist." 450 | (interactive) 451 | (let ((add-count 0) 452 | (remove-count 0) 453 | (total-count 0) 454 | (ann-count 0)) 455 | (with-elfeed-db-visit (entry _) 456 | (let ((has-ann (/= (length (elfeed-curate-get-entry-annotation entry)) 0)) 457 | (has-tag (elfeed-tagged-p elfeed-curate-annotation-tag entry))) 458 | (cl-incf total-count) 459 | (cond 460 | ((and has-ann has-tag) 461 | (cl-incf ann-count)) 462 | ((and has-ann (not has-tag)) 463 | (cl-incf add-count) 464 | (cl-incf ann-count) 465 | (elfeed-curate--update-tag entry elfeed-curate-annotation-tag t)) 466 | ((and has-tag (not has-ann)) 467 | (cl-incf remove-count) 468 | (elfeed-curate--update-tag entry elfeed-curate-annotation-tag nil))))) 469 | (message "Annotation tags reconciled for %d entries: %d added, %d removed, %d total." 470 | total-count add-count remove-count ann-count))) 471 | 472 | ;;;###autoload 473 | (defun elfeed-curate-toggle-star () 474 | "Toggle `elfeed-curate-star-tag' on the current entry. 475 | This work in either the search or show buffer." 476 | (interactive) 477 | (let* ((entry (elfeed-curate--get-entry)) 478 | (add-tag (not (memq elfeed-curate-star-tag (elfeed-curate-entry-tags entry))))) 479 | (elfeed-curate--update-tag entry elfeed-curate-star-tag add-tag))) 480 | 481 | ;;;###autoload 482 | (defun elfeed-curate-edit-entry-annoation () 483 | "Edit selected entry annotation." 484 | (interactive) 485 | (let* ((entry (elfeed-curate--get-entry)) 486 | (current-annotation (elfeed-curate-get-entry-annotation entry)) 487 | (new-annotation (elfeed-curate-edit-annotation (elfeed-entry-title entry) current-annotation))) 488 | (when (not (string-equal new-annotation current-annotation)) 489 | (elfeed-curate-set-entry-annotation entry new-annotation)))) 490 | 491 | ;;;###autoload 492 | (defun elfeed-curate-export-entries () 493 | "Write all displayed Elfeed entries to an export file. 494 | Use prefix key (`C-u`) to turn off showing the group count if it's enabled." 495 | (interactive) 496 | (let* ((groups (elfeed-curate-group-org-entries elfeed-search-entries)) 497 | (group-keys (elfeed-curate-plist-keys groups)) 498 | (org-file (expand-file-name (elfeed-curate--org-file-path))) 499 | (total-entries (elfeed-curate--group-entries-count groups))) 500 | (if (= total-entries 0) 501 | (message "elfeed-curate: There are no entries to export!") 502 | (with-temp-file org-file 503 | (when (functionp elfeed-curate-org-content-header-function) 504 | (insert (funcall elfeed-curate-org-content-header-function elfeed-curate-org-title))) 505 | (let ((show-group-count (and elfeed-curate-show-group-count (null current-prefix-arg)))) 506 | (mapc (lambda (group-key) 507 | (elfeed-curate-add-org-group group-key (plist-get groups group-key) show-group-count)) group-keys)) 508 | (let ((out-file-name (elfeed-curate-export-file-name))) 509 | (delete-file out-file-name) 510 | (org-export-to-file elfeed-curate-org-export-backend out-file-name 511 | nil nil nil nil nil #'elfeed-curate--hugo-post-process) 512 | (when (not (elfeed-curate--is-hugo?)) 513 | (elfeed-curate--open-in-external-app out-file-name)) 514 | (message "Exported %d Elfeed groups (%d total entries) to %s" 515 | (length group-keys) total-entries out-file-name)))))) 516 | 517 | (provide 'elfeed-curate) 518 | 519 | ;;; elfeed-curate.el ends here 520 | -------------------------------------------------------------------------------- /screenshots/elfeed-curate-ann-export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rnadler/elfeed-curate/195ee944a1dd95380c680d886e15a8aadab50b8e/screenshots/elfeed-curate-ann-export.png -------------------------------------------------------------------------------- /screenshots/elfeed-curate-ann-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rnadler/elfeed-curate/195ee944a1dd95380c680d886e15a8aadab50b8e/screenshots/elfeed-curate-ann-window.png -------------------------------------------------------------------------------- /screenshots/elfeed-curate-export-content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rnadler/elfeed-curate/195ee944a1dd95380c680d886e15a8aadab50b8e/screenshots/elfeed-curate-export-content.png -------------------------------------------------------------------------------- /screenshots/elfeed-curate-search-content.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rnadler/elfeed-curate/195ee944a1dd95380c680d886e15a8aadab50b8e/screenshots/elfeed-curate-search-content.png -------------------------------------------------------------------------------- /test/elfeed-curate-tests.el: -------------------------------------------------------------------------------- 1 | ;;; elfeed-curate-tests.el --- Elfeed curate tests -*- lexical-binding: t; -*- 2 | 3 | (require 'ert) 4 | (require 'elfeed-curate) 5 | 6 | (ert-deftest tag-to-group-name-test () 7 | (should (string-equal (elfeed-curate-tag-to-group-name "") "")) 8 | (should (string-equal (elfeed-curate-tag-to-group-name 4) "4")) 9 | (should (string-equal (elfeed-curate-tag-to-group-name nil) "Nil")) 10 | (should (string-equal (elfeed-curate-tag-to-group-name '(one two three)) "(One Two Three)")) 11 | (should (string-equal (elfeed-curate-tag-to-group-name 'singleword) "Singleword")) 12 | (should (string-equal (elfeed-curate-tag-to-group-name 'this_is_four_words) "This Is Four Words"))) 13 | 14 | (defvar groups (list 'group1 '(a b c d) 'group2 `(x y z))) 15 | 16 | (ert-deftest count-group-ertries-test () 17 | (should (= 7 (elfeed-curate--group-entries-count groups)))) 18 | 19 | (ert-deftest normalize-tags-test () 20 | (should (= 2 (length (elfeed-curate-normalize-tags (list 'soft 'Soft 'sofT 'med_dev 'med_Dev 'MED_Dev)))))) 21 | 22 | (provide 'elfeed-curate-tests) 23 | 24 | ;;; elfeed-curate-tests.el ends here 25 | --------------------------------------------------------------------------------