From 180bf50bb9a5299ebbe51afefe5b4f81999016a1 Mon Sep 17 00:00:00 2001 From: 0xhenrique Date: Sat, 31 May 2025 06:02:14 +0100 Subject: much better now :D --- README.org | 99 +++++++++++----- esb.el | 388 ++++++++++++++++++++++++++++++++++++++++++++----------------- 2 files changed, 351 insertions(+), 136 deletions(-) diff --git a/README.org b/README.org index 253d00f..2c373fa 100644 --- a/README.org +++ b/README.org @@ -10,11 +10,13 @@ This README has become quite extensive, but I hope it addresses most of the doub * Features -- Encrypted bookmark storage using GPG -- Simple JSON format for easy parsing -- Git-friendly for syncing across machines +- Encrypted bookmark storage using GPG (with plain text option) +- Simple JSON format +- Git-friendly - Built-in Emacs integration -- No external dependencies beyond GPG +- Tag support +- Filtering by tags +- No external dependencies beyond GPG (you probably already have that) * Installation and Setup @@ -56,22 +58,22 @@ Add to your Emacs configuration: ;; Optional: customize bookmark file location (setq esb-bookmarks-file "~/bookmarks/bookmarks.gpg") -;; Optional: enable global minor mode (doesn't bind keys by default) -(esb-mode 1) +;; Optional: use plain text instead of GPG encryption +;; (setq esb-storage-backend 'plain) #+END_SRC *** Key Binding Setup ESB doesn't define any global key bindings by default to avoid conflicts. You need to define your own key bindings. -**** Recommended key bindings +**** Key bindings I use #+BEGIN_SRC elisp -;; My personal recommendation - follows Emacs conventions (global-set-key (kbd "C-c C-b s") 'esb-select-bookmark) (global-set-key (kbd "C-c C-b a") 'esb-add-bookmark) (global-set-key (kbd "C-c C-b d") 'esb-delete-bookmark) (global-set-key (kbd "C-c C-b l") 'esb-list-bookmarks) (global-set-key (kbd "C-c C-b e") 'esb-edit-bookmark) +(global-set-key (kbd "C-c C-b t") 'esb-list-tags) (global-set-key (kbd "C-c C-b r") 'esb-reload-bookmarks) (global-set-key (kbd "C-c C-b i") 'esb-initialize) #+END_SRC @@ -117,33 +119,43 @@ Update your Emacs config to point to the git repository: All functions are autoloaded and can be called via ~M-x~: -- ~esb-select-bookmark~ - Select bookmark and copy URL to clipboard -- ~esb-add-bookmark~ - Add new bookmark with URL and optional description +- ~esb-select-bookmark~ - Select bookmark and copy URL to clipboard (prefix arg to filter by tag) +- ~esb-add-bookmark~ - Add new bookmark with URL, description and tags are optional - ~esb-delete-bookmark~ - Delete bookmark by selection -- ~esb-list-bookmarks~ - Display all bookmarks in a buffer -- ~esb-edit-bookmark~ - Edit bookmark description +- ~esb-list-bookmarks~ - Display all bookmarks in a buffer (prefix arg to filter by tag) +- ~esb-edit-bookmark~ - Edit bookmark description and tags +- ~esb-list-tags~ - Display all available tags with bookmark counts - ~esb-reload-bookmarks~ - Reload bookmarks from file (after git pull) - ~esb-initialize~ - Initialize empty bookmark file -** Key Binding Reference +** Tag Usage -Using the recommended key bindings: +*** Adding tags +When adding bookmarks, you can specify tags as comma-separated values: +- Single tag: ~work~ +- Multiple tags: ~work, api, reference~ +- Tags with spaces: ~machine learning, data science~ -- ~C-c C-b s~ - Select bookmark (copy URL to clipboard) -- ~C-c C-b a~ - Add new bookmark -- ~C-c C-b d~ - Delete bookmark -- ~C-c C-b l~ - List all bookmarks -- ~C-c C-b e~ - Edit bookmark description -- ~C-c C-b r~ - Reload bookmarks from file -- ~C-c C-b i~ - Initialize empty bookmark file +*** Filtering by tags +Use prefix argument (C-u) with list and select functions: +- ~C-u C-c C-b l~ - List bookmarks filtered by tag +- ~C-u C-c C-b s~ - Select bookmark filtered by tag ** Basic workflow *** First time setup 1. ~M-x esb-initialize~ - Create empty encrypted bookmark file -2. ~M-x esb-add-bookmark~ - Add your first bookmark +2. ~M-x esb-add-bookmark~ - Add your first bookmark with tags 3. Commit and push to GitHub +*** Adding bookmarks with tags +#+BEGIN_EXAMPLE +M-x esb-add-bookmark +Bookmark URL: https://api.github.com +Description (optional): GitHub API Documentation +Tags (comma-separated, optional): work, api, reference +#+END_EXAMPLE + *** Syncing across machines #+BEGIN_SRC bash # Pull latest bookmarks @@ -183,8 +195,7 @@ Note: GPG files can't be automatically merged, so avoid simultaneous edits when ** GPG Key Management - Keep your private key secure and backed up (VERY IMPORTANT!) -- Use a strong passphrase for your GPG key -- Consider setting key expiration dates +- Use a strong passphrase for your GPG key (you can cache it) - The same GPG key must be available on all machines where you use bookmarks ** Repository Security @@ -192,8 +203,8 @@ Note: GPG files can't be automatically merged, so avoid simultaneous edits when - The repository only contains the encrypted file, not plaintext bookmarks ** Best Practices -- Never commit your GPG private key to the repository -- Regularly backup your GPG keys! +- Never commit your GPG private key to the repository (ALSO VERY IMPORTANT!) +- Regularly backup your GPG keys! (ALSO VERY IMPORTANT!) * Configuration Options @@ -202,13 +213,30 @@ Note: GPG files can't be automatically merged, so avoid simultaneous edits when ;; Bookmark file location (setq esb-bookmarks-file "~/path/to/bookmarks.gpg") +;; Storage backend (gpg, plain, or custom function) +(setq esb-storage-backend 'gpg) ; default: GPG encrypted +;; (setq esb-storage-backend 'plain) ; plain text + ;; GPG program path (if needed) (setq epg-gpg-program "/usr/local/bin/gpg") -;; Cache passphrase (security vs convenience trade-off) +;; Cache passphrase (setq epa-file-cache-passphrase-for-symmetric-encryption t) #+END_SRC +** Storage Backends + +*** GPG (default) +Stores bookmarks in GPG-encrypted files, suitable for public repositories like Github. + +*** Plain Text +Stores bookmarks in plain JSON files. Useful for local-only usage or when GPG is not available. + +#+BEGIN_SRC elisp +(setq esb-storage-backend 'plain) +(setq esb-bookmarks-file "~/bookmarks/bookmarks.json") +#+END_SRC + * File Format The encrypted file is just a simple JSON array: @@ -216,11 +244,18 @@ The encrypted file is just a simple JSON array: [ { "url": "https://example.com", - "description": "Example website" + "description": "Example website", + "tags": ["work", "reference"] }, { "url": "https://github.com", - "description": null + "description": null, + "tags": ["code", "git"] + }, + { + "url": "https://api.example.com", + "description": "API docs", + "tags": null } ] #+END_SRC @@ -238,6 +273,12 @@ If you encounter key binding conflicts: - Check that ~epa-file~ is working: try opening any ~.gpg~ file - Verify GPG agent is running if using GUI Emacs +** Invalid Bookmark Errors +ESB now validates bookmarks and will skip invalid entries: +- URLs must start with http:// or https:// +- Tags must be strings if present +- Use ~M-x esb-reload-bookmarks~ if you manually edit the file (NOT RECOMMENDED!) + * License 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. diff --git a/esb.el b/esb.el index 0aba035..4b0df12 100644 --- a/esb.el +++ b/esb.el @@ -4,7 +4,7 @@ ;; Author: Henrique Marques ;; URL: https://github.com/0xhenrique/esb -;; Version: 0.1 +;; Version: 0.2 ;; Package-Requires: ((emacs "27.1")) ;; SPDX-License-Identifier: AGPL-3.0-or-later @@ -18,6 +18,10 @@ (require 'epa-file) (require 'json) +(require 'seq) +(require 'url-parse) + +;;; Customization (defgroup esb nil "Emacs Simple Bookmark." @@ -28,44 +32,133 @@ :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) + +;;; 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.") -;;; Core functions +;;; 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--read-bookmarks () - "Read and decrypt bookmarks from file." +(defun esb--valid-url-p (url) + "Check if URL is valid." + (and (stringp url) + (not (string-empty-p url)) + (string-match-p "^https?://" url) + (url-generic-parse-url url))) + +(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) + (condition-case err + (with-temp-buffer + (insert (json-encode bookmarks)) + (write-file 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) - (with-temp-buffer - (insert-file-contents esb-bookmarks-file) - (let ((content (string-trim (buffer-string)))) - (if (string-empty-p content) - '() - (condition-case err - (let ((parsed (json-parse-string content :array-type 'list :object-type 'alist))) - (if (eq parsed :null) - '() - parsed)) - (json-error - (message "Error parsing bookmarks file: %s" err) - '()))))) - '())) + (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-file 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) - "Encrypt and write BOOKMARKS to file." - (esb--ensure-epa-setup) - (with-temp-buffer - (insert (json-encode bookmarks)) - (write-file esb-bookmarks-file)) + "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 () @@ -79,92 +172,192 @@ (when esb-cache-dirty (esb--write-bookmarks esb-bookmarks-cache))) -(defun esb--bookmark-urls () - "Get list of bookmark URLs." - (mapcar (lambda (bookmark) (alist-get 'url bookmark)) (esb--get-bookmarks))) +;;; 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))) -;;; Interactive functions +(defun esb--bookmark-exists-p (url) + "Check if bookmark with URL already exists." + (not (null (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 + (setq all-tags (append all-tags tags))))) + (seq-uniq 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) - "Add a new bookmark with URL and optional DESCRIPTION." - (interactive "sBookmark URL: \nsDescription (optional): ") - (let* ((bookmarks (esb--get-bookmarks)) - (existing (esb--find-bookmark-by-url url))) - (if existing - (message "Bookmark already exists: %s" url) - (let ((new-bookmark `((url . ,url) - (description . ,(if (string-empty-p description) nil description))))) - (setq esb-bookmarks-cache (append bookmarks (list new-bookmark))) - (setq esb-cache-dirty t) - (esb--save-if-dirty) - (message "Added bookmark: %s" url))))) +(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" 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)) - (urls (esb--bookmark-urls))) - (if (null urls) - (message "No bookmarks found") - (let* ((selected-url (completing-read "Delete bookmark: " urls nil t)) - (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))))) + (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 () - "Display all bookmarks in a buffer." - (interactive) - (let ((bookmarks (esb--get-bookmarks))) - (if (null bookmarks) - (message "No bookmarks found") - (with-output-to-temp-buffer "*Esb Bookmarks*" - (princ "Bookmarks:\n\n") - (dolist (bookmark bookmarks) - (let ((url (alist-get 'url bookmark)) - (desc (alist-get 'description bookmark))) - (princ (format "• %s\n" url)) - (when desc - (princ (format " %s\n" desc))) - (princ "\n"))))))) +(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 () - "Select a bookmark and copy URL to clipboard." - (interactive) - (let ((urls (esb--bookmark-urls))) - (if (null urls) - (message "No bookmarks found") - (let ((selected-url (completing-read "Select bookmark: " urls nil t))) - (kill-new selected-url) - (message "Copied to clipboard: %s" selected-url))))) +(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 description of an existing 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* ((urls (esb--bookmark-urls))) - (if (null urls) - (message "No bookmarks found") - (let* ((selected-url (completing-read "Edit bookmark: " urls nil t)) - (bookmark (esb--find-bookmark-by-url selected-url)) - (current-desc (or (alist-get 'description bookmark) "")) - (new-desc (read-string "Description: " current-desc))) - (setf (alist-get 'description bookmark) (if (string-empty-p new-desc) nil new-desc)) - (setq esb-cache-dirty t) - (esb--save-if-dirty) - (message "Updated bookmark: %s" selected-url))))) + (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 () @@ -184,25 +377,6 @@ (esb--write-bookmarks '()) (message "Initialized empty bookmark file at: %s" esb-bookmarks-file))) -;;;###autoload -(defun esb-flush-cache () - "Flush the bookmark cache and force reload from file." - (interactive) - (setq esb-bookmarks-cache nil) - (setq esb-cache-dirty nil) - (message "Bookmark cache flushed")) - - - - -;;;###autoload -(define-minor-mode esb-mode - "Minor mode for encrypted bookmark management. -This mode provides no key bindings by default. -Users should define their own key bindings for ESB functions." - :global t - :group 'esb) - (provide 'esb) ;;; esb.el ends here -- cgit v1.3