├── README.org ├── tests └── wallabag-test.el ├── wombag-db.el ├── wombag-heading.el ├── wombag-options.el ├── wombag-search.el ├── wombag-show.el └── wombag.el /README.org: -------------------------------------------------------------------------------- 1 | #+title: Wombag - A Wallabag client for Emacs 2 | #+options: h:5 num:nil toc:nil 3 | 4 | #+html: 5 | 6 | # #+html: 7 | 8 | Wombag is a Wallabag client for Emacs. 9 | 10 | [[https://wallabag.org/][Wallabag]] is an (optionally self-hosted) Read-It-Later and archiving service for web pages, like Pocket or Instapaper. 11 | 12 | * Installation 13 | 14 | *Please Note*: Wombag requires at least Emacs 28.1 and a recent-ish server-side installation of Wallabag (v2.6.0 or higher). You can check your Wallabag version from the web interface. 15 | 16 | Install using your package manager of choice, or clone this repo and run =package-install-file= on the cloned directory. 17 | 18 | * Setup and Usage 19 | 20 | #+begin_src emacs-lisp 21 | (use-package wombag 22 | :config 23 | (setq wombag-host "https://app.wallabag.it" ;where you access Wallabag 24 | wombag-username "my-wallabag-username" 25 | wombag-password "my-wallabag-password" 26 | wombag-client-id "abcdefgh1234" 27 | wombag-client-secret "abcdefgh1234")) 28 | #+end_src 29 | Consider obfuscating your password and client secret here using =auth-source= or some other mechanism. There are other customization options, which see. 30 | 31 | To use it, run =wombag=. 32 | 33 | Run =wombag-sync= to sync the local database. It may take a while the first time. 34 | 35 | ** In the Wombag buffer 36 | 37 | If you have used [[https://github.com/skeeto/elfeed][Elfeed]] before you should feel at home. 38 | 39 | # #+attr_html: :width 800px 40 | # [[file:images/wombag-view.png]] 41 | 42 | #+html: 43 | 44 | - Press =s= to search for articles, =?= for help when searching. 45 | - Sync with the server with =wombag-sync= (=G=). 46 | - You can archive (=A=), star (=F=), tag (=+=) or delete (=D=) entries. 47 | - Removing tags is not supported yet. 48 | 49 | ** Anywhere in Emacs 50 | 51 | To add a URL to Wallabag, you can run =M-x wombag-add-entry=. 52 | 53 | * Quality of life features 54 | 55 | Wombag provides a few QoL features. 56 | 57 | *** Full-featured text search 58 | 59 | You can filter the search listing incrementally using any combination of criteria. 60 | 61 | In this demo I filter the listing to articles that mention /Julia/ in the title, added between /2021/ and /2023/, are not archived, and contain the text /automatic differentiation/: 62 | 63 | # #+begin_export html 64 | #

66 | # #+end_export 67 | 68 | #+html:

69 | https://github.com/karthink/gptel/assets/8607532/3fa5b41f-4b18-416c-b4d4-7c314603d037 70 | #+html:

71 | 72 | # https://github-production-user-asset-6210df.s3.amazonaws.com/8607532/280438599-3fa5b41f-4b18-416c-b4d4-7c314603d037.mp4 73 | 74 | You can search by text, tags, title, author, URL, date published, date added, starred/archived status and more. 75 | 76 | *** Saves your place 77 | 78 | Wombag saves your place in articles. You can also show only partly-read articles when searching (press =?=), or call =M-x wombag-resume= from anywhere. 79 | 80 | *** =imenu= support 81 | 82 | Jump between article sections with =imenu=: 83 | 84 | # #+attr_html: :width 800px 85 | # [[file:images/wombag-imenu.png]] 86 | 87 | #+html: 88 | 89 | The above image is with =consult-imenu=. Any =imenu=-based command will work (including just =imenu=). 90 | 91 | *** Bookmark support 92 | 93 | You can bookmark search results or individual articles. 94 | 95 | *** TODO Org-link support 96 | 97 | (Not yet implemented) 98 | 99 | * How it works 100 | 101 | Wombag uses a local sqlite database to store your Wallabag entries and Emacs as a front-end. Remote to local syncing is incremental and based on the last sync time. Local changes are propagated to the remote instantaneously and requires an Internet connection. 102 | 103 | * Comparison with [[https://github.com/chenyanming/wallabag.el][wallabag.el]] 104 | 105 | I needed something that could work smoothly with my decades-old reading list of 15K+ articles. I could not get =wallabag.el=, a pre-existing Wallabag client for Emacs, to sync correctly or to work without freezing Emacs. This is a smaller, stripped down rewrite of the package -- no code is shared except for a couple of face definitions. 106 | 107 | - =wallabag.el= has many features Wombag doesn't: annotation support, ivy integration, image caching, bulk action support via marking entries and more customization options. 108 | - Wombag has a 12-25x lower memory footprint, more consistent syncing and graceful error handling, better search and many quality of life features. 109 | 110 | For more information on the differences see [[https://github.com/chenyanming/wallabag.el/issues/21][this thread]]. 111 | 112 | * Known issues 113 | 114 | - Deleting articles from the web interface or a different client will not cause them to be deleted from the local database when syncing. This is a known limitation of the Wallabag API. This can be done by doing a full (as opposed to incremental) sync, which Wombag does not (officially) support. Note that all local changes (including deletions) are always reflected server-side. 115 | 116 | - Removing tags is not implemented due to Wallabag [[https://github.com/wallabag/wallabag/issues/6928][API issues]]. 117 | 118 | * Acknowledgments 119 | 120 | - [[https://github.com/chenyanming][Damon Chan]] for writing wallabag.el, the inspiration for this package. 121 | - [[https://github.com/skeeto][Chris Wellons]] for writing Elfeed, the other inspiration for this package. He also authored =emacsql=, which Wombag uses. 122 | -------------------------------------------------------------------------------- /tests/wallabag-test.el: -------------------------------------------------------------------------------- 1 | (require 'ert) 2 | 3 | (ert-deftest w-db--sanitize-entry-test () 4 | ";TODO:" 5 | (let ((entry '((reading_time . 12) 6 | (created_at . "2023-08-25T05:01:37+0000") 7 | (updated_at . "2023-08-25T05:03:02+0000") 8 | (published_at . "2023-08-23T14:27:54+0000") 9 | (published_by . ["SK Ventures"]) 10 | (starred_at) 11 | (tags . [((id . 2) 12 | (label . "tag1") 13 | (slug . "tag1")) 14 | ((id . 3) 15 | (label . "tag2") 16 | (slug . "tag2"))]) 17 | (annotations . []) 18 | (mimetype . "text/html; charset=utf-8") 19 | (language . "en") 20 | (is_public . :json-false))) 21 | (entry-sanitized '((tag . "tag1,tag2") 22 | (reading_time . 12) 23 | (created_at . "2023-08-25T05:01:37") 24 | (updated_at . "2023-08-25T05:03:02") 25 | (published_at . "2023-08-23T14:27:54") 26 | (published_by . ["SK Ventures"]) 27 | (starred_at) 28 | (tags . [((id . 2) 29 | (label . "tag1") 30 | (slug . "tag1")) 31 | ((id . 3) 32 | (label . "tag2") 33 | (slug . "tag2"))]) 34 | (annotations . []) 35 | (mimetype . "text/html; charset=utf-8") 36 | (language . "en") 37 | (is_public . 0)))) 38 | (should (equal entry-sanitized 39 | (w-db--sanitize-entry entry))))) 40 | 41 | 42 | 43 | 44 | ;; Local Variables: 45 | ;; read-symbol-shorthands: (("w-" . "wombag-")) 46 | ;; End: 47 | -------------------------------------------------------------------------------- /wombag-db.el: -------------------------------------------------------------------------------- 1 | ;;; wombag-db.el --- Wombag database interaction -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | 7 | ;; This program is free software; you can redistribute it and/or modify 8 | ;; it under the terms of the GNU General Public License as published by 9 | ;; the Free Software Foundation, either version 3 of the License, or 10 | ;; (at your option) any later version. 11 | 12 | ;; This program is distributed in the hope that it will be useful, 13 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | ;; GNU General Public License for more details. 16 | 17 | ;; You should have received a copy of the GNU General Public License 18 | ;; along with this program. If not, see . 19 | 20 | ;;; Commentary: 21 | 22 | ;; Wombag database interaction 23 | 24 | ;;; Code: 25 | (require 'emacsql) 26 | (require 'emacsql-sqlite) 27 | (require 'map) 28 | (require 'wombag-options) 29 | 30 | (defconst w-db-version 2) 31 | (defvar w-db-connection nil) 32 | (defvar w-search-columns) 33 | 34 | (defvar w-db-open-func 35 | (or (and (fboundp 'sqlite-available-p) 36 | (sqlite-available-p) 37 | (require 'emacsql-sqlite-builtin nil t) 38 | (functionp 'emacsql-sqlite-builtin) 39 | #'emacsql-sqlite-builtin) 40 | (progn (require 'emacsql-sqlite) 41 | #'emacsql-sqlite)) 42 | ;; :type 'function ;; :group 'w-db 43 | "Function for creating the database connection.") 44 | 45 | (defun w-db-ensure () 46 | ";TODO:" 47 | (unless (and w-db-connection (emacsql-live-p w-db-connection)) 48 | (let ((dir (file-name-directory w-db-file))) 49 | (or (file-directory-p dir) (make-directory dir))) 50 | (setq w-db-connection (funcall w-db-open-func w-db-file)) 51 | 52 | (emacsql w-db-connection 53 | [:create-table 54 | :if-not-exists 55 | items ([tag is_archived is_starred 56 | user_name user_email user_id tags 57 | is_public (id integer :primary-key) uid title 58 | url hashed_url origin_url given_url hashed_given_url 59 | (archived_at DATE) content (created_at DATE) 60 | (updated_at DATE) (published_at DATE) published_by 61 | (starred_at DATE) annotations mimetype language reading_time 62 | domain_name preview_picture http_status headers _links])]) 63 | 64 | (emacsql w-db-connection [:create-table :if-not-exists version ([user-version])]) 65 | ;; Note: fetch-new is not currently used for anything 66 | (emacsql w-db-connection [:create-table :if-not-exists last_update ([ (fetch-new INTEGER) (fetch-all INTEGER) ])]) 67 | (unless (emacsql w-db-connection [:select * :from last_update]) 68 | (let ((epoch 1)) 69 | (emacsql w-db-connection `[:insert :into last_update :values ([,epoch ,epoch])]))) 70 | (unless (emacsql w-db-connection [:select user-version :from version]) 71 | (emacsql w-db-connection `[:insert :into version :values ([,w-db-version])]))) 72 | 73 | w-db-connection) 74 | 75 | (defun w-db-update-date (date &optional only-new) 76 | "Update the database with DATE (seconds since epoch) as the last_update time. 77 | 78 | With optional arg ONLY-NEW, update the \\='fetch-new\\=' field: 79 | Currently unused." 80 | (cl-assert (or (floatp date) (integerp date))) 81 | (setq date (floor date)) 82 | (emacsql (w-db-ensure) `[:update last_update 83 | :set (= ,(if only-new 'fetch-new 'fetch-all) ,date)])) 84 | 85 | ;; (w-db-update-date (time-to-seconds (time-subtract (current-time) (days-to-time 1)))) 86 | 87 | (defun w-db--sanitize-entry (entry) 88 | "Transform ENTRY before adding it to the database." 89 | (let ((alltags 90 | (mapconcat (lambda (el) (map-elt el 'label)) 91 | (map-elt entry 'tags) ","))) 92 | ;; Use 0 and 1 instead of :json-false and t for is_public 93 | (when-let ((public (map-elt entry 'is_public))) 94 | (map-put! entry 'is_public (if (eq public t) 1 0))) 95 | (map-put! entry 'headers nil) 96 | ;; ;; Remove timezone info if not in the form "+03:30" or "Z" 97 | ;; (dolist (datetype '(created_at updated_at published_at starred_at archived_at)) 98 | ;; (when-let* ((date (map-elt entry datetype)) 99 | ;; (_ (string-match-p "\\+[[:digit:]]\\{4\\}$" date))) 100 | ;; (map-put! entry datetype (substring date 0 -5)))) 101 | ;; Add all tags concatenated with a comma 102 | (cons `(tag . ,alltags) entry))) 103 | 104 | (defun w-db-query (query &rest args) 105 | "Run QUERY with ARGS against the Wombag database." 106 | (if (stringp query) 107 | (emacsql (w-db-ensure) (apply #'format query args)) 108 | (apply #'emacsql (w-db-ensure) query args))) 109 | 110 | (defconst w-db-schema 111 | '(tag is_archived is_starred user_name user_email user_id tags is_public 112 | id uid title url hashed_url origin_url given_url hashed_given_url 113 | archived_at content created_at updated_at published_at published_by 114 | starred_at annotations mimetype language reading_time domain_name 115 | preview_picture http_status headers _links)) 116 | 117 | (defun w-db-get-entries (query &optional columns) 118 | "Select COLUMNS from the Wombag DB for QUERY. 119 | 120 | If COLUMNS is nil select the full record. 121 | 122 | Return a list of alists of the column names and data." 123 | (setq columns (or (ensure-list columns) 124 | w-db-schema)) 125 | (mapcar (lambda (sel) (cl-pairlis columns sel)) 126 | (w-db-query query))) 127 | 128 | (defun w-db-get-ids (ids) 129 | (cl-typecase ids 130 | (number 131 | (w-db-get-entries 132 | `[:select ,(vconcat w-search-columns) :from items :where (= id ,ids)] 133 | w-search-columns)) 134 | (vector 135 | (w-db-get-entries 136 | `[:select ,(vconcat w-search-columns) :from items :where (in id ,(vconcat ids))])) 137 | (list 138 | (w-db-get-entries 139 | `[:select ,(vconcat w-search-columns) :from items :where (in id ,(vconcat ids))])))) 140 | 141 | (defun w-db--close () 142 | (emacsql-close (w-db-ensure))) 143 | 144 | (defun w-db-insert (entries &optional replace) 145 | "Return a list of the ids of ENTRIES inserted into the database. 146 | 147 | If REPLACE is non-nil, replace the entry in place instead." 148 | (let* ((ids '()) ;byte-compiler complains otherwise 149 | (entries 150 | (cl-loop for entry across entries 151 | collect (map-elt entry 'id) into ids 152 | collect (apply #'vector 153 | (mapcar 'cdr (w-db--sanitize-entry entry)))))) 154 | (w-db-query 155 | (if replace 156 | `[:insert-or-replace :into items :values ,entries] 157 | `[:insert-or-ignore :into items :values ,entries])) 158 | ids)) 159 | 160 | (defun w-db-delete (ids) 161 | (cond ((vectorp ids) 162 | (w-db-query `[:delete :from items :where (in id ,ids)]) ) 163 | ((numberp ids) (w-db-query `[:delete :from items :where (= id ,ids)])) 164 | (t nil))) 165 | 166 | (defun w-db-update (field id new) 167 | ";TODO: Update FIELD with ID in database with NEW data. 168 | 169 | FIELD is a symbol, ID the primary key integer and NEW is any data." 170 | (w-db-query `[:update items :set (= ,field ,new) :where (= id ,id)])) 171 | 172 | (provide 'wombag-db) 173 | ;;; wombag-db.el ends here 174 | 175 | ;; Local Variables: 176 | ;; read-symbol-shorthands: (("w-" . "wombag-")) 177 | ;; End: 178 | -------------------------------------------------------------------------------- /wombag-heading.el: -------------------------------------------------------------------------------- 1 | ;;; wombag-heading.el --- Wombag heaading navigation -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | 7 | ;; This program is free software; you can redistribute it and/or modify 8 | ;; it under the terms of the GNU General Public License as published by 9 | ;; the Free Software Foundation, either version 3 of the License, or 10 | ;; (at your option) any later version. 11 | 12 | ;; This program is distributed in the hope that it will be useful, 13 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | ;; GNU General Public License for more details. 16 | 17 | ;; You should have received a copy of the GNU General Public License 18 | ;; along with this program. If not, see . 19 | 20 | ;;; Commentary: 21 | 22 | ;; Wombag heading navigation support 23 | 24 | ;;; Code: 25 | (require 'shr) 26 | (require 'imenu) 27 | 28 | (eval-and-compile 29 | (if (require 'shr-heading nil t) 30 | (progn 31 | (defalias 'w-heading-next 'shr-heading-next) 32 | (defalias 'w-heading--line-at-point 'shr-heading--line-at-point) 33 | (defalias 'w-heading-previous 'shr-heading-previous) 34 | (defalias 'w-heading-setup-imenu 'shr-heading-setup-imenu)) 35 | (defun w-heading-next (&optional arg) 36 | "Move forward by ARG headings (any h1-h5). 37 | If ARG is negative move backwards, ARG defaults to 1." 38 | (interactive "p") 39 | (unless arg (setq arg 1)) 40 | (catch 'return 41 | (dotimes (_ (abs arg)) 42 | (when (> arg 0) (end-of-line)) 43 | (if-let ((match 44 | (funcall (if (> arg 0) 45 | #'text-property-search-forward 46 | #'text-property-search-backward) 47 | 'face '(shr-h1 shr-h2 shr-h3 shr-h4) 48 | (lambda (tags face) 49 | (cl-loop for x in (if (consp face) face (list face)) 50 | thereis (memq x tags)))))) 51 | (goto-char 52 | (if (> arg 0) (prop-match-beginning match) (prop-match-end match))) 53 | (throw 'return nil)) 54 | (when (< arg 0) (beginning-of-line))) 55 | (beginning-of-line) 56 | (point))) 57 | 58 | (defun w-heading-previous (&optional arg) 59 | "Move backward by ARG headings (any h1-h5). 60 | If ARG is negative move forwards instead, ARG defaults to 1." 61 | (interactive "p") 62 | (w-heading-next (- (or arg 1)))) 63 | 64 | (defun w-heading--line-at-point () 65 | "Return the current line." 66 | (buffer-substring (line-beginning-position) (line-end-position))) 67 | 68 | (defun w-heading-setup-imenu () 69 | "Setup imenu for h1-h5 headings in eww buffer. 70 | Add this function to appropriate major mode hooks such as 71 | `eww-mode-hook' or `elfeed-show-mode-hook'." 72 | (setq-local 73 | imenu-prev-index-position-function #'w-heading-previous 74 | imenu-extract-index-name-function #'w-heading--line-at-point)))) 75 | 76 | (provide 'wombag-heading) 77 | ;;; wombag-heading.el ends here 78 | 79 | ;; Local Variables: 80 | ;; read-symbol-shorthands: (("w-" . "wombag-")) 81 | ;; End: 82 | -------------------------------------------------------------------------------- /wombag-options.el: -------------------------------------------------------------------------------- 1 | ;;; wombag-options.el --- Customization options for Wombag -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | 7 | ;; This program is free software; you can redistribute it and/or modify 8 | ;; it under the terms of the GNU General Public License as published by 9 | ;; the Free Software Foundation, either version 3 of the License, or 10 | ;; (at your option) any later version. 11 | 12 | ;; This program is distributed in the hope that it will be useful, 13 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | ;; GNU General Public License for more details. 16 | 17 | ;; You should have received a copy of the GNU General Public License 18 | ;; along with this program. If not, see . 19 | 20 | ;;; Commentary: 21 | 22 | ;; Customization options for Wombag. 23 | 24 | ;;; Code: 25 | (require 'shr) 26 | 27 | 28 | ;;; Groups 29 | (defgroup wombag nil 30 | "Wombag client group." 31 | :group 'comm) 32 | 33 | (defgroup w-db nil 34 | "Wombag database group." 35 | :group 'wombag) 36 | 37 | (defgroup w-search nil 38 | "Wombag search buffer group." 39 | :group 'wombag) 40 | 41 | (defgroup w-show nil 42 | "Wombag search buffer group." 43 | :group 'wombag) 44 | 45 | (defgroup w-faces nil 46 | "Wombag faces group." 47 | :group 'wombag) 48 | 49 | (define-obsolete-variable-alias 50 | 'w-clientid 'w-client-id "0.1.0") 51 | (define-obsolete-variable-alias 52 | 'w-secret 'w-client-secret "0.1.0") 53 | 54 | 55 | ;;; Remote and authentication 56 | (defcustom w-host "" 57 | "Wombag host." 58 | :type 'string 59 | :group 'wombag) 60 | 61 | (defcustom w-username nil 62 | "User name for Wombag." 63 | :type 'string 64 | :group 'wombag) 65 | 66 | (defcustom w-password "" 67 | "Password for Wombag" 68 | :type '(choice 69 | (string :tag "Password") 70 | (function :tag "Function that returns the password.")) 71 | :group 'wombag) 72 | 73 | (defcustom w-client-id "" 74 | "Client ID for Wombag." 75 | :type 'string 76 | :group 'wombag) 77 | 78 | (defcustom w-client-secret "" 79 | "Client secret for Wombag." 80 | :type 'string 81 | :group 'wombag) 82 | 83 | 84 | ;;; Local database 85 | (defcustom w-dir (file-name-concat 86 | (or (getenv "XDG_CACHE_HOME") user-emacs-directory) 87 | "wombag") 88 | "Wombag data directory." 89 | :type 'directory 90 | :set (lambda (sym val) 91 | (condition-case nil 92 | (progn 93 | (unless (file-directory-p val) 94 | (make-directory val :parents)) 95 | (set-default-toplevel-value sym val)) 96 | (error (user-error "Could not create directory: %s" val)))) 97 | :group 'w-db) 98 | 99 | (defcustom w-db-file 100 | (file-name-concat w-dir "wombag.sqlite") 101 | "Sqlite database used to store Wombag data." 102 | :type 'file 103 | :group 'w-db) 104 | 105 | 106 | ;;; Wombag search 107 | (defcustom w-search-buffer-name "*wombag-search*" 108 | "Buffer name for the Wombag search buffer." 109 | :type 'string 110 | :group 'w-search) 111 | 112 | (defcustom w-search-filter "#30 " 113 | "Default search filter for Wombag." 114 | :type 'string 115 | :group 'w-search) 116 | 117 | (defcustom w-search-filter-help nil 118 | "When non-nil, pop up a help window showing Wombag's search filter syntax. 119 | 120 | This window can be toggled manually using \\`?'." 121 | :type 'boolean 122 | :group 'w-search) 123 | 124 | 125 | ;;; Wombag show 126 | (defcustom w-show-entry-switch #'pop-to-buffer-same-window 127 | "Function used to display Wombag entry window." 128 | :type 'function 129 | :group 'w-show) 130 | 131 | (defcustom w-browse-url-function #'browse-url 132 | "Function called with URLs to open." 133 | :type 'function 134 | :group 'w-show) 135 | 136 | (defcustom w-pre-html-render-hook nil 137 | "Hook run before rendering Wombag articles." 138 | :type 'hook 139 | :group 'w-show) 140 | 141 | (defcustom w-post-html-render-hook nil 142 | "Hook run after rendering Wombag articles." 143 | :type 'hook 144 | :group 'w-show) 145 | 146 | (defcustom w-show-buffer-name "*wombag-entry*" 147 | "Buffer name for the Wombag search buffer." 148 | :type 'string 149 | :group 'w-show) 150 | 151 | 152 | ;;; Faces 153 | (defface w-title-face '((t :inherit bold)) 154 | "Face used for title on compact view." 155 | :group 'w-faces) 156 | 157 | (defface w-tag-face 158 | '((((class color) (background light)) 159 | :foreground "brown") 160 | (((class color) (background dark)) 161 | :foreground "#EBCB8B") 162 | (t :inherit default)) 163 | "Face used for tag." 164 | :group 'w-faces) 165 | 166 | (defface w-domain-face 167 | '((((class color) (background light)) 168 | :foreground "#3B6EA8") 169 | (((class color) (background dark)) 170 | :foreground "#d9c6d6") 171 | (t :inherit default)) 172 | "Face used for author." 173 | :group 'w-faces) 174 | 175 | (defface w-reading-time-face 176 | '((((class color) (background light)) 177 | :foreground "#8b94a5") 178 | (((class color) (background dark)) 179 | :foreground "#6f7787") 180 | (t :inherit default)) 181 | "Face used for size." 182 | :group 'w-faces) 183 | 184 | (defface w-date-face 185 | '((((class color) (background light)) 186 | :foreground "#29838D") 187 | (((class color) (background dark)) 188 | :foreground "#8FBCBB") 189 | (t :inherit default)) 190 | "Face for the date (last_modified)." 191 | :group 'w-faces) 192 | 193 | (defface w-show-title-face 194 | '((t (:inherit (shr-h1 variable-pitch)))) 195 | "Face used for title." 196 | :group 'w-faces) 197 | 198 | (defface w-archive-face 199 | '((((class color) (background light)) 200 | :foreground "grey" 201 | :weight light) 202 | (((class color) (background dark)) 203 | :foreground "dim grey" 204 | :weight light) 205 | (t :inherit default)) 206 | "Face used for archive." 207 | :group 'w-faces) 208 | 209 | (defface w-starred-face 210 | '((((class color) (background light)) 211 | :foreground "red3") 212 | (((class color) (background dark)) 213 | :foreground "yellow") 214 | (t :inherit default)) 215 | "Face used for title." 216 | :group 'w-faces) 217 | 218 | (provide 'wombag-options) 219 | ;;; wombag-options.el ends here 220 | 221 | ;; Local Variables: 222 | ;; read-symbol-shorthands: (("w-" . "wombag-")) 223 | ;; End: 224 | -------------------------------------------------------------------------------- /wombag-search.el: -------------------------------------------------------------------------------- 1 | ;;; wombag-search.el --- Wombag search interface -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | 7 | ;; This program is free software; you can redistribute it and/or modify 8 | ;; it under the terms of the GNU General Public License as published by 9 | ;; the Free Software Foundation, either version 3 of the License, or 10 | ;; (at your option) any later version. 11 | 12 | ;; This program is distributed in the hope that it will be useful, 13 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | ;; GNU General Public License for more details. 16 | 17 | ;; You should have received a copy of the GNU General Public License 18 | ;; along with this program. If not, see . 19 | 20 | ;;; Commentary: 21 | 22 | ;; Wombag search interface 23 | 24 | ;;; Code: 25 | (require 'wombag-db) 26 | (require 'wombag-options) 27 | (require 'wombag-show) 28 | (require 'bookmark) 29 | (require 'request) 30 | 31 | (bookmark-maybe-load-default-file) 32 | 33 | (declare-function w-show-entry "wombag-show") 34 | (declare-function wombag "wombag") 35 | (declare-function w-add-entry "wombag") 36 | (declare-function w-sync "wombag") 37 | (declare-function w--debug "wombag") 38 | (declare-function w--retry-with-token "wombag") 39 | (defvar w-token nil) 40 | 41 | (defun w-search-buffer (&optional livep) 42 | "Get or create buffer *wombag-search*. 43 | 44 | With optional arg LIVEP, only return the buffer if it is live." 45 | (if livep 46 | (get-buffer w-search-buffer-name) 47 | (get-buffer-create w-search-buffer-name))) 48 | 49 | (defvar w-retrieving nil 50 | "Header message for state of Wombag.") 51 | 52 | 53 | ;;; Bookmark support 54 | 55 | ;;;###autoload 56 | (defun w-search-bookmark-handler (record) 57 | "Jump to the wombag-search bookmarked location in RECORD." 58 | (wombag) 59 | (setq w-search-filter (bookmark-prop-get record 'filter)) 60 | (w-search-update--force)) 61 | 62 | (defun w-search-bookmark-make-record () 63 | "Return a bookmark record for the current w-search buffer." 64 | `(,(format "(Wombag search) %s" w-search-filter) 65 | (filter . ,w-search-filter) 66 | (handler . w-search-bookmark-handler))) 67 | 68 | 69 | ;;; Searching and live searching 70 | (defvar w-search-filter-active nil) 71 | 72 | (defvar w-search-filter-syntax-table 73 | (let ((table (make-syntax-table))) 74 | (prog1 table 75 | (dolist (char (list ?# ?+ ?- ?= ?@ ?! ?* ?~ ?/ ?> ?< ?: ?& ?^ ?.)) 76 | (modify-syntax-entry char "w" table)))) 77 | "Syntax table active when editing the filter in the minibuffer.") 78 | 79 | (defun w-search--minibuffer-setup () 80 | "Set up the minibuffer for live filtering Wombag." 81 | (when w-search-filter-active 82 | (set-syntax-table w-search-filter-syntax-table) 83 | (when w-search-filter-help (w-search--show-filter-help)) 84 | (when (eq :live w-search-filter-active) 85 | (add-hook 'post-command-hook (w-search--live-updater) nil :local)))) 86 | 87 | (defconst w-search--filter-help-string 88 | (cl-labels ((spc (to) (propertize " " 'display `(space :align-to ,to))) 89 | (hlp (text) (propertize text 'face 'help-key-binding)) 90 | (box (text) (propertize text 'face '(:underline t :weight semi-bold))) 91 | (desc (key s1 desc &optional s2) 92 | (concat (hlp key) (spc s1) desc (and s2 (spc s2))))) 93 | (concat 94 | (propertize 95 | "Filter Syntax [All filters are \"AND\" composed; Regexp support is not available]" 96 | 'face '(inherit shadow :height 1.1)) 97 | (propertize "\n\n" 'face '(inherit :height 0.8)) 98 | (hlp ".") (spc 5) (concat "Currently Reading") 99 | (spc 31) (box "Match") (spc 55) (box "Date added") 100 | "\n" 101 | (desc "#20" 5 "Limit to 20 articles" 30) 102 | (desc " text" 36 "in content/title" 55) 103 | (desc "@2020-06-27" 69 "on") 104 | "\n" 105 | (desc "<12" 5 "reading time < 12 mins" 30) 106 | (desc "*text" 36 "in title" 55) 107 | (desc "@2020-06-27--" 69 "after") 108 | "\n" 109 | (desc ">12" 5 "reading time > 12 mins" 30) 110 | (desc "/text" 36 "in full url" 55) 111 | (desc "@--2022-06-27" 69 "before") 112 | "\n" 113 | (desc "+tag" 5 "tagged as 'tag'" 30) 114 | (desc "^text" 36 "in domain name" 55) 115 | (desc "@2022-01-11--2022-06-27" 79 "between") 116 | "\n" 117 | (spc 30) 118 | (desc "=text" 36 "in author's name") 119 | "\n" 120 | (box "Flags") (spc 55) (box "Date published") 121 | "\n" 122 | (desc "**" 4 "Starred" 30) 123 | (spc 31) (box "Not match") (spc 55) 124 | (desc ":2020-06-27" 69 "on") 125 | "\n" 126 | (desc "!*" 4 "Not starred" 30) 127 | (desc "!text" 36 "in content/title" 55) 128 | (desc ":2020-06-27--" 69 "after") 129 | "\n" 130 | (desc "&&" 4 "Archived" 30) 131 | (desc "~text" 36 "in full url" 55) 132 | (desc ":--2022-06-27" 69 "before") 133 | "\n" 134 | (desc "!&" 4 "Not archived" 30) 135 | (desc "-tag" 36 "not tagged 'tag'" 55) 136 | (desc ":2022-01-11--2022-06-27" 79 "between")))) 137 | 138 | (defun w-search--show-filter-help () 139 | "Display search filter help for Wombag." 140 | (interactive) 141 | (let* ((buf (get-buffer-create "*wombag filter help*")) 142 | (win (get-buffer-window buf))) 143 | (if (window-live-p win) 144 | (quit-window nil win) 145 | (display-buffer 146 | buf `((display-buffer-in-side-window 147 | display-buffer-at-bottom) 148 | (side . bottom) 149 | (slot . -20) 150 | (window-height . 14))) 151 | (with-current-buffer buf 152 | (when (= (buffer-size buf) 0) 153 | (setq truncate-lines t) 154 | (insert w-search--filter-help-string) 155 | (special-mode) 156 | (setq-local mode-line-format nil)))))) 157 | 158 | (defvar w-search--filter-overflow nil 159 | "Flag when there are more entries than fit on the screen.") 160 | 161 | (defun w-search--live-updater (&optional delay) 162 | "Return a live-update function for filtering with Wombag. 163 | 164 | DELAY is the duration for which user input is debounced before 165 | querying the database." 166 | (let ((prev-filter) 167 | (debounce-timer) 168 | (delay (or delay 0.20))) 169 | (lambda () 170 | (if (timerp debounce-timer) 171 | (timer-set-idle-time debounce-timer delay) 172 | (unless (eq this-command 'w-search-live-filter) 173 | (setq debounce-timer 174 | (run-with-idle-timer 175 | delay nil 176 | (lambda (buffer) 177 | (cancel-timer debounce-timer) 178 | (setq debounce-timer nil) 179 | (let ((current-filter (string-trim-right (minibuffer-contents-no-properties)))) 180 | (unless (equal prev-filter current-filter) 181 | (with-current-buffer buffer 182 | (let* ((user-limit (or (and (string-match "#\\([[:digit:]]+\\)" current-filter) 183 | (string-to-number (match-string 1 current-filter))) 184 | most-positive-fixnum)) 185 | (wombag-buf (w-search-buffer)) 186 | (window (get-buffer-window wombag-buf)) 187 | (height (window-total-height window)) 188 | (limiter (if window (format " #%d " height) " #1 ")) 189 | (w-search-filter (if (> user-limit height) 190 | (concat current-filter limiter) 191 | current-filter))) 192 | (w-search-update :force) 193 | (setq w-search--filter-overflow (>= (car (buffer-line-statistics wombag-buf)) 194 | (window-text-height window)) 195 | prev-filter current-filter)))))) 196 | (current-buffer)))))))) 197 | 198 | (defun w-search--live-update () 199 | ";TODO:" 200 | (when (eq :live w-search-filter-active) 201 | (let ((buffer (w-search-buffer)) 202 | (current-filter (minibuffer-contents-no-properties))) 203 | (when buffer 204 | (with-current-buffer buffer 205 | (let* ((user-limit (or (and (string-match "#\\([[:digit:]]+\\)" current-filter) 206 | (string-to-number (match-string 1 current-filter))) 207 | most-positive-fixnum)) 208 | (window (get-buffer-window (w-search-buffer))) 209 | (height (window-total-height window)) 210 | (limiter (if window 211 | (format " #%d " height) 212 | " #1 ")) 213 | (w-search-filter (if (< height user-limit) 214 | (concat current-filter limiter) 215 | current-filter))) 216 | (w-search-update :force) 217 | (setq w-search--filter-overflow (< (window-end) (point-max))))))))) 218 | 219 | (defvar-keymap w-search--filter-map 220 | :doc "Keymap for Wombag filter editing." 221 | :parent minibuffer-local-map 222 | "?" #'w-search--show-filter-help) 223 | 224 | (defvar w-search--filter-history nil) 225 | 226 | (defun w-search-live-filter () 227 | "Live search the Wombag database." 228 | (interactive) 229 | (let ((line (with-current-buffer (w-search-buffer) 230 | (line-number-at-pos)))) 231 | (unwind-protect 232 | (let ((w-search-filter-active :live)) 233 | (setq w-search-filter 234 | (read-from-minibuffer 235 | "Filter (? for help): " w-search-filter w-search--filter-map 236 | nil w-search--filter-history))) 237 | (w-search-update :force) 238 | (setq w-search--filter-overflow nil 239 | w-retrieving nil) 240 | (goto-char (point-min)) 241 | (forward-line (1- line)) 242 | (when (eobp) (forward-line -1))))) 243 | 244 | (defvar w-search-columns 245 | '(id title created_at reading_time is_archived 246 | is_starred tag domain_name url published_by 247 | published_at)) 248 | 249 | (defun w-search-update (&optional _) 250 | ";TODO:" 251 | (let ((entries (w-db-get-entries 252 | (w-search-parse-filter w-search-filter w-search-columns) 253 | w-search-columns))) 254 | (w-search-print-entries entries) 255 | (length entries))) 256 | 257 | (defun w-search--token (token coll) 258 | (let ((re (substring token 1))) 259 | (if (string-empty-p re) 260 | coll (cons re coll)))) 261 | 262 | (defun w-search--date-token (element) 263 | (let ((re (substring element 1))) 264 | (unless (string-empty-p re) 265 | (pcase-let ((`(,from ,to) (split-string re "--"))) 266 | (when (equal from "") (setq from "1970-01-01")) 267 | (when (equal to "") (setq to (format-time-string "%Y-%m-%d" (current-time)))) 268 | (cons from to))))) 269 | 270 | (defun w-search-parse-filter (filter &optional columns) 271 | "Parse the elements of a search FILTER into an emacsql query. 272 | 273 | Query should ask for COLUMNS, or `wombag-search-columns'." 274 | (let ((matches) (limit) (urls) (not-urls) (not-matches) 275 | (have-tag) (not-have-tag) (titles) (under-time) (over-time) 276 | (domains) (authors) (add-dates) (pub-dates) 277 | (starred) (archived) (unstarred) (unarchived) (reading)) 278 | (cl-loop for element in (split-string filter) 279 | for type = (aref element 0) 280 | do (cl-case type 281 | (?+ (setq have-tag (w-search--token element have-tag))) 282 | (?- (setq not-have-tag (w-search--token element not-have-tag))) 283 | (?/ (setq urls (w-search--token element urls))) 284 | (?~ (setq not-urls (w-search--token element not-urls))) 285 | (?! (pcase (ignore-errors (aref element 1)) 286 | ('?* (setq unstarred t)) 287 | ('?& (setq unarchived t)) 288 | (_ (setq not-matches (w-search--token element not-matches))))) 289 | (?* (if (eq (ignore-errors (aref element 1)) ?*) 290 | (setq starred t) 291 | (setq titles (w-search--token element titles)))) 292 | (?& (if (eq (ignore-errors (aref element 1)) ?&) 293 | (setq archived t) 294 | (push element matches))) 295 | (?^ (setq domains (w-search--token element domains))) 296 | (?= (setq authors (w-search--token element authors))) 297 | (?@ (setq add-dates (w-search--date-token element))) 298 | (?: (setq pub-dates (w-search--date-token element))) 299 | (?> (setf over-time 300 | (list (string-to-number (substring element 1))))) 301 | (?< (setf under-time 302 | (list (string-to-number (substring element 1))))) 303 | (?# (setf limit (string-to-number (substring element 1)))) 304 | (otherwise (if (string= element ".") 305 | (setq reading t) 306 | (push element matches))))) 307 | (apply #'vector 308 | (append (if columns 309 | `(:select ,(vconcat columns) :from items) 310 | '(:select * :from items)) 311 | `(,@(when (or urls matches titles have-tag under-time over-time 312 | not-have-tag not-matches not-urls domains authors add-dates 313 | pub-dates starred unstarred archived unarchived reading) 314 | (list :where 315 | `(and 316 | ,@(when starred `((= is_starred 1))) 317 | ,@(when archived `((= is_archived 1))) 318 | ,@(when unstarred `((= is_starred 0))) 319 | ,@(when unarchived `((= is_archived 0))) 320 | ,@(when urls 321 | (cl-loop for link in urls 322 | collect `(like url ,(concat "%" link "%")))) 323 | ,@(when matches 324 | (cl-loop for text in matches 325 | collect `(or (like title ,(concat "%" text "%")) 326 | (like content ,(concat "%" text "%"))))) 327 | ,@(when titles 328 | (cl-loop for title in titles 329 | collect `(like title ,(concat "%" title "%")))) 330 | ,@(when authors 331 | (cl-loop for author in authors 332 | collect `(like published_by ',(concat "%" author "%")))) 333 | ,@(when have-tag 334 | (cl-loop for tag in have-tag 335 | collect `(like tags ',(concat "%\"" tag "\"%")))) 336 | ,@(when domains 337 | (cl-loop for domain in domains 338 | collect `(like domain_name ,(concat "%" domain "%")))) 339 | ,@(when not-have-tag 340 | (cl-loop for tag in not-have-tag 341 | collect `(not (like tags ',(concat "%\"" tag "\"%"))))) 342 | ,@(when add-dates 343 | (if (cdr add-dates) 344 | `((<= ,(car add-dates) created_at ,(cdr add-dates))) 345 | `((like created_at ,(concat (car add-dates) "%"))))) 346 | ,@(when pub-dates 347 | (if (cdr pub-dates) 348 | `((<= ,(car pub-dates) published_at ,(cdr pub-dates))) 349 | `((like published_at ,(concat (car pub-dates) "%"))))) 350 | ,@(when (or under-time over-time) 351 | `((< ,@over-time reading_time ,@under-time))) 352 | ,@(when not-urls 353 | (cl-loop for link in not-urls 354 | collect `(not (like url ,(concat "%" link "%"))))) 355 | ,@(when not-matches 356 | (cl-loop for text in not-matches 357 | collect `(not (or (like title ,(concat "%" text "%")) 358 | (like content ,(concat "%" text "%")))))) 359 | ,@(when reading 360 | (if-let ((ids (hash-table-keys w-show--positions-table))) 361 | `((in id ,(vconcat ids))) 362 | `((= id 0))))))) 363 | :order-by (desc id) 364 | ,@(when limit (list :limit limit))))))) 365 | 366 | 367 | ;;; Search buffer display 368 | 369 | (defvar w-search-trailing-width 38) 370 | 371 | (defvar w-search-min-title-width 30) 372 | (defvar w-search-max-title-width 70) 373 | (defvar w-search-title-width w-search-min-title-width) 374 | 375 | (defun w-search-print-entries (entries) 376 | "Print ENTRIES to the Wombag search buffer." 377 | (with-current-buffer (w-search-buffer) 378 | (let ((inhibit-read-only t)) 379 | (erase-buffer) 380 | (setq w-search-title-width 381 | (min (max (- (window-width (get-buffer-window "*wombag-search*")) 382 | 10 w-search-trailing-width) 383 | w-search-min-title-width) 384 | w-search-max-title-width)) 385 | (mapc #'w-search-print-entry--default entries)))) 386 | 387 | (defun w-search-format-entry (entry) 388 | "Wombag ENTRY as string." 389 | (let* ((title (or (alist-get 'title entry) "NO TITLE")) 390 | (created-at (alist-get 'created_at entry)) 391 | (published-at (alist-get 'published_at entry)) 392 | (reading-time (alist-get 'reading_time entry)) 393 | (is-archived (alist-get 'is_archived entry)) 394 | (is-starred (alist-get 'is_starred entry)) 395 | (tag (alist-get 'tag entry)) 396 | (domain-name (or (alist-get 'domain_name entry) "")) 397 | (authors (mapconcat #'identity (alist-get 'published_by entry) ","))) 398 | (format "%s %s %s %s (%s)" 399 | ;; (substring created-at 0 10) 400 | (propertize 401 | (or (substring created-at 0 10) 402 | (and published-at (substring published-at 0 10))) 403 | 'face 'w-date-face) 404 | (propertize 405 | (truncate-string-to-width title w-search-title-width nil ? nil) 406 | 'face (if (= is-archived 1) 'w-archive-face 'w-title-face)) 407 | (propertize (format "%3d min" reading-time) 'face 'w-reading-time-face) 408 | (propertize ;; (if (string= authors "") domain-name authors) 409 | domain-name 'face 'w-domain-face) 410 | (concat 411 | (and (eq is-starred 1) 412 | (concat (propertize "★" 'face 'w-starred-face) 413 | (unless (string-empty-p tag) ","))) 414 | (propertize tag 'face 'w-tag-face))))) 415 | 416 | (defun w-search-print-entry--default (entry) 417 | "Print ENTRY to the buffer." 418 | (unless (equal entry "") 419 | (let (beg end) 420 | (setq beg (point)) 421 | (insert (w-search-format-entry entry)) 422 | (setq end (point)) 423 | ;; format the tag and push into attr alist 424 | (put-text-property beg end 'w-entry entry) 425 | (put-text-property beg end 'w-id (alist-get 'id entry)) 426 | (insert "\n")))) 427 | 428 | (defun w-search-header () 429 | "Return the string to be used as the wombag header." 430 | (format "%s: %s %s" 431 | (propertize "Wombag" 'face font-lock-preprocessor-face) 432 | (if w-retrieving 433 | (propertize w-retrieving 'face font-lock-warning-face) 434 | (concat 435 | (propertize (format "Total %s, " (if w-search--filter-overflow 436 | "??" (car (buffer-line-statistics)))) 437 | 'face font-lock-warning-face) 438 | (propertize w-search-filter 'face 'font-lock-keyword-face) 439 | ;; (propertize (let ((len (length (w-find-marked-candidates)))) 440 | ;; (if (> len 0) 441 | ;; (concat "Marked: " (number-to-string len)) "")) 'face font-lock-negation-char-face) 442 | )) 443 | (concat 444 | (propertize " " 'display `(space :align-to (- right ,(length w-host)))) 445 | (propertize (format "%s" w-host) 'face font-lock-type-face)))) 446 | 447 | 448 | ;;; Search buffer movement and interaction 449 | 450 | (defun w-search-update--force (&optional keep-header) 451 | "Force refresh view of the article listing. 452 | 453 | When KEEP-HEADER is non-nil, don't reset the header message." 454 | (interactive) 455 | (let ((line (line-number-at-pos))) 456 | (w-search-update :force) 457 | (unless keep-header (setq w-retrieving nil)) 458 | (goto-char (point-min)) 459 | (forward-line (1- line)))) 460 | 461 | (defun w-search-quit-window (&optional arg) 462 | "Quit Wombag and close the database. 463 | 464 | With prefix ARG only quit Wombag." 465 | (interactive "P") 466 | (unless arg (w-db--close)) 467 | (when-let ((buf (get-buffer w-show-buffer-name))) 468 | (if-let ((win (get-buffer-window buf))) 469 | (quit-window 'kill win) 470 | (kill-buffer buf))) 471 | (quit-window 'kill)) 472 | 473 | (defun w-search-selected (&optional ignore-region-p) 474 | ";TODO:" 475 | (let ((use-region (and (not ignore-region-p) (use-region-p)))) 476 | (let ((beg (if use-region (region-beginning) (point))) 477 | (end (if use-region (region-end) (point))) 478 | (entries)) 479 | (save-excursion 480 | (goto-char beg) 481 | (while (and (not (eobp)) (<= (point) end)) 482 | (push (get-text-property (point) 'w-entry) entries) 483 | (forward-line 1)) 484 | (if ignore-region-p 485 | (car entries) 486 | (nreverse entries)))))) 487 | 488 | (defun w-search-show-entry (entry) 489 | "Show Wombag ENTRY at point." 490 | (interactive (list (w-search-selected :ignore-region))) 491 | (when entry (w-show-entry entry))) 492 | 493 | (defmacro w-search--with-entry (&rest body) 494 | `(if-let ((entry (get-text-property (point) 'w-entry))) 495 | ,(macroexp-progn body) 496 | (message "No Wombag entry at point."))) 497 | 498 | (defun w-search-browse-url () 499 | "Open Wombag entry at point using `browse-url'." 500 | (interactive) 501 | (w-search--with-entry 502 | (when-let ((url (map-elt entry 'url))) 503 | (funcall w-browse-url-function url)))) 504 | 505 | (defun w-search-eww-open () 506 | "Open Wombag entry at point in EWW." 507 | (interactive) 508 | (w-search--with-entry 509 | (when-let ((url (map-elt entry 'url))) 510 | (eww url)))) 511 | 512 | (defun w-search-copy () 513 | "Copy URL of Wombag entry at point." 514 | (interactive) 515 | (w-search--with-entry 516 | (when-let ((url (map-elt entry 'url))) 517 | (kill-new url) 518 | (message "Copied to kill-ring: \"%s\"" url)))) 519 | 520 | (defun w-search--eob () 521 | "Go to the last line of the Wombag search buffer." 522 | (interactive) 523 | (prog1 (goto-char (point-max)) 524 | (when (eq (point) (line-beginning-position)) 525 | (forward-line -1)))) 526 | 527 | 528 | ;;; Resume search session 529 | ;;;###autoload 530 | (defun w-resume () 531 | "Resume reading Wombag articles. 532 | 533 | This limits the Wombag listing to articles that you have begun 534 | reading but not finished." 535 | (interactive) 536 | (call-interactively #'wombag) 537 | (if-let* ((ids (hash-table-keys w-show--positions-table)) 538 | (entries (w-db-get-entries 539 | `[:select ,(vconcat w-search-columns) :from items 540 | :where (in id ,(vconcat ids)) 541 | :order-by (desc created_at)] 542 | w-search-columns))) 543 | (let ((line (with-current-buffer (w-search-buffer) 544 | (line-number-at-pos)))) 545 | (w-search-print-entries entries) 546 | (goto-char (point-min)) 547 | (when line 548 | (forward-line (1- line)) 549 | (when (eobp) (forward-line -1))) 550 | (length entries)) 551 | (message "No entries to resume."))) 552 | 553 | 554 | ;;; Update/delete entries in search buffer 555 | (defconst w-search--update-fields 556 | '(("archive" is_archived "Archiving...") 557 | ("starred" is_starred "Starring..."))) 558 | 559 | (defun w-search--updater (method) 560 | "Update Wombag entry at point using METHOD." 561 | (lambda () 562 | "Update Wombag entry at point." 563 | (interactive) 564 | (pcase-let* ((id (get-text-property (point) 'w-id)) 565 | (entry (get-text-property (point) 'w-entry)) 566 | (`(,field ,msg) (map-elt w-search--update-fields method)) 567 | (oldval (map-elt entry field)) 568 | (location (point))) 569 | (let ((newval (pcase oldval 570 | ('1 0) 571 | ('0 1)))) 572 | (setq w-retrieving msg) 573 | (request (format "%s/api/entries/%d" w-host id) 574 | :type "PATCH" 575 | :params `(("access_token" . ,w-token) 576 | ("detail" . "metadata")) 577 | :parser 'json-read 578 | :data (json-encode `(("access_token" . ,w-token) 579 | (,method . ,newval))) 580 | :headers '(("Content-Type" . "application/json") 581 | ("Prefer" . "return=minimal")) 582 | :status-code `((401 . ,(w--retry-with-token 583 | (intern-soft (format "wombag-search-%s-entry" method))))) 584 | :success 585 | (cl-function 586 | (lambda (&key data &allow-other-keys) 587 | (setq w-retrieving nil) 588 | (with-current-buffer (w-search-buffer) 589 | (let ((state (map-elt data field)) 590 | (updated-at (map-elt data 'updated_at)) 591 | (inhibit-read-only t)) 592 | ;; (message "%S" data) 593 | ;; (message "state: %S" state) 594 | (w-db-update field id state) 595 | (w-db-update 'updated_at id updated-at) 596 | (save-excursion 597 | (goto-char location) 598 | (delete-line) 599 | (w-search-print-entry--default 600 | (car (w-db-get-ids id)))))))) 601 | :error #'w--debug))))) 602 | 603 | (defalias 'w-search-archive-entry (w-search--updater "archive") "Archive entry at point") 604 | (defalias 'w-search-starred-entry (w-search--updater "starred") "Star entry at point") 605 | 606 | (defun w-search-add-tags (addtags remtags) 607 | "Add or remove tags to Wombag entry at point. 608 | 609 | NOTE: Removing tags is not yet implemented. 610 | 611 | ADDTAGS and REMTAGS are the tags to be added and removed 612 | respectively." 613 | (interactive (let ((alltags (split-string 614 | (read-string "Tag or untag (+tag1 -tag2 ...): " 615 | (if (equal this-command 'w-search-remove-tags) 616 | "-" "+"))))) 617 | (cl-loop for tag in alltags 618 | when (string-prefix-p "+" tag) collect (substring tag 1) into addtags 619 | when (string-prefix-p "-" tag) collect (substring tag 1) into remtags 620 | finally return (list addtags remtags)))) 621 | (let ((id (get-text-property (point) 'w-id)) 622 | (location (point))) 623 | (setq w-retrieving "Tagging...") 624 | (when addtags 625 | (request (format "%s/api/entries/%d/tags" w-host id) 626 | :type "POST" 627 | :params `(("access_token" . ,w-token) 628 | ("detail" . "metadata")) 629 | :parser 'json-read 630 | :data (json-encode `(("access_token" . ,w-token) 631 | (tags . ,(mapconcat #'identity addtags ",")))) 632 | :headers '(("Content-Type" . "application/json") 633 | ("Prefer" . "return=minimal")) 634 | :status-code `((401 . ,(w--retry-with-token #'w-search-add-tags addtags remtags))) 635 | :success 636 | (cl-function 637 | (lambda (&key data &allow-other-keys) 638 | (setq w-retrieving nil) 639 | (with-current-buffer (w-search-buffer) 640 | (let ((tags (map-elt data 'tags)) 641 | (updated-at (map-elt data 'updated_at)) 642 | (inhibit-read-only t)) 643 | ;; (message "%S" data) 644 | ;; (message "state: %S" state) 645 | (print (map-elt data 'tags) (get-buffer "*scratch*")) 646 | (w-db-update 647 | 'tag id (mapconcat (lambda (el) (map-elt el 'label)) (map-elt data 'tags) ",")) 648 | (when (vectorp tags) (setq tags `',tags)) 649 | (w-db-update 'tags id tags) 650 | (w-db-update 'updated_at id updated-at) 651 | (save-excursion 652 | (goto-char location) 653 | (delete-line) 654 | (w-search-print-entry--default 655 | (car (w-db-get-ids id)))))))) 656 | :error #'w--debug)) 657 | (when remtags 658 | (setq w-retrieving nil) 659 | (message "Removing tags is not implemented, sorry!")))) 660 | 661 | (defalias 'w-search-remove-tags 'w-search-add-tags) 662 | 663 | (defun w-search-delete-entry (&optional no-confirm) 664 | "Delete Wombag entry at point. 665 | 666 | When NO-CONFIRM is non-nil, do not ask for confirmation." 667 | (interactive (list (not (yes-or-no-p "Delete entry at point? ")))) 668 | (when-let (((not no-confirm)) 669 | (id (get-text-property (point) 'w-id)) 670 | (entry (get-text-property (point) 'w-entry)) 671 | (location (point))) 672 | (setq w-retrieving "Deleting...") 673 | (request (format "%s/api/entries/%d" w-host id) 674 | :parser 'json-read 675 | :type "DELETE" 676 | :params `(("access_token" . ,w-token)) 677 | :headers '(("Content-Type" . "application/json") ("Prefer" . "return=minimal")) 678 | :status-code `((401 . ,(w--retry-with-token #'w-search-delete-entry)) 679 | (404 . ,(cl-function 680 | (lambda (&key _data &allow-other-keys) 681 | (w-search--remove-from-listing id location))))) 682 | :success 683 | (cl-function 684 | (lambda (&key _data &allow-other-keys) 685 | (w-search--remove-from-listing id location))) 686 | :error #'w--debug))) 687 | 688 | (defun w-search--remove-from-listing (id location) 689 | (setq w-retrieving nil) 690 | (with-current-buffer (w-search-buffer) 691 | (let ((inhibit-read-only t)) 692 | (w-db-delete id) 693 | (save-excursion 694 | (goto-char location) 695 | (delete-line))))) 696 | 697 | 698 | ;;; Search buffer major mode 699 | 700 | (defvar-keymap w-search-mode-map 701 | :doc "Keymap for `wombag-search-mode'." 702 | :suppress t 703 | "" #'w-search-show-entry 704 | "<" #'beginning-of-buffer 705 | ">" #'w-search--eob 706 | "G" #'w-sync 707 | "B" #'w-search-eww-open 708 | "&" #'w-search-browse-url 709 | "x" #'w-search-browse-url 710 | "s" #'w-search-live-filter 711 | "q" #'w-search-quit-window 712 | "g" #'w-search-update--force 713 | "R" #'w-add-entry 714 | "D" #'w-search-delete-entry 715 | "n" #'next-line 716 | "p" #'previous-line 717 | "w" #'w-search-copy 718 | "+" #'w-search-add-tags 719 | "-" #'w-search-remove-tags 720 | "A" #'w-search-archive-entry 721 | "F" #'w-search-starred-entry) 722 | 723 | (define-derived-mode w-search-mode fundamental-mode "wombag-search" 724 | "Major mode for listing wombag entries. 725 | \\{wombag-search-mode-map}" 726 | (setq truncate-lines t 727 | buffer-read-only t 728 | header-line-format '(:eval (w-search-header))) 729 | (buffer-disable-undo) 730 | (w-search-update :force) 731 | (goto-char (point-min)) 732 | (hl-line-mode 1) 733 | (add-hook 'minibuffer-setup-hook 'w-search--minibuffer-setup) 734 | (setq-local bookmark-make-record-function 735 | #'w-search-bookmark-make-record)) 736 | 737 | (provide 'wombag-search) 738 | ;;; wombag-search.el ends here 739 | 740 | ;; Local Variables: 741 | ;; read-symbol-shorthands: (("w-" . "wombag-")) 742 | ;; End: 743 | -------------------------------------------------------------------------------- /wombag-show.el: -------------------------------------------------------------------------------- 1 | ;;; wombag-show.el --- Wombag article interface -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | 7 | ;; This program is free software; you can redistribute it and/or modify 8 | ;; it under the terms of the GNU General Public License as published by 9 | ;; the Free Software Foundation, either version 3 of the License, or 10 | ;; (at your option) any later version. 11 | 12 | ;; This program is distributed in the hope that it will be useful, 13 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | ;; GNU General Public License for more details. 16 | 17 | ;; You should have received a copy of the GNU General Public License 18 | ;; along with this program. If not, see . 19 | 20 | ;;; Commentary: 21 | 22 | ;; Wombag article interface 23 | 24 | ;;; Code: 25 | (require 'cl-lib) 26 | (require 'shr) 27 | (require 'browse-url) 28 | (require 'bookmark) 29 | (require 'wombag-options) 30 | (require 'wombag-heading) 31 | (require 'wombag-db) 32 | 33 | (declare-function w-search-buffer "wombag-search") 34 | 35 | (bookmark-maybe-load-default-file) 36 | 37 | (defun w-show-buffer () 38 | "Return the Wombag entry buffer." 39 | (get-buffer "*wombag-entry*")) 40 | 41 | (defvar-local w-show-entry nil 42 | "The Wombag entry displayed in this buffer.") 43 | 44 | 45 | ;;; Bookmarks 46 | ;;;###autoload 47 | (defun w-show-bookmark-handler (record) 48 | "Show the bookmarked entry saved in the `RECORD'." 49 | (let* ((id (bookmark-prop-get record 'id)) 50 | (entry (car (w-db-get-ids id))) 51 | (position (bookmark-get-position record))) 52 | (set-buffer (w-show-entry entry)) 53 | (goto-char position))) 54 | 55 | (defun w-show-bookmark-make-record () 56 | "Save the current position and the entry into a bookmark." 57 | (let ((id (alist-get 'id w-show-entry)) 58 | (position (point)) 59 | (title (alist-get 'title w-show-entry))) 60 | `(,(format "(Wombag) \"%s\"" title) 61 | (id . ,id) 62 | ;; (location . ,title) 63 | (position . ,position) 64 | (handler . w-show-bookmark-handler)))) 65 | 66 | 67 | ;;; Saving entry positions 68 | (defvar w-show--positions-file 69 | (file-name-concat w-dir "positions.eld")) 70 | 71 | (defvar w-show--positions-table 72 | (if (file-exists-p w-show--positions-file) 73 | (condition-case nil 74 | (let ((coding-system-for-read 'utf-8)) 75 | (with-temp-buffer 76 | (insert-file-contents w-show--positions-file) 77 | (goto-char (point-min)) 78 | (read (current-buffer)))) 79 | (error (message "Could not sync Wombag positions, starting over.") 80 | (make-hash-table :size 1024))) 81 | (make-hash-table :size 1024))) 82 | 83 | (defun w-show--positions-save () 84 | (when-let ((id (alist-get 'id w-show-entry))) 85 | (if (or (bobp) (eobp)) 86 | (remhash id w-show--positions-table) 87 | (puthash id (point) w-show--positions-table)))) 88 | 89 | (defun w-show--positions-write () 90 | (when (and (boundp 'w-show--positions-table) 91 | (hash-table-p w-show--positions-table) 92 | (not (hash-table-empty-p w-show--positions-table))) 93 | (let ((write-region-inhibit-fsync t) 94 | (coding-system-for-write 'utf-8) 95 | (print-level nil) 96 | (print-length nil)) 97 | (with-temp-file w-show--positions-file 98 | (insert ";;; -*- lisp-data -*-\n" 99 | (prin1-to-string w-show--positions-table)))))) 100 | 101 | (add-hook 'kill-emacs-hook #'w-show--positions-write) 102 | 103 | 104 | ;;; Rendering the entry buffer 105 | (defun w-show-render-html (begin end) 106 | "Render HTML in current buffer with shr. 107 | 108 | Render from positions BEGIN to END." 109 | (run-hooks 'w-pre-html-render-hook) 110 | (shr-render-region begin end) 111 | (run-hooks 'w-post-html-render-hook)) 112 | 113 | (defun w-show-entry (entry) 114 | "Read Wombag ENTRY at point." 115 | (let ((buf (get-buffer-create w-show-buffer-name)) 116 | (title (alist-get 'title entry "(UNTITLED)")) 117 | (reading-time (alist-get 'reading_time entry)) 118 | (created-at (alist-get 'created_at entry)) 119 | ;; (tag (alist-get 'tag entry)) 120 | (domain-name (or (alist-get 'domain_name entry) "")) 121 | (id (alist-get 'id entry)) 122 | (url (alist-get 'url entry)) 123 | (beg) (end)) 124 | (with-current-buffer buf 125 | (delay-mode-hooks 126 | (w-show-mode) 127 | (funcall w-show-entry-switch buf) 128 | (let ((inhibit-read-only t)) 129 | (erase-buffer) 130 | (insert (propertize title 'face 'w-show-title-face) 131 | "\n") 132 | (insert (format "%s %s %s\n\n\n" 133 | (propertize (format "%d min" reading-time) 134 | 'face 'w-reading-time-face) 135 | (propertize (substring created-at 0 10) 136 | 'face 'w-date-face) 137 | ;; No DATA or HELP-ECHO args, the latter is not supported on Emacs 28.2 138 | (propertize (button-buttonize 139 | domain-name (lambda (_) (browse-url url))) 140 | 'face 'w-domain-face))) 141 | (if-let ((content (car 142 | (car-safe 143 | (w-db-query `[:select content :from items :where (= id ,id)]))))) 144 | (progn (setq beg (point)) 145 | (insert content) 146 | (setq end (point)) 147 | (w-show-render-html beg end)) 148 | (insert (propertize "(empty)" 'face 'warning))) 149 | (if-let ((pos (gethash id w-show--positions-table))) 150 | (prog1 (goto-char pos) 151 | ;; (recenter next-screen-context-lines) 152 | (recenter) 153 | ) 154 | (goto-char (point-min))))) 155 | (setq w-show-entry entry) 156 | (run-mode-hooks) 157 | (current-buffer)))) 158 | 159 | ;;; Actions in the entry buffer 160 | (defun w-show-quit-window () 161 | "Close this Wombag article." 162 | (interactive) 163 | (quit-window 'kill) 164 | (when-let* ((buf (w-search-buffer)) 165 | (win (get-buffer-window buf))) 166 | (if (window-live-p win) 167 | (select-window win)))) 168 | 169 | (defun w-show-disable-images () 170 | "Disable images in this Wombag buffer." 171 | (interactive) 172 | (when-let ((entry w-show-entry) 173 | (shr-inhibit-images t)) 174 | (w-show-entry entry))) 175 | 176 | 177 | ;;; Major mode 178 | (defvar-keymap w-show-mode-map 179 | :doc "Keymap for `wombag-show-mode'." 180 | "TAB" #'shr-next-link 181 | "" #'shr-previous-link 182 | "SPC" #'scroll-up-command 183 | "S-SPC" #'scroll-down-command 184 | "DEL" #'scroll-down-command 185 | "<" #'beginning-of-buffer 186 | ">" #'end-of-buffer 187 | "q" #'w-show-quit-window 188 | "I" #'w-show-disable-images 189 | "C-c C-n" #'w-heading-next 190 | "C-c C-p" #'w-heading-previous) 191 | 192 | (define-derived-mode w-show-mode fundamental-mode 193 | "wombag-entry" 194 | "Mode for displaying wombag entry details. 195 | \\{wombag-show-mode-map}" 196 | (setq buffer-read-only t) 197 | (buffer-disable-undo) 198 | (setq-local bookmark-make-record-function 199 | #'w-show-bookmark-make-record) 200 | (w-heading-setup-imenu) 201 | (add-hook 'kill-buffer-hook 202 | #'w-show--positions-save nil 'local)) 203 | 204 | (provide 'wombag-show) 205 | ;;; wombag-show.el ends here 206 | 207 | ;; Local Variables: 208 | ;; read-symbol-shorthands: (("w-" . "wombag-")) 209 | ;; End: 210 | -------------------------------------------------------------------------------- /wombag.el: -------------------------------------------------------------------------------- 1 | ;;; wombag.el --- A Wallabag Client -*- lexical-binding: t; -*- 2 | 3 | ;; Copyright (C) 2023 Karthik Chikmagalur 4 | 5 | ;; Author: Karthik Chikmagalur 6 | ;; Version: 0.1.0 7 | ;; Package-Requires: ((emacs "28.1") (emacsql "3.1.1") (request "0.3.3") (compat "29.1.0")) 8 | ;; Keywords: multimedia, extensions 9 | ;; URL: https://github.com/karthink/wombag 10 | 11 | ;; This file is NOT part of GNU Emacs. 12 | 13 | ;; This program is free software; you can redistribute it and/or modify 14 | ;; it under the terms of the GNU General Public License as published by 15 | ;; the Free Software Foundation, either version 3 of the License, or 16 | ;; (at your option) any later version. 17 | 18 | ;; This program is distributed in the hope that it will be useful, 19 | ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | ;; GNU General Public License for more details. 22 | 23 | ;; You should have received a copy of the GNU General Public License 24 | ;; along with this program. If not, see . 25 | 26 | ;;; Commentary: 27 | 28 | ;; Wombag is a Wallabag client for Emacs. 29 | ;; 30 | ;; Wallabag is an (optionally self-hosted) read-it-later or web page archival 31 | ;; service. To use this package you need a Wallabag account, or access to a 32 | ;; server running Wallabag. 33 | ;; 34 | ;; Usage: 35 | ;; 36 | ;; Set the following parameters: 37 | ;; (setq wombag-host "https://app.wallabag.it" ;where you access Wallabag 38 | ;; wombag-username "my-wallabag-username" 39 | ;; wombag-password "my-wallabag-password" 40 | ;; wombag-client-id "abcdefgh1234" 41 | ;; wombag-client-secret "abcdefgh1234")) 42 | ;; 43 | ;; - Start Wombag with M-x `wombag'. 44 | ;; 45 | ;; - Sync your reading list with the server using `wombag-sync' (`G' in the 46 | ;; Wombag buffer) 47 | ;; 48 | ;; - Filter entries with `s', press `?' for help with searching. 49 | ;; 50 | ;; - You can read, archive, star, tag or delete entries. 51 | ;; - You can bookmark Wombag searches/entries, and navigate entries with `imenu'. 52 | 53 | ;;; Code: 54 | (eval-when-compile 55 | (require 'cl-lib) 56 | (require 'subr-x)) 57 | (require 'compat) 58 | (require 'map) 59 | (require 'request) 60 | (require 'json) 61 | (require 'wombag-options) 62 | (require 'wombag-db) 63 | 64 | (declare-function w-search-buffer 'wombag-search) 65 | (declare-function w-search-mode 'wombag-search) 66 | (declare-function w-search-update--force 'wombag-search) 67 | (defvar w-retrieving) 68 | 69 | 70 | ;;; Utility vars and functions 71 | (defvar w-token-file (file-name-concat w-dir "token") 72 | "File used to store the token. 73 | 74 | NOTE: This is currently not implemented.") 75 | (defvar w-token nil) 76 | (defvar w-version nil) 77 | (defvar w-appname nil) 78 | (defvar w-data nil) 79 | 80 | (defvar w--debug t) 81 | 82 | (cl-defun w--debug (&key data error-thrown symbol-status response &allow-other-keys) 83 | "Handle `request' errors when interacting with Wombag. 84 | 85 | DATA, ERROR-THROWN, SYMBOL-STATUS and RESPONSE have their usual 86 | meanings in a `request' callback, see `request'." 87 | (let ((status-code (request-response-status-code response)) 88 | (error-desc (cdr-safe error-thrown)) 89 | (data-desc (map-elt data 'error_description))) 90 | (unless (eq status-code 401) ;Handled elsewhere 91 | (setq w-retrieving 92 | (concat (format "%S: %S " symbol-status error-desc) data-desc)) 93 | (when w--debug 94 | (with-current-buffer (get-buffer-create "*Wombag Error*") 95 | (with-silent-modifications (erase-buffer)) 96 | (insert ";; Request failed with error: \n" (pp-to-string symbol-status) 97 | "\n\n;; Error data is:\n" (pp-to-string error-thrown) 98 | "\n\n;; Data is:\n" (pp-to-string data)) 99 | (unless (eq major-mode 'emacs-lisp-mode) (emacs-lisp-mode)) 100 | (display-buffer-in-side-window (current-buffer) 101 | '((side . bottom) 102 | (slot . -40) 103 | (window-height . 10)))))))) 104 | 105 | 106 | ;;; Fetching data: Remote to local 107 | 108 | (cl-defun w-get-token (&key callback args) 109 | "Request a Wallabag token. 110 | 111 | If provided, call CALLBACK with ARGS afterwards." 112 | (interactive) 113 | (request (format "%s/oauth/v2/token" w-host) 114 | :parser 'json-read 115 | :params `(("username" . ,w-username) 116 | ("password" . ,w-password) 117 | ("client_id" . ,w-client-id) 118 | ("client_secret" . ,w-client-secret) 119 | ("grant_type" . "password")) 120 | :headers '(("Content-Type" . "application/json")) 121 | :sync t 122 | :success (cl-function 123 | (lambda (&key data &allow-other-keys) 124 | (setf w-token (alist-get 'access_token data)) 125 | (when w--debug (message "Wombag token acquired.")) 126 | (when callback (apply callback args)))) 127 | :error #'w--debug)) 128 | 129 | (defun w--retry-with-token (func &rest args) 130 | "Retrieve a Wombag token and call FUNC with ARGS." 131 | (cl-function 132 | (lambda (&key data error-thrown &allow-other-keys) 133 | (if (not (equal (map-elt data 'error) "invalid_grant")) 134 | (message (format "Request failed with: %S" error-thrown)) 135 | (setq w-retrieving "Authenticating...") 136 | (w-get-token :callback func :args args))))) 137 | 138 | (defsubst w--sync-message (num-total) 139 | "Adjust Wombag header message stating NUM-TOTAL." 140 | (setq w-retrieving 141 | (if (= num-total 0) 142 | "Retrieving... already up to date" 143 | (format "Retrieving... %d entries added" num-total)))) 144 | 145 | ;;----------------8<------------------- 146 | ;; (defvar w-all-entries nil) 147 | ;; (defvar w-local-ids nil) 148 | ;;----------------8<------------------- 149 | (cl-defun w-sync (&key since page num-total local-ids full) 150 | "Synchronize the local Wombag database. 151 | 152 | This will update the local state of Wombag to that the server: 153 | - Fetch new entries since the last update 154 | - Update all metadata (archived/starred/annotations etc) 155 | 156 | With `prefix-arg' \\[universal-argument], query for date to sync 157 | from. 158 | 159 | By default, it will not delete local entries that have been 160 | deleted on the server. 161 | 162 | With double prefix-arg \\[universal-argument] 163 | \\[universal-argument], do a full sweep of the database and 164 | delete all entries not on the Server. 165 | NOTE: This is not yet implemented. 166 | 167 | Keywords: 168 | 169 | SINCE: Unix timestamp or date formatted as \"YYYY-MM-DD\" to sync 170 | upwards from. (Determined automatically when not provided.) 171 | 172 | FULL: If non-nil, perform a full sweep of deleted entries on the 173 | server and delete them locally. 174 | 175 | The remaining keywords are for internal use only 176 | 177 | PAGE: Page number of entries. 178 | NUM-TOTAL: Running total of new entries 179 | LOCAL-IDS: Ids from SINCE available locally." 180 | (interactive 181 | (list :since 182 | (when (= (prefix-numeric-value current-prefix-arg) 4) 183 | (read-string "Sync from (2023-09-01): ")) 184 | :full (and (= (prefix-numeric-value current-prefix-arg) 16) 185 | (y-or-n-p 186 | "Do a full sweep of the database (This will transfer a lot of data)? ")))) 187 | (unless num-total 188 | (setq w-retrieving "Retrieving..." 189 | num-total (or num-total 0))) 190 | ;;----------------8<------------------- 191 | ;; (setq w-all-entries nil 192 | ;; w-local-ids nil) 193 | ;;----------------8<------------------- 194 | (if since 195 | (unless (numberp since) 196 | (if (string-match-p "[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" since) 197 | (setq since (floor (float-time (date-to-time since)))) 198 | (user-error "Date %s does not match format YYYY-MM-DD" since))) 199 | (setq since (or (caar (w-db-query `[:select fetch-all :from last_update])) 1))) 200 | (request (format "%s/api/entries" w-host) 201 | :parser 'json-read 202 | :params `(("access_token" . ,w-token) 203 | ("sort" . "created") 204 | ("order" . "desc") 205 | ("page" . ,(or page 1)) 206 | ("perPage" . 30) 207 | ("detail" . "full") 208 | ("since" . ,since)) 209 | :headers '(("Content-Type" . "application/json")) 210 | :status-code `((401 . ,(w--retry-with-token #'w-sync 211 | :page page :since since :full full)) 212 | (404 . ,(lambda (&rest _) 213 | (w-db-update-date (float-time)) 214 | (w--sync-message num-total) 215 | (when full (w--sweep-deleted-entries))))) 216 | :success 217 | (cl-function 218 | (lambda (&key data &allow-other-keys) 219 | "Update Wombag db if necessary" 220 | (let ((num-new) 221 | (all-entries (map-nested-elt data '(_embedded items))) 222 | (local-ids (or local-ids 223 | (apply #'nconc 224 | (w-db-query 225 | `[:select id :from items 226 | :where (>= updated_at ;created_at 227 | ,(format-time-string "%Y-%m-%dT%H:%M:%S" since t)) 228 | :order-by (desc id)]))))) 229 | ;;----------------8<------------------- 230 | ;; (setq w-all-entries 231 | ;; (nconc w-all-entries all-entries)) 232 | ;; (setq w-local-ids 233 | ;; (nconc w-local-ids local-ids)) 234 | ;;----------------8<------------------- 235 | (if local-ids 236 | (let ((server-ids)) 237 | (cl-loop for entry across all-entries 238 | for id = (map-elt entry 'id) 239 | do (push id server-ids) 240 | if (memq id local-ids) 241 | collect entry into updated-entries 242 | else collect entry into new-entries 243 | finally do 244 | (when new-entries 245 | (w--insert-entries :data (vconcat new-entries) :replace t)) 246 | (when updated-entries 247 | (w--insert-entries :data (vconcat updated-entries) :replace t)) 248 | (setq num-new (length new-entries))) 249 | (when-let ((deleted-ids (cl-set-difference local-ids server-ids))) 250 | (w-db-delete (vconcat deleted-ids)))) 251 | (unless (= 0 (length all-entries)) (w--insert-entries :data all-entries)) 252 | (setq num-new (length all-entries))) 253 | (if (>= (length all-entries) 30) 254 | ;; There might be more entries 255 | (run-with-idle-timer 256 | 1 nil #'w-sync 257 | :page (1+ (or page 1)) 258 | :since since 259 | :num-total (+ num-new num-total) 260 | :local-ids local-ids 261 | :full full) 262 | (w-db-update-date (float-time)) 263 | (when full (w--sweep-deleted-entries))) 264 | (w--sync-message (+ num-new num-total))))) 265 | :error #'w--debug)) 266 | 267 | (defun w--sweep-deleted-entries () 268 | ";TODO: Sweeping all deleted entries not implemented yet." 269 | (message "Sweeping all deleted entries not implemented yet.")) 270 | 271 | ;;;; Updating the database: 272 | (cl-defun w--insert-entries (&key data replace &allow-other-keys) 273 | "Insert entries in DATA into the Wombag database. 274 | 275 | If keyword REPLACE is non-nil, replace entries if they already exist." 276 | (condition-case-unless-debug parse-error 277 | (if (not data) 278 | (message "Parse error! Could not extract entry data.") 279 | (or (vectorp data) (setq data (vector data))) 280 | (when w--debug (message "Running insert entry")) 281 | (prog1 (w-db-insert data replace) 282 | (let ((inhibit-message t) 283 | (num (length data))) 284 | (if (= num 1) 285 | (message "Entry added to Wombag.") 286 | (message "%d entries added to Wombag." num))) 287 | (when-let* (((featurep 'w-search)) 288 | (buf (w-search-buffer :if-live)) 289 | (win (get-buffer-window buf)) 290 | ((window-live-p win))) 291 | (with-selected-window win 292 | (w-search-update--force :keep-header))))) 293 | (error (message "Couldn't insert entries into database: %S" (car parse-error))))) 294 | 295 | 296 | ;;; Sending data: Local to remote 297 | 298 | ;;;###autoload 299 | (defun w-add-entry (url &optional tags) 300 | "Add an entry (from URL) to the Wombag database. 301 | 302 | Interactively, query for TAGS as well. TAGS must be a 303 | comma-separated string." 304 | (interactive 305 | (list (read-string "URL to add to Wombag: ") 306 | (split-string (read-string "Tags (comma separated): ") "," t "\\s-+"))) 307 | (request (format "%s/api/entries" w-host) 308 | :parser 'json-read 309 | :type "POST" 310 | :params `(("access_token" . ,w-token)) 311 | :data (json-encode 312 | `(("url" . ,url) 313 | ("archive" . 0) 314 | ("starred" . 0) 315 | ("tags" . ,(or tags "")))) 316 | :headers '(("Content-Type" . "application/json")) 317 | :error #'w--debug 318 | :status-code `((401 . ,(w--retry-with-token 319 | (lambda () (w-add-entry url tags))))) 320 | :success #'w--insert-entries)) 321 | 322 | 323 | ;;; Main command 324 | ;;;###autoload 325 | (defun wombag () 326 | "Open Wombag." 327 | (interactive) 328 | (w-db-ensure) 329 | (require 'w-search) 330 | (pop-to-buffer-same-window (w-search-buffer)) 331 | (unless (eq major-mode 'w-search-mode) 332 | (w-search-mode))) 333 | 334 | (provide 'wombag) 335 | ;;; wombag.el ends here 336 | 337 | ;; Local Variables: 338 | ;; read-symbol-shorthands: (("w-" . "wombag-")) 339 | ;; End: 340 | --------------------------------------------------------------------------------