summaryrefslogtreecommitdiff
path: root/esb.el
blob: a13259702181cf373eb8bbe1bd29967c58c3b854 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
;;; esb.el --- Emacs Simple Bookmark -*- lexical-binding: t; -*-

;; Copyright (C) 2025 Henrique Marques

;; Author: Henrique Marques <[email protected]>
;; URL: https://github.com/0xhenrique/esb
;; Version: 0.2
;; Package-Requires: ((emacs "27.1"))
;; SPDX-License-Identifier: AGPL-3.0-or-later

;;; Commentary:
;; This is a simple encrypted bookmark manager for Emacs that
;; stores bookmarks in an encrypted file suitable for syncing via Git.
;; It uses GPG encryption to keep your bookmarks secure while allowing
;; you to store them in public repositories.

;;; Code:

(require 'epa-file)
(require 'json)
(require 'seq)
(require 'url-parse)

;;; Customization

(defgroup esb nil
  "Emacs Simple Bookmark."
  :group 'tools)

(defcustom esb-bookmarks-file "~/.bookmarks.gpg"
  "Path to the encrypted bookmarks file."
  :type 'string
  :group 'esb)

(defcustom esb-storage-backend 'gpg
  "Storage backend for bookmarks."
  :type '(choice (const :tag "GPG encrypted" gpg)
                 (const :tag "Plain text" plain)
                 (function :tag "Custom backend"))
  :group 'esb)

(defcustom esb-gpg-recipient nil
  "GPG key ID or email for asymmetric encryption.
When nil, epa-file uses symmetric encryption (passphrase only).
Set this to your GPG key ID or email for asymmetric encryption.
Example: \"[email protected]\" or \"ABCD1234\"."
  :type '(choice (const :tag "Symmetric (passphrase)" nil)
                 (string :tag "Key ID or email"))
  :group 'esb)

(defcustom esb-clear-cache-on-idle nil
  "When non-nil, clear the bookmark cache after idle timeout.
This provides additional security by not keeping decrypted
bookmarks in memory indefinitely."
  :type 'boolean
  :group 'esb)

(defcustom esb-idle-clear-seconds 300
  "Seconds of idle time before clearing cache.
Only used when `esb-clear-cache-on-idle' is non-nil."
  :type 'integer
  :group 'esb)

;;; Internal Variables

(defvar esb-bookmarks-cache nil
  "In-memory cache of decrypted bookmarks.")

(defvar esb-cache-dirty nil
  "Flag indicating if cache needs to be saved.")

(defvar esb--idle-timer nil
  "Timer for clearing cache on idle.")

;;; Cache Security Functions

(defun esb--clear-cache ()
  "Clear the in-memory bookmark cache."
  (when esb-bookmarks-cache
    (setq esb-bookmarks-cache nil)
    (message "ESB: Bookmark cache cleared")))

(defun esb--setup-idle-timer ()
  "Setup idle timer for cache clearing if enabled."
  (when esb--idle-timer
    (cancel-timer esb--idle-timer))
  (when esb-clear-cache-on-idle
    (setq esb--idle-timer
          (run-with-idle-timer esb-idle-clear-seconds t #'esb--clear-cache))))

(defun esb--setup-cache-clearing ()
  "Setup all cache clearing hooks and timers."
  ;; Clear on suspend/sleep
  (add-hook 'suspend-hook #'esb--clear-cache)
  ;; Clear when Emacs loses focus (optional, can be noisy)
  ;; (add-hook 'focus-out-hook #'esb--clear-cache)
  ;; Clear on screen lock (if using screen-lock package or similar)
  (when (boundp 'screen-lock-hook)
    (add-hook 'screen-lock-hook #'esb--clear-cache))
  ;; Setup idle timer
  (esb--setup-idle-timer))

;;; Utility Functions

(defun esb--ensure-epa-setup ()
  "Ensure EPA file encryption is properly configured."
  (unless (member epa-file-handler file-name-handler-alist)
    (epa-file-enable)))

(defun esb--valid-url-p (url)
  "Check if URL is valid."
  (and (stringp url)
       (not (string-empty-p url))
       (string-match-p "^https?://" url)
       (let ((parsed (url-generic-parse-url url)))
         (and parsed
              (url-host parsed)
              (not (string-empty-p (url-host parsed)))
              ;; Must have at least one dot in host (or be localhost)
              (or (string-match-p "\\." (url-host parsed))
                  (string= "localhost" (url-host parsed)))))))

(defun esb--valid-bookmark-p (bookmark)
  "Check if BOOKMARK has valid structure."
  (and (listp bookmark)
       (esb--valid-url-p (alist-get 'url bookmark))
       (let ((tags (alist-get 'tags bookmark)))
         (or (null tags)
             (and (listp tags)
                  (seq-every-p #'stringp tags))))))

(defun esb--normalize-tags (tags)
  "Normalize TAGS input to a list of strings."
  (cond
   ((null tags) nil)
   ((stringp tags)
    (if (string-empty-p tags)
        nil
      (mapcar #'string-trim (split-string tags "[,[:space:]]+" t))))
   ((listp tags) (seq-filter (lambda (tag) (and (stringp tag) (not (string-empty-p tag)))) tags))
   (t nil)))

;;; Storage Backend Functions

(defun esb--read-bookmarks-gpg ()
  "Read bookmarks from GPG encrypted file."
  (esb--ensure-epa-setup)
  (if (file-exists-p esb-bookmarks-file)
      (condition-case err
          (with-temp-buffer
            (insert-file-contents esb-bookmarks-file)
            (let ((content (string-trim (buffer-string))))
              (if (string-empty-p content)
                  '()
                (let ((parsed (json-parse-string content :array-type 'list :object-type 'alist)))
                  (if (eq parsed :null) '() parsed)))))
        (file-error
         (user-error "Cannot read bookmark file: %s" (error-message-string err)))
        (json-error
         (user-error "Invalid JSON in bookmark file: %s" (error-message-string err)))
        (error
         (user-error "GPG decryption failed: %s" (error-message-string err))))
    '()))

(defun esb--write-bookmarks-gpg (bookmarks)
  "Write BOOKMARKS to GPG encrypted file."
  (esb--ensure-epa-setup)
  (let ((epa-file-encrypt-to (when esb-gpg-recipient
                               (list esb-gpg-recipient))))
    (condition-case err
        (with-temp-buffer
          (insert (json-encode bookmarks))
          (write-region (point-min) (point-max) esb-bookmarks-file))
      (error
       (user-error "Failed to write bookmark file: %s" (error-message-string err))))))

(defun esb--read-bookmarks-plain ()
  "Read bookmarks from plain text file."
  (if (file-exists-p esb-bookmarks-file)
      (condition-case err
          (with-temp-buffer
            (insert-file-contents esb-bookmarks-file)
            (let ((content (string-trim (buffer-string))))
              (if (string-empty-p content)
                  '()
                (let ((parsed (json-parse-string content :array-type 'list :object-type 'alist)))
                  (if (eq parsed :null) '() parsed)))))
        (file-error
         (user-error "Cannot read bookmark file: %s" (error-message-string err)))
        (json-error
         (user-error "Invalid JSON in bookmark file: %s" (error-message-string err))))
    '()))

(defun esb--write-bookmarks-plain (bookmarks)
  "Write BOOKMARKS to plain text file."
  (condition-case err
      (with-temp-buffer
        (insert (json-encode bookmarks))
        (write-region (point-min) (point-max) esb-bookmarks-file))
    (error
     (user-error "Failed to write bookmark file: %s" (error-message-string err)))))

;;; Core Storage Functions

(defun esb--read-bookmarks ()
  "Read bookmarks using configured backend."
  (let ((bookmarks
         (pcase esb-storage-backend
           ('gpg (esb--read-bookmarks-gpg))
           ('plain (esb--read-bookmarks-plain))
           ((pred functionp) (funcall esb-storage-backend 'read))
           (_ (user-error "Unknown storage backend: %s" esb-storage-backend)))))
    (seq-filter #'esb--valid-bookmark-p bookmarks)))

(defun esb--write-bookmarks (bookmarks)
  "Write BOOKMARKS using configured backend."
  (let ((valid-bookmarks (seq-filter #'esb--valid-bookmark-p bookmarks)))
    (pcase esb-storage-backend
      ('gpg (esb--write-bookmarks-gpg valid-bookmarks))
      ('plain (esb--write-bookmarks-plain valid-bookmarks))
      ((pred functionp) (funcall esb-storage-backend 'write valid-bookmarks))
      (_ (user-error "Unknown storage backend: %s" esb-storage-backend))))
  (setq esb-cache-dirty nil))

(defun esb--get-bookmarks ()
  "Get bookmarks from cache or file."
  (unless esb-bookmarks-cache
    (setq esb-bookmarks-cache (or (esb--read-bookmarks) '())))
  esb-bookmarks-cache)

(defun esb--save-if-dirty ()
  "Save bookmarks to file if cache is dirty."
  (when esb-cache-dirty
    (esb--write-bookmarks esb-bookmarks-cache)))

;;; Bookmark Query Functions

(defun esb--find-bookmark-by-url (url)
  "Find bookmark by URL."
  (seq-find (lambda (bookmark) (string= (alist-get 'url bookmark) url))
            (esb--get-bookmarks)))

(defun esb--bookmark-exists-p (url)
  "Check if bookmark with URL already exists."
  (esb--find-bookmark-by-url url))

(defun esb--get-all-tags ()
  "Get all unique tags from bookmarks."
  (let ((all-tags '()))
    (dolist (bookmark (esb--get-bookmarks))
      (let ((tags (alist-get 'tags bookmark)))
        (when tags
          (dolist (tag tags)
            (push tag all-tags)))))
    (seq-uniq (nreverse all-tags))))

(defun esb--filter-bookmarks-by-tag (tag)
  "Filter bookmarks that contain TAG."
  (seq-filter (lambda (bookmark)
                (member tag (alist-get 'tags bookmark)))
              (esb--get-bookmarks)))

;;; Interactive Functions

;;;###autoload
(defun esb-add-bookmark (url &optional description tags)
  "Add a new bookmark with URL, optional DESCRIPTION and TAGS."
  (interactive
   (let ((url (read-string "Bookmark URL: "))
         (description (read-string "Description (optional): "))
         (tags (read-string "Tags (comma-separated, optional): ")))
     (list url
           (if (string-empty-p description) nil description)
           tags)))
  
  (unless (esb--valid-url-p url)
    (user-error "Invalid URL: %s (must be http(s):// with valid host)" url))
  
  (when (esb--bookmark-exists-p url)
    (user-error "Bookmark already exists: %s" url))
  
  (let* ((normalized-tags (esb--normalize-tags tags))
         (new-bookmark `((url . ,url)
                        (description . ,description)
                        (tags . ,normalized-tags)))
         (bookmarks (esb--get-bookmarks)))
    
    (setq esb-bookmarks-cache (append bookmarks (list new-bookmark)))
    (setq esb-cache-dirty t)
    (esb--save-if-dirty)
    (message "Added bookmark: %s%s"
             url
             (if normalized-tags (format " [%s]" (string-join normalized-tags ", ")) ""))))

;;;###autoload
(defun esb-delete-bookmark ()
  "Delete a bookmark by selecting from list."
  (interactive)
  (let ((bookmarks (esb--get-bookmarks)))
    (when (null bookmarks)
      (user-error "No bookmarks found"))
    
    (let* ((choices (mapcar (lambda (bookmark)
                             (let ((url (alist-get 'url bookmark))
                                   (desc (alist-get 'description bookmark))
                                   (tags (alist-get 'tags bookmark)))
                               (format "%s%s%s"
                                      url
                                      (if desc (format " - %s" desc) "")
                                      (if tags (format " [%s]" (string-join tags ", ")) ""))))
                           bookmarks))
           (selected (completing-read "Delete bookmark: " choices nil t))
           (selected-url (car (split-string selected " ")))
           (updated-bookmarks (seq-remove (lambda (bookmark)
                                         (string= (alist-get 'url bookmark) selected-url))
                                       bookmarks)))
      
      (setq esb-bookmarks-cache updated-bookmarks)
      (setq esb-cache-dirty t)
      (esb--save-if-dirty)
      (message "Deleted bookmark: %s" selected-url))))

;;;###autoload
(defun esb-list-bookmarks (&optional tag)
  "Display all bookmarks in a buffer, optionally filtered by TAG."
  (interactive
   (when current-prefix-arg
     (list (completing-read "Filter by tag: " (esb--get-all-tags) nil t))))
  
  (let ((bookmarks (if tag
                      (esb--filter-bookmarks-by-tag tag)
                    (esb--get-bookmarks))))
    (when (null bookmarks)
      (user-error "No bookmarks found%s" (if tag (format " with tag '%s'" tag) "")))
    
    (with-output-to-temp-buffer "*ESB Bookmarks*"
      (princ (format "Bookmarks%s:\n\n" (if tag (format " tagged '%s'" tag) "")))
      (dolist (bookmark bookmarks)
        (let ((url (alist-get 'url bookmark))
              (desc (alist-get 'description bookmark))
              (tags (alist-get 'tags bookmark)))
          (princ (format "• %s\n" url))
          (when desc
            (princ (format "  %s\n" desc)))
          (when tags
            (princ (format "  Tags: %s\n" (string-join tags ", "))))
          (princ "\n"))))))

;;;###autoload
(defun esb-select-bookmark (&optional tag)
  "Select a bookmark and copy URL to clipboard, optionally filtered by TAG."
  (interactive
   (when current-prefix-arg
     (list (completing-read "Filter by tag: " (esb--get-all-tags) nil t))))
  
  (let ((bookmarks (if tag
                      (esb--filter-bookmarks-by-tag tag)
                    (esb--get-bookmarks))))
    (when (null bookmarks)
      (user-error "No bookmarks found%s" (if tag (format " with tag '%s'" tag) "")))
    
    (let* ((choices (mapcar (lambda (bookmark)
                             (let ((url (alist-get 'url bookmark))
                                   (desc (alist-get 'description bookmark))
                                   (tags (alist-get 'tags bookmark)))
                               (format "%s%s%s"
                                      url
                                      (if desc (format " - %s" desc) "")
                                      (if tags (format " [%s]" (string-join tags ", ")) ""))))
                           bookmarks))
           (selected (completing-read "Select bookmark: " choices nil t))
           (selected-url (car (split-string selected " "))))
      
      (kill-new selected-url)
      (message "Copied to clipboard: %s" selected-url))))

;;;###autoload
(defun esb-edit-bookmark ()
  "Edit an existing bookmark."
  (interactive)
  (let ((bookmarks (esb--get-bookmarks)))
    (when (null bookmarks)
      (user-error "No bookmarks found"))
    
    (let* ((choices (mapcar (lambda (bookmark)
                             (let ((url (alist-get 'url bookmark))
                                   (desc (alist-get 'description bookmark))
                                   (tags (alist-get 'tags bookmark)))
                               (format "%s%s%s"
                                      url
                                      (if desc (format " - %s" desc) "")
                                      (if tags (format " [%s]" (string-join tags ", ")) ""))))
                           bookmarks))
           (selected (completing-read "Edit bookmark: " choices nil t))
           (selected-url (car (split-string selected " ")))
           (bookmark (esb--find-bookmark-by-url selected-url))
           (current-desc (or (alist-get 'description bookmark) ""))
           (current-tags (alist-get 'tags bookmark))
           (new-desc (read-string "Description: " current-desc))
           (new-tags-str (read-string "Tags (comma-separated): "
                                     (if current-tags (string-join current-tags ", ") "")))
           (new-tags (esb--normalize-tags new-tags-str)))
      
      (setf (alist-get 'description bookmark) (if (string-empty-p new-desc) nil new-desc))
      (setf (alist-get 'tags bookmark) new-tags)
      (setq esb-cache-dirty t)
      (esb--save-if-dirty)
      (message "Updated bookmark: %s" selected-url))))

;;;###autoload
(defun esb-list-tags ()
  "Display all available tags."
  (interactive)
  (let ((tags (esb--get-all-tags)))
    (if (null tags)
        (message "No tags found")
      (with-output-to-temp-buffer "*ESB Tags*"
        (princ "Available tags:\n\n")
        (dolist (tag (sort tags #'string<))
          (let ((count (length (esb--filter-bookmarks-by-tag tag))))
            (princ (format "• %s (%d bookmark%s)\n"
                          tag count (if (= count 1) "" "s")))))))))

;;;###autoload
(defun esb-reload-bookmarks ()
  "Reload bookmarks from file (useful after git pull)."
  (interactive)
  (setq esb-bookmarks-cache nil)
  (setq esb-cache-dirty nil)
  (esb--get-bookmarks)
  (message "Bookmarks reloaded from %s" esb-bookmarks-file))

;;;###autoload
(defun esb-initialize ()
  "Initialize bookmark file if it doesn't exist."
  (interactive)
  (if (file-exists-p esb-bookmarks-file)
      (message "Bookmark file already exists at: %s" esb-bookmarks-file)
    (esb--write-bookmarks '())
    (message "Initialized empty bookmark file at: %s" esb-bookmarks-file)))

;;;###autoload
(defun esb-setup ()
  "Setup ESB with cache clearing hooks.
Call this in your init file after loading ESB."
  (interactive)
  (esb--setup-cache-clearing)
  (message "ESB setup complete"))

(provide 'esb)

;;; esb.el ends here