summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-config.el17
-rw-r--r--modules/ai-conversations.el3
-rw-r--r--modules/archived/org-gcal-config.el213
-rw-r--r--modules/auth-config.el147
-rw-r--r--modules/browser-config.el81
-rw-r--r--modules/calendar-sync.el921
-rw-r--r--modules/calibredb-epub-config.el16
-rw-r--r--modules/chrono-tools.el46
-rw-r--r--modules/config-utilities.el34
-rw-r--r--modules/custom-buffer-file.el376
-rw-r--r--modules/custom-case.el6
-rw-r--r--modules/custom-comments.el679
-rw-r--r--modules/custom-datetime.el9
-rw-r--r--modules/custom-file-buffer.el200
-rw-r--r--modules/custom-line-paragraph.el25
-rw-r--r--modules/custom-misc.el123
-rw-r--r--modules/custom-ordering.el241
-rw-r--r--modules/custom-text-enclose.el309
-rw-r--r--modules/custom-whitespace.el206
-rw-r--r--modules/dashboard-config.el73
-rw-r--r--modules/diff-config.el8
-rw-r--r--modules/dirvish-config.el72
-rw-r--r--modules/dwim-shell-config.el28
-rw-r--r--modules/elfeed-config.el2
-rw-r--r--modules/erc-config.el9
-rw-r--r--modules/eshell-vterm-config.el15
-rw-r--r--modules/external-open.el5
-rw-r--r--modules/flycheck-config.el57
-rw-r--r--modules/flyspell-and-abbrev.el19
-rw-r--r--modules/font-config.el26
-rw-r--r--modules/host-environment.el2
-rw-r--r--modules/jumper.el255
-rw-r--r--modules/keybindings.el18
-rw-r--r--modules/lipsum-generator.el9
-rw-r--r--modules/lorem-optimum.el (renamed from modules/lorem-generator.el)102
-rw-r--r--modules/mail-config.el18
-rw-r--r--modules/media-utils.el5
-rw-r--r--modules/modeline-config.el229
-rw-r--r--modules/mousetrap-mode.el216
-rw-r--r--modules/music-config.el145
-rw-r--r--modules/org-agenda-config-debug.el63
-rw-r--r--modules/org-agenda-config.el196
-rw-r--r--modules/org-capture-config.el4
-rw-r--r--modules/org-config.el268
-rw-r--r--modules/org-contacts-config.el208
-rw-r--r--modules/org-drill-config.el25
-rw-r--r--modules/org-export-config.el4
-rw-r--r--modules/org-gcal-config.el94
-rw-r--r--modules/org-noter-config.el261
-rw-r--r--modules/org-refile-config.el175
-rw-r--r--modules/org-roam-config.el137
-rw-r--r--modules/org-webclipper.el80
-rw-r--r--modules/popper-config.el5
-rw-r--r--modules/prog-general.el22
-rw-r--r--modules/prog-go.el8
-rw-r--r--modules/prog-lisp.el5
-rw-r--r--modules/quick-video-capture.el4
-rw-r--r--modules/reconcile-open-repos.el5
-rw-r--r--modules/selection-framework.el165
-rw-r--r--modules/system-commands.el138
-rw-r--r--modules/system-defaults.el7
-rw-r--r--modules/system-lib.el31
-rw-r--r--modules/system-utils.el69
-rw-r--r--modules/test-runner.el331
-rw-r--r--modules/text-config.el3
-rw-r--r--modules/transcription-config.el397
-rw-r--r--modules/ui-config.el48
-rw-r--r--modules/undead-buffers.el6
-rw-r--r--modules/user-constants.el36
-rw-r--r--modules/vc-config.el98
-rw-r--r--modules/video-audio-recording.el501
-rw-r--r--modules/weather-config.el41
-rw-r--r--modules/wip.el153
-rw-r--r--modules/wrap-up.el2
74 files changed, 6673 insertions, 1882 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
index 004750b6..3b89faca 100644
--- a/modules/ai-config.el
+++ b/modules/ai-config.el
@@ -415,5 +415,22 @@ Works for any buffer, whether it's visiting a file or not."
"x" #'cj/gptel-clear-buffer) ;; clears the assistant buffer
(keymap-set cj/custom-keymap "a" cj/ai-keymap)
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; a" "AI assistant menu"
+ "C-; a B" "switch backend"
+ "C-; a M" "gptel menu"
+ "C-; a d" "delete conversation"
+ "C-; a ." "add buffer"
+ "C-; a f" "add file"
+ "C-; a l" "load conversation"
+ "C-; a m" "change model"
+ "C-; a p" "change prompt"
+ "C-; a &" "rewrite region"
+ "C-; a r" "clear context"
+ "C-; a s" "save conversation"
+ "C-; a t" "toggle window"
+ "C-; a x" "clear buffer"))
+
(provide 'ai-config)
;;; ai-config.el ends here.
diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el
index 92549176..4f97d761 100644
--- a/modules/ai-conversations.el
+++ b/modules/ai-conversations.el
@@ -159,7 +159,6 @@ Expect FILENAME to match _YYYYMMDD-HHMMSS.gptel."
(or (get-buffer buf-name)
(user-error "Could not create or find *AI-Assistant* buffer"))))
-;;;###autoload
(defun cj/gptel-save-conversation ()
"Save the current AI-Assistant buffer to a .gptel file.
@@ -188,7 +187,6 @@ Enable autosave for subsequent AI responses to the same file."
(setq-local cj/gptel-autosave-enabled t))
(message "Conversation saved to: %s" filepath))))
-;;;###autoload
(defun cj/gptel-delete-conversation ()
"Delete a saved GPTel conversation file (chronologically sorted candidates)."
(interactive)
@@ -218,7 +216,6 @@ Enable autosave for subsequent AI responses to the same file."
(when (looking-at "^\n+")
(delete-region (point) (match-end 0)))))
-;;;###autoload
(defun cj/gptel-load-conversation ()
"Load a saved GPTel conversation into the AI-Assistant buffer.
diff --git a/modules/archived/org-gcal-config.el b/modules/archived/org-gcal-config.el
new file mode 100644
index 00000000..9f43c1c8
--- /dev/null
+++ b/modules/archived/org-gcal-config.el
@@ -0,0 +1,213 @@
+;;; org-gcal-config.el --- Google Calendar synchronization for Org-mode -*- lexical-binding: t; coding: utf-8; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; Bidirectional synchronization between Google Calendar and Org-mode using org-gcal.
+;; - Credential management via authinfo.gpg
+;; - Automatic archival of past events
+;; - Automatic removal of cancelled events, but with TODOs added for visibility
+;; - System timezone configuration via functions in host-environment
+;; - No notifications on syncing
+;; - Events are managed by Org (changes in org file push back to Google Calendar)
+;; This is controlled by org-gcal-managed-newly-fetched-mode and
+;; org-gcal-managed-update-existing-mode set to "org"
+;; - Automatic sync timer (configurable via cj/org-gcal-sync-interval-minutes)
+;; Default: 30 minutes, set to nil to disable
+;; See: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#sync-automatically-at-regular-times
+;; - Validates existing oath2-auto.plist file or creates it to avoid the issue mentioned here:
+;; https://github.com/kidd/org-gcal.el?tab=readme-ov-file#note
+;;
+;; Prerequisites:
+;; 1. Create OAuth 2.0 credentials in Google Cloud Console
+;; See: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#installation
+;; 2. Store credentials in ~/.authinfo.gpg with this format:
+;; machine org-gcal login YOUR_CLIENT_ID password YOUR_CLIENT_SECRET
+;; 3. Define `gcal-file' in user-constants (location of org file to hold sync'd events).
+;;
+;; Usage:
+;; - Manual sync: C-; g s (or M-x org-gcal-sync)
+;; - Toggle auto-sync on/off: C-; g t
+;; - Restart auto-sync (e.g., after changing interval): C-; g r
+;; - Clear sync lock (if sync gets stuck): C-; g c
+;;
+;; Note:
+;; This configuration creates oauth2-auto.plist on first run to prevent sync errors.
+;; Passphrase caching is enabled.
+;;
+;;; Code:
+
+(require 'host-environment)
+(require 'user-constants)
+
+;; Forward declare org-gcal internal variables and functions
+(eval-when-compile
+ (defvar org-gcal--sync-lock))
+(declare-function org-gcal-reload-client-id-secret "org-gcal")
+
+;; User configurable sync interval
+(defvar cj/org-gcal-sync-interval-minutes 30
+ "Interval in minutes for automatic Google Calendar sync.
+Set to nil to disable automatic syncing.
+Changes take effect after calling `cj/org-gcal-restart-auto-sync'.")
+
+;; Internal timer object
+(defvar cj/org-gcal-sync-timer nil
+ "Timer object for automatic org-gcal sync.
+Use `cj/org-gcal-start-auto-sync' and `cj/org-gcal-stop-auto-sync' to control.")
+
+(defun cj/org-gcal-clear-sync-lock ()
+ "Clear the org-gcal sync lock.
+Useful when a sync fails and leaves the lock in place, preventing future syncs."
+ (interactive)
+ (setq org-gcal--sync-lock nil)
+ (message "org-gcal sync lock cleared"))
+
+(defun cj/org-gcal-convert-all-to-org-managed ()
+ "Convert all org-gcal events in current buffer to Org-managed.
+
+Changes all events with org-gcal-managed property from `gcal' to `org',
+enabling bidirectional sync so changes push back to Google Calendar."
+ (interactive)
+ (let ((count 0))
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^:org-gcal-managed: gcal$" nil t)
+ (replace-match ":org-gcal-managed: org")
+ (setq count (1+ count))))
+ (when (> count 0)
+ (save-buffer))
+ (message "Converted %d event(s) to Org-managed" count)))
+
+(defun cj/org-gcal-start-auto-sync ()
+ "Start automatic Google Calendar sync timer.
+Uses the interval specified in `cj/org-gcal-sync-interval-minutes'.
+Does nothing if interval is nil or timer is already running."
+ (interactive)
+ (when (and cj/org-gcal-sync-interval-minutes
+ (not (and cj/org-gcal-sync-timer
+ (memq cj/org-gcal-sync-timer timer-list))))
+ (let ((interval-seconds (* cj/org-gcal-sync-interval-minutes 60)))
+ (setq cj/org-gcal-sync-timer
+ (run-with-timer
+ 120 ;; Initial delay: 2 minutes after startup
+ interval-seconds
+ (lambda ()
+ (condition-case err
+ (org-gcal-sync)
+ (error (message "org-gcal: Auto-sync failed: %s" err))))))
+ (message "org-gcal: Auto-sync started (every %d minutes)"
+ cj/org-gcal-sync-interval-minutes))))
+
+(defun cj/org-gcal-stop-auto-sync ()
+ "Stop automatic Google Calendar sync timer."
+ (interactive)
+ (when (and cj/org-gcal-sync-timer
+ (memq cj/org-gcal-sync-timer timer-list))
+ (cancel-timer cj/org-gcal-sync-timer)
+ (setq cj/org-gcal-sync-timer nil)
+ (message "org-gcal: Auto-sync stopped")))
+
+(defun cj/org-gcal-toggle-auto-sync ()
+ "Toggle automatic Google Calendar sync timer on/off."
+ (interactive)
+ (if (and cj/org-gcal-sync-timer
+ (memq cj/org-gcal-sync-timer timer-list))
+ (cj/org-gcal-stop-auto-sync)
+ (cj/org-gcal-start-auto-sync)))
+
+(defun cj/org-gcal-restart-auto-sync ()
+ "Restart automatic Google Calendar sync timer.
+Useful after changing `cj/org-gcal-sync-interval-minutes'."
+ (interactive)
+ (cj/org-gcal-stop-auto-sync)
+ (cj/org-gcal-start-auto-sync))
+
+;; Deferred library required by org-gcal
+(use-package deferred
+ :ensure t)
+
+;; OAuth2 authentication library required by org-gcal
+(use-package oauth2-auto
+ :ensure t)
+
+(use-package org-gcal
+ :vc (:url "https://github.com/cjennings/org-gcal" :rev :newest)
+ :defer t ;; unless idle timer is set below
+
+ :init
+ ;; Configure org-gcal settings (no authinfo.gpg decryption here - deferred to :config)
+ ;; identify calendar to sync and it's destination
+ (setq org-gcal-fetch-file-alist `(("craigmartinjennings@gmail.com" . ,gcal-file)))
+
+ (setq org-gcal-up-days 30) ;; Look 30 days back
+ (setq org-gcal-down-days 60) ;; Look 60 days forward
+ (setq org-gcal-auto-archive t) ;; auto-archive old events
+ (setq org-gcal-notify-p nil) ;; nil disables; t enables notifications
+ (setq org-gcal-remove-api-cancelled-events t) ;; auto-remove cancelled events
+ (setq org-gcal-update-cancelled-events-with-todo t) ;; todo cancelled events for visibility
+
+ ;; Google Calendar is authoritative - avoids sync conflicts
+ (setq org-gcal-managed-newly-fetched-mode "gcal") ;; New events from GCal stay GCal-managed
+ (setq org-gcal-managed-update-existing-mode "gcal") ;; GCal wins on conflicts
+
+ :config
+ ;; Retrieve credentials from authinfo.gpg when org-gcal is first loaded
+ ;; This happens on first use (e.g., C-; g s), not during daemon startup
+ (require 'auth-source)
+ (let ((credentials (car (auth-source-search :host "org-gcal" :require '(:user :secret)))))
+ (when credentials
+ (setq org-gcal-client-id (plist-get credentials :user))
+ ;; The secret might be a function, so we need to handle that
+ (let ((secret (plist-get credentials :secret)))
+ (setq org-gcal-client-secret
+ (if (functionp secret)
+ (funcall secret)
+ secret)))))
+ ;; Plstore caching is now configured globally in auth-config.el
+ ;; to ensure it loads before org-gcal needs it
+
+ ;; set org-gcal timezone based on system timezone
+ (setq org-gcal-local-timezone (cj/detect-system-timezone))
+
+ ;; Reload client credentials (should already be loaded by org-gcal, but ensure it's set)
+ (org-gcal-reload-client-id-secret)
+
+ ;; Auto-save gcal files after sync completes
+ (defun cj/org-gcal-save-files-after-sync (&rest _)
+ "Save all org-gcal files after sync completes."
+ (dolist (entry org-gcal-fetch-file-alist)
+ (let* ((file (cdr entry))
+ (buffer (get-file-buffer file)))
+ (when (and buffer (buffer-modified-p buffer))
+ (with-current-buffer buffer
+ (save-buffer)
+ (message "Saved %s after org-gcal sync" (file-name-nondirectory file)))))))
+
+ ;; Advise org-gcal--sync-unlock which is called when sync completes
+ (advice-add 'org-gcal--sync-unlock :after #'cj/org-gcal-save-files-after-sync))
+
+;; Start automatic sync timer based on user configuration
+;; Set cj/org-gcal-sync-interval-minutes to nil to disable
+;; (cj/org-gcal-start-auto-sync)
+
+;; Google Calendar keymap and keybindings
+(defvar-keymap cj/gcal-map
+ :doc "Keymap for Google Calendar operations"
+ "s" #'org-gcal-sync
+ "t" #'cj/org-gcal-toggle-auto-sync
+ "r" #'cj/org-gcal-restart-auto-sync
+ "c" #'cj/org-gcal-clear-sync-lock)
+(keymap-set cj/custom-keymap "g" cj/gcal-map)
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; g" "gcal menu"
+ "C-; g s" "sync"
+ "C-; g t" "toggle auto-sync"
+ "C-; g r" "restart auto-sync"
+ "C-; g c" "clear sync lock"))
+
+(provide 'org-gcal-config)
+;;; org-gcal-config.el ends here
diff --git a/modules/auth-config.el b/modules/auth-config.el
index 6b8a8ddb..83a7e2d0 100644
--- a/modules/auth-config.el
+++ b/modules/auth-config.el
@@ -7,14 +7,23 @@
;; • auth-source
;; – Forces use of your default authinfo file
-;; – Disable external GPG agent in favor of Emacs’s own prompt
+;; – Disable external GPG agent in favor of Emacs's own prompt
;; – Enable auth-source debug messages
;; • Easy PG Assistant (epa)
-;; – Force using the ‘gpg2’ executable for encryption/decryption operations
+;; – Force using the 'gpg2' executable for encryption/decryption operations
+
+;; • oauth2-auto cache fix (via advice)
+;; – oauth2-auto version 20250624.1919 has caching bug on line 206
+;; – Function oauth2-auto--plstore-read has `or nil` disabling cache
+;; – This caused GPG passphrase prompts every ~15 minutes during gcal-sync
+;; – Fix: Advice to enable hash-table cache without modifying package
+;; – Works across package updates
+;; – Fixed 2025-11-11
;;; Code:
+(require 'system-lib)
(eval-when-compile (require 'user-constants)) ;; defines authinfo-file location
;; -------------------------------- Auth Sources -------------------------------
@@ -24,9 +33,11 @@
:ensure nil ;; built in
:demand t ;; load this package immediately
:config
- (setenv "GPG_AGENT_INFO" nil) ;; disassociate with external gpg agent
- (setq auth-sources `(,authinfo-file)) ;; use authinfo.gpg (see user-constants.el)
- (setq auth-source-debug t)) ;; echo debug info to Messages
+ ;; USE gpg-agent for passphrase caching (400-day cache from gpg-agent.conf)
+ ;; (setenv "GPG_AGENT_INFO" nil) ;; DISABLED: was preventing gpg-agent cache
+ (setq auth-sources `(,authinfo-file)) ;; use authinfo.gpg (see user-constants.el)
+ (setq auth-source-debug t) ;; echo debug info to Messages
+ (setq auth-source-cache-expiry 86400)) ;; cache decrypted credentials for 24 hours
;; ----------------------------- Easy PG Assistant -----------------------------
;; Key management, cryptographic operations on regions and files, dired
@@ -38,7 +49,131 @@
:config
(epa-file-enable)
;; (setq epa-pinentry-mode 'loopback) ;; emacs request passwords in minibuffer
- (setq epg-gpg-program "gpg2")) ;; force use gpg2 (not gpg v.1)
+ (setq epg-gpg-program "gpg2") ;; force use gpg2 (not gpg v.1)
+
+ ;; Update gpg-agent with current DISPLAY environment
+ ;; This ensures pinentry can open GUI windows when Emacs starts
+ (call-process "gpg-connect-agent" nil nil nil "updatestartuptty" "/bye"))
+
+;; ---------------------------------- Plstore ----------------------------------
+;; Encrypted storage used by oauth2-auto for Google Calendar tokens.
+;; CRITICAL: Enable passphrase caching to prevent password prompts every 10 min.
+
+(use-package plstore
+ :ensure nil ;; built-in
+ :demand t
+ :config
+ ;; Cache passphrase indefinitely (relies on gpg-agent for actual caching)
+ (setq plstore-cache-passphrase-for-symmetric-encryption t)
+ ;; Allow gpg-agent to cache the passphrase (400 days per gpg-agent.conf)
+ (setq plstore-encrypt-to nil)) ;; Use symmetric encryption, not key-based
+
+;; ----------------------------- oauth2-auto Cache Fix -----------------------------
+;; Fix oauth2-auto caching bug that causes repeated GPG passphrase prompts.
+;; The package has `or nil` on line 206 that disables its internal cache.
+;; This advice overrides the buggy function to enable caching properly.
+
+(defun cj/oauth2-auto--plstore-read-fixed (username provider)
+ "Fixed version of oauth2-auto--plstore-read that enables caching.
+
+This is a workaround for oauth2-auto.el bug where line 206 has:
+ (or nil ;(gethash id oauth2-auto--plstore-cache)
+which completely disables the internal hash-table cache.
+
+This function re-implements the intended behavior with cache enabled."
+ (require 'oauth2-auto) ; Ensure package is loaded
+ (let ((id (oauth2-auto--compute-id username provider)))
+ ;; Check cache FIRST (this is what the original should do)
+ (or (gethash id oauth2-auto--plstore-cache)
+ ;; Cache miss - read from plstore and cache the result
+ (let ((plstore (plstore-open oauth2-auto-plstore)))
+ (unwind-protect
+ (puthash id
+ (cdr (plstore-get plstore id))
+ oauth2-auto--plstore-cache)
+ (plstore-close plstore))))))
+
+;; Apply the fix via advice (survives package updates)
+(with-eval-after-load 'oauth2-auto
+ (advice-add 'oauth2-auto--plstore-read :override #'cj/oauth2-auto--plstore-read-fixed)
+ (cj/log-silently "✓ oauth2-auto cache fix applied via advice"))
+
+;; ------------------------ Authentication Reset Utility -----------------------
+
+(defun cj/reset-auth-cache (&optional include-gpg-agent)
+ "Reset authentication caches when wrong password was entered.
+
+By default, only clears Emacs-side caches (auth-source, EPA file
+handler) and leaves gpg-agent's long-term cache intact. This preserves
+your 400-day cache for GPG and SSH passphrases.
+
+With prefix argument INCLUDE-GPG-AGENT (\\[universal-argument]), also
+clears gpg-agent's password cache. Use this when gpg-agent itself has
+cached an incorrect password.
+
+Clears:
+1. auth-source cache (Emacs-level credential cache)
+2. EPA file handler cache (encrypted file cache)
+3. gpg-agent cache (only if INCLUDE-GPG-AGENT is non-nil)
+
+Use this when you see errors like:
+ - \"Bad session key\"
+ - \"Decryption failed\"
+ - GPG repeatedly using wrong cached password"
+ (interactive "P")
+ (message "Resetting authentication caches...")
+
+ ;; Clear auth-source cache (Emacs credential cache)
+ (auth-source-forget-all-cached)
+
+ ;; Clear EPA file handler cache
+ (when (fboundp 'epa-file-clear-cache)
+ (epa-file-clear-cache))
+
+ ;; Only clear gpg-agent cache if explicitly requested
+ (if include-gpg-agent
+ (let ((result (shell-command "echo RELOADAGENT | gpg-connect-agent")))
+ (if (zerop result)
+ (message "✓ Emacs and gpg-agent caches cleared. Next access will prompt for password.")
+ (message "⚠ Warning: Failed to clear gpg-agent cache")))
+ (message "✓ Emacs caches cleared. GPG/SSH passphrases preserved for session.")))
+
+(defun cj/kill-gpg-agent ()
+ "Force kill gpg-agent (it will restart automatically on next use).
+
+This is a more aggressive reset than `cj/reset-auth-cache'. Use this
+when gpg-agent is stuck or behaving incorrectly.
+
+The gpg-agent will automatically restart on the next GPG operation."
+ (interactive)
+ (let ((result (shell-command "gpgconf --kill gpg-agent")))
+ (if (zerop result)
+ (message "✓ gpg-agent killed. It will restart automatically on next use.")
+ (message "⚠ Warning: Failed to kill gpg-agent"))))
+
+(defun cj/clear-oauth2-auto-cache ()
+ "Clear the oauth2-auto in-memory token cache.
+
+This forces oauth2-auto to re-read tokens from oauth2-auto.plist on next
+access. Useful when OAuth tokens have been manually updated or after
+re-authentication.
+
+Note: This only clears Emacs's in-memory cache. The oauth2-auto.plist
+file on disk is not modified."
+ (interactive)
+ (if (boundp 'oauth2-auto--plstore-cache)
+ (progn
+ (clrhash oauth2-auto--plstore-cache)
+ (message "✓ oauth2-auto token cache cleared"))
+ (message "⚠ oauth2-auto not loaded yet")))
+
+;; Keybindings
+(with-eval-after-load 'keybindings
+ (keymap-set cj/custom-keymap "A" #'cj/reset-auth-cache))
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; A" "reset auth cache"))
(provide 'auth-config)
;;; auth-config.el ends here.
diff --git a/modules/browser-config.el b/modules/browser-config.el
index fddc02e6..52c3b8a6 100644
--- a/modules/browser-config.el
+++ b/modules/browser-config.el
@@ -80,19 +80,44 @@ Returns the browser plist if found, nil otherwise."
cj/saved-browser-choice))
(error nil))))
-(defun cj/apply-browser-choice (browser-plist)
- "Apply the browser settings from BROWSER-PLIST."
- (when browser-plist
+(defun cj/--do-apply-browser-choice (browser-plist)
+ "Apply the browser settings from BROWSER-PLIST.
+Returns: \\='success if applied successfully,
+ \\='invalid-plist if browser-plist is nil or missing required keys."
+ (if (null browser-plist)
+ 'invalid-plist
(let ((browse-fn (plist-get browser-plist :function))
(executable (plist-get browser-plist :executable))
(path (plist-get browser-plist :path))
(program-var (plist-get browser-plist :program-var)))
- ;; Set the main browse-url function
- (setq browse-url-browser-function browse-fn)
- ;; Set the specific browser program variable if it exists
- (when program-var
- (set program-var (or path executable)))
- (message "Default browser set to: %s" (plist-get browser-plist :name)))))
+ (if (null browse-fn)
+ 'invalid-plist
+ ;; Set the main browse-url function
+ (setq browse-url-browser-function browse-fn)
+ ;; Set the specific browser program variable if it exists
+ (when program-var
+ (set program-var (or path executable)))
+ 'success))))
+
+(defun cj/apply-browser-choice (browser-plist)
+ "Apply the browser settings from BROWSER-PLIST."
+ (pcase (cj/--do-apply-browser-choice browser-plist)
+ ('success (message "Default browser set to: %s" (plist-get browser-plist :name)))
+ ('invalid-plist (message "Invalid browser configuration"))))
+
+(defun cj/--do-choose-browser (browser-plist)
+ "Save and apply BROWSER-PLIST as the default browser.
+Returns: \\='success if browser was saved and applied,
+ \\='save-failed if save operation failed,
+ \\='invalid-plist if browser-plist is invalid."
+ (condition-case _err
+ (progn
+ (cj/save-browser-choice browser-plist)
+ (let ((result (cj/--do-apply-browser-choice browser-plist)))
+ (if (eq result 'success)
+ 'success
+ 'invalid-plist)))
+ (error 'save-failed)))
(defun cj/choose-browser ()
"Interactively choose a browser from available options.
@@ -107,21 +132,39 @@ Persists the choice for future sessions."
(string= (plist-get b :name) choice))
browsers)))
(when selected
- (cj/save-browser-choice selected)
- (cj/apply-browser-choice selected))))))
+ (pcase (cj/--do-choose-browser selected)
+ ('success (message "Default browser set to: %s" (plist-get selected :name)))
+ ('save-failed (message "Failed to save browser choice"))
+ ('invalid-plist (message "Invalid browser configuration"))))))))
;; Initialize: Load saved choice or use first available browser
-(defun cj/initialize-browser ()
- "Initialize browser configuration on startup."
+(defun cj/--do-initialize-browser ()
+ "Initialize browser configuration.
+Returns: (cons \\='loaded browser-plist) if saved choice was loaded,
+ (cons \\='first-available browser-plist) if using first discovered browser,
+ (cons \\='no-browsers nil) if no browsers found."
(let ((saved-choice (cj/load-browser-choice)))
(if saved-choice
- (cj/apply-browser-choice saved-choice)
- ;; No saved choice - try to set first available browser silently
+ (cons 'loaded saved-choice)
+ ;; No saved choice - try to set first available browser
(let ((browsers (cj/discover-browsers)))
- (when browsers
- (cj/apply-browser-choice (car browsers))
- (message "No browser configured. Using %s. Run M-x cj/choose-browser to change."
- (plist-get (car browsers) :name)))))))
+ (if browsers
+ (cons 'first-available (car browsers))
+ (cons 'no-browsers nil))))))
+
+(defun cj/initialize-browser ()
+ "Initialize browser configuration on startup."
+ (let ((result (cj/--do-initialize-browser)))
+ (pcase (car result)
+ ('loaded
+ (cj/--do-apply-browser-choice (cdr result)))
+ ('first-available
+ (let ((browser (cdr result)))
+ (cj/--do-apply-browser-choice browser)
+ (message "No browser configured. Using %s. Run M-x cj/choose-browser to change."
+ (plist-get browser :name))))
+ ('no-browsers
+ (message "No supported browsers found")))))
;; Run initialization
(cj/initialize-browser)
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
new file mode 100644
index 00000000..8f71c709
--- /dev/null
+++ b/modules/calendar-sync.el
@@ -0,0 +1,921 @@
+;;; calendar-sync.el --- Multi-calendar sync via .ics -*- lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Created: 2025-11-16
+
+;;; Commentary:
+
+;; Simple, reliable one-way sync from multiple calendars to Org mode.
+;; Downloads .ics files from calendar URLs (Google, Proton, etc.) and
+;; converts to Org format. No OAuth, no API complexity, just file conversion.
+;;
+;; Features:
+;; - Multi-calendar support (sync multiple calendars to separate files)
+;; - Pure Emacs Lisp .ics parser (no external dependencies)
+;; - Recurring event support (RRULE expansion)
+;; - Timer-based automatic sync (every 60 minutes, configurable)
+;; - Self-contained in .emacs.d (no cron, portable across machines)
+;; - Read-only (can't corrupt source calendars)
+;; - Works with chime.el for event notifications
+;;
+;; Recurring Events (RRULE):
+;;
+;; Calendar recurring events are defined once with an RRULE
+;; (recurrence rule) rather than as individual event instances. This
+;; module expands recurring events into individual org entries.
+;;
+;; Expansion uses a rolling window approach:
+;; - Past: 3 months before today
+;; - Future: 12 months after today
+;;
+;; Every sync regenerates the entire file based on the current date,
+;; so the window automatically advances as time passes. Old events
+;; naturally fall off after 3 months, and new future events appear
+;; as you approach them.
+;;
+;; Supported RRULE patterns:
+;; - FREQ=DAILY: Daily events
+;; - FREQ=WEEKLY;BYDAY=MO,WE,FR: Weekly on specific days
+;; - FREQ=MONTHLY: Monthly events (same day each month)
+;; - FREQ=YEARLY: Yearly events (anniversaries, birthdays)
+;; - INTERVAL: Repeat every N periods (e.g., every 2 weeks)
+;; - UNTIL: End date for recurrence
+;; - COUNT: Maximum occurrences (combined with date range limit)
+;;
+;; Setup:
+;; 1. Configure calendars in your init.el:
+;; (setq calendar-sync-calendars
+;; '((:name "google"
+;; :url "https://calendar.google.com/calendar/ical/.../basic.ics"
+;; :file gcal-file)
+;; (:name "proton"
+;; :url "https://calendar.proton.me/api/calendar/v1/url/.../calendar.ics"
+;; :file pcal-file)))
+;;
+;; 2. Load and start:
+;; (require 'calendar-sync)
+;; (calendar-sync-start)
+;;
+;; 3. Add to org-agenda (optional):
+;; (dolist (cal calendar-sync-calendars)
+;; (add-to-list 'org-agenda-files (plist-get cal :file)))
+;;
+;; Usage:
+;; - M-x calendar-sync-now ; Sync all or select specific calendar
+;; - M-x calendar-sync-start ; Start auto-sync
+;; - M-x calendar-sync-stop ; Stop auto-sync
+;; - M-x calendar-sync-toggle ; Toggle auto-sync
+;; - M-x calendar-sync-status ; Show sync status for all calendars
+
+;;; Code:
+
+(require 'org)
+(require 'user-constants) ; For gcal-file, pcal-file paths
+
+;;; Configuration
+
+(defvar calendar-sync-calendars nil
+ "List of calendars to sync.
+Each calendar is a plist with the following keys:
+ :name - Display name for the calendar (used in logs and prompts)
+ :url - URL to fetch .ics file from
+ :file - Output file path for org format
+
+Example:
+ (setq calendar-sync-calendars
+ \\='((:name \"google\"
+ :url \"https://calendar.google.com/calendar/ical/.../basic.ics\"
+ :file gcal-file)
+ (:name \"proton\"
+ :url \"https://calendar.proton.me/api/calendar/v1/url/.../calendar.ics\"
+ :file pcal-file)))")
+
+(defvar calendar-sync-interval-minutes 60
+ "Sync interval in minutes.
+Default: 60 minutes (1 hour).")
+
+(defvar calendar-sync-auto-start t
+ "Whether to automatically start calendar sync when module loads.
+If non-nil, sync starts automatically when calendar-sync is loaded.
+If nil, user must manually call `calendar-sync-start'.")
+
+(defvar calendar-sync-past-months 3
+ "Number of months in the past to include when expanding recurring events.
+Default: 3 months. This keeps recent history visible in org-agenda.")
+
+(defvar calendar-sync-future-months 12
+ "Number of months in the future to include when expanding recurring events.
+Default: 12 months. This provides a full year of future events.")
+
+;;; Internal state
+
+(defvar calendar-sync--timer nil
+ "Timer object for automatic syncing.")
+
+(defvar calendar-sync--calendar-states (make-hash-table :test 'equal)
+ "Per-calendar sync state.
+Hash table mapping calendar name (string) to state plist with:
+ :last-sync - Time of last successful sync
+ :status - Symbol: ok, error, or syncing
+ :last-error - Error message string, or nil")
+
+(defvar calendar-sync--last-timezone-offset nil
+ "Timezone offset in seconds from UTC at last sync.
+Used to detect timezone changes (e.g., when traveling).")
+
+(defvar calendar-sync--state-file
+ (expand-file-name "data/calendar-sync-state.el" user-emacs-directory)
+ "File to persist sync state across Emacs sessions.")
+
+;;; Timezone Detection
+
+(defun calendar-sync--current-timezone-offset ()
+ "Get current timezone offset in seconds from UTC.
+Returns negative for west of UTC, positive for east.
+Example: -21600 for CST (UTC-6), -28800 for PST (UTC-8)."
+ (car (current-time-zone)))
+
+(defun calendar-sync--timezone-name ()
+ "Get human-readable timezone name.
+Returns string like 'CST' or 'PST'."
+ (cadr (current-time-zone)))
+
+(defun calendar-sync--format-timezone-offset (offset)
+ "Format timezone OFFSET (in seconds) as human-readable string.
+Example: -21600 → 'UTC-6' or 'UTC-6:00'."
+ (if (null offset)
+ "unknown"
+ (let* ((hours (/ offset 3600))
+ (minutes (abs (mod (/ offset 60) 60)))
+ (sign (if (>= hours 0) "+" "-"))
+ (abs-hours (abs hours)))
+ (if (= minutes 0)
+ (format "UTC%s%d" sign abs-hours)
+ (format "UTC%s%d:%02d" sign abs-hours minutes)))))
+
+(defun calendar-sync--timezone-changed-p ()
+ "Return t if timezone has changed since last sync."
+ (and calendar-sync--last-timezone-offset
+ (not (= (calendar-sync--current-timezone-offset)
+ calendar-sync--last-timezone-offset))))
+
+;;; State Persistence
+
+(defun calendar-sync--save-state ()
+ "Save sync state to disk for persistence across sessions."
+ (let* ((calendar-states-alist
+ (let ((result '()))
+ (maphash (lambda (name state)
+ (push (cons name state) result))
+ calendar-sync--calendar-states)
+ result))
+ (state `((timezone-offset . ,calendar-sync--last-timezone-offset)
+ (calendar-states . ,calendar-states-alist)))
+ (dir (file-name-directory calendar-sync--state-file)))
+ (unless (file-directory-p dir)
+ (make-directory dir t))
+ (with-temp-file calendar-sync--state-file
+ (prin1 state (current-buffer)))))
+
+(defun calendar-sync--load-state ()
+ "Load sync state from disk."
+ (when (file-exists-p calendar-sync--state-file)
+ (condition-case err
+ (with-temp-buffer
+ (insert-file-contents calendar-sync--state-file)
+ (let ((state (read (current-buffer))))
+ (setq calendar-sync--last-timezone-offset
+ (alist-get 'timezone-offset state))
+ ;; Load per-calendar states
+ (let ((cal-states (alist-get 'calendar-states state)))
+ (clrhash calendar-sync--calendar-states)
+ (dolist (entry cal-states)
+ (puthash (car entry) (cdr entry) calendar-sync--calendar-states)))))
+ (error
+ (cj/log-silently "calendar-sync: Error loading state: %s" (error-message-string err))))))
+
+(defun calendar-sync--get-calendar-state (calendar-name)
+ "Get state plist for CALENDAR-NAME, or nil if not found."
+ (gethash calendar-name calendar-sync--calendar-states))
+
+(defun calendar-sync--set-calendar-state (calendar-name state)
+ "Set STATE plist for CALENDAR-NAME."
+ (puthash calendar-name state calendar-sync--calendar-states))
+
+;;; Line Ending Normalization
+
+(defun calendar-sync--normalize-line-endings (content)
+ "Normalize line endings in CONTENT to Unix format (LF only).
+Removes all carriage return characters (\\r) from CONTENT.
+The iCalendar format (RFC 5545) uses CRLF line endings, but Emacs
+and org-mode expect LF only. This function ensures consistent line
+endings throughout the parsing pipeline.
+
+Returns CONTENT with all \\r characters removed."
+ (if (not (stringp content))
+ content
+ (replace-regexp-in-string "\r" "" content)))
+
+;;; Date Utilities
+
+(defun calendar-sync--add-months (date months)
+ "Add MONTHS to DATE.
+DATE is (year month day), returns new (year month day)."
+ (let* ((year (nth 0 date))
+ (month (nth 1 date))
+ (day (nth 2 date))
+ (total-months (+ (* year 12) month -1 months))
+ (new-year (/ total-months 12))
+ (new-month (1+ (mod total-months 12))))
+ (list new-year new-month day)))
+
+(defun calendar-sync--get-date-range ()
+ "Get date range for event expansion as (start-time end-time).
+Returns time values for -3 months and +12 months from today."
+ (let* ((now (decode-time))
+ (today (list (nth 5 now) (nth 4 now) (nth 3 now)))
+ (start-date (calendar-sync--add-months today (- calendar-sync-past-months)))
+ (end-date (calendar-sync--add-months today calendar-sync-future-months))
+ (start-time (apply #'encode-time 0 0 0 (reverse start-date)))
+ (end-time (apply #'encode-time 0 0 0 (reverse end-date))))
+ (list start-time end-time)))
+
+(defun calendar-sync--date-in-range-p (date range)
+ "Check if DATE is within RANGE.
+DATE is (year month day hour minute), RANGE is (start-time end-time)."
+ (let* ((year (nth 0 date))
+ (month (nth 1 date))
+ (day (nth 2 date))
+ (date-time (encode-time 0 0 0 day month year))
+ (start-time (nth 0 range))
+ (end-time (nth 1 range)))
+ (and (time-less-p start-time date-time)
+ (time-less-p date-time end-time))))
+
+(defun calendar-sync--weekday-to-number (weekday)
+ "Convert WEEKDAY string (MO, TU, etc.) to number (1-7).
+Monday = 1, Sunday = 7."
+ (pcase weekday
+ ("MO" 1)
+ ("TU" 2)
+ ("WE" 3)
+ ("TH" 4)
+ ("FR" 5)
+ ("SA" 6)
+ ("SU" 7)
+ (_ nil)))
+
+(defun calendar-sync--date-weekday (date)
+ "Get weekday number for DATE (year month day).
+Monday = 1, Sunday = 7."
+ (let* ((year (nth 0 date))
+ (month (nth 1 date))
+ (day (nth 2 date))
+ (time (encode-time 0 0 0 day month year))
+ (decoded (decode-time time))
+ (dow (nth 6 decoded))) ; 0 = Sunday, 1 = Monday, etc.
+ (if (= dow 0) 7 dow))) ; Convert to 1-7 with Monday=1
+
+(defun calendar-sync--add-days (date days)
+ "Add DAYS to DATE (year month day).
+Returns new (year month day)."
+ (let* ((year (nth 0 date))
+ (month (nth 1 date))
+ (day (nth 2 date))
+ (time (encode-time 0 0 0 day month year))
+ (new-time (time-add time (days-to-time days)))
+ (decoded (decode-time new-time)))
+ (list (nth 5 decoded) (nth 4 decoded) (nth 3 decoded))))
+
+;;; .ics Parsing
+
+(defun calendar-sync--split-events (ics-content)
+ "Split ICS-CONTENT into individual VEVENT blocks.
+Returns list of strings, each containing one VEVENT block."
+ (let ((events '())
+ (start 0))
+ (while (string-match "BEGIN:VEVENT\\(.\\|\n\\)*?END:VEVENT" ics-content start)
+ (push (match-string 0 ics-content) events)
+ (setq start (match-end 0)))
+ (nreverse events)))
+
+(defun calendar-sync--get-property (event property)
+ "Extract PROPERTY value from EVENT string.
+Handles property parameters (e.g., DTSTART;TZID=America/Chicago:value).
+Handles multi-line values (lines starting with space).
+Returns nil if property not found."
+ (when (string-match (format "^%s[^:\n]*:\\(.*\\)$" (regexp-quote property)) event)
+ (let ((value (match-string 1 event))
+ (start (match-end 0)))
+ ;; Handle continuation lines (start with space or tab)
+ (while (and (< start (length event))
+ (string-match "^\n[ \t]\\(.*\\)$" event start))
+ (setq value (concat value (match-string 1 event)))
+ (setq start (match-end 0)))
+ value)))
+
+(defun calendar-sync--convert-utc-to-local (year month day hour minute second)
+ "Convert UTC datetime to local time.
+Returns list (year month day hour minute) in local timezone."
+ (let* ((utc-time (encode-time second minute hour day month year 0))
+ (local-time (decode-time utc-time)))
+ (list (nth 5 local-time) ; year
+ (nth 4 local-time) ; month
+ (nth 3 local-time) ; day
+ (nth 2 local-time) ; hour
+ (nth 1 local-time)))) ; minute
+
+(defun calendar-sync--parse-timestamp (timestamp-str)
+ "Parse iCal timestamp string TIMESTAMP-STR.
+Returns (year month day hour minute) or (year month day) for all-day events.
+Converts UTC times (ending in Z) to local time.
+Returns nil if parsing fails."
+ (cond
+ ;; DateTime format: 20251116T140000Z or 20251116T140000
+ ((string-match "\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\(Z\\)?" timestamp-str)
+ (let* ((year (string-to-number (match-string 1 timestamp-str)))
+ (month (string-to-number (match-string 2 timestamp-str)))
+ (day (string-to-number (match-string 3 timestamp-str)))
+ (hour (string-to-number (match-string 4 timestamp-str)))
+ (minute (string-to-number (match-string 5 timestamp-str)))
+ (second (string-to-number (match-string 6 timestamp-str)))
+ (is-utc (match-string 7 timestamp-str)))
+ (if is-utc
+ (calendar-sync--convert-utc-to-local year month day hour minute second)
+ (list year month day hour minute))))
+ ;; Date format: 20251116
+ ((string-match "\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)" timestamp-str)
+ (list (string-to-number (match-string 1 timestamp-str))
+ (string-to-number (match-string 2 timestamp-str))
+ (string-to-number (match-string 3 timestamp-str))))
+ (t nil)))
+
+(defun calendar-sync--format-timestamp (start end)
+ "Format START and END timestamps as org timestamp.
+START and END are lists from `calendar-sync--parse-timestamp'.
+Returns string like '<2025-11-16 Sun 14:00-15:00>' or '<2025-11-16 Sun>'."
+ (let* ((year (nth 0 start))
+ (month (nth 1 start))
+ (day (nth 2 start))
+ (start-hour (nth 3 start))
+ (start-min (nth 4 start))
+ (end-hour (and end (nth 3 end)))
+ (end-min (and end (nth 4 end)))
+ (date-str (format-time-string
+ "<%Y-%m-%d %a"
+ (encode-time 0 0 0 day month year)))
+ (time-str (when (and start-hour end-hour)
+ (format " %02d:%02d-%02d:%02d"
+ start-hour start-min end-hour end-min))))
+ (concat date-str time-str ">")))
+
+;;; RRULE Parsing and Expansion
+
+;;; Helper Functions
+
+(defun calendar-sync--date-to-time (date)
+ "Convert DATE (year month day) to time value for comparison.
+DATE should be a list like (year month day)."
+ (apply #'encode-time 0 0 0 (reverse date)))
+
+(defun calendar-sync--before-date-p (date1 date2)
+ "Return t if DATE1 is before DATE2.
+Both dates should be lists like (year month day)."
+ (time-less-p (calendar-sync--date-to-time date1)
+ (calendar-sync--date-to-time date2)))
+
+(defun calendar-sync--create-occurrence (base-event occurrence-date)
+ "Create an occurrence from BASE-EVENT with OCCURRENCE-DATE.
+OCCURRENCE-DATE should be a list (year month day hour minute second)."
+ (let* ((occurrence (copy-sequence base-event))
+ (end (plist-get base-event :end)))
+ (plist-put occurrence :start occurrence-date)
+ (when end
+ ;; Use the date from occurrence-date but keep the time from the original end
+ (let ((date-only (list (nth 0 occurrence-date)
+ (nth 1 occurrence-date)
+ (nth 2 occurrence-date))))
+ (plist-put occurrence :end (append date-only (nthcdr 3 end)))))
+ occurrence))
+
+(defun calendar-sync--parse-rrule (rrule-str)
+ "Parse RRULE string into plist.
+Returns plist with :freq :interval :byday :until :count."
+ (let ((parts (split-string rrule-str ";"))
+ (result '()))
+ (dolist (part parts)
+ (when (string-match "\\([^=]+\\)=\\(.+\\)" part)
+ (let ((key (match-string 1 part))
+ (value (match-string 2 part)))
+ (pcase key
+ ("FREQ" (setq result (plist-put result :freq (intern (downcase value)))))
+ ("INTERVAL" (setq result (plist-put result :interval (string-to-number value))))
+ ("BYDAY" (setq result (plist-put result :byday (split-string value ","))))
+ ("UNTIL" (setq result (plist-put result :until (calendar-sync--parse-timestamp value))))
+ ("COUNT" (setq result (plist-put result :count (string-to-number value))))))))
+ ;; Set defaults
+ (unless (plist-get result :interval)
+ (setq result (plist-put result :interval 1)))
+ result))
+
+(defun calendar-sync--expand-daily (base-event rrule range)
+ "Expand daily recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+ (let* ((start (plist-get base-event :start))
+ (interval (plist-get rrule :interval))
+ (until (plist-get rrule :until))
+ (count (plist-get rrule :count))
+ (occurrences '())
+ (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
+ (num-generated 0)
+ (range-end-time (cadr range)))
+ ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
+ ;; For COUNT, generate all occurrences from start regardless of range
+ (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
+ (or (not until) (calendar-sync--before-date-p current-date until))
+ (or (not count) (< num-generated count)))
+ (let ((occurrence-datetime (append current-date (nthcdr 3 start))))
+ ;; Check UNTIL date first
+ (when (or (not until) (calendar-sync--before-date-p current-date until))
+ ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
+ (when (or (not count) (< num-generated count))
+ (setq num-generated (1+ num-generated))
+ ;; Only add to output if within date range
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences)))))
+ (setq current-date (calendar-sync--add-days current-date interval)))
+ (nreverse occurrences)))
+
+(defun calendar-sync--expand-weekly (base-event rrule range)
+ "Expand weekly recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+ (let* ((start (plist-get base-event :start))
+ (end (plist-get base-event :end))
+ (interval (plist-get rrule :interval))
+ (byday (plist-get rrule :byday))
+ (until (plist-get rrule :until))
+ (count (plist-get rrule :count))
+ (occurrences '())
+ (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
+ (num-generated 0)
+ (range-end-time (cadr range))
+ (max-iterations 1000) ;; Safety: prevent infinite loops
+ (iterations 0)
+ (weekdays (if byday
+ (mapcar #'calendar-sync--weekday-to-number byday)
+ (list (calendar-sync--date-weekday current-date)))))
+ ;; Validate interval
+ (when (<= interval 0)
+ (error "Invalid RRULE interval: %s (must be > 0)" interval))
+ ;; Start from the first week
+ ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
+ ;; For COUNT, generate all occurrences from start regardless of range
+ (while (and (< iterations max-iterations)
+ (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
+ (or (not count) (< num-generated count))
+ (or (not until) (calendar-sync--before-date-p current-date until)))
+ (setq iterations (1+ iterations))
+ ;; Generate occurrences for each weekday in this week
+ (dolist (weekday weekdays)
+ (let* ((current-weekday (calendar-sync--date-weekday current-date))
+ (days-ahead (mod (- weekday current-weekday) 7))
+ (occurrence-date (calendar-sync--add-days current-date days-ahead))
+ (occurrence-datetime (append occurrence-date (nthcdr 3 start))))
+ ;; Check UNTIL date first
+ (when (or (not until) (calendar-sync--before-date-p occurrence-date until))
+ ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
+ (when (or (not count) (< num-generated count))
+ (setq num-generated (1+ num-generated))
+ ;; Only add to output if within date range
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences))))))
+ ;; Move to next interval week
+ (setq current-date (calendar-sync--add-days current-date (* 7 interval))))
+ (when (>= iterations max-iterations)
+ (cj/log-silently "calendar-sync: WARNING: Hit max iterations (%d) expanding weekly event" max-iterations))
+ (nreverse occurrences)))
+
+(defun calendar-sync--expand-monthly (base-event rrule range)
+ "Expand monthly recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+ (let* ((start (plist-get base-event :start))
+ (interval (plist-get rrule :interval))
+ (until (plist-get rrule :until))
+ (count (plist-get rrule :count))
+ (occurrences '())
+ (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
+ (num-generated 0)
+ (range-end-time (cadr range)))
+ ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
+ ;; For COUNT, generate all occurrences from start regardless of range
+ (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
+ (or (not until) (calendar-sync--before-date-p current-date until))
+ (or (not count) (< num-generated count)))
+ (let ((occurrence-datetime (append current-date (nthcdr 3 start))))
+ ;; Check UNTIL date first
+ (when (or (not until) (calendar-sync--before-date-p current-date until))
+ ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
+ (when (or (not count) (< num-generated count))
+ (setq num-generated (1+ num-generated))
+ ;; Only add to output if within date range
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences)))))
+ (setq current-date (calendar-sync--add-months current-date interval)))
+ (nreverse occurrences)))
+
+(defun calendar-sync--expand-yearly (base-event rrule range)
+ "Expand yearly recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+ (let* ((start (plist-get base-event :start))
+ (interval (plist-get rrule :interval))
+ (until (plist-get rrule :until))
+ (count (plist-get rrule :count))
+ (occurrences '())
+ (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
+ (num-generated 0)
+ (range-end-time (cadr range)))
+ ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
+ ;; For COUNT, generate all occurrences from start regardless of range
+ (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
+ (or (not until) (calendar-sync--before-date-p current-date until))
+ (or (not count) (< num-generated count)))
+ (let ((occurrence-datetime (append current-date (nthcdr 3 start))))
+ ;; Check UNTIL date first
+ (when (or (not until) (calendar-sync--before-date-p current-date until))
+ ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
+ (when (or (not count) (< num-generated count))
+ (setq num-generated (1+ num-generated))
+ ;; Only add to output if within date range
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences)))))
+ (setq current-date (calendar-sync--add-months current-date (* 12 interval))))
+ (nreverse occurrences)))
+
+(defun calendar-sync--expand-recurring-event (event-str range)
+ "Expand recurring event EVENT-STR into individual occurrences within RANGE.
+Returns list of event plists, or nil if not a recurring event."
+ (let ((rrule (calendar-sync--get-property event-str "RRULE")))
+ (when rrule
+ (let* ((base-event (calendar-sync--parse-event event-str))
+ (parsed-rrule (calendar-sync--parse-rrule rrule))
+ (freq (plist-get parsed-rrule :freq)))
+ (when base-event
+ (pcase freq
+ ('daily (calendar-sync--expand-daily base-event parsed-rrule range))
+ ('weekly (calendar-sync--expand-weekly base-event parsed-rrule range))
+ ('monthly (calendar-sync--expand-monthly base-event parsed-rrule range))
+ ('yearly (calendar-sync--expand-yearly base-event parsed-rrule range))
+ (_ (cj/log-silently "calendar-sync: Unsupported RRULE frequency: %s" freq)
+ nil)))))))
+
+(defun calendar-sync--parse-event (event-str)
+ "Parse single VEVENT string EVENT-STR into plist.
+Returns plist with :summary :description :location :start :end.
+Returns nil if event lacks required fields (DTSTART, SUMMARY).
+Skips events with RECURRENCE-ID (individual instances of recurring events)."
+ ;; Skip individual instances of recurring events (they're handled by RRULE expansion)
+ (unless (calendar-sync--get-property event-str "RECURRENCE-ID")
+ (let ((summary (calendar-sync--get-property event-str "SUMMARY"))
+ (description (calendar-sync--get-property event-str "DESCRIPTION"))
+ (location (calendar-sync--get-property event-str "LOCATION"))
+ (dtstart (calendar-sync--get-property event-str "DTSTART"))
+ (dtend (calendar-sync--get-property event-str "DTEND")))
+ (when (and summary dtstart)
+ (let ((start-parsed (calendar-sync--parse-timestamp dtstart))
+ (end-parsed (and dtend (calendar-sync--parse-timestamp dtend))))
+ (when start-parsed
+ (list :summary summary
+ :description description
+ :location location
+ :start start-parsed
+ :end end-parsed)))))))
+
+(defun calendar-sync--event-to-org (event)
+ "Convert parsed EVENT plist to org entry string."
+ (let* ((summary (plist-get event :summary))
+ (description (plist-get event :description))
+ (location (plist-get event :location))
+ (start (plist-get event :start))
+ (end (plist-get event :end))
+ (timestamp (calendar-sync--format-timestamp start end))
+ (parts (list (format "* %s" summary))))
+ (push timestamp parts)
+ (when description
+ (push description parts))
+ (when location
+ (push (format "Location: %s" location) parts))
+ (string-join (nreverse parts) "\n")))
+
+(defun calendar-sync--event-start-time (event)
+ "Extract comparable start time from EVENT plist.
+Returns time value suitable for comparison, or 0 if no start time."
+ (let ((start (plist-get event :start)))
+ (if start
+ (apply #'encode-time
+ 0 ; second
+ (or (nth 4 start) 0) ; minute
+ (or (nth 3 start) 0) ; hour
+ (nth 2 start) ; day
+ (nth 1 start) ; month
+ (nth 0 start) ; year
+ nil)
+ 0)))
+
+(defun calendar-sync--parse-ics (ics-content)
+ "Parse ICS-CONTENT and return org-formatted string.
+Returns nil if parsing fails.
+Events are sorted chronologically by start time.
+Recurring events are expanded into individual occurrences."
+ (condition-case err
+ (let* ((range (calendar-sync--get-date-range))
+ (events (calendar-sync--split-events ics-content))
+ (parsed-events '())
+ (max-events 5000) ; Safety limit to prevent Emacs from hanging
+ (events-generated 0))
+ ;; Process each event
+ (dolist (event-str events)
+ (when (< events-generated max-events)
+ (let ((expanded (calendar-sync--expand-recurring-event event-str range)))
+ (if expanded
+ ;; Recurring event - add all occurrences
+ (progn
+ (setq parsed-events (append parsed-events expanded))
+ (setq events-generated (+ events-generated (length expanded))))
+ ;; Non-recurring event - parse normally
+ (let ((parsed (calendar-sync--parse-event event-str)))
+ (when (and parsed
+ (calendar-sync--date-in-range-p (plist-get parsed :start) range))
+ (push parsed parsed-events)
+ (setq events-generated (1+ events-generated))))))))
+ (when (>= events-generated max-events)
+ (cj/log-silently "calendar-sync: WARNING: Hit max events limit (%d), some events may be missing" max-events))
+ (cj/log-silently "calendar-sync: Processing %d events..." (length parsed-events))
+ ;; Sort and convert to org format
+ (let* ((sorted-events (sort parsed-events
+ (lambda (a b)
+ (time-less-p (calendar-sync--event-start-time a)
+ (calendar-sync--event-start-time b)))))
+ (org-entries (mapcar #'calendar-sync--event-to-org sorted-events)))
+ (if org-entries
+ (concat "# Calendar Events\n\n"
+ (string-join org-entries "\n\n")
+ "\n")
+ nil)))
+ (error
+ (setq calendar-sync--last-error (error-message-string err))
+ (cj/log-silently "calendar-sync: Parse error: %s" calendar-sync--last-error)
+ nil)))
+
+;;; Sync functions
+
+(defun calendar-sync--fetch-ics (url callback)
+ "Fetch .ics file from URL asynchronously using curl.
+Calls CALLBACK with the .ics content as string (normalized to Unix line endings)
+or nil on error. CALLBACK signature: (lambda (content) ...).
+
+The fetch happens asynchronously and doesn't block Emacs. The callback is
+invoked when the fetch completes, either successfully or with an error."
+ (condition-case err
+ (let ((buffer (generate-new-buffer " *calendar-sync-curl*")))
+ (make-process
+ :name "calendar-sync-curl"
+ :buffer buffer
+ :command (list "curl" "-s" "-L" "-m" "30" url)
+ :sentinel
+ (lambda (process event)
+ (when (memq (process-status process) '(exit signal))
+ (let ((buf (process-buffer process)))
+ (when (buffer-live-p buf)
+ (let ((content
+ (with-current-buffer buf
+ (if (and (eq (process-status process) 'exit)
+ (= (process-exit-status process) 0))
+ (calendar-sync--normalize-line-endings (buffer-string))
+ (setq calendar-sync--last-error
+ (format "curl failed: %s" (string-trim event)))
+ (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error)
+ nil))))
+ (kill-buffer buf)
+ (funcall callback content))))))))
+ (error
+ (setq calendar-sync--last-error (error-message-string err))
+ (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error)
+ (funcall callback nil))))
+
+(defun calendar-sync--write-file (content file)
+ "Write CONTENT to FILE.
+Creates parent directories if needed."
+ (let ((dir (file-name-directory file)))
+ (unless (file-directory-p dir)
+ (make-directory dir t)))
+ (with-temp-file file
+ (insert content)))
+
+;;; Single Calendar Sync
+
+(defun calendar-sync--sync-calendar (calendar)
+ "Sync a single CALENDAR asynchronously.
+CALENDAR is a plist with :name, :url, and :file keys.
+Updates calendar state and saves to disk on completion."
+ (let ((name (plist-get calendar :name))
+ (url (plist-get calendar :url))
+ (file (plist-get calendar :file)))
+ ;; Mark as syncing
+ (calendar-sync--set-calendar-state name '(:status syncing))
+ (cj/log-silently "calendar-sync: [%s] Syncing..." name)
+ (calendar-sync--fetch-ics
+ url
+ (lambda (ics-content)
+ (let ((org-content (and ics-content (calendar-sync--parse-ics ics-content))))
+ (if org-content
+ (progn
+ (calendar-sync--write-file org-content file)
+ (calendar-sync--set-calendar-state
+ name
+ (list :status 'ok
+ :last-sync (current-time)
+ :last-error nil))
+ (setq calendar-sync--last-timezone-offset
+ (calendar-sync--current-timezone-offset))
+ (calendar-sync--save-state)
+ (message "calendar-sync: [%s] Sync complete → %s" name file))
+ (calendar-sync--set-calendar-state
+ name
+ (list :status 'error
+ :last-sync (plist-get (calendar-sync--get-calendar-state name) :last-sync)
+ :last-error "Parse failed"))
+ (calendar-sync--save-state)
+ (message "calendar-sync: [%s] Sync failed (see *Messages*)" name)))))))
+
+(defun calendar-sync--sync-all-calendars ()
+ "Sync all configured calendars asynchronously.
+Each calendar syncs in parallel."
+ (if (null calendar-sync-calendars)
+ (message "calendar-sync: No calendars configured (set calendar-sync-calendars)")
+ (message "calendar-sync: Syncing %d calendar(s)..." (length calendar-sync-calendars))
+ (dolist (calendar calendar-sync-calendars)
+ (calendar-sync--sync-calendar calendar))))
+
+(defun calendar-sync--calendar-names ()
+ "Return list of configured calendar names."
+ (mapcar (lambda (cal) (plist-get cal :name)) calendar-sync-calendars))
+
+(defun calendar-sync--get-calendar-by-name (name)
+ "Find calendar plist by NAME, or nil if not found."
+ (cl-find-if (lambda (cal) (string= (plist-get cal :name) name))
+ calendar-sync-calendars))
+
+;;;###autoload
+(defun calendar-sync-now (&optional calendar-name)
+ "Sync calendar(s) now asynchronously.
+When called interactively, prompts to select a specific calendar or all.
+When called non-interactively with CALENDAR-NAME, syncs that calendar.
+When called non-interactively with nil, syncs all calendars."
+ (interactive
+ (list (when calendar-sync-calendars
+ (let ((choices (cons "all" (calendar-sync--calendar-names))))
+ (completing-read "Sync calendar: " choices nil t nil nil "all")))))
+ (cond
+ ((null calendar-sync-calendars)
+ (message "calendar-sync: No calendars configured (set calendar-sync-calendars)"))
+ ((or (null calendar-name) (string= calendar-name "all"))
+ (calendar-sync--sync-all-calendars))
+ (t
+ (let ((calendar (calendar-sync--get-calendar-by-name calendar-name)))
+ (if calendar
+ (calendar-sync--sync-calendar calendar)
+ (message "calendar-sync: Calendar '%s' not found" calendar-name))))))
+
+;;;###autoload
+(defun calendar-sync-status ()
+ "Display sync status for all configured calendars."
+ (interactive)
+ (if (null calendar-sync-calendars)
+ (message "calendar-sync: No calendars configured")
+ (let ((status-lines '()))
+ (dolist (calendar calendar-sync-calendars)
+ (let* ((name (plist-get calendar :name))
+ (file (plist-get calendar :file))
+ (state (calendar-sync--get-calendar-state name))
+ (status (or (plist-get state :status) 'never))
+ (last-sync (plist-get state :last-sync))
+ (last-error (plist-get state :last-error))
+ (status-str
+ (pcase status
+ ('ok (format "✓ %s" (if last-sync
+ (format-time-string "%Y-%m-%d %H:%M" last-sync)
+ "unknown")))
+ ('error (format "✗ %s" (or last-error "error")))
+ ('syncing "⟳ syncing...")
+ ('never "— never synced"))))
+ (push (format " %s: %s → %s" name status-str (abbreviate-file-name file))
+ status-lines)))
+ (message "calendar-sync status:\n%s"
+ (string-join (nreverse status-lines) "\n")))))
+
+;;; Timer management
+
+(defun calendar-sync--sync-timer-function ()
+ "Function called by sync timer.
+Checks for timezone changes and triggers re-sync if detected."
+ (when (calendar-sync--timezone-changed-p)
+ (let ((old-tz (calendar-sync--format-timezone-offset
+ calendar-sync--last-timezone-offset))
+ (new-tz (calendar-sync--format-timezone-offset
+ (calendar-sync--current-timezone-offset))))
+ (message "calendar-sync: Timezone change detected (%s → %s), re-syncing..."
+ old-tz new-tz)))
+ (calendar-sync--sync-all-calendars))
+
+;;;###autoload
+(defun calendar-sync-start ()
+ "Start automatic calendar syncing.
+Syncs all calendars immediately, then every `calendar-sync-interval-minutes'."
+ (interactive)
+ (when calendar-sync--timer
+ (cancel-timer calendar-sync--timer))
+ (if (null calendar-sync-calendars)
+ (message "calendar-sync: No calendars configured (set calendar-sync-calendars)")
+ ;; Sync immediately
+ (calendar-sync--sync-all-calendars)
+ ;; Start timer for future syncs (convert minutes to seconds)
+ (let ((interval-seconds (* calendar-sync-interval-minutes 60)))
+ (setq calendar-sync--timer
+ (run-at-time interval-seconds
+ interval-seconds
+ #'calendar-sync--sync-timer-function)))
+ (message "calendar-sync: Auto-sync started (every %d minutes, %d calendars)"
+ calendar-sync-interval-minutes
+ (length calendar-sync-calendars))))
+
+;;;###autoload
+(defun calendar-sync-stop ()
+ "Stop automatic calendar syncing."
+ (interactive)
+ (when calendar-sync--timer
+ (cancel-timer calendar-sync--timer)
+ (setq calendar-sync--timer nil)
+ (message "calendar-sync: Auto-sync stopped")))
+
+;;;###autoload
+(defun calendar-sync-toggle ()
+ "Toggle automatic calendar syncing on/off."
+ (interactive)
+ (if calendar-sync--timer
+ (calendar-sync-stop)
+ (calendar-sync-start)))
+
+;;; Keybindings
+
+;; Calendar sync prefix and keymap
+(defvar-keymap cj/calendar-map
+ :doc "Keymap for calendar synchronization operations"
+ "s" #'calendar-sync-now
+ "i" #'calendar-sync-status
+ "t" #'calendar-sync-toggle
+ "S" #'calendar-sync-start
+ "x" #'calendar-sync-stop)
+
+;; Only set up keybindings if cj/custom-keymap exists (not in test environment)
+(when (boundp 'cj/custom-keymap)
+ (keymap-set cj/custom-keymap "g" cj/calendar-map)
+
+ (with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; g" "calendar sync menu"
+ "C-; g s" "sync now"
+ "C-; g i" "sync status"
+ "C-; g t" "toggle auto-sync"
+ "C-; g S" "start auto-sync"
+ "C-; g x" "stop auto-sync")))
+
+;;; Initialization
+
+;; Load saved state from previous session
+(calendar-sync--load-state)
+
+;; Check for timezone change on startup
+(when (and calendar-sync-calendars
+ (calendar-sync--timezone-changed-p))
+ (let ((old-tz (calendar-sync--format-timezone-offset
+ calendar-sync--last-timezone-offset))
+ (new-tz (calendar-sync--format-timezone-offset
+ (calendar-sync--current-timezone-offset))))
+ (message "calendar-sync: Timezone changed since last session (%s → %s)"
+ old-tz new-tz)
+ (message "calendar-sync: Will sync on next timer tick")
+ ;; Note: We don't auto-sync here to avoid blocking Emacs startup
+ ;; User can manually sync or it will happen on next timer tick if auto-sync is enabled
+ ))
+
+;; Start auto-sync if enabled and calendars are configured
+;; Syncs immediately then every calendar-sync-interval-minutes (default: 60 minutes)
+(when (and calendar-sync-auto-start calendar-sync-calendars)
+ (calendar-sync-start))
+
+(provide 'calendar-sync)
+;;; calendar-sync.el ends here
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
index ab9defd0..cf4b65ba 100644
--- a/modules/calibredb-epub-config.el
+++ b/modules/calibredb-epub-config.el
@@ -87,6 +87,10 @@
;; ------------------------------ Nov Epub Reader ------------------------------
+(defvar cj/nov-margin-percent 25
+ "Percentage of window width to use as margins on each side when reading epubs.
+For example, 25 means 25% left margin + 25% right margin, with 50% for text.")
+
;; Prevent magic-fallback-mode-alist from opening epub as archive-mode
;; Advise set-auto-mode to force nov-mode for .epub files before magic-fallback runs
(defun cj/force-nov-mode-for-epub (orig-fun &rest args)
@@ -123,7 +127,7 @@
(interactive)
;; Use Merriweather for comfortable reading with appropriate scaling
;; Darker sepia color (#E8DCC0) is easier on the eyes than pure white
- (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.8 :foreground "#E8DCC0")
+ (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0 :foreground "#E8DCC0")
(face-remap-add-relative 'default :family "Merriweather" :height 180 :foreground "#E8DCC0")
(face-remap-add-relative 'fixed-pitch :height 180 :foreground "#E8DCC0")
;; Make this buffer-local so other Nov buffers can choose differently
@@ -136,8 +140,14 @@
;; Enable visual-fill-column for centered text with margins
(when (require 'visual-fill-column nil t)
(setq-local visual-fill-column-center-text t)
- ;; Set text width for comfortable reading (characters per line)
- (setq-local visual-fill-column-width 100)
+ ;; Calculate text width based on configurable margin percentage
+ (let ((window (get-buffer-window (current-buffer)))
+ (text-width-ratio (- 1.0 (* 2 (/ cj/nov-margin-percent 100.0)))))
+ (if window
+ (setq-local visual-fill-column-width
+ (floor (* text-width-ratio (window-body-width window))))
+ ;; Fallback if no window yet
+ (setq-local visual-fill-column-width 80)))
(visual-fill-column-mode 1))
(nov-render-document)
;; Force visual-fill-column to recalculate after rendering
diff --git a/modules/chrono-tools.el b/modules/chrono-tools.el
index e68c2a50..ab3a9890 100644
--- a/modules/chrono-tools.el
+++ b/modules/chrono-tools.el
@@ -5,7 +5,7 @@
;;
;; This module centralizes configuration for Emacs time-related tools:
;;
-;; – world-clock: predefined city list and custom time format
+;; – time-zones: interactive world clock with fuzzy search and time shifting
;; – calendar: quick navigation keybindings by day, month, and year
;; – tmr: lightweight timer setup with sounds, notifications, and history
;;
@@ -13,24 +13,32 @@
(require 'user-constants)
-(use-package time
- :ensure nil ;; built-in
- :defer 0.5
- :bind ("C-x c" . world-clock)
- :config
- (setq world-clock-list
- '(("Pacific/Honolulu" " Honolulu")
- ("America/Los_Angeles" " San Francisco, LA")
- ("America/Chicago" " Chicago, New Orleans")
- ("America/New_York" " New York, Boston")
- ("Etc/UTC" " UTC =================")
- ("Europe/London" " London, Lisbon")
- ("Europe/Paris" " Paris, Berlin, Rome")
- ("Europe/Athens" " Athens, Istanbul, Moscow")
- ("Asia/Kolkata" " India")
- ("Asia/Shanghai" " Shanghai, Singapore")
- ("Asia/Tokyo" " Tokyo, Seoul")))
- (setq world-clock-time-format " %a, %d %b @ %I:%M %p %Z"))
+;; -------------------------------- Time Zones ---------------------------------
+
+(use-package time-zones
+ :defer
+ :commands time-zones
+ :bind ("M-C" . time-zones))
+
+;; Commented out old world-clock config while testing time-zone package above
+;; (use-package time
+;; :ensure nil ;; built-in
+;; :defer 0.5
+;; :bind ("C-x c" . world-clock)
+;; :config
+;; (setq world-clock-list
+;; '(("Pacific/Honolulu" " Honolulu")
+;; ("America/Los_Angeles" " San Francisco, LA")
+;; ("America/Chicago" " Chicago, New Orleans")
+;; ("America/New_York" " New York, Boston")
+;; ("Etc/UTC" " UTC =================")
+;; ("Europe/London" " London, Lisbon")
+;; ("Europe/Paris" " Paris, Berlin, Rome")
+;; ("Europe/Athens" " Athens, Istanbul, Moscow")
+;; ("Asia/Kolkata" " India")
+;; ("Asia/Shanghai" " Shanghai, Singapore")
+;; ("Asia/Tokyo" " Tokyo, Seoul")))
+;; (setq world-clock-time-format " %a, %d %b @ %I:%M %p %Z"))
(use-package calendar
:ensure nil ;; built-in
diff --git a/modules/config-utilities.el b/modules/config-utilities.el
index d1538256..2af3effa 100644
--- a/modules/config-utilities.el
+++ b/modules/config-utilities.el
@@ -17,12 +17,26 @@
(keymap-global-set "C-c d" cj/debug-config-keymap)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-c d" "config debugging utils"))
+ (which-key-add-key-based-replacements
+ "C-c d" "config debugging utils"
+ "C-c d p" "profiler menu"
+ "C-c d p s" "start profiler"
+ "C-c d p h" "stop profiler"
+ "C-c d p r" "profiler report"
+ "C-c d t" "toggle debug-on-error"
+ "C-c d b" "benchmark method"
+ "C-c d c" "compilation menu"
+ "C-c d c h" "compile home"
+ "C-c d c d" "delete compiled"
+ "C-c d c ." "compile buffer"
+ "C-c d i" "info menu"
+ "C-c d i b" "info build"
+ "C-c d i p" "info packages"
+ "C-c d i f" "info features"
+ "C-c d r" "reload init"))
;;; --------------------------------- Profiling ---------------------------------
-(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-c d p" "profiler menu."))
(keymap-set cj/debug-config-keymap "p s" #'profiler-start)
(keymap-set cj/debug-config-keymap "p h" #'profiler-stop)
(keymap-set cj/debug-config-keymap "p r" #'profiler-report)
@@ -92,8 +106,6 @@ Recompile natively when supported, otherwise fall back to byte compilation."
(message "Cancelled recompilation of %s" user-emacs-directory))))
(keymap-set cj/debug-config-keymap "c h" 'cj/recompile-emacs-home)
-(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-c d c" "config compilation options."))
(defun cj/delete-emacs-home-compiled-files ()
"Delete all compiled files recursively in \='user-emacs-directory\='."
@@ -108,7 +120,6 @@ Recompile natively when supported, otherwise fall back to byte compilation."
(find-lisp-find-files user-emacs-directory ""))
(message "Done. Compiled files removed under %s" user-emacs-directory))
(keymap-set cj/debug-config-keymap "c d" 'cj/delete-emacs-home-compiled-files)
-(keymap-set cj/debug-config-keymap "c d" 'cj/delete-emacs-home-compiled-files)
(defun cj/compile-this-elisp-buffer ()
"Compile the current .el: prefer native (.eln), else .elc. Message if neither."
@@ -215,8 +226,6 @@ Recompile natively when supported, otherwise fall back to byte compilation."
(pop-to-buffer buf)))
(keymap-set cj/debug-config-keymap "i b" 'cj/info-emacs-build)
-(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-c d i" "info on build/features/packages."))
(defvar cj--loaded-file-paths nil
"All file paths that are loaded.")
@@ -273,15 +282,6 @@ Recompile natively when supported, otherwise fall back to byte compilation."
(load-file user-init-file))
(keymap-set cj/debug-config-keymap "r" 'cj/reload-init-file)
-;; ----------------------------- Reset-Auth-Sources ----------------------------
-
-(defun cj/reset-auth-cache ()
- "Clear Emacs auth-source cache."
- (interactive)
- (auth-source-forget-all-cached)
- (message "Emacs auth-source cache cleared."))
-(keymap-set cj/debug-config-keymap "a" 'cj/reset-auth-cache)
-
;; ------------------------ Validate Org Agenda Entries ------------------------
(defun cj/validate-org-agenda-timestamps ()
diff --git a/modules/custom-buffer-file.el b/modules/custom-buffer-file.el
new file mode 100644
index 00000000..b5740cd2
--- /dev/null
+++ b/modules/custom-buffer-file.el
@@ -0,0 +1,376 @@
+;;; custom-buffer-file.el --- Custom Buffer and File Operations -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;;; Commentary:
+;; This module provides custom buffer and file operations including PostScript
+;; printing capabilities.
+;;
+;; Functions include:
+;; - printing buffers or regions as PostScript to the default printer (with color support)
+;; - moving/renaming/deleting buffer files
+;; - diffing buffer contents with saved file version
+;; - copying file paths and file:// links to the kill ring
+;; - copying buffer contents (whole buffer, to top of buffer, to bottom of buffer)
+;; - clearing buffer contents from point to top or bottom.
+;;
+;; The PostScript printing auto-detects the system print spooler (lpr or lp)
+;; and prints with face/syntax highlighting.
+;;
+;; Keybindings under ~C-; b~:
+;; - ~C-; b k~ kill buffer and window (delete window, kill/bury buffer)
+;; - Copy buffer content submenu at ~C-; b c~
+;; - ~C-; b c w~ copy whole buffer
+;; - ~C-; b c t~ copy from beginning to point
+;; - ~C-; b c b~ copy from point to end
+;;
+;;; Code:
+
+;; cj/custom-keymap defined in keybindings.el
+(eval-when-compile (defvar cj/custom-keymap))
+(eval-when-compile (require 'ps-print)) ;; for ps-print variables
+(declare-function ps-print-buffer-with-faces "ps-print")
+(declare-function ps-print-region-with-faces "ps-print")
+
+;; ------------------------- Print Buffer As Postscript ------------------------
+
+(defvar cj/print-spooler-command 'auto
+ "Command used to send PostScript to the system print spooler.
+Set to a string to force a specific command (e.g., lpr or lp). Set to `auto' to
+auto-detect once per session.")
+
+(defvar cj/print--spooler-cache nil
+ "Cached spooler command detected for the current Emacs session.")
+
+(defun cj/print--resolve-spooler ()
+ "Return the spooler command to use, auto-detecting and caching if needed."
+ (cond
+ ;; User-specified command
+ ((and (stringp cj/print-spooler-command)
+ (> (length cj/print-spooler-command) 0))
+ (or (executable-find cj/print-spooler-command)
+ (user-error "Cannot print: spooler command '%s' not found in PATH"
+ cj/print-spooler-command))
+ cj/print-spooler-command)
+ ;; Auto-detect once per session
+ ((eq cj/print-spooler-command 'auto)
+ (or cj/print--spooler-cache
+ (let ((cmd (or (and (executable-find "lpr") "lpr")
+ (and (executable-find "lp") "lp"))))
+ (unless cmd
+ (user-error "Cannot print: neither 'lpr' nor 'lp' found in PATH"))
+ (setq cj/print--spooler-cache cmd)
+ cmd)))
+ (t
+ (user-error "Invalid value for cj/print-spooler-command: %S"
+ cj/print-spooler-command))))
+
+(defun cj/print-buffer-ps (&optional color)
+ "Print the buffer (or active region) as PostScript to the default printer.
+With prefix argument COLOR, print in color and skip confirmation; otherwise
+print in monochrome with confirmation prompt.
+Sends directly to the system spooler with no header."
+ (interactive "P")
+ (unless (require 'ps-print nil t)
+ (user-error "Cannot print: ps-print library not found"))
+ (let* ((spooler (cj/print--resolve-spooler))
+ (want-color (not (null color)))
+ (have-region (use-region-p))
+ (skip-confirm color)) ; C-u skips confirmation
+ ;; Confirm unless C-u was used
+ (when (and (not skip-confirm)
+ (not (y-or-n-p (format "Send %s to printer? "
+ (if have-region "region" "buffer")))))
+ (user-error "Printing cancelled"))
+ (let ((ps-lpr-command spooler)
+ (ps-printer-name nil) ; default system printer
+ (ps-lpr-switches nil)
+ (ps-print-color-p want-color)
+ (ps-use-face-background want-color)
+ (ps-print-header nil)) ; no headers
+ (if have-region
+ (ps-print-region-with-faces (region-beginning) (region-end))
+ (ps-print-buffer-with-faces)))
+ (message "Sent %s to default printer via %s (%s)"
+ (if have-region "region" "buffer")
+ spooler
+ (if want-color "color" "monochrome"))))
+
+;; ------------------------- Buffer And File Operations ------------------------
+
+(defun cj/--move-buffer-and-file (dir &optional ok-if-exists)
+ "Internal implementation: Move buffer and file to DIR.
+If OK-IF-EXISTS is nil and target exists, signal an error.
+If OK-IF-EXISTS is non-nil, overwrite existing file.
+Returns t on success, nil if buffer not visiting a file."
+ (let* ((name (buffer-name))
+ (filename (buffer-file-name))
+ (dir (expand-file-name dir))
+ (dir
+ (if (string-match "[/\\\\]$" dir)
+ (substring dir 0 -1) dir))
+ (newname (concat dir "/" name)))
+ (if (not filename)
+ (progn
+ (message "Buffer '%s' is not visiting a file!" name)
+ nil)
+ (progn (copy-file filename newname ok-if-exists)
+ (delete-file filename)
+ (set-visited-file-name newname)
+ (set-buffer-modified-p nil)
+ t))))
+
+(defun cj/move-buffer-and-file (dir)
+ "Move both current buffer and the file it visits to DIR.
+When called interactively, prompts for confirmation if target file exists."
+ (interactive (list (read-directory-name "Move buffer and file (to new directory): ")))
+ (let* ((target (expand-file-name (buffer-name) (expand-file-name dir))))
+ (condition-case _
+ (cj/--move-buffer-and-file dir nil)
+ (file-already-exists
+ (if (yes-or-no-p (format "File %s exists; overwrite? " target))
+ (cj/--move-buffer-and-file dir t)
+ (message "File not moved"))))))
+
+(defun cj/--rename-buffer-and-file (new-name &optional ok-if-exists)
+ "Internal implementation: Rename buffer and file to NEW-NAME.
+NEW-NAME can be just a basename or a full path to move to different directory.
+If OK-IF-EXISTS is nil and target exists, signal an error.
+If OK-IF-EXISTS is non-nil, overwrite existing file.
+Returns t on success, nil if buffer not visiting a file."
+ (let ((filename (buffer-file-name))
+ (new-basename (file-name-nondirectory new-name)))
+ (if (not filename)
+ (progn
+ (message "Buffer '%s' is not visiting a file!" (buffer-name))
+ nil)
+ ;; Check if a buffer with the new name already exists
+ (when (and (get-buffer new-basename)
+ (not (eq (get-buffer new-basename) (current-buffer))))
+ (error "A buffer named '%s' already exists" new-basename))
+ ;; Expand new-name to absolute path (preserves directory if just basename)
+ (let ((expanded-name (expand-file-name new-name
+ (file-name-directory filename))))
+ (rename-file filename expanded-name ok-if-exists)
+ (rename-buffer new-basename)
+ (set-visited-file-name expanded-name)
+ (set-buffer-modified-p nil)
+ t))))
+
+(defun cj/rename-buffer-and-file (new-name)
+ "Rename both current buffer and the file it visits to NEW-NAME.
+When called interactively, prompts for confirmation if target file exists."
+ (interactive
+ (list (if (not (buffer-file-name))
+ (user-error "Buffer '%s' is not visiting a file!" (buffer-name))
+ (read-string "Rename buffer and file (to new name): "
+ (file-name-nondirectory (buffer-file-name))))))
+ (condition-case err
+ (cj/--rename-buffer-and-file new-name nil)
+ (file-already-exists
+ (if (yes-or-no-p (format "File %s exists; overwrite? " new-name))
+ (cj/--rename-buffer-and-file new-name t)
+ (message "File not renamed")))
+ (error
+ ;; Handle buffer-already-exists and other errors
+ (message "%s" (error-message-string err)))))
+
+(defun cj/delete-buffer-and-file ()
+ "Kill the current buffer and delete the file it visits."
+ (interactive)
+ (let ((filename (buffer-file-name)))
+ (when filename
+ (if (vc-backend filename)
+ (vc-delete-file filename)
+ (progn
+ (delete-file filename t)
+ (message "Deleted file %s" filename)
+ (kill-buffer))))))
+
+(defun cj/copy-link-to-buffer-file ()
+ "Copy the full file:// path of the current buffer's source file to the kill ring."
+ (interactive)
+ (let ((file-path (buffer-file-name)))
+ (when file-path
+ (setq file-path (concat "file://" file-path))
+ (kill-new file-path)
+ (message "Copied file link to kill ring: %s" file-path))))
+
+(defun cj/copy-path-to-buffer-file-as-kill ()
+ "Copy the full path of the current buffer's file to the kill ring.
+Signal an error if the buffer is not visiting a file."
+ (interactive)
+ (let ((path (buffer-file-name)))
+ (if (not path)
+ (user-error "Current buffer is not visiting a file")
+ (kill-new path)
+ (message "Copied file path: %s" path)
+ path)))
+
+(defun cj/copy-whole-buffer ()
+ "Copy the entire contents of the current buffer to the kill ring.
+Point and mark are left exactly where they were. No transient region
+is created. A message is displayed when done."
+ (interactive)
+ (let ((contents (buffer-substring-no-properties (point-min) (point-max))))
+ (kill-new contents)
+ (message "Buffer contents copied to kill ring")))
+
+(defun cj/copy-to-bottom-of-buffer ()
+ "Copy text from point to the end of the buffer to the kill ring.
+Point and mark are left exactly where they were. No transient region
+is created. A message is displayed when done."
+ (interactive)
+ (let ((contents (buffer-substring-no-properties (point) (point-max))))
+ (kill-new contents)
+ (message "Copied from point to end of buffer")))
+
+(defun cj/copy-to-top-of-buffer ()
+ "Copy text from the beginning of the buffer to point to the kill ring.
+Point and mark are left exactly where they were. No transient region
+is created. A message is displayed when done."
+ (interactive)
+ (let ((contents (buffer-substring-no-properties (point-min) (point))))
+ (kill-new contents)
+ (message "Copied from beginning of buffer to point")))
+
+(defun cj/clear-to-bottom-of-buffer ()
+ "Delete all text from point to the end of the current buffer.
+This does not save the deleted text in the kill ring."
+ (interactive)
+ (delete-region (point) (point-max))
+ (message "Buffer contents removed to the end of the buffer."))
+
+(defun cj/clear-to-top-of-buffer ()
+ "Delete all text from point to the beginning of the current buffer.
+Do not save the deleted text in the kill ring."
+ (interactive)
+ (delete-region (point) (point-min))
+ (message "Buffer contents removed to the beginning of the buffer."))
+
+(defun cj/copy-buffer-name ()
+ "Copy current buffer name to kill ring."
+ (interactive)
+ (kill-new (buffer-name))
+ (message "Copied: %s" (buffer-name)))
+
+(require 'system-lib)
+(declare-function ansi-color-apply-on-region "ansi-color")
+
+(defun cj/--diff-with-difftastic (file1 file2 buffer)
+ "Run difftastic on FILE1 and FILE2, output to BUFFER.
+Applies ANSI color and sets up special-mode for navigation."
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert (format "Difftastic diff: %s (saved) vs buffer (modified)\n\n"
+ (file-name-nondirectory file1)))
+ (call-process "difft" nil t nil
+ "--color" "always"
+ "--display" "side-by-side-show-both"
+ file1 file2)
+ (require 'ansi-color)
+ (ansi-color-apply-on-region (point-min) (point-max))
+ (special-mode)
+ (goto-char (point-min)))))
+
+(defun cj/--diff-with-regular-diff (file1 file2 buffer)
+ "Run regular unified diff on FILE1 and FILE2, output to BUFFER.
+Sets up diff-mode for navigation."
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert (format "Unified diff: %s (saved) vs buffer (modified)\n\n"
+ (file-name-nondirectory file1)))
+ (call-process "diff" nil t nil "-u" file1 file2)
+ (diff-mode)
+ (goto-char (point-min)))))
+
+(defun cj/diff-buffer-with-file ()
+ "Compare the current modified buffer with the saved version.
+Uses difftastic if available for syntax-aware diffing, falls back to regular diff.
+Shows output in a separate buffer.
+Signal an error if the buffer is not visiting a file."
+ (interactive)
+ (unless (buffer-file-name)
+ (user-error "Current buffer is not visiting a file"))
+ (let* ((file (buffer-file-name))
+ (file-ext (file-name-extension file t)) ; includes the dot
+ (temp-file (make-temp-file "buffer-diff-" nil file-ext))
+ (buffer-content (buffer-string))) ; Capture BEFORE with-temp-file!
+ (unwind-protect
+ (progn
+ ;; Write current buffer content to temp file
+ (with-temp-file temp-file
+ (insert buffer-content))
+ ;; Check if there are any differences first
+ (if (zerop (call-process "diff" nil nil nil "-q" file temp-file))
+ (message "No differences between buffer and file")
+ ;; Run diff/difftastic and display in buffer
+ (let* ((using-difftastic (cj/executable-exists-p "difft"))
+ (buffer-name (if using-difftastic
+ "*Diff (difftastic)*"
+ "*Diff (unified)*"))
+ (diff-buffer (get-buffer-create buffer-name)))
+ (if using-difftastic
+ (cj/--diff-with-difftastic file temp-file diff-buffer)
+ (cj/--diff-with-regular-diff file temp-file diff-buffer))
+ (display-buffer diff-buffer))))
+ ;; Clean up temp file
+ (when (file-exists-p temp-file)
+ (delete-file temp-file)))))
+
+;; --------------------------- Buffer And File Keymap --------------------------
+
+;; Copy buffer content sub-keymap
+(defvar-keymap cj/copy-buffer-content-map
+ :doc "Keymap for copy buffer content operations."
+ "w" #'cj/copy-whole-buffer
+ "b" #'cj/copy-to-bottom-of-buffer
+ "t" #'cj/copy-to-top-of-buffer)
+
+;; Buffer & file operations prefix and keymap
+(defvar-keymap cj/buffer-and-file-map
+ :doc "Keymap for buffer and file operations."
+ "m" #'cj/move-buffer-and-file
+ "r" #'cj/rename-buffer-and-file
+ "p" #'cj/copy-path-to-buffer-file-as-kill
+ "d" #'cj/delete-buffer-and-file
+ "D" #'cj/diff-buffer-with-file
+ "c" cj/copy-buffer-content-map
+ "n" #'cj/copy-buffer-name
+ "l" #'cj/copy-link-to-buffer-file
+ "k" #'cj/kill-buffer-and-window
+ "P" #'cj/print-buffer-ps
+ "t" #'cj/clear-to-top-of-buffer
+ "b" #'cj/clear-to-bottom-of-buffer
+ "x" #'erase-buffer
+ "s" #'mark-whole-buffer
+ "S" #'write-file ;; save as
+ "g" #'revert-buffer)
+(keymap-set cj/custom-keymap "b" cj/buffer-and-file-map)
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; b" "buffer and file menu"
+ "C-; b m" "move file"
+ "C-; b r" "rename file"
+ "C-; b p" "copy file path"
+ "C-; b d" "delete file"
+ "C-; b D" "diff buffer with file"
+ "C-; b c" "buffer copy menu"
+ "C-; b c w" "copy whole buffer"
+ "C-; b c b" "copy to bottom"
+ "C-; b c t" "copy to top"
+ "C-; b n" "copy buffer name"
+ "C-; b l" "copy file link"
+ "C-; b k" "kill buffer and window"
+ "C-; b P" "print to PS"
+ "C-; b t" "clear to top"
+ "C-; b b" "clear to bottom"
+ "C-; b x" "erase buffer"
+ "C-; b s" "select whole buffer"
+ "C-; b S" "save as"
+ "C-; b g" "revert buffer"))
+
+
+(provide 'custom-buffer-file)
+;;; custom-buffer-file.el ends here.
diff --git a/modules/custom-case.el b/modules/custom-case.el
index 4fd9ac05..59250ddb 100644
--- a/modules/custom-case.el
+++ b/modules/custom-case.el
@@ -118,7 +118,11 @@ short prepositions, and all articles are considered minor words."
(keymap-set cj/custom-keymap "c" cj/case-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; c" "case change menu"))
+ (which-key-add-key-based-replacements
+ "C-; c" "case change menu"
+ "C-; c t" "title case"
+ "C-; c u" "upcase"
+ "C-; c l" "downcase"))
(provide 'custom-case)
;;; custom-case.el ends here.
diff --git a/modules/custom-comments.el b/modules/custom-comments.el
index 101ba092..0d83d31b 100644
--- a/modules/custom-comments.el
+++ b/modules/custom-comments.el
@@ -13,11 +13,50 @@
;; These utilities help create consistent, well-formatted code comments and section headers.
;; Bound to keymap prefix: C-; C
;;
+;; Comment Style Patterns:
+;;
+;; inline-border:
+;; ========== inline-border ==========
+;;
+;; simple-divider:
+;; ====================================
+;; simple-divider
+;; ====================================
+;;
+;; padded-divider:
+;; ====================================
+;; padded-divider
+;; ====================================
+;;
+;; box:
+;; ************************************
+;; * box *
+;; ************************************
+;;
+;; heavy-box:
+;; ************************************
+;; * *
+;; * heavy-box *
+;; * *
+;; ************************************
+;;
+;; unicode-box:
+;; ┌──────────────────────────────────┐
+;; │ unicode-box │
+;; └──────────────────────────────────┘
+;;
+;; block-banner:
+;; /************************************
+;; * block-banner
+;; ************************************/
+;;
;;; Code:
(eval-when-compile (defvar cj/custom-keymap)) ;; cj/custom-keymap defined in keybindings.el
(autoload 'cj/join-line-or-region "custom-line-paragraph" nil t)
+;; ======================== Comment Manipulation Functions =====================
+
;; --------------------------- Delete Buffer Comments --------------------------
(defun cj/delete-buffer-comments ()
@@ -38,150 +77,560 @@
(orig-fill-column fill-column))
(uncomment-region beg end)
(setq fill-column (- fill-column 3))
- (cj/join-line-or-region beg end)
+ (cj/join-line-or-region)
(comment-region beg end)
(setq fill-column orig-fill-column )))
;; if no region
(message "No region was selected. Select the comment lines to reformat."))
-;; ------------------------------ Comment Centered -----------------------------
+;; ======================== Comment Generation Functions =======================
+
+;; ----------------------------- Inline Border ---------------------------------
+
+(defun cj/--comment-inline-border (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate single-line centered comment with decoration.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for borders (string).
+TEXT is the comment text (will be centered).
+LENGTH is the total width of the line."
+ (let* ((current-column-pos (current-column))
+ (text-length (length text))
+ (comment-start-len (+ (length cmt-start)
+ (if (equal cmt-start ";") 1 0))) ; doubled semicolon
+ ;; Calculate available space for decoration + text + spaces
+ (available-width (- length current-column-pos
+ comment-start-len
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))
+ 1)) ; space after comment-start
+ ;; Space for decoration on each side (excluding text and its surrounding spaces)
+ (space-on-each-side (/ (- available-width
+ text-length
+ (if (> text-length 0) 2 0)) ; spaces around text
+ 2))
+ (min-space 2))
+ ;; Validate we have enough space
+ (when (< space-on-each-side min-space)
+ (error "Length %d is too small for text '%s' (need at least %d more chars)"
+ length text (- min-space space-on-each-side)))
+ ;; Generate the line
+ (insert cmt-start)
+ (when (equal cmt-start ";")
+ (insert cmt-start))
+ (insert " ")
+ ;; Left decoration
+ (dotimes (_ space-on-each-side)
+ (insert decoration-char))
+ ;; Text with spaces
+ (when (> text-length 0)
+ (insert " " text " "))
+ ;; Right decoration (handle odd-length text)
+ (dotimes (_ (if (= (% text-length 2) 0)
+ (- space-on-each-side 1)
+ space-on-each-side))
+ (insert decoration-char))
+ ;; Comment end
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)))
+
+(defun cj/comment-inline-border (&optional decoration-char)
+ "Insert single-line comment with TEXT centered around DECORATION-CHAR borders.
+DECORATION-CHAR defaults to \"#\" if not provided.
+Uses the lesser of `fill-column\\=' or 80 for line length."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (or decoration-char "#"))
+ (text (capitalize (string-trim (read-from-minibuffer "Comment: "))))
+ (length (min fill-column 80)))
+ (cj/--comment-inline-border comment-start comment-end decoration-char text length)))
+
+;; ---------------------------- Simple Divider ---------------------------------
+
+(defun cj/--comment-simple-divider (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a simple divider comment.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for the divider lines.
+TEXT is the comment text.
+LENGTH is the total width of each line."
+ (let* ((current-column-pos (current-column))
+ (min-length (+ current-column-pos
+ (length cmt-start)
+ (if (equal cmt-start ";") 1 0) ; doubled semicolon
+ 1 ; space after comment-start
+ 3 ; minimum decoration chars
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end))))))
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos
+ (length cmt-start)
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))))
+ (line (make-string available-width (string-to-char decoration-char))))
+ ;; Top line
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (insert line)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Text line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " " text)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Bottom line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (insert line)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline))))
+
+(defun cj/comment-simple-divider ()
+ "Insert a simple divider comment banner.
+Prompts for decoration character, text, and length option."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (read-string "Decoration character (default =): " nil nil "="))
+ (text (read-string "Comment text: "))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "match-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "match-text")
+ (+ (length comment-start)
+ (if (equal comment-start ";") 1 0)
+ 1 ; space after comment-start
+ (length text)
+ (if (string-empty-p comment-end) 0 (1+ (length comment-end))))))))
+ (cj/--comment-simple-divider comment-start comment-end decoration-char text length)))
+
+;; ---------------------------- Padded Divider ---------------------------------
+
+(defun cj/--comment-padded-divider (cmt-start cmt-end decoration-char text length padding)
+ "Internal implementation: Generate a padded divider comment.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for the divider lines.
+TEXT is the comment text.
+LENGTH is the total width of each line.
+PADDING is the number of spaces before the text."
+ (when (< padding 0)
+ (error "Padding %d cannot be negative" padding))
+ (let* ((current-column-pos (current-column))
+ (min-length (+ current-column-pos
+ (length cmt-start)
+ (if (equal cmt-start ";") 1 0) ; doubled semicolon
+ 1 ; space after comment-start
+ 3 ; minimum decoration chars
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end))))))
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos
+ (length cmt-start)
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))))
+ (line (make-string available-width (string-to-char decoration-char))))
+ ;; Top line
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (insert line)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Text line with padding
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (dotimes (_ padding) (insert " "))
+ (insert text)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Bottom line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert cmt-start)
+ (when (equal cmt-start ";") (insert cmt-start))
+ (insert " ")
+ (insert line)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline))))
-(defun cj/comment-centered (&optional comment-char)
- "Insert comment text centered around the COMMENT-CHAR character.
-Default to the hash character when COMMENT-CHAR is nil.
-Use the lesser of `fill-column' or 80 to calculate the comment length.
-Begin and end line with the appropriate comment symbols for the current mode."
+(defun cj/comment-padded-divider ()
+ "Insert a padded divider comment banner.
+Prompts for decoration character, text, padding, and length option."
(interactive)
- (if (not (char-or-string-p comment-char))
- (setq comment-char "#"))
- (let* ((comment (capitalize (string-trim (read-from-minibuffer "Comment: "))))
- (fill-column (min fill-column 80))
- (comment-length (length comment))
- ;; (comment-start-length (length comment-start))
- ;; (comment-end-length (length comment-end))
- (current-column-pos (current-column))
- (space-on-each-side (/ (- fill-column
- current-column-pos
- comment-length
- (length comment-start)
- (length comment-end)
- ;; Single space on each side of comment
- (if (> comment-length 0) 2 0)
- ;; Single space after comment syntax sting
- 1)
- 2)))
- (if (< space-on-each-side 2)
- (message "Comment string is too big to fit in one line")
- (progn
- (insert comment-start)
- (when (equal comment-start ";") ;; emacs-lisp line comments are ';;'
- (insert comment-start)) ;; so insert comment-char again
- (insert " ")
- (dotimes (_ space-on-each-side) (insert comment-char))
- (when (> comment-length 0) (insert " "))
- (insert comment)
- (when (> comment-length 0) (insert " "))
- (dotimes (_ (if (= (% comment-length 2) 0)
- (- space-on-each-side 1)
- space-on-each-side))
- (insert comment-char))
- ;; Only insert trailing space and comment-end if comment-end is not empty
- (when (not (string-empty-p comment-end))
- (insert " ")
- (insert comment-end))))))
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (read-string "Decoration character (default =): " nil nil "="))
+ (text (read-string "Comment text: "))
+ (padding (string-to-number (read-string "Padding spaces (default 2): " nil nil "2")))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "match-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "match-text")
+ (+ (length comment-start)
+ (if (equal comment-start ";") 1 0)
+ 1 ; space after comment-start
+ padding
+ (length text)
+ (if (string-empty-p comment-end) 0 (1+ (length comment-end))))))))
+ (cj/--comment-padded-divider comment-start comment-end decoration-char text length padding)))
;; -------------------------------- Comment Box --------------------------------
+(defun cj/--comment-box (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a 3-line box comment with centered text.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for borders.
+TEXT is the comment text (centered).
+LENGTH is the total width of each line."
+ (let* ((current-column-pos (current-column))
+ (comment-char (if (equal cmt-start ";") ";;" cmt-start))
+ (comment-end-char (if (string-empty-p cmt-end) comment-char cmt-end))
+ (min-length (+ current-column-pos
+ (length comment-char)
+ 2 ; spaces around content
+ (length comment-end-char)
+ 6))) ; minimum: 3 border chars + text space + 3 border chars
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos
+ (length comment-char)
+ (length comment-end-char)
+ 2)) ; spaces around content
+ (border-line (make-string available-width (string-to-char decoration-char)))
+ (text-length (length text))
+ ;; For text line: need space for decoration + space + text + space + decoration
+ (text-available (- available-width 4)) ; 2 for side decorations, 2 for spaces
+ (padding-each-side (max 1 (/ (- text-available text-length) 2)))
+ (right-padding (if (= (% (- text-available text-length) 2) 0)
+ padding-each-side
+ (1+ padding-each-side))))
+ ;; Top border
+ (insert comment-char " " border-line " " comment-end-char)
+ (newline)
+
+ ;; Centered text line with side borders
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " decoration-char " ")
+ (dotimes (_ padding-each-side) (insert " "))
+ (insert text)
+ (dotimes (_ right-padding) (insert " "))
+ (insert " " decoration-char " " comment-end-char)
+ (newline)
+
+ ;; Bottom border
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " border-line " " comment-end-char)
+ (newline))))
+
(defun cj/comment-box ()
- "Insert a comment box around text that the user inputs.
-The box extends to the fill column, centers the text, and uses the current
-mode's comment syntax at both the beginning and end of each line. The box
-respects the current indentation level and avoids trailing whitespace."
+ "Insert a 3-line comment box with centered text.
+Prompts for decoration character, text, and uses `fill-column' for length."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (read-string "Decoration character (default -): " nil nil "-"))
+ (text (capitalize (string-trim (read-from-minibuffer "Comment: "))))
+ (length (min fill-column 80)))
+ (cj/--comment-box comment-start comment-end decoration-char text length)))
+
+;; ------------------------------ Heavy Box ------------------------------------
+
+(defun cj/--comment-heavy-box (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a heavy box comment with blank lines.
+CMT-START and CMT-END are the comment syntax strings.
+DECORATION-CHAR is the character to use for borders.
+TEXT is the comment text (centered).
+LENGTH is the total width of each line."
+ (let* ((current-column-pos (current-column))
+ (comment-char (if (equal cmt-start ";") ";;" cmt-start))
+ (comment-end-char (if (string-empty-p cmt-end) comment-char cmt-end))
+ (available-width (- length current-column-pos
+ (length comment-char)
+ (length comment-end-char)
+ 2)) ; spaces around content
+ (border-line (make-string available-width (string-to-char decoration-char)))
+ (text-length (length text))
+ (padding-each-side (max 1 (/ (- available-width text-length) 2)))
+ (right-padding (if (= (% (- available-width text-length) 2) 0)
+ padding-each-side
+ (1+ padding-each-side))))
+ ;; Top border
+ (insert comment-char " " border-line " " comment-end-char)
+ (newline)
+
+ ;; Empty line with side borders
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert decoration-char)
+ (dotimes (_ available-width) (insert " "))
+ (insert " " decoration-char)
+ (newline)
+
+ ;; Centered text line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert decoration-char " ")
+ (dotimes (_ padding-each-side) (insert " "))
+ (insert text)
+ (dotimes (_ right-padding) (insert " "))
+ (insert " " decoration-char)
+ (newline)
+
+ ;; Empty line with side borders
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert decoration-char)
+ (dotimes (_ available-width) (insert " "))
+ (insert " " decoration-char)
+ (newline)
+
+ ;; Bottom border
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " border-line " " comment-end-char)
+ (newline)))
+
+(defun cj/comment-heavy-box ()
+ "Insert a heavy box comment with blank lines around centered text.
+Prompts for decoration character, text, and length option."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (decoration-char (read-string "Decoration character (default *): " nil nil "*"))
+ (text (read-string "Comment text: "))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "padded-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "padded-text")
+ (+ (current-column)
+ (length (if (equal comment-start ";") ";;" comment-start))
+ 2 ; decoration char + space
+ 4 ; minimum padding (2 on each side)
+ (length text)
+ (if (string-empty-p comment-end)
+ 1 ; just the side decoration
+ (1+ (length comment-end))))))))
+ (cj/--comment-heavy-box comment-start comment-end decoration-char text length)))
+
+;; ---------------------------- Unicode Box ------------------------------------
+
+(defun cj/--comment-unicode-box (cmt-start cmt-end text length box-style)
+ "Internal implementation: Generate a unicode box comment.
+CMT-START and CMT-END are the comment syntax strings.
+TEXT is the comment text.
+LENGTH is the total width of each line.
+BOX-STYLE is either \\='single or \\='double for line style."
+ (let* ((current-column-pos (current-column))
+ (comment-char (if (equal cmt-start ";") ";;" cmt-start))
+ (min-length (+ current-column-pos
+ (length comment-char)
+ 1 ; space after comment-char
+ 5 ; minimum: corner + corner + padding
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end))))))
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos
+ (length comment-char)
+ (if (string-empty-p cmt-end) 0 (1+ (length cmt-end)))
+ 3)) ; box corners and padding
+ (top-left (if (eq box-style 'double) "╔" "┌"))
+ (top-right (if (eq box-style 'double) "╗" "┐"))
+ (bottom-left (if (eq box-style 'double) "╚" "└"))
+ (bottom-right (if (eq box-style 'double) "╝" "┘"))
+ (horizontal (if (eq box-style 'double) "═" "─"))
+ (vertical (if (eq box-style 'double) "║" "│"))
+ (text-padding (- available-width (length text) 2)))
+ ;; Top line
+ (insert comment-char " " top-left)
+ (dotimes (_ available-width) (insert horizontal))
+ (insert top-right)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Text line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " vertical " " text)
+ (dotimes (_ text-padding) (insert " "))
+ (insert " " vertical)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline)
+
+ ;; Bottom line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert comment-char " " bottom-left)
+ (dotimes (_ available-width) (insert horizontal))
+ (insert bottom-right)
+ (when (not (string-empty-p cmt-end))
+ (insert " " cmt-end))
+ (newline))))
+
+(defun cj/comment-unicode-box ()
+ "Insert a unicode box comment.
+Prompts for text, box style, and length option."
+ (interactive)
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start)
+ comment-start
+ (read-string "Comment start character(s): ")))
+ (comment-end (if (and (boundp 'comment-end) comment-end)
+ comment-end
+ ""))
+ (text (read-string "Comment text: "))
+ (box-style (intern (completing-read "Box style: "
+ '("single" "double")
+ nil t nil nil "single")))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "padded-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "padded-text")
+ (+ (current-column)
+ (length (if (equal comment-start ";") ";;" comment-start))
+ 5 ; box chars and spaces
+ (length text)
+ (if (string-empty-p comment-end) 0 (1+ (length comment-end))))))))
+ (cj/--comment-unicode-box comment-start comment-end text length box-style)))
+
+;; ---------------------------- Block Banner -----------------------------------
+
+(defun cj/--comment-block-banner (cmt-start cmt-end decoration-char text length)
+ "Internal implementation: Generate a block banner comment (JSDoc/Doxygen style).
+CMT-START should be the block comment start (e.g., '/*').
+CMT-END should be the block comment end (e.g., '*/').
+DECORATION-CHAR is the character to use for the border line.
+TEXT is the comment text.
+LENGTH is the total width of each line."
+ (let* ((current-column-pos (current-column))
+ (min-length (+ current-column-pos
+ (length cmt-start)
+ 3))) ; minimum: 3 decoration chars
+ (when (< length min-length)
+ (error "Length %d is too small to generate comment (minimum %d)" length min-length))
+ (let* ((available-width (- length current-column-pos (length cmt-start) 1))
+ (border-line (make-string available-width (string-to-char decoration-char))))
+ ;; Top line
+ (insert cmt-start border-line)
+ (newline)
+
+ ;; Text line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert " " decoration-char " " text)
+ (newline)
+
+ ;; Bottom line
+ (dotimes (_ current-column-pos) (insert " "))
+ (insert " ")
+ (dotimes (_ (- available-width (length cmt-end)))
+ (insert decoration-char))
+ (insert cmt-end)
+ (newline))))
+
+(defun cj/comment-block-banner ()
+ "Insert a block banner comment (JSDoc/Doxygen style).
+Prompts for decoration character, text, and length option."
(interactive)
- (let* ((comment-char (if (equal comment-start ";") ";;"
- (string-trim comment-start)))
- (comment-end-char (if (string-empty-p comment-end)
- comment-char
- (string-trim comment-end)))
- (line-char (if (equal comment-char ";;") "-" "#"))
- (comment (capitalize (string-trim (read-from-minibuffer "Comment: "))))
- (comment-length (length comment))
- (current-column-pos (current-column))
- (max-width (min fill-column 80))
- ;; Calculate available width between comment markers
- (available-width (- max-width
- current-column-pos
- (length comment-char)
- (length comment-end-char)))
- ;; Inner width is the width without the spaces after comment start and before comment end
- (inner-width (- available-width 2))
- ;; Calculate padding for each side of the centered text
- (padding-each-side (max 1 (/ (- inner-width comment-length) 2)))
- ;; Adjust for odd-length comments
- (right-padding (if (= (% (- inner-width comment-length) 2) 0)
- padding-each-side
- (1+ padding-each-side))))
-
- ;; Check if we have enough space
- (if (< inner-width (+ comment-length 4)) ; minimum sensible width
- (message "Comment string is too big to fit in one line")
- (progn
- ;; Top line - fill entirely with line characters except for space after comment start
- (insert comment-char)
- (insert " ")
- (insert (make-string inner-width (string-to-char line-char)))
- (insert " ")
- (insert comment-end-char)
- (newline)
-
- ;; Add indentation on the new line to match current column
- (dotimes (_ current-column-pos) (insert " "))
-
- ;; Middle line with centered text
- (insert comment-char)
- (insert " ")
- ;; Left padding
- (dotimes (_ padding-each-side) (insert " "))
- ;; The comment text
- (insert comment)
- ;; Right padding
- (dotimes (_ right-padding) (insert " "))
- (insert " ")
- (insert comment-end-char)
- (newline)
-
- ;; Add indentation on the new line to match current column
- (dotimes (_ current-column-pos) (insert " "))
-
- ;; Bottom line - same as top line
- (insert comment-char)
- (insert " ")
- (dotimes (_ inner-width) (insert line-char))
- (insert " ")
- (insert comment-end-char)
- (newline)))))
+ (let* ((comment-start (if (and (boundp 'comment-start) comment-start
+ (string-match-p "/\\*" comment-start))
+ comment-start
+ (read-string "Block comment start (e.g., /*): " nil nil "/*")))
+ (comment-end (if (and (boundp 'comment-end) comment-end
+ (not (string-empty-p comment-end)))
+ comment-end
+ (read-string "Block comment end (e.g., */): " nil nil "*/")))
+ (decoration-char (read-string "Decoration character (default *): " nil nil "*"))
+ (text (read-string "Comment text: "))
+ (length-option (completing-read "Length: "
+ '("fill-column" "half-column" "match-text")
+ nil t nil nil "fill-column"))
+ (length (cond
+ ((string= length-option "fill-column") fill-column)
+ ((string= length-option "half-column") (/ fill-column 2))
+ ((string= length-option "match-text")
+ (+ (current-column)
+ (length comment-start)
+ 2 ; space + decoration
+ (length text))))))
+ (cj/--comment-block-banner comment-start comment-end decoration-char text length)))
;; ------------------------------- Comment Hyphen ------------------------------
(defun cj/comment-hyphen()
"Insert a centered comment with `-' (hyphens) on each side.
-Leverages cj/comment-centered."
+Leverages cj/comment-inline-border."
(interactive)
- (cj/comment-centered "-"))
+ (cj/comment-inline-border "-"))
;; ------------------------------- Comment Keymap ------------------------------
(defvar-keymap cj/comment-map
:doc "Keymap for code comment operations"
"r" #'cj/comment-reformat
- "c" #'cj/comment-centered
+ "d" #'cj/delete-buffer-comments
+ "c" #'cj/comment-inline-border
"-" #'cj/comment-hyphen
+ "s" #'cj/comment-simple-divider
+ "p" #'cj/comment-padded-divider
"b" #'cj/comment-box
- "D" #'cj/delete-buffer-comments)
+ "h" #'cj/comment-heavy-box
+ "u" #'cj/comment-unicode-box
+ "n" #'cj/comment-block-banner)
(keymap-set cj/custom-keymap "C" cj/comment-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; C" "code comment menu"))
+ (which-key-add-key-based-replacements
+ "C-; C" "code comment menu"
+ "C-; C r" "reformat comment"
+ "C-; C d" "delete comments"
+ "C-; C c" "inline border"
+ "C-; C -" "hyphen divider"
+ "C-; C s" "simple divider"
+ "C-; C p" "padded divider"
+ "C-; C b" "box"
+ "C-; C h" "heavy box"
+ "C-; C u" "unicode box"
+ "C-; C n" "block banner"))
(provide 'custom-comments)
;;; custom-comments.el ends here.
diff --git a/modules/custom-datetime.el b/modules/custom-datetime.el
index c195ebc2..5b06d81a 100644
--- a/modules/custom-datetime.el
+++ b/modules/custom-datetime.el
@@ -117,7 +117,14 @@ Use `readable-date-format' for formatting."
(keymap-set cj/custom-keymap "d" cj/datetime-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; d" "date/time insertion menu"))
+ (which-key-add-key-based-replacements
+ "C-; d" "date/time insertion menu"
+ "C-; d r" "readable date-time"
+ "C-; d s" "sortable date-time"
+ "C-; d t" "sortable time"
+ "C-; d T" "readable time"
+ "C-; d d" "sortable date"
+ "C-; d D" "readable date"))
(provide 'custom-datetime)
;;; custom-datetime.el ends here.
diff --git a/modules/custom-file-buffer.el b/modules/custom-file-buffer.el
deleted file mode 100644
index 6ed19d73..00000000
--- a/modules/custom-file-buffer.el
+++ /dev/null
@@ -1,200 +0,0 @@
-;;; custom-file-buffer.el --- Custom Buffer and File Operations -*- coding: utf-8; lexical-binding: t; -*-
-;;
-;;; Commentary:
-;; This module provides custom buffer and file operations including PostScript
-;; printing capabilities.
-;;
-;; Functions include:
-;; - printing buffers or regions as PostScript to the default printer (with color support)
-;; - moving/renaming/deleting buffer files
-;; - copying file paths and file:// links to the kill ring
-;; - copying entire buffer contents
-;; - clearing buffer contents from point to top or bottom.
-;;
-;; The PostScript printing auto-detects the system print spooler (lpr or lp)
-;; and prints with face/syntax highlighting. Bound to keymap prefix ~C-; b~.
-;;
-;;; Code:
-
-;; cj/custom-keymap defined in keybindings.el
-(eval-when-compile (defvar cj/custom-keymap))
-(eval-when-compile (require 'ps-print)) ;; for ps-print variables
-(declare-function ps-print-buffer-with-faces "ps-print")
-(declare-function ps-print-region-with-faces "ps-print")
-
-;; ------------------------- Print Buffer As Postscript ------------------------
-
-(defvar cj/print-spooler-command 'auto
- "Command used to send PostScript to the system print spooler.
-Set to a string to force a specific command (e.g., lpr or lp). Set to `auto' to
-auto-detect once per session.")
-
-(defvar cj/print--spooler-cache nil
- "Cached spooler command detected for the current Emacs session.")
-
-(defun cj/print--resolve-spooler ()
- "Return the spooler command to use, auto-detecting and caching if needed."
- (cond
- ;; User-specified command
- ((and (stringp cj/print-spooler-command)
- (> (length cj/print-spooler-command) 0))
- (or (executable-find cj/print-spooler-command)
- (user-error "Cannot print: spooler command '%s' not found in PATH"
- cj/print-spooler-command))
- cj/print-spooler-command)
- ;; Auto-detect once per session
- ((eq cj/print-spooler-command 'auto)
- (or cj/print--spooler-cache
- (let ((cmd (or (and (executable-find "lpr") "lpr")
- (and (executable-find "lp") "lp"))))
- (unless cmd
- (user-error "Cannot print: neither 'lpr' nor 'lp' found in PATH"))
- (setq cj/print--spooler-cache cmd)
- cmd)))
- (t
- (user-error "Invalid value for cj/print-spooler-command: %S"
- cj/print-spooler-command))))
-
-(defun cj/print-buffer-ps (&optional color)
- "Print the buffer (or active region) as PostScript to the default printer.
-With prefix argument COLOR, print in color; otherwise print in monochrome.
-Sends directly to the system spooler with no header."
- (interactive "P")
- (unless (require 'ps-print nil t)
- (user-error "Cannot print: ps-print library not found"))
- (let* ((spooler (cj/print--resolve-spooler))
- (want-color (not (null color)))
- (have-region (use-region-p)))
- (let ((ps-lpr-command spooler)
- (ps-printer-name nil) ; default system printer
- (ps-lpr-switches nil)
- (ps-print-color-p want-color)
- (ps-use-face-background want-color)
- (ps-print-header nil)) ; no headers
- (if have-region
- (ps-print-region-with-faces (region-beginning) (region-end))
- (ps-print-buffer-with-faces)))
- (message "Sent %s to default printer via %s (%s)"
- (if have-region "region" "buffer")
- spooler
- (if want-color "color" "monochrome"))))
-
-;; ------------------------- Buffer And File Operations ------------------------
-
-(defun cj/move-buffer-and-file (dir)
- "Move both current buffer and the file it visits to DIR."
- (interactive "DMove buffer and file (to new directory): ")
- (let* ((name (buffer-name))
- (filename (buffer-file-name))
- (dir
- (if (string-match dir "\\(?:/\\|\\\\)$")
- (substring dir 0 -1) dir))
- (newname (concat dir "/" name)))
- (if (not filename)
- (message "Buffer '%s' is not visiting a file!" name)
- (progn (copy-file filename newname 1) (delete-file filename)
- (set-visited-file-name newname) (set-buffer-modified-p nil) t))))
-
-(defun cj/rename-buffer-and-file (new-name)
- "Rename both current buffer and the file it visits to NEW-NAME."
- (interactive
- (list (if (not (buffer-file-name))
- (user-error "Buffer '%s' is not visiting a file!" (buffer-name))
- (read-string "Rename buffer and file (to new name): "
- (file-name-nondirectory (buffer-file-name))))))
- (let ((filename (buffer-file-name)))
- (if (get-buffer new-name)
- (message "A buffer named '%s' already exists!" new-name)
- (progn
- (rename-file filename new-name 1)
- (rename-buffer new-name)
- (set-visited-file-name new-name)
- (set-buffer-modified-p nil)))))
-
-(defun cj/delete-buffer-and-file ()
- "Kill the current buffer and delete the file it visits."
- (interactive)
- (let ((filename (buffer-file-name)))
- (when filename
- (if (vc-backend filename)
- (vc-delete-file filename)
- (progn
- (delete-file filename t)
- (message "Deleted file %s" filename)
- (kill-buffer))))))
-
-(defun cj/copy-link-to-buffer-file ()
- "Copy the full file:// path of the current buffer's source file to the kill ring."
- (interactive)
- (let ((file-path (buffer-file-name)))
- (when file-path
- (setq file-path (concat "file://" file-path))
- (kill-new file-path)
- (message "Copied file link to kill ring: %s" file-path))))
-
-(defun cj/copy-path-to-buffer-file-as-kill ()
- "Copy the full path of the current buffer's file to the kill ring.
-Signal an error if the buffer is not visiting a file."
- (interactive)
- (let ((path (buffer-file-name)))
- (if (not path)
- (user-error "Current buffer is not visiting a file")
- (kill-new path)
- (message "Copied file path: %s" path)
- path)))
-
-(defun cj/copy-whole-buffer ()
- "Copy the entire contents of the current buffer to the kill ring.
-Point and mark are left exactly where they were. No transient region
-is created. A message is displayed when done."
- (interactive)
- (let ((contents (buffer-substring-no-properties (point-min) (point-max))))
- (kill-new contents)
- (message "Buffer contents copied to kill ring")))
-
-(defun cj/clear-to-bottom-of-buffer ()
- "Delete all text from point to the end of the current buffer.
-This does not save the deleted text in the kill ring."
- (interactive)
- (delete-region (point) (point-max))
- (message "Buffer contents removed to the end of the buffer."))
-
-(defun cj/clear-to-top-of-buffer ()
- "Delete all text from point to the beginning of the current buffer.
-Do not save the deleted text in the kill ring."
- (interactive)
- (delete-region (point) (point-min))
- (message "Buffer contents removed to the beginning of the buffer."))
-
-(defun cj/copy-buffer-name ()
- "Copy current buffer name to kill ring."
- (interactive)
- (kill-new (buffer-name))
- (message "Copied: %s" (buffer-name)))
-
-;; --------------------------- Buffer And File Keymap --------------------------
-
-;; Buffer & file operations prefix and keymap
-(defvar-keymap cj/buffer-and-file-map
- :doc "Keymap for buffer and file operations."
- "m" #'cj/move-buffer-and-file
- "r" #'cj/rename-buffer-and-file
- "p" #'cj/print-buffer-ps
- "d" #'cj/delete-buffer-and-file
- "c" #'cj/copy-whole-buffer
- "n" #'cj/copy-buffer-name
- "t" #'cj/clear-to-top-of-buffer
- "b" #'cj/clear-to-bottom-of-buffer
- "x" #'erase-buffer
- "s" #'write-file ;; save as
-
- "l" #'cj/copy-link-to-buffer-file
- "P" #'cj/copy-path-to-buffer-file-as-kill)
-(keymap-set cj/custom-keymap "b" cj/buffer-and-file-map)
-
-(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; b" "buffer and file menu"))
-
-
-(provide 'custom-file-buffer)
-;;; custom-file-buffer.el ends here.
diff --git a/modules/custom-line-paragraph.el b/modules/custom-line-paragraph.el
index e66b024d..32f9aaa1 100644
--- a/modules/custom-line-paragraph.el
+++ b/modules/custom-line-paragraph.el
@@ -1,14 +1,13 @@
;;; custom-line-paragraph.el --- -*- coding: utf-8; lexical-binding: t; -*-
-
+;; Author: Craig Jennings <c@cjennings.net>
+;;
;;; Commentary:
;;
-;; This module provides line and paragraph manipulation utilities.
-;; These utilities enhance text editing and formatting capabilities.
+;; This module provides the following line and paragraph manipulation utilities:
;;
-;; Functions include:
;; - joining lines in a region or the current line with the previous one
-;; - joining entire paragraphs into single lines
-;; - duplicating lines or regions (with optional commenting)
+;; - joining separate lines into a single paragraph
+;; - duplicating lines or regions (optional commenting)
;; - removing duplicate lines
;; - removing lines containing specific text
;; - underlining text with a custom character
@@ -18,6 +17,9 @@
;;; Code:
+(eval-when-compile (defvar cj/custom-keymap)) ;; defined in keybindings.el
+(declare-function er/mark-paragraph "expand-region") ;; for cj/join-paragraph
+
(defun cj/join-line-or-region ()
"Join lines in the active region or join the current line with the previous one."
(interactive)
@@ -137,8 +139,15 @@ If the line is empty or contains only whitespace, abort with a message."
(keymap-set cj/custom-keymap "l" cj/line-and-paragraph-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; l" "line and paragraph menu")
- (which-key-add-key-based-replacements "C-; l c" "duplicate and comment"))
+ (which-key-add-key-based-replacements
+ "C-; l" "line and paragraph menu"
+ "C-; l j" "join lines"
+ "C-; l J" "join paragraph"
+ "C-; l d" "duplicate"
+ "C-; l c" "duplicate and comment"
+ "C-; l R" "remove duplicates"
+ "C-; l r" "remove matching"
+ "C-; l u" "underscore line"))
(provide 'custom-line-paragraph)
;;; custom-line-paragraph.el ends here.
diff --git a/modules/custom-misc.el b/modules/custom-misc.el
index 0c6d7ac8..7ba5a054 100644
--- a/modules/custom-misc.el
+++ b/modules/custom-misc.el
@@ -46,19 +46,27 @@ If not on a delimiter, show a message. Respects the current syntax table."
(message "Point is not on a delimiter.")))))
+(defun cj/--format-region (start end)
+ "Internal implementation: Reformat text between START and END.
+START and END define the region to operate on.
+Replaces tabs with spaces, reindents, and deletes trailing whitespace."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ (untabify (point-min) (point-max))
+ (indent-region (point-min) (point-max))
+ (delete-trailing-whitespace (point-min) (point-max)))))
+
(defun cj/format-region-or-buffer ()
"Reformat the region or the entire buffer.
Replaces tabs with spaces, deletes trailing whitespace, and reindents."
(interactive)
(let ((start-pos (if (use-region-p) (region-beginning) (point-min)))
- (end-pos (if (use-region-p) (region-end) (point-max))))
- (save-excursion
- (save-restriction
- (narrow-to-region start-pos end-pos)
- (untabify (point-min) (point-max))
- (indent-region (point-min) (point-max))
- (delete-trailing-whitespace (point-min) (point-max))))
- (message "Formatted %s" (if (use-region-p) "region" "buffer"))))
+ (end-pos (if (use-region-p) (region-end) (point-max))))
+ (cj/--format-region start-pos end-pos)
+ (message "Formatted %s" (if (use-region-p) "region" "buffer"))))
(defun cj/switch-to-previous-buffer ()
"Switch to previously open buffer.
@@ -66,6 +74,14 @@ Repeated invocations toggle between the two most recently open buffers."
(interactive)
(switch-to-buffer (other-buffer (current-buffer) 1)))
+(defun cj/--count-words (start end)
+ "Internal implementation: Count words between START and END.
+START and END define the region to count.
+Returns the word count as an integer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (count-words start end))
+
(defun cj/count-words-buffer-or-region ()
"Count the number of words in the buffer or region.
Display the result in the minibuffer."
@@ -73,9 +89,57 @@ Display the result in the minibuffer."
(let* ((use-region (use-region-p))
(begin (if use-region (region-beginning) (point-min)))
(end (if use-region (region-end) (point-max)))
- (area-type (if use-region "the region" "the buffer")))
- (message "There are %d words in %s." (count-words begin end) area-type)))
+ (area-type (if use-region "the region" "the buffer"))
+ (word-count (cj/--count-words begin end)))
+ (message "There are %d words in %s." word-count area-type)))
+(defun cj/--count-characters (start end)
+ "Internal implementation: Count characters between START and END.
+START and END define the region to count.
+Returns the character count as an integer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (- end start))
+
+(defun cj/count-characters-buffer-or-region ()
+ "Count the number of characters in the buffer or region.
+Display the result in the minibuffer."
+ (interactive)
+ (let* ((use-region (use-region-p))
+ (begin (if use-region (region-beginning) (point-min)))
+ (end (if use-region (region-end) (point-max)))
+ (area-type (if use-region "the region" "the buffer"))
+ (char-count (cj/--count-characters begin end)))
+ (message "There are %d characters in %s." char-count area-type)))
+
+
+(defun cj/--replace-fraction-glyphs (start end to-glyphs)
+ "Internal implementation: Replace fraction glyphs or text between START and END.
+START and END define the region to operate on.
+TO-GLYPHS when non-nil converts text (1/4) to glyphs (¼),
+otherwise converts glyphs to text."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((replacements (if to-glyphs
+ '(("1/4" . "¼")
+ ("1/2" . "½")
+ ("3/4" . "¾")
+ ("1/3" . "⅓")
+ ("2/3" . "⅔"))
+ '(("¼" . "1/4")
+ ("½" . "1/2")
+ ("¾" . "3/4")
+ ("⅓" . "1/3")
+ ("⅔" . "2/3"))))
+ (count 0)
+ (end-marker (copy-marker end)))
+ (save-excursion
+ (dolist (r replacements)
+ (goto-char start)
+ (while (search-forward (car r) end-marker t)
+ (replace-match (cdr r))
+ (setq count (1+ count)))))
+ count))
(defun cj/replace-fraction-glyphs (start end)
"Replace common fraction glyphs between START and END.
@@ -83,27 +147,10 @@ Operate on the buffer or region designated by START and END.
Replace the text representations with glyphs when called with a
\\[universal-argument] prefix."
(interactive (if (use-region-p)
- (list (region-beginning) (region-end))
- (list (point-min) (point-max))))
- (let ((replacements (if current-prefix-arg
- '(("1/4" . "¼")
- ("1/2" . "½")
- ("3/4" . "¾")
- ("1/3" . "⅓")
- ("2/3" . "⅔"))
- '(("¼" . "1/4")
- ("½" . "1/2")
- ("¾" . "3/4")
- ("⅓" . "1/3")
- ("⅔" . "2/3"))))
- (count 0))
- (save-excursion
- (dolist (r replacements)
- (goto-char start)
- (while (search-forward (car r) end t)
- (replace-match (cdr r))
- (setq count (1+ count)))))
- (message "Replaced %d fraction%s" count (if (= count 1) "" "s"))))
+ (list (region-beginning) (region-end))
+ (list (point-min) (point-max))))
+ (let ((count (cj/--replace-fraction-glyphs start end current-prefix-arg)))
+ (message "Replaced %d fraction%s" count (if (= count 1) "" "s"))))
(defun cj/align-regexp-with-spaces (orig-fun &rest args)
"Call ORIG-FUN with ARGS while temporarily disabling tabs for alignment.
@@ -118,11 +165,23 @@ to nil."
(keymap-set cj/custom-keymap ")" #'cj/jump-to-matching-paren)
(keymap-set cj/custom-keymap "f" #'cj/format-region-or-buffer)
-(keymap-set cj/custom-keymap "W" #'cj/count-words-buffer-or-region)
+(keymap-set cj/custom-keymap "# w" #'cj/count-words-buffer-or-region)
+(keymap-set cj/custom-keymap "# c" #'cj/count-characters-buffer-or-region)
(keymap-set cj/custom-keymap "/" #'cj/replace-fraction-glyphs)
(keymap-set cj/custom-keymap "A" #'align-regexp)
(keymap-set cj/custom-keymap "SPC" #'cj/switch-to-previous-buffer)
(keymap-set cj/custom-keymap "|" #'display-fill-column-indicator-mode)
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; )" "jump to paren"
+ "C-; f" "format buffer"
+ "C-; # w" "count words"
+ "C-; # c" "count characters"
+ "C-; /" "fraction glyphs"
+ "C-; A" "align regexp"
+ "C-; SPC" "prev buffer"
+ "C-; |" "fill column"))
+
(provide 'custom-misc)
;;; custom-misc.el ends here
diff --git a/modules/custom-ordering.el b/modules/custom-ordering.el
index 5d308604..f6972910 100644
--- a/modules/custom-ordering.el
+++ b/modules/custom-ordering.el
@@ -2,47 +2,197 @@
;;; Commentary:
-;; This module provides functions for converting text between different formats and sorting operations.
-;; These utilities are useful for reformatting data structures and organizing text.
-
-;; Functions include:
-
-;; - converting lines to quoted comma-separated arrays (arrayify)
-;; - converting arrays back to separate lines (unarrayify)
-;; - alphabetically sorting words in a region
-;; - splitting comma-separated text into individual lines
-
+;; Text transformation and sorting utilities for reformatting data structures.
+;;
+;; Array/list formatting:
+;; - arrayify/listify - convert lines to comma-separated format (with/without quotes, brackets)
+;; - unarrayify - convert arrays back to separate lines
+;;
+;; Line manipulation:
+;; - toggle-quotes - swap double ↔ single quotes
+;; - reverse-lines - reverse line order
+;; - number-lines - add line numbers with custom format (supports zero-padding)
+;; - alphabetize-region - sort words alphabetically
+;; - comma-separated-text-to-lines - split CSV text into lines
+;;
+;; Convenience functions: listify, arrayify-json, arrayify-python
;; Bound to keymap prefix C-; o
;;; Code:
+(require 'cl-lib)
+
;; cj/custom-keymap defined in keybindings.el
(eval-when-compile (defvar cj/custom-keymap))
(defvar cj/ordering-map)
+(defun cj/--arrayify (start end quote &optional prefix suffix)
+ "Internal implementation: Convert lines to quoted, comma-separated format.
+START and END define the region to operate on.
+QUOTE specifies the quotation characters to surround each element.
+ Use \"\" for no quotes, \"\\\"\" for double quotes, \"'\" for single quotes.
+PREFIX is an optional string to prepend to the result (e.g., \"[\" or \"(\").
+SUFFIX is an optional string to append to the result (e.g., \"]\" or \")\").
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((result (mapconcat
+ (lambda (x) (format "%s%s%s" quote x quote))
+ (split-string (buffer-substring start end)) ", ")))
+ (concat (or prefix "") result (or suffix ""))))
+
(defun cj/arrayify (start end quote)
"Convert lines between START and END into quoted, comma-separated strings.
START and END identify the active region.
QUOTE specifies the quotation characters to surround each element."
(interactive "r\nMQuotation character to use for array element: ")
- (let ((insertion
- (mapconcat
- (lambda (x) (format "%s%s%s" quote x quote))
- (split-string (buffer-substring start end)) ", ")))
+ (let ((insertion (cj/--arrayify start end quote)))
(delete-region start end)
(insert insertion)))
+(defun cj/listify (start end)
+ "Convert lines between START and END into an unquoted, comma-separated list.
+START and END identify the active region.
+Example: `apple banana cherry' becomes `apple, banana, cherry'."
+ (interactive "r")
+ (let ((insertion (cj/--arrayify start end "")))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/arrayify-json (start end)
+ "Convert lines between START and END into a JSON-style array.
+START and END identify the active region.
+Example: `apple banana cherry' becomes `[\"apple\", \"banana\", \"cherry\"]'."
+ (interactive "r")
+ (let ((insertion (cj/--arrayify start end "\"" "[" "]")))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/arrayify-python (start end)
+ "Convert lines between START and END into a Python-style list.
+START and END identify the active region.
+Example: `apple banana cherry' becomes `[\"apple\", \"banana\", \"cherry\"]'."
+ (interactive "r")
+ (let ((insertion (cj/--arrayify start end "\"" "[" "]")))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/--unarrayify (start end)
+ "Internal implementation: Convert comma-separated array to lines.
+START and END define the region to operate on.
+Removes quotes (both single and double) and splits by ', '.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (mapconcat
+ (lambda (x) (replace-regexp-in-string "[\"']" "" x))
+ (split-string (buffer-substring start end) ", ") "\n"))
+
(defun cj/unarrayify (start end)
"Convert quoted comma-separated strings between START and END to separate lines.
START and END identify the active region."
(interactive "r")
- (let ((insertion
- (mapconcat
- (lambda (x) (replace-regexp-in-string "[\"']" "" x))
- (split-string (buffer-substring start end) ", ") "\n")))
+ (let ((insertion (cj/--unarrayify start end)))
(delete-region start end)
(insert insertion)))
+(defun cj/--toggle-quotes (start end)
+ "Internal implementation: Toggle between double and single quotes.
+START and END define the region to operate on.
+Swaps all double quotes with single quotes and vice versa.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((text (buffer-substring start end)))
+ (with-temp-buffer
+ (insert text)
+ (goto-char (point-min))
+ ;; Use a placeholder to avoid double-swapping
+ (while (search-forward "\"" nil t)
+ (replace-match "\001" nil t))
+ (goto-char (point-min))
+ (while (search-forward "'" nil t)
+ (replace-match "\"" nil t))
+ (goto-char (point-min))
+ (while (search-forward "\001" nil t)
+ (replace-match "'" nil t))
+ (buffer-string))))
+
+(defun cj/toggle-quotes (start end)
+ "Toggle between double and single quotes in region between START and END.
+START and END identify the active region."
+ (interactive "r")
+ (let ((insertion (cj/--toggle-quotes start end)))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/--reverse-lines (start end)
+ "Internal implementation: Reverse the order of lines in region.
+START and END define the region to operate on.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((lines (split-string (buffer-substring start end) "\n")))
+ (mapconcat #'identity (nreverse lines) "\n")))
+
+(defun cj/reverse-lines (start end)
+ "Reverse the order of lines in region between START and END.
+START and END identify the active region."
+ (interactive "r")
+ (let ((insertion (cj/--reverse-lines start end)))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/--number-lines (start end format-string zero-pad)
+ "Internal implementation: Number lines in region with custom format.
+START and END define the region to operate on.
+FORMAT-STRING is the format for each line, with N as placeholder for number.
+ Example: \"N. \" produces \"1. \", \"2. \", etc.
+ Example: \"[N] \" produces \"[1] \", \"[2] \", etc.
+ZERO-PAD when non-nil pads numbers with zeros for alignment.
+ Example with 100 lines: \"001\", \"002\", ..., \"100\".
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let* ((lines (split-string (buffer-substring start end) "\n"))
+ (line-count (length lines))
+ (width (if zero-pad (length (number-to-string line-count)) 1))
+ (format-spec (if zero-pad (format "%%0%dd" width) "%d")))
+ (mapconcat
+ (lambda (pair)
+ (let* ((num (car pair))
+ (line (cdr pair))
+ (num-str (format format-spec num)))
+ (concat (replace-regexp-in-string "N" num-str format-string) line)))
+ (cl-loop for line in lines
+ for i from 1
+ collect (cons i line))
+ "\n")))
+
+(defun cj/number-lines (start end format-string zero-pad)
+ "Number lines in region between START and END with custom format.
+START and END identify the active region.
+FORMAT-STRING is the format for each line, with N as placeholder for number.
+ Example: \"N. \" produces \"1. \", \"2. \", etc.
+ZERO-PAD when non-nil (prefix argument) pads numbers with zeros."
+ (interactive "r\nMFormat string (use N for number): \nP")
+ (let ((insertion (cj/--number-lines start end format-string zero-pad)))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/--alphabetize-region (start end)
+ "Internal implementation: Alphabetize words in region.
+START and END define the region to operate on.
+Splits by whitespace and commas, sorts alphabetically, joins with ', '.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((string (buffer-substring-no-properties start end)))
+ (mapconcat #'identity
+ (sort (split-string string "[[:space:],]+" t)
+ #'string-lessp)
+ ", ")))
+
(defun cj/alphabetize-region ()
"Alphabetize words in the active region and replace the original text.
Produce a comma-separated list as the result."
@@ -51,14 +201,26 @@ Produce a comma-separated list as the result."
(user-error "No region selected"))
(let ((start (region-beginning))
(end (region-end))
- (string (buffer-substring-no-properties (region-beginning) (region-end))))
+ (insertion (cj/--alphabetize-region (region-beginning) (region-end))))
(delete-region start end)
(goto-char start)
- (insert
- (mapconcat #'identity
- (sort (split-string string "[[:space:],]+" t)
- #'string-lessp)
- ", "))))
+ (insert insertion)))
+
+(defun cj/--comma-separated-text-to-lines (start end)
+ "Internal implementation: Convert comma-separated text to lines.
+START and END define the region to operate on.
+Replaces commas with newlines and removes trailing whitespace from each line.
+Returns the transformed string without modifying the buffer."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (let ((text (buffer-substring-no-properties start end)))
+ (with-temp-buffer
+ (insert text)
+ (goto-char (point-min))
+ (while (search-forward "," nil t)
+ (replace-match "\n" nil t))
+ (delete-trailing-whitespace)
+ (buffer-string))))
(defun cj/comma-separated-text-to-lines ()
"Break up comma-separated text in active region so each item is on own line."
@@ -68,15 +230,7 @@ Produce a comma-separated list as the result."
(let ((beg (region-beginning))
(end (region-end))
- (text (buffer-substring-no-properties (region-beginning) (region-end))))
- (with-temp-buffer
- (insert text)
- (goto-char (point-min))
- (while (search-forward "," nil t)
- (replace-match "\n" nil t))
- (delete-trailing-whitespace)
- (setq text (buffer-string)))
-
+ (text (cj/--comma-separated-text-to-lines (region-beginning) (region-end))))
(delete-region beg end)
(goto-char beg)
(insert text)))
@@ -88,12 +242,29 @@ Produce a comma-separated list as the result."
:doc "Keymap for text ordering and sorting operations"
"a" #'cj/arrayify
"u" #'cj/unarrayify
+ "l" #'cj/listify
+ "j" #'cj/arrayify-json
+ "p" #'cj/arrayify-python
+ "q" #'cj/toggle-quotes
+ "r" #'cj/reverse-lines
+ "n" #'cj/number-lines
"A" #'cj/alphabetize-region
- "l" #'cj/comma-separated-text-to-lines)
+ "L" #'cj/comma-separated-text-to-lines
+ "o" #'cj/org-sort-by-todo-and-priority)
(keymap-set cj/custom-keymap "o" cj/ordering-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; o" "ordering/sorting menu"))
+ (which-key-add-key-based-replacements
+ "C-; o" "ordering/sorting menu"
+ "C-; o l" "listify"
+ "C-; o j" "JSON array"
+ "C-; o p" "Python list"
+ "C-; o q" "toggle quotes"
+ "C-; o r" "reverse lines"
+ "C-; o n" "number lines"
+ "C-; o A" "alphabetize"
+ "C-; o L" "comma to lines"
+ "C-; o o" "org: sort by TODO+priority"))
(provide 'custom-ordering)
;;; custom-ordering.el ends here.
diff --git a/modules/custom-text-enclose.el b/modules/custom-text-enclose.el
index 514419cd..e93e6dea 100644
--- a/modules/custom-text-enclose.el
+++ b/modules/custom-text-enclose.el
@@ -2,82 +2,285 @@
;;; Commentary:
-;; This module provides functions to surround words or regions with custom strings, and to append or prepend text to lines.
+;; Text enclosure utilities for wrapping and line manipulation.
+;;
+;; Wrapping functions:
+;; - surround-word-or-region - wrap text with same delimiter on both sides
+;; - wrap-word-or-region - wrap with different opening/closing delimiters
+;; - unwrap-word-or-region - remove surrounding delimiters
+;;
+;; Line manipulation:
+;; - append-to-lines - add suffix to each line
+;; - prepend-to-lines - add prefix to each line
+;; - indent-lines - add leading whitespace (spaces or tabs)
+;; - dedent-lines - remove leading whitespace
+;;
+;; Most functions work on region or entire buffer when no region is active.
+;;
+;; Bound to keymap prefix C-; s
-;; It includes three main functions:
-;; - surround word or region with a user-specified string
-;; - append text to the end of lines
-;; - prepend text to the beginning of lines
+;;; Code:
-;; All functions work on both the active region and the entire buffer when no region is selected.
+;; cj/custom-keymap defined in keybindings.el
+(eval-when-compile (defvar cj/custom-keymap))
-;; Bound to keymap prefix C-; s
+(defun cj/--surround (text surround-string)
+ "Internal implementation: Surround TEXT with SURROUND-STRING.
+TEXT is the string to be surrounded.
+SURROUND-STRING is prepended and appended to TEXT.
+Returns the surrounded text without modifying the buffer."
+ (concat surround-string text surround-string))
-;;; Code:
+(defun cj/--wrap (text opening closing)
+ "Internal implementation: Wrap TEXT with OPENING and CLOSING strings.
+TEXT is the string to be wrapped.
+OPENING is prepended to TEXT.
+CLOSING is appended to TEXT.
+Returns the wrapped text without modifying the buffer."
+ (concat opening text closing))
(defun cj/surround-word-or-region ()
- "Surround the word at point or active region with a string read from the minibuffer."
+ "Surround the word at point or active region with a string.
+The surround string is read from the minibuffer."
(interactive)
(let ((str (read-string "Surround with: "))
(regionp (use-region-p)))
- (save-excursion
- (if regionp
- (let ((beg (region-beginning))
- (end (region-end)))
- (goto-char end)
- (insert str)
- (goto-char beg)
- (insert str))
- (if (thing-at-point 'word)
- (let ((bounds (bounds-of-thing-at-point 'word)))
- (goto-char (cdr bounds))
- (insert str)
- (goto-char (car bounds))
- (insert str))
- (message "Can't insert around. No word at point and no region selected."))))))
+ (if regionp
+ (let ((beg (region-beginning))
+ (end (region-end))
+ (text (buffer-substring (region-beginning) (region-end))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (cj/--surround text str)))
+ (if (thing-at-point 'word)
+ (let* ((bounds (bounds-of-thing-at-point 'word))
+ (text (buffer-substring (car bounds) (cdr bounds))))
+ (delete-region (car bounds) (cdr bounds))
+ (goto-char (car bounds))
+ (insert (cj/--surround text str)))
+ (message "Can't insert around. No word at point and no region selected.")))))
+
+(defun cj/wrap-word-or-region ()
+ "Wrap the word at point or active region with different opening/closing strings.
+The opening and closing strings are read from the minibuffer."
+ (interactive)
+ (let ((opening (read-string "Opening: "))
+ (closing (read-string "Closing: "))
+ (regionp (use-region-p)))
+ (if regionp
+ (let ((beg (region-beginning))
+ (end (region-end))
+ (text (buffer-substring (region-beginning) (region-end))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (cj/--wrap text opening closing)))
+ (if (thing-at-point 'word)
+ (let* ((bounds (bounds-of-thing-at-point 'word))
+ (text (buffer-substring (car bounds) (cdr bounds))))
+ (delete-region (car bounds) (cdr bounds))
+ (goto-char (car bounds))
+ (insert (cj/--wrap text opening closing)))
+ (message "Can't wrap. No word at point and no region selected.")))))
+
+(defun cj/--unwrap (text opening closing)
+ "Internal implementation: Remove OPENING and CLOSING from TEXT if present.
+TEXT is the string to unwrap.
+OPENING is checked at the start of TEXT.
+CLOSING is checked at the end of TEXT.
+Returns the unwrapped text if both delimiters present, otherwise unchanged."
+ (if (and (string-prefix-p opening text)
+ (string-suffix-p closing text)
+ (>= (length text) (+ (length opening) (length closing))))
+ (substring text (length opening) (- (length text) (length closing)))
+ text))
+
+(defun cj/unwrap-word-or-region ()
+ "Remove surrounding delimiters from word at point or active region.
+The opening and closing strings are read from the minibuffer."
+ (interactive)
+ (let ((opening (read-string "Opening to remove: "))
+ (closing (read-string "Closing to remove: "))
+ (regionp (use-region-p)))
+ (if regionp
+ (let ((beg (region-beginning))
+ (end (region-end))
+ (text (buffer-substring (region-beginning) (region-end))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (cj/--unwrap text opening closing)))
+ (if (thing-at-point 'word)
+ (let* ((bounds (bounds-of-thing-at-point 'word))
+ (text (buffer-substring (car bounds) (cdr bounds))))
+ (delete-region (car bounds) (cdr bounds))
+ (goto-char (car bounds))
+ (insert (cj/--unwrap text opening closing)))
+ (message "Can't unwrap. No word at point and no region selected.")))))
+
+(defun cj/--append-to-lines (text suffix)
+ "Internal implementation: Append SUFFIX to each line in TEXT.
+TEXT is the string containing one or more lines.
+SUFFIX is appended to the end of each line.
+Returns the transformed string without modifying the buffer."
+ (let* ((lines (split-string text "\n"))
+ (has-trailing-newline (string-suffix-p "\n" text))
+ ;; If has trailing newline, last element will be empty string - exclude it
+ (lines-to-process (if (and has-trailing-newline
+ (not (null lines))
+ (string-empty-p (car (last lines))))
+ (butlast lines)
+ lines)))
+ (concat
+ (mapconcat (lambda (line) (concat line suffix)) lines-to-process "\n")
+ (if has-trailing-newline "\n" ""))))
(defun cj/append-to-lines-in-region-or-buffer (str)
"Append STR to the end of each line in the region or entire buffer."
(interactive "sEnter string to append: ")
- (let ((start-pos (if (use-region-p)
- (region-beginning)
- (point-min)))
- (end-pos (if (use-region-p)
- (region-end)
- (point-max))))
- (save-excursion
- (goto-char start-pos)
- (while (< (point) end-pos)
- (move-end-of-line 1)
- (insert str)
- (forward-line 1)))))
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--append-to-lines text str)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+(defun cj/--prepend-to-lines (text prefix)
+ "Internal implementation: Prepend PREFIX to each line in TEXT.
+TEXT is the string containing one or more lines.
+PREFIX is prepended to the beginning of each line.
+Returns the transformed string without modifying the buffer."
+ (let* ((lines (split-string text "\n"))
+ (has-trailing-newline (string-suffix-p "\n" text))
+ ;; If has trailing newline, last element will be empty string - exclude it
+ (lines-to-process (if (and has-trailing-newline
+ (not (null lines))
+ (string-empty-p (car (last lines))))
+ (butlast lines)
+ lines)))
+ (concat
+ (mapconcat (lambda (line) (concat prefix line)) lines-to-process "\n")
+ (if has-trailing-newline "\n" ""))))
(defun cj/prepend-to-lines-in-region-or-buffer (str)
"Prepend STR to the beginning of each line in the region or entire buffer."
(interactive "sEnter string to prepend: ")
- (let ((start-pos (if (use-region-p)
- (region-beginning)
- (point-min)))
- (end-pos (if (use-region-p)
- (region-end)
- (point-max))))
- (save-excursion
- (goto-char start-pos)
- (while (< (point) end-pos)
- (beginning-of-line 1)
- (insert str)
- (forward-line 1)))))
-
-;; Surround, append, prepend prefix keymap
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--prepend-to-lines text str)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+(defun cj/--indent-lines (text count use-tabs)
+ "Internal implementation: Indent each line in TEXT by COUNT characters.
+TEXT is the string containing one or more lines.
+COUNT is the number of indentation characters to add.
+USE-TABS when non-nil uses tabs instead of spaces for indentation.
+Returns the indented text without modifying the buffer."
+ (let ((indent-string (if use-tabs
+ (make-string count ?\t)
+ (make-string count ?\s))))
+ (cj/--prepend-to-lines text indent-string)))
+
+(defun cj/indent-lines-in-region-or-buffer (count use-tabs)
+ "Indent each line in region or buffer by COUNT characters.
+COUNT is the number of characters to indent (default 4).
+USE-TABS when non-nil (prefix argument) uses tabs instead of spaces."
+ (interactive "p\nP")
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--indent-lines text count use-tabs)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+(defun cj/--dedent-lines (text count)
+ "Internal implementation: Remove up to COUNT leading characters from each line.
+TEXT is the string containing one or more lines.
+COUNT is the maximum number of leading whitespace characters to remove.
+Removes spaces and tabs, but only up to COUNT characters per line.
+Returns the dedented text without modifying the buffer."
+ (let* ((lines (split-string text "\n"))
+ (has-trailing-newline (string-suffix-p "\n" text))
+ (lines-to-process (if (and has-trailing-newline
+ (not (null lines))
+ (string-empty-p (car (last lines))))
+ (butlast lines)
+ lines))
+ (dedented-lines
+ (mapcar
+ (lambda (line)
+ (let ((removed 0)
+ (pos 0)
+ (len (length line)))
+ (while (and (< removed count)
+ (< pos len)
+ (memq (aref line pos) '(?\s ?\t)))
+ (setq removed (1+ removed))
+ (setq pos (1+ pos)))
+ (substring line pos)))
+ lines-to-process)))
+ (concat
+ (mapconcat #'identity dedented-lines "\n")
+ (if has-trailing-newline "\n" ""))))
+
+(defun cj/dedent-lines-in-region-or-buffer (count)
+ "Remove up to COUNT leading whitespace characters from each line.
+COUNT is the number of characters to remove (default 4).
+Works on region if active, otherwise entire buffer."
+ (interactive "p")
+ (let* ((start-pos (if (use-region-p)
+ (region-beginning)
+ (point-min)))
+ (end-pos (if (use-region-p)
+ (region-end)
+ (point-max)))
+ (text (buffer-substring start-pos end-pos))
+ (insertion (cj/--dedent-lines text count)))
+ (delete-region start-pos end-pos)
+ (goto-char start-pos)
+ (insert insertion)))
+
+;; Text enclosure keymap
(defvar-keymap cj/enclose-map
- :doc "Keymap for enclosing text: surrounding, appending, and prepending"
+ :doc "Keymap for text enclosure: wrapping, line manipulation, and indentation"
"s" #'cj/surround-word-or-region
+ "w" #'cj/wrap-word-or-region
+ "u" #'cj/unwrap-word-or-region
"a" #'cj/append-to-lines-in-region-or-buffer
- "p" #'cj/prepend-to-lines-in-region-or-buffer)
+ "p" #'cj/prepend-to-lines-in-region-or-buffer
+ "i" #'cj/indent-lines-in-region-or-buffer
+ "d" #'cj/dedent-lines-in-region-or-buffer
+ "I" #'change-inner
+ "O" #'change-outer)
(keymap-set cj/custom-keymap "s" cj/enclose-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; s" "text enclose menu"))
+ (which-key-add-key-based-replacements
+ "C-; s" "text enclose menu"
+ "C-; s s" "surround text"
+ "C-; s w" "wrap text"
+ "C-; s u" "unwrap text"
+ "C-; s a" "append to lines"
+ "C-; s p" "prepend to lines"
+ "C-; s i" "indent lines"
+ "C-; s d" "dedent lines"
+ "C-; s I" "change inner"
+ "C-; s O" "change outer"))
(provide 'custom-text-enclose)
;;; custom-text-enclose.el ends here.
diff --git a/modules/custom-whitespace.el b/modules/custom-whitespace.el
index a69d6138..d5f8d80c 100644
--- a/modules/custom-whitespace.el
+++ b/modules/custom-whitespace.el
@@ -17,14 +17,32 @@
;;; Code:
+(eval-when-compile (defvar cj/custom-keymap)) ;; cj/custom-keymap defined in keybindings.el
;;; ---------------------- Whitespace Operations And Keymap ---------------------
+;; ------------------- Remove Leading/Trailing Whitespace ---------------------
+
+(defun cj/--remove-leading-trailing-whitespace (start end)
+ "Internal implementation: Remove leading and trailing whitespace.
+START and END define the region to operate on.
+Removes leading whitespace (^[ \\t]+) and trailing whitespace ([ \\t]+$).
+Preserves interior whitespace."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ (goto-char (point-min))
+ (while (re-search-forward "^[ \t]+" nil t) (replace-match ""))
+ (goto-char (point-min))
+ (while (re-search-forward "[ \t]+$" nil t) (replace-match "")))))
+
(defun cj/remove-leading-trailing-whitespace ()
"Remove leading and trailing whitespace in a region, line, or buffer.
When called interactively:
- If a region is active, operate on the region.
-- If called with a \[universal-argument] prefix, operate on the entire buffer.
+- If called with a \\[universal-argument] prefix, operate on the entire buffer.
- Otherwise, operate on the current line."
(interactive)
(let ((start (cond (current-prefix-arg (point-min))
@@ -33,36 +51,90 @@ When called interactively:
(end (cond (current-prefix-arg (point-max))
((use-region-p) (region-end))
(t (line-end-position)))))
- (save-excursion
- (save-restriction
- (narrow-to-region start end)
- (goto-char (point-min))
- (while (re-search-forward "^[ \t]+" nil t) (replace-match ""))
- (goto-char (point-min))
- (while (re-search-forward "[ \t]+$" nil t) (replace-match ""))))))
+ (cj/--remove-leading-trailing-whitespace start end)))
+
+;; ----------------------- Collapse Whitespace ---------------------------------
+
+(defun cj/--collapse-whitespace (start end)
+ "Internal implementation: Collapse whitespace to single spaces.
+START and END define the region to operate on.
+Converts tabs to spaces, removes leading/trailing whitespace,
+and collapses multiple spaces to single space.
+Preserves newlines and line structure."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ ;; Replace all tabs with space
+ (goto-char (point-min))
+ (while (search-forward "\t" nil t)
+ (replace-match " " nil t))
+ ;; Remove leading and trailing spaces (but not newlines)
+ (goto-char (point-min))
+ (while (re-search-forward "^[ \t]+\\|[ \t]+$" nil t)
+ (replace-match "" nil nil))
+ ;; Ensure only one space between words (but preserve newlines)
+ (goto-char (point-min))
+ (while (re-search-forward "[ \t]\\{2,\\}" nil t)
+ (replace-match " " nil nil)))))
(defun cj/collapse-whitespace-line-or-region ()
"Collapse whitespace to one space in the current line or active region.
-Ensure there is exactly one space between words and remove leading and trailing whitespace."
+Ensure there is exactly one space between words and remove leading and
+trailing whitespace."
(interactive)
+ (let* ((region-active (use-region-p))
+ (beg (if region-active (region-beginning) (line-beginning-position)))
+ (end (if region-active (region-end) (line-end-position))))
+ (cj/--collapse-whitespace beg end)))
+
+;; --------------------- Ensure Single Blank Line ------------------------------
+
+(defun cj/--ensure-single-blank-line (start end)
+ "Internal implementation: Collapse consecutive blank lines to one.
+START and END define the region to operate on.
+Replaces runs of 2+ blank lines with exactly one blank line.
+A blank line is defined as a line containing only whitespace."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
(save-excursion
- (let* ((region-active (use-region-p))
- (beg (if region-active (region-beginning) (line-beginning-position)))
- (end (if region-active (region-end) (line-end-position))))
- (save-restriction
- (narrow-to-region beg end)
- ;; Replace all tabs with space
- (goto-char (point-min))
- (while (search-forward "\t" nil t)
- (replace-match " " nil t))
- ;; Remove leading and trailing spaces
- (goto-char (point-min))
- (while (re-search-forward "^\\s-+\\|\\s-+$" nil t)
- (replace-match "" nil nil))
- ;; Ensure only one space between words/symbols
- (goto-char (point-min))
- (while (re-search-forward "\\s-\\{2,\\}" nil t)
- (replace-match " " nil nil))))))
+ (save-restriction
+ (narrow-to-region start end)
+ (goto-char (point-min))
+ ;; Match 2+ consecutive blank lines (lines with only whitespace)
+ ;; Pattern: Match sequences of blank lines (newline + optional space + newline)
+ ;; but preserve leading whitespace on the following content line
+ ;; Match: newline, then 1+ (optional whitespace + newline), capturing the last one
+ (while (re-search-forward "\n\\(?:[[:space:]]*\n\\)+" nil t)
+ (replace-match "\n\n")))))
+
+(defun cj/ensure-single-blank-line (start end)
+ "Collapse consecutive blank lines to exactly one blank line.
+START and END define the region to operate on.
+Operates on the active region when one exists.
+Prompt before operating on the whole buffer when no region is selected."
+ (interactive
+ (if (use-region-p)
+ (list (region-beginning) (region-end))
+ (if (yes-or-no-p "Ensure single blank lines in entire buffer? ")
+ (list (point-min) (point-max))
+ (user-error "Aborted"))))
+ (cj/--ensure-single-blank-line start end))
+
+;; ------------------------ Delete Blank Lines ---------------------------------
+
+(defun cj/--delete-blank-lines (start end)
+ "Internal implementation: Delete blank lines between START and END.
+Blank lines are lines containing only whitespace or nothing.
+Uses the regexp ^[[:space:]]*$ to match blank lines."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (widen)
+ ;; Regexp "^[[:space:]]*$" matches lines of zero or more spaces/tabs/newlines.
+ (flush-lines "^[[:space:]]*$" start end))))
(defun cj/delete-blank-lines-region-or-buffer (start end)
"Delete blank lines between START and END.
@@ -73,32 +145,62 @@ Signal a user error and do nothing when the user declines.
Restore point to its original position after deletion."
(interactive
(if (use-region-p)
- ;; grab its boundaries if there's a region
- (list (region-beginning) (region-end))
- ;; or ask if user intended operating on whole buffer
- (if (yes-or-no-p "Delete blank lines in entire buffer? ")
- (list (point-min) (point-max))
- (user-error "Aborted"))))
- (save-excursion
- (save-restriction
- (widen)
- ;; Regexp "^[[:space:]]*$" matches lines of zero or more spaces/tabs.
- (flush-lines "^[[:space:]]*$" start end)))
+ ;; grab its boundaries if there's a region
+ (list (region-beginning) (region-end))
+ ;; or ask if user intended operating on whole buffer
+ (if (yes-or-no-p "Delete blank lines in entire buffer? ")
+ (list (point-min) (point-max))
+ (user-error "Aborted"))))
+ (cj/--delete-blank-lines start end)
;; Return nil (Emacs conventions). Point is already restored.
nil)
+;; ------------------------- Delete All Whitespace -----------------------------
+
+(defun cj/--delete-all-whitespace (start end)
+ "Internal implementation: Delete all whitespace from region.
+START and END define the region to operate on.
+Removes all spaces, tabs, newlines, and carriage returns."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ (goto-char (point-min))
+ (while (re-search-forward "[ \t\n\r]+" nil t)
+ (replace-match "")))))
+
+(defun cj/delete-all-whitespace (start end)
+ "Delete all whitespace between START and END.
+Removes all spaces, tabs, newlines, and carriage returns.
+Operates on the active region."
+ (interactive "*r")
+ (if (use-region-p)
+ (cj/--delete-all-whitespace start end)
+ (message "No region; nothing to delete.")))
+
+;; ------------------------- Hyphenate Whitespace ------------------------------
+
+(defun cj/--hyphenate-whitespace (start end)
+ "Internal implementation: Replace whitespace runs with hyphens.
+START and END define the region to operate on.
+Replaces all runs of spaces, tabs, newlines, and carriage returns with hyphens."
+ (when (> start end)
+ (error "Invalid region: start (%d) is greater than end (%d)" start end))
+ (save-excursion
+ (save-restriction
+ (narrow-to-region start end)
+ (goto-char (point-min))
+ (while (re-search-forward "[ \t\n\r]+" nil t)
+ (replace-match "-")))))
+
(defun cj/hyphenate-whitespace-in-region (start end)
"Replace runs of whitespace between START and END with hyphens.
Operate on the active region designated by START and END."
(interactive "*r")
(if (use-region-p)
- (save-excursion
- (save-restriction
- (narrow-to-region start end)
- (goto-char (point-min))
- (while (re-search-forward "[ \t\n\r]+" nil t)
- (replace-match "-"))))
- (message "No region; nothing to hyphenate.")))
+ (cj/--hyphenate-whitespace start end)
+ (message "No region; nothing to hyphenate.")))
;; Whitespace operations prefix and keymap
@@ -107,11 +209,23 @@ Operate on the active region designated by START and END."
"r" #'cj/remove-leading-trailing-whitespace
"c" #'cj/collapse-whitespace-line-or-region
"l" #'cj/delete-blank-lines-region-or-buffer
- "-" #'cj/hyphenate-whitespace-in-region)
+ "1" #'cj/ensure-single-blank-line
+ "d" #'cj/delete-all-whitespace
+ "-" #'cj/hyphenate-whitespace-in-region
+ "t" #'untabify
+ "T" #'tabify)
(keymap-set cj/custom-keymap "w" cj/whitespace-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; w" "whitespace menu"))
+ (which-key-add-key-based-replacements
+ "C-; w" "whitespace menu"
+ "C-; w c" "collapse whitespace"
+ "C-; w l" "delete blank lines"
+ "C-; w 1" "single blank line"
+ "C-; w d" "delete all whitespace"
+ "C-; w -" "hyphenate whitespace"
+ "C-; w t" "untabify"
+ "C-; w T" "tabify"))
(provide 'custom-whitespace)
;;; custom-whitespace.el ends here.
diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el
index 44e87d5a..918acdf2 100644
--- a/modules/dashboard-config.el
+++ b/modules/dashboard-config.el
@@ -64,7 +64,7 @@
;; a useful startup screen for Emacs
(use-package dashboard
- :defer t
+ :demand t
:hook (emacs-startup . cj/dashboard-only)
:bind ("<f1>" . cj/dashboard-only)
:custom
@@ -75,7 +75,7 @@
(bookmarks . dashboard-insert-bookmarks)))
(dashboard-items '((projects . 5)
- (bookmarks . 15)))
+ (bookmarks . 10)))
(dashboard-startupify-list
'(dashboard-insert-banner
@@ -83,7 +83,7 @@
dashboard-insert-newline
dashboard-insert-newline
dashboard-insert-navigator
- dashboard-insert-init-info
+ ;; dashboard-insert-init-info ; Disabled: package count and startup time
dashboard-insert-newline
dashboard-insert-newline
dashboard-insert-items
@@ -109,37 +109,78 @@
;; == navigation
(setq dashboard-set-navigator t)
(setq dashboard-navigator-buttons
- `(((,(nerd-icons-faicon "nf-fa-envelope")
+ `(;; Row 1
+ ((,(nerd-icons-faicon "nf-fa-code")
+ "Code" "Switch Project"
+ (lambda (&rest _) (projectile-switch-project))
+ nil " " "")
+
+ (,(nerd-icons-faicon "nf-fa-envelope")
"Email" "Mu4e Email Client"
- (lambda (&rest _) (mu4e)))
+ (lambda (&rest _) (mu4e))
+ nil " " "")
- (,(nerd-icons-faicon "nf-fae-book_open_o")
- "Ebooks" "Calibre Ebook Reader"
- (lambda (&rest _) (calibredb)))
+ (,(nerd-icons-mdicon "nf-md-calendar")
+ "Agenda" "Main Org Agenda"
+ (lambda (&rest _) (cj/main-agenda-display))
+ nil " " "")
(,(nerd-icons-mdicon "nf-md-school")
"Flashcards" "Org-Drill"
- (lambda (&rest _) (cj/drill-start)))
+ (lambda (&rest _) (cj/drill-start))
+ nil " " "")
+
+ (,(nerd-icons-faicon "nf-fae-book_open_o")
+ "Books" "Calibre Ebook Reader"
+ (lambda (&rest _) (calibredb))
+ nil " " ""))
- (,(nerd-icons-faicon "nf-fa-rss_square")
- "Feeds" "Elfeed Feed Reader"
- (lambda (&rest _) (cj/elfeed-open)))
+ ;; Row 2
+ ((,(nerd-icons-faicon "nf-fa-rss_square")
+ "RSS/Feeds" "Elfeed Feed Reader"
+ (lambda (&rest _) (cj/elfeed-open))
+ nil " " "")
(,(nerd-icons-faicon "nf-fa-comments")
"IRC" "Emacs Relay Chat"
- (lambda (&rest _) (cj/erc-switch-to-buffer-with-completion)))
+ (lambda (&rest _) (cj/erc-switch-to-buffer-with-completion))
+ nil " " "")
+
+ (,(nerd-icons-devicon "nf-dev-terminal")
+ "Terminal" "Launch VTerm"
+ (lambda (&rest _) (vterm))
+ nil " " "")
;; (,(nerd-icons-faicon "nf-fae-telegram")
;; "Telegram" "Telega Chat Client"
- ;; (lambda (&rest _) (telega)))
+ ;; (lambda (&rest _) (telega))
+ ;; nil " " "")
(,(nerd-icons-faicon "nf-fa-folder_o")
- "Files" "Dirvish File Manager"
- (lambda (&rest _) (dirvish user-home-dir))))))
+ "Directory/Files" "Dirvish File Manager"
+ (lambda (&rest _) (dirvish user-home-dir))
+ nil " " ""))))
;; == content
(setq dashboard-show-shortcuts nil) ;; don't show dashboard item abbreviations
) ;; end use-package dashboard
+;; ------------------------ Dashboard Keybindings ------------------------------
+
+(with-eval-after-load 'dashboard
+ ;; Disable 'q' to quit dashboard
+ (define-key dashboard-mode-map (kbd "q") nil)
+
+ ;; Dashboard launcher keybindings
+ (define-key dashboard-mode-map (kbd "e") (lambda () (interactive) (mu4e)))
+ (define-key dashboard-mode-map (kbd "c") (lambda () (interactive) (projectile-switch-project)))
+ (define-key dashboard-mode-map (kbd "a") (lambda () (interactive) (cj/main-agenda-display)))
+ (define-key dashboard-mode-map (kbd "b") (lambda () (interactive) (calibredb)))
+ (define-key dashboard-mode-map (kbd "f") (lambda () (interactive) (cj/drill-start)))
+ (define-key dashboard-mode-map (kbd "r") (lambda () (interactive) (cj/elfeed-open)))
+ (define-key dashboard-mode-map (kbd "i") (lambda () (interactive) (cj/erc-switch-to-buffer-with-completion)))
+ (define-key dashboard-mode-map (kbd "t") (lambda () (interactive) (vterm)))
+ (define-key dashboard-mode-map (kbd "d") (lambda () (interactive) (dirvish user-home-dir))))
+
(provide 'dashboard-config)
;;; dashboard-config.el ends here.
diff --git a/modules/diff-config.el b/modules/diff-config.el
index 382b2250..45c2a778 100644
--- a/modules/diff-config.el
+++ b/modules/diff-config.el
@@ -48,6 +48,14 @@
(add-hook 'ediff-mode-hook #'cj/ediff-hook)
(add-hook 'ediff-after-quit-hook-internal #'winner-undo))
+;; which-key labels
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-c D" "ediff menu"
+ "C-c D f" "ediff files"
+ "C-c D b" "ediff buffers"
+ "C-c D r" "ediff revision"
+ "C-c D D" "ediff directories"))
(provide 'diff-config)
;;; diff-config.el ends here
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
index e35cc528..5577b9f1 100644
--- a/modules/dirvish-config.el
+++ b/modules/dirvish-config.el
@@ -8,13 +8,16 @@
;; ediff, playlist creation, path copying, and external file manager integration.
;;
;; Key Bindings:
+;; - d: Delete marked files (dired-do-delete)
+;; - D: Duplicate file at point (adds "-copy" before extension)
;; - g: Quick access menu (jump to predefined directories)
+;; - G: Search with deadgrep in current directory
;; - f: Open system file manager in current directory
;; - o/O: Open file with xdg-open/custom command
-;; - l: Copy file path (project-relative or home-relative)
-;; - L: Copy absolute file path
-;; - P: Create M3U playlist from marked audio files
-;; - M-D: DWIM menu (context actions for files)
+;; - l: Copy org-link with relative file path (project-relative or home-relative)
+;; - p: Copy absolute file path
+;; - P: Copy relative file path (project-relative or home-relative)
+;; - M-S-d (Meta-Shift-d): DWIM shell commands menu
;; - TAB: Toggle subtree expansion
;; - F11: Toggle sidebar view
@@ -23,6 +26,9 @@
(eval-when-compile (require 'user-constants))
(eval-when-compile (require 'system-utils))
+;; mark files in dirvish, attach in mu4e
+(add-hook 'dired-mode-hook 'turn-on-gnus-dired-mode)
+
;;; ----------------------------- Dired Ediff Files -----------------------------
(defun cj/dired-ediff-files ()
@@ -114,9 +120,9 @@ Filters for audio files, prompts for the playlist name, and saves the resulting
(setq dired-listing-switches "-l --almost-all --human-readable --group-directories-first")
(setq dired-dwim-target t)
(setq dired-clean-up-buffers-too t) ;; offer to kill buffers associated deleted files and dirs
- (setq dired-clean-confirm-killing-deleted-buffers t) ;; don't ask; just kill buffers associated with deleted files
- (setq dired-recursive-copies (quote always)) ;; “always” means no asking
- (setq dired-recursive-deletes (quote top))) ;; “top” means ask once
+ (setq dired-clean-confirm-killing-deleted-buffers nil) ;; don't ask; just kill buffers associated with deleted files
+ (setq dired-recursive-copies (quote always)) ;; "always" means no asking
+ (setq dired-recursive-deletes (quote top))) ;; "top" means ask once
;; note: disabled as it prevents marking and moving files to another directory
;; (setq dired-kill-when-opening-new-dired-buffer t) ;; don't litter by leaving buffers when navigating directories
@@ -145,6 +151,36 @@ Filters for audio files, prompts for the playlist name, and saves the resulting
(dired-mark 1))
(forward-line 1))))
+;;; ------------------------ Dirvish Duplicate File Copy ------------------------
+
+(defun cj/dirvish-duplicate-file ()
+ "Duplicate the file at point with '-copy' suffix before the extension.
+Examples:
+ report.pdf → report-copy.pdf
+ script.el → script-copy.el
+ README → README-copy"
+ (interactive)
+ (let* ((file (dired-get-filename nil t))
+ (dir (file-name-directory file))
+ (base (file-name-base file))
+ (ext (file-name-extension file t)) ; includes the dot
+ (new-name (concat base "-copy" ext))
+ (new-path (expand-file-name new-name dir)))
+ (unless file
+ (user-error "No file at point"))
+ (when (file-directory-p file)
+ (user-error "Cannot duplicate directories, only files"))
+
+ ;; Check if target already exists
+ (when (file-exists-p new-path)
+ (unless (y-or-n-p (format "File '%s' already exists. Overwrite? " new-name))
+ (user-error "Cancelled")))
+
+ ;; Copy the file
+ (copy-file file new-path t)
+ (revert-buffer)
+ (message "Duplicated: %s → %s" (file-name-nondirectory file) new-name)))
+
;;; ----------------------- Dirvish Open File Manager Here ----------------------
(defun cj/dirvish-open-file-manager-here ()
@@ -193,12 +229,14 @@ regardless of what file or subdirectory the point is on."
("dr" ,(concat org-dir "/drill/") "drill files")
("dt" ,(concat dl-dir "/torrents/complete/") "torrents")
("dx" "~/documents/" "documents")
+ ("db" "~/documents/dropbox/" "dropbox")
("gd" "~/documents/google-drive/" "google-drive")
- ("lx" "~/lectures/" "lectures")
+ ("lx" "~/archive/lectures/" "lectures")
("mb" "/media/backup/" "backup directory")
("mx" "~/music/" "music")
- ("pD" "~/projects/documents/" "project documents")
- ("pd" "~/projects/danneel/" "project danneel")
+ ("pdx" "~/projects/documents/" "project documents")
+ ("pdl" "~/projects/danneel/" "project danneel")
+ ("pc" "~/projects/clipper/" "project clipper")
("pl" "~/projects/elibrary/" "project elibrary")
("pf" "~/projects/finances/" "project finances")
("pjr" "~/projects/jr-estate/" "project jr-estate")
@@ -211,8 +249,8 @@ regardless of what file or subdirectory the point is on."
("sx" ,sync-dir "sync directory")
("so" ,(concat sync-dir "/org/") "sync/org directory")
("sr" ,(concat sync-dir "/recordings/") "sync/recordings directory")
- ("sv" ,(concat sync-dir "/videos/") "sync/videos directory")
- ("tg" ,(concat sync-dir "/text.games/") "text games")
+ ("spv" ,(concat sync-dir "/phone/videos/") "sync/phone/videos directory")
+ ("tg" ,(concat org-dir "/text.games/") "text games")
("vr" ,video-recordings-dir "video recordings directory")
("vx" ,videos-dir "videos")))
:config
@@ -276,9 +314,8 @@ regardless of what file or subdirectory the point is on."
("C-." . dirvish-history-go-forward)
("F" . dirvish-file-info-menu)
("G" . revert-buffer)
- ("l" . (lambda () (interactive) (cj/dired-copy-path-as-kill))) ;; overwrites dired-do-redisplay
- ("L" . (lambda () (interactive) (cj/dired-copy-path-as-kill nil t))) ;; copy absolute path
("h" . cj/dirvish-open-html-in-eww) ;; it does what it says it does
+ ("l" . (lambda () (interactive) (cj/dired-copy-path-as-kill t nil))) ;; copy as org-link, relative path
("M" . cj/dired-mark-all-visible-files)
("M-e" . dirvish-emerge-menu)
("M-l" . dirvish-ls-switches-menu)
@@ -286,13 +323,15 @@ regardless of what file or subdirectory the point is on."
("M-p" . dirvish-peek-toggle)
("M-s" . dirvish-setup-menu)
("TAB" . dirvish-subtree-toggle)
- ("d" . dired-flag-file-deletion)
+ ("d" . dired-do-delete)
+ ("D" . cj/dirvish-duplicate-file)
("f" . cj/dirvish-open-file-manager-here)
("g" . dirvish-quick-access)
("o" . cj/xdg-open)
("O" . cj/open-file-with-command) ; Prompts for command to run
+ ("p" . (lambda () (interactive) (cj/dired-copy-path-as-kill nil t)))
+ ("P" . (lambda () (interactive) (cj/dired-copy-path-as-kill)))
("r" . dirvish-rsync)
- ("P" . cj/dired-create-playlist-from-marked)
("s" . dirvish-quicksort)
("v" . dirvish-vc-menu)
("y" . dirvish-yank-menu)))
@@ -390,5 +429,6 @@ Returns nil if not in a project."
(t nil)))
+
(provide 'dirvish-config)
;;; dirvish-config.el ends here.
diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el
index a05646b2..1881f791 100644
--- a/modules/dwim-shell-config.el
+++ b/modules/dwim-shell-config.el
@@ -90,16 +90,10 @@
(require 'cl-lib)
-
-;; Bind menu to dired (after dwim-shell-command loads)
-(with-eval-after-load 'dwim-shell-command
- (with-eval-after-load 'dired
- (keymap-set dired-mode-map "M-D" #'dwim-shell-commands-menu)))
-
;; ----------------------------- Dwim Shell Command ----------------------------
(use-package dwim-shell-command
- :after (dired dirvish)
+ :demand t
:bind (("<remap> <shell-command>" . dwim-shell-command)
:map dired-mode-map
("<remap> <dired-do-async-shell-command>" . dwim-shell-command)
@@ -606,10 +600,14 @@ in process lists or command history."
(defun cj/dwim-shell-commands-extract-audio-from-video ()
"Extract audio track from video file(s)."
(interactive)
- (dwim-shell-command-on-marked-files
- "Extract audio"
- "ffmpeg -i '<<f>>' -vn -acodec copy '<<fne>>.m4a'"
- :utils "ffmpeg"))
+ (let ((bitrate (completing-read "Audio bitrate: "
+ '("64k" "96k" "128k" "192k")
+ nil t)))
+ (dwim-shell-command-on-marked-files
+ "Extract audio"
+ (format "ffmpeg -i '<<f>>' -vn -c:a aac -b:a %s '<<fne>>.m4a'" bitrate)
+ :utils "ffmpeg"
+ :extensions '("mp4" "mkv" "webm" "avi" "mov" "flv" "wmv" "m4v" "mpg" "mpeg" "ogv" "3gp" "ts"))))
(defun cj/dwim-shell-commands-normalize-audio-volume ()
"Normalize audio volume in file(s)."
@@ -809,7 +807,13 @@ gpg: decryption failed: No pinentry"
'dwim-shell-command-history))
(command (alist-get selected command-alist nil nil #'string=)))
(when command
- (call-interactively command)))))
+ (call-interactively command))))
+
+ ;; Bind menu to keymaps after function is defined
+ (with-eval-after-load 'dired
+ (keymap-set dired-mode-map "M-D" #'dwim-shell-commands-menu))
+ (with-eval-after-load 'dirvish
+ (keymap-set dirvish-mode-map "M-D" #'dwim-shell-commands-menu)))
(provide 'dwim-shell-config)
;;; dwim-shell-config.el ends here.
diff --git a/modules/elfeed-config.el b/modules/elfeed-config.el
index 46520be2..52c5b8f1 100644
--- a/modules/elfeed-config.el
+++ b/modules/elfeed-config.el
@@ -15,7 +15,7 @@
;;; Code:
(require 'user-constants)
-(require 'system-utils)
+(require 'system-lib)
(require 'media-utils)
;; ------------------------------- Elfeed Config -------------------------------
diff --git a/modules/erc-config.el b/modules/erc-config.el
index 1c189fa3..e7efb33f 100644
--- a/modules/erc-config.el
+++ b/modules/erc-config.el
@@ -183,7 +183,14 @@ Auto-adds # prefix if missing. Offers completion from configured channels."
(keymap-set cj/custom-keymap "E" cj/erc-keymap)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; E" "ERC chat menu"))
+ (which-key-add-key-based-replacements
+ "C-; E" "ERC chat menu"
+ "C-; E C" "connect server"
+ "C-; E c" "join channel"
+ "C-; E b" "switch buffer"
+ "C-; E l" "list servers"
+ "C-; E q" "quit channel"
+ "C-; E Q" "quit server"))
;; Main ERC configuration
(use-package erc
diff --git a/modules/eshell-vterm-config.el b/modules/eshell-vterm-config.el
index 4f2d14df..5799c7c3 100644
--- a/modules/eshell-vterm-config.el
+++ b/modules/eshell-vterm-config.el
@@ -47,6 +47,9 @@
(setq eshell-prefer-lisp-functions nil)
(setq eshell-destroy-buffer-when-process-dies t)
+ ;; no pagers required
+ (setenv "PAGER" "cat")
+
(setq eshell-prompt-function
(lambda ()
(concat
@@ -54,7 +57,7 @@
" "
(propertize (user-login-name) 'face '(:foreground "gray"))
" "
- (propertize (system-name) 'face '(:foreground "gray"))
+ (propertize (system-name) 'face '(:foreground "gray"))
":"
(propertize (abbreviate-file-name (eshell/pwd)) 'face '(:foreground "gray"))
"\n"
@@ -81,7 +84,6 @@
(add-to-list 'eshell-visual-options '("git" "--help" "--paginate"))
;; aliases
- (eshell/alias "clear" "clear 1") ;; leaves prompt at the top of the window
(eshell/alias "e" "find-file $1")
(eshell/alias "em" "find-file $1")
(eshell/alias "emacs" "find-file $1")
@@ -111,7 +113,13 @@
(find-file (car files))
;; Multiple files
(dolist (file files)
- (find-file file))))
+ (find-file file))))
+
+(defun eshell/clear ()
+ "Clear the eshell buffer."
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (eshell-send-input)))
(defun eshell/find-using-dired (file-pattern)
"Find a file FILE-PATTERN' using 'find-name-dired'."
@@ -125,7 +133,6 @@
(advice-add 'eshell-life-is-too-much :after 'cj/eshell-delete-window-on-exit)
(use-package eshell-toggle
- :after eshell
:custom
(eshell-toggle-size-fraction 2)
(eshell-toggle-run-command nil)
diff --git a/modules/external-open.el b/modules/external-open.el
index 41d842fb..8c4db810 100644
--- a/modules/external-open.el
+++ b/modules/external-open.el
@@ -111,6 +111,11 @@
(keymap-global-set "C-c x o" #'cj/open-this-file-with)
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-c x" "external open menu"
+ "C-c x o" "open file with"))
+
;; -------------------- Open Files With Default File Handler -------------------
(defun cj/find-file-auto (orig-fun &rest args)
diff --git a/modules/flycheck-config.el b/modules/flycheck-config.el
index d7f1ad39..e2e8abe9 100644
--- a/modules/flycheck-config.el
+++ b/modules/flycheck-config.el
@@ -6,30 +6,30 @@
;; This file configures Flycheck for on-demand syntax and grammar checking.
;; - Flycheck starts automatically only in sh-mode and emacs-lisp-mode
-;; - This binds a custom helper (=cj/flycheck-list-errors=) to “C-; ?”
+;; - This binds a custom helper (=cj/flycheck-list-errors=) to "C-; ?"
;; for popping up Flycheck's error list in another window.
-;; - It also customizes Checkdoc to suppress only the “sentence-end-double-space”
-;; and “warn-escape” warnings.
+;; - It also customizes Checkdoc to suppress only the "sentence-end-double-space"
+;; and "warn-escape" warnings.
-;; - It registers a Proselint checker for prose files
-;; (text-mode, markdown-mode, gfm-mode).
+;; - It registers LanguageTool for comprehensive grammar checking of prose files
+;; (text-mode, markdown-mode, gfm-mode, org-mode).
-;; Note: I do use proselint quite a bit in emails and org-mode files. However, some
-;; org-files can be large and running proselint on them will slow Emacs to a crawl.
-;; Therefore, hitting "C-; ?" also runs cj/flycheck-prose-on-demand if in an org buffer.
+;; Note: Grammar checking is on-demand only to avoid performance issues.
+;; Hitting "C-; ?" runs cj/flycheck-prose-on-demand if in an org buffer.
-;;
;; The cj/flycheck-prose-on-demand function:
;; - Turns on flycheck for the local buffer
-;; - ensures proselint is added
-;; - triggers an immediate check
-;;
-;; Since this is called within cj/flycheck-list-errors, flycheck's error list will still
-;; display and the focus transferred to that buffer.
+;; - Enables LanguageTool checker
+;; - Triggers an immediate check
+;; - Displays errors in the *Flycheck errors* buffer
-;; OS Dependencies:
-;; proselint (in the Arch AUR)
+;; Installation:
+;; On Arch Linux:
+;; sudo pacman -S languagetool
+;;
+;; The wrapper script at scripts/languagetool-flycheck formats LanguageTool's
+;; JSON output into flycheck-compatible format. It requires Python 3.
;;; Code:
@@ -62,20 +62,20 @@
;; use the load-path of the currently running Emacs instance
(setq flycheck-emacs-lisp-load-path 'inherit)
- ;; Define the prose checker (installed separately via OS).
- (flycheck-define-checker proselint
- "A linter for prose."
- :command ("proselint" source-inplace)
+ ;; Define LanguageTool checker for comprehensive grammar checking
+ (flycheck-define-checker languagetool
+ "A grammar checker using LanguageTool.
+Uses a wrapper script to format output for flycheck."
+ :command ("~/.emacs.d/scripts/languagetool-flycheck"
+ source-inplace)
:error-patterns
((warning line-start (file-name) ":" line ":" column ": "
- (id (one-or-more (not (any " "))))
(message) line-end))
:modes (text-mode markdown-mode gfm-mode org-mode))
- (add-to-list 'flycheck-checkers 'proselint)
+ (add-to-list 'flycheck-checkers 'languagetool)
(defun cj/flycheck-list-errors ()
"Display flycheck's error list and switch to its buffer.
-
Runs flycheck-prose-on-demand if in an org-buffer."
(interactive)
(when (derived-mode-p 'org-mode)
@@ -85,14 +85,19 @@ Runs flycheck-prose-on-demand if in an org-buffer."
(switch-to-buffer-other-window "*Flycheck errors*"))
(defun cj/flycheck-prose-on-demand ()
- "Enable Flycheck+Proselint in this buffer, run it, and show errors."
+ "Enable Flycheck with LanguageTool in this buffer, run it, and show errors."
(interactive)
;; turn on Flycheck locally
(flycheck-mode 1)
- ;; ensure proselint is valid for org/text
- (flycheck-add-mode 'proselint major-mode)
+ ;; ensure LanguageTool is valid for current mode
+ (flycheck-add-mode 'languagetool major-mode)
+ ;; select LanguageTool as the checker
+ (setq-local flycheck-checker 'languagetool)
;; trigger immediate check
(flycheck-buffer)))
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements "C-; ?" "list errors"))
+
(provide 'flycheck-config)
;;; flycheck-config.el ends here
diff --git a/modules/flyspell-and-abbrev.el b/modules/flyspell-and-abbrev.el
index 12e0d348..e29ad6e9 100644
--- a/modules/flyspell-and-abbrev.el
+++ b/modules/flyspell-and-abbrev.el
@@ -111,7 +111,6 @@
;; ------------------------------ Flyspell Toggle ------------------------------
;; easy toggling flyspell and also leverage the 'for-buffer-type' functionality.
-;;;###autoload
(defun cj/flyspell-toggle ()
"Turn Flyspell on if it is off, or off if it is on.
@@ -198,7 +197,6 @@ buffer."
(downcase misspelled-word)
nil)))
-;;;###autoload
(defun cj/flyspell-then-abbrev (p)
"Find and correct the previous misspelled word, creating an abbrev.
@@ -236,10 +234,21 @@ Press C-' repeatedly to step through misspellings one at a time."
;; -------------------------------- Keybindings --------------------------------
;; Global keybindings for spell checking commands
-;; With autoload cookies, these will lazy-load the file when pressed
-;;;###autoload (keymap-set global-map "C-c f" #'cj/flyspell-toggle)
-;;;###autoload (keymap-set global-map "C-'" #'cj/flyspell-then-abbrev)
+;; Set global keybindings
+(keymap-set global-map "C-c f" #'cj/flyspell-toggle)
+(keymap-set global-map "C-'" #'cj/flyspell-then-abbrev)
+
+;; Override org-mode's C-' binding (org-cycle-agenda-files)
+;; Org-mode binds C-' globally, but we want our spell check binding instead
+(with-eval-after-load 'org
+ (keymap-set org-mode-map "C-'" #'cj/flyspell-then-abbrev))
+
+;; which-key labels
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-c f" "flyspell toggle"
+ "C-'" "flyspell then abbrev"))
(provide 'flyspell-and-abbrev)
;;; flyspell-and-abbrev.el ends here.
diff --git a/modules/font-config.el b/modules/font-config.el
index 1541f55f..25026efc 100644
--- a/modules/font-config.el
+++ b/modules/font-config.el
@@ -53,26 +53,33 @@
(setq fontaine-presets
'(
(default
- :default-family "FiraCode Nerd Font Mono"
+ :default-family "Berkeley Mono"
:default-weight regular
:default-height 110
:fixed-pitch-family nil ;; falls back to :default-family
:fixed-pitch-weight nil ;; falls back to :default-weight
:fixed-pitch-height 1.0
- :variable-pitch-family "Merriweather"
- :variable-pitch-weight light
+ :variable-pitch-family "Lexend"
+ :variable-pitch-weight regular
:variable-pitch-height 1.0)
+ (FiraCode
+ :default-family "FiraCode Nerd Font Mono"
+ :variable-pitch-family "Merriweather"
+ :variable-pitch-weight light)
(Hack
:default-family "Hack Nerd Font Mono"
:variable-pitch-family "Hack Nerd Font Mono")
+ (BerkeleyMono
+ :default-family "Berkeley Mono"
+ :variable-pitch-family "Charis SIL")
(FiraCode-Literata
:default-family "Fira Code Nerd Font"
:variable-pitch-family "Literata")
(EBook
- :default-family "Merriweather"
+ :default-family "Lexend"
:default-weight regular
:default-height 200
- :variable-pitch-family "Merriweather")
+ :variable-pitch-family "Lexend")
(24-point-font
:default-height 240)
(20-point-font
@@ -142,7 +149,6 @@ If FRAME is nil, uses the selected frame."
;; ----------------------------- Font Install Check ----------------------------
;; convenience function to indicate whether a font is available by name.
-;;;###autoload
(defun cj/font-installed-p (font-name)
"Check if font with FONT-NAME is available."
(if (find-font (font-spec :name font-name))
@@ -224,7 +230,6 @@ If FRAME is nil, uses the selected frame."
;; -------------------------- Display Available Fonts --------------------------
;; display all available fonts on the system in a side panel
-;;;###autoload
(defun cj/display-available-fonts ()
"Display a list of all font faces with sample text in another read-only buffer."
(interactive)
@@ -286,5 +291,12 @@ If FRAME is nil, uses the selected frame."
"<~" "<~~" "</" "</>" "~@" "~-" "~>" "~~" "~~>" "%%"))
(global-ligature-mode t))
+;; which-key labels
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-c E" "emojify menu"
+ "C-c E i" "insert emoji"
+ "C-c E l" "list emojis"))
+
(provide 'font-config)
;;; font-config.el ends here
diff --git a/modules/host-environment.el b/modules/host-environment.el
index 6900a8df..3cec5df1 100644
--- a/modules/host-environment.el
+++ b/modules/host-environment.el
@@ -112,7 +112,7 @@ Tries multiple methods in order of reliability:
(when (string-match ".*/zoneinfo/\\(.+\\)" target)
(match-string 1 target))))
- ;; Default to nil - lets org-gcal use its default
+ ;; Default to nil if detection fails
nil))
(provide 'host-environment)
diff --git a/modules/jumper.el b/modules/jumper.el
index e1025472..67d930aa 100644
--- a/modules/jumper.el
+++ b/modules/jumper.el
@@ -10,24 +10,76 @@
;; Jumper provides a simple way to store and jump between locations
;; in your codebase without needing to remember register assignments.
+;;
+;; PURPOSE:
+;;
+;; When working on large codebases, you often need to jump between
+;; multiple related locations: a function definition, its tests, its
+;; callers, configuration files, etc. Emacs registers are perfect for
+;; this, but require you to remember which register you assigned to
+;; which location. Jumper automates register management, letting you
+;; focus on your work instead of bookkeeping.
+;;
+;; WORKFLOW:
+;;
+;; 1. Navigate to an important location in your code
+;; 2. Press M-SPC SPC to store it (automatically assigned to register 0)
+;; 3. Continue working, storing more locations as needed (registers 1-9)
+;; 4. Press M-SPC j to jump back to any stored location
+;; 5. Select from the list using completion (shows file, line, context)
+;; 6. Press M-SPC d to remove locations you no longer need
+;;
+;; RECOMMENDED USAGE:
+;;
+;; Store locations temporarily while working on a feature:
+;; - Store the main function you're implementing
+;; - Store the test file where you're writing tests
+;; - Store the caller that needs updating
+;; - Store the documentation that needs changes
+;; - Jump between them freely as you work
+;; - Clear them when done with the feature
+;;
+;; SPECIAL BEHAVIORS:
+;;
+;; - Duplicate prevention: Storing the same location twice shows a message
+;; instead of wasting a register slot.
+;;
+;; - Single location toggle: When only one location is stored, M-SPC j
+;; toggles between that location and your current position. Perfect for
+;; rapid back-and-forth between two related files.
+;;
+;; - Last location tracking: The last position before each jump is saved
+;; in register 'z', allowing quick "undo" of navigation.
+;;
+;; - Smart selection: With multiple locations, completing-read shows
+;; helpful context: "[0] filename.el:42 - function definition..."
+;;
+;; KEYBINDINGS:
+;;
+;; M-SPC SPC Store current location in next available register
+;; M-SPC j Jump to a stored location (with completion)
+;; M-SPC d Delete a stored location from the list
+;;
+;; CONFIGURATION:
+;;
+;; You can customize the prefix key and maximum locations:
+;;
+;; (setq jumper-prefix-key "C-c j") ; Change prefix key
+;; (setq jumper-max-locations 20) ; Store up to 20 locations
+;;
+;; Note: Changing jumper-max-locations requires restarting Emacs or
+;; manually reinitializing jumper--registers.
;;; Code:
-(defgroup jumper nil
- "Quick navigation between stored locations."
- :group 'convenience)
+(require 'cl-lib)
-(defcustom jumper-prefix-key "M-SPC"
+(defvar jumper-prefix-key "M-SPC"
"Prefix key for jumper commands.
+Note that using M-SPC will override the default binding to just-one-space.")
-Note that using M-SPC will override the default binding to just-one-space."
- :type 'string
- :group 'jumper)
-
-(defcustom jumper-max-locations 10
- "Maximum number of locations to store."
- :type 'integer
- :group 'jumper)
+(defvar jumper-max-locations 10
+ "Maximum number of locations to store.")
;; Internal variables
(defvar jumper--registers (make-vector jumper-max-locations nil)
@@ -50,12 +102,10 @@ Note that using M-SPC will override the default binding to just-one-space."
"Check if current location is already stored."
(let ((key (jumper--location-key))
(found nil))
- (dotimes (i
- jumper--next-index found)
+ (dotimes (i jumper--next-index found)
(let* ((reg (aref jumper--registers i))
- (pos (get-register reg))
- (marker (and pos (registerv-data pos))))
- (when marker
+ (marker (get-register reg)))
+ (when (and marker (markerp marker))
(save-current-buffer
(set-buffer (marker-buffer marker))
(save-excursion
@@ -70,9 +120,8 @@ Note that using M-SPC will override the default binding to just-one-space."
(defun jumper--format-location (index)
"Format location at INDEX for display."
(let* ((reg (aref jumper--registers index))
- (pos (get-register reg))
- (marker (and pos (registerv-data pos))))
- (when marker
+ (marker (get-register reg)))
+ (when (and marker (markerp marker))
(save-current-buffer
(set-buffer (marker-buffer marker))
(save-excursion
@@ -86,49 +135,83 @@ Note that using M-SPC will override the default binding to just-one-space."
(min (+ (line-beginning-position) 40)
(line-end-position)))))))))
+(defun jumper--do-store-location ()
+ "Store current location in the next free register.
+Returns: \\='already-exists if location is already stored,
+ \\='no-space if all registers are full,
+ register character if successfully stored."
+ (cond
+ ((jumper--location-exists-p) 'already-exists)
+ ((not (jumper--register-available-p)) 'no-space)
+ (t
+ (let ((reg (+ ?0 jumper--next-index)))
+ (point-to-register reg)
+ (aset jumper--registers jumper--next-index reg)
+ (setq jumper--next-index (1+ jumper--next-index))
+ reg))))
+
(defun jumper-store-location ()
"Store current location in the next free register."
(interactive)
- (if (jumper--location-exists-p)
- (message "Location already stored")
- (if (jumper--register-available-p)
- (let ((reg (+ ?0 jumper--next-index)))
- (point-to-register reg)
- (aset jumper--registers jumper--next-index reg)
- (setq jumper--next-index (1+ jumper--next-index))
- (message "Location stored in register %c" reg))
- (message "Sorry - all jump locations are filled!"))))
+ (pcase (jumper--do-store-location)
+ ('already-exists (message "Location already stored"))
+ ('no-space (message "Sorry - all jump locations are filled!"))
+ (reg (message "Location stored in register %c" reg))))
+
+(defun jumper--do-jump-to-location (target-idx)
+ "Jump to location at TARGET-IDX.
+TARGET-IDX: -1 for last location, 0-9 for stored locations, nil for toggle.
+Returns: \\='no-locations if no locations stored,
+ \\='already-there if at the only location (toggle case),
+ \\='jumped if successfully jumped."
+ (cond
+ ((= jumper--next-index 0) 'no-locations)
+ ;; Toggle behavior when target-idx is nil and only 1 location
+ ((and (null target-idx) (= jumper--next-index 1))
+ (if (jumper--location-exists-p)
+ 'already-there
+ (let ((reg (aref jumper--registers 0)))
+ (point-to-register jumper--last-location-register)
+ (jump-to-register reg)
+ 'jumped)))
+ ;; Jump to specific target
+ (t
+ (if (= target-idx -1)
+ ;; Jumping to last location - don't overwrite it
+ (jump-to-register jumper--last-location-register)
+ ;; Jumping to stored location - save current for "last"
+ (progn
+ (point-to-register jumper--last-location-register)
+ (jump-to-register (aref jumper--registers target-idx))))
+ 'jumped)))
(defun jumper-jump-to-location ()
"Jump to a stored location."
(interactive)
- (if (= jumper--next-index 0)
- (message "No locations stored")
- (if (= jumper--next-index 1)
- ;; Special case for one location - toggle behavior
- (let ((reg (aref jumper--registers 0)))
- (if (jumper--location-exists-p)
- (message "You're already at the stored location")
- (point-to-register jumper--last-location-register)
- (jump-to-register reg)
- (message "Jumped to location")))
- ;; Multiple locations - use completing-read
- (let* ((locations
- (cl-loop for i from 0 below jumper--next-index
- for fmt = (jumper--format-location i)
- when fmt collect (cons fmt i)))
- ;; Add last location if available
- (last-pos (get-register jumper--last-location-register))
- (locations (if last-pos
- (cons (cons "[z] Last location" -1) locations)
- locations))
- (choice (completing-read "Jump to: " locations nil t))
- (idx (cdr (assoc choice locations))))
- (point-to-register jumper--last-location-register)
- (if (= idx -1)
- (jump-to-register jumper--last-location-register)
- (jump-to-register (aref jumper--registers idx)))
- (message "Jumped to location")))))
+ (cond
+ ;; No locations
+ ((= jumper--next-index 0)
+ (message "No locations stored"))
+ ;; Single location - toggle
+ ((= jumper--next-index 1)
+ (pcase (jumper--do-jump-to-location nil)
+ ('already-there (message "You're already at the stored location"))
+ ('jumped (message "Jumped to location"))))
+ ;; Multiple locations - prompt user
+ (t
+ (let* ((locations
+ (cl-loop for i from 0 below jumper--next-index
+ for fmt = (jumper--format-location i)
+ when fmt collect (cons fmt i)))
+ ;; Add last location if available
+ (last-pos (get-register jumper--last-location-register))
+ (locations (if last-pos
+ (cons (cons "[z] Last location" -1) locations)
+ locations))
+ (choice (completing-read "Jump to: " locations nil t))
+ (idx (cdr (assoc choice locations))))
+ (jumper--do-jump-to-location idx)
+ (message "Jumped to location")))))
(defun jumper--reorder-registers (removed-idx)
"Reorder registers after removing the one at REMOVED-IDX."
@@ -139,32 +222,40 @@ Note that using M-SPC will override the default binding to just-one-space."
(aset jumper--registers i next-reg))))
(setq jumper--next-index (1- jumper--next-index)))
+(defun jumper--do-remove-location (index)
+ "Remove location at INDEX.
+Returns: \\='no-locations if no locations stored,
+ \\='cancelled if index is -1,
+ t if successfully removed."
+ (cond
+ ((= jumper--next-index 0) 'no-locations)
+ ((= index -1) 'cancelled)
+ (t
+ (jumper--reorder-registers index)
+ t)))
+
(defun jumper-remove-location ()
"Remove a stored location."
(interactive)
(if (= jumper--next-index 0)
- (message "No locations stored")
- (let* ((locations
- (cl-loop for i from 0 below jumper--next-index
- for fmt = (jumper--format-location i)
- when fmt collect (cons fmt i)))
- (locations (cons (cons "Cancel" -1) locations))
- (choice (completing-read "Remove location: " locations nil t))
- (idx (cdr (assoc choice locations))))
- (if (= idx -1)
- (message "Operation cancelled")
- (jumper--reorder-registers idx)
- (message "Location removed")))))
-
-(defvar jumper-map
- (let ((map (make-sparse-keymap)))
- (define-key map (kbd "SPC") #'jumper-store-location)
- (define-key map (kbd "j") #'jumper-jump-to-location)
- (define-key map (kbd "d") #'jumper-remove-location)
- map)
- "Keymap for jumper commands.")
-
-;;;###autoload
+ (message "No locations stored")
+ (let* ((locations
+ (cl-loop for i from 0 below jumper--next-index
+ for fmt = (jumper--format-location i)
+ when fmt collect (cons fmt i)))
+ (locations (cons (cons "Cancel" -1) locations))
+ (choice (completing-read "Remove location: " locations nil t))
+ (idx (cdr (assoc choice locations))))
+ (pcase (jumper--do-remove-location idx)
+ ('cancelled (message "Operation cancelled"))
+ ('t (message "Location removed"))))))
+
+(defvar-keymap jumper-map
+ :doc "Keymap for jumper commands"
+ "SPC" #'jumper-store-location
+ "j" #'jumper-jump-to-location
+ "d" #'jumper-remove-location)
+
(defun jumper-setup-keys ()
"Setup default keybindings for jumper."
(interactive)
@@ -173,5 +264,13 @@ Note that using M-SPC will override the default binding to just-one-space."
;; Call jumper-setup-keys when the package is loaded
(jumper-setup-keys)
+;; which-key integration
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "M-SPC" "jumper menu"
+ "M-SPC SPC" "store location"
+ "M-SPC j" "jump to location"
+ "M-SPC d" "remove location"))
+
(provide 'jumper)
;;; jumper.el ends here.
diff --git a/modules/keybindings.el b/modules/keybindings.el
index 1f8867ef..1eff621c 100644
--- a/modules/keybindings.el
+++ b/modules/keybindings.el
@@ -68,12 +68,22 @@ Errors if VAR is unbound, not a non-empty string, or the file does not exist."
;; Bind it under the prefix map.
(keymap-set cj/jump-map key fn))))
-;; Bind the prefix globally (user-reserved prefix).
-(keymap-global-set "C-c j" cj/jump-map)
+;; Bind the prefix to custom keymap
+(keymap-set cj/custom-keymap "j" cj/jump-map)
-;; nicer prefix label in which-key
+;; which-key labels
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-c j" "Jump to common files."))
+ (which-key-add-key-based-replacements
+ "C-; j" "jump to files menu"
+ "C-; j r" "jump to reference"
+ "C-; j s" "jump to schedule"
+ "C-; j i" "jump to inbox"
+ "C-; j c" "jump to contacts"
+ "C-; j m" "jump to macros"
+ "C-; j n" "jump to reading notes"
+ "C-; j w" "jump to webclipped"
+ "C-; j g" "jump to gcal"
+ "C-; j I" "jump to emacs init"))
;; ---------------------------- Keybinding Discovery ---------------------------
diff --git a/modules/lipsum-generator.el b/modules/lipsum-generator.el
index b328b989..11ed8caa 100644
--- a/modules/lipsum-generator.el
+++ b/modules/lipsum-generator.el
@@ -129,25 +129,21 @@ Defaults to 'liber-primus.txt' in the modules directory."
(when candidates
(nth (random (length candidates)) candidates))))
-;;;###autoload
(defvar cj/lipsum-chain (cj/markov-chain-create)
"Global Markov chain for lipsum generation.")
-;;;###autoload
(defun cj/lipsum-reset ()
"Reset the global lipsum Markov chain."
(interactive)
(setq cj/lipsum-chain (cj/markov-chain-create))
(message "cj/lipsum-chain reset."))
-;;;###autoload
(defun cj/lipsum-learn-region (beg end)
"Learn text from region."
(interactive "r")
(cj/markov-learn cj/lipsum-chain (buffer-substring-no-properties beg end))
(message "Learned from region."))
-;;;###autoload
(defun cj/lipsum-learn-buffer ()
"Learn from entire buffer."
(interactive)
@@ -155,7 +151,6 @@ Defaults to 'liber-primus.txt' in the modules directory."
(buffer-substring-no-properties (point-min) (point-max)))
(message "Learned from buffer."))
-;;;###autoload
(defun cj/lipsum-learn-file (file)
"Learn from FILE containing plain text."
(interactive "fTrain from file: ")
@@ -164,12 +159,10 @@ Defaults to 'liber-primus.txt' in the modules directory."
(cj/markov-learn cj/lipsum-chain (buffer-string)))
(message "Learned from file: %s" file))
-;;;###autoload
(defun cj/lipsum (n)
"Return N words of lorem ipsum."
(cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum")))
-;;;###autoload
(defun cj/lipsum-insert (n)
"Insert N words of lorem ipsum at point."
(interactive "nNumber of words: ")
@@ -181,7 +174,6 @@ Defaults to 'liber-primus.txt' in the modules directory."
(defconst cj/lipsum-title-max 8)
(defconst cj/lipsum-title-small 3)
-;;;###autoload
(defun cj/lipsum-title ()
"Generate a pseudo-Latin title."
(interactive)
@@ -205,7 +197,6 @@ Defaults to 'liber-primus.txt' in the modules directory."
;;; Paragraphs
-;;;###autoload
(defun cj/lipsum-paragraphs (count &optional min max)
"Insert COUNT paragraphs of lipsum.
Each paragraph has a random length between MIN and MAX words.
diff --git a/modules/lorem-generator.el b/modules/lorem-optimum.el
index 6148dfdc..6ccca55f 100644
--- a/modules/lorem-generator.el
+++ b/modules/lorem-optimum.el
@@ -1,4 +1,4 @@
-;;; lorem-generator.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*-
+;;; lorem-optimum.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*-
;;
;; Author: Craig Jennings
;; Version: 0.5
@@ -24,6 +24,19 @@
(require 'cl-lib)
+;;; Configuration
+
+(defvar cj/lipsum-training-file "assets/liber-primus.txt"
+ "Default training file name (relative to `user-emacs-directory`).")
+
+(defvar cj/lipsum-default-file
+ (expand-file-name cj/lipsum-training-file user-emacs-directory)
+ "Default training file for cj-lipsum.
+
+This should be a plain UTF-8 text file with hundreds of Latin words
+or sentences. By default it points to the file specified in
+`cj/lipsum-training-file` relative to `user-emacs-directory`.")
+
(cl-defstruct (cj/markov-chain
(:constructor cj/markov-chain-create))
"An order-two Markov chain."
@@ -31,25 +44,45 @@
(keys nil))
(defun cj/markov-tokenize (text)
- "Split TEXT into tokens: words and punctuation separately."
- (let ((case-fold-search nil))
- (split-string text "\\b" t "[[:space:]\n]+")))
-
+ "Split TEXT into tokens: words and punctuation separately.
+Returns a list of words and punctuation marks as separate tokens."
+ (let ((tokens '())
+ (pos 0)
+ (len (length text)))
+ (while (< pos len)
+ (cond
+ ;; Skip whitespace
+ ((string-match-p "[[:space:]]" (substring text pos (1+ pos)))
+ (setq pos (1+ pos)))
+ ;; Match word (sequence of alphanumeric characters)
+ ((string-match "\\`\\([[:alnum:]]+\\)" (substring text pos))
+ (let ((word (match-string 1 (substring text pos))))
+ (push word tokens)
+ (setq pos (+ pos (length word)))))
+ ;; Match punctuation (single character)
+ ((string-match "\\`\\([[:punct:]]\\)" (substring text pos))
+ (let ((punct (match-string 1 (substring text pos))))
+ (push punct tokens)
+ (setq pos (+ pos (length punct)))))
+ ;; Skip any other character
+ (t (setq pos (1+ pos)))))
+ (nreverse tokens)))
(defun cj/markov-learn (chain text)
"Add TEXT into the Markov CHAIN with tokenized input."
- (let* ((words (cj/markov-tokenize text))
+ (let* ((word-list (cj/markov-tokenize text))
+ ;; Convert to vector for O(1) access instead of O(n) with nth
+ (words (vconcat word-list))
(len (length words)))
(cl-loop for i from 0 to (- len 3)
- for a = (nth i words)
- for b = (nth (1+ i) words)
- for c = (nth (+ i 2) words)
+ for a = (aref words i)
+ for b = (aref words (1+ i))
+ for c = (aref words (+ i 2))
do (let* ((bigram (list a b))
(nexts (gethash bigram (cj/markov-chain-map chain))))
(puthash bigram (cons c nexts)
(cj/markov-chain-map chain)))))
- (setf (cj/markov-chain-keys chain)
- (cl-loop for k being the hash-keys of (cj/markov-chain-map chain)
- collect k)))
+ ;; Invalidate cached keys after learning new data
+ (setf (cj/markov-chain-keys chain) nil))
(defun cj/markov-fix-capitalization (sentence)
"Capitalize the first word and the first word after .!? in SENTENCE."
@@ -94,7 +127,7 @@
(defun cj/markov-generate (chain n &optional start)
"Generate a sentence of N tokens from CHAIN."
- (when (cj/markov-chain-keys chain)
+ (when (> (hash-table-count (cj/markov-chain-map chain)) 0)
(let* ((state (or (and start
(gethash start (cj/markov-chain-map chain))
start)
@@ -116,33 +149,37 @@
(cj/markov-join-tokens tokens))))
(defun cj/markov-random-key (chain)
- (nth (random (length (cj/markov-chain-keys chain)))
- (cj/markov-chain-keys chain)))
+ "Return a random bigram key from CHAIN.
+Builds and caches the keys list lazily if not already cached."
+ (unless (cj/markov-chain-keys chain)
+ ;; Lazily build keys list only when needed
+ (setf (cj/markov-chain-keys chain)
+ (cl-loop for k being the hash-keys of (cj/markov-chain-map chain)
+ collect k)))
+ (let ((keys (cj/markov-chain-keys chain)))
+ (when keys
+ (nth (random (length keys)) keys))))
(defun cj/markov-next-word (chain bigram)
(let ((candidates (gethash bigram (cj/markov-chain-map chain))))
(when candidates
(nth (random (length candidates)) candidates))))
-;;;###autoload
(defvar cj/lipsum-chain (cj/markov-chain-create)
"Global Markov chain for lipsum generation.")
-;;;###autoload
(defun cj/lipsum-reset ()
"Reset the global lipsum Markov chain."
(interactive)
(setq cj/lipsum-chain (cj/markov-chain-create))
(message "cj/lipsum-chain reset."))
-;;;###autoload
(defun cj/lipsum-learn-region (beg end)
"Learn text from region."
(interactive "r")
(cj/markov-learn cj/lipsum-chain (buffer-substring-no-properties beg end))
(message "Learned from region."))
-;;;###autoload
(defun cj/lipsum-learn-buffer ()
"Learn from entire buffer."
(interactive)
@@ -150,7 +187,6 @@
(buffer-substring-no-properties (point-min) (point-max)))
(message "Learned from buffer."))
-;;;###autoload
(defun cj/lipsum-learn-file (file)
"Learn from FILE containing plain text."
(interactive "fTrain from file: ")
@@ -159,12 +195,10 @@
(cj/markov-learn cj/lipsum-chain (buffer-string)))
(message "Learned from file: %s" file))
-;;;###autoload
(defun cj/lipsum (n)
"Return N words of lorem ipsum."
(cj/markov-generate cj/lipsum-chain n '("Lorem" "ipsum")))
-;;;###autoload
(defun cj/lipsum-insert (n)
"Insert N words of lorem ipsum at point."
(interactive "nNumber of words: ")
@@ -176,7 +210,6 @@
(defconst cj/lipsum-title-max 8)
(defconst cj/lipsum-title-small 3)
-;;;###autoload
(defun cj/lipsum-title ()
"Generate a pseudo-Latin title."
(interactive)
@@ -190,6 +223,7 @@
(or (cj/markov-next-word cj/lipsum-chain state)
(cadr (cj/markov-random-key cj/lipsum-chain))))))
collect (replace-regexp-in-string "^[[:punct:]]+\\|[[:punct:]]+$" "" w))))
+ ;; Filter empty strings from generated words
(setq words (cl-remove-if #'string-empty-p words))
(mapconcat
(lambda (word idx)
@@ -200,7 +234,6 @@
;;; Paragraphs
-;;;###autoload
(defun cj/lipsum-paragraphs (count &optional min max)
"Insert COUNT paragraphs of lipsum.
@@ -213,23 +246,6 @@ Defaults: MIN=30, MAX=80."
(let ((len (+ min (random (1+ (- max min))))))
(insert (cj/lipsum len) "\n\n")))))
-;;; Customization
-
-(defgroup cj-lipsum nil
- "Pseudo-Latin lorem ipsum text generator."
- :prefix "cj/lipsum-"
- :group 'text)
-
-(defcustom cj/lipsum-default-file
- (expand-file-name "latin.txt"
- (file-name-directory (or load-file-name buffer-file-name)))
- "Default training file for cj-lipsum.
-
-This should be a plain UTF-8 text file with hundreds of Latin words
-or sentences. By default it points to the bundled `latin.txt`."
- :type 'file
- :group 'cj-lipsum)
-
;;; Initialization: train on default file
(defun cj/lipsum--init ()
"Initialize cj-lipsum by learning from `cj/lipsum-default-file`."
@@ -240,5 +256,5 @@ or sentences. By default it points to the bundled `latin.txt`."
(cj/lipsum--init)
-(provide 'lorem-generator)
-;;; lorem-generator.el ends here.
+(provide 'lorem-optimum)
+;;; lorem-optimum.el ends here.
diff --git a/modules/mail-config.el b/modules/mail-config.el
index c65e5342..170711bb 100644
--- a/modules/mail-config.el
+++ b/modules/mail-config.el
@@ -82,7 +82,10 @@ Prompts user for the action when executing."
(setq mu4e-maildir mail-dir) ;; same as above (for newer mu4e)
(setq mu4e-sent-messages-behavior 'delete) ;; don't save to "Sent", IMAP does this already
(setq mu4e-show-images t) ;; show embedded images
- (setq mu4e-update-interval nil) ;; disallow automatic checking for new emails
+ ;; (setq mu4e-update-interval 600) ;; check for new mail every 10 minutes (600 seconds)
+ ;; TEMPORARILY DISABLED: Causing password prompts that interrupt work
+ (setq mu4e-hide-index-messages t) ;; don't show indexing messages buffer
+ (setq mu4e-headers-from-or-to-prefix '("" . "➜ "))
;; Format=flowed for better plain text email handling
;; This will be automatically disabled when org-msg is active
@@ -283,9 +286,9 @@ Prompts user for the action when executing."
;; user composes org mode; recipient receives html
(use-package org-msg
- :ensure nil ;; loading locally for fixes
+ ;; :vc (:url "https://github.com/cjennings/org-msg" :rev :newest)
+ :load-path "/home/cjennings/code/org-msg"
:defer 1
- :load-path "~/code/org-msg/"
:after (org mu4e)
:preface
(defvar-keymap cj/email-map
@@ -294,7 +297,10 @@ Prompts user for the action when executing."
"d" #'org-msg-attach-delete)
(keymap-set cj/custom-keymap "e" cj/email-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; e" "email menu"))
+ (which-key-add-key-based-replacements
+ "C-; e" "email menu"
+ "C-; e a" "attach file"
+ "C-; e d" "delete attachment"))
:bind
;; more intuitive keybinding for attachments
(:map org-msg-edit-mode-map
@@ -342,5 +348,9 @@ Prompts user for the action when executing."
(advice-add #'mu4e-compose-wide-reply
:after (lambda (&rest _) (org-msg-edit-mode)))
+;; which-key labels
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements "C-c m" "mu4e email"))
+
(provide 'mail-config)
;;; mail-config.el ends here
diff --git a/modules/media-utils.el b/modules/media-utils.el
index e4eccb5e..db66a71f 100644
--- a/modules/media-utils.el
+++ b/modules/media-utils.el
@@ -25,8 +25,9 @@
;;
;;; Code:
-;; Declare functions and variables from other modules
-(declare-function cj/log-silently "system-utils" (format-string &rest args))
+(require 'system-lib)
+
+;; Declare variables from other modules
(defvar videos-dir) ;; from user-constants.el
;; ------------------------ Default Media Configurations -----------------------
diff --git a/modules/modeline-config.el b/modules/modeline-config.el
index 0a247732..6573671a 100644
--- a/modules/modeline-config.el
+++ b/modules/modeline-config.el
@@ -3,73 +3,176 @@
;;; Commentary:
-;; Doom modeline configuration with performance optimizations.
-
-;; Settings prioritize speed while keeping essential information including:
-;; - relative file paths from project root
-;; - column number and percentage position
-;; - buffer modification indicators
-;; - and major mode with icon.
-
-;; Disabled features for performance:
-;; - minor modes display
-;; - word count
-;; - encoding info
-;; - LSP information
-
-;; Performance tuning includes:
-;; - 0.75 second refresh rate
-;; - 1MB process output chunks
-;; - nerd-icons (faster than all-the-icons)
-;; - simplified checker format
-;; - limited VCS info length
+;; Simple, minimal modeline using only built-in Emacs functionality.
+;; No external packages = no buffer issues, no native-comp errors.
+
+;; Features:
+;; - Buffer status (modified, read-only)
+;; - Buffer name
+;; - Major mode
+;; - Version control status
+;; - Line and column position
+;; - Buffer percentage
;;; Code:
-;; ------------------------------- Doom Modeline -------------------------------
-
-(use-package doom-modeline
- :hook (after-init . doom-modeline-mode)
- :custom
- ;; Performance optimizations
- (doom-modeline-buffer-file-name-style 'relative-from-project) ;; Faster than 'file-name
- (doom-modeline-icon t)
- (doom-modeline-major-mode-icon t)
- (doom-modeline-major-mode-color-icon t)
- (doom-modeline-buffer-state-icon t)
- (doom-modeline-buffer-modification-icon t)
- (doom-modeline-unicode-fallback nil)
- (doom-modeline-minor-modes nil) ;; Hide minor modes as requested
- (doom-modeline-enable-word-count nil) ;; Faster without word count
- (doom-modeline-continuous-word-count-modes nil)
- (doom-modeline-buffer-encoding nil) ;; Hide encoding for speed
- (doom-modeline-indent-info nil) ;; Hide indent info for speed
- (doom-modeline-checker-simple-format t) ;; Simpler checker format for speed
- (doom-modeline-number-limit 99) ;; Lower number limit for better performance
- (doom-modeline-vcs-max-length 12) ;; Limit VCS info length for speed
- (doom-modeline-persp-name nil) ;; Disable perspective name for speed
- (doom-modeline-display-default-persp-name nil)
- (doom-modeline-persp-icon nil)
- (doom-modeline-lsp nil) ;; Disable LSP info for speed
-
- ;; UI Preferences
- (doom-modeline-height 25)
- (doom-modeline-bar-width 3)
- (doom-modeline-window-width-limit 0.25)
- (doom-modeline-project-detection 'projectile) ;; Use projectile if available, nil is faster
-
- ;; Use nerd-icons instead of all-the-icons
- (doom-modeline-icon-preference 'nerd-icons)
-
- ;; Enable elements you specifically requested
- (doom-modeline-column-number t) ;; Show column number
- (doom-modeline-percent-position t) ;; Show percentage position
- (doom-modeline-buffer-name t) ;; Show buffer name
- (doom-modeline-buffer-file-name t) ;; Show file name
- :config
- (setq read-process-output-max (* 1024 1024)) ;; 1MB process read size for better performance
- (setq doom-modeline-refresh-rate 0.75)) ;; Update rate in seconds
+;; Use buffer status colors from user-constants
+(require 'user-constants)
+
+;; -------------------------- Modeline Configuration --------------------------
+
+;; Use Emacs 30's built-in right-alignment
+;; Use 'window instead of 'right-margin so centered text modes (nov-mode, etc.)
+;; don't push modeline elements inward
+(setq mode-line-right-align-edge 'window)
+
+;; String truncation length for narrow windows
+(defcustom cj/modeline-string-truncate-length 12
+ "String length after which truncation happens in narrow windows."
+ :type 'natnum
+ :group 'modeline)
+
+;; -------------------------- Helper Functions ---------------------------------
+
+(defun cj/modeline-window-narrow-p ()
+ "Return non-nil if window is narrow (less than 100 chars wide)."
+ (< (window-total-width) 100))
+
+(defun cj/modeline-string-truncate-p (str)
+ "Return non-nil if STR should be truncated."
+ (and (stringp str)
+ (not (string-empty-p str))
+ (cj/modeline-window-narrow-p)
+ (> (length str) cj/modeline-string-truncate-length)
+ (not (one-window-p :no-minibuffer))))
+
+(defun cj/modeline-string-cut-middle (str)
+ "Truncate STR in the middle if appropriate, else return STR.
+Example: `my-very-long-name.el' → `my-ver...me.el'"
+ (if (cj/modeline-string-truncate-p str)
+ (let ((half (floor cj/modeline-string-truncate-length 2)))
+ (concat (substring str 0 half) "..." (substring str (- half))))
+ str))
+
+;; -------------------------- Modeline Segments --------------------------------
+
+(defvar-local cj/modeline-buffer-name
+ '(:eval (let* ((state (cond
+ (buffer-read-only 'read-only)
+ (overwrite-mode 'overwrite)
+ ((buffer-modified-p) 'modified)
+ (t 'unmodified)))
+ (color (alist-get state cj/buffer-status-colors))
+ (name (buffer-name))
+ (truncated-name (cj/modeline-string-cut-middle name)))
+ (propertize truncated-name
+ 'face `(:foreground ,color)
+ 'mouse-face 'mode-line-highlight
+ 'help-echo (concat
+ name "\n"
+ (or (buffer-file-name)
+ (format "No file. Directory: %s" default-directory)))
+ 'local-map (let ((map (make-sparse-keymap)))
+ (define-key map [mode-line mouse-1] 'previous-buffer)
+ (define-key map [mode-line mouse-3] 'next-buffer)
+ map))))
+ "Buffer name colored by modification and read-only status.
+White = unmodified, Green = modified, Red = read-only, Gold = overwrite.
+Truncates in narrow windows. Click to switch buffers.")
+
+(defvar-local cj/modeline-position
+ '("L:" (:eval (format-mode-line "%l")) " C:" (:eval (format-mode-line "%c")))
+ "Line and column position as L:line C:col.
+Uses built-in cached values for performance.")
+
+(defvar cj/modeline-vc-faces
+ '((added . vc-locally-added-state)
+ (edited . vc-edited-state)
+ (removed . vc-removed-state)
+ (missing . vc-missing-state)
+ (conflict . vc-conflict-state)
+ (locked . vc-locked-state)
+ (up-to-date . vc-up-to-date-state))
+ "VC state to face mapping.")
+
+(defvar-local cj/modeline-vc-branch
+ '(:eval (when (mode-line-window-selected-p) ; Only show in active window
+ (when-let* ((file (or buffer-file-name default-directory))
+ (backend (vc-backend file)))
+ (when-let* ((branch (vc-working-revision file backend)))
+ ;; For Git, try to get symbolic branch name
+ (when (eq backend 'Git)
+ (require 'vc-git)
+ (when-let* ((symbolic (vc-git--symbolic-ref file)))
+ (setq branch symbolic)))
+ ;; Get VC state for face
+ (let* ((state (vc-state file backend))
+ (face (alist-get state cj/modeline-vc-faces 'vc-up-to-date-state))
+ (truncated-branch (cj/modeline-string-cut-middle branch)))
+ (concat
+ (propertize (char-to-string #xE0A0) 'face 'shadow) ; Git branch symbol
+ " "
+ (propertize truncated-branch
+ 'face face
+ 'mouse-face 'mode-line-highlight
+ 'help-echo (format "Branch: %s\nState: %s\nmouse-1: vc-diff\nmouse-3: vc-root-diff" branch state)
+ 'local-map (let ((map (make-sparse-keymap)))
+ (define-key map [mode-line mouse-1] 'vc-diff)
+ (define-key map [mode-line mouse-3] 'vc-root-diff)
+ map))))))))
+ "Git branch with symbol and colored by VC state.
+Shows only in active window. Truncates in narrow windows.
+Click to show diffs with `vc-diff' or `vc-root-diff'.")
+
+(defvar-local cj/modeline-major-mode
+ '(:eval (let ((mode-str (format-mode-line mode-name)) ; Convert to string
+ (mode-sym major-mode))
+ (propertize mode-str
+ 'mouse-face 'mode-line-highlight
+ 'help-echo (if-let* ((parent (get mode-sym 'derived-mode-parent)))
+ (format "Major mode: %s\nDerived from: %s\nmouse-1: describe-mode" mode-sym parent)
+ (format "Major mode: %s\nmouse-1: describe-mode" mode-sym))
+ 'local-map (let ((map (make-sparse-keymap)))
+ (define-key map [mode-line mouse-1] 'describe-mode)
+ map))))
+ "Major mode name only (no minor modes).
+Click to show help with `describe-mode'.")
+
+(defvar-local cj/modeline-misc-info
+ '(:eval (when (mode-line-window-selected-p)
+ mode-line-misc-info))
+ "Misc info (chime notifications, etc).
+Shows only in active window.")
+
+;; -------------------------- Modeline Assembly --------------------------------
+
+(setq-default mode-line-format
+ '("%e" ; Error message if out of memory
+ ;; LEFT SIDE
+ " "
+ cj/modeline-major-mode
+ " "
+ cj/modeline-buffer-name
+ " "
+ cj/modeline-position
+ ;; RIGHT SIDE (using Emacs 30 built-in right-align)
+ ;; Order: leftmost to rightmost as they appear in the list
+ mode-line-format-right-align
+ (:eval (when (fboundp 'cj/recording-modeline-indicator)
+ (cj/recording-modeline-indicator)))
+ cj/modeline-vc-branch
+ " "
+ cj/modeline-misc-info
+ " "))
+;; Mark all segments as risky-local-variable (required for :eval forms)
+(dolist (construct '(cj/modeline-buffer-name
+ cj/modeline-position
+ cj/modeline-vc-branch
+ cj/modeline-vc-faces
+ cj/modeline-major-mode
+ cj/modeline-misc-info))
+ (put construct 'risky-local-variable t))
(provide 'modeline-config)
;;; modeline-config.el ends here
diff --git a/modules/mousetrap-mode.el b/modules/mousetrap-mode.el
index fa9ee6dd..7ee91d3b 100644
--- a/modules/mousetrap-mode.el
+++ b/modules/mousetrap-mode.el
@@ -2,65 +2,211 @@
;;
;;; Commentary:
;; Mouse Trap Mode is a minor mode for Emacs that disables most mouse and
-;; trackpad events to prevent accidental text modifications. Hitting the trackpad and
-;; finding my text is being inserted in an unintended place is quite annoying,
-;; especially when you're overcaffeinated.
+;; trackpad events to prevent accidental text modifications. Hitting the
+;; trackpad and finding my text is being inserted in an unintended place is
+;; quite annoying, especially when you're overcaffeinated.
;;
-;; The mode unbinds almost every mouse event, including clicks, drags, and wheel
-;; movements, with various modifiers like Control, Meta, and Shift.
+;; The mode uses a profile-based architecture to selectively enable/disable
+;; mouse events based on the current major mode. Profiles define which
+;; event categories are allowed (scrolling, clicks, drags, etc.), and modes
+;; are mapped to profiles.
+;;
+;; The keymap is built dynamically when the mode is toggled, so you can
+;; change profiles or mode mappings and re-enable the mode without reloading
+;; your Emacs configuration.
;;
;; Inspired by this blog post from Malabarba
;; https://endlessparentheses.com/disable-mouse-only-inside-emacs.html
;;
;;; Code:
+(require 'cl-lib)
+
;; ------------------------------ Mouse Trap Mode ------------------------------
-(defvar mouse-trap-mode-map
- (let* ((prefixes '("" "C-" "M-" "S-" "C-M-" "C-S-" "M-S-" "C-M-S-")) ; modifiers
- (buttons (number-sequence 1 5)) ; mouse-1..5
- (types '("mouse" "down-mouse" "drag-mouse"
- "double-mouse" "triple-mouse"))
- (wheel '("wheel-up" "wheel-down" "wheel-left" "wheel-right"))
- (map (make-sparse-keymap)))
- ;; clicks, drags, double, triple
- (dolist (type types)
- (dolist (pref prefixes)
- (dolist (n buttons)
- (define-key map (kbd (format "<%s%s-%d>" pref type n)) #'ignore))))
- ;; wheel
- (dolist (evt wheel)
- (dolist (pref prefixes)
- (define-key map (kbd (format "<%s%s>" pref evt)) #'ignore)))
- map)
- "Keymap for `mouse-trap-mode'. Unbinds almost every mouse event.
-
-Disabling mouse prevents accidental mouse moves modifying text.")
+;;; Event Categories
+
+(defvar mouse-trap--event-categories
+ '((primary-click . ((types . ("mouse" "down-mouse"))
+ (buttons . (1))))
+ (secondary-click . ((types . ("mouse" "down-mouse"))
+ (buttons . (2 3))))
+ (drags . ((types . ("drag-mouse"))
+ (buttons . (1 2 3 4 5))))
+ (multi-clicks . ((types . ("double-mouse" "triple-mouse"))
+ (buttons . (1 2 3 4 5))))
+ (scroll . ((wheel . ("wheel-up" "wheel-down" "wheel-left" "wheel-right")))))
+ "Event category definitions for mouse-trap-mode.
+
+Each category maps to a set of event types and buttons (or wheel events).
+Categories can be combined in profiles to allow specific interaction patterns.")
+
+;;; Profiles
+
+(defvar mouse-trap-profiles
+ '((disabled . ())
+ (scroll-only . (scroll))
+ (primary-click . (primary-click))
+ (scroll+primary . (scroll primary-click))
+ (read-only . (scroll primary-click secondary-click))
+ (interactive . (scroll primary-click secondary-click drags))
+ (full . (scroll primary-click secondary-click drags multi-clicks)))
+ "Mouse interaction profiles for different use cases.
+
+Each profile specifies which event categories are allowed.
+Available categories: primary-click, secondary-click, drags, multi-clicks, scroll.
+
+Profiles:
+ - disabled: Block all mouse events
+ - scroll-only: Only allow scrolling
+ - primary-click: Only allow left click
+ - scroll+primary: Allow scrolling and left click
+ - read-only: Scrolling and clicking for reading/browsing
+ - interactive: Add dragging for text selection
+ - full: Allow all mouse events")
+
+(defvar mouse-trap-mode-profiles
+ '((dashboard-mode . scroll+primary)
+ (pdf-view-mode . full)
+ (nov-mode . full))
+ "Map major modes to mouse-trap profiles.
+
+Modes not listed here will use `mouse-trap-default-profile'.
+When checking, the mode hierarchy is respected via `derived-mode-p'.")
+
+(defvar mouse-trap-default-profile 'disabled
+ "Default profile to use when current major mode is not in `mouse-trap-mode-profiles'.")
+
+;;; Keymap Builder
+
+(defun mouse-trap--get-profile-for-mode ()
+ "Return the profile for the current major mode.
+
+Checks `mouse-trap-mode-profiles' for an exact match with `major-mode',
+then checks parent modes via `derived-mode-p'. Falls back to
+`mouse-trap-default-profile' if no match."
+ ;; First check for exact match with current major-mode
+ (or (alist-get major-mode mouse-trap-mode-profiles)
+ ;; Then check parent modes
+ (cl-loop for (mode . profile) in mouse-trap-mode-profiles
+ when (and (not (eq mode major-mode))
+ (derived-mode-p mode))
+ return profile)
+ ;; Finally use default
+ mouse-trap-default-profile))
+
+(defun mouse-trap--build-keymap ()
+ "Build a keymap based on current major mode's profile.
+
+Returns a keymap that binds mouse events to `ignore' for all events
+NOT allowed by the current profile. This function is called each time
+the mode is toggled, allowing dynamic behavior without reloading config."
+ (let* ((profile-name (mouse-trap--get-profile-for-mode))
+ (allowed-categories (alist-get profile-name mouse-trap-profiles))
+ (prefixes '("" "C-" "M-" "S-" "C-M-" "C-S-" "M-S-" "C-M-S-"))
+ (map (make-sparse-keymap)))
+
+ ;; For each event category, disable it if not in allowed list
+ (dolist (category-entry mouse-trap--event-categories)
+ (let ((category (car category-entry))
+ (spec (cdr category-entry)))
+ (unless (memq category allowed-categories)
+ ;; This category is NOT allowed - bind its events to ignore
+ (cond
+ ;; Scroll events (wheel)
+ ((alist-get 'wheel spec)
+ (dolist (evt (alist-get 'wheel spec))
+ (dolist (pref prefixes)
+ (define-key map (kbd (format "<%s%s>" pref evt)) #'ignore))))
+
+ ;; Click/drag events (types + buttons)
+ ((and (alist-get 'types spec) (alist-get 'buttons spec))
+ (dolist (type (alist-get 'types spec))
+ (dolist (button (alist-get 'buttons spec))
+ (dolist (pref prefixes)
+ (define-key map (kbd (format "<%s%s-%d>" pref type button)) #'ignore)))))))))
+ map))
+
+;;; Minor Mode Definition
+
+(defvar-local mouse-trap-mode-map nil
+ "Keymap for `mouse-trap-mode'. Built dynamically per buffer.")
+
+(defvar mouse-trap--lighter-keymap
+ (let ((map (make-sparse-keymap)))
+ (define-key map [mode-line mouse-1]
+ (lambda (event)
+ (interactive "e")
+ (with-selected-window (posn-window (event-start event))
+ (mouse-trap-mode (if mouse-trap-mode -1 1)))))
+ map)
+ "Keymap for the mouse-trap-mode lighter.
+Allows clicking the lighter to toggle the mode.")
+
+(defun mouse-trap--lighter-string ()
+ "Generate the mode-line lighter string for mouse-trap-mode.
+Returns a propertized string that shows 🪤 when mode is on, 🐭 when off.
+The string is clickable to toggle the mode."
+ (propertize (if mouse-trap-mode " 🪤" " 🐭")
+ 'mouse-face 'mode-line-highlight
+ 'help-echo "mouse-1: Toggle mousetrap mode"
+ 'local-map mouse-trap--lighter-keymap))
(define-minor-mode mouse-trap-mode
- "Buffer-locally disable most mouse and trackpad events.
+ "Buffer-locally disable mouse and trackpad events based on major mode.
+
+Mouse-trap-mode uses a profile-based system to selectively enable or
+disable mouse events. Each major mode can be mapped to a profile, and
+profiles define which event categories are allowed.
+
+Available event categories:
+ - primary-click: Left mouse button
+ - secondary-click: Middle and right mouse buttons
+ - drags: Drag selections
+ - multi-clicks: Double and triple clicks
+ - scroll: Mouse wheel / trackpad scrolling
-When active, <mouse-*>, <down-mouse-*>, <drag-mouse-*>,
-<double-mouse-*>, <triple-mouse-*>, and wheel events are bound to `ignore',
-with or without C-, M-, S- modifiers."
- :lighter " 🐭"
- :keymap mouse-trap-mode-map
- :group 'convenience)
+The keymap is built dynamically when the mode is toggled, so you can
+change `mouse-trap-mode-profiles' or `mouse-trap-profiles' and re-enable
+the mode without reloading your configuration.
+
+See `mouse-trap-profiles' for available profiles and
+`mouse-trap-mode-profiles' for mode mappings."
+ :lighter nil ; We use mode-line-misc-info instead
+ :group 'convenience
+ ;; Build keymap dynamically when mode is activated
+ (if mouse-trap-mode
+ (progn
+ (setq mouse-trap-mode-map (mouse-trap--build-keymap))
+ ;; Add dynamic lighter to mode-line-misc-info (always visible)
+ (unless (member '(:eval (mouse-trap--lighter-string)) mode-line-misc-info)
+ (push '(:eval (mouse-trap--lighter-string)) mode-line-misc-info)))
+ ;; When disabling, clear the keymap
+ (setq mouse-trap-mode-map nil)
+ ;; Note: We keep the lighter in mode-line-misc-info so it shows 🐭 when disabled
+ ))
(defvar mouse-trap-excluded-modes
- '(nov-mode pdf-view-mode dashboard-mode image-mode eww-mode Info-mode dired-mode)
- "Major modes where `mouse-trap-mode' should not be enabled.")
+ '(image-mode eww-mode Info-mode dired-mode)
+ "Major modes where `mouse-trap-mode' should not be auto-enabled.
+
+These modes are excluded from automatic activation via hooks, but you
+can still manually enable mouse-trap-mode in these buffers if desired.")
(defun mouse-trap-maybe-enable ()
"Enable `mouse-trap-mode' unless in an excluded mode."
(unless (apply #'derived-mode-p mouse-trap-excluded-modes)
(mouse-trap-mode 1)))
-;; Enable in text and prog modes
+;; Enable in text, prog, and special modes
(add-hook 'text-mode-hook #'mouse-trap-maybe-enable)
(add-hook 'prog-mode-hook #'mouse-trap-maybe-enable)
+(add-hook 'special-mode-hook #'mouse-trap-maybe-enable)
(keymap-global-set "C-c M" #'mouse-trap-mode)
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements "C-c M" "mouse trap mode"))
+
(provide 'mousetrap-mode)
;;; mousetrap-mode.el ends here.
diff --git a/modules/music-config.el b/modules/music-config.el
index 90feb7eb..f60ff36a 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -1,8 +1,8 @@
-;;; music-config.el --- EMMS configuration with MPD integration -*- coding: utf-8; lexical-binding: t; -*-
+;;; music-config.el --- EMMS configuration with MPV backend -*- coding: utf-8; lexical-binding: t; -*-
;;
;;; Commentary:
;;
-;; Comprehensive music management in Emacs via EMMS with MPD backend.
+;; Comprehensive music management in Emacs via EMMS with MPV backend.
;; Focus: simple, modular helpers; consistent error handling; streamlined UX.
;;
;; Highlights:
@@ -10,17 +10,18 @@
;; - Recursive directory add
;; - Dired/Dirvish integration (add selection)
;; - M3U playlist save/load/edit/reload
-;; - Radio station M3U creation
+;; - Radio station M3U creation (streaming URLs supported)
;; - Playlist window toggling
-;; - MPD as player
+;; - MPV as player (no daemon required)
;;
;;; Code:
(require 'subr-x)
+(require 'user-constants)
;;; Settings (no Customize)
-(defvar cj/music-root (expand-file-name "~/music")
+(defvar cj/music-root music-dir
"Root directory of your music collection.")
(defvar cj/music-m3u-root cj/music-root
@@ -44,14 +45,16 @@
(defun cj/music--valid-file-p (file)
"Return non-nil if FILE has an accepted music extension (case-insensitive)."
- (when-let ((ext (file-name-extension file)))
- (member (downcase ext) cj/music-file-extensions)))
+ (when (and file (stringp file))
+ (when-let ((ext (file-name-extension file)))
+ (member (downcase ext) cj/music-file-extensions))))
(defun cj/music--valid-directory-p (dir)
"Return non-nil if DIR is a non-hidden directory."
- (and (file-directory-p dir)
- (not (string-prefix-p "." (file-name-nondirectory
- (directory-file-name dir))))))
+ (when (and dir (stringp dir) (not (string-empty-p dir)))
+ (and (file-directory-p dir)
+ (not (string-prefix-p "." (file-name-nondirectory
+ (directory-file-name dir)))))))
(defun cj/music--collect-entries-recursive (root)
"Return sorted relative paths of all subdirs and music files under ROOT.
@@ -105,7 +108,7 @@ Directories are suffixed with /; files are plain. Hidden dirs/files skipped."
(let ((line (string-trim (match-string 0))))
(unless (string-empty-p line)
(push (if (or (file-name-absolute-p line)
- (string-match-p "\`\(https?\|mms\)://" line))
+ (string-match-p "\\`\\(https?\\|mms\\)://" line))
line
(expand-file-name line dir))
tracks))))
@@ -189,6 +192,64 @@ Directories (trailing /) are added recursively; files added singly."
;;; Commands: playlist management (load/save/clear/reload/edit)
+(defun cj/music--append-track-to-m3u-file (track-path m3u-file)
+ "Append TRACK-PATH to M3U-FILE. Signals error on failure.
+Pure function for testing - no user interaction.
+TRACK-PATH should be an absolute path.
+M3U-FILE should be an existing, writable M3U file path."
+ (unless (file-exists-p m3u-file)
+ (error "M3U file does not exist: %s" m3u-file))
+ (unless (file-writable-p m3u-file)
+ (error "M3U file is not writable: %s" m3u-file))
+
+ ;; Convert absolute path to relative path from music root
+ (let ((relative-path (if (file-name-absolute-p track-path)
+ (file-relative-name track-path cj/music-root)
+ track-path)))
+ ;; Determine if we need a leading newline
+ (let ((needs-prefix-newline nil)
+ (file-size (file-attribute-size (file-attributes m3u-file))))
+ (when (> file-size 0)
+ ;; Read the last character of the file to check if it ends with newline
+ (with-temp-buffer
+ (insert-file-contents m3u-file nil (max 0 (1- file-size)) file-size)
+ (setq needs-prefix-newline (not (= (char-after (point-min)) ?\n)))))
+
+ ;; Append the track with proper newline handling
+ (with-temp-buffer
+ (when needs-prefix-newline
+ (insert "\n"))
+ (insert relative-path "\n")
+ (write-region (point-min) (point-max) m3u-file t 0))))
+ t)
+
+
+(defun cj/music-append-track-to-playlist ()
+ "Append track at point to a selected M3U playlist file.
+Prompts for M3U file selection with completion. Allows cancellation."
+ (interactive)
+ (unless (derived-mode-p 'emms-playlist-mode)
+ (user-error "This command must be run in the EMMS playlist buffer"))
+ (let ((track (emms-playlist-track-at (point))))
+ (unless track
+ (user-error "No track at point"))
+ (let* ((track-path (emms-track-name track))
+ (m3u-files (cj/music--get-m3u-files)))
+ (when (null m3u-files)
+ (user-error "No M3U files found in %s" cj/music-m3u-root))
+ (let* ((choices (append (mapcar #'car m3u-files) '("(Cancel)")))
+ (choice (completing-read "Append track to playlist: " choices nil t)))
+ (if (string= choice "(Cancel)")
+ (message "Cancelled")
+ (let ((m3u-file (cdr (assoc choice m3u-files))))
+ (condition-case err
+ (progn
+ (cj/music--append-track-to-m3u-file track-path m3u-file)
+ (message "Added '%s' to %s"
+ (file-name-nondirectory track-path)
+ choice))
+ (error (message "Failed to append track: %s" (error-message-string err))))))))))
+
(defun cj/music-playlist-load ()
"Load an M3U playlist from cj/music-m3u-root.
@@ -346,9 +407,7 @@ Dirs added recursively."
((file-directory-p file) (cj/music-add-directory-recursive file))
((cj/music--valid-file-p file) (emms-add-file file))
(t (message "Skipping non-music file: %s" file))))
- (message "Added %d item(s) to playlist" (length files))))
-
- (keymap-set dirvish-mode-map "p" #'cj/music-add-dired-selection))
+ (message "Added %d item(s) to playlist" (length files)))))
;;; EMMS setup and keybindings
@@ -361,12 +420,25 @@ Dirs added recursively."
"r" #'cj/music-create-radio-station
"SPC" #'emms-pause
"s" #'emms-stop
- "p" #'emms-playlist-mode-go
+ "n" #'emms-next
+ "p" #'emms-previous
+ "g" #'emms-playlist-mode-go
"x" #'emms-shuffle)
(keymap-set cj/custom-keymap "m" cj/music-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; m" "music menu"))
+ (which-key-add-key-based-replacements
+ "C-; m" "music menu"
+ "C-; m m" "toggle playlist"
+ "C-; m M" "show playlist"
+ "C-; m a" "add music"
+ "C-; m r" "create radio"
+ "C-; m SPC" "pause"
+ "C-; m s" "stop"
+ "C-; m n" "next track"
+ "C-; m p" "previous track"
+ "C-; m g" "goto playlist"
+ "C-; m x" "shuffle"))
(use-package emms
:defer t
@@ -376,7 +448,7 @@ Dirs added recursively."
:commands (emms-mode-line-mode)
:config
(require 'emms-setup)
- (require 'emms-player-mpd)
+ (require 'emms-player-mpv)
(require 'emms-playlist-mode)
(require 'emms-source-file)
(require 'emms-source-playlist)
@@ -385,8 +457,8 @@ Dirs added recursively."
(setq emms-source-file-default-directory cj/music-root)
(setq emms-playlist-default-major-mode 'emms-playlist-mode)
- ;; Use only MPD as player - MUST be set before emms-all
- (setq emms-player-list '(emms-player-mpd))
+ ;; Use MPV as player - MUST be set before emms-all
+ (setq emms-player-list '(emms-player-mpv))
;; Now initialize EMMS
(emms-all)
@@ -395,22 +467,18 @@ Dirs added recursively."
(emms-playing-time-disable-display)
(emms-mode-line-mode -1)
- ;; MPD configuration
- (setq emms-player-mpd-server-name "localhost")
- (setq emms-player-mpd-server-port "6600")
- (setq emms-player-mpd-music-directory cj/music-root)
- (condition-case err
- (emms-player-mpd-connect)
- (error (message "Failed to connect to MPD: %s" err)))
-
- ;; note setopt as variable is customizeable
- ;; MPD can play both local files and stream URLs
- (setopt emms-player-mpd-supported-regexp
- (rx (or
- ;; Stream URLs
- (seq bos (or "http" "https" "mms") "://")
- ;; Local music files by extension
- (regexp (apply #'emms-player-simple-regexp cj/music-file-extensions)))))
+ ;; MPV configuration
+ ;; MPV supports both local files and stream URLs
+ (setq emms-player-mpv-parameters
+ '("--quiet" "--no-video" "--audio-display=no"))
+
+ ;; Update supported file types for mpv player
+ (setq emms-player-mpv-regexp
+ (rx (or
+ ;; Stream URLs
+ (seq bos (or "http" "https" "mms") "://")
+ ;; Local music files by extension
+ (seq "." (or "aac" "flac" "m4a" "mp3" "ogg" "opus" "wav") eos))))
;; Keep cj/music-playlist-file in sync if playlist is cleared
(defun cj/music--after-playlist-clear (&rest _)
@@ -428,10 +496,15 @@ Dirs added recursively."
("p" . emms-playlist-mode-go)
("SPC" . emms-pause)
("s" . emms-stop)
+ ("n" . emms-next)
+ ("P" . emms-previous)
+ ("f" . emms-seek-forward)
+ ("b" . emms-seek-backward)
("x" . emms-shuffle)
("q" . emms-playlist-mode-bury-buffer)
("a" . cj/music-fuzzy-select-and-add)
;; Manipulation
+ ("A" . cj/music-append-track-to-playlist)
("C" . cj/music-playlist-clear)
("L" . cj/music-playlist-load)
("E" . cj/music-playlist-edit)
@@ -442,7 +515,7 @@ Dirs added recursively."
("C-<down>" . emms-playlist-mode-shift-track-down)
;; Radio
("r" . cj/music-create-radio-station)
- ;; Volume (MPD)
+ ;; Volume (MPV)
("-" . emms-volume-lower)
("=" . emms-volume-raise)))
diff --git a/modules/org-agenda-config-debug.el b/modules/org-agenda-config-debug.el
new file mode 100644
index 00000000..a9c713a1
--- /dev/null
+++ b/modules/org-agenda-config-debug.el
@@ -0,0 +1,63 @@
+;;; org-agenda-config-debug.el --- Debug functions for org-agenda-config -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; This file contains debug functions for org-agenda-config.el.
+;; It is only loaded when cj/debug-modules includes 'org-agenda or is t.
+;;
+;; Enable with: (setq cj/debug-modules '(org-agenda))
+;; or: (setq cj/debug-modules t)
+;;
+;; Available debug functions:
+;; - cj/org-agenda-debug-dump-files - Show all org-agenda-files with status
+;; - cj/org-agenda-debug-rebuild-timing - Measure rebuild performance
+;;
+;;; Code:
+
+(require 'user-constants)
+(require 'system-lib)
+
+;; ---------------------------- Debug Functions --------------------------------
+
+;;;###autoload
+(defun cj/org-agenda-debug-dump-files ()
+ "Dump all org-agenda-files to *Messages* buffer with status.
+Shows which files exist, which are missing, and their sizes."
+ (interactive)
+ (cj/log-silently "=== Org Agenda Debug: Files ===")
+ (cj/log-silently "Total files: %d" (length org-agenda-files))
+ (cj/log-silently "")
+ (dolist (file org-agenda-files)
+ (if (file-exists-p file)
+ (let ((size (file-attribute-size (file-attributes file)))
+ (mtime (format-time-string "%Y-%m-%d %H:%M:%S"
+ (file-attribute-modification-time
+ (file-attributes file)))))
+ (cj/log-silently "✓ %s" file)
+ (cj/log-silently " Size: %d bytes, Modified: %s" size mtime))
+ (cj/log-silently "✗ %s [MISSING]" file)))
+ (cj/log-silently "")
+ (cj/log-silently "=== End Org Agenda Debug ===")
+ (message "Org Agenda: Dumped %d files to *Messages* buffer" (length org-agenda-files)))
+
+;;;###autoload
+(defun cj/org-agenda-debug-rebuild-timing ()
+ "Measure and report timing for rebuilding org-agenda-files.
+Runs cj/build-org-agenda-list and reports detailed timing."
+ (interactive)
+ (cj/log-silently "=== Org Agenda Debug: Rebuild Timing ===")
+ (let ((start-time (current-time)))
+ (cj/build-org-agenda-list)
+ (let ((elapsed (float-time (time-subtract (current-time) start-time))))
+ (cj/log-silently "Rebuild completed in %.3f seconds" elapsed)
+ (cj/log-silently "Files found: %d" (length org-agenda-files))
+ (cj/log-silently "Average time per file: %.4f seconds"
+ (if (> (length org-agenda-files) 0)
+ (/ elapsed (float (length org-agenda-files)))
+ 0.0))))
+ (cj/log-silently "=== End Org Agenda Debug ===")
+ (message "Org Agenda: Timing info dumped to *Messages* buffer"))
+
+(provide 'org-agenda-config-debug)
+;;; org-agenda-config-debug.el ends here
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el
index c7aac99b..4be4db9e 100644
--- a/modules/org-agenda-config.el
+++ b/modules/org-agenda-config.el
@@ -3,6 +3,14 @@
;;
;;; Commentary:
;;
+;; Performance:
+;; - Caches agenda file list to avoid scanning projects directory on every view
+;; - Cache builds asynchronously 10 seconds after Emacs startup (non-blocking)
+;; - First agenda view uses cache if ready, otherwise builds synchronously
+;; - Subsequent views are instant (cached)
+;; - Cache auto-refreshes after 1 hour
+;; - Manual refresh: M-x cj/org-agenda-refresh-files (e.g., after adding projects)
+;;
;; Agenda views are tied to the F8 (fate) key.
;;
;; "We are what we repeatedly do.
@@ -31,10 +39,20 @@
;;; Code:
(require 'user-constants)
+(require 'system-lib)
+
+;; Load debug functions if enabled
+(when (or (eq cj/debug-modules t)
+ (memq 'org-agenda cj/debug-modules))
+ (require 'org-agenda-config-debug
+ (expand-file-name "org-agenda-config-debug.el"
+ (file-name-directory load-file-name))
+ t))
(use-package org-agenda
:ensure nil ;; built-in
:after (org)
+ :demand t
:config
(setq org-agenda-prefix-format '((agenda . " %i %-25:c%?-12t% s")
(timeline . " % s")
@@ -57,10 +75,24 @@
(add-hook 'org-agenda-mode-hook (lambda ()
(local-set-key (kbd "s-<right>") #'org-agenda-todo-nextset)
(local-set-key (kbd "s-<left>")
- #'org-agenda-todo-previousset)))
+ #'org-agenda-todo-previousset))))
+
+;; ------------------------ Org Agenda File List Cache -------------------------
+;; Cache agenda file list to avoid expensive directory scanning on every view
+
+(defvar cj/org-agenda-files-cache nil
+ "Cached agenda files list to avoid expensive directory scanning.
+Set to nil to invalidate cache.")
+
+(defvar cj/org-agenda-files-cache-time nil
+ "Time when agenda files cache was last built.")
+
+(defvar cj/org-agenda-files-cache-ttl 3600
+ "Time-to-live for agenda files cache in seconds (default: 1 hour).")
- ;; build org-agenda-list for the first time after emacs init completes.
- (add-hook 'emacs-startup-hook #'cj/build-org-agenda-list))
+(defvar cj/org-agenda-files-building nil
+ "Non-nil when agenda files are being built asynchronously.
+Prevents duplicate builds if user opens agenda before async build completes.")
;; ------------------------ Add Files To Org Agenda List -----------------------
;; finds files named 'todo.org' (case insensitive) and adds them to
@@ -68,7 +100,6 @@
(defun cj/add-files-to-org-agenda-files-list (directory)
"Search for files named \\='todo.org\\=' add them to org-project-files.
-
DIRECTORY is a string of the path to begin the search."
(interactive "D")
(setq org-agenda-files
@@ -77,35 +108,74 @@ DIRECTORY is a string of the path to begin the search."
org-agenda-files)))
;; ---------------------------- Rebuild Org Agenda ---------------------------
-;; builds the org agenda list from all agenda targets.
+;; builds the org agenda list from all agenda targets with caching.
;; agenda targets is the schedule, contacts, project todos,
;; inbox, and org roam projects.
-(defun cj/build-org-agenda-list ()
- "Rebuilds the org agenda list without checking org-roam for projects.
-
-Begins with the inbox-file, schedule-file, and contacts-file.
-Then adds all todo.org files from projects-dir and code-dir.
-Reports elapsed time in the messages buffer."
+(defun cj/build-org-agenda-list (&optional force-rebuild)
+ "Build org-agenda-files list with caching.
+
+When FORCE-REBUILD is non-nil, bypass cache and rebuild from scratch.
+Otherwise, returns cached list if available and not expired.
+
+This function scans projects-dir for todo.org files, so caching
+improves performance from several seconds to instant."
+ (interactive "P")
+ ;; Check if we can use cache
+ (let ((cache-valid (and cj/org-agenda-files-cache
+ cj/org-agenda-files-cache-time
+ (not force-rebuild)
+ (< (- (float-time) cj/org-agenda-files-cache-time)
+ cj/org-agenda-files-cache-ttl))))
+ (if cache-valid
+ ;; Use cached file list (instant)
+ (progn
+ (setq org-agenda-files cj/org-agenda-files-cache)
+ ;; Always show cache-hit message (interactive or background)
+ (cj/log-silently "Using cached agenda files (%d files)"
+ (length org-agenda-files)))
+ ;; Check if async build is in progress
+ (when cj/org-agenda-files-building
+ (cj/log-silently "Waiting for background agenda build to complete..."))
+ ;; Rebuild from scratch (slow - scans projects directory)
+ (unwind-protect
+ (progn
+ (setq cj/org-agenda-files-building t)
+ (let ((start-time (current-time)))
+ ;; Reset org-agenda-files to base files
+ (setq org-agenda-files (list inbox-file schedule-file gcal-file pcal-file))
+
+ ;; Check all projects for scheduled tasks
+ (cj/add-files-to-org-agenda-files-list projects-dir)
+
+ ;; Update cache
+ (setq cj/org-agenda-files-cache org-agenda-files)
+ (setq cj/org-agenda-files-cache-time (float-time))
+
+ ;; Always show completion message (interactive or background)
+ (cj/log-silently "Built agenda files: %d files in %.3f sec"
+ (length org-agenda-files)
+ (- (float-time) (float-time start-time)))))
+ ;; Always clear the building flag, even if build fails
+ (setq cj/org-agenda-files-building nil)))))
+
+;; Build cache asynchronously after startup to avoid blocking Emacs
+(run-with-idle-timer
+ 10 ; Wait 10 seconds after Emacs is idle
+ nil ; Don't repeat
+ (lambda ()
+ (cj/log-silently "Building org-agenda files cache in background...")
+ (cj/build-org-agenda-list)))
+
+(defun cj/org-agenda-refresh-files ()
+ "Force rebuild of agenda files cache.
+
+Use this after adding new projects or todo.org files.
+Bypasses cache and scans directories from scratch."
(interactive)
- (let ((start-time (current-time)))
- ;; reset org-agenda-files to inbox, schedule, and gcal
- (setq org-agenda-files (list inbox-file schedule-file gcal-file))
-
- ;; check all projects for scheduled tasks
- (cj/add-files-to-org-agenda-files-list projects-dir)
-
- (message "Rebuilt org-agenda-files in %.3f sec"
- (float-time (time-subtract (current-time) start-time)))))
-
-;; Run the above once after Emacs startup when idle for 1 second
-;; makes regenerating the list much faster
-(add-hook 'emacs-startup-hook
- (lambda ()
- (run-with-idle-timer 1 nil #'cj/build-org-agenda-list)))
+ (cj/build-org-agenda-list 'force-rebuild))
(defun cj/todo-list-all-agenda-files ()
"Displays an \\='org-agenda\\=' todo list.
-
The contents of the agenda will be built from org-project-files and org-roam
files that have project in their filetag."
(interactive)
@@ -118,7 +188,6 @@ files that have project in their filetag."
(defun cj/todo-list-from-this-buffer ()
"Displays an \\='org-agenda\\=' todo list built from the current buffer.
-
If the current buffer isn't an org buffer, inform the user."
(interactive)
(if (eq major-mode 'org-mode)
@@ -153,7 +222,6 @@ If the current buffer isn't an org buffer, inform the user."
(defun cj/org-agenda-skip-subtree-if-not-overdue ()
"Skip an agenda subtree if it is not an overdue deadline or scheduled task.
-
An entry is considered overdue if it has a scheduled or deadline date strictly
before today, is not marked as done, and is not a habit."
(let* ((subtree-end (save-excursion (org-end-of-subtree t)))
@@ -176,7 +244,6 @@ before today, is not marked as done, and is not a habit."
(defun cj/org-skip-subtree-if-priority (priority)
"Skip an agenda subtree if it has a priority of PRIORITY.
-
PRIORITY may be one of the characters ?A, ?B, or ?C."
(let ((subtree-end (save-excursion (org-end-of-subtree t)))
(pri-value (* 1000 (- org-lowest-priority priority)))
@@ -187,7 +254,6 @@ PRIORITY may be one of the characters ?A, ?B, or ?C."
(defun cj/org-skip-subtree-if-keyword (keywords)
"Skip an agenda subtree if it has a TODO keyword in KEYWORDS.
-
KEYWORDS must be a list of strings."
(let ((subtree-end (save-excursion (org-end-of-subtree t))))
(if (member (org-get-todo-state) keywords)
@@ -224,12 +290,10 @@ KEYWORDS must be a list of strings."
(defun cj/main-agenda-display ()
"Display the main daily org-agenda view.
-
This uses all org-agenda targets and presents three sections:
- All unfinished priority A tasks
- Today's schedule, including habits with consistency graphs
- All priority B and C unscheduled/undeadlined tasks
-
The agenda is rebuilt from all sources before display, including:
- inbox-file and schedule-file
- Org-roam nodes tagged as \"Project\"
@@ -244,7 +308,6 @@ The agenda is rebuilt from all sources before display, including:
(defun cj/add-timestamp-to-org-entry (s)
"Add an event with time S to appear underneath the line-at-point.
-
This allows a line to show in an agenda without being scheduled or a deadline."
(interactive "sTime: ")
(defvar cj/timeformat "%Y-%m-%d %a")
@@ -253,7 +316,6 @@ This allows a line to show in an agenda without being scheduled or a deadline."
(open-line 1)
(forward-line 1)
(insert (concat "<" (format-time-string cj/timeformat (current-time)) " " s ">" ))))
-;;(global-set-key (kbd "M-t") #'cj/add-timestamp-to-org-entry)
;; --------------------------- Notifications / Alerts --------------------------
;; send libnotify notifications for agenda items
@@ -265,39 +327,61 @@ This allows a line to show in an agenda without being scheduled or a deadline."
;; Install CHIME from GitHub using use-package :vc (Emacs 29+)
(use-package chime
- :vc (:url "https://github.com/cjennings/chime.el" :rev :newest)
- :after (alert org-agenda)
:demand t
+ ;; :vc (:url "https://github.com/cjennings/chime.el" :rev :newest) ;; using latest on github
+ :after alert ; Removed org-agenda - chime.el requires it internally
+ :ensure nil ;; using local version
+ :load-path "~/code/chime.el"
+ :init
+ ;; Initialize org-agenda-files with base files before chime loads
+ ;; The full list will be built asynchronously later
+ (setq org-agenda-files (list inbox-file schedule-file gcal-file pcal-file))
+
+ ;; Debug mode (keep set to nil, but available for troubleshooting)
+ (setq chime-debug nil)
:bind
("C-c A" . chime-check)
:config
- ;; Notification times: 5 minutes before and at event time (0 minutes)
- ;; This gives two notifications per event without any after-event notifications
- (setq chime-alert-time '(5 0))
+ ;; Polling interval: check every minute
+ (setq chime-check-interval 60)
- ;; Modeline display: show upcoming events within 60 minutes
- (setq chime-modeline-lookahead 120)
- (setq chime-modeline-format " ⏰ %s")
+ ;; Alert intervals: 5 minutes before and at event time
+ ;; All notifications use medium urgency
+ (setq chime-alert-intervals '((5 . medium) (0 . medium)))
- ;; Chime sound: plays when notifications appear
- (setq chime-play-sound t)
- ;; Uses bundled chime.wav by default
+ ;; Day-wide events: notify at 9 AM for birthdays/all-day events
+ (setq chime-day-wide-time "09:00")
- ;; Notification settings
- (setq chime-notification-title "Reminder")
- (setq chime-alert-severity 'medium)
+ ;; Modeline display: show upcoming events within 6 hours
+ (setq chime-modeline-lookahead-minutes (* 6 60))
+
+ ;; Tooltip settings: show up to 10 upcoming events within 6 days
+ (setq chime-modeline-tooltip-max-events 20)
+ (setq chime-tooltip-lookahead-hours (* 7 24))
+
+ ;; Modeline content: show title and countdown only (omit event time)
+ (setq chime-notification-text-format "%t %u")
- ;; Don't filter by TODO keywords - notify for all events with timestamps
- (setq chime-keyword-whitelist nil)
- (setq chime-keyword-blacklist nil)
+ ;; Time-until format: compact style like " in 10m" or " in 1h 37m"
+ (setq chime-time-left-format-short " in %mm ") ; Under 1 hour: " in 10m"
+ (setq chime-time-left-format-long " in %hh %mm ") ; 1 hour+: " in 1h 37m"
+ (setq chime-time-left-format-at-event "now")
+
+ ;; Title truncation: limit long event titles to 25 characters
+ (setq chime-max-title-length 25)
+
+ ;; Notification title
+ (setq chime-notification-title "Reminder")
- ;; Only notify for non-done items (default behavior)
- (setq chime-predicate-blacklist
- '(chime-done-keywords-predicate))
+ ;; Calendar URL
+ (setq chime-calendar-url "https://calendar.google.com/calendar/u/0/r")
- ;; Enable chime-mode automatically
+ ;; Enable chime-mode
(chime-mode 1))
+;; which-key labels
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements "C-c A" "chime check"))
(provide 'org-agenda-config)
;;; org-agenda-config.el ends here
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el
index f41d0228..5d569002 100644
--- a/modules/org-capture-config.el
+++ b/modules/org-capture-config.el
@@ -84,10 +84,6 @@ Intended to be called within an org capture template."
'(("t" "Task" entry (file+headline inbox-file "Inbox")
"* TODO %?" :prepend t)
- ("a" "Appointment" entry (file gcal-file)
- "* %?\n:PROPERTIES:\n:calendar-id:craigmartinjennings@gmail.com\n:END:\n:org-gcal:\n%^T--%^T\n:END:\n\n"
- :jump-to-captured t)
-
("e" "Event" entry (file+headline schedule-file "Scheduled Events")
"* %?%:description
SCHEDULED: %^t%(cj/org-capture-event-content)
diff --git a/modules/org-config.el b/modules/org-config.el
index 0249973f..a4f98310 100644
--- a/modules/org-config.el
+++ b/modules/org-config.el
@@ -6,102 +6,6 @@
;;; Code:
-
-;; ---------------------------------- Org Mode ---------------------------------
-
-(use-package org
- :defer t
- :ensure nil ;; use the built-in package
- :pin manual ;; never upgrade from the version built-into Emacs
- :init
- (defvar-keymap cj/org-table-map
- :doc "org table operations.")
- (keymap-global-set "C-c t" cj/org-table-map)
- :bind
- ("C-c c" . org-capture)
- ("C-c a" . org-agenda)
- (:map org-mode-map
- ("C-c I" . org-table-field-info) ;; was C-c ?
- ("C-\\" . org-match-sparse-tree)
- ("C-c t" . org-set-tags-command)
- ("C-c l" . org-store-link)
- ("C-c C-l" . org-insert-link)
- ("s-<up>" . org-priority-up)
- ("s-<down>" . org-priority-down)
- ("C-c N" . org-narrow-to-subtree)
- ("C-c >" . cj/org-narrow-forward)
- ("C-c <" . cj/org-narrow-backwards)
- ("<f5>" . org-reveal)
- ("C-c <ESC>" . widen))
- (:map cj/org-table-map
- ("r i" . org-table-insert-row)
- ("r d" . org-table-kill-row)
- ("c i" . org-table-insert-column)
- ("c d" . org-table-delete-column))
-
- ;; backward and forward day are ',' and '.'
- ;; shift & meta moves by week or year
- ;; C-. jumps to today
- ;; original keybindings blocked by windmove keys
- ;; these are consistent with plain-old calendar mode
- (:map org-read-date-minibuffer-local-map
- ("," . (lambda () (interactive)
- (org-eval-in-calendar '(calendar-backward-day 1))))
- ("." . (lambda () (interactive)
- (org-eval-in-calendar '(calendar-forward-day 1))))
- ("<" . (lambda () (interactive)
- (org-eval-in-calendar '(calendar-backward-month 1))))
- (">" . (lambda () (interactive)
- (org-eval-in-calendar '(calendar-forward-month 1))))
- ("M-," . (lambda () (interactive)
- (org-eval-in-calendar '(calendar-backward-year 1))))
- ("M-." . (lambda () (interactive)
- (org-eval-in-calendar '(calendar-forward-year 1)))))
-
- :init
- ;; windmove's keybindings conflict with org-agenda-todo-nextset/previousset keybindings
- ;; solution: map the super key so that
- ;; - super up/down increases and decreases the priority
- ;; - super left/right changes the todo state
- (setq org-replace-disputed-keys t)
- (setq org-disputed-keys
- '(([(shift left)] . [(super left)])
- ([(shift right)] . [(super right)])
- ([(shift up)] . [(super up)])
- ([(shift down)] . [(super down)])
- ([(control shift right)] . [(meta shift +)])
- ([(control shift left)] . [(meta shift -)])))
-
- (defun cj/org-narrow-forward ()
- "Narrow to the next subtree at the same level."
- (interactive)
- (widen)
- (org-forward-heading-same-level 1)
- (org-narrow-to-subtree))
-
- (defun cj/org-narrow-backwards ()
- "Narrow to the previous subtree at the same level."
- (interactive)
- (widen)
- (org-backward-heading-same-level 1)
- (org-narrow-to-subtree))
-
- :hook
- (org-mode . turn-on-visual-line-mode)
- (org-mode . (lambda () (setq-local tab-width 8)))
-
- :config
- ;; Load org-protocol for org-protocol:// URL handling
- (require 'org-protocol nil t)
-
- ;; Set archive location (must be done after org loads)
- (setq org-archive-location
- (concat org-dir "/archives/archive.org::datetree/"))
-
- (cj/org-general-settings)
- (cj/org-appearance-settings)
- (cj/org-todo-settings))
-
;; ---------------------------- Org General Settings ---------------------------
(defun cj/org-general-settings ()
@@ -164,11 +68,11 @@
(set-face-attribute 'org-link nil :underline t)
(setq org-ellipsis " ▾") ;; change ellipses to down arrow
- (setq org-hide-emphasis-markers t) ;; remove emphasis markers to keep the screen clean
+ (setq org-hide-emphasis-markers t) ;; hide emphasis markers (org-appear shows them when editing)
(setq org-hide-leading-stars t) ;; hide leading stars, just show one per line
(setq org-pretty-entities t) ;; render special symbols
(setq org-pretty-entities-include-sub-superscripts nil) ;; ...except superscripts and subscripts
- (setq org-fontify-emphasized-text nil) ;; ...and don't render bold and italic markup
+ (setq org-fontify-emphasized-text t) ;; render bold and italic markup
(setq org-fontify-whole-heading-line t) ;; fontify the whole line for headings (for face-backgrounds)
(add-hook 'org-mode-hook 'prettify-symbols-mode))
@@ -213,6 +117,106 @@
;; inherit parents properties (sadly not schedules or deadlines)
(setq org-use-property-inheritance t))
+;; ---------------------------------- Org Mode ---------------------------------
+
+(use-package org
+ :defer t
+ :ensure nil ;; use the built-in package
+ :pin manual ;; never upgrade from the version built-into Emacs
+ :init
+ (defvar-keymap cj/org-table-map
+ :doc "org table operations.")
+ (keymap-set cj/custom-keymap "T" cj/org-table-map)
+
+ (defvar-keymap cj/org-map
+ :doc "General org-mode operations and utilities.")
+ (keymap-set cj/custom-keymap "O" cj/org-map)
+ :bind
+ ("C-c c" . org-capture)
+ ("C-c a" . org-agenda)
+ (:map org-mode-map
+ ("C-c I" . org-table-field-info) ;; was C-c ?
+ ("C-\\" . org-match-sparse-tree)
+ ("C-c t" . org-set-tags-command)
+ ("C-c l" . org-store-link)
+ ("C-c C-l" . org-insert-link)
+ ("s-<up>" . org-priority-up)
+ ("s-<down>" . org-priority-down)
+ ("C-c N" . org-narrow-to-subtree)
+ ("C-c >" . cj/org-narrow-forward)
+ ("C-c <" . cj/org-narrow-backwards)
+ ("<f5>" . org-reveal)
+ ("C-c <ESC>" . widen)
+ ("C-c C-a" . cj/org-appear-toggle))
+ (:map cj/org-table-map
+ ("r i" . org-table-insert-row)
+ ("r d" . org-table-kill-row)
+ ("c i" . org-table-insert-column)
+ ("c d" . org-table-delete-column))
+
+ ;; backward and forward day are ',' and '.'
+ ;; shift & meta moves by week or year
+ ;; C-. jumps to today
+ ;; original keybindings blocked by windmove keys
+ ;; these are consistent with plain-old calendar mode
+ (:map org-read-date-minibuffer-local-map
+ ("," . (lambda () (interactive)
+ (org-eval-in-calendar '(calendar-backward-day 1))))
+ ("." . (lambda () (interactive)
+ (org-eval-in-calendar '(calendar-forward-day 1))))
+ ("<" . (lambda () (interactive)
+ (org-eval-in-calendar '(calendar-backward-month 1))))
+ (">" . (lambda () (interactive)
+ (org-eval-in-calendar '(calendar-forward-month 1))))
+ ("M-," . (lambda () (interactive)
+ (org-eval-in-calendar '(calendar-backward-year 1))))
+ ("M-." . (lambda () (interactive)
+ (org-eval-in-calendar '(calendar-forward-year 1)))))
+
+ :init
+ ;; windmove's keybindings conflict with org-agenda-todo-nextset/previousset keybindings
+ ;; solution: map the super key so that
+ ;; - super up/down increases and decreases the priority
+ ;; - super left/right changes the todo state
+ (setq org-replace-disputed-keys t)
+ (setq org-disputed-keys
+ '(([(shift left)] . [(super left)])
+ ([(shift right)] . [(super right)])
+ ([(shift up)] . [(super up)])
+ ([(shift down)] . [(super down)])
+ ([(control shift right)] . [(meta shift +)])
+ ([(control shift left)] . [(meta shift -)])))
+
+ (defun cj/org-narrow-forward ()
+ "Narrow to the next subtree at the same level."
+ (interactive)
+ (widen)
+ (org-forward-heading-same-level 1)
+ (org-narrow-to-subtree))
+
+ (defun cj/org-narrow-backwards ()
+ "Narrow to the previous subtree at the same level."
+ (interactive)
+ (widen)
+ (org-backward-heading-same-level 1)
+ (org-narrow-to-subtree))
+
+ :hook
+ (org-mode . turn-on-visual-line-mode)
+ (org-mode . (lambda () (setq-local tab-width 8)))
+
+ :config
+ ;; Load org-protocol for org-protocol:// URL handling
+ (require 'org-protocol nil t)
+
+ ;; Set archive location (must be done after org loads)
+ (setq org-archive-location
+ (concat org-dir "/archives/archive.org::datetree/"))
+
+ (cj/org-general-settings)
+ (cj/org-appearance-settings)
+ (cj/org-todo-settings))
+
;; ------------------------------- Org Superstar -------------------------------
(use-package org-superstar
@@ -222,6 +226,29 @@
(org-superstar-configure-like-org-bullets)
(setq org-superstar-leading-bullet ?\s))
+;; -------------------------------- Org-Appear ---------------------------------
+
+(use-package org-appear
+ ;; Default: OFF (toggle with cj/org-appear-toggle)
+ ;; Useful for editing links, but can make tables hard to read when links expand
+ :custom
+ (org-appear-autoemphasis t) ;; Show * / _ when cursor is on them
+ (org-appear-autolinks t) ;; Also works for links
+ (org-appear-autosubmarkers t)) ;; And sub/superscripts
+
+(defun cj/org-appear-toggle ()
+ "Toggle org-appear-mode in the current org-mode buffer.
+When enabled, org-appear shows emphasis markers and link URLs only when
+point is on them. When disabled, they stay hidden (cleaner for reading,
+especially in tables with long URLs)."
+ (interactive)
+ (if (bound-and-true-p org-appear-mode)
+ (progn
+ (org-appear-mode -1)
+ (message "org-appear disabled (links/emphasis stay hidden)"))
+ (org-appear-mode 1)
+ (message "org-appear enabled (links/emphasis show when editing)")))
+
;; ------------------------------- Org-Checklist -------------------------------
;; needed for org-habits to reset checklists once task is complete
@@ -266,5 +293,50 @@ the current buffer's cache. Useful when encountering parsing errors like
(message "Cleared org-element cache for current buffer"))
(user-error "Current buffer is not in org-mode"))))
+;; Add to org keymap
+(keymap-set cj/org-map "c" #'cj/org-clear-element-cache)
+
+;; ----------------------- Org Multi-Level Sorting -----------------------------
+
+(defun cj/org-sort-by-todo-and-priority ()
+ "Sort org entries by TODO status (TODO before DONE) and priority (A to D).
+Sorts the current level's entries. Within each TODO state group, entries are
+sorted by priority. Uses stable sorting: sort by priority first, then by TODO
+status to preserve priority ordering within TODO groups."
+ (interactive)
+ (unless (derived-mode-p 'org-mode)
+ (user-error "Current buffer is not in org-mode"))
+ (save-excursion
+ ;; First sort by priority (A, B, C, D, then no priority)
+ ;; Ignore "Nothing to sort" errors for empty sections
+ (condition-case nil
+ (org-sort-entries nil ?p)
+ (user-error nil))
+ ;; Then sort by TODO status (TODO before DONE)
+ ;; This preserves the priority ordering within each TODO group
+ (condition-case nil
+ (org-sort-entries nil ?o)
+ (user-error nil)))
+ (message "Sorted entries by TODO status and priority"))
+
+;; which-key labels for org keymaps
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ ;; org general operations
+ "C-; O" "org menu"
+ "C-; O c" "clear element cache"
+ ;; org table operations
+ "C-; T" "org table menu"
+ "C-; T r" "table row"
+ "C-; T r i" "insert row"
+ "C-; T r d" "delete row"
+ "C-; T c" "table column"
+ "C-; T c i" "insert column"
+ "C-; T c d" "delete column"
+ ;; org global bindings
+ "C-c a" "org agenda"
+ "C-c c" "org capture"
+ "C-c l" "org store link"))
+
(provide 'org-config)
;;; org-config.el ends here
diff --git a/modules/org-contacts-config.el b/modules/org-contacts-config.el
index 706412a2..924b164c 100644
--- a/modules/org-contacts-config.el
+++ b/modules/org-contacts-config.el
@@ -20,68 +20,106 @@
;; Add a wrapper function that ensures proper context
(defun cj/org-contacts-anniversaries-safe ()
- "Safely call org-contacts-anniversaries with required bindings."
- (require 'diary-lib)
- ;; These need to be dynamically bound for diary functions
- (defvar date)
- (defvar entry)
- (defvar original-date)
- (let ((date (calendar-current-date))
- (entry "")
- (original-date (calendar-current-date)))
- (ignore-errors
- (org-contacts-anniversaries))))
+ "Safely call org-contacts-anniversaries with required bindings."
+ (require 'diary-lib)
+ ;; These need to be dynamically bound for diary functions
+ (defvar date)
+ (defvar entry)
+ (defvar original-date)
+ (let ((date (calendar-current-date))
+ (entry "")
+ (original-date (calendar-current-date)))
+ (ignore-errors
+ (org-contacts-anniversaries))))
;; Use the safe wrapper instead
(add-hook 'org-agenda-finalize-hook 'cj/org-contacts-anniversaries-safe))
;; ----------------------- Org-Contacts Capture Template -----------------------
+(defun cj/org-contacts-finalize-birthday-timestamp ()
+ "Add yearly repeating timestamp after properties drawer if BIRTHDAY is set."
+ (when (string= (plist-get org-capture-plist :key) "C")
+ (save-excursion
+ (goto-char (point-min))
+ (let ((birthday (org-entry-get (point) "BIRTHDAY")))
+ (when (and birthday (not (string-blank-p birthday)))
+ ;; Parse birthday - returns (year month day) or nil
+ (let ((parsed
+ (cond
+ ((string-match "^\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday)
+ (list (string-to-number (match-string 1 birthday))
+ (string-to-number (match-string 2 birthday))
+ (string-to-number (match-string 3 birthday))))
+ ((string-match "^\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday)
+ (list (nth 5 (decode-time))
+ (string-to-number (match-string 1 birthday))
+ (string-to-number (match-string 2 birthday))))
+ (t nil))))
+ (when parsed
+ (let* ((year (nth 0 parsed))
+ (month (nth 1 parsed))
+ (day (nth 2 parsed))
+ (time (encode-time 0 0 0 day month year))
+ (dow (format-time-string "%a" time))
+ (timestamp (format "<%04d-%02d-%02d %s +1y>" year month day dow))
+ (heading-end (save-excursion (outline-next-heading) (point))))
+ ;; Find :END: and insert timestamp
+ (when (re-search-forward "^[ \t]*:END:[ \t]*$" heading-end t)
+ (let ((end-pos (point)))
+ (goto-char end-pos)
+ (unless (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^>]*\\+1y>" heading-end t)
+ (goto-char end-pos)
+ (end-of-line)
+ (insert "\n" timestamp))))))))))))
+
(with-eval-after-load 'org-capture
(add-to-list 'org-capture-templates
- '("C" "Contact" entry (file+headline contacts-file "Contacts")
- "* %(cj/org-contacts-template-name)
+ '("C" "Contact" entry (file+headline contacts-file "Contacts")
+ "* %(cj/org-contacts-template-name)
:PROPERTIES:
:EMAIL: %(cj/org-contacts-template-email)
:PHONE: %^{Phone(s) - separate multiple with commas}
:ADDRESS: %^{Address}
-:BIRTHDAY: %^{Birthday (YYYY-MM-DD)}
+:BIRTHDAY: %^{Birthday (YYYY-MM-DD or MM-DD)}
+:NICKNAME: %^{Nickname}
:COMPANY: %^{Company}
:TITLE: %^{Title/Position}
:WEBSITE: %^{URL}
+:NOTE: %^{Notes}
:END:
-%^{Notes}
-Added: %U")))
+Added: %U"
+ :prepare-finalize cj/org-contacts-finalize-birthday-timestamp)))
;; TASK: What purpose did this serve?
;; duplicate?!?
;; (with-eval-after-load 'org-capture
;; (add-to-list 'org-capture-templates
-;; '("C" "Contact" entry (file+headline contacts-file "Contacts")
-;; "* %(cj/org-contacts-template-name)
+;; '("C" "Contact" entry (file+headline contacts-file "Contacts")
+;; "* %(cj/org-contacts-template-name)
;; Added: %U")))
(defun cj/org-contacts-template-name ()
"Get name for contact template from context."
(let ((name (when (boundp 'cj/contact-name) cj/contact-name)))
- (or name
- (when (eq major-mode 'mu4e-headers-mode)
- (mu4e-message-field (mu4e-message-at-point) :from-or-to))
- (when (eq major-mode 'mu4e-view-mode)
- (mu4e-message-field mu4e~view-message :from-or-to))
- (read-string "Name: "))))
+ (or name
+ (when (eq major-mode 'mu4e-headers-mode)
+ (mu4e-message-field (mu4e-message-at-point) :from-or-to))
+ (when (eq major-mode 'mu4e-view-mode)
+ (mu4e-message-field mu4e~view-message :from-or-to))
+ (read-string "Name: "))))
(defun cj/org-contacts-template-email ()
"Get email for contact template from context."
(let ((email (when (boundp 'cj/contact-email) cj/contact-email)))
- (or email
- (when (eq major-mode 'mu4e-headers-mode)
- (let ((from (mu4e-message-field (mu4e-message-at-point) :from)))
- (when from (cdr (car from)))))
- (when (eq major-mode 'mu4e-view-mode)
- (let ((from (mu4e-message-field mu4e~view-message :from)))
- (when from (cdr (car from)))))
- (read-string "Email: "))))
+ (or email
+ (when (eq major-mode 'mu4e-headers-mode)
+ (let ((from (mu4e-message-field (mu4e-message-at-point) :from)))
+ (when from (cdr (car from)))))
+ (when (eq major-mode 'mu4e-view-mode)
+ (let ((from (mu4e-message-field mu4e~view-message :from)))
+ (when from (cdr (car from)))))
+ (read-string "Email: "))))
;;; ------------------------- Quick Contact Functions ---------------------------
@@ -91,13 +129,13 @@ Added: %U")))
(find-file contacts-file)
(goto-char (point-min))
(let ((contact (completing-read "Contact: "
- (org-map-entries
- (lambda () (nth 4 (org-heading-components)))
- nil (list contacts-file)))))
- (goto-char (point-min))
- (search-forward contact)
- (org-fold-show-entry)
- (org-reveal)))
+ (org-map-entries
+ (lambda () (nth 4 (org-heading-components)))
+ nil (list contacts-file)))))
+ (goto-char (point-min))
+ (search-forward contact)
+ (org-fold-show-entry)
+ (org-reveal)))
(defun cj/org-contacts-new ()
"Create a new contact."
@@ -110,19 +148,6 @@ Added: %U")))
(find-file contacts-file)
(org-columns))
-;;; -------------------------- Org-Roam Integration -----------------------------
-
-;; (with-eval-after-load 'org-roam
-;; (defun cj/org-contacts-link-to-roam ()
-;; "Link current contact to an org-roam node."
-;; (interactive)
-;; (when (eq major-mode 'org-mode)
-;; (let ((contact-name (org-entry-get (point) "ITEM")))
-;; (org-set-property "ROAM_REFS"
-;; (org-roam-node-id
-;; (org-roam-node-read nil nil nil nil
-;; :initial-input contact-name)))))))
-
;;; ----------------------------- Birthday Agenda --------------------------------
(with-eval-after-load 'org-agenda
@@ -131,40 +156,48 @@ Added: %U")))
;; Custom agenda command for upcoming birthdays
(add-to-list 'org-agenda-custom-commands
- '("b" "Birthdays and Anniversaries"
- ((tags-todo "BIRTHDAY|ANNIVERSARY"
- ((org-agenda-overriding-header "Upcoming Birthdays and Anniversaries")
- (org-agenda-sorting-strategy '(time-up))))))))
+ '("b" "Birthdays and Anniversaries"
+ ((tags-todo "BIRTHDAY|ANNIVERSARY"
+ ((org-agenda-overriding-header "Upcoming Birthdays and Anniversaries")
+ (org-agenda-sorting-strategy '(time-up))))))))
;;; ---------------------------- Core Contact Data Functions ---------------------------
(defun cj/org-contacts--props-matching (entry pattern)
"Return all property values from ENTRY whose keys match PATTERN (a regexp)."
(let ((props (nth 2 entry)))
- (delq nil
- (mapcar (lambda (prop)
- (when (string-match-p pattern (car prop))
- (cdr prop)))
- props))))
+ (delq nil
+ (mapcar (lambda (prop)
+ (when (string-match-p pattern (car prop))
+ (cdr prop)))
+ props))))
+
+(defun cj/--parse-email-string (name email-string)
+ "Parse EMAIL-STRING and return formatted entries for NAME.
+EMAIL-STRING may contain multiple emails separated by commas, semicolons, or spaces.
+Returns a list of strings formatted as 'Name <email>'.
+Returns nil if EMAIL-STRING is nil or contains only whitespace."
+ (when (and email-string (string-match-p "[^[:space:]]" email-string))
+ (let ((emails (split-string email-string "[,;[:space:]]+" t)))
+ (mapcar (lambda (email)
+ (format "%s <%s>" name (string-trim email)))
+ emails))))
(defun cj/get-all-contact-emails ()
"Retrieve all contact emails from org-contacts database.
Returns a list of formatted strings like \"Name <email@example.com>\".
This is the core function used by the mu4e integration module."
(let ((contacts (org-contacts-db)))
- (delq nil
- (mapcan (lambda (e)
- (let* ((name (car e))
- ;; This returns a LIST of email strings
- (email-strings (cj/org-contacts--props-matching e "EMAIL")))
- ;; Need mapcan here to handle the list
- (mapcan (lambda (email-str)
- (when (and email-str (string-match-p "[^[:space:]]" email-str))
- (mapcar (lambda (email)
- (format "%s <%s>" name (string-trim email)))
- (split-string email-str "[,;[:space:]]+" t))))
- email-strings)))
- contacts))))
+ (delq nil
+ (mapcan (lambda (e)
+ (let* ((name (car e))
+ ;; This returns a LIST of email strings
+ (email-strings (cj/org-contacts--props-matching e "EMAIL")))
+ ;; Process each email string using the extracted parser
+ (mapcan (lambda (email-str)
+ (cj/--parse-email-string name email-str))
+ email-strings)))
+ contacts))))
;; Simple insertion function for use outside of mu4e
(defun cj/insert-contact-email ()
@@ -173,8 +206,8 @@ For use outside of mu4e compose buffers. In mu4e, the integration
module provides more sophisticated completion."
(interactive)
(let* ((items (cj/get-all-contact-emails))
- (selected (completing-read "Contact: " items nil t)))
- (insert selected)))
+ (selected (completing-read "Contact: " items nil t)))
+ (insert selected)))
;;; -------------------------------- Org Contacts --------------------------------
@@ -195,9 +228,9 @@ module provides more sophisticated completion."
(setq mu4e-org-contacts-file contacts-file)
(add-to-list 'mu4e-headers-actions
- '("org-contact-add" . mu4e-action-add-org-contact) t)
+ '("org-contact-add" . mu4e-action-add-org-contact) t)
(add-to-list 'mu4e-view-actions
- '("org-contact-add" . mu4e-action-add-org-contact) t)
+ '("org-contact-add" . mu4e-action-add-org-contact) t)
;; Disable mu4e's built-in completion in favor of our custom solution
(setq mu4e-compose-complete-addresses nil))
@@ -207,15 +240,24 @@ module provides more sophisticated completion."
;; Keymap for `org-contacts' commands
(defvar cj/org-contacts-map
(let ((map (make-sparse-keymap)))
- (keymap-set map "f" #'cj/org-contacts-find) ;; find contact
- (keymap-set map "n" #'cj/org-contacts-new) ;; new contact
- (keymap-set map "e" #'cj/insert-contact-email) ;; inserts email from org-contact
- (keymap-set map "v" #'cj/org-contacts-view-all) ;; view all contacts
- map)
+ (keymap-set map "f" #'cj/org-contacts-find) ;; find contact
+ (keymap-set map "n" #'cj/org-contacts-new) ;; new contact
+ (keymap-set map "e" #'cj/insert-contact-email) ;; inserts email from org-contact
+ (keymap-set map "v" #'cj/org-contacts-view-all) ;; view all contacts
+ map)
"Keymap for `org-contacts' commands.")
;; Bind the org-contacts map to the C-c C prefix
(keymap-global-set "C-c C" cj/org-contacts-map)
+;; which-key labels
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-c C" "contacts menu"
+ "C-c C f" "find contact"
+ "C-c C n" "new contact"
+ "C-c C e" "insert email"
+ "C-c C v" "view all contacts"))
+
(provide 'org-contacts-config)
;;; org-contacts-config.el ends here
diff --git a/modules/org-drill-config.el b/modules/org-drill-config.el
index f18760c7..8d82c42c 100644
--- a/modules/org-drill-config.el
+++ b/modules/org-drill-config.el
@@ -19,17 +19,30 @@
;; --------------------------------- Org Drill ---------------------------------
(use-package org-drill
+ :load-path "~/code/org-drill"
+ ;; Use local development version instead of VC install
+ ;; :vc (:url "https://github.com/cjennings/org-drill"
+ ;; :branch "main"
+ ;; :rev :newest)
:after (org org-capture)
+ :demand t
:commands (org-drill cj/drill-start)
:config
(setq org-drill-leech-failure-threshold 50) ;; leech cards = 50 wrong anwers
(setq org-drill-leech-method 'warn) ;; leech cards show warnings
(setq org-drill-use-visible-cloze-face-p t) ;; cloze text show up in a different font
(setq org-drill-hide-item-headings-p t) ;; don't show heading text
- (setq org-drill-maximum-items-per-session 1000) ;; drill sessions end after 1000 cards
- (setq org-drill-maximum-duration 60) ;; each drill session can last up to a an hour
+ (setq org-drill-maximum-items-per-session 100) ;; drill sessions end after 100 cards
+ (setq org-drill-maximum-duration 30) ;; each drill session can last up to 30 mins
(setq org-drill-add-random-noise-to-intervals-p t) ;; slightly vary number of days to repetition
+ ;; ------------------------------ Display Settings -----------------------------
+
+ ;; Configure display settings for drill sessions
+ (setq org-drill-text-size-during-session 24) ;; 24-point font for comfortable reading
+ (setq org-drill-use-variable-pitch t) ;; use variable-pitch font for readability
+ (setq org-drill-hide-modeline-during-session t) ;; hide modeline for cleaner display
+
(defun cj/drill-start ()
"Prompt user to pick a drill org file, then start an org-drill session."
(interactive)
@@ -70,7 +83,13 @@
(keymap-set cj/custom-keymap "D" cj/drill-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; D" "org-drill menu")))
+ (which-key-add-key-based-replacements
+ "C-; D" "org-drill menu"
+ "C-; D s" "start drill"
+ "C-; D e" "edit drill file"
+ "C-; D c" "capture question"
+ "C-; D r" "refile to drill"
+ "C-; D R" "resume drill")))
(provide 'org-drill-config)
;;; org-drill-config.el ends here.
diff --git a/modules/org-export-config.el b/modules/org-export-config.el
index 43329cc3..612b80cb 100644
--- a/modules/org-export-config.el
+++ b/modules/org-export-config.el
@@ -24,6 +24,8 @@
;;
;;; Code:
+(require 'system-lib)
+
;; --------------------------------- Org Export --------------------------------
(use-package ox
@@ -112,7 +114,7 @@
(variable . "transition=slide")
(variable . "slideNumber=true"))))
(unless (file-exists-p reveal-dir)
- (message "Downloading reveal.js...")
+ (cj/log-silently "Downloading reveal.js...")
(shell-command
(format "git clone https://github.com/hakimel/reveal.js.git %s" reveal-dir)))
(org-pandoc-export-to-revealjs)))
diff --git a/modules/org-gcal-config.el b/modules/org-gcal-config.el
deleted file mode 100644
index ed0831b8..00000000
--- a/modules/org-gcal-config.el
+++ /dev/null
@@ -1,94 +0,0 @@
-;;; org-gcal-config.el --- Google Calendar synchronization for Org-mode -*- lexical-binding: t; coding: utf-8; -*-
-;;
-;; Author: Craig Jennings <c@cjennings.net>
-;;
-;;; Commentary:
-;;
-;; Bidirectional synchronization between Google Calendar and Org-mode using org-gcal.
-;; - Credential management via authinfo.gpg
-;; - Automatic archival of past events
-;; - Automatic removal of cancelled events, but with TODOs added for visibility
-;; - System timezone configuration via functions in host-environment
-;; - No notifications on syncing
-;; - Initial automatic sync post Emacs startup. No auto resync'ing.
-;; (my calendar doesn't change hourly and I want fewer distractions and slowdowns).
-;; if you need it: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#sync-automatically-at-regular-times
-;; - Validates existing oath2-auto.plist file or creates it to avoid the issue mentioned here:
-;; https://github.com/kidd/org-gcal.el?tab=readme-ov-file#note
-;;
-;; Prerequisites:
-;; 1. Create OAuth 2.0 credentials in Google Cloud Console
-;; See: https://github.com/kidd/org-gcal.el?tab=readme-ov-file#installation
-;; 2. Store credentials in ~/.authinfo.gpg with this format:
-;; machine org-gcal login YOUR_CLIENT_ID password YOUR_CLIENT_SECRET
-;; 3. Define `gcal-file' in user-constants (location of org file to hold sync'd events).
-;;
-;; Usage:
-;; - Manual sync: C-; g (or M-x org-gcal-sync)
-;;
-;; Note:
-;; This configuration creates oauth2-auto.plist on first run to prevent sync errors.
-;; Passphrase caching is enabled.
-;;
-;;; Code:
-
-(require 'host-environment)
-(require 'user-constants)
-
-(defun cj/org-gcal-clear-sync-lock ()
- "Clear the org-gcal sync lock.
-Useful when a sync fails and leaves the lock in place, preventing future syncs."
- (interactive)
- (setq org-gcal--sync-lock nil)
- (message "org-gcal sync lock cleared"))
-
-(use-package org-gcal
- :defer t ;; unless idle timer is set below
- :bind (("C-; g" . org-gcal-sync)
- ("C-; G" . cj/org-gcal-clear-sync-lock))
-
- :init
- ;; Retrieve credentials from authinfo.gpg BEFORE package loads
- ;; This is critical - org-gcal checks these variables at load time
- (require 'auth-source)
- (let ((credentials (car (auth-source-search :host "org-gcal" :require '(:user :secret)))))
- (when credentials
- (setq org-gcal-client-id (plist-get credentials :user))
- ;; The secret might be a function, so we need to handle that
- (let ((secret (plist-get credentials :secret)))
- (setq org-gcal-client-secret
- (if (functionp secret)
- (funcall secret)
- secret)))))
-
- ;; identify calendar to sync and it's destination
- (setq org-gcal-fetch-file-alist `(("craigmartinjennings@gmail.com" . ,gcal-file)))
-
- (setq org-gcal-up-days 30) ;; Look 30 days back
- (setq org-gcal-down-days 60) ;; Look 60 days forward
- (setq org-gcal-auto-archive t) ;; auto-archive old events
- (setq org-gcal-notify-p nil) ;; nil disables; t enables notifications
- (setq org-gcal-remove-api-cancelled-events t) ;; auto-remove cancelled events
- (setq org-gcal-update-cancelled-events-with-todo t) ;; todo cancelled events for visibility
-
- :config
- ;; Enable plstore passphrase caching after org-gcal loads
- (require 'plstore)
- (setq plstore-cache-passphrase-for-symmetric-encryption t)
-
- ;; set org-gcal timezone based on system timezone
- (setq org-gcal-local-timezone (cj/detect-system-timezone))
-
- ;; Reload client credentials (should already be loaded by org-gcal, but ensure it's set)
- (org-gcal-reload-client-id-secret))
-
-;; Set up automatic initial sync on boot with error handling
-;;(run-with-idle-timer
-;; 2 nil
-;; (lambda ()
-;; (condition-case err
-;; (org-gcal-sync)
-;; (error (message "org-gcal: Initial sync failed: %s" err)))))
-
-(provide 'org-gcal-config)
-;;; org-gcal-config.el ends here
diff --git a/modules/org-noter-config.el b/modules/org-noter-config.el
index 253ed892..fc578085 100644
--- a/modules/org-noter-config.el
+++ b/modules/org-noter-config.el
@@ -1,63 +1,250 @@
-;;; org-noter-config.el --- -*- coding: utf-8; lexical-binding: t; -*-
+;;; org-noter-config.el --- Org-noter configuration -*- coding: utf-8; lexical-binding: t; -*-
;;; Commentary:
-;; Org-noter configuration for taking notes on PDF and DjVu documents. Workflow:
-;; open a PDF/DjVu file in Emacs, press F6 to start org-noter session, frame
-;; splits with document on one side and notes on the other, notes are saved to
-;; ~/sync/org-noter/reading-notes.org by default, and position is automatically
-;; saved when closing session. Features include integration with pdf-tools and
-;; djvu, org-roam integration for linking notes, automatic session resumption at
-;; last position, inserting highlighted text into notes, notes following
-;; TASK: Aborted Commentary
+;;
+;; Org-noter configuration for taking notes on PDF and EPUB documents.
+;;
+;; Workflow:
+;; 1. Open a PDF (pdf-view-mode) or EPUB (nov-mode) in Emacs
+;; 2. Press F6 to start org-noter session
+;; 3. If new book: prompted for title, creates notes file as org-roam node
+;; 4. If existing book: finds and opens associated notes file
+;; 5. Window splits with document on left (2/3) and notes on right (1/3)
+;; 6. Use 'i' to insert notes at current location
+;; 7. Notes are saved as org-roam nodes in org-roam-directory
+;;
+;; Can also start from notes file: open notes via org-roam, press F6 to open document.
+;;
+;; See docs/org-noter-workflow-spec.org for full specification.
;;; Code:
+(require 'cl-lib)
+
+;; Forward declarations
+(declare-function org-id-uuid "org-id")
+(declare-function nov-mode "ext:nov")
+(declare-function pdf-view-mode "ext:pdf-view")
+(defvar nov-file-name)
+(defvar org-roam-directory)
+(defvar org-dir)
+
+;;; Configuration Variables
+
+(defvar cj/org-noter-notes-directory
+ (if (boundp 'org-roam-directory)
+ org-roam-directory
+ (expand-file-name "~/sync/org/roam/"))
+ "Directory where org-noter notes files are stored.
+Defaults to `org-roam-directory' so notes are indexed by org-roam.")
+
+(defvar cj/org-noter-keybinding (kbd "<f6>")
+ "Keybinding to start org-noter session.")
+
+(defvar cj/org-noter-split-direction 'horizontal
+ "Direction to split window for notes.
+`vertical' puts notes on the right (side-by-side).
+`horizontal' puts notes on the bottom (stacked).")
+
+(defvar cj/org-noter-split-fraction 0.67
+ "Fraction of window for document (notes get the remainder).
+Default 0.67 means document gets 2/3, notes get 1/3.")
+
+;;; Helper Functions
+
+(defun cj/org-noter--title-to-slug (title)
+ "Convert TITLE to lowercase hyphenated slug for filename.
+Example: \"The Pragmatic Programmer\" -> \"the-pragmatic-programmer\""
+ (let ((slug (downcase title)))
+ (setq slug (replace-regexp-in-string "[^a-z0-9]+" "-" slug))
+ (setq slug (replace-regexp-in-string "^-\\|-$" "" slug))
+ slug))
+
+(defun cj/org-noter--generate-notes-template (title doc-path)
+ "Generate org-roam notes template for TITLE and DOC-PATH."
+ (format ":PROPERTIES:
+:ID: %s
+:ROAM_REFS: %s
+:NOTER_DOCUMENT: %s
+:END:
+#+title: Notes on %s
+#+FILETAGS: :ReadingNotes:
+#+CATEGORY: %s
+
+* Notes
+"
+ (org-id-uuid)
+ doc-path
+ doc-path
+ title
+ title))
+
+(defun cj/org-noter--in-document-p ()
+ "Return non-nil if current buffer is a PDF or EPUB document."
+ (or (derived-mode-p 'pdf-view-mode)
+ (derived-mode-p 'nov-mode)))
+
+(defun cj/org-noter--in-notes-file-p ()
+ "Return non-nil if current buffer is an org-noter notes file."
+ (and (derived-mode-p 'org-mode)
+ (save-excursion
+ (goto-char (point-min))
+ (org-entry-get nil "NOTER_DOCUMENT"))))
+
+(defun cj/org-noter--get-document-path ()
+ "Get file path of current document."
+ (cond
+ ((derived-mode-p 'nov-mode) nov-file-name)
+ ((derived-mode-p 'pdf-view-mode) (buffer-file-name))
+ (t nil)))
+
+(defun cj/org-noter--extract-document-title ()
+ "Extract title from current document filename.
+Uses filename (without extension) for both PDFs and EPUBs."
+ (file-name-base (cj/org-noter--get-document-path)))
+
+(defun cj/org-noter--find-notes-file ()
+ "Find existing notes file for current document.
+Searches `cj/org-noter-notes-directory' for org files with matching
+NOTER_DOCUMENT property. Returns path to notes file or nil."
+ (let ((doc-path (cj/org-noter--get-document-path)))
+ (when doc-path
+ (cl-find-if
+ (lambda (file)
+ (with-temp-buffer
+ (insert-file-contents file nil 0 1000)
+ (string-match-p (regexp-quote doc-path) (buffer-string))))
+ (directory-files cj/org-noter-notes-directory t "\\.org$")))))
+
+(defun cj/org-noter--create-notes-file ()
+ "Create new org-roam notes file for current document.
+Prompts user to confirm/edit title (pre-slugified), generates filename,
+creates org-roam node with proper properties. Returns path to new file."
+ (let* ((doc-path (cj/org-noter--get-document-path))
+ (default-title (cj/org-noter--title-to-slug
+ (cj/org-noter--extract-document-title)))
+ (title (read-string "Notes title: " default-title))
+ (slug (cj/org-noter--title-to-slug title))
+ (filename (format "notes-on-%s.org" slug))
+ (filepath (expand-file-name filename cj/org-noter-notes-directory)))
+ (unless (file-exists-p filepath)
+ (with-temp-file filepath
+ (insert (cj/org-noter--generate-notes-template title doc-path))))
+ (find-file-noselect filepath)
+ filepath))
+
+;;; Main Entry Point
+
+(defun cj/org-noter--session-active-p ()
+ "Return non-nil if an org-noter session is active for current buffer."
+ (and (boundp 'org-noter--session)
+ org-noter--session))
+
+(defun cj/org-noter--toggle-notes-window ()
+ "Toggle visibility of notes window in active org-noter session.
+Preserves PDF fit setting when toggling."
+ (let ((notes-window (org-noter--get-notes-window))
+ (pdf-fit (and (derived-mode-p 'pdf-view-mode)
+ (bound-and-true-p pdf-view-display-size))))
+ (if notes-window
+ (delete-window notes-window)
+ (org-noter--get-notes-window 'start))
+ ;; Restore PDF fit setting
+ (when pdf-fit
+ (pcase pdf-fit
+ ('fit-width (pdf-view-fit-width-to-window))
+ ('fit-height (pdf-view-fit-height-to-window))
+ ('fit-page (pdf-view-fit-page-to-window))
+ (_ nil)))))
+
+(defun cj/org-noter-start ()
+ "Start org-noter session or toggle notes window if session active.
+When called from a document (PDF/EPUB):
+ - If session active: toggle notes window visibility
+ - If no session: find or create notes file, start session
+When called from a notes file:
+ - If session active: switch to document window
+ - If no session: start session"
+ (interactive)
+ (cond
+ ;; In document with active session - toggle notes
+ ((and (cj/org-noter--in-document-p)
+ (cj/org-noter--session-active-p))
+ (cj/org-noter--toggle-notes-window))
+ ;; In notes file with active session - switch to document
+ ((and (cj/org-noter--in-notes-file-p)
+ (cj/org-noter--session-active-p))
+ (let ((doc-window (org-noter--get-doc-window)))
+ (when doc-window
+ (select-window doc-window))))
+ ;; In document without session - start new session
+ ((cj/org-noter--in-document-p)
+ (let ((notes-file (or (cj/org-noter--find-notes-file)
+ (cj/org-noter--create-notes-file))))
+ (when notes-file
+ ;; Open notes file and call org-noter from there
+ (find-file notes-file)
+ (goto-char (point-min))
+ (org-noter))))
+ ;; In notes file without session - start session
+ ((cj/org-noter--in-notes-file-p)
+ (org-noter))
+ (t
+ (message "Not in a document or org-noter notes file"))))
+
+;;; Package Configuration
+
(use-package djvu
:defer 0.5)
-(use-package pdf-tools
- :defer t
- :mode ("\\.pdf\\'" . pdf-view-mode)
- :config
- (pdf-tools-install :no-query))
-
(use-package org-pdftools
:after (org pdf-tools)
:hook (org-mode . org-pdftools-setup-link))
+(global-set-key (kbd "<f6>") #'cj/org-noter-start)
+
(use-package org-noter
- :after (:any org pdf-tools djvu)
+ :after (:any org pdf-tools djvu nov)
:commands org-noter
:config
+ ;; Window layout based on cj/org-noter-split-direction
+ (setq org-noter-notes-window-location
+ (if (eq cj/org-noter-split-direction 'vertical)
+ 'horizontal-split ; confusingly named: horizontal-split = side-by-side
+ 'vertical-split)) ; vertical-split = stacked
+
+ ;; Split ratio from configuration (first is notes, second is doc)
+ (setq org-noter-doc-split-fraction
+ (cons (- 1.0 cj/org-noter-split-fraction)
+ cj/org-noter-split-fraction))
+
;; Basic settings
(setq org-noter-always-create-frame nil)
- (setq org-noter-notes-window-location 'horizontal-split)
- (setq org-noter-notes-window-behavior '(start scroll)) ; note: must be a list!
- (setq org-noter-doc-split-fraction '(0.5 . 0.5))
- (setq org-noter-notes-search-path (list (concat org-dir "/org-noter/")))
- (setq org-noter-default-notes-file-names '("reading-notes.org"))
+ (setq org-noter-notes-window-behavior '(start scroll))
+ (setq org-noter-notes-search-path (list cj/org-noter-notes-directory))
(setq org-noter-separate-notes-from-heading t)
- (setq org-noter-kill-frame-at-session-end t) ; kill frame when closing session
+ (setq org-noter-kill-frame-at-session-end nil)
- (setq org-noter-auto-save-last-location t) ; Save position when closing
- (setq org-noter-insert-selected-text-inside-note t) ; Insert highlighted text
- (setq org-noter-closest-tipping-point 0.3) ; When to show closest previous note
- (setq org-noter-hide-other t) ; Hide unrelated notes
+ (setq org-noter-auto-save-last-location t)
+ (setq org-noter-insert-selected-text-inside-note t)
+ (setq org-noter-closest-tipping-point 0.3)
+ (setq org-noter-hide-other t)
- ;; Load the integration file if it exists in your config
+ ;; Load integration file if exists
(let ((integration-file (expand-file-name "org-noter-integration.el"
- (file-name-directory (locate-library "org-noter")))))
- (when (file-exists-p integration-file)
- (load integration-file)))
+ (file-name-directory (locate-library "org-noter")))))
+ (when (file-exists-p integration-file)
+ (load integration-file)))
- ;; If you want to use the org-noter-pdftools integration features
+ ;; PDF tools integration
(when (featurep 'org-noter-integration)
- (setq org-noter-use-pdftools-link-location t)
- (setq org-noter-use-org-id t)
- (setq org-noter-use-unique-org-id t))
- (org-noter-enable-org-roam-integration)
+ (setq org-noter-use-pdftools-link-location t)
+ (setq org-noter-use-org-id t)
+ (setq org-noter-use-unique-org-id t))
- (org-noter-enable-org-roam-integration))
+ ;; Defer org-roam integration to avoid slowing PDF load
+ (with-eval-after-load 'org-roam
+ (org-noter-enable-org-roam-integration)))
(provide 'org-noter-config)
-;;; org-noter-config.el ends here.
+;;; org-noter-config.el ends here
diff --git a/modules/org-refile-config.el b/modules/org-refile-config.el
index 7b50604a..1cf976d4 100644
--- a/modules/org-refile-config.el
+++ b/modules/org-refile-config.el
@@ -2,49 +2,154 @@
;; author: Craig Jennings <c@cjennings.net>
;;; Commentary:
;; Configuration and custom functions for org-mode refiling.
+;;
+;; Performance:
+;; - Caches refile targets to avoid scanning 34,000+ files on every refile
+;; - Cache builds asynchronously 5 seconds after Emacs startup (non-blocking)
+;; - First refile uses cache if ready, otherwise builds synchronously (one-time delay)
+;; - Subsequent refiles are instant (cached)
+;; - Cache auto-refreshes after 1 hour
+;; - Manual refresh: M-x cj/org-refile-refresh-targets (e.g., after adding projects)
;;; Code:
+(require 'system-lib)
+
;; ----------------------------- Org Refile Targets ----------------------------
;; sets refile targets
;; - adds project files in org-roam to the refile targets
;; - adds todo.org files in subdirectories of the code and project directories
-(defun cj/build-org-refile-targets ()
- "Build =org-refile-targets=."
+(defvar cj/org-refile-targets-cache nil
+ "Cached refile targets to avoid expensive directory scanning.
+Set to nil to invalidate cache.")
+
+(defvar cj/org-refile-targets-cache-time nil
+ "Time when refile targets cache was last built.")
+
+(defvar cj/org-refile-targets-cache-ttl 3600
+ "Time-to-live for refile targets cache in seconds (default: 1 hour).")
+
+(defvar cj/org-refile-targets-building nil
+ "Non-nil when refile targets are being built asynchronously.
+Prevents duplicate builds if user refiles before async build completes.")
+
+(defun cj/org-refile-ensure-org-mode (file)
+ "Ensure FILE is a .org file and its buffer is in org-mode.
+Returns the buffer visiting FILE, switching it to org-mode if needed.
+Signals an error if FILE doesn't have a .org extension.
+
+This prevents issues where:
+1. Buffers get stuck in fundamental-mode (e.g., opened before org loaded)
+2. Non-.org files are accidentally added to refile targets"
+ (unless (string-match-p "\\.org\\'" file)
+ (error "Refile target \"%s\" is not a .org file" file))
+
+ (let ((buf (org-get-agenda-file-buffer file)))
+ (with-current-buffer buf
+ (unless (derived-mode-p 'org-mode)
+ (cj/log-silently "Switching %s to org-mode (was in %s)"
+ (buffer-name) major-mode)
+ (org-mode)))
+ buf))
+
+(defun cj/build-org-refile-targets (&optional force-rebuild)
+ "Build =org-refile-targets= with caching.
+
+When FORCE-REBUILD is non-nil, bypass cache and rebuild from scratch.
+Otherwise, returns cached targets if available and not expired.
+
+This function scans 30,000+ files across code/projects directories,
+so caching improves performance from 15-20 seconds to instant."
+ (interactive "P")
+ ;; Check if we can use cache
+ (let ((cache-valid (and cj/org-refile-targets-cache
+ cj/org-refile-targets-cache-time
+ (not force-rebuild)
+ (< (- (float-time) cj/org-refile-targets-cache-time)
+ cj/org-refile-targets-cache-ttl))))
+ (if cache-valid
+ ;; Use cached targets (instant)
+ (progn
+ (setq org-refile-targets cj/org-refile-targets-cache)
+ ;; Always show cache-hit message (interactive or background)
+ (cj/log-silently "Using cached refile targets (%d files)"
+ (length org-refile-targets)))
+ ;; Check if async build is in progress
+ (when cj/org-refile-targets-building
+ (cj/log-silently "Waiting for background cache build to complete..."))
+ ;; Rebuild from scratch (slow - scans 34,000+ files)
+ (unwind-protect
+ (progn
+ (setq cj/org-refile-targets-building t)
+ (let ((start-time (current-time))
+ (new-files
+ (list
+ (cons inbox-file '(:maxlevel . 1))
+ (cons reference-file '(:maxlevel . 2))
+ (cons schedule-file '(:maxlevel . 1)))))
+
+ ;; Extend with org-roam files if available AND org-roam is loaded
+ (when (and (fboundp 'cj/org-roam-list-notes-by-tag)
+ (fboundp 'org-roam-node-list))
+ (let* ((project-and-topic-files
+ (append (cj/org-roam-list-notes-by-tag "Project")
+ (cj/org-roam-list-notes-by-tag "Topic")))
+ (file-rule '(:maxlevel . 1)))
+ (dolist (file project-and-topic-files)
+ (unless (assoc file new-files)
+ (push (cons file file-rule) new-files)))))
+
+ ;; Add todo.org files from known directories
+ (dolist (dir (list user-emacs-directory code-dir projects-dir))
+ (let* ((todo-files (directory-files-recursively
+ dir "^[Tt][Oo][Dd][Oo]\\.[Oo][Rr][Gg]$"))
+ (file-rule '(:maxlevel . 1)))
+ (dolist (file todo-files)
+ (unless (assoc file new-files)
+ (push (cons file file-rule) new-files)))))
+
+ ;; Update targets and cache
+ (setq new-files (nreverse new-files))
+ (setq org-refile-targets new-files)
+ (setq cj/org-refile-targets-cache new-files)
+ (setq cj/org-refile-targets-cache-time (float-time))
+
+ ;; Always show completion message (interactive or background)
+ (cj/log-silently "Built refile targets: %d files in %.2f seconds"
+ (length org-refile-targets)
+ (- (float-time) (float-time start-time)))))
+ ;; Always clear the building flag, even if build fails
+ (setq cj/org-refile-targets-building nil)))))
+
+;; Build cache asynchronously after startup to avoid blocking Emacs
+(run-with-idle-timer
+ 5 ; Wait 5 seconds after Emacs is idle
+ nil ; Don't repeat
+ (lambda ()
+ (cj/log-silently "Building org-refile targets cache in background...")
+ (cj/build-org-refile-targets)))
+
+(defun cj/org-refile-refresh-targets ()
+ "Force rebuild of refile targets cache.
+
+Use this after adding new projects or todo.org files.
+Bypasses cache and scans all directories from scratch."
(interactive)
- (let ((new-files
- (list
- (cons inbox-file '(:maxlevel . 1))
- (cons reference-file '(:maxlevel . 2))
- (cons schedule-file '(:maxlevel . 1)))))
- ;; Extend with org-roam files if available AND org-roam is loaded
- (when (and (fboundp 'cj/org-roam-list-notes-by-tag)
- (fboundp 'org-roam-node-list)) ; <-- Add this check
- (let* ((project-and-topic-files
- (append (cj/org-roam-list-notes-by-tag "Project")
- (cj/org-roam-list-notes-by-tag "Topic")))
- (file-rule '(:maxlevel . 1)))
- (dolist (file project-and-topic-files)
- (unless (assoc file new-files)
- (push (cons file file-rule) new-files)))))
- ;; Add todo.org files from known directories
- (dolist (dir (list user-emacs-directory code-dir projects-dir))
- (let* ((todo-files (directory-files-recursively
- dir "^[Tt][Oo][Dd][Oo]\\.[Oo][Rr][Gg]$"))
- (file-rule '(:maxlevel . 1)))
- (dolist (file todo-files)
- (unless (assoc file new-files)
- (push (cons file file-rule) new-files)))))
- (setq org-refile-targets (nreverse new-files))))
-
-(add-hook 'emacs-startup-hook #'cj/build-org-refile-targets)
+ (cj/build-org-refile-targets 'force-rebuild))
(defun cj/org-refile (&optional ARG DEFAULT-BUFFER RFLOC MSG)
- "Simply rebuilds the refile targets before calling org-refile.
+ "Call org-refile with cached refile targets.
+
+Uses cached targets for performance (instant vs 15-20 seconds).
+Cache auto-refreshes after 1 hour or on Emacs restart.
+
+To manually refresh cache (e.g., after adding projects):
+ M-x cj/org-refile-refresh-targets
ARG DEFAULT-BUFFER RFLOC and MSG parameters passed to org-refile."
(interactive "P")
+ ;; Use cached targets (don't rebuild every time!)
(cj/build-org-refile-targets)
(org-refile ARG DEFAULT-BUFFER RFLOC MSG))
@@ -72,7 +177,17 @@ ARG DEFAULT-BUFFER RFLOC and MSG parameters passed to org-refile."
;; save all open org buffers after a refile is complete
(advice-add 'org-refile :after
(lambda (&rest _)
- (org-save-all-org-buffers))))
+ (org-save-all-org-buffers)))
+
+ ;; Ensure refile target buffers are in org-mode before processing
+ ;; Fixes issue where buffers opened before org loaded get stuck in fundamental-mode
+ (advice-add 'org-refile-get-targets :before
+ (lambda (&rest _)
+ "Ensure all refile target buffers are in org-mode."
+ (dolist (target org-refile-targets)
+ (let ((file (car target)))
+ (when (stringp file)
+ (cj/org-refile-ensure-org-mode file)))))))
(provide 'org-refile-config)
;;; org-refile-config.el ends here.
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el
index 18552b1d..e8132776 100644
--- a/modules/org-roam-config.el
+++ b/modules/org-roam-config.el
@@ -1,7 +1,15 @@
;;; org-roam-config.el --- Org-Roam Config -*- lexical-binding: t; coding: utf-8; -*-
;; author: Craig Jennings <c@cjennings.net>
;;; Commentary:
-;; Currently a work in progress. The initial version of this was taken from David Wilson:
+;; Configuration and utilities for org-roam knowledge management.
+;;
+;; Key features:
+;; - Custom capture templates for different node types (v2mom, recipe, topic)
+;; - Automatic moving of completed tasks to daily journal
+;; - Tag-based node filtering and finding
+;; - Branch extraction to new roam nodes (cj/move-org-branch-to-roam)
+;;
+;; The initial version was adapted from David Wilson:
;; https://systemcrafters.net/build-a-second-brain-in-emacs/5-org-roam-hacks/
;;; Code:
@@ -11,6 +19,7 @@
;; ---------------------------------- Org Roam ---------------------------------
(use-package org-roam
+ :defer 1
:commands (org-roam-node-find org-roam-node-insert org-roam-db-autosync-mode)
:config
;; Enable autosync mode after org-roam loads
@@ -77,7 +86,9 @@
(add-to-list 'org-after-todo-state-change-hook
(lambda ()
(when (and (member org-state org-done-keywords)
- (not (member org-last-state org-done-keywords)))
+ (not (member org-last-state org-done-keywords))
+ ;; Don't run for gcal.org - it's synced from Google Calendar
+ (not (string= (buffer-file-name) (expand-file-name gcal-file))))
(cj/org-roam-copy-todo-to-today)))))
;; ------------------------- Org Roam Insert Immediate -------------------------
@@ -190,6 +201,51 @@ Otherwise return TEXT unchanged."
(or description url))
text))
+(defun cj/--generate-roam-slug (title)
+ "Convert TITLE to a filename-safe slug.
+Converts to lowercase, replaces non-alphanumeric characters with hyphens,
+and removes leading/trailing hyphens.
+Returns the slugified string."
+ (let ((slug (replace-regexp-in-string
+ "[^a-zA-Z0-9]+" "-"
+ (downcase title))))
+ (replace-regexp-in-string "^-\\|-$" "" slug)))
+
+(defun cj/--demote-org-subtree (content from-level to-level)
+ "Demote org subtree CONTENT from FROM-LEVEL to TO-LEVEL.
+CONTENT is the org-mode text with headings.
+FROM-LEVEL is the current level of the top heading (integer).
+TO-LEVEL is the desired level for the top heading (integer).
+Returns the demoted content as a string.
+All headings in the tree are adjusted proportionally."
+ (if (<= from-level to-level)
+ ;; No demotion needed
+ content
+ (let ((demote-count (- from-level to-level)))
+ (with-temp-buffer
+ (insert content)
+ (goto-char (point-min))
+ (while (re-search-forward "^\\(\\*+\\) " nil t)
+ (let* ((stars (match-string 1))
+ (level (length stars))
+ (new-level (max 1 (- level demote-count)))
+ (new-stars (make-string new-level ?*)))
+ (replace-match (concat new-stars " "))))
+ (buffer-string)))))
+
+(defun cj/--format-roam-node (title node-id content)
+ "Format org-roam node file CONTENT with TITLE and NODE-ID.
+TITLE is the node title string.
+NODE-ID is the unique identifier for the node.
+CONTENT is the main body content (already demoted if needed).
+Returns the complete file content as a string."
+ (concat ":PROPERTIES:\n"
+ ":ID: " node-id "\n"
+ ":END:\n"
+ "#+TITLE: " title "\n"
+ "#+CATEGORY: " title "\n"
+ "#+FILETAGS: Topic\n\n"
+ content))
(defun cj/move-org-branch-to-roam ()
"Move the org subtree at point to a new org-roam node.
@@ -213,12 +269,7 @@ title."
(title (cj/org-link-get-description raw-title))
(timestamp (format-time-string "%Y%m%d%H%M%S"))
;; Convert title to filename-safe format
- (title-slug (replace-regexp-in-string
- "[^a-zA-Z0-9]+" "-"
- (downcase title)))
- ;; Remove leading/trailing hyphens
- (title-slug (replace-regexp-in-string
- "^-\\|-$" "" title-slug))
+ (title-slug (cj/--generate-roam-slug title))
(filename (format "%s-%s.org" timestamp title-slug))
(filepath (expand-file-name filename org-roam-directory))
;; Generate a unique ID for the node
@@ -234,33 +285,11 @@ title."
(org-cut-subtree)
;; Process the subtree to demote it to level 1
- (with-temp-buffer
- (org-mode)
- (insert subtree-content)
- ;; Demote the entire tree so the top level becomes level 1
- (goto-char (point-min))
- (when (> current-level 1)
- (let ((demote-count (- current-level 1)))
- (while (re-search-forward "^\\*+ " nil t)
- (beginning-of-line)
- (dotimes (_ demote-count)
- (when (looking-at "^\\*\\*")
- (delete-char 1)))
- (forward-line))))
- (setq subtree-content (buffer-string)))
+ (setq subtree-content (cj/--demote-org-subtree subtree-content current-level 1))
;; Create the new org-roam file
(with-temp-file filepath
- ;; Insert the org-roam template with ID at file level
- (insert ":PROPERTIES:\n")
- (insert ":ID: " node-id "\n")
- (insert ":END:\n")
- (insert "#+TITLE: " title "\n")
- (insert "#+CATEGORY: " title "\n")
- (insert "#+FILETAGS: Topic\n\n")
-
- ;; Insert the demoted subtree content
- (insert subtree-content))
+ (insert (cj/--format-roam-node title node-id subtree-content)))
;; Sync the org-roam database
(org-roam-db-sync)
@@ -268,5 +297,49 @@ title."
;; Message to user
(message "'%s' added as an org-roam node." title)))
+;; TASK: Need to decide keybindings before implementation and testing
+;; (use-package consult-org-roam
+;; :ensure t
+;; :after org-roam
+;; :init
+;; (require 'consult-org-roam)
+;; ;; Activate the minor mode
+;; (consult-org-roam-mode 1)
+;; :custom
+;; ;; Use `ripgrep' for searching with `consult-org-roam-search'
+;; (consult-org-roam-grep-func #'consult-ripgrep)
+;; ;; Configure a custom narrow key for `consult-buffer'
+;; (consult-org-roam-buffer-narrow-key ?r)
+;; ;; Display org-roam buffers right after non-org-roam buffers
+;; ;; in consult-buffer (and not down at the bottom)
+;; (consult-org-roam-buffer-after-buffers t)
+;; :config
+;; ;; Eventually suppress previewing for certain functions
+;; (consult-customize
+;; consult-org-roam-forward-links
+;; :preview-key "M-.")
+;; :bind
+;; ;; Define some convenient keybindings as an addition
+;; ("C-c n e" . consult-org-roam-file-find)
+;; ("C-c n b" . consult-org-roam-backlinks)
+;; ("C-c n B" . consult-org-roam-backlinks-recursive)
+;; ("C-c n l" . consult-org-roam-forward-links)
+;; ("C-c n r" . consult-org-roam-search))
+
+
+;; which-key labels
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-c n" "org-roam menu"
+ "C-c n l" "roam buffer toggle"
+ "C-c n f" "roam find node"
+ "C-c n p" "roam find project"
+ "C-c n r" "roam find recipe"
+ "C-c n t" "roam find topic"
+ "C-c n i" "roam insert node"
+ "C-c n w" "roam find webclip"
+ "C-c n I" "roam insert immediate"
+ "C-c n d" "roam dailies menu"))
+
(provide 'org-roam-config)
;;; org-roam-config.el ends here.
diff --git a/modules/org-webclipper.el b/modules/org-webclipper.el
index e8f2cf23..7b024e43 100644
--- a/modules/org-webclipper.el
+++ b/modules/org-webclipper.el
@@ -11,6 +11,7 @@
;; - Automatic conversion to Org format using eww-readable and Pandoc
;; - One-click capture from any web page
;; - Preserves page structure and formatting
+;; - Smart heading adjustment (removes page title, demotes remaining headings)
;;
;; Setup:
;; 1. Ensure this file is loaded in your Emacs configuration
@@ -30,6 +31,11 @@
;; The clipped content will be added to the file specified by `webclipped-file`
;; under the "Webclipped Inbox" heading with proper formatting and metadata.
;;
+;; Architecture:
+;; - cj/--process-webclip-content: Pure function for content processing
+;; - cj/org-protocol-webclip-handler: Handles URL fetching and capture
+;; - cj/org-webclipper-EWW: Direct capture from EWW/W3M buffers
+;;
;; Requirements:
;; - org-web-tools package
;; - Pandoc installed on your system
@@ -37,23 +43,6 @@
;;; Code:
-;; Declare functions and variables to avoid warnings
-(declare-function org-protocol-protocol-alist "org-protocol")
-(declare-function org-capture "org-capture")
-(declare-function org-capture-get "org-capture")
-(declare-function org-web-tools--url-as-readable-org "org-web-tools")
-(declare-function org-w3m-copy-for-org-mode "org-w3m")
-(declare-function org-eww-copy-for-org-mode "org-eww")
-(declare-function org-at-heading-p "org")
-(declare-function org-heading-components "org")
-(declare-function org-copy-subtree "org")
-(declare-function org-cut-subtree "org")
-(declare-function org-id-new "org-id")
-(declare-function org-roam-db-sync "org-roam")
-(defvar org-capture-templates)
-(defvar org-protocol-protocol-alist)
-(defvar org-roam-directory)
-(defvar webclipped-file)
;; Variables for storing org-protocol data
(defvar cj/webclip-current-url nil
@@ -66,6 +55,9 @@
(defvar cj/webclipper-initialized nil
"Track if webclipper has been initialized.")
+(use-package org-web-tools
+ :defer t)
+
;; Lazy initialization function
(defun cj/webclipper-ensure-initialized ()
"Ensure webclipper is initialized when first used."
@@ -73,6 +65,7 @@
;; Load required packages now
(require 'org-protocol)
(require 'org-capture)
+ (require 'org-web-tools)
(require 'user-constants) ;; for webclipped-file
;; Register the org-protocol handler
@@ -102,7 +95,28 @@
(setq cj/webclipper-initialized t)))
-;;;###autoload
+(defun cj/--process-webclip-content (org-content)
+ "Process webclip ORG-CONTENT by removing first heading and demoting others.
+ORG-CONTENT is the raw org-mode text from the web page conversion.
+Returns the processed content as a string with:
+- First top-level heading removed
+- Initial blank lines removed
+- All remaining headings demoted by one level"
+ (with-temp-buffer
+ (insert org-content)
+ (goto-char (point-min))
+ ;; Skip the first heading line (we'll use our template's heading)
+ (when (looking-at "^\\* .*\n")
+ (delete-region (match-beginning 0) (match-end 0)))
+ ;; Remove any initial blank lines
+ (while (looking-at "^[ \t]*\n")
+ (delete-char 1))
+ ;; Demote all remaining headings by one level
+ ;; since our template already provides the top-level heading
+ (while (re-search-forward "^\\(\\*+\\) " nil t)
+ (replace-match (concat (match-string 1) "* ") t t))
+ (buffer-string)))
+
(defun cj/org-protocol-webclip (info)
"Process org-protocol webclip requests.
INFO is a plist containing :url and :title from the org-protocol call."
@@ -135,22 +149,7 @@ It fetches the page content and converts it to Org format."
(error "No URL provided for clipping")
(condition-case err
(let* ((org-content (org-web-tools--url-as-readable-org url))
- ;; Process the content to adjust heading levels
- (processed-content
- (with-temp-buffer
- (insert org-content)
- (goto-char (point-min))
- ;; Skip the first heading line (we'll use our template's heading)
- (when (looking-at "^\\* .*\n")
- (delete-region (match-beginning 0) (match-end 0)))
- ;; Remove any initial blank lines
- (while (looking-at "^[ \t]*\n")
- (delete-char 1))
- ;; Demote all remaining headings by one level
- ;; since our template already provides the top-level heading
- (while (re-search-forward "^\\(\\*+\\) " nil t)
- (replace-match (concat (match-string 1) "* ") t t))
- (buffer-string))))
+ (processed-content (cj/--process-webclip-content org-content)))
;; Show success message with the title
(require 'user-constants) ;; Ensure webclipped-file is available
(message "'%s' added to %s" title webclipped-file)
@@ -162,7 +161,7 @@ It fetches the page content and converts it to Org format."
;; ---------------------------- Org Webpage Clipper ----------------------------
-;;;###autoload
+
(defun cj/org-webclipper-EWW ()
"Capture the current web page for later viewing in an Org file.
Return the yanked content as a string so templates can insert it."
@@ -182,13 +181,11 @@ Return the yanked content as a string so templates can insert it."
;; extract the webpage content from the kill ring
(car kill-ring)))
-
;; ----------------------------- Webclipper Keymap -----------------------------
;; keymaps shouldn't be required for webclipper
-;; TASK Move org-branch to roam functionality under org-roam
;; Setup keymaps
-;; ;;;###autoload
+;;
;; (defun cj/webclipper-setup-keymaps ()
;; "Setup webclipper keymaps."
;; (define-prefix-command 'cj/webclipper-map nil
@@ -201,7 +198,6 @@ Return the yanked content as a string so templates can insert it."
;; (cj/webclipper-setup-keymaps))
;; Register protocol handler early for external calls
-;;;###autoload
(with-eval-after-load 'org-protocol
(unless (assoc "webclip" org-protocol-protocol-alist)
(add-to-list 'org-protocol-protocol-alist
@@ -210,9 +206,9 @@ Return the yanked content as a string so templates can insert it."
:function cj/org-protocol-webclip
:kill-client t))))
-(with-eval-after-load 'cj/custom-keymap
- (require 'org-webclipper)
- (cj/webclipper-setup-keymaps))
+;; (with-eval-after-load 'cj/custom-keymap
+;; (require 'org-webclipper)
+;; (cj/webclipper-setup-keymaps))
(provide 'org-webclipper)
;;; org-webclipper.el ends here
diff --git a/modules/popper-config.el b/modules/popper-config.el
index b0f503e8..359e789c 100644
--- a/modules/popper-config.el
+++ b/modules/popper-config.el
@@ -16,6 +16,7 @@
;;; Code:
(use-package popper
+ :disabled t
:bind (("C-`" . popper-toggle)
("M-`" . popper-cycle)
("C-M-`" . popper-toggle-type))
@@ -23,9 +24,11 @@
(popper-display-control-nil)
:init
(setq popper-reference-buffers
- '("\\*Messages\\*"
+ '(
+ ;; "\\*Messages\\*"
"Output\\*$"
"\\*Async Shell Command\\*"
+ "\\*Async-native-compile-log\\*"
help-mode
compilation-mode))
(add-to-list 'display-buffer-alist
diff --git a/modules/prog-general.el b/modules/prog-general.el
index f6ebfe09..0ae6aa82 100644
--- a/modules/prog-general.el
+++ b/modules/prog-general.el
@@ -93,8 +93,7 @@
;; --------------------------------- Treesitter --------------------------------
;; incremental language syntax parser
-
-(use-package tree-sitter)
+;; Using Emacs 29+ built-in treesit with treesit-auto for grammar management
;; installs tree-sitter grammars if they're absent
(use-package treesit-auto
@@ -251,7 +250,7 @@ If no such file exists there, display a message."
(deadgrep term dir))))
(with-eval-after-load 'dired
- (keymap-set dired-mode-map "d" #'cj/deadgrep-here))
+ (keymap-set dired-mode-map "G" #'cj/deadgrep-here))
;; ---------------------------------- Snippets ---------------------------------
@@ -264,12 +263,8 @@ If no such file exists there, display a message."
("C-c s n" . yas-new-snippet)
("C-c s e" . yas-visit-snippet-file)
:config
- (setq yas-snippet-dirs '(snippets-dir)))
-
-(use-package ivy-yasnippet
- :after yasnippet
- :bind
- ("C-c s i" . ivy-yasnippet))
+ (setq yas-snippet-dirs (list snippets-dir))
+ (yas-reload-all))
;; --------------------- Display Color On Color Declaration --------------------
;; display the actual color as highlight to color hex code
@@ -400,6 +395,15 @@ If no such file exists there, display a message."
"1.5 sec" nil 'delete-windows-on
(get-buffer-create "*compilation*"))))))
+;; which-key labels
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-c s" "snippets menu"
+ "C-c s n" "new snippet"
+ "C-c s e" "edit snippet"
+ "C-c s i" "insert snippet"
+ "C-c p" "projectile menu"
+ "C-c C-s" "symbol overlay"))
(provide 'prog-general)
;;; prog-general.el ends here
diff --git a/modules/prog-go.el b/modules/prog-go.el
index 465cbf14..0d271617 100644
--- a/modules/prog-go.el
+++ b/modules/prog-go.el
@@ -31,9 +31,6 @@ Install with: go install golang.org/x/tools/gopls@latest")
"Path to Delve debugger.
Install with: go install github.com/go-delve/delve/cmd/dlv@latest")
-(defvar go-ts-mode-map)
-(defvar go-mod-ts-mode-map)
-
;; Forward declarations for LSP
(declare-function lsp-deferred "lsp-mode")
(defvar lsp-go-gopls-server-path)
@@ -88,6 +85,9 @@ Install with: go install github.com/go-delve/delve/cmd/dlv@latest")
(defun cj/go-mode-keybindings ()
"Set up keybindings for Go programming.
Overrides default prog-mode keybindings with Go-specific commands."
+ ;; C-; f: Format with gofmt/goimports
+ (local-set-key (kbd "C-; f") #'gofmt)
+
;; S-f5: Run staticcheck (static analysis)
(local-set-key (kbd "S-<f5>") #'cj/go-staticcheck)
@@ -100,8 +100,6 @@ Overrides default prog-mode keybindings with Go-specific commands."
(use-package go-mode
:hook ((go-ts-mode . cj/go-setup)
(go-ts-mode . cj/go-mode-keybindings))
- :bind (:map go-ts-mode-map
- ("C-; f" . gofmt)) ;; Override global formatter with gofmt/goimports
:mode (("\\.go\\'" . go-ts-mode) ;; .go files use go-ts-mode
("go\\.mod\\'" . go-mod-ts-mode)) ;; go.mod uses go-mod-ts-mode
:config
diff --git a/modules/prog-lisp.el b/modules/prog-lisp.el
index 7693c253..cfa015ae 100644
--- a/modules/prog-lisp.el
+++ b/modules/prog-lisp.el
@@ -97,9 +97,12 @@
:commands (with-mock mocklet mocklet-function)) ;; mock/stub framework
;; --------------------------------- Elisp Lint --------------------------------
+;; Comprehensive linting for Emacs Lisp code (indentation, whitespace, etc.)
+;; Used by chime.el 'make lint' target for code quality checks
(use-package elisp-lint
- :commands (elisp-lint-file elisp-lint-directory))
+ :ensure t
+ :commands (elisp-lint-file elisp-lint-directory elisp-lint-files-batch))
;; ------------------------------ Package Tooling ------------------------------
diff --git a/modules/quick-video-capture.el b/modules/quick-video-capture.el
index 100cf04a..4e62309e 100644
--- a/modules/quick-video-capture.el
+++ b/modules/quick-video-capture.el
@@ -20,6 +20,8 @@
;;; Code:
+(require 'system-lib)
+
;; Declare external functions to avoid warnings
(declare-function org-capture "org-capture" (&optional goto keys))
(declare-function org-protocol-check-filename-for-protocol "org-protocol" (fname restoffiles client))
@@ -99,7 +101,7 @@ It's designed to be idempotent - safe to call multiple times."
:jump-to-captured nil)))
(setq cj/video-download-initialized t)
- (message "Video download functionality initialized")))
+ (cj/log-silently "Video download functionality initialized")))
(defun cj/video-download-bookmarklet-instructions ()
"Display instructions for setting up the browser bookmarklet."
diff --git a/modules/reconcile-open-repos.el b/modules/reconcile-open-repos.el
index 648de222..4bd0be7c 100644
--- a/modules/reconcile-open-repos.el
+++ b/modules/reconcile-open-repos.el
@@ -51,7 +51,7 @@ Magit for review."
;; if git directory is clean, pulling generates no errors
(if (string-empty-p (shell-command-to-string "git status --porcelain"))
(progn
- (let ((pull-result (shell-command "git pull --quiet")))
+ (let ((pull-result (shell-command "git pull --rebase --quiet")))
(unless (= pull-result 0)
(message "Warning: git pull failed for %s (exit code: %d)" directory pull-result))))
@@ -61,7 +61,7 @@ Magit for review."
(let ((stash-result (shell-command "git stash --quiet")))
(if (= stash-result 0)
(progn
- (let ((pull-result (shell-command "git pull --quiet")))
+ (let ((pull-result (shell-command "git pull --rebase --quiet")))
(if (= pull-result 0)
(let ((stash-pop-result (shell-command "git stash pop --quiet")))
(unless (= stash-pop-result 0)
@@ -73,7 +73,6 @@ Magit for review."
;; ---------------------------- Check For Open Work ----------------------------
-;;;###autoload
(defun cj/check-for-open-work ()
"Check all project directories for open work."
(interactive)
diff --git a/modules/selection-framework.el b/modules/selection-framework.el
index 66ca1cbd..5ace0a5f 100644
--- a/modules/selection-framework.el
+++ b/modules/selection-framework.el
@@ -27,14 +27,13 @@
(vertico-resize nil) ; Don't resize the minibuffer
(vertico-sort-function #'vertico-sort-history-alpha) ; History first, then alphabetical
:bind (:map vertico-map
- ;; Match ivy's C-j C-k behavior
- ("C-j" . vertico-next)
- ("C-k" . vertico-previous)
- ("C-l" . vertico-insert) ; Insert current candidate
- ("RET" . vertico-exit)
- ("C-RET" . vertico-exit-input)
- ("M-RET" . minibuffer-force-complete-and-exit)
- ("TAB" . minibuffer-complete))
+ ("C-j" . vertico-next)
+ ("C-k" . vertico-previous)
+ ("C-l" . vertico-insert) ; Insert current candidate
+ ("RET" . vertico-exit)
+ ("C-RET" . vertico-exit-input)
+ ("M-RET" . minibuffer-force-complete-and-exit)
+ ("TAB" . minibuffer-complete))
:init
(vertico-mode))
@@ -59,45 +58,45 @@
(use-package consult
:demand t
:bind (;; C-c bindings (mode-specific-map)
- ("C-c h" . consult-history)
- ;; C-x bindings (ctl-x-map)
- ("C-x M-:" . consult-complex-command)
- ("C-x b" . consult-buffer)
- ("C-x 4 b" . consult-buffer-other-window)
- ("C-x 5 b" . consult-buffer-other-frame)
- ("C-x r b" . consult-bookmark)
- ("C-x p b" . consult-project-buffer)
- ;; M-g bindings (goto-map)
- ("M-g e" . consult-compile-error)
- ("M-g f" . consult-flymake)
- ("M-g g" . consult-goto-line)
- ("M-g M-g" . consult-goto-line)
- ("M-g o" . consult-outline)
- ("M-g m" . consult-mark)
- ("M-g k" . consult-global-mark)
- ("M-g i" . consult-imenu)
- ("M-g I" . consult-imenu-multi)
- ;; M-s bindings (search-map)
- ("M-s d" . consult-find)
- ("M-s D" . consult-locate)
- ("M-s g" . consult-grep)
- ("M-s G" . consult-git-grep)
- ("M-s r" . consult-ripgrep)
- ("M-s l" . consult-line)
- ("M-s L" . consult-line-multi)
- ("M-s k" . consult-keep-lines)
- ("M-s u" . consult-focus-lines)
- ;; Isearch integration
- ("M-s e" . consult-isearch-history)
- :map isearch-mode-map
- ("M-e" . consult-isearch-history)
- ("M-s e" . consult-isearch-history)
- ("M-s l" . consult-line)
- ("M-s L" . consult-line-multi)
- ;; Minibuffer history
- :map minibuffer-local-map
- ("M-s" . consult-history)
- ("M-r" . consult-history))
+ ("C-c h" . consult-history)
+ ;; C-x bindings (ctl-x-map)
+ ("C-x M-:" . consult-complex-command)
+ ("C-x b" . consult-buffer)
+ ("C-x 4 b" . consult-buffer-other-window)
+ ("C-x 5 b" . consult-buffer-other-frame)
+ ("C-x r b" . consult-bookmark)
+ ("C-x p b" . consult-project-buffer)
+ ;; M-g bindings (goto-map)
+ ("M-g e" . consult-compile-error)
+ ("M-g f" . consult-flymake)
+ ("M-g g" . consult-goto-line)
+ ("M-g M-g" . consult-goto-line)
+ ("M-g o" . consult-outline)
+ ("M-g m" . consult-mark)
+ ("M-g k" . consult-global-mark)
+ ("M-g i" . consult-imenu)
+ ("M-g I" . consult-imenu-multi)
+ ;; M-s bindings (search-map)
+ ("M-s d" . consult-find)
+ ("M-s D" . consult-locate)
+ ("M-s g" . consult-grep)
+ ("M-s G" . consult-git-grep)
+ ("M-s r" . consult-ripgrep)
+ ("M-s l" . consult-line)
+ ("M-s L" . consult-line-multi)
+ ("M-s k" . consult-keep-lines)
+ ("M-s u" . consult-focus-lines)
+ ;; Isearch integration
+ ("M-s e" . consult-isearch-history)
+ :map isearch-mode-map
+ ("M-e" . consult-isearch-history)
+ ("M-s e" . consult-isearch-history)
+ ("M-s l" . consult-line)
+ ("M-s L" . consult-line-multi)
+ ;; Minibuffer history
+ :map minibuffer-local-map
+ ("M-s" . consult-history)
+ ("M-r" . consult-history))
:hook (completion-list-mode . consult-preview-at-point-mode)
@@ -106,14 +105,14 @@
;; preview for =consult-register', =consult-register-load',
;; =consult-register-store' and the Emacs built-ins.
(setq register-preview-delay 0.5
- register-preview-function #'consult-register-format)
+ register-preview-function #'consult-register-format)
;; Optionally tweak the register preview window.
(advice-add #'register-preview :override #'consult-register-window)
;; Configure other variables and modes
(setq xref-show-xrefs-function #'consult-xref
- xref-show-definitions-function #'consult-xref)
+ xref-show-definitions-function #'consult-xref)
:config
;; Configure preview. Default is 'any.
@@ -128,7 +127,7 @@
;; Use Consult for completion-at-point
(setq completion-in-region-function #'consult-completion-in-region))
-(global-unset-key (kbd "C-s"))
+;; Override default search with consult-line
(keymap-global-set "C-s" #'consult-line)
;; Consult integration with Embark
@@ -139,9 +138,9 @@
(use-package consult-dir
:bind (("C-x C-d" . consult-dir)
- :map vertico-map
- ("C-x C-d" . consult-dir)
- ("C-x C-j" . consult-dir-jump-file))
+ :map vertico-map
+ ("C-x C-d" . consult-dir)
+ ("C-x C-j" . consult-dir-jump-file))
:config
(add-to-list 'consult-dir-sources 'consult-dir--source-tramp-ssh t)
(setq consult-dir-project-list-function #'consult-dir-projectile-dirs))
@@ -152,14 +151,14 @@
(use-package orderless
:demand t
:custom
- (completion-styles '(orderless))
+ (completion-styles '(orderless basic))
(completion-category-defaults nil)
- (completion-category-overrides '((file (styles partial-completion))
- (multi-category (styles orderless))))
+ (completion-category-overrides '((file (styles partial-completion orderless basic))
+ (multi-category (styles orderless basic))))
(orderless-matching-styles '(orderless-literal
- orderless-regexp
- orderless-initialism
- orderless-prefixes)))
+ orderless-regexp
+ orderless-initialism
+ orderless-prefixes)))
;; ---------------------------------- Embark -----------------------------------
;; Contextual actions - provides right-click like functionality
@@ -179,20 +178,14 @@
:config
;; Hide the mode line of the Embark live/completions buffers
(add-to-list 'display-buffer-alist
- '("\\`\\*Embark Collect \\(Live\\|Completions\\)\\*"
- nil
- (window-parameters (mode-line-format . none)))))
-
-;; this typo causes crashes
-;; (add-to-list 'display-buffer-alist
-;; '("\\=\\*Embark Collect \\(Live\\|Completions\\)\\*"
-;; nil
-;; (window-parameters (mode-line-format . none)))))
+ '("\\`\\*Embark Collect \\(Live\\|Completions\\)\\*"
+ nil
+ (window-parameters (mode-line-format . none)))))
;; --------------------------- Consult Integration ----------------------------
;; Additional integrations for specific features
-;; Yasnippet integration - replaces ivy-yasnippet
+;; Yasnippet integration
(use-package consult-yasnippet
:after yasnippet
:bind ("C-c s i" . consult-yasnippet))
@@ -201,19 +194,19 @@
(use-package consult-flycheck
:after flycheck
:bind (:map flycheck-mode-map
- ("C-c ! c" . consult-flycheck)))
+ ("C-c ! c" . consult-flycheck)))
;; ---------------------------------- Company ----------------------------------
-;; In-buffer completion (retained from original configuration)
+;; In-buffer completion for text and code
(use-package company
:demand t
:hook (after-init . global-company-mode)
:bind
(:map company-active-map
- ("<tab>" . company-complete-selection)
- ("C-n" . company-select-next)
- ("C-p" . company-select-previous))
+ ("<tab>" . company-complete-selection)
+ ("C-n" . company-select-next)
+ ("C-p" . company-select-previous))
:custom
(company-backends '(company-capf company-files company-keywords))
(company-idle-delay 2)
@@ -227,9 +220,9 @@
:config
;; Disable company in mail-related modes
(setq company-global-modes
- '(not message-mode
- mu4e-compose-mode
- org-msg-edit-mode)))
+ '(not message-mode
+ mu4e-compose-mode
+ org-msg-edit-mode)))
(use-package company-quickhelp
@@ -259,5 +252,23 @@
:config
(company-prescient-mode))
+;; -------------------------- Consult Line Or Repeat -------------------------
+
+(defun cj/consult-line-or-repeat ()
+ "Call consult-line, or repeat last search if called twice."
+ (interactive)
+ (if (eq last-command 'cj/consult-line-or-repeat)
+ (vertico-repeat)
+ (consult-line)))
+(keymap-global-set "C-s" #'cj/consult-line-or-repeat)
+
+;; which-key labels
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-c h" "consult history"
+ "C-c s i" "insert snippet"
+ "M-g" "goto menu"
+ "M-s" "search menu"))
+
(provide 'selection-framework)
;;; selection-framework.el ends here
diff --git a/modules/system-commands.el b/modules/system-commands.el
new file mode 100644
index 00000000..fb8c0611
--- /dev/null
+++ b/modules/system-commands.el
@@ -0,0 +1,138 @@
+;;; system-commands.el --- System power and session management -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; System commands for logout, lock, suspend, shutdown, reboot, and Emacs
+;; exit/restart. Provides both a keymap (C-; !) and a completing-read menu.
+;;
+;; Commands include:
+;; - Logout (terminate user session)
+;; - Lock screen (slock)
+;; - Suspend (systemctl suspend)
+;; - Shutdown (systemctl poweroff)
+;; - Reboot (systemctl reboot)
+;; - Exit Emacs (kill-emacs)
+;; - Restart Emacs (via systemctl --user restart emacs.service)
+;;
+;; Dangerous commands (logout, suspend, shutdown, reboot) require confirmation.
+;;
+;;; Code:
+
+(eval-when-compile (require 'keybindings))
+(eval-when-compile (require 'subr-x))
+(require 'rx)
+
+;; ------------------------------ System Commands ------------------------------
+
+(defun cj/system-cmd--resolve (cmd)
+ "Return (values symbol-or-nil command-string label) for CMD."
+ (cond
+ ((symbolp cmd)
+ (let ((val (and (boundp cmd) (symbol-value cmd))))
+ (unless (and (stringp val) (not (string-empty-p val)))
+ (user-error "Variable %s is not a non-empty string" cmd))
+ (list cmd val (symbol-name cmd))))
+ ((stringp cmd)
+ (let ((s (string-trim cmd)))
+ (when (string-empty-p s) (user-error "Command string is empty"))
+ (list nil s "command")))
+ (t (user-error "Error: cj/system-cmd expects a string or a symbol"))))
+
+(defun cj/system-cmd (cmd)
+ "Run CMD (string or symbol naming a string) detached via the shell.
+Shell expansions like $(...) are supported. Output is silenced.
+If CMD is deemed dangerous, ask for confirmation."
+ (interactive (list (read-shell-command "System command: ")))
+ (pcase-let ((`(,sym ,cmdstr ,label) (cj/system-cmd--resolve cmd)))
+ (when (and sym (get sym 'cj/system-confirm)
+ (memq (read-char-choice
+ (format "Run %s now (%s)? (Y/n) " label cmdstr)
+ '(?y ?Y ?n ?N ?\r ?\n ?\s))
+ '(?n ?N)))
+ (user-error "Aborted"))
+ (let ((proc (start-process-shell-command "cj/system-cmd" nil
+ (format "nohup %s >/dev/null 2>&1 &" cmdstr))))
+ (set-process-query-on-exit-flag proc nil)
+ (set-process-sentinel proc #'ignore)
+ (message "Running %s..." label))))
+
+(defmacro cj/defsystem-command (name var cmdstr &optional confirm)
+ "Define VAR with CMDSTR and interactive command NAME to run it.
+If CONFIRM is non-nil, mark VAR to always require confirmation."
+ (declare (indent defun))
+ `(progn
+ (defvar ,var ,cmdstr)
+ ,(when confirm `(put ',var 'cj/system-confirm t))
+ (defun ,name ()
+ ,(format "Run %s via `cj/system-cmd'." var)
+ (interactive)
+ (cj/system-cmd ',var))))
+
+;; Define system commands
+(cj/defsystem-command cj/system-cmd-logout logout-cmd "loginctl terminate-user $(whoami)" t)
+(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd "slock")
+(cj/defsystem-command cj/system-cmd-suspend suspend-cmd "systemctl suspend" t)
+(cj/defsystem-command cj/system-cmd-shutdown shutdown-cmd "systemctl poweroff" t)
+(cj/defsystem-command cj/system-cmd-reboot reboot-cmd "systemctl reboot" t)
+
+(defun cj/system-cmd-exit-emacs ()
+ "Exit Emacs server and all clients."
+ (interactive)
+ (when (memq (read-char-choice
+ "Exit Emacs? (Y/n) "
+ '(?y ?Y ?n ?N ?\r ?\n ?\s))
+ '(?n ?N))
+ (user-error "Aborted"))
+ (kill-emacs))
+
+(defun cj/system-cmd-restart-emacs ()
+ "Restart Emacs server after saving buffers."
+ (interactive)
+ (when (memq (read-char-choice
+ "Restart Emacs? (Y/n) "
+ '(?y ?Y ?n ?N ?\r ?\n ?\s))
+ '(?n ?N))
+ (user-error "Aborted"))
+ (save-some-buffers)
+ ;; Start the restart process before killing Emacs
+ (run-at-time 0.5 nil
+ (lambda ()
+ (call-process-shell-command
+ "systemctl --user restart emacs.service && emacsclient -c"
+ nil 0)))
+ (run-at-time 1 nil #'kill-emacs)
+ (message "Restarting Emacs..."))
+
+(defvar-keymap cj/system-command-map
+ :doc "Keymap for system commands."
+ "L" #'cj/system-cmd-logout
+ "r" #'cj/system-cmd-reboot
+ "s" #'cj/system-cmd-shutdown
+ "S" #'cj/system-cmd-suspend
+ "l" #'cj/system-cmd-lock
+ "E" #'cj/system-cmd-exit-emacs
+ "e" #'cj/system-cmd-restart-emacs)
+(keymap-set cj/custom-keymap "!" cj/system-command-map)
+
+(defun cj/system-command-menu ()
+ "Present system commands via \='completing-read\='."
+ (interactive)
+ (let* ((commands '(("Logout System" . cj/system-cmd-logout)
+ ("Lock Screen" . cj/system-cmd-lock)
+ ("Suspend System" . cj/system-cmd-suspend)
+ ("Shutdown System" . cj/system-cmd-shutdown)
+ ("Reboot System" . cj/system-cmd-reboot)
+ ("Exit Emacs" . cj/system-cmd-exit-emacs)
+ ("Restart Emacs" . cj/system-cmd-restart-emacs)))
+ (choice (completing-read "System command: " commands nil t)))
+ (when-let ((cmd (alist-get choice commands nil nil #'equal)))
+ (call-interactively cmd))))
+
+(keymap-set cj/custom-keymap "!" #'cj/system-command-menu)
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements "C-; !" "system commands"))
+
+(provide 'system-commands)
+;;; system-commands.el ends here
diff --git a/modules/system-defaults.el b/modules/system-defaults.el
index 52607121..715dcda6 100644
--- a/modules/system-defaults.el
+++ b/modules/system-defaults.el
@@ -229,6 +229,13 @@ Used to disable functionality with defalias \='somefunc \='cj/disabled)."
(setq kept-new-versions 25) ;; keep 25 of the newest backups made (default: 2)
(setq vc-make-backup-files t) ;; also backup any files in version control
+;; ------------------ Unpropertize Kill Ring For Performance -----------------
+
+(defun unpropertize-kill-ring ()
+ (setq kill-ring (mapcar 'substring-no-properties kill-ring)))
+
+(add-hook 'kill-emacs-hook 'unpropertize-kill-ring)
+
;; ------------------------------- GNU 'ls' On BSD -------------------------------
(when (env-bsd-p)
diff --git a/modules/system-lib.el b/modules/system-lib.el
new file mode 100644
index 00000000..4c2f17ef
--- /dev/null
+++ b/modules/system-lib.el
@@ -0,0 +1,31 @@
+;;; system-lib.el --- System utility library functions -*- lexical-binding: t; -*-
+;;
+;;; Commentary:
+;; This module provides low-level system utility functions for checking
+;; the availability of external programs and system capabilities.
+;;
+;; Functions include:
+;; - Checking if external programs are available in PATH
+;; - Silent logging to *Messages* buffer
+;;
+;;; Code:
+
+(defun cj/executable-exists-p (program)
+ "Return non-nil if PROGRAM is available in PATH.
+PROGRAM should be a string naming an executable program."
+ (and (stringp program)
+ (not (string-empty-p program))
+ (executable-find program)))
+
+(defun cj/log-silently (format-string &rest args)
+ "Append formatted message (FORMAT-STRING with ARGS) to *Messages* buffer.
+This does so without echoing in the minibuffer."
+ (let ((inhibit-read-only t))
+ (with-current-buffer (get-buffer-create "*Messages*")
+ (goto-char (point-max))
+ (unless (bolp) (insert "\n"))
+ (insert (apply #'format format-string args))
+ (unless (bolp) (insert "\n")))))
+
+(provide 'system-lib)
+;;; system-lib.el ends here
diff --git a/modules/system-utils.el b/modules/system-utils.el
index 6e51c32c..f5af18de 100644
--- a/modules/system-utils.el
+++ b/modules/system-utils.el
@@ -43,6 +43,9 @@
(message "Error occurred during evaluation: %s" (error-message-string err)))))
(keymap-global-set "C-c b" #'cj/eval-buffer-with-confirmation-or-error-message)
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements "C-c b" "eval buffer"))
+
;;; ---------------------------- Edit A File With Sudo ----------------------------
(use-package sudo-edit
@@ -60,7 +63,7 @@ fully detached from Emacs."
(let* ((file (cond
;; In dired/dirvish mode, get file at point
((derived-mode-p 'dired-mode)
- (dired-get-file-for-visit))
+ (dired-get-file-for-visit))
;; In a regular file buffer
(buffer-file-name
buffer-file-name)
@@ -131,13 +134,40 @@ Logs output and exit code to buffer *external-open.log*."
(keymap-global-set "C-<f10>" #'cj/server-shutdown)
;;; ---------------------------- History Persistence ----------------------------
-;; Persist history over Emacs restarts
(use-package savehist
:ensure nil ; built-in
:config
- (savehist-mode)
- (setq savehist-file "~/.emacs.d/.emacs-history"))
+ (setq kill-ring-max 50
+ history-length 50)
+
+ (setq savehist-additional-variables
+ '(kill-ring
+ command-history
+ set-variable-value-history
+ custom-variable-history
+ query-replace-history
+ read-expression-history
+ minibuffer-history
+ read-char-history
+ face-name-history
+ bookmark-history
+ file-name-history))
+
+ (put 'minibuffer-history 'history-length 50)
+ (put 'file-name-history 'history-length 50)
+ (put 'set-variable-value-history 'history-length 25)
+ (put 'custom-variable-history 'history-length 25)
+ (put 'query-replace-history 'history-length 25)
+ (put 'read-expression-history 'history-length 25)
+ (put 'read-char-history 'history-length 25)
+ (put 'face-name-history 'history-length 25)
+ (put 'bookmark-history 'history-length 25)
+
+ (setq history-delete-duplicates t)
+ (let (message-log-max)
+ (savehist-mode))
+ )
;;; ------------------------ List Buffers With Nerd Icons -----------------------
@@ -156,34 +186,33 @@ Logs output and exit code to buffer *external-open.log*."
;;; -------------------------- Scratch Buffer Happiness -------------------------
(defvar scratch-emacs-version-and-system
- (concat ";; Emacs " emacs-version
- " on " system-configuration ".\n"))
-(defvar scratch-greet
- (concat ";; Emacs ♥ you, " user-login-name ". Happy Hacking!\n\n"))
+ (concat "# Emacs " emacs-version " ♥ you, " user-login-name ". Happy Hacking!\n"))
+(defvar scratch-greet "\n")
(setopt initial-scratch-message
(concat scratch-emacs-version-and-system scratch-greet))
+;; Set scratch buffer to org-mode
+(setopt initial-major-mode 'org-mode)
+
+;; Move cursor to end of scratch buffer on startup and set font size to 16pt
+(add-hook 'emacs-startup-hook
+ (lambda ()
+ (when (get-buffer "*scratch*")
+ (with-current-buffer "*scratch*"
+ (buffer-face-set :height 160) ; 160 = 16pt (height is in 1/10pt units)
+ (goto-char (point-max))))))
+
;;; --------------------------------- Dictionary --------------------------------
(use-package quick-sdcv
:bind
("C-h d" . quick-sdcv-search-input)
+ :bind (:map quick-sdcv-mode-map
+ ("q" . quit-window))
:custom
(quick-sdcv-dictionary-prefix-symbol "►")
(quick-sdcv-ellipsis " ▼"))
-;;; -------------------------------- Log Silently -------------------------------
-
-(defun cj/log-silently (format-string &rest args)
- "Append formatted message (FORMAT-STRING with ARGS) to *Messages* buffer.
-This does so without echoing in the minibuffer."
- (let ((inhibit-read-only t))
- (with-current-buffer (get-buffer-create "*Messages*")
- (goto-char (point-max))
- (unless (bolp) (insert "\n"))
- (insert (apply #'format format-string args))
- (unless (bolp) (insert "\n")))))
-
;;; ------------------------------ Process Monitor ------------------------------
(use-package proced
diff --git a/modules/test-runner.el b/modules/test-runner.el
index b4c40820..125a8d20 100644
--- a/modules/test-runner.el
+++ b/modules/test-runner.el
@@ -2,26 +2,75 @@
;; author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
-;; Provides utilities for running ERT tests with focus/unfocus workflow
+
+;; This module provides a powerful ERT test runner with focus/unfocus workflow
+;; for efficient test-driven development in Emacs Lisp projects.
+;;
+;; PURPOSE:
+;;
+;; When working on large Emacs Lisp projects with many test files, you often
+;; want to focus on running just the tests relevant to your current work without
+;; waiting for the entire suite to run. This module provides a smart test runner
+;; that supports both running all tests and focusing on specific test files.
+;;
+;; WORKFLOW:
+;;
+;; 1. Run all tests initially to establish baseline (C-; t R)
+;; 2. Add test files to focus while working on a feature (C-; t a)
+;; 3. Run focused tests repeatedly as you develop (C-; t r)
+;; 4. Add more test files as needed (C-; t b from within test buffer)
+;; 5. View your focused test list at any time (C-; t v)
+;; 6. Clear focus and run all tests before finishing (C-; t c, then C-; t R)
+;;
+;; PROJECT INTEGRATION:
;;
-;; Tests should be located in the Projectile project test directories,
-;; typically "test" or "tests" under the project root.
-;; Falls back to =~/.emacs.d/tests= if not in a Projectile project.
+;; - Automatically discovers test directories in Projectile projects
+;; (looks for "test" or "tests" under project root)
+;; - Falls back to ~/.emacs.d/tests if not in a Projectile project
+;; - Test files must match pattern: test-*.el
;;
-;; The default mode is to load and run all tests.
+;; SPECIAL BEHAVIORS:
;;
-;; To focus on running a specific set of test files:
-;; - Toggle the mode to "focus" mode
-;; - Add specific test files to the list of tests in "focus"
-;; - Running tests (smartly) will now just run those tests
+;; - Smart test running: Automatically runs all or focused tests based on mode
+;; - Test extraction: Discovers test names via regex to run specific tests
+;; - At-point execution: Run individual test at cursor position (C-; t .)
+;; - Error handling: Continues loading tests even if individual files fail
;;
-;; Don't forget to run all tests again in default mode at least once before finishing.
+;; KEYBINDINGS:
+;;
+;; C-; t L Load all test files
+;; C-; t R Run all tests (full suite)
+;; C-; t r Run tests smartly (all or focused based on mode)
+;; C-; t . Run test at point
+;; C-; t a Add test file to focus (with completion)
+;; C-; t b Add current buffer's test file to focus
+;; C-; t c Clear all focused test files
+;; C-; t v View list of focused test files
+;; C-; t t Toggle mode between 'all and 'focused
+;;
+;; RECOMMENDED USAGE:
+;;
+;; While implementing a feature:
+;; - Add the main test file for the feature you're working on
+;; - Add any related test files that might be affected
+;; - Use C-; t r to repeatedly run just those focused tests
+;; - This provides fast feedback during development
+;;
+;; Before committing:
+;; - Clear the focus with C-; t c
+;; - Run the full suite with C-; t R to ensure nothing broke
+;; - Verify all tests pass before pushing changes
;;
;;; Code:
(require 'ert)
(require 'cl-lib)
+;;; External Variables and Functions
+
+(defvar cj/custom-keymap) ; Defined in init.el
+(declare-function projectile-project-root "projectile" ())
+
;;; Variables
(defvar cj/test-global-directory nil
@@ -35,19 +84,19 @@ Each element is a filename (without path) to run.")
(defvar cj/test-mode 'all
"Current test execution mode.
-Either 'all (run all tests) or 'focused (run only focused tests).")
+Either \\='all (run all tests) or \\='focused (run only focused tests).")
(defvar cj/test-last-results nil
"Results from the last test run.")
;;; Core Functions
-;;;###autoload
(defun cj/test--get-test-directory ()
"Return the test directory path for the current project.
-If in a Projectile project, prefers a 'test' or 'tests' directory inside the project root.
-Falls back to =cj/test-global-directory= if not found or not in a project."
+If in a Projectile project, prefers \\='test or \\='tests directory
+inside the project root. Falls back to `cj/test-global-directory'
+if not found or not in a project."
(require 'projectile)
(let ((project-root (ignore-errors (projectile-project-root))))
(if (not (and project-root (file-directory-p project-root)))
@@ -60,15 +109,32 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
((file-directory-p tests-dir) tests-dir)
(t cj/test-global-directory))))))
-;;;###autoload
(defun cj/test--get-test-files ()
- "Return a list of test file names (without path) in the appropriate test directory."
+ "Return list of test file names (without path) in test directory."
(let ((dir (cj/test--get-test-directory)))
(when (file-directory-p dir)
(mapcar #'file-name-nondirectory
(directory-files dir t "^test-.*\\.el$")))))
-;;;###autoload
+(defun cj/test--do-load-files (_dir files)
+ "Load test FILES from DIR.
+Returns: (cons \\='success loaded-count) on success,
+ (cons \\='error (list failed-files errors)) on errors."
+ (let ((loaded-count 0)
+ (errors '()))
+ (dolist (file files)
+ (condition-case err
+ (progn
+ (load-file file)
+ (setq loaded-count (1+ loaded-count)))
+ (error
+ (push (cons (file-name-nondirectory file)
+ (error-message-string err))
+ errors))))
+ (if (null errors)
+ (cons 'success loaded-count)
+ (cons 'error (list loaded-count (nreverse errors))))))
+
(defun cj/test-load-all ()
"Load all test files from the appropriate test directory."
(interactive)
@@ -76,21 +142,27 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
(let ((dir (cj/test--get-test-directory)))
(unless (file-directory-p dir)
(user-error "Test directory %s does not exist" dir))
- (let ((test-files (directory-files dir t "^test-.*\\.el$"))
- (loaded-count 0))
- (dolist (file test-files)
- (condition-case err
- (progn
- (load-file file)
- (setq loaded-count (1+ loaded-count))
- (message "Loaded test file: %s" (file-name-nondirectory file)))
- (error
- (message "Error loading %s: %s"
- (file-name-nondirectory file)
- (error-message-string err)))))
- (message "Loaded %d test file(s)" loaded-count))))
-
-;;;###autoload
+ (let ((test-files (directory-files dir t "^test-.*\\.el$")))
+ (pcase (cj/test--do-load-files dir test-files)
+ (`(success . ,count)
+ (message "Loaded %d test file(s)" count))
+ (`(error ,count ,errors)
+ (dolist (err errors)
+ (message "Error loading %s: %s" (car err) (cdr err)))
+ (message "Loaded %d test file(s) with %d error(s)" count (length errors)))))))
+
+(defun cj/test--do-focus-add (filename available-files focused-files)
+ "Add FILENAME to focused test files.
+AVAILABLE-FILES is the list of all available test files.
+FOCUSED-FILES is the current list of focused files.
+Returns: \\='success if added successfully,
+ \\='already-focused if file is already focused,
+ \\='not-available if file is not in available-files."
+ (cond
+ ((not (member filename available-files)) 'not-available)
+ ((member filename focused-files) 'already-focused)
+ (t 'success)))
+
(defun cj/test-focus-add ()
"Select test file(s) to add to the focused list."
(interactive)
@@ -109,30 +181,64 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
unfocused-files
nil t)
(user-error "All test files are already focused"))))
- (push selected cj/test-focused-files)
- (message "Added to focus: %s" selected)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused))))))
+ (pcase (cj/test--do-focus-add selected available-files cj/test-focused-files)
+ ('success
+ (push selected cj/test-focused-files)
+ (message "Added to focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused)))
+ ('already-focused
+ (message "Already focused: %s" selected))
+ ('not-available
+ (user-error "File not available: %s" selected)))))))
+
+(defun cj/test--do-focus-add-file (filepath testdir focused-files)
+ "Validate and add FILEPATH to focused list.
+TESTDIR is the test directory path.
+FOCUSED-FILES is the current list of focused files.
+Returns: \\='success if added successfully,
+ \\='no-file if filepath is nil,
+ \\='not-in-testdir if file is not inside test directory,
+ \\='already-focused if file is already focused.
+Second value is the relative filename if successful."
+ (cond
+ ((null filepath) (cons 'no-file nil))
+ ((not (string-prefix-p (file-truename testdir) (file-truename filepath)))
+ (cons 'not-in-testdir nil))
+ (t
+ (let ((relative (file-relative-name filepath testdir)))
+ (if (member relative focused-files)
+ (cons 'already-focused relative)
+ (cons 'success relative))))))
-;;;###autoload
(defun cj/test-focus-add-this-buffer-file ()
"Add the current buffer's file to the focused test list."
(interactive)
(let ((file (buffer-file-name))
(dir (cj/test--get-test-directory)))
- (unless file
- (user-error "Current buffer is not visiting a file"))
- (unless (string-prefix-p (file-truename dir) (file-truename file))
- (user-error "File is not inside the test directory: %s" dir))
- (let ((relative (file-relative-name file dir)))
- (if (member relative cj/test-focused-files)
- (message "Already focused: %s" relative)
- (push relative cj/test-focused-files)
- (message "Added to focus: %s" relative)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused))))))
-
-;;;###autoload
+ (pcase (cj/test--do-focus-add-file file dir cj/test-focused-files)
+ (`(no-file . ,_)
+ (user-error "Current buffer is not visiting a file"))
+ (`(not-in-testdir . ,_)
+ (user-error "File is not inside the test directory: %s" dir))
+ (`(already-focused . ,relative)
+ (message "Already focused: %s" relative))
+ (`(success . ,relative)
+ (push relative cj/test-focused-files)
+ (message "Added to focus: %s" relative)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused))))))
+
+(defun cj/test--do-focus-remove (filename focused-files)
+ "Remove FILENAME from FOCUSED-FILES.
+Returns: \\='success if removed successfully,
+ \\='empty-list if focused-files is empty,
+ \\='not-found if filename is not in focused-files."
+ (cond
+ ((null focused-files) 'empty-list)
+ ((not (member filename focused-files)) 'not-found)
+ (t 'success)))
+
(defun cj/test-focus-remove ()
"Remove a test file from the focused list."
(interactive)
@@ -141,13 +247,18 @@ Falls back to =cj/test-global-directory= if not found or not in a project."
(let ((selected (completing-read "Remove from focus: "
cj/test-focused-files
nil t)))
- (setq cj/test-focused-files
- (delete selected cj/test-focused-files))
- (message "Removed from focus: %s" selected)
- (when (called-interactively-p 'interactive)
- (cj/test-view-focused)))))
+ (pcase (cj/test--do-focus-remove selected cj/test-focused-files)
+ ('success
+ (setq cj/test-focused-files
+ (delete selected cj/test-focused-files))
+ (message "Removed from focus: %s" selected)
+ (when (called-interactively-p 'interactive)
+ (cj/test-view-focused)))
+ ('not-found
+ (message "File not in focused list: %s" selected))
+ ('empty-list
+ (user-error "No focused files to remove"))))))
-;;;###autoload
(defun cj/test-focus-clear ()
"Clear all focused test files."
(interactive)
@@ -168,73 +279,82 @@ Returns a list of test name symbols defined in the file."
(push (match-string 1) test-names)))
test-names))
-;;;###autoload
+(defun cj/test--do-get-focused-tests (focused-files test-dir)
+ "Get test names from FOCUSED-FILES in TEST-DIR.
+Returns: (cons \\='success (list test-names loaded-count)) if successful,
+ (cons \\='no-tests nil) if no tests found,
+ (cons \\='empty-list nil) if focused-files is empty."
+ (if (null focused-files)
+ (cons 'empty-list nil)
+ (let ((all-test-names '())
+ (loaded-count 0))
+ (dolist (file focused-files)
+ (let ((full-path (expand-file-name file test-dir)))
+ (when (file-exists-p full-path)
+ (load-file full-path)
+ (setq loaded-count (1+ loaded-count))
+ (let ((test-names (cj/test--extract-test-names full-path)))
+ (setq all-test-names (append all-test-names test-names))))))
+ (if (null all-test-names)
+ (cons 'no-tests nil)
+ (cons 'success (list all-test-names loaded-count))))))
+
(defun cj/test-run-focused ()
"Run only the focused test files."
(interactive)
- (if (null cj/test-focused-files)
- (user-error "No focused files set. Use =cj/test-focus-add' first")
- (let ((all-test-names '())
- (loaded-count 0)
- (dir (cj/test--get-test-directory)))
- ;; Load the focused files and collect their test names
- (dolist (file cj/test-focused-files)
- (let ((full-path (expand-file-name file dir)))
- (when (file-exists-p full-path)
- (load-file full-path)
- (setq loaded-count (1+ loaded-count))
- ;; Extract test names from this file
- (let ((test-names (cj/test--extract-test-names full-path)))
- (setq all-test-names (append all-test-names test-names))))))
- (if (null all-test-names)
- (message "No tests found in focused files")
- ;; Build a regexp that matches any of our test names
- (let ((pattern (regexp-opt all-test-names)))
- (message "Running %d test(s) from %d focused file(s)"
- (length all-test-names) loaded-count)
- ;; Run only the tests we found
- (ert (concat "^" pattern "$")))))))
+ (let ((dir (cj/test--get-test-directory)))
+ (pcase (cj/test--do-get-focused-tests cj/test-focused-files dir)
+ (`(empty-list . ,_)
+ (user-error "No focused files set. Use =cj/test-focus-add' first"))
+ (`(no-tests . ,_)
+ (message "No tests found in focused files"))
+ (`(success ,test-names ,loaded-count)
+ (let ((pattern (regexp-opt test-names)))
+ (message "Running %d test(s) from %d focused file(s)"
+ (length test-names) loaded-count)
+ (ert (concat "^" pattern "$")))))))
(defun cj/test--ensure-test-dir-in-load-path ()
- "Ensure the directory returned by cj/test--get-test-directory is in `load-path`."
+ "Ensure test directory is in `load-path'."
(let ((dir (cj/test--get-test-directory)))
(when (and dir (file-directory-p dir))
(add-to-list 'load-path dir))))
-;;;###autoload
+(defun cj/test--extract-test-at-pos ()
+ "Extract test name at current position.
+Returns: test name symbol if found, nil otherwise."
+ (save-excursion
+ (beginning-of-defun)
+ (condition-case nil
+ (let ((form (read (current-buffer))))
+ (when (and (listp form)
+ (eq (car form) 'ert-deftest)
+ (symbolp (cadr form)))
+ (cadr form)))
+ (error nil))))
+
(defun cj/run-test-at-point ()
"Run the ERT test at point.
If point is inside an `ert-deftest` definition, run that test only.
Otherwise, message that no test is found."
(interactive)
- (let ((original-point (point)))
- (save-excursion
- (beginning-of-defun)
- (condition-case nil
- (let ((form (read (current-buffer))))
- (if (and (listp form)
- (eq (car form) 'ert-deftest)
- (symbolp (cadr form)))
- (ert (cadr form))
- (message "Not in an ERT test method.")))
- (error (message "No ERT test methods found at point."))))
- (goto-char original-point)))
-
-;;;###autoload
+ (let ((test-name (cj/test--extract-test-at-pos)))
+ (if test-name
+ (ert test-name)
+ (message "Not in an ERT test method."))))
+
(defun cj/test-run-all ()
"Load and run all tests."
(interactive)
(cj/test-load-all)
(ert t))
-;;;###autoload
(defun cj/test-toggle-mode ()
- "Toggle between 'all and 'focused test execution modes."
+ "Toggle between \\='all and \\='focused test execution modes."
(interactive)
(setq cj/test-mode (if (eq cj/test-mode 'all) 'focused 'all))
(message "Test mode: %s" cj/test-mode))
-;;;###autoload
(defun cj/test-view-focused ()
"Display test files in focus."
(interactive)
@@ -243,7 +363,6 @@ Otherwise, message that no test is found."
(message "Focused files: %s"
(mapconcat 'identity cj/test-focused-files ", "))))
-;;;###autoload
(defun cj/test-run-smart ()
"Run tests based on current mode (all or focused)."
(interactive)
@@ -265,8 +384,20 @@ Otherwise, message that no test is found."
"t" #'cj/test-toggle-mode)
(keymap-set cj/custom-keymap "t" cj/testrunner-map)
+
+;; which-key integration
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; t" "test runner menu"))
+ (which-key-add-key-based-replacements
+ "C-; t" "test runner menu"
+ "C-; t L" "load all tests"
+ "C-; t R" "run all tests"
+ "C-; t r" "run smart"
+ "C-; t ." "run test at point"
+ "C-; t a" "add to focus"
+ "C-; t b" "add buffer to focus"
+ "C-; t c" "clear focus"
+ "C-; t v" "view focused"
+ "C-; t t" "toggle mode"))
(provide 'test-runner)
;;; test-runner.el ends here
diff --git a/modules/text-config.el b/modules/text-config.el
index 730e36a3..29db9e0b 100644
--- a/modules/text-config.el
+++ b/modules/text-config.el
@@ -46,8 +46,7 @@
;; change inner and outer, just like in vim.
(use-package change-inner
- :bind (("C-c i" . change-inner)
- ("C-c o" . change-outer)))
+ :commands (change-inner change-outer))
;; ------------------------------ Delete Selection -----------------------------
;; delete the region on character insertion
diff --git a/modules/transcription-config.el b/modules/transcription-config.el
new file mode 100644
index 00000000..5349ade0
--- /dev/null
+++ b/modules/transcription-config.el
@@ -0,0 +1,397 @@
+;;; transcription-config.el --- Audio transcription workflow -*- lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Created: 2025-11-04
+
+;;; Commentary:
+;;
+;; Audio transcription workflow with multiple backend options.
+;;
+;; USAGE:
+;; In dired: Press `T` on an audio file to transcribe
+;; Anywhere: M-x cj/transcribe-audio
+;; View active: M-x cj/transcriptions-buffer
+;; Switch backend: C-; T b (or M-x cj/transcription-switch-backend)
+;;
+;; OUTPUT FILES:
+;; audio.m4a → audio.txt (transcript)
+;; → audio.log (process logs, conditionally kept)
+;;
+;; BACKENDS:
+;; - 'openai-api: Fast cloud transcription
+;; API key retrieved from authinfo.gpg (machine api.openai.com)
+;; - 'assemblyai: Cloud transcription with speaker diarization
+;; API key retrieved from authinfo.gpg (machine api.assemblyai.com)
+;; - 'local-whisper: Local transcription (requires whisper installed)
+;;
+;; NOTIFICATIONS:
+;; - "Transcription started on <file>"
+;; - "Transcription complete. Transcript in <file.txt>"
+;; - "Transcription errored. Logs in <file.log>"
+;;
+;; MODELINE:
+;; Shows active transcription count: ⏺2
+;; Click to view *Transcriptions* buffer
+;;
+;;; Code:
+
+(require 'dired)
+(require 'notifications)
+(require 'auth-source)
+(require 'user-constants) ; For cj/audio-file-extensions
+
+;; Declare keymap defined in keybindings.el
+(eval-when-compile (defvar cj/custom-keymap))
+
+;; ----------------------------- Configuration ---------------------------------
+
+(defvar cj/transcribe-backend 'assemblyai
+ "Transcription backend to use.
+- `openai-api': Fast cloud transcription via OpenAI API
+- `assemblyai': Cloud transcription with speaker diarization via AssemblyAI
+- `local-whisper': Local transcription using installed Whisper")
+
+(defvar cj/transcription-keep-log-when-done nil
+ "Whether to keep log files after successful transcription.
+If nil, log files are deleted after successful completion.
+If t, log files are always kept.
+Log files are always kept on error regardless of this setting.")
+
+(defvar cj/transcriptions-list '()
+ "List of active transcriptions.
+Each entry: (process audio-file start-time status)
+Status: running, complete, error")
+
+;; ----------------------------- Pure Functions --------------------------------
+
+(defun cj/--audio-file-p (file)
+ "Return non-nil if FILE is an audio file based on extension."
+ (when (and file (stringp file))
+ (when-let ((ext (file-name-extension file)))
+ (member (downcase ext) cj/audio-file-extensions))))
+
+(defun cj/--transcription-output-files (audio-file)
+ "Return cons cell of (TXT-FILE . LOG-FILE) for AUDIO-FILE."
+ (let ((base (file-name-sans-extension audio-file)))
+ (cons (concat base ".txt")
+ (concat base ".log"))))
+
+(defun cj/--transcription-duration (start-time)
+ "Return duration string (MM:SS) since START-TIME."
+ (let* ((elapsed (float-time (time-subtract (current-time) start-time)))
+ (minutes (floor (/ elapsed 60)))
+ (seconds (floor (mod elapsed 60))))
+ (format "%02d:%02d" minutes seconds)))
+
+(defun cj/--should-keep-log (success-p)
+ "Return non-nil if log file should be kept.
+SUCCESS-P indicates whether transcription succeeded."
+ (or (not success-p) ; Always keep on error
+ cj/transcription-keep-log-when-done))
+
+(defun cj/--transcription-script-path ()
+ "Return absolute path to transcription script based on backend."
+ (let ((script-name (pcase cj/transcribe-backend
+ ('openai-api "oai-transcribe")
+ ('assemblyai "assemblyai-transcribe")
+ ('local-whisper "local-whisper"))))
+ (expand-file-name (concat "scripts/" script-name) user-emacs-directory)))
+
+(defun cj/--get-openai-api-key ()
+ "Retrieve OpenAI API key from authinfo.gpg.
+Expects entry in authinfo.gpg:
+ machine api.openai.com login api password sk-...
+Returns the API key string, or nil if not found."
+ (when-let* ((auth-info (car (auth-source-search
+ :host "api.openai.com"
+ :require '(:secret))))
+ (secret (plist-get auth-info :secret)))
+ (if (functionp secret)
+ (funcall secret)
+ secret)))
+
+(defun cj/--get-assemblyai-api-key ()
+ "Retrieve AssemblyAI API key from authinfo.gpg.
+Expects entry in authinfo.gpg:
+ machine api.assemblyai.com login api password <key>
+Returns the API key string, or nil if not found."
+ (when-let* ((auth-info (car (auth-source-search
+ :host "api.assemblyai.com"
+ :require '(:secret))))
+ (secret (plist-get auth-info :secret)))
+ (if (functionp secret)
+ (funcall secret)
+ secret)))
+
+;; ---------------------------- Process Management -----------------------------
+
+(defun cj/--notify (title message &optional urgency)
+ "Send desktop notification and echo area message.
+TITLE and MESSAGE are strings. URGENCY is normal or critical."
+ (message "%s: %s" title message)
+ (when (and (fboundp 'notifications-notify)
+ (getenv "DISPLAY"))
+ (notifications-notify
+ :title title
+ :body message
+ :urgency (or urgency 'normal))))
+
+(defun cj/--start-transcription-process (audio-file)
+ "Start async transcription process for AUDIO-FILE.
+Returns the process object."
+ (unless (file-exists-p audio-file)
+ (user-error "Audio file does not exist: %s" audio-file))
+
+ (unless (cj/--audio-file-p audio-file)
+ (user-error "Not an audio file: %s" audio-file))
+
+ (let* ((script (cj/--transcription-script-path))
+ (outputs (cj/--transcription-output-files audio-file))
+ (txt-file (car outputs))
+ (log-file (cdr outputs))
+ (buffer-name (format " *transcribe-%s*" (file-name-nondirectory audio-file)))
+ (process-name (format "transcribe-%s" (file-name-nondirectory audio-file))))
+
+ (unless (file-executable-p script)
+ (user-error "Transcription script not found or not executable: %s" script))
+
+ ;; Create log file
+ (with-temp-file log-file
+ (insert (format "Transcription started: %s\n" (current-time-string))
+ (format "Backend: %s\n" cj/transcribe-backend)
+ (format "Audio file: %s\n" audio-file)
+ (format "Script: %s\n\n" script)))
+
+ ;; Start process with environment
+ (let* ((process-environment
+ ;; Add API key to environment based on backend
+ (pcase cj/transcribe-backend
+ ('openai-api
+ (if-let ((api-key (cj/--get-openai-api-key)))
+ (cons (format "OPENAI_API_KEY=%s" api-key)
+ process-environment)
+ (user-error "OpenAI API key not found in authinfo.gpg for host api.openai.com")))
+ ('assemblyai
+ (if-let ((api-key (cj/--get-assemblyai-api-key)))
+ (cons (format "ASSEMBLYAI_API_KEY=%s" api-key)
+ process-environment)
+ (user-error "AssemblyAI API key not found in authinfo.gpg for host api.assemblyai.com")))
+ (_ process-environment)))
+ (process (make-process
+ :name process-name
+ :buffer (get-buffer-create buffer-name)
+ :command (list script audio-file)
+ :sentinel (lambda (proc event)
+ (cj/--transcription-sentinel proc event audio-file txt-file log-file))
+ :stderr log-file)))
+
+ ;; Track transcription
+ (push (list process audio-file (current-time) 'running) cj/transcriptions-list)
+ (force-mode-line-update t)
+
+ ;; Notify user
+ (cj/--notify "Transcription"
+ (format "Started on %s" (file-name-nondirectory audio-file)))
+
+ process)))
+
+(defun cj/--transcription-sentinel (process event audio-file txt-file log-file)
+ "Sentinel for transcription PROCESS.
+EVENT is the process event string.
+AUDIO-FILE, TXT-FILE, and LOG-FILE are the associated files."
+ (let* ((success-p (and (string-match-p "finished" event)
+ (= 0 (process-exit-status process))))
+ (process-buffer (process-buffer process))
+ (entry (assq process cj/transcriptions-list)))
+
+ ;; Write process output to txt file
+ (when (and success-p (buffer-live-p process-buffer))
+ (with-current-buffer process-buffer
+ (write-region (point-min) (point-max) txt-file nil 'silent)))
+
+ ;; Append process output to log file
+ (when (buffer-live-p process-buffer)
+ (with-temp-buffer
+ (insert-file-contents log-file)
+ (goto-char (point-max))
+ (insert "\n" (format-time-string "[%Y-%m-%d %H:%M:%S] ") event "\n")
+ (insert-buffer-substring process-buffer)
+ (write-region (point-min) (point-max) log-file nil 'silent)))
+
+ ;; Update transcription status
+ (when entry
+ (setf (nth 3 entry) (if success-p 'complete 'error)))
+
+ ;; Cleanup log file if successful and configured to do so
+ (when (and success-p (not (cj/--should-keep-log t)))
+ (delete-file log-file))
+
+ ;; Kill process buffer
+ (when (buffer-live-p process-buffer)
+ (kill-buffer process-buffer))
+
+ ;; Notify user
+ (if success-p
+ (cj/--notify "Transcription"
+ (format "Complete. Transcript in %s" (file-name-nondirectory txt-file)))
+ (cj/--notify "Transcription"
+ (format "Errored. Logs in %s" (file-name-nondirectory log-file))
+ 'critical))
+
+ ;; Clean up completed transcriptions after 10 minutes
+ (run-at-time 600 nil #'cj/--cleanup-completed-transcriptions)
+
+ ;; Update modeline
+ (force-mode-line-update t)))
+
+(defun cj/--cleanup-completed-transcriptions ()
+ "Remove completed/errored transcriptions from tracking list."
+ (setq cj/transcriptions-list
+ (seq-filter (lambda (entry)
+ (eq (nth 3 entry) 'running))
+ cj/transcriptions-list))
+ (force-mode-line-update t))
+
+(defun cj/--count-active-transcriptions ()
+ "Return count of running transcriptions."
+ (length (seq-filter (lambda (entry)
+ (eq (nth 3 entry) 'running))
+ cj/transcriptions-list)))
+
+;; ----------------------------- Modeline Integration --------------------------
+
+(defun cj/--transcription-modeline-string ()
+ "Return modeline string for active transcriptions."
+ (let ((count (cj/--count-active-transcriptions)))
+ (when (> count 0)
+ (propertize (format " ⏺%d " count)
+ 'face 'warning
+ 'help-echo (format "%d active transcription%s (click to view)"
+ count (if (= count 1) "" "s"))
+ 'mouse-face 'mode-line-highlight
+ 'local-map (let ((map (make-sparse-keymap)))
+ (define-key map [mode-line mouse-1]
+ #'cj/transcriptions-buffer)
+ map)))))
+
+;; Add to mode-line-format (will be activated when module loads)
+(add-to-list 'mode-line-misc-info
+ '(:eval (cj/--transcription-modeline-string))
+ t)
+
+;; --------------------------- Interactive Commands ----------------------------
+
+;;;###autoload
+(defun cj/transcribe-audio (audio-file)
+ "Transcribe AUDIO-FILE asynchronously.
+Creates AUDIO.txt with transcript and AUDIO.log with process logs.
+Uses backend specified by `cj/transcribe-backend'."
+ (interactive (list (read-file-name "Audio file to transcribe: "
+ nil nil t nil
+ #'cj/--audio-file-p)))
+ (cj/--start-transcription-process (expand-file-name audio-file)))
+
+;;;###autoload
+(defun cj/transcribe-audio-at-point ()
+ "Transcribe audio file at point in dired."
+ (interactive)
+ (unless (derived-mode-p 'dired-mode)
+ (user-error "Not in dired-mode"))
+ (let ((file (dired-get-filename nil t)))
+ (unless file
+ (user-error "No file at point"))
+ (cj/transcribe-audio file)))
+
+;;;###autoload
+(defun cj/transcriptions-buffer ()
+ "Show buffer with active transcriptions."
+ (interactive)
+ (let ((buffer (get-buffer-create "*Transcriptions*")))
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (insert (propertize "Active Transcriptions\n" 'face 'bold)
+ (propertize (make-string 50 ?─) 'face 'shadow)
+ "\n\n")
+ (if (null cj/transcriptions-list)
+ (insert "No active transcriptions.\n")
+ (dolist (entry cj/transcriptions-list)
+ (let* ((process (nth 0 entry))
+ (audio-file (nth 1 entry))
+ (start-time (nth 2 entry))
+ (status (nth 3 entry))
+ (duration (cj/--transcription-duration start-time))
+ (status-face (pcase status
+ ('running 'warning)
+ ('complete 'success)
+ ('error 'error))))
+ (insert (propertize (format "%-10s" status) 'face status-face)
+ " "
+ (file-name-nondirectory audio-file)
+ (format " (%s)\n" duration))))))
+ (goto-char (point-min))
+ (special-mode))
+ (display-buffer buffer)))
+
+;;;###autoload
+(defun cj/transcription-kill (process)
+ "Kill transcription PROCESS."
+ (interactive
+ (list (let ((choices (mapcar (lambda (entry)
+ (cons (file-name-nondirectory (nth 1 entry))
+ (nth 0 entry)))
+ cj/transcriptions-list)))
+ (unless choices
+ (user-error "No active transcriptions"))
+ (cdr (assoc (completing-read "Kill transcription: " choices nil t)
+ choices)))))
+ (when (process-live-p process)
+ (kill-process process)
+ (message "Killed transcription process")))
+
+;;;###autoload
+(defun cj/transcription-switch-backend ()
+ "Switch transcription backend.
+Prompts with completing-read to select from available backends."
+ (interactive)
+ (let* ((backends '(("assemblyai" . assemblyai)
+ ("openai-api" . openai-api)
+ ("local-whisper" . local-whisper)))
+ (current (symbol-name cj/transcribe-backend))
+ (prompt (format "Transcription backend (current: %s): " current))
+ (choice (completing-read prompt backends nil t))
+ (new-backend (alist-get choice backends nil nil #'string=)))
+ (setq cj/transcribe-backend new-backend)
+ (message "Transcription backend: %s" choice)))
+
+;; ------------------------------- Dired Integration ---------------------------
+
+(with-eval-after-load 'dired
+ (define-key dired-mode-map (kbd "T") #'cj/transcribe-audio-at-point))
+
+;; Dirvish inherits dired-mode-map, so T works automatically
+
+;; ------------------------------- Global Keybindings --------------------------
+
+;; Transcription keymap
+(defvar-keymap cj/transcribe-map
+ :doc "Keymap for transcription operations"
+ "a" #'cj/transcribe-audio
+ "b" #'cj/transcription-switch-backend
+ "v" #'cj/transcriptions-buffer
+ "k" #'cj/transcription-kill)
+
+;; Only set keybinding if cj/custom-keymap is bound (not in batch mode)
+(when (boundp 'cj/custom-keymap)
+ (keymap-set cj/custom-keymap "T" cj/transcribe-map))
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; T" "transcription menu"
+ "C-; T a" "transcribe audio"
+ "C-; T b" "switch backend"
+ "C-; T v" "view transcriptions"
+ "C-; T k" "kill transcription"))
+
+(provide 'transcription-config)
+;;; transcription-config.el ends here
diff --git a/modules/ui-config.el b/modules/ui-config.el
index 91dbaf31..775aefb2 100644
--- a/modules/ui-config.el
+++ b/modules/ui-config.el
@@ -36,11 +36,8 @@
"Opacity level for Emacs frames when `cj/enable-transparency' is non-nil.
100 = fully opaque, 0 = fully transparent.")
-(defconst cj/cursor-colors
- '((read-only . "#f06a3f") ; red – buffer is read-only
- (overwrite . "#c48702") ; gold – overwrite mode
- (normal . "#64aa0f")) ; green – insert & read/write
- "Alist mapping cursor states to their colors.")
+;; Use buffer status colors from user-constants
+(require 'user-constants)
;; ----------------------------- System UI Settings ----------------------------
@@ -53,7 +50,8 @@
(setq use-file-dialog nil) ;; no file dialog
(setq use-dialog-box nil) ;; no dialog boxes either
-(column-number-mode 1) ;; show column number in the modeline
+(line-number-mode 1) ;; show line number in the modeline (cached)
+(column-number-mode 1) ;; show column number in the modeline (cached)
(setq switch-to-buffer-obey-display-actions t) ;; manual buffer switching obeys display action rules
;; -------------------------------- Transparency -------------------------------
@@ -99,23 +97,27 @@ When `cj/enable-transparency' is nil, reset alpha to fully opaque."
"Last buffer name where cursor color was applied.")
(defun cj/set-cursor-color-according-to-mode ()
- "Change cursor color according to \\='buffer-read-only or \\='overwrite state."
- (let* ((state (cond
- (buffer-read-only 'read-only)
- (overwrite-mode 'overwrite)
- (t 'normal)))
- (color (alist-get state cj/cursor-colors)))
- (unless (and (string= color cj/-cursor-last-color)
- (string= (buffer-name) cj/-cursor-last-buffer))
- (set-cursor-color color)
- (setq cj/-cursor-last-color color
- cj/-cursor-last-buffer (buffer-name)))))
-
-;; Use more efficient hooks instead of post-command-hook for better performance
-(add-hook 'window-buffer-change-functions
- (lambda (_window) (cj/set-cursor-color-according-to-mode)))
-(add-hook 'read-only-mode-hook #'cj/set-cursor-color-according-to-mode)
-(add-hook 'overwrite-mode-hook #'cj/set-cursor-color-according-to-mode)
+ "Change cursor color according to buffer state (modified, read-only, overwrite).
+Only updates for real user buffers, not internal/temporary buffers."
+ ;; Only update cursor for real buffers (not internal ones like *temp*, *Echo Area*, etc.)
+ (unless (string-prefix-p " " (buffer-name)) ; Internal buffers start with space
+ (let* ((state (cond
+ (buffer-read-only 'read-only)
+ (overwrite-mode 'overwrite)
+ ((buffer-modified-p) 'modified)
+ (t 'unmodified)))
+ (color (alist-get state cj/buffer-status-colors)))
+ ;; Only skip if BOTH color AND buffer are the same (optimization)
+ ;; This allows color to update when buffer state changes
+ (unless (and (string= color cj/-cursor-last-color)
+ (string= (buffer-name) cj/-cursor-last-buffer))
+ (set-cursor-color color)
+ (setq cj/-cursor-last-color color
+ cj/-cursor-last-buffer (buffer-name))))))
+
+;; Use post-command-hook to update cursor color after every command
+;; This ensures cursor color always matches the current buffer's state
+(add-hook 'post-command-hook #'cj/set-cursor-color-according-to-mode)
;; Don’t show a cursor in non-selected windows:
(setq cursor-in-non-selected-windows nil)
diff --git a/modules/undead-buffers.el b/modules/undead-buffers.el
index 50c9bb9c..fa09e04a 100644
--- a/modules/undead-buffers.el
+++ b/modules/undead-buffers.el
@@ -2,12 +2,12 @@
;;; Commentary:
;;
-;; This library allows for “burying” selected buffers instead of killing them.
+;; This library allows for "burying" selected buffers instead of killing them.
;; Since they won't be killed, I'm calling them "undead buffers".
;; The main function cj/kill-buffer-or-bury-alive replaces kill-buffer.
;;
;; Additional helper commands and key bindings:
-;; - M-C (=cj/kill-buffer-and-window=): delete this window and bury/kill its buffer.
+;; - C-; b k (=cj/kill-buffer-and-window=): delete this window and bury/kill its buffer.
;; - M-O (=cj/kill-other-window=): delete the next window and bury/kill its buffer.
;; - M-M (=cj/kill-all-other-buffers-and-windows=): kill or bury all buffers except
;; the current one and delete all other windows.
@@ -65,7 +65,7 @@ ARG is passed to `save-some-buffers'."
(unless (one-window-p)
(delete-window))
(cj/kill-buffer-or-bury-alive buf)))
-(keymap-global-set "M-C" #'cj/kill-buffer-and-window)
+;; Keybinding moved to custom-buffer-file.el (C-; b k)
(defun cj/kill-other-window ()
"Delete the next window and kill or bury its buffer."
diff --git a/modules/user-constants.el b/modules/user-constants.el
index 59129697..3b248ddd 100644
--- a/modules/user-constants.el
+++ b/modules/user-constants.el
@@ -20,6 +20,15 @@
;;
;;; Code:
+;; -------------------------------- Debug Toggle -------------------------------
+
+(defvar cj/debug-modules nil
+ "List of modules with debug functions enabled.
+Possible values: org-agenda, mail, chime, etc.
+Set to t to enable all debug modules.
+Example: (setq cj/debug-modules '(org-agenda mail))
+ (setq cj/debug-modules t) ; Enable all")
+
;; -------------------------------- Contact Info -------------------------------
(defvar user-whole-name "Craig Jennings"
@@ -29,6 +38,24 @@
(defvar user-mail-address "c@cjennings.net"
"The user's email address.")
+;; ---------------------------- Buffer Status Colors ---------------------------
+
+(defconst cj/buffer-status-colors
+ '((read-only . "#f06a3f") ; red – buffer is read-only
+ (overwrite . "#c48702") ; gold – overwrite mode
+ (modified . "#64aa0f") ; green – modified & writeable
+ (unmodified . "#ffffff")) ; white – unmodified & writeable
+ "Alist mapping buffer states to their colors.
+Used by cursor color, modeline, and other UI elements.")
+
+;; --------------------------- Media File Extensions ---------------------------
+
+(defvar cj/audio-file-extensions
+ '("m4a" "mp3" "wav" "flac" "ogg" "opus" "aac"
+ "aiff" "aif" "wma" "ape" "alac" "weba")
+ "File extensions recognized as audio files.
+Used by transcription module and other audio-related functionality.")
+
;; ------------------------ Directory And File Constants -----------------------
;; DIRECTORIES
@@ -100,8 +127,13 @@
(defvar schedule-file (expand-file-name "schedule.org" org-dir)
"The location of the org file containing scheduled events.")
-(defvar gcal-file (expand-file-name "gcal.org" org-dir)
- "The location of the org file containing Google Calendar information.")
+(defvar gcal-file (expand-file-name "data/gcal.org" user-emacs-directory)
+ "The location of the org file containing Google Calendar information.
+Stored in .emacs.d/data/ so each machine syncs independently from Google Calendar.")
+
+(defvar pcal-file (expand-file-name "data/pcal.org" user-emacs-directory)
+ "The location of the org file containing Proton Calendar information.
+Stored in .emacs.d/data/ so each machine syncs independently from Proton Calendar.")
(defvar reference-file (expand-file-name "reference.org" org-dir)
"The location of the org file containing reference information.")
diff --git a/modules/vc-config.el b/modules/vc-config.el
index 3b116cc1..7865d0f4 100644
--- a/modules/vc-config.el
+++ b/modules/vc-config.el
@@ -35,6 +35,13 @@
(setq magit-clone-set-remote.pushDefault 'ask) ;; ask if origin is default
) ;; end use-package magit
+;; Git Clone from Clipboard
+(defvar cj/git-clone-dirs
+ (list code-dir ;; Already configured in your init
+ "~/projects/"
+ user-emacs-directory) ;; For cloning Emacs packages
+ "List of directories to choose from when cloning with prefix argument.")
+
;; --------------------------------- Git Gutter --------------------------------
;; mark changed lines since last commit in the margin
@@ -115,23 +122,108 @@
(forge-create-issue)
(user-error "Not in a forge repository")))
+(defun cj/goto-git-gutter-diff-hunks ()
+ "Jump to git-gutter diff hunks using consult.
+Searches for lines starting with + or - (diff markers) and allows
+interactive selection to jump to any changed line in the buffer."
+ (interactive)
+ (require 'git-gutter)
+ (consult-line "^[+\\-]"))
+
+;; ------------------------------ Git Clone Clipboard -----------------------------
+;; Quick git clone from clipboard URL
+;; Based on: https://xenodium.com/bending-emacs-episode-3-git-clone-the-lazy-way
+
+(defun cj/git-clone-clipboard-url (url target-dir)
+ "Clone git repository from clipboard URL to TARGET-DIR.
+
+With no prefix argument: uses first directory in `cj/git-clone-dirs'.
+With \\[universal-argument]: choose from `cj/git-clone-dirs'.
+With \\[universal-argument] \\[universal-argument]: choose any directory.
+
+After cloning, opens the repository's README file if found."
+ (interactive
+ (list (current-kill 0) ;; Get URL from clipboard
+ (cond
+ ;; C-u C-u: Choose any directory
+ ((equal current-prefix-arg '(16))
+ (read-directory-name "Clone to: " code-dir))
+ ;; C-u: Choose from configured list
+ (current-prefix-arg
+ (completing-read "Clone to: " cj/git-clone-dirs nil t))
+ ;; No prefix: Use default (first in list)
+ (t (car cj/git-clone-dirs)))))
+
+ (let* ((default-directory target-dir)
+ (repo-name (file-name-sans-extension
+ (file-name-nondirectory url)))
+ (clone-dir (expand-file-name repo-name target-dir)))
+
+ ;; Clone the repository
+ (message "Cloning %s to %s..." url target-dir)
+ (shell-command (format "git clone %s" (shell-quote-argument url)))
+
+ ;; Find and open README
+ (when (file-directory-p clone-dir)
+ (let ((readme (seq-find
+ (lambda (file)
+ (string-match-p "^README" (upcase file)))
+ (directory-files clone-dir))))
+ (if readme
+ (find-file (expand-file-name readme clone-dir))
+ (dired clone-dir))))))
+
+;; -------------------------------- Difftastic ---------------------------------
+;; Structural diffs for better git change visualization
+;; Requires: difft binary (installed via pacman -S difftastic)
+
+(use-package difftastic
+ :defer t
+ :commands (difftastic-magit-diff difftastic-magit-show)
+ :bind (:map magit-blame-read-only-mode-map
+ ("D" . difftastic-magit-show)
+ ("S" . difftastic-magit-show))
+ :config
+ (eval-after-load 'magit-diff
+ '(transient-append-suffix 'magit-diff '(-1 -1)
+ [("D" "Difftastic diff (dwim)" difftastic-magit-diff)
+ ("S" "Difftastic show" difftastic-magit-show)])))
+
;; --------------------------------- VC Keymap ---------------------------------
;; Ordering & sorting prefix and keymap
(defvar-keymap cj/vc-map
:doc "Keymap for version control operations"
+ "c" #'cj/git-clone-clipboard-url
"d" #'cj/goto-git-gutter-diff-hunks
- "c" #'cj/forge-create-issue
"f" #'forge-pull
- "i" #'forge-list-issues
"n" #'git-gutter:next-hunk
"p" #'git-gutter:previous-hunk
"r" #'forge-list-pullreqs
"t" #'cj/git-timemachine)
+;; Issues submenu under C-; v i
+(defvar-keymap cj/vc-issues-map
+ :doc "Keymap for forge issue operations"
+ "c" #'cj/forge-create-issue
+ "l" #'forge-list-issues)
+
+(keymap-set cj/vc-map "i" cj/vc-issues-map)
+
(keymap-set cj/custom-keymap "v" cj/vc-map)
(with-eval-after-load 'which-key
- (which-key-add-key-based-replacements "C-; v" "version control menu"))
+ (which-key-add-key-based-replacements
+ "C-; v" "version control menu"
+ "C-; v c" "clone from clipboard"
+ "C-; v d" "goto diff hunks"
+ "C-; v f" "forge pull"
+ "C-; v i" "issues menu"
+ "C-; v i c" "create issue"
+ "C-; v i l" "list issues"
+ "C-; v n" "next hunk"
+ "C-; v p" "previous hunk"
+ "C-; v r" "list pull requests"
+ "C-; v t" "git timemachine"))
(provide 'vc-config)
;;; vc-config.el ends here.
diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el
index fa4c2926..b3151dba 100644
--- a/modules/video-audio-recording.el
+++ b/modules/video-audio-recording.el
@@ -3,12 +3,56 @@
;;
;;; Commentary:
;; Use ffmpeg to record desktop video or just audio.
-;; with audio from mic and audio from default audio sink
-;; Also supports audio-only recording in Opus format.
+;; Records audio from both microphone and system audio (for calls/meetings).
+;; Audio recordings use M4A/AAC format for best compatibility.
;;
;; Note: video-recordings-dir and audio-recordings-dir are defined
;; (and directory created) in user-constants.el
;;
+;; Quick Start
+;; ===========
+;; 1. Press C-; r a (start/stop audio recording)
+;; 2. First time: you'll be prompted for device setup
+;; 3. Choose "Bluetooth Headset" (or your device)
+;; 4. Recording starts - you'll see 🔴Audio in your modeline
+;; 5. Press C-; r a again to stop (🔴 disappears)
+;;
+;; Device Setup (First Time Only)
+;; ===============================
+;; C-; r a automatically prompts for device selection on first use.
+;; Device selection persists across Emacs sessions.
+;;
+;; Manual device selection:
+;;
+;; C-; r c (cj/recording-quick-setup-for-calls) - RECOMMENDED
+;; Quick setup: picks one device for both mic and monitor.
+;; Perfect for calls, meetings, or when using headset.
+;;
+;; C-; r s (cj/recording-select-devices) - ADVANCED
+;; Manual selection: choose mic and monitor separately.
+;; Use when you need different devices for input/output.
+;;
+;; C-; r d (cj/recording-list-devices)
+;; List all available audio devices and current configuration.
+;;
+;; C-; r w (cj/recording-show-active-audio) - DIAGNOSTIC TOOL
+;; Show which apps are currently playing audio and through which device.
+;; Use this DURING a phone call to see if the call audio is going through
+;; the device you think it is. Helps diagnose "missing one side" issues.
+;;
+;; Testing Devices Before Important Recordings
+;; ============================================
+;; Always test devices before important meetings/calls:
+;;
+;; C-; r t b (cj/recording-test-both) - RECOMMENDED
+;; Guided test: mic only, monitor only, then both together.
+;; Catches hardware issues before they ruin recordings!
+;;
+;; C-; r t m (cj/recording-test-mic)
+;; Quick 5-second mic test with playback.
+;;
+;; C-; r t s (cj/recording-test-monitor)
+;; Quick 5-second system audio test with playback.
;;
;; To adjust volumes:
;; - Use =M-x cj/recording-adjust-volumes= (or your keybinding =r l=)
@@ -20,6 +64,8 @@
;;
;;; Code:
+(require 'system-lib)
+
;; Forward declarations
(eval-when-compile (defvar video-recordings-dir))
(eval-when-compile (defvar audio-recordings-dir))
@@ -29,9 +75,10 @@
"Volume multiplier for microphone in recordings.
1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB).")
-(defvar cj/recording-system-volume 0.5
+(defvar cj/recording-system-volume 2.0
"Volume multiplier for system audio in recordings.
-1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB).")
+1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB).
+Default is 2.0 because the pan filter reduces by 50%, so final level is 1.0x.")
(defvar cj/recording-mic-device nil
"PulseAudio device name for microphone input.
@@ -47,6 +94,36 @@ If nil, will auto-detect on first use.")
(defvar cj/audio-recording-ffmpeg-process nil
"Variable to store the process of the ffmpeg audio recording.")
+;; Modeline recording indicator
+(defun cj/recording-modeline-indicator ()
+ "Return modeline string showing active recordings.
+Shows 🔴 when recording (audio and/or video).
+Checks if process is actually alive, not just if variable is set."
+ (let ((audio-active (and cj/audio-recording-ffmpeg-process
+ (process-live-p cj/audio-recording-ffmpeg-process)))
+ (video-active (and cj/video-recording-ffmpeg-process
+ (process-live-p cj/video-recording-ffmpeg-process))))
+ (cond
+ ((and audio-active video-active) " 🔴A+V ")
+ (audio-active " 🔴Audio ")
+ (video-active " 🔴Video ")
+ (t ""))))
+
+(defun cj/recording-process-sentinel (process event)
+ "Sentinel for recording processes to clean up and update modeline.
+PROCESS is the ffmpeg process, EVENT describes what happened."
+ (when (memq (process-status process) '(exit signal))
+ ;; Process ended - clear the variable
+ (cond
+ ((eq process cj/audio-recording-ffmpeg-process)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (message "Audio recording stopped: %s" (string-trim event)))
+ ((eq process cj/video-recording-ffmpeg-process)
+ (setq cj/video-recording-ffmpeg-process nil)
+ (message "Video recording stopped: %s" (string-trim event))))
+ ;; Force modeline update
+ (force-mode-line-update t)))
+
(defun cj/recording-check-ffmpeg ()
"Check if ffmpeg is available.
Return t if found, nil otherwise."
@@ -55,63 +132,332 @@ Return t if found, nil otherwise."
nil)
t)
-(defun cj/recording-detect-mic-device ()
- "Auto-detect PulseAudio microphone input device.
-Returns device name or nil if not found."
- (let ((output (shell-command-to-string "pactl list sources short 2>/dev/null")))
- (when (string-match "\\([^\t\n]+\\).*analog.*stereo" output)
- (match-string 1 output))))
+;; Auto-detection functions removed - they were unreliable and preferred built-in
+;; audio over Bluetooth/USB devices. Use explicit device selection instead:
+;; - C-; r c (cj/recording-quick-setup-for-calls) - recommended for most use cases
+;; - C-; r s (cj/recording-select-devices) - manual selection of mic + monitor
-(defun cj/recording-detect-system-device ()
- "Auto-detect PulseAudio system audio monitor device.
-Returns device name or nil if not found."
- (let ((output (shell-command-to-string "pactl list sources short 2>/dev/null")))
- (when (string-match "\\([^\t\n]+\\.monitor\\)" output)
- (match-string 1 output))))
+(defun cj/recording--parse-pactl-output (output)
+ "Internal parser for pactl sources output. Takes OUTPUT string.
+Returns list of (device-name driver state) tuples.
+Extracted for testing without shell command execution."
+ (let ((sources nil))
+ (dolist (line (split-string output "\n" t))
+ (when (string-match "^[0-9]+\t\\([^\t]+\\)\t\\([^\t]+\\)\t\\([^\t]+\\)\t\\([^\t]+\\)" line)
+ (let ((device (match-string 1 line))
+ (driver (match-string 2 line))
+ (state (match-string 4 line)))
+ (push (list device driver state) sources))))
+ (nreverse sources)))
-(defun cj/recording-get-devices ()
- "Get or auto-detect audio devices.
-Returns (mic-device . system-device) or nil on error."
- ;; Auto-detect if not already set
+(defun cj/recording-parse-sources ()
+ "Parse pactl sources output into structured list.
+Returns list of (device-name driver state) tuples."
+ (cj/recording--parse-pactl-output
+ (shell-command-to-string "pactl list sources short 2>/dev/null")))
+
+(defun cj/recording-friendly-state (state)
+ "Convert technical state name to user-friendly label.
+STATE is the raw state from pactl (SUSPENDED, RUNNING, IDLE, etc.)."
+ (pcase state
+ ("SUSPENDED" "Ready")
+ ("RUNNING" "Active")
+ ("IDLE" "Ready")
+ (_ state))) ; fallback to original if unknown
+
+(defun cj/recording-list-devices ()
+ "Show all available audio sources in a readable format.
+Opens a buffer showing devices with their states."
+ (interactive)
+ (let ((sources (cj/recording-parse-sources)))
+ (with-current-buffer (get-buffer-create "*Recording Devices*")
+ (erase-buffer)
+ (insert "Available Audio Sources\n")
+ (insert "========================\n\n")
+ (insert "Note: 'Ready' devices are available and will activate when recording starts.\n\n")
+ (insert "Current Configuration:\n")
+ (insert (format " Microphone: %s\n" (or cj/recording-mic-device "Not set")))
+ (insert (format " System Audio: %s\n\n" (or cj/recording-system-device "Not set")))
+ (insert "Available Devices:\n\n")
+ (if sources
+ (dolist (source sources)
+ (let ((device (nth 0 source))
+ (driver (nth 1 source))
+ (state (nth 2 source))
+ (friendly-state (cj/recording-friendly-state (nth 2 source))))
+ (insert (format "%-10s [%s]\n" friendly-state driver))
+ (insert (format " %s\n\n" device))))
+ (insert " No audio sources found. Is PulseAudio/PipeWire running?\n"))
+ (goto-char (point-min))
+ (special-mode))
+ (switch-to-buffer-other-window "*Recording Devices*")))
+
+(defun cj/recording-show-active-audio ()
+ "Show which audio sinks are currently PLAYING audio in real-time.
+Useful for diagnosing why phone call audio isn't being captured - helps identify
+which device the phone app is actually using for output."
+ (interactive)
+ (let ((output (shell-command-to-string "pactl list sink-inputs")))
+ (with-current-buffer (get-buffer-create "*Active Audio Playback*")
+ (erase-buffer)
+ (insert "Active Audio Playback (Updated: " (format-time-string "%H:%M:%S") ")\n")
+ (insert "======================================================\n\n")
+ (insert "This shows which applications are CURRENTLY playing audio and through which device.\n")
+ (insert "If you're on a phone call, you should see the phone app listed here.\n")
+ (insert "The 'Sink' line shows which output device it's using.\n\n")
+ (if (string-match-p "Sink Input" output)
+ (progn
+ (insert output)
+ (insert "\n\nTIP: The '.monitor' device corresponding to the 'Sink' above is what\n")
+ (insert "you need to select for system audio to capture the other person's voice.\n\n")
+ (insert "For example, if Sink is 'alsa_output.usb...Jabra...analog-stereo',\n")
+ (insert "then you need 'alsa_output.usb...Jabra...analog-stereo.monitor'\n"))
+ (insert "No active audio playback detected.\n\n")
+ (insert "This means no applications are currently playing audio.\n")
+ (insert "If you're on a phone call and see this, the phone app might be:\n")
+ (insert " 1. Using a different audio system (not PulseAudio/PipeWire)\n")
+ (insert " 2. Using a Bluetooth device directly (bypassing system audio)\n")
+ (insert " 3. Not actually playing audio (check if you can hear the other person)\n"))
+ (goto-char (point-min))
+ (special-mode))
+ (switch-to-buffer-other-window "*Active Audio Playback*")
+ (message "Showing active audio playback. Press 'g' to refresh, 'q' to quit.")))
+
+(defun cj/recording-select-device (prompt device-type)
+ "Interactively select an audio device.
+PROMPT is shown to user. DEVICE-TYPE is 'mic or 'monitor for filtering.
+Returns selected device name or nil."
+ (let* ((sources (cj/recording-parse-sources))
+ (filtered (if (eq device-type 'monitor)
+ (seq-filter (lambda (s) (string-match-p "\\.monitor$" (car s))) sources)
+ (seq-filter (lambda (s) (not (string-match-p "\\.monitor$" (car s)))) sources)))
+ (choices (mapcar (lambda (s)
+ (let ((device (nth 0 s))
+ (driver (nth 1 s))
+ (state (nth 2 s))
+ (friendly-state (cj/recording-friendly-state (nth 2 s))))
+ (cons (format "%-10s %s" friendly-state device) device)))
+ filtered)))
+ (if choices
+ (cdr (assoc (completing-read prompt choices nil t) choices))
+ (user-error "No %s devices found" (if (eq device-type 'monitor) "monitor" "input")))))
+
+(defun cj/recording-select-devices ()
+ "Interactively select microphone and system audio devices.
+Sets cj/recording-mic-device and cj/recording-system-device."
+ (interactive)
+ (setq cj/recording-mic-device
+ (cj/recording-select-device "Select microphone device: " 'mic))
+ (setq cj/recording-system-device
+ (cj/recording-select-device "Select system audio monitor: " 'monitor))
+ (message "Devices set - Mic: %s, System: %s"
+ cj/recording-mic-device
+ cj/recording-system-device))
+
+(defun cj/recording-group-devices-by-hardware ()
+ "Group audio sources by hardware device.
+Returns alist of (device-name . (mic-source . monitor-source))."
+ (let ((sources (cj/recording-parse-sources))
+ (devices (make-hash-table :test 'equal))
+ (result nil))
+ ;; Group sources by base device name
+ (dolist (source sources)
+ (let* ((device (nth 0 source))
+ (driver (nth 1 source))
+ ;; Extract hardware ID (the unique part that identifies the physical device)
+ (base-name (cond
+ ;; USB devices: extract usb-XXXXX-XX part
+ ((string-match "\\.\\(usb-[^.]+\\-[0-9]+\\)\\." device)
+ (match-string 1 device))
+ ;; Built-in (pci) devices: extract pci-XXXXX part
+ ((string-match "\\.\\(pci-[^.]+\\)\\." device)
+ (match-string 1 device))
+ ;; Bluetooth devices: extract and normalize MAC address
+ ;; (input uses colons, output uses underscores - normalize to colons)
+ ((string-match "bluez_\\(?:input\\|output\\)\\.\\([^.]+\\)" device)
+ (replace-regexp-in-string "_" ":" (match-string 1 device)))
+ (t device)))
+ (is-monitor (string-match-p "\\.monitor$" device))
+ (device-entry (gethash base-name devices)))
+ (unless device-entry
+ (setf device-entry (cons nil nil))
+ (puthash base-name device-entry devices))
+ ;; Store mic or monitor in the pair
+ (if is-monitor
+ (setcdr device-entry device)
+ (setcar device-entry device))))
+
+ ;; Convert hash table to alist with friendly names
+ (maphash (lambda (base-name pair)
+ (when (and (car pair) (cdr pair)) ; Only include if we have both mic and monitor
+ (let ((friendly-name
+ (cond
+ ((string-match-p "usb.*[Jj]abra" base-name) "Jabra SPEAK 510 USB")
+ ((string-match-p "^usb-" base-name) "USB Audio Device")
+ ((string-match-p "^pci-" base-name) "Built-in Laptop Audio")
+ ((string-match-p "^[0-9A-Fa-f:]+$" base-name) "Bluetooth Headset")
+ (t base-name))))
+ (push (cons friendly-name pair) result))))
+ devices)
+ (nreverse result)))
+
+(defun cj/recording-quick-setup-for-calls ()
+ "Quick setup for recording call/meetings.
+Detects available audio devices and lets you pick one device to use for
+both microphone (your voice) and monitor (remote person + sound effects).
+Perfect for recording video calls, phone calls, or presentations."
+ (interactive)
+ (let* ((grouped-devices (cj/recording-group-devices-by-hardware))
+ (choices (mapcar #'car grouped-devices)))
+ (if (null choices)
+ (user-error "No complete audio devices found (need both mic and monitor)")
+ (let* ((choice (completing-read "Which device are you using for the call? " choices nil t))
+ (device-pair (cdr (assoc choice grouped-devices)))
+ (mic (car device-pair))
+ (monitor (cdr device-pair)))
+ (setq cj/recording-mic-device mic)
+ (setq cj/recording-system-device monitor)
+ (message "Call recording ready! Using: %s\n Mic: %s\n Monitor: %s"
+ choice
+ (file-name-nondirectory mic)
+ (file-name-nondirectory monitor))))))
+
+(defun cj/recording-test-mic ()
+ "Test microphone by recording 5 seconds and playing it back.
+Records from configured mic device, saves to temp file, plays back.
+Useful for verifying mic hardware works before important recordings."
+ (interactive)
(unless cj/recording-mic-device
- (setq cj/recording-mic-device (cj/recording-detect-mic-device)))
+ (user-error "No microphone configured. Run C-; r c first"))
+
+ (let* ((temp-file (make-temp-file "mic-test-" nil ".wav"))
+ (duration 5))
+ (message "Recording from mic for %d seconds... SPEAK NOW!" duration)
+ (shell-command
+ (format "ffmpeg -f pulse -i %s -t %d -y %s 2>/dev/null"
+ (shell-quote-argument cj/recording-mic-device)
+ duration
+ (shell-quote-argument temp-file)))
+ (message "Playing back recording...")
+ (shell-command (format "ffplay -autoexit -nodisp %s 2>/dev/null &"
+ (shell-quote-argument temp-file)))
+ (message "Mic test complete. Temp file: %s" temp-file)))
+
+(defun cj/recording-test-monitor ()
+ "Test system audio monitor by recording 5 seconds and playing it back.
+Records from configured monitor device (system audio output).
+Play some audio/video during test. Useful for verifying you can capture
+conference call audio, YouTube, etc."
+ (interactive)
(unless cj/recording-system-device
- (setq cj/recording-system-device (cj/recording-detect-system-device)))
+ (user-error "No system monitor configured. Run C-; r c first"))
+
+ (let* ((temp-file (make-temp-file "monitor-test-" nil ".wav"))
+ (duration 5))
+ (message "Recording system audio for %d seconds... PLAY SOMETHING NOW!" duration)
+ (shell-command
+ (format "ffmpeg -f pulse -i %s -t %d -y %s 2>/dev/null"
+ (shell-quote-argument cj/recording-system-device)
+ duration
+ (shell-quote-argument temp-file)))
+ (message "Playing back recording...")
+ (shell-command (format "ffplay -autoexit -nodisp %s 2>/dev/null &"
+ (shell-quote-argument temp-file)))
+ (message "Monitor test complete. Temp file: %s" temp-file)))
- ;; Validate devices
+(defun cj/recording-test-both ()
+ "Test both mic and monitor together with guided prompts.
+This simulates a real recording scenario:
+1. Tests mic only (speak into it)
+2. Tests monitor only (play audio/video)
+3. Tests both together (speak while audio plays)
+
+Run this before important recordings to verify everything works!"
+ (interactive)
(unless (and cj/recording-mic-device cj/recording-system-device)
- (user-error "Could not detect audio devices. Set cj/recording-mic-device and cj/recording-system-device manually"))
+ (user-error "Devices not configured. Run C-; r c first"))
+
+ (when (y-or-n-p "Test 1: Record from MICROPHONE only (5 sec). Ready? ")
+ (cj/recording-test-mic)
+ (sit-for 6)) ; Wait for playback
+
+ (when (y-or-n-p "Test 2: Record from SYSTEM AUDIO only (5 sec). Start playing audio/video, then press y: ")
+ (cj/recording-test-monitor)
+ (sit-for 6)) ; Wait for playback
+
+ (when (y-or-n-p "Test 3: Record BOTH mic + system audio (5 sec). Speak while audio plays, then press y: ")
+ (let* ((temp-file (make-temp-file "both-test-" nil ".wav"))
+ (duration 5))
+ (message "Recording BOTH for %d seconds... SPEAK + PLAY AUDIO NOW!" duration)
+ (shell-command
+ (format "ffmpeg -f pulse -i %s -f pulse -i %s -filter_complex \"[0:a]volume=%.1f[mic];[1:a]volume=%.1f[sys];[mic][sys]amix=inputs=2:duration=longest\" -t %d -y %s 2>/dev/null"
+ (shell-quote-argument cj/recording-mic-device)
+ (shell-quote-argument cj/recording-system-device)
+ cj/recording-mic-boost
+ cj/recording-system-volume
+ duration
+ (shell-quote-argument temp-file)))
+ (message "Playing back recording...")
+ (shell-command (format "ffplay -autoexit -nodisp %s 2>/dev/null &"
+ (shell-quote-argument temp-file)))
+ (sit-for 6)
+ (message "All tests complete! Temp file: %s" temp-file)))
+
+ (message "Device testing complete. If you heard audio in all tests, recording will work!"))
+
+(defun cj/recording-get-devices ()
+ "Get audio devices, prompting user if not already configured.
+Returns (mic-device . system-device) or nil on error."
+ ;; If devices not set, prompt user to select them
+ (unless (and cj/recording-mic-device cj/recording-system-device)
+ (if (y-or-n-p "Audio devices not configured. Use quick setup for calls? ")
+ (cj/recording-quick-setup-for-calls)
+ (cj/recording-select-devices)))
+
+ ;; Final validation
+ (unless (and cj/recording-mic-device cj/recording-system-device)
+ (user-error "Audio devices not configured. Run C-; r c (quick setup) or C-; r s (manual select)"))
(cons cj/recording-mic-device cj/recording-system-device))
-(defun cj/video-recording-start (arg)
- "Start the ffmpeg video recording.
+(defun cj/video-recording-toggle (arg)
+ "Toggle video recording: start if not recording, stop if recording.
+On first use (or when devices not configured), runs quick setup (C-; r c).
With prefix ARG, prompt for recording location.
Otherwise use the default location in `video-recordings-dir'."
(interactive "P")
- (let* ((location (if arg
- (read-directory-name "Enter recording location: ")
- video-recordings-dir))
- (directory (file-name-directory location)))
- (unless (file-directory-p directory)
- (make-directory directory t))
- (cj/ffmpeg-record-video location)))
-
-(defun cj/audio-recording-start (arg)
- "Start the ffmpeg audio recording.
+ (if cj/video-recording-ffmpeg-process
+ ;; Recording in progress - stop it
+ (cj/video-recording-stop)
+ ;; Not recording - start it
+ (let* ((location (if arg
+ (read-directory-name "Enter recording location: ")
+ video-recordings-dir))
+ (directory (file-name-directory location)))
+ (unless (file-directory-p directory)
+ (make-directory directory t))
+ (cj/ffmpeg-record-video location))))
+
+(defun cj/audio-recording-toggle (arg)
+ "Toggle audio recording: start if not recording, stop if recording.
+On first use (or when devices not configured), runs quick setup (C-; r c).
With prefix ARG, prompt for recording location.
Otherwise use the default location in `audio-recordings-dir'."
(interactive "P")
- (let* ((location (if arg
- (read-directory-name "Enter recording location: ")
- audio-recordings-dir))
- (directory (file-name-directory location)))
- (unless (file-directory-p directory)
- (make-directory directory t))
- (cj/ffmpeg-record-audio location)))
+ (if cj/audio-recording-ffmpeg-process
+ ;; Recording in progress - stop it
+ (cj/audio-recording-stop)
+ ;; Not recording - start it
+ (let* ((location (if arg
+ (read-directory-name "Enter recording location: ")
+ audio-recordings-dir))
+ (directory (file-name-directory location)))
+ (unless (file-directory-p directory)
+ (make-directory directory t))
+ (cj/ffmpeg-record-audio location))))
(defun cj/ffmpeg-record-video (directory)
- "Start an ffmpeg video recording. Save output to DIRECTORY."
+ "Start an ffmpeg video recording. Save output to DIRECTORY."
(cj/recording-check-ffmpeg)
(unless cj/video-recording-ffmpeg-process
(let* ((devices (cj/recording-get-devices))
@@ -140,41 +486,54 @@ Otherwise use the default location in `audio-recordings-dir'."
"*ffmpeg-video-recording*"
ffmpeg-command))
(set-process-query-on-exit-flag cj/video-recording-ffmpeg-process nil)
+ (set-process-sentinel cj/video-recording-ffmpeg-process #'cj/recording-process-sentinel)
+ (force-mode-line-update t)
(message "Started video recording to %s (mic: %.1fx, system: %.1fx)."
filename cj/recording-mic-boost cj/recording-system-volume))))
(defun cj/ffmpeg-record-audio (directory)
- "Start an ffmpeg audio recording. Save output to DIRECTORY."
+ "Start an ffmpeg audio recording. Save output to DIRECTORY.
+Records from microphone and system audio monitor (configured device), mixing them together.
+Use C-; r c to configure which device to use - it must match the device your phone call uses."
(cj/recording-check-ffmpeg)
(unless cj/audio-recording-ffmpeg-process
(let* ((devices (cj/recording-get-devices))
(mic-device (car devices))
+ ;; Use the explicitly configured monitor device
+ ;; This must match the device your phone call/audio is using
(system-device (cdr devices))
(location (expand-file-name directory))
(name (format-time-string "%Y-%m-%d-%H-%M-%S"))
- (filename (expand-file-name (concat name ".opus") location))
+ (filename (expand-file-name (concat name ".m4a") location))
(ffmpeg-command
(format (concat "ffmpeg "
- "-f pulse -i %s "
- "-ac 1 "
- "-f pulse -i %s "
- "-ac 2 "
- "-filter_complex \"[0:a]volume=%.1f[mic];[1:a]volume=%.1f[sys];[mic][sys]amerge=inputs=2\" "
- "-c:a libopus "
- "-b:a 96k "
+ "-f pulse -i %s " ; Input 0: Microphone (specific device)
+ "-f pulse -i %s " ; Input 1: System audio monitor
+ "-filter_complex \""
+ "[0:a]volume=%.1f[mic];" ; Apply mic boost
+ "[1:a]volume=%.1f[sys];" ; Apply system volume
+ "[mic][sys]amix=inputs=2:duration=longest[out]\" " ; Mix both inputs
+ "-map \"[out]\" "
+ "-c:a aac "
+ "-b:a 64k "
"%s")
mic-device
system-device
cj/recording-mic-boost
cj/recording-system-volume
filename)))
+ ;; Log the command for debugging
+ (message "Recording from mic: %s + ALL system outputs" mic-device)
+ (cj/log-silently "Audio recording ffmpeg command: %s" ffmpeg-command)
;; start the recording
(setq cj/audio-recording-ffmpeg-process
(start-process-shell-command "ffmpeg-audio-recording"
"*ffmpeg-audio-recording*"
ffmpeg-command))
(set-process-query-on-exit-flag cj/audio-recording-ffmpeg-process nil)
- (message "Started audio recording to %s (mic: %.1fx, system: %.1fx)."
+ (set-process-sentinel cj/audio-recording-ffmpeg-process #'cj/recording-process-sentinel)
+ (force-mode-line-update t)
+ (message "Started recording to %s (mic: %.1fx, all system audio: %.1fx)"
filename cj/recording-mic-boost cj/recording-system-volume))))
(defun cj/video-recording-stop ()
@@ -187,6 +546,7 @@ Otherwise use the default location in `audio-recordings-dir'."
;; Give ffmpeg a moment to finalize the file
(sit-for 0.2)
(setq cj/video-recording-ffmpeg-process nil)
+ (force-mode-line-update t)
(message "Stopped video recording."))
(message "No video recording in progress.")))
@@ -200,6 +560,7 @@ Otherwise use the default location in `audio-recordings-dir'."
;; Give ffmpeg a moment to finalize the file
(sit-for 0.2)
(setq cj/audio-recording-ffmpeg-process nil)
+ (force-mode-line-update t)
(message "Stopped audio recording."))
(message "No audio recording in progress.")))
@@ -217,15 +578,37 @@ Otherwise use the default location in `audio-recordings-dir'."
;; Recording operations prefix and keymap
(defvar cj/record-map
(let ((map (make-sparse-keymap)))
- (define-key map (kbd "V") #'cj/video-recording-stop)
- (define-key map (kbd "v") #'cj/video-recording-start)
- (define-key map (kbd "A") #'cj/audio-recording-stop)
- (define-key map (kbd "a") #'cj/audio-recording-start)
+ (define-key map (kbd "v") #'cj/video-recording-toggle)
+ (define-key map (kbd "a") #'cj/audio-recording-toggle)
(define-key map (kbd "l") #'cj/recording-adjust-volumes)
+ (define-key map (kbd "d") #'cj/recording-list-devices)
+ (define-key map (kbd "w") #'cj/recording-show-active-audio) ; "w" for "what's playing"
+ (define-key map (kbd "s") #'cj/recording-select-devices)
+ (define-key map (kbd "c") #'cj/recording-quick-setup-for-calls)
+ (define-key map (kbd "t m") #'cj/recording-test-mic)
+ (define-key map (kbd "t s") #'cj/recording-test-monitor)
+ (define-key map (kbd "t b") #'cj/recording-test-both)
map)
"Keymap for video/audio recording operations.")
-(keymap-set cj/custom-keymap "r" cj/record-map)
+;; Only set keybinding if cj/custom-keymap is bound (not in batch mode)
+(when (boundp 'cj/custom-keymap)
+ (keymap-set cj/custom-keymap "r" cj/record-map))
+
+(with-eval-after-load 'which-key
+ (which-key-add-key-based-replacements
+ "C-; r" "recording menu"
+ "C-; r v" "toggle video recording"
+ "C-; r a" "toggle audio recording"
+ "C-; r l" "adjust levels"
+ "C-; r d" "list devices"
+ "C-; r w" "what's playing (diagnostics)"
+ "C-; r s" "select devices"
+ "C-; r c" "quick setup for calls"
+ "C-; r t" "test devices"
+ "C-; r t m" "test microphone"
+ "C-; r t s" "test system audio"
+ "C-; r t b" "test both (guided)"))
(provide 'video-audio-recording)
;;; video-audio-recording.el ends here.
diff --git a/modules/weather-config.el b/modules/weather-config.el
index 526a0b41..0259d4a9 100644
--- a/modules/weather-config.el
+++ b/modules/weather-config.el
@@ -11,30 +11,33 @@
;; ----------------------------------- Wttrin ----------------------------------
(use-package wttrin
- :defer t
- :load-path ("~/code/wttrin")
- :ensure nil ;; local package
+ ;; Uncomment the next line to use vc-install instead of local directory:
+ ;; :vc (:url "https://github.com/cjennings/emacs-wttrin" :rev :newest)
+ :demand t ;; REQUIRED: mode-line must start at Emacs startup
+ :load-path "/home/cjennings/code/wttrin"
:preface
- ;; dependency for wttrin
- (use-package xterm-color
- :demand t)
+ ;; Change this to t to enable debug logging
+ ;; (setq wttrin-debug t)
:bind
("M-W" . wttrin)
- :custom
- (wttrin-unit-system "u")
:config
+ (setopt wttrin-unit-system "u")
+ (setopt wttrin-favorite-location "New Orleans, LA")
+ (setopt wttrin-mode-line-refresh-interval (* 30 60)) ;; thirty minutes
(setq wttrin-default-locations '(
- "New Orleans, LA"
- "Athens, GR"
- "Berkeley, CA"
- "Bury St Edmunds, UK"
- "Kyiv, UA"
- "Littlestown, PA"
- "Soufrière, St Lucia"
- "London, GB"
- "Naples, IT"
- "New York, NY"
- )))
+ "New Orleans, LA"
+ "Berkeley, CA"
+ "Huntington Beach, CA"
+ "Bury St Edmunds, UK"
+ "New York, NY"
+ "Littlestown, PA"
+ "Soufrière, St Lucia"
+ "London, GB"
+ "Naples, IT"
+ "Athens, GR"
+ "Kyiv, UA"
+ ))
+ (wttrin-mode-line-mode 1))
(provide 'weather-config)
;;; weather-config.el ends here.
diff --git a/modules/wip.el b/modules/wip.el
index 314881d2..93c799fb 100644
--- a/modules/wip.el
+++ b/modules/wip.el
@@ -14,134 +14,6 @@
;;
;;; Code:
-(eval-when-compile (require 'user-constants))
-(eval-when-compile (require 'keybindings))
-(eval-when-compile (require 'subr-x)) ;; for system commands
-(require 'rx) ;; for system commands
-
-;; ------------------------------ System Commands ------------------------------
-
-(defun cj/system-cmd--resolve (cmd)
- "Return (values symbol-or-nil command-string label) for CMD."
- (cond
- ((symbolp cmd)
- (let ((val (and (boundp cmd) (symbol-value cmd))))
- (unless (and (stringp val) (not (string-empty-p val)))
- (user-error "Variable %s is not a non-empty string" cmd))
- (list cmd val (symbol-name cmd))))
- ((stringp cmd)
- (let ((s (string-trim cmd)))
- (when (string-empty-p s) (user-error "Command string is empty"))
- (list nil s "command")))
- (t (user-error "Error: cj/system-cmd expects a string or a symbol"))))
-
-;;;###autoload
-(defun cj/system-cmd (cmd)
- "Run CMD (string or symbol naming a string) detached via the shell.
-Shell expansions like $(...) are supported. Output is silenced.
-If CMD is deemed dangerous, ask for confirmation."
- (interactive (list (read-shell-command "System command: ")))
- (pcase-let ((`(,sym ,cmdstr ,label) (cj/system-cmd--resolve cmd)))
- (when (and sym (get sym 'cj/system-confirm)
- (memq (read-char-choice
- (format "Run %s now (%s)? (Y/n) " label camdstr)
- '(?y ?Y ?n ?N ?\r ?\n ?\s))
- '(?n ?N)))
- (user-error "Aborted"))
- (let ((proc (start-process-shell-command "cj/system-cmd" nil
- (format "nohup %s >/dev/null 2>&1 &" cmdstr))))
- (set-process-query-on-exit-flag proc nil)
- (set-process-sentinel proc #'ignore)
- (message "Running %s..." label))))
-
-(defmacro cj/defsystem-command (name var cmdstr &optional confirm)
- "Define VAR with CMDSTR and interactive command NAME to run it.
-If CONFIRM is non-nil, mark VAR to always require confirmation."
- (declare (indent defun))
- `(progn
- (defvar ,var ,cmdstr)
- ,(when confirm `(put ',var 'cj/system-confirm t))
- (defun ,name ()
- ,(format "Run %s via `cj/system-cmd'." var)
- (interactive)
- (cj/system-cmd ',var))))
-
-;; Define system commands
-(cj/defsystem-command cj/system-cmd-logout logout-cmd "loginctl terminate-user $(whoami)" t)
-(cj/defsystem-command cj/system-cmd-lock lockscreen-cmd "slock")
-(cj/defsystem-command cj/system-cmd-suspend suspend-cmd "systemctl suspend" t)
-(cj/defsystem-command cj/system-cmd-shutdown shutdown-cmd "systemctl poweroff" t)
-(cj/defsystem-command cj/system-cmd-reboot reboot-cmd "systemctl reboot" t)
-
-(defun cj/system-cmd-exit-emacs ()
- "Exit Emacs server and all clients."
- (interactive)
- (when (memq (read-char-choice
- "Exit Emacs? (Y/n) "
- '(?y ?Y ?n ?N ?\r ?\n ?\s))
- '(?n ?N))
- (user-error "Aborted"))
- (kill-emacs))
-
-(defun cj/system-cmd-restart-emacs ()
- "Restart Emacs server after saving buffers."
- (interactive)
- (when (memq (read-char-choice
- "Restart Emacs? (Y/n) "
- '(?y ?Y ?n ?N ?\r ?\n ?\s))
- '(?n ?N))
- (user-error "Aborted"))
- (save-some-buffers)
- ;; Start the restart process before killing Emacs
- (run-at-time 0.5 nil
- (lambda ()
- (call-process-shell-command
- "systemctl --user restart emacs.service && emacsclient -c"
- nil 0)))
- (run-at-time 1 nil #'kill-emacs)
- (message "Restarting Emacs..."))
-
-;; (defvar-keymap cj/system-command-map
-;; :doc "Keymap for system commands."
-;; "L" #'cj/system-cmd-logout
-;; "r" #'cj/system-cmd-reboot
-;; "s" #'cj/system-cmd-shutdown
-;; "S" #'cj/system-cmd-suspend
-;; "l" #'cj/system-cmd-lock
-;; "E" #'cj/system-cmd-exit-emacs
-;; "e" #'cj/system-cmd-restart-emacs)
-;; (keymap-set cj/custom-keymap "!" cj/system-command-map)
-
-(defun cj/system-command-menu ()
- "Present system commands via \='completing-read\='."
- (interactive)
- (let* ((commands '(("Logout System" . cj/system-cmd-logout)
- ("Lock Screen" . cj/system-cmd-lock)
- ("Suspend System" . cj/system-cmd-suspend)
- ("Shutdown System" . cj/system-cmd-shutdown)
- ("Reboot System" . cj/system-cmd-reboot)
- ("Exit Emacs" . cj/system-cmd-exit-emacs)
- ("Restart Emacs" . cj/system-cmd-restart-emacs)))
- (choice (completing-read "System command: " commands nil t)))
- (when-let ((cmd (alist-get choice commands nil nil #'equal)))
- (call-interactively cmd))))
-
-(keymap-set cj/custom-keymap "!" #'cj/system-command-menu)
-
-
-;; --------------------------- Org Upcoming Modeline ---------------------------
-
-;; (use-package org-upcoming-modeline
-;; :after org
-;; :load-path "~/code/org-upcoming-modeline/org-upcoming-modeline.el"
-;; :config
-;; (setq org-upcoming-modeline-keep-late 300)
-;; (setq org-upcoming-modeline-ignored-keywords '("DONE" "CANCELLED" "FAILED"))
-;; (setq org-upcoming-modeline-trim 30)
-;; (setq org-upcoming-modeline-days-ahead 5)
-;; (setq org-upcoming-modeline-format (lambda (ms mh) (format "📅 %s %s" ms mh)))
-;; (org-upcoming-modeline-mode))
-
;; ----------------------------------- Efrit -----------------------------------
;; not working as of Wednesday, September 03, 2025 at 12:44:09 AM CDT
@@ -184,30 +56,5 @@ If CONFIRM is non-nil, mark VAR to always require confirmation."
:bind ("M-p" . pomm)
:commands (pomm pomm-third-time))
-;; ----------------------------------- Popper ----------------------------------
-
-;; (use-package popper
-;; :bind (("C-`" . popper-toggle)
-;; ("M-`" . popper-cycle)
-;; ("C-M-`" . popper-toggle-type))
-;; :custom
-;; (popper-display-control-nil)
-;; :init
-;; (setq popper-reference-buffers
-;; '("\\*Messages\\*"
-;; "Output\\*$"
-;; "\\*Async Shell Command\\*"
-;; ;; "\\*scratch\\*"
-;; help-mode
-;; compilation-mode))
-;; (add-to-list 'display-buffer-alist
-;; '(popper-display-control-p ; Predicate to match popper buffers
-;; (display-buffer-in-side-window)
-;; (side . bottom)
-;; (slot . 0)
-;; (window-height . 0.5))) ; Half the frame height
-;; (popper-mode +1)
-;; (popper-echo-mode +1))
-
(provide 'wip)
;;; wip.el ends here.
diff --git a/modules/wrap-up.el b/modules/wrap-up.el
index b00d56a8..523d55b2 100644
--- a/modules/wrap-up.el
+++ b/modules/wrap-up.el
@@ -5,6 +5,8 @@
;;; Code:
+(require 'system-lib)
+
;; -------------------------------- Bury Buffers -------------------------------
;; wait a few seconds then bury compile-related buffers.