summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
committerCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
commit092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch)
treeea81999b8442246c978b364dd90e8c752af50db5 /modules
changing repositories
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-config.el419
-rw-r--r--modules/ai-conversations.el277
-rw-r--r--modules/auth-config.el45
-rw-r--r--modules/calibredb-epub-config.el223
-rw-r--r--modules/chrono-tools.el118
-rw-r--r--modules/config-utilities.el291
-rw-r--r--modules/custom-functions.el1012
-rw-r--r--modules/dashboard-config.el144
-rw-r--r--modules/diff-config.el53
-rw-r--r--modules/dirvish-config.el403
-rw-r--r--modules/dwim-shell-config.el732
-rw-r--r--modules/elfeed-config.el290
-rw-r--r--modules/eradio-config.el36
-rw-r--r--modules/erc-config.el317
-rw-r--r--modules/eshell-vterm-config.el229
-rw-r--r--modules/eww-config.el153
-rw-r--r--modules/external-open.el129
-rw-r--r--modules/flycheck-config.el99
-rw-r--r--modules/flyspell-and-abbrev.el211
-rw-r--r--modules/font-config.el283
-rw-r--r--modules/games-config.el60
-rw-r--r--modules/help-config.el108
-rw-r--r--modules/help-utils.el77
-rw-r--r--modules/host-environment.el116
-rw-r--r--modules/httpd-config.el26
-rw-r--r--modules/jumper.el177
-rw-r--r--modules/keybindings.el100
-rw-r--r--modules/keyboard-macros.el97
-rw-r--r--modules/latex-config.el56
-rw-r--r--modules/ledger-config.el50
-rw-r--r--modules/lipsum-generator.el239
-rw-r--r--modules/local-repository.el53
-rw-r--r--modules/lorem-generator.el244
-rw-r--r--modules/mail-config.el341
-rw-r--r--modules/markdown-config.el47
-rw-r--r--modules/media-utils.el187
-rw-r--r--modules/modeline-config.el55
-rw-r--r--modules/mu4e-org-contacts-integration.el167
-rw-r--r--modules/mu4e-org-contacts-setup.el24
-rw-r--r--modules/music-config.el597
-rw-r--r--modules/org-agenda-config.el290
-rw-r--r--modules/org-babel-config.el151
-rw-r--r--modules/org-capture-config.el143
-rw-r--r--modules/org-config.el267
-rw-r--r--modules/org-contacts-config.el205
-rw-r--r--modules/org-drill-config.el109
-rw-r--r--modules/org-export-config.el162
-rw-r--r--modules/org-gcal-config.el92
-rw-r--r--modules/org-noter-config.el60
-rw-r--r--modules/org-refile-config.el78
-rw-r--r--modules/org-roam-config.el178
-rw-r--r--modules/org-webclipper.el145
-rw-r--r--modules/pdf-config.el59
-rw-r--r--modules/prog-c.el32
-rw-r--r--modules/prog-general.el299
-rw-r--r--modules/prog-go.el41
-rw-r--r--modules/prog-lisp.el126
-rw-r--r--modules/prog-lsp.el55
-rw-r--r--modules/prog-python.el81
-rw-r--r--modules/prog-shell.el15
-rw-r--r--modules/prog-training.el35
-rw-r--r--modules/prog-webdev.el120
-rw-r--r--modules/prog-yaml.el18
-rw-r--r--modules/quick-video-capture.el104
-rw-r--r--modules/reconcile-open-repos.el72
-rw-r--r--modules/selection-framework.el264
-rw-r--r--modules/show-kill-ring.el125
-rw-r--r--modules/system-defaults.el243
-rw-r--r--modules/system-utils.el202
-rw-r--r--modules/test-runner.el270
-rw-r--r--modules/text-config.el121
-rw-r--r--modules/tramp-config.el135
-rw-r--r--modules/ui-config.el141
-rw-r--r--modules/ui-navigation.el154
-rw-r--r--modules/ui-theme.el135
-rw-r--r--modules/undead-buffers.el181
-rw-r--r--modules/user-constants.el180
-rw-r--r--modules/vc-config.el131
-rw-r--r--modules/video-audio-recording.el184
-rw-r--r--modules/weather-config.el40
-rw-r--r--modules/wip.el121
-rw-r--r--modules/wrap-up.el30
82 files changed, 13879 insertions, 0 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
new file mode 100644
index 00000000..ef574412
--- /dev/null
+++ b/modules/ai-config.el
@@ -0,0 +1,419 @@
+;;; ai-config.el --- Configuration for AI Integrations -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Configuration for AI integrations in Emacs, focused on GPTel.
+;;
+;; Main Features:
+;; - Quick toggle for AI assistant window (F9 or M-a t)
+;; - Custom keymap (M-a prefix, overrides 'backwards-sentence') for AI-related commands.
+;; - Enhanced org-mode conversation formatting with timestamps
+;; allows switching models and easily compare and track responses.
+;; - Various specialized AI directives (coder, reviewer, etc.)
+;; - Context management for adding files/buffers to conversations
+;; - Conversation persistence with save/load functionality
+;; - Integration with Magit for code review
+;;
+;; Basic Workflow
+;;
+;; Using a side-chat window:
+;; - Launch GPTel via F9 or M-a t, and chat in the AI-Assistant side window (C-<return> to send)
+;; - Change system prompt (expertise, personalities) with M-a p
+;; - Add context from files (M-a f) or buffers (M-a b)
+;; - Save conversations with M-a s, load previous ones with M-a l
+;; - Clear the conversation and start over with M-a x
+;; Or in any buffer:
+;; - Add directive as above, and select a region to rewrite with M-a r.
+;;
+
+;;; Code:
+
+(autoload 'cj/gptel-save-conversation "ai-conversations" "Save the AI conversation to a file." t)
+(autoload 'cj/gptel-load-conversation "ai-conversations" "Load a saved AI conversation." t)
+(autoload 'cj/gptel-delete-conversation "ai-conversations" "Delete a saved AI conversation." t)
+
+(with-eval-after-load 'gptel
+ (require 'ai-conversations))
+
+;;; ------------------------- AI Config Helper Functions ------------------------
+
+;; Define all our variables upfront
+(defvar cj/anthropic-api-key-cached nil "Cached Anthropic API key.")
+(defvar cj/openai-api-key-cached nil "Cached OpenAI API key.")
+(defvar gptel-claude-backend nil "Claude backend, lazy-initialized.")
+(defvar gptel-chatgpt-backend nil "ChatGPT backend, lazy-initialized.")
+
+
+(defun cj/auth-source-secret (host user)
+ "Fetch a secret from auth-source for HOST and USER.
+
+HOST and USER must be strings that identify the credential to return."
+ (let* ((found (auth-source-search :host host :user user :require '(:secret) :max 1))
+ (secret (plist-get (car found) :secret)))
+ (cond
+ ((functionp secret) (funcall secret))
+ ((stringp secret) secret)
+ (t (error "No usable secret found for host %s and user %s" host user)))))
+
+(defun cj/anthropic-api-key ()
+ "Return the Anthropic API key, caching the result after first retrieval."
+ (or cj/anthropic-api-key-cached
+ (setq cj/anthropic-api-key-cached
+ (cj/auth-source-secret "api.anthropic.com" "apikey"))))
+
+(defun cj/openai-api-key ()
+ "Return the OpenAI API key, caching the result after first retrieval."
+ (or cj/openai-api-key-cached
+ (setq cj/openai-api-key-cached
+ (cj/auth-source-secret "api.openai.com" "apikey"))))
+
+(defun cj/ensure-gptel-backends ()
+ "Initialize GPTel backends if they are not already available.
+
+Call this only after loading 'gptel' so the backend constructors exist."
+ (unless gptel-claude-backend
+ (setq gptel-claude-backend
+ (gptel-make-anthropic
+ "Claude"
+ :key (cj/anthropic-api-key)
+ :models '(
+ "claude-opus-4-1-20250805"
+ "claude-3-5-sonnet-20241022"
+ "claude-3-opus-20240229"
+ "claude-3-5-haiku-20241022"
+ )
+ :stream t)))
+ (unless gptel-chatgpt-backend
+ (setq gptel-chatgpt-backend
+ (gptel-make-openai
+ "ChatGPT"
+ :key (cj/openai-api-key)
+ :models '(
+ "gpt-4o"
+ "gpt-5"
+ "gpt-4.1"
+ "o1"
+ )
+ :stream t)))
+ ;; Set default backend
+ (unless gptel-backend
+ (setq gptel-backend (or gptel-chatgpt-backend gptel-claude-backend))))
+
+(autoload 'cj/toggle-gptel "ai-config" "Toggle the AI-Assistant window" t)
+
+;; ------------------ Gptel Conversation And Utility Commands ------------------
+
+(defun cj/gptel--available-backends ()
+ "Return an alist of (NAME . BACKEND), ensuring gptel and backends are initialized."
+ (unless (featurep 'gptel)
+ (require 'gptel))
+ (cj/ensure-gptel-backends)
+ (delq nil
+ (list (and (bound-and-true-p gptel-claude-backend)
+ (cons "Anthropic - Claude" gptel-claude-backend))
+ (and (bound-and-true-p gptel-chatgpt-backend)
+ (cons "OpenAI - ChatGPT" gptel-chatgpt-backend)))))
+
+(defun cj/gptel--model->string (m)
+ (cond
+ ((stringp m) m)
+ ((symbolp m) (symbol-name m))
+ (t (format "%s" m))))
+
+;; Backend/model switching commands (moved out of use-package so they are commandp)
+(defun cj/gptel-change-model ()
+ "Change the GPTel backend and select a model from that backend.
+
+Present all available models from every backend, switching backends when
+necessary. Prompt for whether to apply the selection globally or buffer-locally."
+ (interactive)
+ (let* ((backends (cj/gptel--available-backends))
+ (all-models
+ (mapcan
+ (lambda (pair)
+ (let* ((backend-name (car pair))
+ (backend (cdr pair))
+ (models (when (fboundp 'gptel-backend-models)
+ (gptel-backend-models backend))))
+ (mapcar (lambda (m)
+ (list (format "%s: %s" backend-name (cj/gptel--model->string m))
+ backend
+ (cj/gptel--model->string m)
+ backend-name))
+ models)))
+ backends))
+ (current-backend-name (car (rassoc (bound-and-true-p gptel-backend) backends)))
+ (current-selection (format "%s: %s"
+ (or current-backend-name "AI")
+ (cj/gptel--model->string (bound-and-true-p gptel-model))))
+ (scope (completing-read "Set model for: " '("buffer" "global") nil t))
+ (selected (completing-read
+ (format "Select model (current: %s): " current-selection)
+ (mapcar #'car all-models) nil t nil nil current-selection)))
+ (let* ((model-info (assoc selected all-models))
+ (backend (nth 1 model-info))
+ (model (intern (nth 2 model-info))) ;; Convert string to symbol
+ (backend-name (nth 3 model-info)))
+ (if (string= scope "global")
+ (progn
+ (setq gptel-backend backend)
+ (setq gptel-model model)
+ (message "Changed to %s model: %s (global)" backend-name model))
+ (setq-local gptel-backend backend)
+ (setq-local gptel-model (if (stringp model) (intern model) model))
+ (message "Changed to %s model: %s (buffer-local)" backend-name model)))))
+
+(defun cj/gptel-switch-backend ()
+ "Switch the GPTel backend and then choose one of its models."
+ (interactive)
+ (let* ((backends (cj/gptel--available-backends))
+ (choice (completing-read "Select GPTel backend: " (mapcar #'car backends) nil t))
+ (backend (cdr (assoc choice backends))))
+ (unless backend
+ (user-error "Invalid GPTel backend: %s" choice))
+ (let* ((models (when (fboundp 'gptel-backend-models)
+ (gptel-backend-models backend)))
+ (model (completing-read (format "Select %s model: " choice)
+ (mapcar #'cj/gptel--model->string models)
+ nil t nil nil (cj/gptel--model->string (bound-and-true-p gptel-model)))))
+ (setq gptel-backend backend
+ gptel-model model)
+ (message "Switched to %s with model: %s" choice model))))
+
+;; Clear assistant buffer (moved out so it's always available)
+(defun cj/gptel-clear-buffer ()
+ "Erase the current GPTel buffer while preserving the initial Org heading.
+
+Operate only when `gptel-mode' is active in an Org buffer so the heading
+can be reinserted."
+ (interactive)
+ (let ((is-gptel (bound-and-true-p gptel-mode))
+ (is-org (derived-mode-p 'org-mode)))
+ (if (and is-gptel is-org)
+ (progn
+ (erase-buffer)
+ (when (fboundp 'cj/gptel--fresh-org-prefix)
+ (insert (cj/gptel--fresh-org-prefix)))
+ (message "GPTel buffer cleared and heading reset"))
+ (message "Not a GPTel buffer in org-mode. Nothing cleared."))))
+
+;; ----------------------------- Context Management ----------------------------
+
+(defun cj/gptel--add-file-to-context (file-path)
+ "Add FILE-PATH to the GPTel context.
+
+Returns t on success, nil on failure.
+Provides consistent user feedback about the context state."
+ (when (and file-path (file-exists-p file-path))
+ (gptel-add-file file-path)
+ (let ((context-count (if (boundp 'gptel-context--alist)
+ (length gptel-context--alist)
+ 0)))
+ (message "Added %s to GPTel context (%d sources total)"
+ (file-name-nondirectory file-path)
+ context-count))
+ t))
+
+(defun cj/gptel-add-file ()
+ "Add a file to the GPTel context.
+
+If inside a Projectile project, prompt from that project's file list.
+Otherwise, prompt with `read-file-name'."
+ (interactive)
+ (let* ((in-proj (and (featurep 'projectile)
+ (fboundp 'projectile-project-p)
+ (projectile-project-p)))
+ (file-name (if in-proj
+ (let ((cands (projectile-current-project-files)))
+ (if (fboundp 'projectile-completing-read)
+ (projectile-completing-read "GPTel add file: " cands)
+ (completing-read "GPTel add file: " cands nil t)))
+ (read-file-name "GPTel add file: ")))
+ (file-path (if in-proj
+ (expand-file-name file-name (projectile-project-root))
+ file-name)))
+ (unless (cj/gptel--add-file-to-context file-path)
+ (error "Failed to add file: %s" file-path))))
+
+(defun cj/gptel-add-buffer-file ()
+ "Select a buffer and add its associated file to the GPTel context.
+
+Lists all open buffers for selection. If the selected buffer is visiting
+a file, that file is added to the GPTel context. Otherwise, an error
+message is displayed."
+ (interactive)
+ (let* ((buffers (mapcar #'buffer-name (buffer-list)))
+ (selected-buffer-name (completing-read "Add file from buffer: " buffers nil t))
+ (selected-buffer (get-buffer selected-buffer-name))
+ (file-path (and selected-buffer
+ (buffer-file-name selected-buffer))))
+ (if file-path
+ (cj/gptel--add-file-to-context file-path)
+ (message "Buffer '%s' is not visiting a file" selected-buffer-name))))
+
+(defun cj/gptel-add-this-buffer ()
+ "Add the current buffer to the GPTel context.
+
+Works for any buffer, whether it's visiting a file or not."
+ (interactive)
+ ;; Load gptel-context if needed
+ (unless (featurep 'gptel-context)
+ (require 'gptel-context))
+ ;; Use gptel-add with prefix arg '(4) to add current buffer
+ (gptel-add '(4))
+ (message "Added buffer '%s' to GPTel context" (buffer-name)))
+
+;;; ---------------------------- GPTel Configuration ----------------------------
+
+(use-package gptel
+ :defer t
+ :commands (gptel gptel-send gptel-menu)
+ :bind
+ (("<f9>" . cj/toggle-gptel)
+ :map gptel-mode-map
+ ("C-<return>" . gptel-send))
+ :custom
+ (gptel-default-mode 'org-mode)
+ (gptel-expert-commands t)
+ (gptel-track-media t)
+ ;; TODO: add reasoning to a buffer. See docstring.
+ (gptel-include-reasoning 'ignore)
+ (gptel-log-level 'info)
+ (gptel--debug nil)
+ :config
+ (cj/ensure-gptel-backends)
+ ;; Set ChatGPT as default after initialization
+ (setq gptel-backend gptel-chatgpt-backend)
+
+ ;; Named backend list for switching
+ (defvar cj/gptel-backends
+ `(("Anthropic - Claude" . ,gptel-claude-backend)
+ ("OpenAI - ChatGPT" . ,gptel-chatgpt-backend))
+ "Alist of GPTel backends for interactive switching.")
+
+ (setq gptel-confirm-tool-calls nil) ;; allow tool access by default
+ ;;; ---------------------------- Backend Management ---------------------------
+
+ (setq gptel-backend gptel-chatgpt-backend) ;; use ChatGPT as default
+ ;; (setq gptel-backend gptel-claude-backend) ;; use Claude as default
+
+;;; -------------------------- Org Header Construction --------------------------
+
+ ;; Dynamic user prefix for org-mode heading (string, refreshed just before send)
+ (defun cj/gptel--fresh-org-prefix ()
+ "Generate a fresh org-mode header with current timestamp for user messages."
+ (concat "* " user-login-name " " (format-time-string "[%Y-%m-%d %H:%M:%S]") "\n"))
+
+ ;; Initialize as a string (GPTel expectation)
+ (setf (alist-get 'org-mode gptel-prompt-prefix-alist)
+ (cj/gptel--fresh-org-prefix))
+
+ ;; Refresh immediately before each send for accurate timestamp
+ (defun cj/gptel--refresh-org-prefix (&rest _)
+ "Update the org-mode prefix with fresh timestamp before sending message."
+ (setf (alist-get 'org-mode gptel-prompt-prefix-alist)
+ (cj/gptel--fresh-org-prefix)))
+ (advice-add 'gptel-send :before #'cj/gptel--refresh-org-prefix)
+
+ ;; AI header on each reply: (e.g. "*** AI: <model> [timestamp]")
+ (defun cj/gptel-backend-and-model ()
+ "Return backend, model, and timestamp as a single string."
+ (let* ((backend (pcase (bound-and-true-p gptel-backend)
+ ((and v (pred vectorp)) (aref v 1)) ;; display name if vector
+ (_ "AI")))
+ (model (format "%s" (or (bound-and-true-p gptel-model) "")))
+ (ts (format-time-string "[%Y-%m-%d %H:%M:%S]")))
+ (format "%s: %s %s" backend model ts)))
+
+ (defun cj/gptel-insert-model-heading (response-begin-pos _response-end-pos)
+ "Insert an Org heading for the AI reply at RESPONSE-BEGIN-POS."
+ (save-excursion
+ (goto-char response-begin-pos)
+ (insert (format "* %s\n" (cj/gptel-backend-and-model)))))
+
+ (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading))
+
+;;; ---------------------------- Toggle GPTel Window ----------------------------
+
+(defun cj/toggle-gptel ()
+ "Toggle the visibility of the AI-Assistant buffer, and place point at its end."
+ (interactive)
+ (let* ((buf-name "*AI-Assistant*")
+ (buffer (get-buffer buf-name))
+ (win (and buffer (get-buffer-window buffer))))
+ (if win
+ (delete-window win)
+ ;; Ensure GPTel and our backends are initialized before creating the buffer
+ (unless (featurep 'gptel)
+ (require 'gptel))
+ (cj/ensure-gptel-backends)
+ (unless buffer
+ ;; Pass backend, not model
+ (gptel buf-name gptel-backend))
+ (setq buffer (get-buffer buf-name))
+ (setq win
+ (display-buffer-in-side-window
+ buffer
+ '((side . right)
+ (window-width . 0.4))))
+ (select-window win)
+ (with-current-buffer buffer
+ (goto-char (point-max))))))
+
+;; ------------------------------- Clear Context -------------------------------
+
+(defun cj/gptel-context-clear ()
+ "Clear all GPTel context sources, with compatibility across GPTel versions."
+ (interactive)
+ (cond
+ ((fboundp 'gptel-context-remove-all)
+ (call-interactively 'gptel-context-remove-all)
+ (message "GPTel context cleared"))
+ ((fboundp 'gptel-context-clear)
+ (call-interactively 'gptel-context-clear)
+ (message "GPTel context cleared"))
+ ((boundp 'gptel-context--alist)
+ (setq gptel-context--alist nil)
+ (message "GPTel context cleared"))
+ (t
+ (message "No known GPTel context clearing function available"))))
+
+;;; -------------------------------- GPTel-Magit --------------------------------
+
+(use-package gptel-magit
+ :defer t
+ :hook (magit-mode . gptel-magit-install))
+
+;; ------------------------------ GPTel Directives -----------------------------
+
+(use-package gptel-prompts
+ :load-path (lambda () (expand-file-name "custom/" user-emacs-directory))
+ :after gptel
+ :if (file-exists-p (expand-file-name "custom/gptel-prompts.el" user-emacs-directory))
+ :custom
+ (gptel-prompts-directory (concat user-emacs-directory "ai-prompts"))
+ :config
+ (gptel-prompts-update)
+ (gptel-prompts-add-update-watchers))
+
+;;; --------------------------------- AI Keymap ---------------------------------
+
+(define-prefix-command 'cj/ai-keymap nil
+ "Keymap for AI operations.")
+(define-key cj/custom-keymap "a" 'cj/ai-keymap)
+(define-key cj/ai-keymap "B" #'cj/gptel-switch-backend) ;; change the backend (OpenAI, Anthropic, etc.)
+(define-key cj/ai-keymap "M" #'gptel-menu) ;; gptel's transient menu
+(define-key cj/ai-keymap "d" #'cj/gptel-delete-conversation) ;; delete conversation
+(define-key cj/ai-keymap "." #'cj/gptel-add-this-buffer) ;; add buffer to context
+(define-key cj/ai-keymap "f" #'cj/gptel-add-file) ;; add a file to context
+(define-key cj/ai-keymap "l" #'cj/gptel-load-conversation) ;; load and continue conversation
+(define-key cj/ai-keymap "m" #'cj/gptel-change-model) ;; change the LLM model
+(define-key cj/ai-keymap "p" #'gptel-system-prompt) ;; change prompt
+(define-key cj/ai-keymap "&" #'gptel-rewrite) ;; rewrite a region of code/text
+(define-key cj/ai-keymap "r" #'cj/gptel-context-clear) ;; remove all context
+(define-key cj/ai-keymap "s" #'cj/gptel-save-conversation) ;; save conversation
+(define-key cj/ai-keymap "t" #'cj/toggle-gptel) ;; toggles the ai-assistant window
+(define-key cj/ai-keymap "x" #'cj/gptel-clear-buffer) ;; clears the assistant buffer
+
+(provide 'ai-config)
+;;; ai-config.el ends here.
diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el
new file mode 100644
index 00000000..92549176
--- /dev/null
+++ b/modules/ai-conversations.el
@@ -0,0 +1,277 @@
+;;; ai-conversations.el --- GPTel conversation persistence and autosave -*- lexical-binding: t; coding: utf-8; -*-
+;; Author: Craig Jennings <c@cjennings.net>
+;; Maintainer: Craig Jennings <c@cjennings.net>
+;; Version 0.1
+;; Package-Requires: ((emacs "27.1"))
+;; Keywords: convenience, tools
+;;
+;;; Commentary:
+;; Provides conversation save/load/delete, autosave after responses, and
+;; org-visibility headers for GPTel-powered assistant buffers.
+;;
+;; Loads lazily via autoloads for the interactive entry points.
+
+;;; Code:
+
+(defgroup cj/ai-conversations nil
+ "Conversation persistence for GPTel (save/load/delete, autosave)."
+ :group 'gptel
+ :prefix "cj/")
+
+(defcustom cj/gptel-conversations-directory
+ (expand-file-name "ai-conversations" user-emacs-directory)
+ "Directory where GPTel conversations are stored."
+ :type 'directory
+ :group 'cj/ai-conversations)
+
+(defcustom cj/gptel-conversations-window-side 'right
+ "Side to display the AI-Assistant buffer when loading a conversation."
+ :type '(choice (const :tag "Right" right)
+ (const :tag "Left" left)
+ (const :tag "Bottom" bottom)
+ (const :tag "Top" top))
+ :group 'cj/ai-conversations)
+
+(defcustom cj/gptel-conversations-window-width 0.4
+ "Set the side window width when loading a conversation.
+
+If displaying on the top or bottom, treat this value as a height fraction."
+ :type 'number
+ :group 'cj/ai-conversations)
+
+(defcustom cj/gptel-conversations-sort-order 'newest-first
+ "Sort order for conversation selection prompts."
+ :type '(choice (const :tag "Newest first" newest-first)
+ (const :tag "Oldest first" oldest-first))
+ :group 'cj/ai-conversations)
+
+(defvar-local cj/gptel-autosave-enabled nil
+ "Non-nil means auto-save after each AI response in GPTel buffers.")
+
+(defvar-local cj/gptel-autosave-filepath nil
+ "File path used for auto-saving the conversation buffer.")
+
+(defcustom cj/gptel-conversations-autosave-on-send t
+ "Non-nil means auto-save the conversation immediately after `gptel-send'."
+ :type 'boolean
+ :group 'cj/ai-conversations)
+
+(defun cj/gptel--autosave-after-send (&rest _args)
+ "Auto-save current GPTel buffer right after `gptel-send' if enabled."
+ (when (and cj/gptel-conversations-autosave-on-send
+ (bound-and-true-p gptel-mode)
+ cj/gptel-autosave-enabled
+ (stringp cj/gptel-autosave-filepath)
+ (> (length cj/gptel-autosave-filepath) 0))
+ (condition-case err
+ (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
+ (error (message "cj/gptel autosave-on-send failed: %s" (error-message-string err))))))
+
+(with-eval-after-load 'gptel
+ (unless (advice-member-p #'cj/gptel--autosave-after-send #'gptel-send)
+ (advice-add 'gptel-send :after #'cj/gptel--autosave-after-send)))
+
+(defun cj/gptel--slugify-topic (s)
+ "Return a filesystem-friendly slug for topic string S."
+ (let* ((down (downcase (or s "")))
+ (repl (replace-regexp-in-string "[^a-z0-9]+" "-" down))
+ (trim (replace-regexp-in-string "^-+\\|-+$" "" repl)))
+ (or (and (> (length trim) 0) trim) "conversation")))
+
+(defun cj/gptel--existing-topics ()
+ "Return topic slugs, without timestamps, present in the conversations directory."
+ (when (file-exists-p cj/gptel-conversations-directory)
+ (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$")))
+ (delete-dups
+ (mapcar
+ (lambda (f)
+ (replace-regexp-in-string "_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" "" f))
+ files)))))
+
+(defun cj/gptel--latest-file-for-topic (topic-slug)
+ "Return the newest saved conversation filename for TOPIC-SLUG, or nil."
+ (let* ((rx (format "^%s_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$"
+ (regexp-quote topic-slug)))
+ (files (and (file-exists-p cj/gptel-conversations-directory)
+ (directory-files cj/gptel-conversations-directory nil rx))))
+ (car (sort files #'string>))))
+
+(defun cj/gptel--timestamp-from-filename (filename)
+ "Return an Emacs timestamp extracted from FILENAME, or nil.
+
+Expect FILENAME to match _YYYYMMDD-HHMMSS.gptel."
+ (when (string-match "_\\([0-9]\\{8\\}\\)-\\([0-9]\\{6\\}\\)\\.gptel\\'" filename)
+ (let* ((date (match-string 1 filename))
+ (time (match-string 2 filename))
+ (Y (string-to-number (substring date 0 4)))
+ (M (string-to-number (substring date 4 6)))
+ (D (string-to-number (substring date 6 8)))
+ (h (string-to-number (substring time 0 2)))
+ (m (string-to-number (substring time 2 4)))
+ (s (string-to-number (substring time 4 6))))
+ (encode-time s m h D M Y))))
+
+(defun cj/gptel--conversation-candidates ()
+ "Return conversation candidates sorted per `cj/gptel-conversations-sort-order'."
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
+ (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$"))
+ (enriched
+ (mapcar
+ (lambda (f)
+ (let* ((full (expand-file-name f cj/gptel-conversations-directory))
+ (ptime (or (cj/gptel--timestamp-from-filename f)
+ (nth 5 (file-attributes full))))
+ (disp (format "%s [%s]" f (format-time-string "%Y-%m-%d %H:%M" ptime))))
+ (list :file f :time ptime :display disp)))
+ files))
+ (sorted
+ (sort enriched
+ (lambda (a b)
+ (let ((ta (plist-get a :time))
+ (tb (plist-get b :time)))
+ (if (eq cj/gptel-conversations-sort-order 'newest-first)
+ (time-less-p tb ta) ;; tb earlier than ta => a first
+ (time-less-p ta tb))))))
+ (cands (mapcar (lambda (pl)
+ (cons (plist-get pl :display)
+ (plist-get pl :file)))
+ sorted)))
+ cands))
+
+(defun cj/gptel--save-buffer-to-file (buffer filepath)
+ "Save BUFFER content to FILEPATH with Org visibility properties."
+ (with-current-buffer buffer
+ (let ((content (buffer-string)))
+ (with-temp-buffer
+ (insert "#+STARTUP: showeverything\n")
+ (insert "#+VISIBILITY: all\n\n")
+ (insert content)
+ (write-region (point-min) (point-max) filepath nil 'silent))))
+ filepath)
+
+(defun cj/gptel--ensure-ai-buffer ()
+ "Return the *AI-Assistant* buffer, creating it via `gptel' if needed."
+ (let* ((buf-name "*AI-Assistant*")
+ (buffer (get-buffer buf-name)))
+ (unless buffer
+ (gptel buf-name))
+ (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.
+
+Enable autosave for subsequent AI responses to the same file."
+ (interactive)
+ (let ((buf (get-buffer "*AI-Assistant*")))
+ (unless buf
+ (user-error "No AI-Assistant buffer found"))
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (make-directory cj/gptel-conversations-directory t)
+ (message "Created directory: %s" cj/gptel-conversations-directory))
+ (let* ((topics (or (cj/gptel--existing-topics) '()))
+ (input (completing-read "Conversation topic: " topics nil nil))
+ (topic-slug (cj/gptel--slugify-topic input))
+ (latest (cj/gptel--latest-file-for-topic topic-slug))
+ (use-existing (and latest
+ (y-or-n-p (format "Update existing file %s? " latest))))
+ (filepath (if use-existing
+ (expand-file-name latest cj/gptel-conversations-directory)
+ (let* ((timestamp (format-time-string "%Y%m%d-%H%M%S"))
+ (filename (format "%s_%s.gptel" topic-slug timestamp)))
+ (expand-file-name filename cj/gptel-conversations-directory)))))
+ (cj/gptel--save-buffer-to-file buf filepath)
+ (with-current-buffer buf
+ (setq-local cj/gptel-autosave-filepath filepath)
+ (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)
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
+ (let* ((cands (cj/gptel--conversation-candidates)))
+ (unless cands
+ (user-error "No saved conversations found in %s" cj/gptel-conversations-directory))
+ (let* ((completion-extra-properties '(:display-sort-function identity
+ :cycle-sort-function identity))
+ (selection (completing-read "Delete conversation: " cands nil t))
+ (filename (cdr (assoc selection cands)))
+ (filepath (and filename
+ (expand-file-name filename cj/gptel-conversations-directory))))
+ (unless filename
+ (user-error "No conversation selected"))
+ (when (y-or-n-p (format "Really delete %s? " filename))
+ (delete-file filepath)
+ (message "Deleted conversation: %s" filename)))))
+
+(defun cj/gptel--strip-visibility-headers ()
+ "Strip org visibility headers at the top of the current buffer if present."
+ (save-excursion
+ (goto-char (point-min))
+ (while (looking-at "^#\\+\\(STARTUP\\|VISIBILITY\\):.*\n")
+ (delete-region (match-beginning 0) (match-end 0)))
+ (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.
+
+Prompt to save the current conversation first when appropriate, then enable autosave."
+ (interactive)
+ (let ((ai-buffer (get-buffer-create "*AI-Assistant*")))
+ (when (and (with-current-buffer ai-buffer (> (buffer-size) 0))
+ (with-current-buffer ai-buffer (bound-and-true-p gptel-mode)))
+ (when (y-or-n-p "Save current conversation before loading new one? ")
+ (with-current-buffer ai-buffer
+ (call-interactively #'cj/gptel-save-conversation)))))
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory))
+ (let* ((cands (cj/gptel--conversation-candidates)))
+ (unless cands
+ (user-error "No saved conversations found in %s" cj/gptel-conversations-directory))
+ (let* ((completion-extra-properties '(:display-sort-function identity
+ :cycle-sort-function identity))
+ (selection (completing-read "Load conversation: " cands nil t))
+ (filename (cdr (assoc selection cands)))
+ (filepath (and filename
+ (expand-file-name filename cj/gptel-conversations-directory))))
+ (unless filename
+ (user-error "No conversation selected"))
+ (with-current-buffer (cj/gptel--ensure-ai-buffer)
+ (erase-buffer)
+ (insert-file-contents filepath)
+ (cj/gptel--strip-visibility-headers)
+ (goto-char (point-max))
+ (set-buffer-modified-p t)
+ (setq-local cj/gptel-autosave-filepath filepath)
+ (setq-local cj/gptel-autosave-enabled t))
+ (let ((buf (get-buffer "*AI-Assistant*")))
+ (unless (get-buffer-window buf)
+ (display-buffer-in-side-window
+ buf `((side . ,cj/gptel-conversations-window-side)
+ (window-width . ,cj/gptel-conversations-window-width)))))
+ (select-window (get-buffer-window "*AI-Assistant*"))
+ (message "Loaded conversation from: %s" filepath))))
+
+(defun cj/gptel--autosave-after-response (&rest _args)
+ "Auto-save the current GPTel buffer when enabled."
+ (when (and (bound-and-true-p gptel-mode)
+ cj/gptel-autosave-enabled
+ (stringp cj/gptel-autosave-filepath)
+ (> (length cj/gptel-autosave-filepath) 0))
+ (condition-case err
+ (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath)
+ (error (message "cj/gptel autosave failed: %s" (error-message-string err))))))
+
+(with-eval-after-load 'gptel
+ (unless (member #'cj/gptel--autosave-after-response gptel-post-response-functions)
+ (add-hook 'gptel-post-response-functions #'cj/gptel--autosave-after-response)))
+
+(provide 'ai-conversations)
+;;; ai-conversations.el ends here
diff --git a/modules/auth-config.el b/modules/auth-config.el
new file mode 100644
index 00000000..a42bd52a
--- /dev/null
+++ b/modules/auth-config.el
@@ -0,0 +1,45 @@
+;; auth-config.el --- Configuration for Authentication Utilities -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; Configuration for Emacs authentication and GPG integration:
+
+;; • auth-source
+;; – Forces use of your default authinfo file
+;; – 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
+
+;;; Code:
+
+(require 'user-constants) ;; defines authinfo-file location
+
+;; -------------------------------- Auth Sources -------------------------------
+;; auth sources settings
+
+(use-package auth-source
+ :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
+
+;; ----------------------------- Easy PG Assistant -----------------------------
+;; Key management, cryptographic operations on regions and files, dired
+;; integration, and automatic encryption/decryption of *.gpg files.
+
+(use-package epa
+ :ensure nil ;; built-in
+ :demand t
+ :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)
+
+
+(provide 'auth-config)
+;;; auth-config.el ends here.
diff --git a/modules/calibredb-epub-config.el b/modules/calibredb-epub-config.el
new file mode 100644
index 00000000..bd82c588
--- /dev/null
+++ b/modules/calibredb-epub-config.el
@@ -0,0 +1,223 @@
+;;; calibredb-epub-config --- Functionality for Ebook Management and Display -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; This module provides a comprehensive ebook management and reading experience
+;; within Emacs, integrating CalibreDB for library management and Nov for EPUB
+;; reading.
+;;
+;; FEATURES:
+;; - CalibreDB integration for managing your Calibre ebook library
+;; - Nov mode for reading EPUB files with customized typography and layout
+;; - Seamless navigation between Nov reading buffers and CalibreDB entries
+;; - Image centering in EPUB documents without modifying buffer text
+;; - Quick filtering and searching within your ebook library
+;;
+;; KEY BINDINGS:
+;; - M-B: Open CalibreDB library browser
+;; - In CalibreDB search mode:
+;; - l: Filter by tag
+;; - L: Clear all filters
+;; - In Nov mode:
+;; - z: Open current EPUB in external viewer (zathura)
+;; - C-c C-b: Jump to CalibreDB entry for current book
+;; - m: Set bookmark
+;; - b: List bookmarks
+;;
+;; WORKFLOW:
+;; 1. Press M-B to browse your Calibre library
+;; 2. Use filters (l for tags, L to clear) to narrow results
+;; 3. Open an EPUB to read it in Nov with optimized typography
+;; 4. While reading, use C-c C-b to jump back to the book's metadata
+;; 5. Use z to open in external reader when needed
+;;
+;; CONFIGURATION NOTES:
+;; - Prefers EPUB format when available, falls back to PDF
+;; - Centers images in EPUB documents using display properties
+;; - Applies custom typography with larger fonts for comfortable reading
+;; - Uses visual-fill-column for centered text with appropriate margins
+
+;;; Code:
+
+(require 'user-constants) ;; for books-dir
+
+;; -------------------------- CalibreDB Ebook Manager --------------------------
+
+(use-package calibredb
+ :defer 1
+ :commands calibredb
+ :bind
+ ("M-B" . calibredb)
+ ;; use built-in filter by tag, add clear-filters
+ (:map calibredb-search-mode-map
+ ("l" . calibredb-filter-by-tag)
+ ("L" . cj/calibredb-clear-filters))
+ :config
+ ;; basic config
+ (setq calibredb-root-dir books-dir)
+ (setq calibredb-db-dir (expand-file-name "metadata.db" calibredb-root-dir))
+ (setq calibredb-program "/usr/bin/calibredb")
+ (setq calibredb-preferred-format "epub")
+ (setq calibredb-search-page-max-rows 20000)
+
+ ;; search window display
+ (setq calibredb-size-show nil)
+ (setq calibredb-order "asc")
+ (setq calibredb-id-width 7))
+
+(defun cj/calibredb-clear-filters ()
+ "Clear active filters and show all results."
+ (interactive)
+ (setq calibredb-tag-filter-p nil
+ calibredb-favorite-filter-p nil
+ calibredb-author-filter-p nil
+ calibredb-date-filter-p nil
+ calibredb-format-filter-p nil
+ calibredb-search-current-page 1)
+ ;; empty string resets keyword filter and refreshes listing
+ (calibredb-search-keyword-filter ""))
+
+;; ------------------------------ Nov Epub Reader ------------------------------
+
+(use-package nov
+ :defer .5
+ :after visual-fill-column
+ :mode ("\\.epub\\'" . nov-mode)
+ :hook (nov-mode . cj/nov-apply-preferences)
+ :bind
+ (:map nov-mode-map
+ ("m" . bookmark-set)
+ ("b" . bookmark-bmenu-list)
+ ("r" . nov-render-document)
+ ("l" . recenter-top-bottom)
+ ("d" . sdcv-search-input)
+ ("." . cj/forward-paragraph-and-center)
+ ("<" . nov-history-back)
+ (">" . nov-history-forward)
+ ("," . backward-paragraph)
+ ;; open current EPUB with zathura (same key in pdf-view)
+ ("z" . (lambda () (interactive) (cj/open-file-with-command "zathura")))
+ ("t" . nov-goto-toc)
+ ("C-c C-b" . cj/nov-jump-to-calibredb)))
+
+(defun cj/forward-paragraph-and-center ()
+ "Forward one paragraph and center the page."
+ (interactive)
+ (forward-paragraph)
+ (recenter))
+
+(defun cj/nov-apply-preferences ()
+ "Apply preferences after nov-mode has launched."
+ (interactive)
+ (face-remap-add-relative 'variable-pitch :height 180)
+ (face-remap-add-relative 'fixed-pitch :height 180)
+ ;; Make this buffer-local so other Nov buffers can choose differently
+ (setq-local nov-text-width 115)
+ (when (require 'visual-fill-column nil t)
+ (setq-local visual-fill-column-center-text t
+ ;; small cushion above nov-text-width prevents truncation
+ visual-fill-column-width (+ nov-text-width 10))
+ (hl-line-mode)
+ (visual-fill-column-mode 1))
+ (nov-render-document))
+
+(defun cj/nov-center-images ()
+ "Center images in the current Nov buffer without modifying text.
+
+Use line-prefix and wrap-prefix with a space display property aligned to a
+computed column based on the window text area width."
+ (let ((inhibit-read-only t))
+ ;; Clear any prior centering prefixes first (fresh render usually makes this
+ ;; unnecessary, but it makes the function idempotent).
+ (remove-text-properties (point-min) (point-max)
+ '(line-prefix nil wrap-prefix nil))
+ (save-excursion
+ (goto-char (point-min))
+ ;; Work in the selected window showing this buffer (if any).
+ (when-let* ((win (get-buffer-window (current-buffer) t))
+ (col-width (window-body-width win)) ;; columns
+ (col-px (* col-width (window-font-width win))))
+ (while (let ((m (text-property-search-forward
+ 'display nil
+ (lambda (_ p) (and (consp p) (eq (car-safe p) 'image))))))
+ (when m
+ (let* ((img (prop-match-value m))
+ (img-px (car (image-size img t))) ;; pixel width
+ ;; Convert pixel image width to columns for alignment.
+ (img-cols (max 1 (ceiling (/ (float img-px)
+ (max 1 (window-font-width win))))))
+ (pad-cols (max 0 (/ (- col-width img-cols) 2)))
+ (prefix (propertize " " 'display `(space :align-to ,pad-cols))))
+ (save-excursion
+ (goto-char (prop-match-beginning m))
+ (beginning-of-line)
+ (let ((bol (point))
+ (eol (line-end-position)))
+ (add-text-properties bol eol
+ `(line-prefix ,prefix
+ wrap-prefix ,prefix)))))
+ t)))))))
+
+(add-hook 'nov-post-html-render-hook #'cj/nov-center-images)
+
+;; Jump from a Nov buffer to the corresponding CalibreDB entry.
+(defun cj/nov--metadata-get (key)
+ "Return a metadata value from nov-metadata trying KEY as symbol and string."
+ (let* ((v (or (and (boundp 'nov-metadata)
+ (or (alist-get key nov-metadata nil nil #'equal)
+ (alist-get (if (symbolp key) (symbol-name key) key)
+ nov-metadata nil nil #'equal)))
+ nil)))
+ (cond
+ ((and (listp v) (= (length v) 1)) (car v))
+ ((stringp v) v)
+ (t v))))
+
+(defun cj/nov--file-path ()
+ "Return the current EPUB file path when in nov-mode, or nil."
+ (when (derived-mode-p 'nov-mode)
+ ;; In nov, the buffer visits the .epub; buffer-file-name is usually the EPUB.
+ (or buffer-file-name
+ (and (boundp 'nov-epub-filename) nov-epub-filename)
+ (and (boundp 'nov-epub-file) nov-epub-file))))
+
+(defun cj/nov-jump-to-calibredb ()
+ "Open CalibreDB focused on the current EPUB's book entry.
+
+Try to use the Calibre book id from the parent folder name (for example,
+\"Title (123)\"). Fall back to a title or author search when no id exists."
+ (interactive)
+ (require 'calibredb)
+ (let* ((file (cj/nov--file-path))
+ (title (or (cj/nov--metadata-get 'title)
+ (cj/nov--metadata-get "title")))
+ (author (or (cj/nov--metadata-get 'creator)
+ (cj/nov--metadata-get 'author)
+ (cj/nov--metadata-get "creator")
+ (cj/nov--metadata-get "author")))
+ (id (when file
+ (let* ((parent (file-name-nondirectory
+ (directory-file-name (file-name-directory file)))))
+ (when (string-match " (\\([0-9]+\\))\\'" parent)
+ (match-string 1 parent))))))
+ (calibredb)
+ (with-current-buffer (calibredb-find-create-search-buffer)
+ (setq calibredb-search-current-page 1)
+ (cond
+ (id
+ (calibredb-search-keyword-filter (format "id:%s" id))
+ (message "CalibreDB: focused by id:%s" id))
+ ((or title author)
+ (let* ((q (string-join
+ (delq nil (list (and title (format "title:\"%s\"" title))
+ (and author (format "authors:\"%s\"" author))))
+ " and ")))
+ (calibredb-search-keyword-filter q)
+ (message "CalibreDB: search %s" (if (string-empty-p q) "<all>" q))))
+ (t
+ (calibredb-search-keyword-filter "")
+ (message "CalibreDB: no metadata; showing all"))))))
+
+(provide 'calibredb-epub-config)
+;;; calibredb-epub-config.el ends here
diff --git a/modules/chrono-tools.el b/modules/chrono-tools.el
new file mode 100644
index 00000000..f6c4c0f6
--- /dev/null
+++ b/modules/chrono-tools.el
@@ -0,0 +1,118 @@
+;;; chrono-tools.el --- Config for Date and Time-Related Utils -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; This module centralizes configuration for Emacs time-related tools:
+;;
+;; – world-clock: predefined city list and custom time format
+;; – calendar: quick navigation keybindings by day, month, and year
+;; – tmr: lightweight timer setup with sounds, notifications, and history
+;;
+;;; Code:
+
+(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"))
+
+(use-package calendar
+ :ensure nil ;; built-in
+ :defer 0.5
+ :bind (("M-#" . calendar)
+ :map calendar-mode-map
+ ("," . calendar-backward-day)
+ ("." . calendar-forward-day)
+ ("<" . calendar-backward-month)
+ (">" . calendar-forward-month)
+ ("M-," . calendar-backward-year)
+ ("M-." . calendar-forward-year)))
+
+
+;; ------------------------------------ TMR ------------------------------------
+
+(defun cj/tmr-select-sound-file ()
+ "Select a sound file from `sounds-dir' to use for tmr timers.
+
+Present all audio files in the sounds directory and set the chosen file as
+`tmr-sound-file'. Use \\[universal-argument] to reset to the default sound."
+ (interactive)
+ (if current-prefix-arg
+ ;; With prefix arg, reset to default
+ (progn
+ (setq tmr-sound-file notification-sound)
+ (message "Timer sound reset to default: %s"
+ (file-name-nondirectory notification-sound)))
+ ;; Otherwise, select a new sound
+ (let* ((audio-extensions '("mp3" "m4a" "ogg" "opus" "wav" "flac" "aac"))
+ (extension-regex (concat "\\." (regexp-opt audio-extensions t) "$"))
+ (sound-files (when (file-directory-p sounds-dir)
+ (directory-files sounds-dir nil extension-regex)))
+ (current-file (when (and tmr-sound-file (file-exists-p tmr-sound-file))
+ (file-name-nondirectory tmr-sound-file)))
+ (selected-file (when sound-files
+ (completing-read
+ (format "Select timer sound%s: "
+ (if current-file
+ (format " (current: %s)" current-file)
+ ""))
+ sound-files
+ nil
+ t
+ nil
+ nil
+ current-file)))) ; Default to current file
+ (cond
+ ((not (file-directory-p sounds-dir))
+ (message "Sounds directory does not exist: %s" sounds-dir))
+ ((null sound-files)
+ (message "No audio files found in %s" sounds-dir))
+ (selected-file
+ (setq tmr-sound-file (expand-file-name selected-file sounds-dir))
+ (when (equal tmr-sound-file notification-sound)
+ (message "Timer sound set to default: %s" selected-file))
+ (unless (equal tmr-sound-file notification-sound)
+ (message "Timer sound set to: %s" selected-file)))
+ (t
+ (message "No file selected"))))))
+
+(defun cj/tmr-reset-sound-to-default ()
+ "Reset the tmr sound file to the default notification sound."
+ (interactive)
+ (setq tmr-sound-file notification-sound)
+ (message "Timer sound reset to default: %s"
+ (file-name-nondirectory notification-package)))
+
+(use-package tmr
+ :defer 0.5
+ :init
+ (global-unset-key (kbd "M-t"))
+ :bind (("M-t" . tmr-prefix-map)
+ :map tmr-prefix-map
+ ("*" . tmr)
+ ("t" . tmr-with-details)
+ ("S" . cj/tmr-select-sound-file)
+ ("R" . cj/tmr-reset-sound-to-default))
+ :config
+ (setq tmr-sound-file notification-sound)
+ (setq tmr-notification-urgency 'normal)
+ (setq tmr-descriptions-list 'tmr-description-history))
+
+(provide 'chrono-tools)
+;;; chrono-tools.el ends here
diff --git a/modules/config-utilities.el b/modules/config-utilities.el
new file mode 100644
index 00000000..beb44bf7
--- /dev/null
+++ b/modules/config-utilities.el
@@ -0,0 +1,291 @@
+;;; config-utilities --- Config Hacking Utilities -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Convenience utilities for working on Emacs configuration.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;; ------------------------------ Reload Init File -----------------------------
+;; it does what it says it does.
+
+(defun cj/reload-init-file ()
+ "Reload the init file. Useful when modifying Emacs config."
+ (interactive)
+ (load-file user-init-file))
+
+;; ---------------------------- Recompile Emacs Home ---------------------------
+;; deletes all .elc and .eln files in user-emacs-directory, then compiles
+;; all emacs-lisp files natively if supported, or byte-compiles them if not.
+
+(defun cj/recompile-emacs-home()
+ "Delete all compiled files in the Emacs home before recompiling.
+
+Recompile natively when supported, otherwise fall back to byte compilation."
+ (interactive)
+ (let* ((native-comp-supported (boundp 'native-compile-async))
+ (elt-dir
+ (expand-file-name (if native-comp-supported "eln" "elc")
+ user-emacs-directory))
+ (message-format
+ (format "Please confirm recursive %s recompilation of %%s: "
+ (if native-comp-supported "native" "byte")))
+ (compile-message (format "%scompiling all emacs-lisp files in %%s"
+ (if native-comp-supported "Natively " "Byte-"))))
+ (if (yes-or-no-p (format message-format user-emacs-directory))
+ (progn
+ (message "Deleting all compiled files in %s" user-emacs-directory)
+ (dolist (file (directory-files-recursively user-emacs-directory
+ "\\(\\.elc\\|\\.eln\\)$"))
+ (delete-file file))
+ (when (file-directory-p elt-dir)
+ (delete-directory elt-dir t t))
+ (message compile-message user-emacs-directory)
+ (if native-comp-supported
+ (let ((comp-async-report-warnings-errors nil))
+ (native-compile-async user-emacs-directory 'recursively))
+ (byte-recompile-directory user-emacs-directory 0)))
+ (message "Cancelled recompilation of %s" user-emacs-directory))))
+
+;; ---------------------- Delete Emacs Home Compiled Files ---------------------
+;; removes all compiled files and deletes the eln directory
+
+(defun cj/delete-emacs-home-compiled-files ()
+ "Delete all compiled files recursively in \='user-emacs-directory\='."
+ (interactive)
+ (message "Deleting compiled files under %s. This may take a while."
+ user-emacs-directory)
+ (require 'find-lisp) ;; make sure the package is required
+ (mapc (lambda (path)
+ (when (or (string-suffix-p ".elc" path)
+ (string-suffix-p ".eln" path))
+ (delete-file path)))
+ (find-lisp-find-files user-emacs-directory ""))
+ (message "Done. Compiled files removed under %s" user-emacs-directory))
+
+;; ---------------------- List Loaded Packages ---------------------
+;; you don't really need an explanation for this function, do you?
+
+(defvar cj--loaded-file-paths nil
+ "All file paths that are loaded.")
+(defvar cj--loaded-packages-buffer "*loaded-packages*"
+ "Buffer name for data about loaded packages.")
+(defvar cj--loaded-features-buffer "*loaded-features*"
+ "Buffer name for data about loaded features.")
+
+(defun cj/list-loaded-packages()
+ "List all currently loaded packages."
+ (interactive)
+ (with-current-buffer (get-buffer-create cj--loaded-packages-buffer)
+ (erase-buffer)
+ (pop-to-buffer (current-buffer))
+
+ (insert "* Live Packages Exploration\n\n")
+ (insert (format "%s total packages currently loaded\n"
+ (length cj--loaded-file-paths)))
+
+ ;; Extract data from builtin variable `load-history'.
+ (setq cj--loaded-file-paths
+ (seq-filter #'stringp
+ (mapcar #'car load-history)))
+ (cl-sort cj--loaded-file-paths 'string-lessp)
+ (cl-loop for file in cj--loaded-file-paths
+ do (insert "\n" file))
+
+ (goto-char (point-min))))
+
+;; ---------------------------- List Loaded Features ---------------------------
+;; this function's also self-explanatory
+
+(defun cj/list-loaded-features()
+ "List all currently loaded features."
+ (interactive)
+ (with-current-buffer (get-buffer-create cj--loaded-features-buffer)
+ (erase-buffer)
+ (pop-to-buffer (current-buffer))
+
+ (insert (format "\n** %d features currently loaded\n"
+ (length features)))
+
+ (let ((features-vec (apply 'vector features)))
+ (cl-sort features-vec 'string-lessp)
+ (cl-loop for x across features-vec
+ do (insert (format " - %-25s: %s\n" x
+ (locate-library (symbol-name x))))))
+ (goto-char (point-min))))
+
+;; ------------------------ Validate Org Agenda Entries ------------------------
+
+(defun cj/check-org-agenda-invalid-timestamps ()
+ "Scan all files in `org-agenda-files' for invalid timestamps.
+
+Checks DEADLINE, SCHEDULED, TIMESTAMP properties and inline timestamps in headline contents.
+
+Generates an Org-mode report buffer with links to problematic entries, property/type, and raw timestamp string."
+ (interactive)
+ (require 'org-element)
+ (let ((report-buffer (get-buffer-create "*Org Invalid Timestamps Report*")))
+ (with-current-buffer report-buffer
+ (erase-buffer)
+ (org-mode)
+ (insert "#+TITLE: Org Invalid Timestamps Report\n\n")
+ (insert "* Overview\nScan of org-agenda-files for invalid timestamps.\n\n"))
+ (dolist (file org-agenda-files)
+ (with-current-buffer (find-file-noselect file)
+ (let ((invalid-entries '())
+ (props '("DEADLINE" "SCHEDULED" "TIMESTAMP"))
+ (parse-tree (org-element-parse-buffer 'headline)))
+ (org-element-map parse-tree 'headline
+ (lambda (hl)
+ (let ((headline-text (org-element-property :raw-value hl))
+ (begin-pos (org-element-property :begin hl)))
+ (dolist (prop props)
+ (let ((timestamp (org-element-property (intern (downcase prop)) hl)))
+ (when timestamp
+ (let ((time-str (org-element-property :raw-value timestamp)))
+ (unless (ignore-errors (org-time-string-to-absolute time-str))
+ (push (list file begin-pos headline-text prop time-str) invalid-entries))))))
+ (let ((contents-begin (org-element-property :contents-begin hl))
+ (contents-end (org-element-property :contents-end hl)))
+ (when (and contents-begin contents-end)
+ (save-excursion
+ (goto-char contents-begin)
+ (while (re-search-forward org-ts-regexp contents-end t)
+ (let ((ts-string (match-string 0)))
+ (unless (ignore-errors (org-time-string-to-absolute ts-string))
+ (push (list file begin-pos headline-text "inline timestamp" ts-string) invalid-entries))))))))))
+
+ (with-current-buffer report-buffer
+ (insert (format "* %s\n" file))
+ (if invalid-entries
+ (dolist (entry (reverse invalid-entries))
+ (cl-destructuring-bind (f pos head prop ts) entry
+ (insert (format "- [[file:%s::%d][%s]]\n - Property/Type: %s\n - Invalid timestamp: \"%s\"\n"
+ f pos head prop ts))))
+ (insert "No invalid timestamps found.\n")))
+ (with-current-buffer report-buffer (insert "\n")))))
+ (pop-to-buffer report-buffer)))
+
+;; ----------------------------- 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."))
+
+;; --------------------------- Org-Alert-Check Timers --------------------------
+;; Utility to list timers running org-alert-check
+
+(defun cj/org-alert-list-timers ()
+ "List all active timers running `org-alert-check' with next run time in human-readable form."
+ (interactive)
+ (let ((timers (cl-remove-if-not
+ (lambda (timer)
+ (eq (timer--function timer) #'org-alert-check))
+ timer-list)))
+ (if timers
+ (let ((lines
+ (mapcar
+ (lambda (timer)
+ (let* ((next-run (timer--time timer))
+ (next-run-str (format-time-string "%Y-%m-%d %H:%M:%S" next-run)))
+ (format "Timer next runs at: %s" next-run-str)))
+ timers)))
+ (message "org-alert-check timers:\n%s" (string-join lines "\n")))
+ (message "No org-alert-check timers found."))))
+
+;; ------------------------------- Sqlite Tracing ------------------------------
+
+
+(defvar cj/sqlite-tracing-enabled nil)
+(defvar cj/sqlite--db-origins (make-hash-table :test 'eq :weakness 'key))
+
+(defun cj/capture-backtrace ()
+ (condition-case nil
+ (if (fboundp 'backtrace-frames)
+ (mapcar (lambda (fr) (car fr)) (backtrace-frames))
+ (list "no-backtrace-frames"))
+ (error (list "failed-to-capture-backtrace"))))
+
+(defun cj/take (n xs)
+ (cl-subseq xs 0 (min n (length xs))))
+
+(defun cj--ad-sqlite-open (orig file &rest opts)
+ (let ((db (apply orig file opts)))
+ (puthash db
+ (list :file file
+ :opts opts
+ :where (or load-file-name buffer-file-name)
+ :time (current-time-string)
+ :stack (cj/capture-backtrace))
+ cj/sqlite--db-origins)
+ db))
+
+(defun cj--ad-sqlite-close (orig db &rest args)
+ (let ((info (gethash db cj/sqlite--db-origins)))
+ (when info
+ (message "cj/sqlite: closing %s opened at %s by %s"
+ (plist-get info :file)
+ (plist-get info :time)
+ (or (plist-get info :where) "unknown"))))
+ (apply orig db args))
+
+(defun cj--ad-set-finalizer (orig obj fn)
+ (let* ((origin (list :time (current-time-string)
+ :where (or load-file-name buffer-file-name)
+ :stack (cj/capture-backtrace)
+ :sqlite-open (when (and (fboundp 'sqlitep)
+ (ignore-errors (sqlitep obj)))
+ (gethash obj cj/sqlite--db-origins))))
+ (wrapped
+ (lambda (&rest args)
+ (condition-case err
+ (apply fn args)
+ (error
+ (let* ((stack (cj/take 8 (plist-get origin :stack)))
+ (dbi (plist-get origin :sqlite-open))
+ (extra (if dbi
+ (format " db=%s opened at %s by %s"
+ (plist-get dbi :file)
+ (plist-get dbi :time)
+ (or (plist-get dbi :where) "unknown"))
+ "")))
+ (message "cj/finalizer: failed; created at %s (%s); callers=%S;%s; error=%S"
+ (plist-get origin :time)
+ (or (plist-get origin :where) "unknown")
+ stack extra err))
+ ;; Re-signal so Emacs still shows the standard finalizer message.
+ (signal (car err) (cdr err)))))))
+ (funcall orig obj wrapped)))
+
+(defun cj/sqlite-tracing-enable ()
+ "Enable tracing of sqlite opens/closes and annotate failing finalizers."
+ (interactive)
+ (unless cj/sqlite-tracing-enabled
+ (setq cj/sqlite-tracing-enabled t)
+ (advice-add 'set-finalizer :around #'cj--ad-set-finalizer)
+ (when (fboundp 'sqlite-open)
+ (advice-add 'sqlite-open :around #'cj--ad-sqlite-open)
+ (advice-add 'sqlite-close :around #'cj--ad-sqlite-close))
+ (message "cj/sqlite tracing enabled")))
+
+(defun cj/sqlite-tracing-disable ()
+ "Disable sqlite/finalizer tracing and clear recorded origins."
+ (interactive)
+ (setq cj/sqlite-tracing-enabled nil)
+ (ignore-errors (advice-remove 'set-finalizer #'cj--ad-set-finalizer))
+ (when (fboundp 'sqlite-open)
+ (ignore-errors (advice-remove 'sqlite-open #'cj--ad-sqlite-open))
+ (ignore-errors (advice-remove 'sqlite-close #'cj--ad-sqlite-close)))
+ (clrhash cj/sqlite--db-origins)
+ (message "cj/sqlite tracing disabled"))
+
+(cj/sqlite-tracing-enable)
+(setq debug-on-message (rx bos "finalizer failed"))
+
+(provide 'config-utilities)
+;;; config-utilities.el ends here
diff --git a/modules/custom-functions.el b/modules/custom-functions.el
new file mode 100644
index 00000000..5899eec7
--- /dev/null
+++ b/modules/custom-functions.el
@@ -0,0 +1,1012 @@
+;;; custom-functions.el --- My Custom Functions and Keymaps -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; These are custom utility functions I use frequently.
+;; For convenience, they are bound to a custom keymap with a prefix of "C-;".
+
+;; Additional keymaps are created on top of this prefix to collect similar operations.
+;;
+;; C-; --- Custom Key Map
+;; C-; ) → jump to matching parenthesis
+;; C-; f → re-formats region or buffer (delete trailing whitespace, reindent, and untabify).
+;; C-; W → counts words in region or buffer displaying results in echo area.
+;; C-; / → replace common glyph fractions (½) to text (1/2) (text to glyph with C-u).
+;; C-; A → align text by regexp with spaces
+;; C-; | → toggle visibility of the fill-column indicator
+;;
+;; C-; b --- Buffer & File Operations
+;; C-; b m → move buffer and file to another directory
+;; C-; b r → rename buffer and its file simultaneously
+;; C-; b d → delete buffer and its file simultaneously
+;; C-; b l → copy file:// link of buffer’s source file
+;; C-; b c → copy entire buffer to the kill rung
+;; C-; b b → clear contents of buffer from point to beginnning
+;; C-; b e → clear contents of buffer from point to end
+;;
+;; C-; w --- Whitespace Operations
+;; C-; w r → remove leading/trailing whitespace from line or region (buffer with C-u).
+;; C-; w c → collapses runs of whitespace to one space.
+;; C-; w l → delete all blank lines in region or buffer
+;; C-; w h → hyphenate all whitespace in region
+;;
+;; C-; s --- Surround, Append & Prepend
+;; C-; s s → surround word or region with string
+;; C-; s a → append a string to each line
+;; C-; s p → prepend a string to each line
+;;
+;; C-; d --- Date/Time Insertion
+;; C-; d r → readable date and time : Sunday, August 31, 2025 at 04:07:02 PM CDT
+;; C-; d s → sortable date and time : 2025-08-31 Sun @ 16:07:30 -0500
+;; C-; d t → sortable time only : 04:07:50 PM CDT
+;; C-; d D → readable time only : 4:08 PM
+;; C-; d T → readable date only : Sunday, August 31, 2025
+;; C-; d d → sortable date only : 2025-08-31 Sun
+;;
+;; C-; l --- Line & Paragraph Operations
+;; C-; l j → join lines (or selected region of lines)
+;; C-; l J → join entire paragraph. guesses at the lines that constitute paragraph.
+;; C-; l d → duplicates the line or region
+;; C-; l r → remove duplicate lines from the buffer, keeping the first occurrence.
+;; C-; l R → remove lines containing specific text from the region or buffer.
+;; C-; l u → "underline" current line: repeat a chosen character to same length on line below.
+
+;;
+;; C-; m --- Comment Styling & Removal
+;; C-; m r → reformats selecton into a commented paragraph re-wrapping at fill column width.
+;; C-; m c → insert centered comment
+;; C-; m - → insert hyphen-style comment
+;; C-; m b → draw a comment box
+;; C-; m D → delete all comments in buffer
+;;
+;; C-; o --- Ordering & Sorting
+;; C-; o a → arrayify lines into quoted list
+;; C-; o u → unarrayify list into lines
+;; C-; o A → alphabetize items in region
+;; C-; o l → split comma-separated text onto lines
+;;
+;; C-; c --- Case-Change Operations
+;; C-; c t → Change selected text to Title Case : This is the Title of a Movie
+;; C-; c u → Change word or region to Upper Case : THIS IS THE TITLE OF A MOVIE
+;; C-; c d → Change word or region to Lower Case : this is the title of a movie
+
+;;; Code:
+
+(require 'subr-x)
+
+(use-package expand-region
+ :demand t) ;; used w/in join paragraph
+
+;;; ----------------- Miscellaneous Functions And Custom Keymap -----------------
+
+(defun cj/jump-to-matching-paren ()
+ "Jump to the matching parenthesis when point is on one.
+
+Signal a message when point is not on a parenthesis."
+ (interactive)
+ (cond ((looking-at "\\s\(\\|\\s\{\\|\\s\[")
+ (forward-list))
+ ((looking-back "\\s\)\\|\\s\}\\|\\s\\]")
+ (backward-list))
+ (t (message "Cursor doesn't follow parenthesis, so there's no match."))))
+
+(defun cj/format-region-or-buffer ()
+ "Reformat the region or the entire buffer.
+
+Replaces tabs with spaces, deletes trailing whitespace, and reindents the region."
+ (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))))
+
+(defun cj/count-words-buffer-or-region ()
+ "Count the number of words in the buffer or region.
+
+Display the result in the minibuffer and *Messages* buffer."
+ (interactive)
+ (let ((begin (point-min))
+ (end (point-max))
+ (area_type "the buffer"))
+ (when mark-active
+ (setq begin (region-beginning)
+ end (region-end)
+ area_type "the region"))
+ (message (format "There are %d words in %s." (count-words begin end) area_type))))
+
+(defun cj/replace-fraction-glyphs (start end)
+ "Replace common fraction glyphs between START and END.
+
+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")))))
+ (save-excursion
+ (dolist (r replacements)
+ (goto-char start)
+ (while (search-forward (car r) end t)
+ (replace-match (cdr r)))))))
+
+(defun cj/align-regexp-with-spaces (orig-fun &rest args)
+ "Call ORIG-FUN with ARGS while temporarily disabling tabs for alignment.
+
+This advice ensures `align-regexp' uses spaces by binding `indent-tabs-mode' to nil."
+ (let ((indent-tabs-mode nil))
+ (apply orig-fun args)))
+
+(advice-remove 'align-regexp #'align-regexp-with-spaces) ; in case this is reloaded
+(advice-add 'align-regexp :around #'cj/align-regexp-with-spaces)
+
+;; Must unbind Flyspell's 'C-;' keybinding before it's assigned to cj/custom-keymap
+(global-unset-key (kbd "C-;"))
+(eval-after-load "flyspell"
+ '(define-key flyspell-mode-map (kbd "C-;") nil))
+
+(defvar cj/custom-keymap
+ (let ((map (make-sparse-keymap)))
+ (define-key map ")" 'cj/jump-to-matching-paren)
+ (define-key map "f" 'cj/format-region-or-buffer)
+ (define-key map "W" 'cj/count-words-buffer-or-region)
+ (define-key map "/" 'cj/replace-fraction-glyphs)
+ (define-key map "A" 'align-regexp)
+ (define-key map "B" 'toggle-debug-on-error)
+ (define-key map "|" 'display-fill-column-indicator-mode)
+
+ ;; load debug helpers only on this keybinding
+ map)
+ "The base key map for custom elisp functions holding miscellaneous functions.
+Other key maps extend from this key map to hold categorized functions.")
+(global-set-key (kbd "C-;") cj/custom-keymap)
+
+;;; ------------------- Buffer And File Operations And Keymap -------------------
+
+(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 ((name (buffer-name))
+ (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."))
+
+;; prints using postscript for much nicer output
+(use-package ps-print
+ :ensure nil ;; built-in
+ :config
+ (defun cj/print-buffer-ps ()
+ "Print the current buffer as PostScript (monochrome) to the system default printer.
+Sends directly to the spooler (no temp files), with no page header."
+ (interactive)
+ (let* ((spooler
+ (cond
+ ((executable-find "lpr") "lpr")
+ ((executable-find "lp") "lp")
+ (t (user-error "Cannot print: neither 'lpr' nor 'lp' found in PATH"))))
+ ;; Configure spooler for this invocation
+ (ps-lpr-command spooler)
+ (ps-printer-name nil) ;; nil => system default printer
+ (ps-lpr-switches nil)
+ ;; Force monochrome and ignore face backgrounds for this job
+ (ps-print-color-p nil)
+ (ps-use-face-background nil)
+ ;; Ensure no headers
+ (ps-print-header nil)
+ (ps-header-lines 0)
+ (ps-left-header nil)
+ (ps-right-header nil))
+ (ps-print-buffer-with-faces)
+ (message "Sent print job via %s to default printer (no header)" spooler))))
+
+;; Buffer & file operations prefix and keymap
+(define-prefix-command 'cj/buffer-and-file-map nil
+ "Keymap for buffer-and-file operations.")
+(define-key cj/custom-keymap "b" 'cj/buffer-and-file-map)
+(define-key cj/buffer-and-file-map "m" 'cj/move-buffer-and-file)
+(define-key cj/buffer-and-file-map "r" 'cj/rename-buffer-and-file)
+(define-key cj/buffer-and-file-map "p" 'cj/print-buffer-ps)
+(define-key cj/buffer-and-file-map "d" 'cj/delete-buffer-and-file)
+(define-key cj/buffer-and-file-map "c" 'cj/copy-whole-buffer)
+(define-key cj/buffer-and-file-map "t" 'cj/clear-to-top-of-buffer)
+(define-key cj/buffer-and-file-map "b" 'cj/clear-to-bottom-of-buffer)
+(define-key cj/buffer-and-file-map "x" 'erase-buffer)
+(define-key cj/buffer-and-file-map "s" 'write-file) ;; save as :)
+
+(define-key cj/buffer-and-file-map "l" 'cj/copy-link-to-buffer-file)
+(define-key cj/buffer-and-file-map "P" 'cj/copy-path-to-buffer-file-as-kill)
+
+;;; ---------------------- Whitespace Operations And Keymap ---------------------
+
+(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.
+- Otherwise, operate on the current line."
+ (interactive)
+ (let ((start (cond (current-prefix-arg (point-min))
+ ((use-region-p) (region-beginning))
+ (t (line-beginning-position))))
+ (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 ""))))))
+
+(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."
+ (interactive)
+ (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))))))
+
+(defun cj/delete-blank-lines-region-or-buffer (start end)
+ "Delete blank lines between START and END.
+
+Treat blank lines as lines that contain nothing or only whitespace.
+Operate on the active region when one exists.
+Prompt before operating on the whole buffer when no region is selected.
+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)))
+ ;; Return nil (Emacs conventions). Point is already restored.
+ nil)
+
+(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.")))
+
+
+;; Whitespace operations prefix and keymap
+(define-prefix-command 'cj/whitespace-map nil
+ "Keymap for whitespace operations.")
+(define-key cj/custom-keymap "w" 'cj/whitespace-map)
+(define-key cj/whitespace-map "r" 'cj/remove-leading-trailing-whitespace)
+(define-key cj/whitespace-map "c" 'cj/collapse-whitespace-line-or-region)
+(define-key cj/whitespace-map "l" 'cj/delete-blank-lines-region-or-buffer)
+(define-key cj/whitespace-map "-" 'cj/hyphenate-whitespace-in-region)
+
+;;; ------------------------- Surround, Append, Prepend -------------------------
+
+(defun cj/surround-word-or-region ()
+ "Surround the word at point or active region with a string 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."))))))
+
+(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)))))
+
+(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
+(define-prefix-command 'cj/surround-map nil
+ "Keymap for surrounding, appending, and prepending operations.")
+(define-key cj/custom-keymap "s" 'cj/surround-map)
+(define-key cj/surround-map "s" 'cj/surround-word-or-region)
+(define-key cj/surround-map "a" 'cj/append-to-lines-in-region-or-buffer)
+(define-key cj/surround-map "p" 'cj/prepend-to-lines-in-region-or-buffer)
+
+;;; -------------------------- Date And Time Insertion --------------------------
+
+(defvar readable-date-time-format "%A, %B %d, %Y at %I:%M:%S %p %Z "
+ "Format string used by `cj/insert-readable-date-time'.
+
+See `format-time-string' for possible replacements.")
+
+(defun cj/insert-readable-date-time ()
+ "Insert the current date and time into the current buffer.
+
+Use `readable-date-time-format' for formatting."
+ (interactive)
+ (insert (format-time-string readable-date-time-format (current-time))))
+
+(defvar sortable-date-time-format "%Y-%m-%d %a @ %H:%M:%S %z "
+ "Format string used by `cj/insert-sortable-date-time'.
+
+See `format-time-string' for possible replacements.")
+
+(defun cj/insert-sortable-date-time ()
+ "Insert the current date and time into the current buffer.
+
+Use `sortable-date-time-format' for formatting."
+ (interactive)
+ (insert (format-time-string sortable-date-time-format (current-time))))
+
+(defvar sortable-time-format "%I:%M:%S %p %Z "
+ "Format string used by `cj/insert-sortable-time'.
+
+See `format-time-string' for possible replacements.")
+
+(defun cj/insert-sortable-time ()
+ "Insert the current time into the current buffer.
+
+Use `sortable-time-format' for formatting."
+ (interactive)
+ (insert (format-time-string sortable-time-format (current-time))))
+
+(defvar readable-time-format "%-I:%M %p "
+ "Format string used by `cj/insert-readable-time'.
+
+See `format-time-string' for possible replacements.")
+
+(defun cj/insert-readable-time ()
+ "Insert the current time into the current buffer.
+
+Use `readable-time-format' for formatting."
+ (interactive)
+ (insert (format-time-string readable-time-format (current-time))))
+
+(defvar sortable-date-format "%Y-%m-%d %a"
+ "Format string used by `cj/insert-sortable-date'.
+
+See `format-time-string' for possible replacements.")
+
+(defun cj/insert-sortable-date ()
+ "Insert the current date into the current buffer.
+
+Use `sortable-date-format' for formatting."
+ (interactive)
+ (insert (format-time-string sortable-date-format (current-time))))
+
+(defvar readable-date-format "%A, %B %d, %Y"
+ "Format string used by `cj/insert-readable-date'.
+
+See `format-time-string' for possible replacements.")
+
+(defun cj/insert-readable-date ()
+ "Insert the current date into the current buffer.
+
+Use `readable-date-format' for formatting."
+ (interactive)
+ (insert (format-time-string readable-date-format (current-time))))
+
+;; Date/time insertion prefix and keymap
+(define-prefix-command 'cj/datetime-map nil
+ "Keymap for inserting various date/time formats.")
+(define-key cj/custom-keymap "d" 'cj/datetime-map)
+(define-key cj/datetime-map "r" 'cj/insert-readable-date-time)
+(define-key cj/datetime-map "s" 'cj/insert-sortable-date-time)
+(define-key cj/datetime-map "t" 'cj/insert-sortable-time)
+(define-key cj/datetime-map "T" 'cj/insert-readable-time)
+(define-key cj/datetime-map "d" 'cj/insert-sortable-date)
+(define-key cj/datetime-map "D" 'cj/insert-readable-date)
+
+;;; ----------------------- Line And Paragraph Operations -----------------------
+
+
+(defun cj/join-line-or-region ()
+ "Join lines in the active region or join the current line with the previous one."
+ (interactive)
+ (if (use-region-p)
+ (let ((beg (region-beginning))
+ (end (copy-marker (region-end))))
+ (goto-char beg)
+ (while (< (point) end)
+ (join-line 1))
+ (goto-char end)
+ (newline))
+ ;; No region - only join if there's a previous line
+ (when (> (line-number-at-pos) 1)
+ (join-line))
+ (newline)))
+
+(defun cj/join-paragraph ()
+ "Join all lines in the current paragraph using `cj/join-line-or-region'."
+ (interactive)
+ (er/mark-paragraph) ;; from package expand region
+ (cj/join-line-or-region (region-beginning)(region-end))
+ (forward-line))
+
+(defun cj/duplicate-line-or-region (&optional comment)
+ "Duplicate the current line or active region below.
+
+Comment the duplicated text when optional COMMENT is non-nil."
+ (interactive "P")
+ (let* ((b (if (region-active-p) (region-beginning) (line-beginning-position)))
+ (e (if (region-active-p) (region-end) (line-end-position)))
+ (lines (split-string (buffer-substring-no-properties b e) "\n")))
+ (save-excursion
+ (goto-char e)
+ (dolist (line lines)
+ (open-line 1)
+ (forward-line 1)
+ (insert line)
+ ;; If the COMMENT prefix argument is non-nil, comment the inserted text
+ (when comment
+ (comment-region (line-beginning-position) (line-end-position)))))))
+
+(defun cj/remove-duplicate-lines-region-or-buffer ()
+ "Remove duplicate lines in the region or buffer, keeping the first occurrence.
+
+Operate on the active region when one exists; otherwise operate on the whole buffer."
+ (interactive)
+ (let ((start (if (use-region-p) (region-beginning) (point-min)))
+ (end (if (use-region-p) (region-end) (point-max))))
+ (save-excursion
+ (let ((end-marker (copy-marker end)))
+ (while
+ (progn
+ (goto-char start)
+ (re-search-forward "^\\(.*\\)\n\\(\\(.*\n\\)*\\)\\1\n" end-marker t))
+ (replace-match "\\1\n\\2"))))))
+
+
+(defun cj/remove-lines-containing (text)
+ "Remove all lines containing TEXT.
+
+If region is active, operate only on the region, otherwise on entire buffer.
+The operation is undoable."
+ (interactive "sRemove lines containing: ")
+ (save-excursion
+ (save-restriction
+ (let ((region-active (use-region-p))
+ (count 0))
+ (when region-active
+ (narrow-to-region (region-beginning) (region-end)))
+ (goto-char (point-min))
+ ;; Count lines before deletion
+ (while (re-search-forward (regexp-quote text) nil t)
+ (setq count (1+ count))
+ (beginning-of-line)
+ (forward-line))
+ ;; Go back and delete
+ (goto-char (point-min))
+ (delete-matching-lines (regexp-quote text))
+ ;; Report what was done
+ (message "Removed %d line%s containing '%s' from %s"
+ count
+ (if (= count 1) "" "s")
+ text
+ (if region-active "region" "buffer"))))))
+
+(defun cj/underscore-line ()
+ "Underline the current line by inserting a row of characters below it.
+
+If the line is empty or contains only whitespace, abort with a message."
+ (interactive)
+ (let ((line (buffer-substring-no-properties
+ (line-beginning-position)
+ (line-end-position))))
+ (if (string-match-p "^[[:space:]]*$" line)
+ (message "Line empty or only whitespace. Aborting.")
+ (let* ((char (read-char "Enter character for underlining: "))
+ (len (save-excursion
+ (goto-char (line-end-position))
+ (current-column))))
+ (save-excursion
+ (end-of-line)
+ (insert "\n" (make-string len char)))))))
+
+
+;; Line & paragraph operations prefix and keymap
+(define-prefix-command 'cj/line-and-paragraph-map nil
+ "Keymap for line and paragraph manipulation.")
+(define-key cj/custom-keymap "l" 'cj/line-and-paragraph-map)
+(define-key cj/line-and-paragraph-map "j" 'cj/join-line-or-region)
+(define-key cj/line-and-paragraph-map "J" 'cj/join-paragraph)
+(define-key cj/line-and-paragraph-map "d" 'cj/duplicate-line-or-region)
+(define-key cj/line-and-paragraph-map "R" 'cj/remove-duplicate-lines-region-or-buffer)
+(define-key cj/line-and-paragraph-map "r" 'cj/remove-lines-containing)
+(define-key cj/line-and-paragraph-map "u" 'cj/underscore-line)
+
+;;; ---------------------------------- Comments ---------------------------------
+
+(defun cj/comment-reformat ()
+ "Reformat commented text into a single paragraph."
+ (interactive)
+
+ (if mark-active
+ (let ((beg (region-beginning))
+ (end (copy-marker (region-end)))
+ (orig-fill-column fill-column))
+ (uncomment-region beg end)
+ (setq fill-column (- fill-column 3))
+ (cj/join-line-or-region beg end)
+ (comment-region beg end)
+ (setq fill-column orig-fill-column )))
+ ;; if no region
+ (message "No region was selected. Select the comment lines to reformat."))
+
+(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 the line with the appropriate comment symbols for the current mode."
+ (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))))))
+
+(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."
+ (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)))))
+
+(defun cj/comment-hyphen()
+ "Insert a centered comment with '-' (hyphens) on each side."
+ (interactive)
+ (cj/comment-centered "-"))
+
+(defun cj/delete-buffer-comments ()
+ "Delete all comments within the current buffer."
+ (interactive)
+ (goto-char (point-min))
+ (let (kill-ring)
+ (comment-kill (count-lines (point-min) (point-max)))))
+
+;; Comment styles & removal prefix and keymap
+(define-prefix-command 'cj/comment-map nil
+ "Keymap for comment styling and removal.")
+(define-key cj/custom-keymap "C" 'cj/comment-map)
+(define-key cj/comment-map "r" 'cj/comment-reformat)
+(define-key cj/comment-map "c" 'cj/comment-centered)
+(define-key cj/comment-map "-" 'cj/comment-hyphen)
+(define-key cj/comment-map "b" 'cj/comment-box)
+(define-key cj/comment-map "D" 'cj/delete-buffer-comments)
+
+;;; ---------------------- Ordering And Sorting Operations ----------------------
+
+(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)) ", ")))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/unarrayify (start end)
+ "Convert quoted, comma-separated strings between START and END into 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")))
+ (delete-region start end)
+ (insert insertion)))
+
+(defun cj/alphabetize-region ()
+ "Alphabetize words in the active region and replace the original text.
+
+Produce a comma-separated list as the result."
+ (interactive)
+ (unless (use-region-p)
+ (user-error "No region selected"))
+ (let ((start (region-beginning))
+ (end (region-end))
+ (string (buffer-substring-no-properties (region-beginning) (region-end))))
+ (delete-region start end)
+ (goto-char start)
+ (insert
+ (mapconcat #'identity
+ (sort (split-string string "[[:space:],]+" t)
+ #'string-lessp)
+ ", "))))
+
+(defun cj/comma-separated-text-to-lines ()
+ "Break up comma-separated text in the active region so each item is on its own line."
+ (interactive)
+ (if (not (region-active-p))
+ (error "No region selected"))
+
+ (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)))
+
+ (delete-region beg end)
+ (goto-char beg)
+ (insert text)))
+
+
+;; Ordering & sorting prefix and keymap
+(define-prefix-command 'cj/ordering-map nil
+ "Keymap for text ordering and sorting operations.")
+(define-key cj/custom-keymap "o" 'cj/ordering-map)
+(define-key cj/ordering-map "a" 'cj/arrayify)
+(define-key cj/ordering-map "u" 'cj/unarrayify)
+(define-key cj/ordering-map "A" 'cj/alphabetize-region)
+(define-key cj/ordering-map "l" 'cj/comma-separated-text-to-lines)
+
+;;; --------------------------- Case Change Operations --------------------------
+
+(defun cj/title-case-region ()
+ "Capitalize the region in title case format.
+
+Title case is a capitalization convention where major words
+are capitalized,and most minor words are lowercase. Nouns,
+verbs (including linking verbs), adjectives, adverbs,pronouns,
+and all words of four letters or more are considered major words.
+Short (i.e., three letters or fewer) conjunctions, short prepositions,
+and all articles are considered minor words."
+ (interactive)
+ (let ((beg nil)
+ (end nil)
+ (prev-word-end nil)
+ ;; Allow capitals for skip characters after this, so:
+ ;; Warning: An Example
+ ;; Capitalizes the `An'.
+ (chars-skip-reset '(?: ?! ??))
+ ;; Don't capitalize characters directly after these. e.g.
+ ;; "Foo-bar" or "Foo\bar" or "Foo's".
+
+ (chars-separator '(?\\ ?- ?' ?.))
+
+ (word-chars "[:alnum:]")
+ (word-skip
+ (list "a" "an" "and" "as" "at" "but" "by"
+ "for" "if" "in" "is" "nor" "of"
+ "on" "or" "so" "the" "to" "yet"))
+ (is-first t))
+ (cond
+ ((region-active-p)
+ (setq beg (region-beginning))
+ (setq end (region-end)))
+ (t
+ (setq beg (line-beginning-position))
+ (setq end (line-end-position))))
+ (save-excursion
+ ;; work on uppercased text (e.g., headlines) by downcasing first
+ (downcase-region beg end)
+ (goto-char beg)
+
+ (while (< (point) end)
+ (setq prev-word-end (point))
+ (skip-chars-forward (concat "^" word-chars) end)
+ (let ((word-end
+ (save-excursion
+ (skip-chars-forward word-chars end)
+ (point))))
+
+ (unless (memq (char-before (point)) chars-separator)
+ (let* ((c-orig (char-to-string (char-after (point))))
+ (c-up (capitalize c-orig)))
+ (unless (string-equal c-orig c-up)
+ (let ((word (buffer-substring-no-properties (point) word-end)))
+ (when
+ (or
+ ;; Always allow capitalization.
+ is-first
+ ;; If it's not a skip word, allow.
+ (not (member word word-skip))
+ ;; Check the beginning of the previous word doesn't reset first.
+ (save-excursion
+ (and
+ (not (zerop
+ (skip-chars-backward "[:blank:]" prev-word-end)))
+ (memq (char-before (point)) chars-skip-reset))))
+ (delete-region (point) (1+ (point)))
+ (insert c-up))))))
+ (goto-char word-end)
+ (setq is-first nil))))))
+
+;; replace the capitalize-region keybinding to call title-case
+(global-set-key [remap capitalize-region] 'cj/title-case-region)
+
+(defun cj/upcase-dwim ()
+ "Upcase the active region, or upcase the symbol at point if no region."
+ (interactive)
+ (if (use-region-p)
+ (upcase-region (region-beginning) (region-end))
+ (let ((bounds (bounds-of-thing-at-point 'symbol)))
+ (if bounds
+ (upcase-region (car bounds) (cdr bounds))
+ (user-error "No symbol at point")))))
+
+(defun cj/downcase-dwim ()
+ "Downcase the active region, or downcase the symbol at point if no region."
+ (interactive)
+ (if (use-region-p)
+ (downcase-region (region-beginning) (region-end))
+ (let ((bounds (bounds-of-thing-at-point 'symbol)))
+ (if bounds
+ (downcase-region (car bounds) (cdr bounds))
+ (user-error "No symbol at point")))))
+
+;; Case-change operations prefix and keymap
+(define-prefix-command 'cj/case-map nil
+ "Keymap for case-change operations.")
+(define-key cj/custom-keymap "c" 'cj/case-map)
+(define-key cj/case-map "t" 'cj/title-case-region)
+(define-key cj/case-map "u" 'cj/upcase-dwim)
+(define-key cj/case-map "l" 'cj/downcase-dwim) ;; for "lower" case
+
+(provide 'custom-functions)
+;;; custom-functions.el ends here.
diff --git a/modules/dashboard-config.el b/modules/dashboard-config.el
new file mode 100644
index 00000000..73a76b6b
--- /dev/null
+++ b/modules/dashboard-config.el
@@ -0,0 +1,144 @@
+;;; dashboard-config.el --- Dashboard Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; Note:
+;; Nerd-Icons Cheat Sheet: https://www.nerdfonts.com/cheat-sheet
+
+;;; Code:
+
+(require 'undead-buffers)
+
+;; ------------------------ Dashboard Bookmarks Override -----------------------
+;; overrides the bookmark insertion from the dashboard package to provide an
+;; option that only shows the bookmark name, avoiding the path. Paths are often
+;; too long and the truncation options aren't aesthetically pleasing. Should be
+;; accompanied by the setting (setq dashboard-bookmarks-show-path nil) in
+;; config.
+
+(defcustom dashboard-bookmarks-item-format "%s"
+ "Format to use when showing the base of the file name."
+ :type 'string
+ :group 'dashboard)
+
+(defun dashboard-insert-bookmarks (list-size)
+ "Add the list of LIST-SIZE items of bookmarks."
+ (require 'bookmark)
+ (dashboard-insert-section
+ "Bookmarks:"
+ (dashboard-subseq (bookmark-all-names) list-size)
+ list-size
+ 'bookmarks
+ (dashboard-get-shortcut 'bookmarks)
+ `(lambda (&rest _) (bookmark-jump ,el))
+ (if-let* ((filename el)
+ (path (bookmark-get-filename el))
+ (path-shorten (dashboard-shorten-path path 'bookmarks)))
+ (cl-case dashboard-bookmarks-show-path
+ (`align
+ (unless dashboard--bookmarks-cache-item-format
+ (let* ((len-align (dashboard--align-length-by-type 'bookmarks))
+ (new-fmt (dashboard--generate-align-format
+ dashboard-bookmarks-item-format len-align)))
+ (setq dashboard--bookmarks-cache-item-format new-fmt)))
+ (format dashboard--bookmarks-cache-item-format filename path-shorten))
+ (`nil filename)
+ (t (format dashboard-bookmarks-item-format filename path-shorten)))
+ el)))
+
+;; ----------------------------- Display Dashboard -----------------------------
+;; convenience function to redisplay dashboard and kill all other windows
+
+(defun cj/dashboard-only ()
+ "Switch to *dashboard* buffer and kill all other buffers and windows."
+ (interactive)
+ (dired-sidebar-hide-sidebar)
+ (if (get-buffer "*dashboard*")
+ (progn
+ (switch-to-buffer "*dashboard*")
+ (cj/kill-all-other-buffers-and-windows))
+ (dashboard-open)))
+
+;; --------------------------------- Dashboard ---------------------------------
+;; a useful startup screen for Emacs
+
+(use-package dashboard
+ :defer t
+ :hook (emacs-startup . cj/dashboard-only)
+ :bind ("<f4>" . cj/dashboard-only)
+ :custom
+ (dashboard-projects-backend 'projectile)
+
+ (dashboard-item-generators
+ '((projects . dashboard-insert-projects)
+ (bookmarks . dashboard-insert-bookmarks)))
+
+ (dashboard-items '((projects . 5)
+ (bookmarks . 15)))
+
+ (dashboard-startupify-list
+ '(dashboard-insert-banner
+ dashboard-insert-banner-title
+ dashboard-insert-newline
+ dashboard-insert-newline
+ dashboard-insert-navigator
+ dashboard-insert-init-info
+ dashboard-insert-newline
+ dashboard-insert-newline
+ dashboard-insert-items
+ dashboard-insert-newline))
+ :config
+
+ ;; == general
+ (dashboard-setup-startup-hook) ;; run dashboard post emacs init
+
+ (if (< (length command-line-args) 2)
+ (setq initial-buffer-choice (lambda () (get-buffer "*dashboard*")))) ;; don't display dashboard if opening a file
+ (setq dashboard-display-icons-p t) ;; display icons on both GUI and terminal
+ (setq dashboard-icon-type 'nerd-icons) ;; use `nerd-icons' package
+ (setq dashboard-center-content t) ;; horizontally center dashboard content
+ (setq dashboard-bookmarks-show-path nil) ;; don't show paths in bookmarks
+ (setq dashboard-set-footer nil) ;; don't show footer and quotes
+
+ ;; == banner
+ (setq dashboard-startup-banner (concat user-emacs-directory "assets/M-x_butterfly.png"))
+ (setq dashboard-banner-logo-title "Emacs: The Editor That Saves Your Soul")
+
+ ;; == navigation
+ (setq dashboard-set-navigator t)
+ (setq dashboard-navigator-buttons
+ `(((,(nerd-icons-faicon "nf-fa-envelope")
+ "Email" "Mu4e Email Client"
+ (lambda (&rest _) (mu4e)))
+
+ (,(nerd-icons-faicon "nf-fae-book_open_o")
+ "Ebooks" "Calibre Ebook Reader"
+ (lambda (&rest _) (calibredb)))
+
+ (,(nerd-icons-mdicon "nf-md-school")
+ "Flashcards" "Org-Drill"
+ (lambda (&rest _) (cj/drill-start)))
+
+ (,(nerd-icons-faicon "nf-fa-rss_square")
+ "Feeds" "Elfeed Feed Reader"
+ (lambda (&rest _) (cj/elfeed-open)))
+
+ (,(nerd-icons-faicon "nf-fa-comments")
+ "IRC" "Emacs Relay Chat"
+ (lambda (&rest _) (cj/erc-start-or-switch)))
+
+ ;; (,(nerd-icons-faicon "nf-fae-telegram")
+ ;; "Telegram" "Telega Chat Client"
+ ;; (lambda (&rest _) (telega)))
+
+ (,(nerd-icons-faicon "nf-fa-folder_o")
+ "Files" "Dirvish File Manager"
+ (lambda (&rest _) (dirvish user-home-dir))))))
+
+ ;; == content
+ (setq dashboard-show-shortcuts nil) ;; don't show dashboard item abbreviations
+ ) ;; end use-package dashboard
+
+(provide 'dashboard-config)
+;;; dashboard-config.el ends here.
diff --git a/modules/diff-config.el b/modules/diff-config.el
new file mode 100644
index 00000000..ff106ead
--- /dev/null
+++ b/modules/diff-config.el
@@ -0,0 +1,53 @@
+;;; diff-config.el --- diff Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; I've configured Ediff for a clean and efficient diff experience.
+
+;; • Ediff will use a plain control window, horizontal splits, ignore whitespace, and only highlight the current change.
+;; • A single keymap under "C-c D" has bindings:
+;; - ediff-files (f)
+;; - ediff-buffers (b)
+;; - ediff-revision (r)
+;; - ediff-directories (D)
+;; • An Ediff hook that remaps j/k to next/previous differences for easier navigation
+;; • The winner-mode functionality ensures window layouts are restored after quitting Ediff
+
+;; Note: Here's a highly useful setup for configuring ediff.
+;; https://oremacs.com/2015/01/17/setting-up-ediff/
+
+;;; Code:
+
+(use-package ediff
+ :ensure nil ;; built-in
+ :defer t
+ :custom
+ (ediff-window-setup-function 'ediff-setup-windows-plain)
+ (ediff-split-window-function 'split-window-horizontally)
+ (ediff-diff-options "-w")
+ (ediff-highlight-all-diffs nil)
+ :bind-keymap ("C-c D" . cj/ediff-map)
+ :init
+ ;; adding this to a hook to make sure ediff is loaded due to :defer
+ (defvar cj/ediff-map
+ (let ((m (make-sparse-keymap)))
+ (define-key m "f" #'ediff-files) ; C-c D f
+ (define-key m "b" #'ediff-buffers) ; C-c D b
+ (define-key m "r" #'ediff-revision) ; C-c D r
+ (define-key m "D" #'ediff-directories) ; C-c D D
+ m)
+ "Prefix map for quick Ediff commands under C-c D.")
+ :config
+ (defun cj/ediff-hook ()
+ "Use j/k to navigate differences in Ediff."
+ (ediff-setup-keymap) ;; keep the defaults…
+ (define-key ediff-mode-map "j" #'ediff-next-difference)
+ (define-key ediff-mode-map "k" #'ediff-previous-difference))
+
+ (add-hook 'ediff-mode-hook #'cj/ediff-hook)
+ (add-hook 'ediff-after-quit-hook-internal #'winner-undo))
+
+
+(provide 'diff-config)
+;;; diff-config.el ends here
diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el
new file mode 100644
index 00000000..82b44008
--- /dev/null
+++ b/modules/dirvish-config.el
@@ -0,0 +1,403 @@
+;;; dirvish-config.el --- Dired/Dirvish Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; DIRVISH NOTES:
+;; access the quick access directories by pressing 'g' (for "go")
+
+;;; Code:
+
+(require 'user-constants)
+(require 'system-utils)
+
+;;; ----------------------------- Dired Ediff Files -----------------------------
+
+(defun cj/dired-ediff-files ()
+ "Ediff two selected files within Dired."
+ (interactive)
+ (let ((files (dired-get-marked-files))
+ (wnd (current-window-configuration)))
+ (if (<= (length files) 2)
+ (let ((file1 (car files))
+ (file2 (if (cdr files)
+ (cadr files)
+ (read-file-name
+ "file: "
+ (dired-dwim-target-directory)))))
+ (if (file-newer-than-file-p file1 file2)
+ (ediff-files file2 file1)
+ (ediff-files file1 file2))
+ (add-hook 'ediff-after-quit-hook-internal
+ (lambda ()
+ (setq ediff-after-quit-hook-internal nil)
+ (set-window-configuration wnd))))
+ (error "No more than 2 files should be marked"))))
+
+;; ------------------------ Create Playlist From Marked ------------------------
+
+(defvar cj/audio-file-extensions
+ '("mp3" "flac" "m4a" "wav" "ogg" "aac" "opus" "aiff" "alac" "wma")
+ "List of audio file extensions (lowercase, no dot).
+Used to filter files for M3U playlists.")
+
+(defun cj/dired-create-playlist-from-marked ()
+ "Create an .m3u playlist file from marked files in Dired (or Dirvish).
+
+Filters for audio files, prompts for the playlist name, and saves the resulting
+.m3u in the directory specified by =music-dir=. Interactive use only."
+ (interactive)
+ (let* ((marked-files (dired-get-marked-files))
+ (audio-files
+ (cl-remove-if-not
+ (lambda (f)
+ (let ((ext (file-name-extension f)))
+ (and ext
+ (member (downcase ext) cj/audio-file-extensions))))
+ marked-files))
+ (count (length audio-files)))
+ (if (zerop count)
+ (user-error "No audio files marked (extensions: %s)"
+ (string-join cj/audio-file-extensions ", "))
+ (let ((base-name nil)
+ (playlist-path nil)
+ (done nil))
+ (while (not done)
+ (setq base-name (read-string
+ (format "Playlist name (without .m3u): ")))
+ ;; Sanitize: strip any trailing .m3u
+ (setq base-name (replace-regexp-in-string "\\.m3u\\'" "" base-name))
+ (setq playlist-path (expand-file-name (concat base-name ".m3u") music-dir))
+ (cond
+ ((not (file-exists-p playlist-path))
+ ;; Safe to write
+ (setq done t))
+ (t
+ (let ((choice (read-char-choice
+ (format "Playlist '%s' exists. [o]verwrite, [c]ancel, [r]ename? "
+ (file-name-nondirectory playlist-path))
+ '(?o ?c ?r))))
+ (cl-case choice
+ (?o (setq done t))
+ (?c (user-error "Cancelled playlist creation"))
+ (?r (setq done nil)))))))
+ ;; Actually write the file
+ (with-temp-file playlist-path
+ (dolist (af audio-files)
+ (insert af "\n")))
+ (message "Wrote playlist %s with %d tracks" (file-name-nondirectory playlist-path) count)))))
+
+;;; ----------------------------------- Dired -----------------------------------
+
+(use-package dired
+ :ensure nil ;; built-in
+ :defer t
+ :bind
+ (:map dired-mode-map
+ ([remap dired-summary] . which-key-show-major-mode)
+ ("E" . wdired-change-to-wdired-mode) ;; edit names and properties in buffer
+ ("e" . cj/dired-ediff-files)) ;; ediff files
+ :custom
+ (dired-use-ls-dired nil) ;; non GNU FreeBSD doesn't support a "--dired" switch
+ :config
+ (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
+
+;; 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
+
+(add-hook 'dired-mode-hook 'auto-revert-mode) ;; auto revert dired when files change
+
+;;; --------------------------- Dired Open HTML In EWW --------------------------
+
+(defun cj/dirvish-open-html-in-eww ()
+ "Open HTML file at point in dired/dirvish using eww."
+ (interactive)
+ (let ((file (dired-get-file-for-visit)))
+ (if (string-match-p "\\.html?\\'" file)
+ (eww-open-file file)
+ (message "Not an HTML file: %s" file))))
+
+;;; ------------------------ Dired Mark All Visible Files -----------------------
+
+(defun cj/dired-mark-all-visible-files ()
+ "Mark all visible files in Dired mode."
+ (interactive)
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (if (not (looking-at "^. d"))
+ (dired-mark 1))
+ (forward-line 1))))
+
+;;; ----------------------- Dirvish Open File Manager Here ----------------------
+
+(defun cj/dirvish-open-file-manager-here ()
+ "Open system's default file manager in the current dired/dirvish directory.
+
+Always opens the file manager in the directory currently being displayed,
+regardless of what file or subdirectory the point is on."
+ (interactive)
+ (let ((current-dir (dired-current-directory)))
+ (if (and current-dir (file-exists-p current-dir))
+ (progn
+ (message "Opening file manager in %s..." current-dir)
+ ;; Use shell-command with & to run asynchronously and detached
+ (let ((process-connection-type nil)) ; Use pipe instead of pty
+ (cond
+ ;; Linux/Unix with xdg-open
+ ((executable-find "xdg-open")
+ (call-process "xdg-open" nil 0 nil current-dir))
+ ;; macOS
+ ((eq system-type 'darwin)
+ (call-process "open" nil 0 nil current-dir))
+ ;; Windows
+ ((eq system-type 'windows-nt)
+ (call-process "explorer" nil 0 nil current-dir))
+ ;; Fallback to shell-command
+ (t
+ (shell-command (format "xdg-open %s &"
+ (shell-quote-argument current-dir)))))))
+ (message "Could not determine current directory."))))
+
+;;; ---------------------------------- Dirvish ----------------------------------
+
+(use-package dirvish
+ :defer 1
+ :init
+ (dirvish-override-dired-mode)
+ :custom
+ ;; This MUST be in :custom section, not :config
+ (dirvish-quick-access-entries
+ `(("h" "~/" "home")
+ ("cx" ,code-dir "code directory")
+ ("ex" ,user-emacs-directory "emacs home")
+ ("es" ,sounds-dir "notification sounds")
+ ("ra" ,video-recordings-dir "video recordings")
+ ("rv" ,audio-recordings-dir "audio recordings")
+ ("dl" ,dl-dir "downloads")
+ ("dr" ,(concat sync-dir "/drill/") "drill files")
+ ("dt" ,(concat dl-dir "/torrents/complete/") "torrents")
+ ("dx" "~/documents/" "documents")
+ ("lx" "~/lectures/" "lectures")
+ ("mb" "/media/backup/" "backup directory")
+ ("mx" "~/music/" "music")
+ ("pD" "~/projects/documents/" "project documents")
+ ("pd" "~/projects/danneel/" "project danneel")
+ ("pl" "~/projects/elibrary/" "project elibrary")
+ ("pf" "~/projects/finances/" "project finances")
+ ("pjr" "~/projects/jr-estate/" "project jr-estate")
+ ("ps" ,(concat pix-dir "/screenshots/") "pictures screenshots")
+ ("pw" ,(concat pix-dir "/wallpaper/") "pictures wallpaper")
+ ("px" ,pix-dir "pictures directory")
+ ("rcj" "/sshx:cjennings@cjennings.net:~" "remote cjennings.net")
+ ("rsb" "/sshx:cjennings@wolf.usbx.me:/home/cjennings/" "remote seedbox")
+ ("sx" ,sync-dir "sync directory")
+ ("so" "~/sync/org" "org directory")
+ ("sv" "~/sync/videos/" "sync/videos directory")
+ ("tg" ,(concat sync-dir "/text.games") "text games")
+ ("vr" ,video-recordings-dir "video recordings directory")
+ ("vx" ,videos-dir "videos")))
+ :config
+ ;; Add the extensions directory to load-path
+ (let ((extensions-dir (expand-file-name "extensions"
+ (file-name-directory (locate-library "dirvish")))))
+ (when (file-directory-p extensions-dir)
+ (add-to-list 'load-path extensions-dir)))
+
+ ;; Load dirvish modules with error checking
+ (let ((dirvish-modules '(dirvish-emerge
+ dirvish-subtree
+ dirvish-narrow
+ dirvish-history
+ dirvish-ls
+ dirvish-yank
+ dirvish-quick-access
+ dirvish-collapse
+ dirvish-rsync
+ dirvish-vc
+ dirvish-icons
+ dirvish-side
+ dirvish-peek)))
+ (dolist (module dirvish-modules)
+ (condition-case err
+ (require module)
+ (error
+ (message "Failed to load %s: %s" module (error-message-string err))))))
+
+ ;; Enable peek mode with error checking
+ (condition-case err
+ (dirvish-peek-mode 1)
+ (error (message "Failed to enable dirvish-peek-mode: %s" (error-message-string err))))
+
+ ;; Enable side-follow mode with error checking
+ (condition-case err
+ (dirvish-side-follow-mode 1)
+ (error (message "Failed to enable dirvish-side-follow-mode: %s"
+ (error-message-string err))))
+
+ ;; Your other configuration settings
+ (setq dirvish-attributes '(nerd-icons file-size))
+ (setq dirvish-preview-dispatchers '(image gif video audio epub pdf archive))
+ (setq dirvish-use-mode-line nil)
+ (setq dirvish-use-header-line nil)
+ :bind
+ (("C-x d" . dirvish)
+ ("C-x C-d" . dirvish)
+ ("C-x D" . dirvish)
+ ("<f11>" . dirvish-side)
+ :map dirvish-mode-map
+ ("bg" . (lambda () (interactive)
+ (shell-command
+ (concat "nitrogen --save --set-zoom-fill "
+ (dired-file-name-at-point) " >>/dev/null 2>&1"))))
+ ("/" . dirvish-narrow)
+ ("<left>" . dired-up-directory)
+ ("<right>" . dired-find-file)
+ ("C-," . dirvish-history-go-backward)
+ ("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
+ ("h" . cj/dirvish-open-html-in-eww) ;; it does what it says it does
+ ("M" . cj/dired-mark-all-visible-files)
+ ("M-e" . dirvish-emerge-menu)
+ ("M-l" . dirvish-ls-switches-menu)
+ ("M-m" . dirvish-mark-menu)
+ ("M-p" . dirvish-peek-toggle)
+ ("M-s" . dirvish-setup-menu)
+ ("TAB" . dirvish-subtree-toggle)
+ ("d". dired-flag-file-deletion)
+ ("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
+ ("r" . dirvish-rsync)
+ ("p" . cj/dired-create-playlist-from-marked)
+ ("s" . dirvish-quicksort)
+ ("v" . dirvish-vc-menu)
+ ("y" . dirvish-yank-menu)))
+
+;;; -------------------------------- Nerd Icons -------------------------------
+
+(use-package nerd-icons
+ :defer .5)
+
+(use-package nerd-icons-dired
+ :commands (nerd-icons-dired-mode))
+
+;;; ---------------------------- Dired Hide Dotfiles ----------------------------
+
+(use-package dired-hide-dotfiles
+ :after dired
+ :hook
+ ;; Auto-hide dotfiles when entering dired/dirvish
+ ((dired-mode . dired-hide-dotfiles-mode)
+ (dirvish-mode . dired-hide-dotfiles-mode))
+ :bind
+ (:map dired-mode-map
+ ("." . dired-hide-dotfiles-mode)))
+
+;;; ------------------------------- Dired Sidebar -------------------------------
+
+(use-package dired-sidebar
+ :after (dired projectile)
+ :bind (("<f11>" . dired-sidebar-toggle-sidebar))
+ :commands (dired-sidebar-toggle-sidebar)
+ :init
+ (add-hook 'dired-sidebar-mode-hook
+ (lambda ()
+ (unless (file-remote-p default-directory)
+ (auto-revert-mode))))
+ :config
+ (push 'toggle-window-split dired-sidebar-toggle-hidden-commands) ;; disallow splitting dired window when it's showing
+ (push 'rotate-windows dired-sidebar-toggle-hidden-commands) ;; disallow rotating windows when sidebar is showing
+ (setq dired-sidebar-subtree-line-prefix " ") ;; two spaces give simple and aesthetic indentation
+ (setq dired-sidebar-no-delete-other-windows t) ;; don't close when calling 'delete other windows'
+ (setq dired-sidebar-theme 'nerd-icons) ;; gimme fancy icons, please
+ (setq dired-sidebar-use-custom-font 'nil) ;; keep the same font as the rest of Emacs
+ (setq dired-sidebar-delay-auto-revert-updates 'nil) ;; don't delay auto-reverting
+ (setq dired-sidebar-pop-to-sidebar-on-toggle-open 'nil)) ;; don't jump to sidebar when it's toggled on
+
+;; --------------------------------- Copy Path ---------------------------------
+
+(defun cj/dired-copy-path-as-kill (&optional as-org-link)
+ "Copy path of file at point in Dired/Dirvish.
+Copies relative path from project root if in a project, otherwise from home
+directory (with ~ prefix) if applicable, otherwise the absolute path.
+
+With prefix arg or when AS-ORG-LINK is non-nil, format as \='org-mode\=' link."
+ (interactive "P")
+ (unless (derived-mode-p 'dired-mode)
+ (user-error "Not in a Dired buffer"))
+
+ (let* ((file (dired-get-filename nil t))
+ (file-name (file-name-nondirectory file))
+ (project-root (cj/get-project-root))
+ (home-dir (expand-file-name "~"))
+ path path-type)
+
+ (unless file
+ (user-error "No file at point"))
+
+ (cond
+ ;; Project-relative path
+ (project-root
+ (setq path (file-relative-name file project-root)
+ path-type "project-relative"))
+
+ ;; Home-relative path
+ ((string-prefix-p home-dir file)
+ (let ((relative-from-home (file-relative-name file home-dir)))
+ (setq path (if (string= relative-from-home ".")
+ "~"
+ (concat "~/" relative-from-home))
+ path-type "home-relative")))
+
+ ;; Absolute path
+ (t
+ (setq path file
+ path-type "absolute")))
+
+ ;; Format as org-link if requested
+ (when as-org-link
+ (setq path (format "[[file:%s][%s]]" path file-name)))
+
+ ;; Copy to kill-ring and clipboard
+ (kill-new path)
+
+ ;; Provide feedback
+ (message "Copied %s path%s: %s"
+ path-type
+ (if as-org-link " as org-link" "")
+ (if (> (length path) 60)
+ (concat (substring path 0 57) "...")
+ path))))
+
+(defun cj/get-project-root ()
+ "Get project root using projectile or project.el.
+Returns nil if not in a project."
+ (cond
+ ;; Try projectile first if available
+ ((and (fboundp 'projectile-project-root)
+ (ignore-errors (projectile-project-root))))
+
+ ;; Fallback to project.el
+ ((and (fboundp 'project-current)
+ (project-current))
+ (let ((proj (project-current)))
+ (if (fboundp 'project-root)
+ (project-root proj)
+ ;; Compatibility with older versions
+ (car (project-roots proj)))))
+
+ ;; No project found
+ (t nil)))
+
+
+(provide 'dirvish-config)
+;;; dirvish-config.el ends here.
diff --git a/modules/dwim-shell-config.el b/modules/dwim-shell-config.el
new file mode 100644
index 00000000..a1ace2be
--- /dev/null
+++ b/modules/dwim-shell-config.el
@@ -0,0 +1,732 @@
+;; dwim-shell-config.el --- Dired Shell Commands -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;;; Commentary:
+;;
+;; This module provides a collection of DWIM (Do What I Mean) shell commands
+;; for common file operations in Dired and other buffers. It leverages the
+;; `dwim-shell-command' package to execute shell commands on marked files
+;; with smart templating and progress tracking.
+;;
+;; Features:
+;; - Audio/Video conversion (mp3, opus, webp, HEVC)
+;; - Image manipulation (resize, flip, format conversion)
+;; - PDF operations (merge, split, password protection, OCR)
+;; - Archive management (zip/unzip)
+;; - Document conversion (epub to org, docx to pdf, pdf to txt)
+1;; - Git operations (clone from clipboard)
+;; - External file opening with context awareness
+;;
+;; Workflow:
+;; 1. *Mark files in Dired/Dirvish*
+;; - Use =m= to mark individual files
+;; - Use =* .= to mark by extension
+;; - Use =% m= to mark by regexp
+;; - Or operate on the file under cursor if nothing is marked
+;;
+;; 2. *Execute a DWIM command*
+;; - Call the command via =M-x dwim-shell-commands-[command-name]=
+;; - Or bind frequently used commands to keys
+;;
+;; 3. *Command execution*
+;; - The command runs asynchronously in the background
+;; - A =*Async Shell Command*= buffer shows progress
+;; - Files are processed with smart templating (replacing =<<f>>=, =<<fne>>=, etc.)
+;;
+;; 4. *Results*
+;; - New files appear in the Dired/Dirvish buffer
+;; - Buffer auto-refreshes when command completes
+;; - Errors appear in the async buffer if something fails
+;;
+;; Requirements:
+;; The commands rely on various external utilities that need to be installed:
+;; - ffmpeg: Audio/video conversion
+;; - imagemagick (convert): Image manipulation
+;; - qpdf: PDF operations
+;; - tesseract: OCR functionality
+;; - pandoc: Document conversion
+;; - atool: Archive extraction
+;; - rsvg-convert: SVG to PNG conversion
+;; - pdftotext: PDF text extraction
+;; - git: Version control operations
+;; - gpgconf: GPG agent management
+;;
+;; On Arch Linux, install the requirements with:
+;; #+begin_src bash
+;; sudo pacman -S --needed ffmpeg imagemagick qpdf tesseract tesseract-data-eng pandoc atool librsvg poppler git gnupg zip unzip mkvtoolnix-cli mpv ruby
+;; #+end_src
+;;
+;; On MacOS, install the requirements with:
+;; #+begin_src bash
+;; brew install ffmpeg imagemagick qpdf tesseract pandoc atool librsvg poppler gnupg mkvtoolnix mpv
+;; #+end_src
+;;
+;; Usage:
+;; Commands operate on marked files in Dired or the current file in other modes.
+;; The package automatically replaces standard shell commands with DWIM versions
+;; for a more intuitive experience.
+;;
+;; Template Variables:
+;; - <<f>>: Full path to file
+;; - <<fne>>: File name without extension
+;; - <<e>>: File extension
+;; - <<td>>: Temporary directory
+;; - <<cb>>: Clipboard contents
+;; - <<*>>: All marked files
+;;
+
+;;; Code:
+
+(require 'system-utils)
+
+;; -------------------------- Dwim Shell Commands Menu -------------------------
+
+(defun dwim-shell-commands-menu ()
+ "Select and execute a dwim-shell-command function with prettified names."
+ (interactive)
+ (let* ((commands (cl-loop for symbol being the symbols
+ when (and (fboundp symbol)
+ (string-prefix-p "cj/dwim-shell-commands-" (symbol-name symbol))
+ (not (eq symbol 'dwim-shell-commands-menu)))
+ collect symbol))
+ ;; Create alist of (pretty-name . command-symbol)
+ (command-alist (mapcar (lambda (cmd)
+ (cons (replace-regexp-in-string
+ "-" " "
+ (replace-regexp-in-string
+ "^cj/dwim-shell-commands-"
+ ""
+ (symbol-name cmd)))
+ cmd))
+ commands))
+ (selected (completing-read "Command: "
+ command-alist
+ nil
+ t
+ nil
+ 'dwim-shell-command-history))
+ (command (alist-get selected command-alist nil nil #'string=)))
+ (when command
+ (call-interactively command))))
+
+(with-eval-after-load 'dired
+ (define-key dired-mode-map (kbd "M-D") #'dwim-shell-commands-menu))
+
+;; ----------------------------- Dwim Shell Command ----------------------------
+
+(use-package dwim-shell-command
+ :defer 0.5
+ :bind (([remap shell-command] . dwim-shell-command)
+ :map dired-mode-map
+ ([remap dired-do-async-shell-command] . dwim-shell-command)
+ ([remap dired-do-shell-command] . dwim-shell-command)
+ ([remap dired-smart-shell-command] . dwim-shell-command))
+ :init
+ (defun cj/dwim-shell-commands-convert-audio-to-mp3 ()
+ "Convert all marked audio to mp3(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to mp3"
+ "ffmpeg -stats -n -i '<<f>>' -acodec libmp3lame '<<fne>>.mp3'"
+ :utils "ffmpeg"))
+
+ (defun cj/dwim-shell-commands-convert-audio-to-opus ()
+ "Convert all marked audio to opus(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to opus"
+ "ffmpeg -stats -n -i '<<f>>' -c:a libopus -vbr on -compression_level 10 -b:a 256k '<<fne>>.opus'"
+ :utils "ffmpeg"))
+
+ (defun cj/dwim-shell-commands-view-image-exif-metadata ()
+ "View EXIF metadata in image(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "View EXIF"
+ "exiftool '<<f>>'"
+ :utils "exiftool"))
+
+ (defun cj/dwim-shell-commands-flip-image-horizontally ()
+ "Horizontally flip image(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Image horizontal flip"
+ "convert -verbose -flop '<<f>>' '<<fne>>_h_flipped.<<e>>'"
+ :utils "convert"))
+
+ (defun cj/dwim-shell-commands-flip-image-vertically ()
+ "Horizontally flip image(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Image vertical flip"
+ "convert -verbose -flip '<<f>>' '<<fne>>_v_flipped.<<e>>'"
+ :utils "convert"))
+
+ (defun cj/dwim-shell-commands-convert-image-to ()
+ "Convert all marked images to a specified format."
+ (interactive)
+ (let ((format (completing-read "Convert to format: "
+ '("jpg" "png" "webp" "gif" "bmp" "tiff")
+ nil t)))
+ (dwim-shell-command-on-marked-files
+ (format "Convert to %s" format)
+ (format "convert -verbose '<<f>>' '<<fne>>.%s'" format)
+ :utils "convert")))
+
+ (defun cj/dwim-shell-commands-convert-svg-to-png ()
+ "Convert all marked svg(s) to png(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to png"
+ "rsvg-convert -b white '<<f>>' -f png -o '<<fne>>.png'"
+ :utils "rsvg-convert"))
+
+ (defun cj/dwim-shell-commands-join-images-into-pdf ()
+ "Join all marked images as a single pdf."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Join as pdf"
+ (format "convert -verbose '<<*>>' '<<%s(u)>>'"
+ (dwim-shell-command-read-file-name
+ "Join as pdf named (default \"joined.pdf\"): "
+ :extension "pdf"
+ :default "joined.pdf"))
+ :utils "convert"))
+
+ (defun cj/dwim-shell-commands-extract-pdf-page-number ()
+ "Keep a page from pdf."
+ (interactive)
+ (let ((page-num (read-number "Keep page number: " 1)))
+ (dwim-shell-command-on-marked-files
+ "Keep pdf page"
+ (format "qpdf '<<f>>' --pages . %d -- '<<fne>>_%d.<<e>>'" page-num page-num)
+ :utils "qpdf")))
+
+ (defun cj/dwim-shell-commands-ocr-text-from-image-using-tesseract ()
+ "Extract text from image via tesseract."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Extract text from image via tesseract."
+ "tesseract '<<f>>' -"
+ :utils "tesseract"))
+
+ (defun cj/dwim-shell-commands-convert-video-to-webp ()
+ "Convert all marked videos to webp(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to webp"
+ "ffmpeg -i '<<f>>' -vcodec libwebp -filter:v fps=fps=10 -compression_level 3 -loop 0 -preset default -an -vsync 0 '<<fne>>'.webp"
+ :utils "ffmpeg"))
+
+ (defun cj/dwim-shell-commands-convert-video-to-high-compatibility-mp4 ()
+ "Convert all marked video(s) to MP4 format with H.264/AAC."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to MP4"
+ "ffmpeg -i '<<f>>' -c:v libx264 -preset slow -crf 23 -profile:v baseline -level 3.0 -c:a aac -b:a 128k '<<fne>>.mp4'"
+ :utils "ffmpeg"))
+
+ (defun cj/dwim-shell-commands-convert-video-to-hevc-mkv ()
+ "Convert all marked videos to HEVC (H.265) in MKV container."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to HEVC/H.265"
+ "ffmpeg -i '<<f>>' -c:v libx265 -preset slower -crf 22 -x265-params profile=main10:level=4.0 -c:a copy -c:s copy '<<fne>>.mkv'"
+ :utils "ffmpeg"))
+
+ (defun cj/dwim-shell-commands-extract-archive-smartly ()
+ "Unzip all marked archives (of any kind) using =atool'.
+
+If there's only one file, unzip it to current directory.
+Otherwise, unzip it to an appropriately named subdirectory "
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Unzip" "atool --extract --subdir --explain '<<f>>'"
+ :utils "atool"))
+
+ (defun cj/dwim-shell-commands-zip-file-or-directory ()
+ "Zip all marked files into archive.zip."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Zip" (if (eq 1 (seq-length (dwim-shell-command--files)))
+ "zip -r '<<fne>>.<<e>>' '<<f>>'"
+ "zip -r '<<archive.zip(u)>>' '<<*>>'")
+ :utils "zip"))
+
+ (defun cj/dwim-shell-commands-tar-gzip-file-or-directory ()
+ "Tar gzip all marked files into archive.tar.gz."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Tar gzip" (if (eq 1 (seq-length (dwim-shell-command--files)))
+ "tar czf '<<fne>>.tar.gz' '<<f>>'"
+ "tar czf '<<archive.tar.gz(u)>>' '<<*>>'")
+ :utils "tar"))
+
+ (defun cj/dwim-shell-commands-epub-to-org ()
+ "Convert epub(s) to org."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "epub to org"
+ "pandoc --from=epub --to=org '<<f>>' > '<<fne>>.org'"
+ :extensions "epub"
+ :utils "pandoc"))
+
+ (defun cj/dwim-shell-commands-document-to-pdf ()
+ "Convert document(s) to pdf (via latex).
+
+Supports docx, odt, and other pandoc-compatible formats."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Document to pdf (via latex)"
+ "pandoc -t latex '<<f>>' -o '<<fne>>.pdf'"
+ :extensions '("docx" "odt" "odp" "ods" "rtf" "doc")
+ :utils "pdflatex"))
+
+ (defun cj/dwim-shell-commands-pdf-to-txt ()
+ "Convert pdf to txt."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "pdf to txt"
+ "pdftotext -layout '<<f>>' '<<fne>>.txt'"
+ :utils "pdftotext"))
+
+ (defun cj/dwim-shell-commands-resize-image-by-factor ()
+ "Resize marked image(s) by factor."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Resize image"
+ (let ((factor (read-number "Resize scaling factor: " 0.5)))
+ (format "convert -resize %%%d '<<f>>' '<<fne>>_x%.2f.<<e>>'"
+ (* 100 factor) factor))
+ :utils "convert"))
+
+ (defun cj/dwim-shell-commands-resize-image-in-pixels ()
+ "Resize marked image(s) in pixels."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Resize image"
+ (let ((width (read-number "Resize width (pixels): " 500)))
+ (format "convert -resize %dx '<<f>>' '<<fne>>_x%d.<<e>>'" width width))
+ :utils "convert"))
+
+ (defun cj/dwim-shell-commands-pdf-password-protect ()
+ "Add a password to pdf(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Password protect pdf"
+ (format "qpdf --verbose --encrypt '%s' '%s' 256 -- '<<f>>' '<<fne>>_protected.<<e>>'"
+ (read-passwd "user-password: ")
+ (read-passwd "owner-password: "))
+ :utils "qpdf"
+ :extensions "pdf"))
+
+ (defun cj/dwim-shell-commands-pdf-password-unprotect ()
+ "Remove a password from pdf(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Remove protection from pdf"
+ (format "qpdf --verbose --decrypt --password='%s' -- '<<f>>' '<<fne>>_unprotected.<<e>>'"
+ (read-passwd "password: "))
+ :utils "qpdf"
+ :extensions "pdf"))
+
+ (defun cj/dwim-shell-commands-video-trim ()
+ "Trim video with options for beginning, end, or both."
+ (interactive)
+ (let* ((trim-type (completing-read "Trim from: "
+ '("Beginning" "End" "Both")
+ nil t))
+ (command (pcase trim-type
+ ("Beginning"
+ (let ((seconds (read-number "Seconds to trim from beginning: " 5)))
+ (format "ffmpeg -i '<<f>>' -y -ss %d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ seconds)))
+ ("End"
+ (let ((seconds (read-number "Seconds to trim from end: " 5)))
+ (format "ffmpeg -sseof -%d -i '<<f>>' -y -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ seconds)))
+ ("Both"
+ (let ((start (read-number "Seconds to trim from beginning: " 5))
+ (end (read-number "Seconds to trim from end: " 5)))
+ (format "ffmpeg -i '<<f>>' -y -ss %d -sseof -%d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ start end))))))
+ (dwim-shell-command-on-marked-files
+ (format "Trim video (%s)" trim-type)
+ command
+ :silent-success t
+ :utils "ffmpeg")))
+
+ (defun cj/dwim-shell-commands-drop-audio-from-video ()
+ "Drop audio from all marked videos."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Drop audio"
+ "ffmpeg -i '<<f>>' -c copy -an '<<fne>>_no_audio.<<e>>'"
+ :utils "ffmpeg"))
+
+ (defun cj/dwim-shell-commands-open-externally ()
+ "Open file(s) externally."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Open externally"
+ (cond ((eq system-type 'darwin)
+ (if (derived-mode-p 'prog-mode)
+ (format "open -a Xcode --args --line %d '<<f>>'"
+ (line-number-at-pos (point)))
+ "open '<<f>>'"))
+ ((eq system-type 'windows-nt)
+ "start '<<f>>'")
+ (t ;; Linux/Unix
+ "xdg-open '<<f>>' 2>/dev/null || (echo 'Failed to open with xdg-open' && exit 1)"))
+ :silent-success t
+ :utils (cond ((eq system-type 'darwin) "open")
+ ((eq system-type 'windows-nt) "start")
+ (t "xdg-open"))))
+
+
+ (defun cj/dwim-shell-commands-git-clone-clipboard-url ()
+ "Clone git URL in clipboard to `default-directory'."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ (format "Clone %s" (file-name-base (current-kill 0)))
+ "git clone <<cb>>"
+ :utils "git"))
+
+ (defun cj/dwim-shell-commands-open-file-manager ()
+ "Open the default file manager in the current directory."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Open file manager"
+ (cond ((eq system-type 'darwin)
+ "open .")
+ ((eq system-type 'windows-nt)
+ "explorer .")
+ (t ;; Linux/Unix - try multiple options
+ (cond ((executable-find "thunar") "thunar .")
+ ((executable-find "nautilus") "nautilus .")
+ ((executable-find "dolphin") "dolphin .")
+ ((executable-find "pcmanfm") "pcmanfm .")
+ (t "xdg-open ."))))
+ :silent-success t
+ :no-progress t))
+
+ (defun cj/dwim-shell-commands-count-words-lines ()
+ "Count words, lines, and characters in text file(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Word count"
+ "wc -lwc '<<f>>'"
+ :utils "wc"))
+
+ (defun cj/dwim-shell-commands-checksum ()
+ "Generate checksums for file(s) and save to .checksum file."
+ (interactive)
+ (let ((algorithm (completing-read "Algorithm: "
+ '("md5" "sha1" "sha256" "sha512")
+ nil t)))
+ (dwim-shell-command-on-marked-files
+ (format "Generate %s checksum" algorithm)
+ (format "%ssum '<<f>>' | tee '<<f>>.%s'" algorithm algorithm)
+ :utils (format "%ssum" algorithm))))
+
+ (defun cj/dwim-shell-commands-backup-with-timestamp ()
+ "Create dated backup of file(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Backup with date"
+ "cp -p '<<f>>' '<<f>>.$(date +%Y%m%d_%H%M%S).bak'"
+ :utils '("cp" "date")))
+
+ (defun cj/dwim-shell-commands-optimize-image-for-web ()
+ "Optimize image(s) for web (reduce file size)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Optimize image"
+ "convert '<<f>>' -strip -interlace Plane -gaussian-blur 0.05 -quality 85% '<<fne>>_optimized.<<e>>'"
+ :utils "convert"))
+
+ (defun cj/dwim-shell-commands-csv-to-json ()
+ "Convert CSV to JSON."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "CSV to JSON"
+ "python -c \"import csv,json,sys; print(json.dumps(list(csv.DictReader(open('<<f>>')))))\" > '<<fne>>.json'"
+ :extensions "csv"
+ :utils "python"))
+
+ (defun cj/dwim-shell-commands-json-to-yaml ()
+ "Convert JSON to YAML."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "JSON to YAML"
+ "python -c \"import json,yaml,sys; yaml.dump(json.load(open('<<f>>')), open('<<fne>>.yaml', 'w'))\" && echo 'Created <<fne>>.yaml'"
+ :extensions "json"
+ :utils "python"))
+
+ (defun cj/dwim-shell-commands-extract-urls-from-file ()
+ "Extract all URLs from file(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Extract URLs"
+ "grep -Eo 'https?://[^[:space:]]+' '<<f>>'"
+ :utils "grep"))
+
+ (defun cj/dwim-shell-commands-extract-emails-from-file ()
+ "Extract all email addresses from file(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Extract emails"
+ "grep -Eo '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}' '<<f>>'"
+ :utils "grep"))
+
+ (defun cj/dwim-shell-commands-create-gif-from-video ()
+ "Create animated GIF from video."
+ (interactive)
+ (let ((fps (read-number "FPS for GIF: " 10))
+ (scale (read-number "Scale (pixels width): " 480)))
+ (dwim-shell-command-on-marked-files
+ "Create GIF"
+ (format "ffmpeg -i '<<f>>' -vf 'fps=%d,scale=%d:-1:flags=lanczos' '<<fne>>.gif'" fps scale)
+ :utils "ffmpeg")))
+
+ (defun cj/dwim-shell-commands-concatenate-videos ()
+ "Concatenate multiple videos into one."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Concatenate videos"
+ "echo '<<*>>' | tr ' ' '\n' | sed 's/^/file /' > '<<td>>/filelist.txt' && ffmpeg -f concat -safe 0 -i '<<td>>/filelist.txt' -c copy '<<concatenated.mp4(u)>>'"
+ :utils "ffmpeg"))
+
+ (defun cj/dwim-shell-commands-create-video-thumbnail ()
+ "Extract thumbnail from video at specific time."
+ (interactive)
+ (let ((time (read-string "Time (HH:MM:SS or seconds): " "00:00:05")))
+ (dwim-shell-command-on-marked-files
+ "Extract video thumbnail"
+ (format "ffmpeg -i '<<f>>' -ss %s -vframes 1 '<<fne>>_thumb.jpg'" time)
+ :utils "ffmpeg")))
+
+ (defun cj/dwim-shell-commands-merge-pdfs ()
+ "Merge multiple PDFs into one."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Merge PDFs"
+ "qpdf --empty --pages '<<*>>' -- '<<merged.pdf(u)>>'"
+ :extensions "pdf"
+ :utils "qpdf"))
+
+ (defun cj/dwim-shell-commands-split-pdf-by-pages ()
+ "Split PDF into individual pages."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Split PDF pages"
+ "qpdf --split-pages '<<f>>' '<<fne>>-page-%d.pdf'"
+ :extensions "pdf"
+ :utils "qpdf"))
+
+ (defun cj/dwim-shell-commands-compress-pdf ()
+ "Compress PDF file size."
+ (interactive)
+ (let ((quality (completing-read "Quality: "
+ '("screen" "ebook" "printer" "prepress")
+ nil t "ebook")))
+ (dwim-shell-command-on-marked-files
+ "Compress PDF"
+ (format "gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dPDFSETTINGS=/%s -dNOPAUSE -dBATCH -sOutputFile='<<fne>>_compressed.pdf' '<<f>>'" quality)
+ :extensions "pdf"
+ :utils "gs")))
+
+ (defun cj/dwim-shell-commands-ascii-art ()
+ "Convert image to ASCII art."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Create ASCII art"
+ "jp2a --width=80 '<<f>>'"
+ :utils "jp2a"))
+
+ (defun cj/dwim-shell-commands-text-to-speech ()
+ "Convert text file to speech (audio file)."
+ (interactive)
+ (let ((voice (if (eq system-type 'darwin)
+ (completing-read "Voice: " '("Alex" "Samantha" "Victoria" "Karen") nil t "Alex")
+ "en")))
+ (dwim-shell-command-on-marked-files
+ "Text to speech"
+ (if (eq system-type 'darwin)
+ (format "say -v %s -o '<<fne>>.aiff' -f '<<f>>'" voice)
+ "espeak -f '<<f>>' -w '<<fne>>.wav'")
+ :utils (if (eq system-type 'darwin) "say" "espeak"))))
+
+ (defun cj/dwim-shell-commands-remove-empty-directories ()
+ "Remove all empty directories recursively."
+ (interactive)
+ (when (yes-or-no-p "Remove all empty directories? ")
+ (dwim-shell-command-on-marked-files
+ "Remove empty dirs"
+ "find . -type d -empty -delete"
+ :utils "find")))
+
+ (defun cj/dwim-shell-commands-create-thumbnail-from-image ()
+ "Create thumbnail(s) from image(s)."
+ (interactive)
+ (let ((size (read-number "Thumbnail size (pixels): " 200)))
+ (dwim-shell-command-on-marked-files
+ "Create thumbnail"
+ (format "convert '<<f>>' -thumbnail %dx%d '<<fne>>_thumb.<<e>>'" size size)
+ :utils "convert")))
+
+ (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"))
+
+ (defun cj/dwim-shell-commands-normalize-audio-volume ()
+ "Normalize audio volume in file(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Normalize audio"
+ "ffmpeg -i '<<f>>' -af 'loudnorm=I=-16:LRA=11:TP=-1.5' '<<fne>>_normalized.<<e>>'"
+ :utils "ffmpeg"))
+
+ (defun cj/dwim-shell-commands-remove-zip-encryption ()
+ "Remove password protection from zip file(s)."
+ (interactive)
+ (let ((password (read-passwd "Current password: ")))
+ (dwim-shell-command-on-marked-files
+ "Remove zip encryption"
+ (format "TMPDIR=$(mktemp -d) && unzip -P '%s' '<<f>>' -d \"$TMPDIR\" && cd \"$TMPDIR\" && zip -r archive.zip * && mv archive.zip '<<fne>>_decrypted.zip' && rm -rf \"$TMPDIR\""
+ password)
+ :utils '("unzip" "zip"))))
+
+ (defun cj/dwim-shell-commands-create-encrypted-zip ()
+ "Create password-protected zip of file(s)."
+ (interactive)
+ (let ((password (read-passwd "Password: ")))
+ (dwim-shell-command-on-marked-files
+ "Create encrypted zip"
+ (format "zip -r -e -P '%s' '<<archive.zip(u)>>' '<<*>>'" password)
+ :utils "zip")))
+
+
+ (defun cj/dwim-shell-commands-list-archive-contents ()
+ "List contents of archive without extracting."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "List archive contents"
+ "atool --list '<<f>>'"
+ :utils "atool"))
+
+ (defun cj/dwim-shell-commands-count-words-lines-in-text-file ()
+ "Count words, lines, and characters in text file(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Word count"
+ "wc -lwc '<<f>>'"
+ :utils "wc"))
+
+ (defun cj/dwim-shell-commands-make-executable ()
+ "Make file(s) executable."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Make executable"
+ "chmod +x '<<f>>'"
+ :silent-success t
+ :utils "chmod"))
+
+ (defun cj/dwim-shell-commands-secure-delete ()
+ "Securely delete file(s) by overwriting with random data."
+ (interactive)
+ (when (yes-or-no-p "This will permanently destroy files. Continue? ")
+ (dwim-shell-command-on-marked-files
+ "Secure delete"
+ "shred -vfz -n 3 '<<f>>'"
+ :utils "shred")))
+
+ (defun cj/dwim-shell-commands-sanitize-filename ()
+ "Sanitize filename(s) - remove spaces and special characters."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Sanitize filename"
+ "NEW_NAME=$(echo '<<b>>' | tr ' ' '_' | tr -cd '[:alnum:]._-'); mv '<<f>>' \"$(dirname '<<f>>')/${NEW_NAME}\""
+ :utils '("tr" "mv")))
+
+ (defun cj/dwim-shell-commands-number-files-sequentially ()
+ "Rename files with sequential numbers."
+ (interactive)
+ (let ((prefix (read-string "Prefix (optional): "))
+ (start (read-number "Start number: " 1)))
+ (dwim-shell-command-on-marked-files
+ "Number files"
+ (format "mv '<<f>>' '<<d>>/%s<<n>>.<<e>>'" prefix)
+ :utils "mv")))
+
+ (defun cj/dwim-shell-commands-git-history ()
+ "Show git history for file(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Git history"
+ "git log --oneline --follow '<<f>>'"
+ :utils "git"))
+
+ (defun cj/dwim-shell-commands-encrypt-with-gpg ()
+ "Encrypt file(s) with GPG."
+ (interactive)
+ (let ((recipient (read-string "Recipient email (or leave empty for symmetric): ")))
+ (dwim-shell-command-on-marked-files
+ "GPG encrypt"
+ (if (string-empty-p recipient)
+ "gpg --symmetric --cipher-algo AES256 '<<f>>'"
+ (format "gpg --encrypt --recipient '%s' '<<f>>'" recipient))
+ :utils "gpg")))
+
+ (defun cj/dwim-shell-commands-decrypt-with-gpg ()
+ "Decrypt GPG file(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "GPG decrypt"
+ "gpg --decrypt '<<f>>' > '<<fne>>'"
+ :extensions '("gpg" "asc" "pgp")
+ :utils "gpg"))
+
+
+(defun cj/dwim-shell-commands-markdown-to-html5-and-open ()
+ "Convert markdown file to HTML in specified directory and open it."
+ (interactive)
+ (let ((files (dwim-shell-command--files)))
+ ;; verify it's a markdown file
+ (unless (and files
+ (= 1 (length files))
+ (string-match-p "\\.\\(md\\|markdown\\|mkd\\|mdown\\)\\'" (car files)))
+ (user-error "Please place cursor on a single markdown file"))
+ (let* ((dest-dir (expand-file-name (read-directory-name "Destination directory: " default-directory)))
+ (base-name (file-name-sans-extension (file-name-nondirectory (car files))))
+ (output-file (expand-file-name (concat base-name ".html") dest-dir)))
+ (dwim-shell-command-on-marked-files
+ "Convert markdown to HTML"
+ (format "pandoc --standalone --from=markdown --to=html5 --metadata title='<<fne>>' '<<f>>' -o '%s'"
+ output-file)
+ :utils "pandoc"
+ :on-completion (lambda (&rest args)
+ (when (file-exists-p output-file)
+ (cj/xdg-open output-file)
+ (message "Opened %s" output-file)))))))
+
+
+
+ (defun cj/dwim-shell-commands-kill-gpg-agent ()
+ "Kill (thus restart) gpg agent.
+
+Useful for when you get this error:
+gpg: public key decryption failed: No pinentry
+gpg: decryption failed: No pinentry"
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Kill gpg agent"
+ "gpgconf --kill gpg-agent"
+ :utils "gpgconf"
+ :silent-success t)))
+
+(provide 'dwim-shell-config)
+;;; dwim-shell-config.el ends here.
diff --git a/modules/elfeed-config.el b/modules/elfeed-config.el
new file mode 100644
index 00000000..46520be2
--- /dev/null
+++ b/modules/elfeed-config.el
@@ -0,0 +1,290 @@
+;;; elfeed-config --- Settings and Enhancements to the Elfeed RSS Feed Reader -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Launch Elfeed with M-R to update feeds and focus the newest entry right away.
+;; Inside the search buffer:
+;; - Use v to stream via the default player, d to download, w/b to open via EWW or browser.
+;; - Hit V to pick a different player for future launches.
+;; - Use R/U to mark all visible stories read or unread before narrowing again.
+;;
+;; When a video needs streaming credentials the player selection drives yt-dlp format choices;
+;; use `cj/select-media-player` to swap profiles, or customize `cj/media-players` for your system.
+;; All commands assume yt-dlp and your players live on PATH.
+;;
+;;; Code:
+
+(require 'user-constants)
+(require 'system-utils)
+(require 'media-utils)
+
+;; ------------------------------- Elfeed Config -------------------------------
+
+(use-package elfeed
+ :bind
+ ("M-R" . cj/elfeed-open)
+ (:map elfeed-show-mode-map
+ ("w" . eww-open-in-new-buffer))
+ (:map elfeed-search-mode-map
+ ("w" . cj/elfeed-eww-open) ;; opens in eww
+ ("b" . cj/elfeed-browser-open) ;; opens in external browser
+ ("d" . cj/elfeed-youtube-dl) ;; async download with yt-dlp and tsp
+ ("v" . cj/play-with-video-player)) ;; async play with mpv
+ ("V" . cj/select-media-player) ;; Capital V to select player
+ ("R" . cj/elfeed-mark-all-as-read) ;; capital marks all as read, since upper case marks one as read
+ ("U" . cj/elfeed-mark-all-as-unread) ;; capital marks all as unread, since lower case marks one as unread
+ :config
+ (setq elfeed-db-directory (concat user-emacs-directory ".elfeed-db"))
+ (setq-default elfeed-search-title-max-width 150)
+ (setq-default elfeed-search-title-min-width 80)
+ (setq-default elfeed-search-filter "+unread")
+ (setq elfeed-feeds
+ '(
+ ;; The Daily
+ ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLdMrbgYfVl-s16D_iT2BJCJ90pWtTO1A4" yt nytdaily)
+
+ ;; The Ezra Klein Show
+ ("https://www.youtube.com/feeds/videos.xml?channel_id=UCnxuOd8obvLLtf5_-YKFbiQ" yt ezra)
+
+ ;; Pivot with Kara Swisher and Scott Galloway
+ ("https://www.youtube.com/feeds/videos.xml?channel_id=UCBHGZpDF2fsqPIPi0pNyuTg" yt pivot)
+
+ ;; The Prof G Pod
+ ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLtQ-jBytlXCasRuBG86m22rOQfrEPcctq" yt profg)
+
+ ;; On with Kara Swisher
+ ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLKof9YSAshgxI6odrEJFKsJbxamwoQBju" yt)
+
+ ;; Raging Moderates
+ ("https://www.youtube.com/feeds/videos.xml?channel_id=UCcvDWzvxz6Kn1iPQHMl2teA" yt raging-moderates)
+
+ ;; Prof G Markets
+ ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLtQ-jBytlXCY28ucRF8P1mNMSG8uC06Aw" yt profg-markets)
+
+ ;; Trae Crowder Porch Rants
+ ("https://www.youtube.com/feeds/videos.xml?playlist_id=PL45Mc1cDgnsB-u1iLPBYNF1fk-y1cVzTJ" yt trae)
+
+ ;; Tropical Tidbits
+ ("https://www.youtube.com/feeds/videos.xml?channel_id=UCrFIk7g_riIm2G2Vi90pxDA" yt)
+
+ ;; If You're Listening | ABC News In-depth
+ ("https://www.youtube.com/feeds/videos.xml?playlist_id=PLDTPrMoGHssAfgMMS3L5LpLNFMNp1U_Nq" yt listening)
+ )))
+
+;; ------------------------------ Elfeed Functions -----------------------------
+
+(defun cj/elfeed-open ()
+ "Open Elfeed, update all feeds, then move to the first entry."
+ (interactive)
+ (elfeed)
+ (elfeed-update)
+ (elfeed-search-update--force))
+
+;; -------------------------- Elfeed Filter Functions --------------------------
+
+(defun cj/elfeed-mark-all-as-read ()
+ "Remove the \='unread\=' tag from all visible entries in search buffer."
+ (interactive)
+ (mark-whole-buffer)
+ (elfeed-search-untag-all-unread))
+
+(defun cj/elfeed-mark-all-as-unread ()
+ "Add the \='unread\=' tag from all visible entries in the search buffer."
+ (interactive)
+ (mark-whole-buffer)
+ (elfeed-search-tag-all 'unread))
+
+(defun cj/elfeed-set-filter-and-update (filterstring)
+ "Set the Elfeed filter to FILTERSTRING and update the buffer."
+ (interactive "sFilter: ")
+ (setq elfeed-search-filter filterstring)
+ (elfeed-search-update--force)
+ (goto-char (point-min)))
+
+;; ----------------------------- Extract Stream URL ----------------------------
+;; TASK: Is this method reused anywhere here or in another file?
+
+(defun cj/extract-stream-url (url format)
+ "Extract the direct stream URL from URL using yt-dlp with FORMAT.
+Returns the stream URL or nil on failure."
+ (unless (executable-find "yt-dlp")
+ (error "The program yt-dlp is not installed or not in PATH"))
+ (let* ((format-args (if format
+ (list "-f" format)
+ nil))
+ (cmd-args (append '("yt-dlp" "-q" "-g")
+ format-args
+ (list url)))
+ ;; DEBUG: Log the command
+ (_ (cj/log-silently "DEBUG: Extracting with command: %s"
+ (mapconcat #'shell-quote-argument cmd-args " ")))
+ (output (with-temp-buffer
+ (let ((exit-code (apply #'call-process
+ (car cmd-args) nil t nil
+ (cdr cmd-args))))
+ (if (zerop exit-code)
+ (string-trim (buffer-string))
+ (progn
+ ;; DEBUG: Log failure
+ (cj/log-silently "DEBUG: yt-dlp failed with exit code %d" exit-code)
+ (cj/log-silently "DEBUG: Error output: %s" (buffer-string))
+ nil))))))
+ ;; DEBUG: Log the result
+ (cj/log-silently "DEBUG: Extracted URL: %s"
+ (if output (truncate-string-to-width output 100) "nil"))
+ (when (and output (string-match-p "^https?://" output))
+ output)))
+
+;; -------------------------- Elfeed Core Processing ---------------------------
+
+(defun cj/elfeed-process-entries (action-fn action-name &optional skip-error-handling)
+ "Process selected Elfeed entries with ACTION-FN.
+ACTION-NAME is used for error messages. Marks entries as read and
+advances to the next line. If SKIP-ERROR-HANDLING is non-nil, errors
+are not caught (useful for actions that handle their own errors)."
+ (let ((entries (elfeed-search-selected)))
+ (unless entries
+ (error "No entries selected"))
+ (cl-loop for entry in entries
+ do (elfeed-untag entry 'unread)
+ for link = (elfeed-entry-link entry)
+ when link
+ do (if skip-error-handling
+ (funcall action-fn link)
+ (condition-case err
+ (funcall action-fn link)
+ (error (message "Failed to %s %s: %s"
+ action-name
+ (truncate-string-to-width link 50)
+ (error-message-string err))))))
+ (mapc #'elfeed-search-update-entry entries)
+ (unless (use-region-p) (forward-line))))
+
+;; -------------------------- Elfeed Browser Functions -------------------------
+
+(defun cj/elfeed-eww-open ()
+ "Opens the links of the currently selected Elfeed entries with EWW.
+
+Applies cj/eww-readable-nonce hook after EWW rendering."
+ (interactive)
+ (cj/elfeed-process-entries
+ (lambda (link)
+ (add-hook 'eww-after-render-hook #'cj/eww-readable-nonce)
+ (eww-browse-url link))
+ "open in EWW"
+ t)) ; skip error handling since eww handles its own errors
+
+(defun cj/eww-readable-nonce ()
+ "Once-off call to eww-readable after EWW is done rendering."
+ (unwind-protect
+ (progn
+ (eww-readable)
+ (goto-char (point-min)))
+ (remove-hook 'eww-after-render-hook #'cj/eww-readable-nonce)))
+
+(defun cj/elfeed-browser-open ()
+ "Opens the link of the selected Elfeed entries in the default browser."
+ (interactive)
+ (cj/elfeed-process-entries
+ #'browse-url-default-browser
+ "open in browser"
+ t)) ; skip error handling since browser handles its own errors
+
+;; --------------------- Elfeed Play And Download Functions --------------------
+
+(defun cj/elfeed-youtube-dl ()
+ "Downloads the selected Elfeed entries' links with youtube-dl."
+ (interactive)
+ (cj/elfeed-process-entries #'cj/yt-dl-it "download"))
+
+(defun cj/play-with-video-player ()
+ "Plays the selected Elfeed entries' links with the configured media player.
+
+Note: Function name kept for backwards compatibility."
+ (interactive)
+ (cj/elfeed-process-entries #'cj/media-play-it
+ (format "play with %s"
+ (plist-get (alist-get cj/default-media-player cj/media-players) :name))))
+
+;; --------------------- Youtube Url To Elfeed Feed Format ---------------------
+
+(defun cj/youtube-to-elfeed-feed-format (url type)
+ "Convert YouTube URL to elfeed-feeds format.
+
+TYPE should be either \='channel or \='playlist."
+ (let ((id nil)
+ (title nil)
+ (buffer nil)
+ (id-pattern (if (eq type 'channel)
+ "href=\"https://www\\.youtube\\.com/feeds/videos\\.xml\\?channel_id=\\([^\"]+\\)\""
+ "/playlist\\?list=\\([^&]+\\)"))
+ (feed-format (if (eq type 'channel)
+ "https://www.youtube.com/feeds/videos.xml?channel_id=%s"
+ "https://www.youtube.com/feeds/videos.xml?playlist_id=%s"))
+ (error-msg (if (eq type 'channel)
+ "Could not extract channel information"
+ "Could not extract playlist information")))
+
+ ;; Extract ID based on type
+ (if (eq type 'channel)
+ ;; For channels, we need to fetch the page to get the channel_id
+ (progn
+ (setq buffer (url-retrieve-synchronously url))
+ (when buffer
+ (with-current-buffer buffer
+ ;; Decode the content as UTF-8
+ (set-buffer-multibyte t)
+ (decode-coding-region (point-min) (point-max) 'utf-8)
+ (goto-char (point-min))
+ ;; Search for the channel_id in the RSS feed link
+ (when (re-search-forward id-pattern nil t)
+ (setq id (match-string 1))))))
+ ;; For playlists, extract from URL first
+ (when (string-match id-pattern url)
+ (setq id (match-string 1 url))
+ (setq buffer (url-retrieve-synchronously url))))
+
+ ;; Get title from the page
+ (when (and buffer id)
+ (with-current-buffer buffer
+ (unless (eq type 'channel)
+ ;; Decode for playlist (already done for channel above)
+ (set-buffer-multibyte t)
+ (decode-coding-region (point-min) (point-max) 'utf-8))
+ ;; Search for the title in og:title meta tag
+ (goto-char (point-min))
+ (when (re-search-forward "<meta property=\"og:title\" content=\"\\([^\"]+\\)\"" nil t)
+ (setq title (match-string 1))
+ ;; Simple HTML entity decoding
+ (setq title (replace-regexp-in-string "&amp;" "&" title))
+ (setq title (replace-regexp-in-string "&lt;" "<" title))
+ (setq title (replace-regexp-in-string "&gt;" ">" title))
+ (setq title (replace-regexp-in-string "&quot;" "\"" title))
+ (setq title (replace-regexp-in-string "&#39;" "'" title))
+ (setq title (replace-regexp-in-string "&#x27;" "'" title))))
+ (kill-buffer buffer))
+
+ (if (and id title)
+ (format ";; %s\n(\"%s\" yt)"
+ title
+ (format feed-format id))
+ (error error-msg))))
+
+(defun cj/youtube-channel-to-elfeed-feed-format (url)
+ "Convert YouTube channel URL to elfeed-feeds format and insert at point."
+ (interactive "sYouTube Channel URL: ")
+ (let ((result (cj/youtube-to-elfeed-feed-format url 'channel)))
+ (when (called-interactively-p 'interactive)
+ (insert result))
+ result))
+
+(defun cj/youtube-playlist-to-elfeed-feed-format (url)
+ "Convert YouTube playlist URL to elfeed-feeds format and insert at point."
+ (interactive "sYouTube Playlist URL: ")
+ (let ((result (cj/youtube-to-elfeed-feed-format url 'playlist)))
+ (when (called-interactively-p 'interactive)
+ (insert result))
+ result))
+
+(provide 'elfeed-config)
+;;; elfeed-config.el ends here.
diff --git a/modules/eradio-config.el b/modules/eradio-config.el
new file mode 100644
index 00000000..63085aa8
--- /dev/null
+++ b/modules/eradio-config.el
@@ -0,0 +1,36 @@
+;;; eradio-config --- Simple Internet Radio Setup -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+(use-package eradio
+ :bind
+ ("C-c r p" . eradio-play)
+ ("C-c r s" . eradio-stop)
+ ("C-c r <SPC>" . eradio-toggle)
+ :config
+ (setq eradio-player '("mpv" "--no-video" "--no-terminal"))
+ (setq eradio-channels
+ '(("BAGeL Radio (alternative)" . "https://ais-sa3.cdnstream1.com/2606_128.mp3")
+ ("Blues Music Fan Radio (blues)" . "http://ais-sa2.cdnstream1.com/1992_128.mp3")
+ ("Blues Radio (blues)" . "http://cast3.radiohost.ovh:8352/")
+ ("Concertzender Baroque (classical)" . "http://streams.greenhost.nl:8080/barok")
+ ("Groove Salad (somafm)" . "https://somafm.com/groovesalad130.pls")
+ ("Indie Pop Rocks (somafm)" . "https://somafm.com/indiepop130.pls")
+ ("KDFC Classical (classical)" . "http://128.mp3.pls.kdfc.live/")
+ ("Radio Caprice Classical Lute (classical)" . "http://79.120.12.130:8000/lute")
+ ("Radio Swiss Classic German (classical)" . "http://stream.srg-ssr.ch/m/rsc_de/mp3_128")
+ ("Radio Caprice Acoustic Blues (blues)" . "http://79.111.14.76:8000/acousticblues")
+ ("Radio Caprice Delta Blues (blues)" . "http://79.120.77.11:8002/deltablues")
+ ("Seven Inch Soul (somafm)" . "https://somafm.com/nossl/7soul.pls")
+ ("Space Station Soma (somafm)" . "https://somafm.com/spacestation.pls")
+ ("Suburbs of Goa (somafm)" . "https://somafm.com/suburbsofgoa.pls")
+ ("Sunday Baroque (classical)" . "http://wshu.streamguys.org/wshu-baroque")
+ ("Underground 80s (somafm)" . "https://somafm.com/u80s256.pls")
+ ("Venice Classic Radio (classical)" . "https://www.veniceclassicradio.eu/live1/128.m3u")
+ ("WWOZ New Orleans (jazz/blues)" . "https://www.wwoz.org/listen/hi"))))
+
+(provide 'eradio-config)
+;;; eradio-config.el ends here
diff --git a/modules/erc-config.el b/modules/erc-config.el
new file mode 100644
index 00000000..03c89aca
--- /dev/null
+++ b/modules/erc-config.el
@@ -0,0 +1,317 @@
+;;; erc-config --- Preferences for Emacs Relay Chat (IRC Client) -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; Enhanced ERC configuration with multi-server support.
+;;
+;; Main keybindings:
+;; - C-c e C : Select and connect to a specific server
+;; - C-c e c : Join a channel on current server
+;; - C-c e b : Switch between ERC buffers across all servers
+;; - C-c C-q : Quit current channel
+;; - C-c C-Q : Quit ERC altogether
+
+;;; Code:
+
+;; Keymap for ERC commands
+(defvar cj/erc-command-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map "C" 'cj/erc-connect-server-with-completion) ;; Connect to server (capital C)
+ (define-key map "c" 'cj/erc-join-channel-with-completion) ;; join channel (lowercase c)
+ (define-key map "b" 'cj/erc-switch-to-buffer-with-completion) ;; switch Buffer
+ (define-key map "l" 'cj/erc-connected-servers) ;; print connected servers in echo area
+ (define-key map "q" 'erc-part-from-channel) ;; quit channel
+ (define-key map "Q" 'erc-quit-server) ;; Quit ERC entirely
+ map)
+ "Keymap for ERC-related commands.")
+
+(global-set-key (kbd "C-c e") cj/erc-command-map)
+
+;; ------------------------------------ ERC ------------------------------------
+;; Server definitions and connection settings
+
+(defvar cj/erc-server-alist
+ '(("Libera.Chat"
+ :host "irc.libera.chat"
+ :port 6697
+ :tls t
+ :channels ("#erc" "#emacs" "#emacs-social" "#systemcrafters"))
+
+ ("IRCnet"
+ :host "open.ircnet.net"
+ :port 6697
+ :tls t
+ :channels ("#english"))
+
+ ("Snoonet"
+ :host "irc.snoonet.org"
+ :port 6697
+ :tls t
+ :channels ("#talk"))
+
+ ("IRCNow"
+ :host "irc.ircnow.org"
+ :port 6697
+ :tls t
+ :channels ("#general" "#lounge")))
+ "Alist of IRC servers and their connection details.")
+
+(defun cj/erc-connect-server (server-name)
+ "Connect to a server specified by SERVER-NAME from =cj/erc-server-alist=."
+ (let* ((server-info (assoc server-name cj/erc-server-alist))
+ (host (plist-get (cdr server-info) :host))
+ (port (plist-get (cdr server-info) :port))
+ (tls (plist-get (cdr server-info) :tls)))
+ (if tls
+ (erc-tls :server host
+ :port port
+ :nick "craigjennings"
+ :full-name user-whole-name)
+ (erc :server host
+ :port port
+ :nick "craigjennings"
+ :full-name user-whole-name))))
+
+(defun cj/erc-connect-server-with-completion ()
+ "Connect to a server using completion for server selection."
+ (interactive)
+ (let ((server-name (completing-read "Connect to IRC server: "
+ (mapcar #'car cj/erc-server-alist))))
+ (cj/erc-connect-server server-name)))
+
+(defun cj/erc-connected-servers ()
+ "Return a list of currently connected servers and display them in echo area."
+ (interactive)
+ (let ((server-buffers '()))
+ (dolist (buf (erc-buffer-list))
+ (with-current-buffer buf
+ (when (eq (buffer-local-value 'erc-server-process buf) erc-server-process)
+ (unless (member (buffer-name) server-buffers)
+ (push (buffer-name) server-buffers)))))
+
+ ;; Display the server list when called interactively
+ (when (called-interactively-p 'any)
+ (if server-buffers
+ (message "Connected ERC servers: %s"
+ (mapconcat 'identity server-buffers ", "))
+ (message "No active ERC server connections")))
+
+ server-buffers))
+
+(defun cj/erc-switch-to-buffer-with-completion ()
+ "Switch to an ERC buffer using completion."
+ (interactive)
+ (let* ((erc-buffers (mapcar 'buffer-name (erc-buffer-list)))
+ (selected (completing-read "Switch to buffer: " erc-buffers)))
+ (switch-to-buffer selected)))
+
+(defun cj/erc-server-buffer-active-p ()
+ "Return t if the current buffer is an active ERC server buffer."
+ (and (derived-mode-p 'erc-mode)
+ (erc-server-process-alive)
+ (erc-server-buffer-p)))
+
+(defun cj/erc-join-channel-with-completion ()
+ "Join a channel on the current server.
+
+If not in an active ERC server buffer, reconnect first."
+ (interactive)
+ (unless (cj/erc-server-buffer-active-p)
+ (if (erc-buffer-list)
+ ;; We have ERC buffers, but current one isn't active
+ (let ((server-buffers (cl-remove-if-not
+ (lambda (buf)
+ (with-current-buffer buf
+ (and (erc-server-buffer-p)
+ (erc-server-process-alive))))
+ (erc-buffer-list))))
+ (if server-buffers
+ ;; Found active server buffer, switch to it
+ (switch-to-buffer (car server-buffers))
+ ;; No active server buffer, reconnect
+ (message "No active ERC connection. Reconnecting...")
+ (call-interactively 'cj/erc-connect-server-with-completion)))
+ ;; No ERC buffers at all, connect to a server
+ (message "No active ERC connection. Connecting to server first...")
+ (call-interactively 'cj/erc-connect-server-with-completion)))
+
+ ;; At this point we should have an active connection
+ (if (cj/erc-server-buffer-active-p)
+ (let ((channel (read-string "Join channel: ")))
+ (when (string-prefix-p "#" channel)
+ (erc-join-channel channel)))
+ (message "Failed to establish an active ERC connection")))
+
+
+;; Main ERC configuration
+(use-package erc
+ :defer 1
+ :ensure nil ;; built-in
+ :commands (erc erc-tls)
+ :hook
+ (erc-mode . emojify-mode)
+ :custom
+ (erc-modules
+ '(autojoin
+ button
+ completion
+ fill
+ irccontrols
+ list
+ log
+ match
+ move-to-prompt
+ noncommands
+ notifications
+ readonly
+ services
+ stamp
+ track)) ;; Added track module
+
+ (erc-autojoin-channels-alist
+ (mapcar (lambda (server)
+ (cons (car server)
+ (plist-get (cdr server) :channels)))
+ cj/erc-server-alist))
+
+ (erc-nick "craigjennings")
+ (erc-user-full-name user-whole-name)
+ (erc-use-auth-source-for-nickserv-password t)
+ (erc-kill-buffer-on-part t)
+ (erc-kill-queries-on-quit t)
+ (erc-kill-server-buffer-on-quit t)
+ (erc-fill-column 120)
+ (erc-fill-function 'erc-fill-static)
+ (erc-fill-static-center 20)
+
+ :config
+
+
+ ;; use all text mode abbrevs in ercmode
+ (abbrev-table-put erc-mode-abbrev-table :parents (list text-mode-abbrev-table))
+
+ ;; create log directory if it doesn't exist
+ (setq erc-log-channels-directory (concat user-emacs-directory "erc/logs/"))
+ (if (not (file-exists-p erc-log-channels-directory))
+ (mkdir erc-log-channels-directory t))
+
+ ;; Configure buffer naming to include server name
+ (setq erc-rename-buffers t)
+ (setq erc-unique-buffers t)
+
+ ;; Custom buffer naming function
+ (defun cj/erc-generate-buffer-name (parms)
+ "Generate buffer name in the format SERVER-CHANNEL."
+ (let ((network (plist-get parms :server))
+ (target (plist-get parms :target)))
+ (if target
+ (concat (or network "") "-" (or target ""))
+ (or network ""))))
+
+ (setq erc-generate-buffer-name-function 'cj/erc-generate-buffer-name)
+
+ ;; Configure erc-track (show channel activity in modeline)
+ (setq erc-track-exclude-types '("JOIN" "NICK" "PART" "QUIT" "MODE"
+ "324" "329" "332" "333" "353" "477")
+ erc-track-exclude-server-buffer t
+ erc-track-visibility 'selected-visible
+ erc-track-switch-direction 'importance
+ erc-track-showcount t))
+
+;; -------------------------------- ERC Track ---------------------------------
+;; Better tracking of activity across channels (already included in modules above)
+
+(use-package erc-track
+ :ensure nil ;; built-in
+ :after erc
+ :custom
+ (erc-track-position-in-mode-line 'before-modes)
+ (erc-track-shorten-function 'erc-track-shorten-names)
+ (erc-track-shorten-cutoff 8)
+ (erc-track-shorten-start 1)
+ (erc-track-priority-faces-only 'all)
+ (erc-track-faces-priority-list
+ '(erc-error-face
+ erc-current-nick-face
+ erc-keyword-face
+ erc-nick-msg-face
+ erc-direct-msg-face
+ erc-notice-face
+ erc-prompt-face)))
+
+;; ------------------------ ERC Desktop Notifications ------------------------
+;; Implementation for desktop notifications
+
+(defun cj/erc-notify-on-mention (match-type nick message)
+ "Display a notification when MATCH-TYPE is 'current-nick.
+
+NICK is the sender and MESSAGE is the message text."
+ (when (and (eq match-type 'current-nick)
+ (not (string= nick (erc-current-nick)))
+ (display-graphic-p))
+ (let ((title (format "ERC: %s mentioned you" nick)))
+ ;; Use alert.el if available, otherwise fall back to notifications
+ (if (fboundp 'alert)
+ (alert message :title title :category 'erc)
+ (when (fboundp 'notifications-notify)
+ (notifications-notify
+ :title title
+ :body message
+ :app-name "Emacs ERC"
+ :sound-name 'message))))))
+
+(add-hook 'erc-text-matched-hook 'cj/erc-notify-on-mention)
+
+;; ------------------------------ ERC Colorize -------------------------------
+;; Better color management with built-in functionality
+
+(defun cj/erc-colorize-setup ()
+ "Setup ERC colorization for nicknames."
+ (make-local-variable 'erc-nick-color-alist)
+ (setq erc-nick-color-alist
+ (cl-loop for i from 0 to 15
+ for color in '("blue" "green" "red" "brown" "purple"
+ "olive" "dark cyan" "light gray" "dark gray"
+ "light blue" "light green" "light red"
+ "light brown" "light purple" "yellow" "white")
+ collect (cons i color)))
+ (setq erc-nick-color-function 'erc-get-color-for-nick))
+
+(add-hook 'erc-mode-hook 'cj/erc-colorize-setup)
+
+;; -------------------------------- ERC Image ---------------------------------
+;; show inlined images (png/jpg/gif/svg) in erc buffers.
+
+(use-package erc-image
+ :defer 1
+ :after erc
+ :config
+ (setq erc-image-inline-rescale 300)
+ (add-to-list 'erc-modules 'image)
+ (erc-update-modules))
+
+;; -------------------------------- ERC Hl Nicks -------------------------------
+;; uniquely identify names in ERC
+
+(use-package erc-hl-nicks
+ :defer 1
+ :after erc
+ :config
+ (add-to-list 'erc-modules 'hl-nicks)
+ (erc-update-modules))
+
+;; ------------------------------ ERC Yank To Gist -----------------------------
+;; automatically create a Gist if pasting more than 5 lines
+;; this module requires https://github.com/defunkt/gist
+;; via ruby: 'gem install gist' via the aur: yay -S gist
+
+(use-package erc-yank
+ :defer 1
+ :after erc
+ :bind
+ (:map erc-mode-map
+ ("C-y" . erc-yank)))
+
+(provide 'erc-config)
+;;; erc-config.el ends here
diff --git a/modules/eshell-vterm-config.el b/modules/eshell-vterm-config.el
new file mode 100644
index 00000000..ba0eba9f
--- /dev/null
+++ b/modules/eshell-vterm-config.el
@@ -0,0 +1,229 @@
+;;; eshell-vterm-config --- Settings for the Emacs Shell -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; ESHELL
+;; - Eshell is useful as a REPL
+;; - Redirect to the kill ring : ls > /dev/kill
+;; - Redirect to the clioboard : ls > /dev/clip
+;; - Redirect to a buffer : ls > #<ls-output>
+;; - Use elisp functions : write your own "detox" command in elisp
+;; : then use it in eshell
+;; - cd to remote directories : cd /sshx:c@cjennings.net:/home/cjennings
+;; : and take all the elisp functionality remotely
+;; : including Dired or Magit on a remote server
+
+;; VTERM
+;; At the moment, vterm behaves like a real terminal. For most keys, vterm will
+;; just send them to the process that is currently running. So, C-a may be
+;; beginning-of-the-line in a shell, or the prefix key in a screen session.
+
+;; If you enter vterm-copy-mode C-c C-t or <pause>, the buffer will become a normal
+;; Emacs buffer. You can then use your navigation keys, select rectangles, etc.
+;; When you press RET, the region will be copied and you'll be back in a working
+;; terminal session.
+
+;; ANSI-TERM & TERM
+;; I haven't yet found a need for term or ansi-term in my workflows, so I leave
+;; them with their default configurations.
+
+;;; Code:
+
+(require 'system-utils)
+
+;; ------------------------------ Eshell -----------------------------
+;; the Emacs shell.
+
+(use-package eshell
+ :ensure nil ;; built-in
+ :defer .5
+ :config
+ (setq eshell-banner-message "")
+ (setq eshell-scroll-to-bottom-on-input 'all)
+ (setq eshell-error-if-no-glob t)
+ (setq eshell-hist-ignoredups t)
+ (setq eshell-save-history-on-exit t)
+ (setq eshell-prefer-lisp-functions nil)
+ (setq eshell-destroy-buffer-when-process-dies t)
+
+ (setq eshell-prompt-function
+ (lambda ()
+ (concat
+ (propertize (format-time-string "[%d-%m-%y %T]") 'face '(:foreground "gray"))
+ " "
+ (propertize (user-login-name) 'face '(:foreground "gray"))
+ " "
+ (propertize (system-name) 'face '(:foreground "gray"))
+ ":"
+ (propertize (abbreviate-file-name (eshell/pwd)) 'face '(:foreground "gray"))
+ "\n"
+ (propertize "%" 'face '(:foreground "white"))
+ " ")))
+
+ (add-hook
+ 'eshell-mode-hook
+ (lambda ()
+ (setq pcomplete-cycle-completions nil)))
+ (setq eshell-cmpl-cycle-completions nil)
+
+ (add-to-list 'eshell-modules-list 'eshell-tramp)
+
+ (add-hook 'eshell-hist-mode-hook
+ (lambda ()
+ (define-key eshell-hist-mode-map (kbd "<up>") 'previous-line)
+ (define-key eshell-hist-mode-map (kbd "<down>") 'next-line)))
+
+ (add-hook 'eshell-mode-hook
+ (lambda ()
+ (add-to-list 'eshell-visual-commands '("lf" "ranger" "tail" "htop" "gotop" "mc" "ncdu" "top"))
+ (add-to-list 'eshell-visual-subcommands '("git" "log" "diff" "show"))
+ (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")
+ (eshell/alias "open" "cj/xdg-open $1")
+ (eshell/alias "gocj" "cd /sshx:cjennings@cjennings.net:/var/cjennings/")
+ (eshell/alias "gosb" "cd /sshx:cjennings@wolf.usbx.me:/home/cjennings/")
+ (eshell/alias "gowolf" "cd /sshx:cjennings@wolf.usbx.me:/home/cjennings/")
+ (eshell/alias "v" "eshell-exec-visual $*")
+ (eshell/alias "ff" "find-file-other-window $1")
+ (eshell/alias "f" "find-using-dired $1")
+ (eshell/alias "r" "ranger")
+ (eshell/alias "ll" "ls -laF"))))
+
+(defun eshell/find-file-other-window (&rest files)
+ "Open FILE(s) in other window from eshell."
+ (if (= 1 (length files))
+ ;; Single file - just use it directly
+ (find-file-other-window (car files))
+ ;; Multiple files - open each in other window
+ (dolist (file files)
+ (find-file-other-window file))))
+
+(defun eshell/find-file (&rest files)
+ "Open FILE(s) from eshell."
+ (if (= 1 (length files))
+ ;; Single file
+ (find-file (car files))
+ ;; Multiple files
+ (dolist (file files)
+ (find-file file))))
+
+(defun eshell/find-using-dired (file-pattern)
+ "Find a file FILE-PATTERN' using 'find-name-dired'."
+ (let ((escaped-pattern (regexp-quote file-pattern)))
+ (find-name-dired . escaped-pattern)))
+
+(defun cj/eshell-delete-window-on-exit ()
+ "Close the eshell window when exiting."
+ (when (not (one-window-p))
+ (delete-window)))
+(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 3)
+ (eshell-toggle-run-command nil)
+ (eshell-toggle-init-function #'eshell-toggle-init-eshell)
+ :bind
+ ("<f12>" . eshell-toggle))
+
+(use-package xterm-color
+ :defer .5
+ :after eshell
+ :hook
+ (eshell-before-prompt-hook . (lambda ()
+ (setq xterm-color-preserve-properties t)))
+ :config
+ (setenv "TERM" "xterm-256color"))
+
+(use-package eshell-syntax-highlighting
+ :after esh-mode
+ :config
+ (eshell-syntax-highlighting-global-mode +1))
+
+(use-package eshell-up
+ :after eshell
+ :config
+ (defalias 'eshell/up 'eshell-up)
+ (defalias 'eshell/up-peek 'eshell-up-peek))
+
+;; Enhance history searching
+(defun cj/eshell-history-search ()
+ "Search eshell history with completion."
+ (interactive)
+ (insert
+ (completing-read "Eshell history: "
+ (delete-dups
+ (ring-elements eshell-history-ring)))))
+
+(add-hook 'eshell-mode-hook
+ (lambda ()
+ (define-key eshell-mode-map (kbd "C-r") 'cj/eshell-history-search)))
+
+;; Better completion for eshell
+(use-package pcmpl-args
+ :after eshell)
+
+;; Company mode integration for eshell
+(use-package company-shell
+ :after (eshell company)
+ :config
+ (add-to-list 'company-backends 'company-shell)
+ (add-hook 'eshell-mode-hook
+ (lambda ()
+ (setq-local company-minimum-prefix-length 2)
+ (setq-local company-idle-delay 2)
+ (company-mode 1))))
+
+
+;; ------------------------------ Vterm ------------------------------
+;; faster and highly dependable, but not extensible
+
+(use-package vterm
+ :defer .5
+ :commands (vterm vterm-other-window)
+ :init
+ (setq vterm-always-compile-module t)
+
+ (defun cj/turn-off-chrome-for-vterm ()
+ (hl-line-mode -1)
+ (display-line-numbers-mode -1))
+
+ :hook (vterm-mode . cj/turn-off-chrome-for-vterm)
+ :bind
+ (:map vterm-mode-map
+ ("<f12>" . nil)
+ ("C-y" . vterm-yank)
+ ("C-p" . vtermf-copy-mode)
+ ("<pause>" . vterm-copy-mode))
+ :custom
+ (vterm-kill-buffer-on-exit t)
+ (vterm-max-scrollback 100000)
+ :config
+ (setq vterm-timer-delay nil))
+
+(use-package vterm-toggle
+ :defer .5
+ :bind
+ ("C-<f12>" . vterm-toggle)
+ :config
+ (setq vterm-toggle-fullscreen-p nil)
+ (add-to-list 'display-buffer-alist
+ '((lambda (buffer-or-name _)
+ (let ((buffer (get-buffer buffer-or-name)))
+ (with-current-buffer buffer
+ (or (equal major-mode 'vterm-mode)
+ (string-prefix-p vterm-buffer-name (buffer-name buffer))))))
+ (display-buffer-reuse-window display-buffer-at-bottom)
+ (dedicated . t) ;dedicated is supported in Emacs 27+
+ (reusable-frames . visible)
+ (window-height . 0.25))))
+
+(provide 'eshell-vterm-config)
+;;; eshell-vterm-config.el ends here.
diff --git a/modules/eww-config.el b/modules/eww-config.el
new file mode 100644
index 00000000..7d990f3a
--- /dev/null
+++ b/modules/eww-config.el
@@ -0,0 +1,153 @@
+;;; eww-config --- EWW Text Browser Settings -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; This module provides a minimal, privacy-focused browsing experience with:
+;; - Simplified navigation keybindings (< and > for back/forward)
+;; - Quick URL copying to clipboard
+;; - Image toggle functionality
+;; - Privacy-conscious defaults (no tracking info sent)
+;; - Alternative search engine (Frog Find for simplified web pages)
+;;
+;; Key features:
+;; - `M-E' to launch EWW
+;; - `u' to copy current URL
+;; - `i' to toggle images
+;; - `o' to open link in new buffer
+;;
+;; The configuration tries to prioritize text-based browsing and minimal distractions.
+;;
+;;; Code:
+
+;; ----------------------- EWW-Only User-Agent Injection -----------------------
+
+(require 'cl-lib)
+
+(defgroup my-eww-user-agent nil
+ "EWW-only User-Agent management."
+ :group 'eww)
+
+(defcustom my-eww-user-agent
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0"
+ "User-Agent string to send for EWW requests only."
+ :type 'string
+ :group 'my-eww-user-agent)
+
+(defun my-eww--inject-user-agent (orig-fun &rest args)
+ "Set a User-Agent only when making requests from an EWW buffer."
+ (if (derived-mode-p 'eww-mode)
+ (let* ((ua my-eww-user-agent)
+ ;; Remove any existing UA header, then add ours.
+ (url-request-extra-headers
+ (cons (cons "User-Agent" ua)
+ (cl-remove-if (lambda (h)
+ (and (consp h)
+ (stringp (car h))
+ (string-equal (car h) "User-Agent")))
+ url-request-extra-headers))))
+ (apply orig-fun args))
+ (apply orig-fun args)))
+
+(with-eval-after-load 'url
+ ;; Cover both async and sync fetches used by eww/shr (pages, images, etc.).
+ (advice-add 'url-retrieve :around #'my-eww--inject-user-agent)
+ (advice-add 'url-retrieve-synchronously :around #'my-eww--inject-user-agent))
+
+;; --------------------------------- EWW Config --------------------------------
+
+(use-package eww
+ :ensure nil ;; built-in
+ :bind
+ (("M-E" . eww)
+ :map eww-mode-map
+ ("<" . eww-back-url)
+ (">" . eww-forward-url)
+ ("i" . eww-toggle-images)
+ ("u" . cj/eww-copy-url)
+ ("b" . cj/eww-bookmark-quick-add)
+ ("B" . eww-list-bookmarks)
+ ("/" . cj/eww-switch-search-engine)
+ ("&" . cj/eww-open-in-external)
+ ("o" . eww-open-in-new-buffer)
+ ("r" . eww-readable))
+
+ :config
+ ;; Define search engines
+ (defvar cj/eww-search-engines
+ '(("frog" . "http://frogfind.com/?q=")
+ ("ddg" . "https://duckduckgo.com/html?q=")
+ ("searx" . "https://searx.be/search?q="))
+ "List of search engines for EWW.")
+
+ (defvar cj/eww-current-search-engine "frog"
+ "Currently selected search engine.")
+
+ ;; Function definitions
+ (defun cj/eww-switch-search-engine ()
+ "Switch between different search engines."
+ (interactive)
+ (let* ((engine (completing-read "Search engine: "
+ (mapcar #'car cj/eww-search-engines)
+ nil t nil nil cj/eww-current-search-engine))
+ (url (cdr (assoc engine cj/eww-search-engines))))
+ (when url
+ (setq eww-search-prefix url)
+ (setq cj/eww-current-search-engine engine)
+ (message "Search engine set to: %s" engine))))
+
+ (defun cj/eww-open-in-external ()
+ "Open current URL in external browser."
+ (interactive)
+ (unless (derived-mode-p 'eww-mode)
+ (user-error "Not in EWW buffer"))
+ (if-let ((url (plist-get eww-data :url)))
+ (browse-url-xdg-open url)
+ (user-error "No URL to open")))
+
+ (defun cj/eww-bookmark-quick-add ()
+ "Quickly bookmark current page with minimal prompting."
+ (interactive)
+ (unless (derived-mode-p 'eww-mode)
+ (user-error "Not in EWW buffer"))
+ (when-let ((title (plist-get eww-data :title)))
+ (let ((eww-bookmarks-directory (expand-file-name "eww-bookmarks" user-emacs-directory)))
+ (unless (file-exists-p eww-bookmarks-directory)
+ (make-directory eww-bookmarks-directory t))
+ (eww-add-bookmark)
+ (message "Bookmarked: %s" title))))
+
+ (defun cj/eww-copy-url ()
+ "Copy the current EWW URL to clipboard."
+ (interactive)
+ (unless (derived-mode-p 'eww-mode)
+ (user-error "Not in EWW buffer"))
+ (if-let ((current-url (plist-get eww-data :url)))
+ (progn
+ (kill-new current-url)
+ (message "URL copied: %s" current-url))
+ (message "No URL to copy")))
+
+ (defun cj/eww-clear-cookies ()
+ "Clear all cookies."
+ (interactive)
+ (setq url-cookie-storage nil)
+ (when (and url-cookie-file (file-exists-p url-cookie-file))
+ (delete-file url-cookie-file))
+ (message "Cookies cleared"))
+
+ ;; Configuration settings
+ (setq shr-use-colors nil)
+ (setq shr-bullet "• ")
+ (setq eww-search-prefix (cdr (assoc cj/eww-current-search-engine cj/eww-search-engines)))
+ (setq url-cookie-file (expand-file-name "~/.local/share/cookies.txt"))
+ ;; sets the user-agent for everything (e.g., package.el)
+ ;;(setq url-user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0")
+ (setq url-privacy-level '(email lastloc))
+ (setq shr-inhibit-images t)
+ (setq shr-use-fonts nil)
+ (setq shr-max-image-proportion 0.2)
+ (setq eww-retrieve-command nil))
+
+(provide 'eww-config)
+;;; eww-config.el ends here
diff --git a/modules/external-open.el b/modules/external-open.el
new file mode 100644
index 00000000..0fe6be64
--- /dev/null
+++ b/modules/external-open.el
@@ -0,0 +1,129 @@
+;;; external-open.el --- Open Files Using Default OS Handler -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; This library provides a simple mechanism for opening files with specific
+;; extensions using your operating system’s default application rather than
+;; visiting them in an Emacs buffer. It offers:
+;;
+;; • A simple method to run a command on the current buffer's file
+;; "C-c x o" bound to cj/open-this-file-with
+;; • A customizable list =default-open-extensions= of file‐type suffixes
+;; (e.g. “pdf”, “docx”, “png”) that should be handled externally.
+;; • A function =default-open-file= (and its helper commands) which will
+;; launch the matching file in the OS’s default MIME handler.
+;; • Integration with =find-file-hook= so that any file whose extension
+;; appears in =default-open-extensions= is automatically opened externally
+;; upon visit.
+;; • Optional interactive commands for manually invoking an external open on
+;; point or on a user-chosen file.
+;;
+;;; Code:
+
+(require 'system-utils) ;; for xdg-open and others
+(require 'host-environment) ;; environment information functions
+(require 'cl-lib)
+
+(defgroup external-open nil
+ "Open certain files with the OS default handler."
+ :group 'files)
+
+(defcustom default-open-extensions
+ '(
+ ;; Video
+ "\\.3g2\\'" "\\.3gp\\'" "\\.asf\\'" "\\.avi\\'" "\\.divx\\'" "\\.dv\\'"
+ "\\.f4v\\'" "\\.flv\\'" "\\.m1v\\'" "\\.m2ts\\'" "\\.m2v\\'" "\\.m4v\\'"
+ "\\.mkv\\'" "\\.mov\\'" "\\.mpe\\'" "\\.mpeg\\'" "\\.mpg\\'" "\\.mp4\\'"
+ "\\.mts\\'" "\\.ogv\\'" "\\.rm\\'" "\\.rmvb\\'" "\\.ts\\'" "\\.vob\\'"
+ "\\.webm\\'" "\\.wmv\\'"
+
+ ;; Audio
+ "\\.aac\\'" "\\.ac3\\'" "\\.aif\\'" "\\.aifc\\'" "\\.aiff\\'"
+ "\\.alac\\'" "\\.amr\\'" "\\.ape\\'" "\\.caf\\'"
+ "\\.dff\\'" "\\.dsf\\'" "\\.flac\\'" "\\.m4a\\'" "\\.mka\\'"
+ "\\.mid\\'" "\\.midi\\'" "\\.mp2\\'" "\\.mp3\\'" "\\.oga\\'"
+ "\\.ogg\\'" "\\.opus\\'" "\\.ra\\'" "\\.spx\\'" "\\.wav\\'"
+ "\\.wave\\'" "\\.weba\\'" "\\.wma\\'"
+
+ ;; Microsoft Word
+ "\\.docx?\\'" "\\.docm\\'"
+ "\\.dotx?\\'" "\\.dotm\\'"
+ "\\.rtf\\'"
+
+ ;; Microsoft Excel
+ "\\.xlsx?\\'" "\\.xlsm\\'" "\\.xlsb\\'"
+ "\\.xltx?\\'" "\\.xltm\\'"
+
+ ;; Microsoft PowerPoint
+ "\\.pptx?\\'" "\\.pptm\\'"
+ "\\.ppsx?\\'" "\\.ppsm\\'"
+ "\\.potx?\\'" "\\.potm\\'"
+
+ ;; Microsoft OneNote / Visio / Project / Access / Publisher
+ "\\.one\\'" "\\.onepkg\\'" "\\.onetoc2\\'"
+ "\\.vsdx?\\'" "\\.vsdm\\'" "\\.vstx?\\'" "\\.vstm\\'" "\\.vssx?\\'" "\\.vssm\\'"
+ "\\.mpp\\'" "\\.mpt\\'"
+ "\\.mdb\\'" "\\.accdb\\'" "\\.accde\\'" "\\.accdr\\'" "\\.accdt\\'"
+ "\\.pub\\'"
+
+ ;; OpenDocument (LibreOffice/OpenOffice)
+ "\\.odt\\'" "\\.ott\\'"
+ "\\.ods\\'" "\\.ots\\'"
+ "\\.odp\\'" "\\.otp\\'"
+ "\\.odg\\'" "\\.otg\\'"
+ "\\.odm\\'" "\\.odf\\'"
+ ;; Flat OpenDocument variants
+ "\\.fodt\\'" "\\.fods\\'" "\\.fodp\\'"
+
+ ;; Apple iWork
+ "\\.pages\\'" "\\.numbers\\'" "\\.key\\'"
+
+ ;; Microsoft’s fixed-layout formats
+ "\\.xps\\'" "\\.oxps\\'"
+ )
+ "Regexps matching file extensions that should be opened externally."
+ :type '(repeat (regexp :tag "File extension regexp"))
+ :group 'external-open)
+
+;; ------------------------------- Open File With ------------------------------
+;; TASK: Add this to buffer custom functions
+
+(defun cj/open-this-file-with (command)
+ "Open this buffer's file with COMMAND, detached from Emacs."
+ (interactive "MOpen with program: ")
+ (unless buffer-file-name
+ (user-error "Current buffer is not visiting a file"))
+ (let ((file (expand-file-name buffer-file-name)))
+ (cond
+ ;; Windows: launch via ShellExecute so the child isn't tied to Emacs.
+ ((env-windows-p)
+ (w32-shell-execute "open" command (format "\"%s\"" file)))
+ ;; POSIX: disown with nohup + background. No child remains.
+ (t
+ (call-process-shell-command
+ (format "nohup %s %s >/dev/null 2>&1 &"
+ command (shell-quote-argument file))
+ nil 0)))))
+
+(global-set-key (kbd "C-c x o") #'cj/open-this-file-with)
+
+;; -------------------- Open Files With Default File Handler -------------------
+
+(defun cj/find-file-auto (orig-fun &rest args)
+ "If file has an extension in `default-open-extensions', open externally.
+Else call ORIG-FUN with ARGS."
+ (let* ((file (car args))
+ (case-fold-search t))
+ (if (and (stringp file)
+ (cl-some (lambda (re) (string-match-p re file))
+ default-open-extensions))
+ (cj/xdg-open file)
+ (apply orig-fun args))))
+
+;; Make advice idempotent if you reevaluate this form.
+(advice-remove 'find-file #'cj/find-file-auto)
+(advice-add 'find-file :around #'cj/find-file-auto)
+
+(provide 'external-open)
+;;; external-open.el ends here.
diff --git a/modules/flycheck-config.el b/modules/flycheck-config.el
new file mode 100644
index 00000000..f14d94ba
--- /dev/null
+++ b/modules/flycheck-config.el
@@ -0,0 +1,99 @@
+;;; flycheck-config --- Syntax/Grammar Check -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; 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-; ?”
+;; 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 registers a Proselint checker for prose files
+;; (text-mode, markdown-mode, gfm-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.
+
+;;
+;; 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.
+
+;; OS Dependencies:
+;; proselint (in the Arch AUR)
+
+;;; Code:
+
+(defun cj/prose-helpers-on ()
+ "Ensure that abbrev, flyspell, and flycheck are all on."
+ (interactive)
+ (if (not (abbrev-mode))
+ (abbrev-mode))
+ ;; (flyspell-on-for-buffer-type)
+ (if (not (flycheck-mode))
+ (flycheck-mode)))
+
+;; ---------------------------------- Linting ----------------------------------
+
+(use-package flycheck
+ :after custom-functions ;; for keymap binding
+ :defer t
+ :commands (flycheck-list-errors
+ cj/flycheck-list-errors)
+ :hook ((sh-mode emacs-lisp-mode) . flycheck-mode)
+ :bind
+ (:map cj/custom-keymap
+ ("?" . cj/flycheck-list-errors))
+ :custom
+ ;; Only disable these two Checkdoc warnings; leave all others intact.
+ (checkdoc-arguments
+ '(("sentence-end-double-space" nil)
+ ("warn-escape" nil)))
+ :config
+
+ ;; 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)
+ :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)
+
+ (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)
+ (cj/flycheck-prose-on-demand))
+
+ (flycheck-list-errors)
+ (switch-to-buffer-other-window "*Flycheck errors*"))
+
+ (defun cj/flycheck-prose-on-demand ()
+ "Enable Flycheck+Proselint 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)
+ ;; trigger immediate check
+ (flycheck-buffer)))
+
+(provide 'flycheck-config)
+;;; flycheck-config.el ends here
diff --git a/modules/flyspell-and-abbrev.el b/modules/flyspell-and-abbrev.el
new file mode 100644
index 00000000..08b96036
--- /dev/null
+++ b/modules/flyspell-and-abbrev.el
@@ -0,0 +1,211 @@
+;;; flyspell-and-abbrev.el --- Spell Check Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; WORKFLOW:
+;; C-' is now my main interface for all spell checking.
+;;
+;; The workflow is that it finds the nearest misspelled word above where the
+;; cursor is, allows for saving or correcting, then stops. You may proceed to
+;; the next misspelling by selecting C-' again.
+;;
+;; Use M-o to get to 'other options', like saving to your personal dictionary.
+;;
+;; Flyspell will automatically run in a mode appropriate for the buffer type
+;; - if it's a programming mode, it will only check comments
+;; - if in text mode, it will check everything
+;; - otherwise it will turn off.
+;; This check happens on every mode switch.
+;;
+;; If you want flyspell on in another mode (say fundamental mode), or you want
+;; to turn it off, you can toggle flyspell's state with 'C-c f'
+;;
+;; The nicest thing is that each spell correction creates an abbrev. This
+;; essentially is a shortcut that expands that same misspelling to the correct
+;; spelling the next time it's typed. That idea comes courtesy Artur Malabarba,
+;; and it's increased my overall typing speed.
+;;
+;; Original idea here:
+;; http://endlessparentheses.com/ispell-and-abbrev-the-perfect-auto-correct.html
+;;
+;; The code below is my refactoring of Artur Malabarba's code, and using
+;; flyspell rather than ispell.
+;;
+;; NOTES:
+;;
+;; FYI, the keybinding typically taken for the flyspell-mode-map "C-;" has
+;; been deliberately hijacked in custom-functions.el for my personal-keymap.
+;; This is the code run there:
+
+;; (eval-after-load "flyspell"
+;; '(define-key flyspell-mode-map (kbd "C-;") nil))
+
+;;; Code:
+
+;; ----------------------------------- Abbrev ----------------------------------
+
+(use-package abbrev-mode
+ :ensure nil
+ :defer 0.5
+ :custom
+ (abbrev-file-name (concat user-emacs-directory "assets/abbrev_defs"))
+ :config
+ (abbrev-mode 1))
+
+;; ---------------------------- Ispell And Flyspell ----------------------------
+
+(use-package ispell
+ :defer .5
+ :ensure nil ;; built-in
+ :config
+ ;; (setopt ispell-alternate-dictionary
+ ;; (concat user-emacs-directory "assets/english-words.txt"))
+ (setopt text-mode-ispell-word-completion nil)
+ (setopt ispell-alternate-dictionary nil)
+
+ (setq ispell-dictionary "american") ; better for aspell
+ ;; use aspell rather than ispell
+ (setq ispell-program-name "aspell")
+ ;; aspell is in /usr/local/ on BSD
+ (cond ((eq system-type 'berkeley-unix)
+ (setq ispell-program-name "/usr/local/bin/aspell")))
+
+ ;; in aspell "-l" means --list, not --lang
+ (setq ispell-list-command "--list")
+ (setq ispell-extra-args '("--sug-mode=ultra" "-W" "3" "--lang=en_US"))
+ (setq ispell-local-dictionary "en_US")
+ (setq ispell-local-dictionary-alist
+ '(("en_US" "[[:alpha:]]" "[^[:alpha:]]" "['‘’]"
+ t ;; Many other characters
+ ("-d" "en_US") nil utf-8)))
+ ;; personal directory goes with sync'd files
+ (setq ispell-personal-dictionary
+ (concat sync-dir "aspell-personal-dictionary"))
+ ;; skip code blocks in org mode
+ (add-to-list 'ispell-skip-region-alist '("^#+BEGIN_SRC" . "^#+END_SRC")))
+
+(use-package flyspell
+ :after (ispell abbrev)
+ :ensure nil ;; built-in
+ :config
+ ;; don't print message for every word when checking
+ (setq flyspell-issue-message-flag nil))
+
+(use-package flyspell-correct
+ :after flyspell
+ :defer .5)
+
+;; ------------------------------ Flyspell Toggle ------------------------------
+;; easy toggling flyspell and also leverage the 'for-buffer-type' functionality.
+
+;; (defun flyspell-toggle ()
+;; "Turn Flyspell on if it is off, or off if it is on.
+
+;; When turning on,it uses `flyspell-on-for-buffer-type' so code-vs-text is
+;; handled appropriately."
+;; (interactive)
+;; (if (symbol-value flyspell-mode)
+;; (progn ; flyspell is on, turn it off
+;; (message "Flyspell off")
+;; (flyspell-mode -1))
+;; ;; else - flyspell is off, turn it on
+;; (progn
+;; (flyspell-on-for-buffer-type)
+;; (message "Flyspell on"))))
+;; (define-key global-map (kbd "C-c f") 'flyspell-toggle )
+
+;; ------------------------ Flyspell On For Buffer Type ------------------------
+;; check strings and comments in prog mode; check everything in text mode
+
+;; (defun flyspell-on-for-buffer-type ()
+;; "Enable Flyspell for the major mode and check the current buffer.
+
+;; If flyspell is already enabled, do nothing. If the mode is derived from
+;; `prog-mode', enable `flyspell-prog-mode' so only strings and comments get
+;; checked. If the buffer is text based `flyspell-mode' is enabled to check
+;; all text."
+;; (interactive)
+;; (unless flyspell-mode ; if not already on
+;; (cond
+;; ((derived-mode-p 'prog-mode)
+;; (flyspell-prog-mode)
+;; (flyspell-buffer)
+;; ((derived-mode-p 'text-mode)
+;; (flyspell-mode 1)
+;; (flyspell-buffer))))))
+
+;; (add-hook 'after-change-major-mode-hook 'flyspell-on-for-buffer-type)
+;; (add-hook 'find-file-hook 'flyspell-on-for-buffer-type)
+
+;; ---------------------------- Flyspell Then Abbrev ---------------------------
+;; Spell check the buffer and create abbrevs to avoid future misspellings.
+
+(setq-default abbrev-mode t)
+
+(defun cj/find-previous-flyspell-overlay (position)
+ "Locate the Flyspell overlay immediately previous to a given POSITION."
+ ;; sort the overlays into position order
+ (let ((overlay-list (sort (overlays-in (point-min) position)
+ (lambda (a b)
+ (> (overlay-start a) (overlay-start b))))))
+ ;; search for previous flyspell overlay
+ (while (and overlay-list
+ (or (not (flyspell-overlay-p (car overlay-list)))
+ ;; check if its face has changed
+ (not (eq (get-char-property
+ (overlay-start (car overlay-list)) 'face)
+ 'flyspell-incorrect))))
+ (setq overlay-list (cdr overlay-list)))
+ ;; if no previous overlay exists, return nil
+ (when overlay-list
+ ;; otherwise, return the overlay start position
+ (overlay-start (car overlay-list)))))
+
+
+(defun cj/flyspell-goto-previous-misspelling (position)
+ "Go to the first misspelled word before the given POSITION.
+Return the misspelled word if found or nil if not. Leave the point at the
+beginning of the misspelled word. Setting the hook on pre-command ensures that
+any started Flyspell corrections complete before running other commands in the
+buffer."
+ (interactive "d")
+ (add-hook 'pre-command-hook
+ (function flyspell-auto-correct-previous-hook) t t)
+ (let* ((overlay-position (cj/find-previous-flyspell-overlay position))
+ (misspelled-word (when overlay-position
+ (goto-char overlay-position)
+ (thing-at-point 'word))))
+ (if misspelled-word
+ (downcase misspelled-word)
+ nil)))
+
+(defun cj/flyspell-then-abbrev (p)
+ "Call \='flyspell-correct-at-point\=' and create abbrev for future corrections.
+The abbrev is created in the local dictionary unless the prefix P
+argument is provided, when it's created in the global dictionary."
+ (interactive "P")
+ (unless (featurep 'files)
+ (require 'files))
+ (setq save-abbrevs 'silently)
+ (flyspell-buffer)
+ (save-excursion
+ (let (misspelled-word corrected-word)
+ (while (setq misspelled-word
+ (cj/flyspell-goto-previous-misspelling (point)))
+ (call-interactively 'flyspell-correct-at-point)
+ (setq corrected-word (downcase (or (thing-at-point 'word) "")))
+ (when (and misspelled-word corrected-word
+ (not (string= corrected-word misspelled-word)))
+ (message "\"%s\" now expands to \"%s\" %sally"
+ misspelled-word corrected-word (if p "loc" "glob"))
+ (define-abbrev
+ (if p local-abbrev-table global-abbrev-table)
+ misspelled-word corrected-word))
+ (goto-char (point-min))))
+ (message "Spell check complete.")))
+
+(define-key global-map (kbd "C-'") 'cj/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
new file mode 100644
index 00000000..ac67d6e0
--- /dev/null
+++ b/modules/font-config.el
@@ -0,0 +1,283 @@
+;;; font-config --- Font Defaults and Related Functionality -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; This module provides font configuration, including:
+;;
+;; 1. Font Management:
+;; - Dynamic font preset switching via `fontaine' package
+;; - Separate configurations for fixed-pitch and variable-pitch fonts
+;; - Multiple size presets for different viewing contexts
+;; - Per-frame font configuration tracking for daemon mode compatibility
+;;
+;; 2. Icon Support:
+;; - All-the-icons integration with automatic font installation
+;; - Nerd fonts support for enhanced icons in terminals and GUI
+;; - Platform-specific emoji font configuration (Noto, Apple, Segoe)
+;; - Emojify package for emoji rendering and insertion
+;;
+;; 3. Typography Enhancements:
+;; - Programming ligatures via `ligature' package
+;; - Mode-specific ligature rules for markdown and programming
+;; - Text scaling keybindings for quick size adjustments
+;;
+;; 4. Utility Functions:
+;; - `cj/font-installed-p': Check font availability
+;; - `cj/display-available-fonts': Interactive font browser with samples
+;; - Frame-aware font application for client/server setups
+;;
+;; Configuration Notes:
+;; - Default font: FiraCode Nerd Font Mono at 110 height
+;; - Variable pitch: Merriweather Light for prose-heavy modes
+;; - Handles both standalone and daemon mode Emacs instances
+;; - Emoji fonts selected based on OS availability
+;;
+;; Keybindings:
+;; - M-F: Select font preset
+;; - C-z F: Display available fonts
+;; - C-+/C-=: Increase text scale
+;; - C--/C-_: Decrease text scale
+;;
+;;
+;;; Code:
+
+;; ----------------------- Font Family And Size Selection ----------------------
+;; preset your fixed and variable fonts, then apply them to text as a set
+
+(use-package fontaine
+ :demand t
+ :bind
+ ("M-F" . fontaine-set-preset)
+ :config
+ (setq fontaine-presets
+ '(
+ (default
+ :default-family "FiraCode Nerd Font 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-height 1.0)
+ (Hack
+ :default-family "Hack Nerd Font Mono"
+ :variable-pitch-family "Hack Nerd Font Mono")
+ (FiraCode-Literata
+ :default-family "Fira Code Nerd Font"
+ :variable-pitch-family "Literata")
+ (Merriweather
+ :default-family "Merriweather"
+ :variable-pitch-family "Merriweather")
+ (24-point-font
+ :default-height 240)
+ (20-point-font
+ :default-height 200)
+ (16-point-font
+ :default-height 160)
+ (14-point-font
+ :default-height 140)
+ (13-point-font
+ :default-height 130)
+ (12-point-font
+ :default-height 120)
+ (11-point-font
+ :default-height 110)
+ (10-point-font
+ :default-height 100)
+ (t ;; shared fallback properties go here
+ :default-family "FiraCode Nerd Font 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
+ :fixed-pitch-serif-family nil ;; falls back to :default-family
+ :fixed-pitch-serif-weight nil ;; falls back to :default-weight
+ :fixed-pitch-serif-height 1.0
+ :variable-pitch-family "Merriweather"
+ :variable-pitch-weight light
+ :variable-pitch-height 1.0
+ :bold-family nil ;; use whatever the underlying face has
+ :bold-weight bold
+ :italic-family nil
+ :italic-slant italic
+ :line-spacing nil))))
+
+(with-eval-after-load 'fontaine
+ ;; Track which frames have had fonts applied
+ (defvar cj/fontaine-configured-frames nil
+ "List of frames that have had fontaine configuration applied.")
+
+ (defun cj/apply-font-settings-to-frame (&optional frame)
+ "Apply font settings to FRAME if not already configured.
+
+If FRAME is nil, uses the selected frame."
+ (let ((target-frame (or frame (selected-frame))))
+ (unless (member target-frame cj/fontaine-configured-frames)
+ (with-selected-frame target-frame
+ (when (display-graphic-p target-frame)
+ (fontaine-set-preset 'default)
+ (push target-frame cj/fontaine-configured-frames))))))
+
+ (defun cj/cleanup-frame-list (frame)
+ "Remove FRAME from the configured frames list when deleted."
+ (setq cj/fontaine-configured-frames
+ (delq frame cj/fontaine-configured-frames)))
+
+ ;; Handle daemon mode and regular mode
+ (if (daemonp)
+ (progn
+ ;; Apply to each new frame in daemon mode
+ (add-hook 'server-after-make-frame-hook #'cj/apply-font-settings-to-frame)
+ ;; Clean up deleted frames from tracking list
+ (add-hook 'delete-frame-functions #'cj/cleanup-frame-list))
+ ;; Apply immediately in non-daemon mode
+ (when (display-graphic-p)
+ (cj/apply-font-settings-to-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))
+ t
+ nil))
+
+;; ------------------------------- All The Icons -------------------------------
+;; icons made available through fonts
+
+(use-package all-the-icons
+ :demand t
+ :config
+ ;; Check for font installation after frame creation
+ (defun cj/maybe-install-all-the-icons-fonts (&optional _frame)
+ "Install all-the-icons fonts if needed and we have a GUI."
+ (when (and (display-graphic-p)
+ (not (cj/font-installed-p "all-the-icons")))
+ (all-the-icons-install-fonts t)
+ ;; Remove this hook after successful installation
+ (remove-hook 'server-after-make-frame-hook #'cj/maybe-install-all-the-icons-fonts)))
+
+ ;; Handle both daemon and non-daemon modes
+ (if (daemonp)
+ (add-hook 'server-after-make-frame-hook #'cj/maybe-install-all-the-icons-fonts)
+ (cj/maybe-install-all-the-icons-fonts)))
+
+(use-package all-the-icons-nerd-fonts
+ :after all-the-icons
+ :demand t
+ :config
+ (all-the-icons-nerd-fonts-prefer))
+
+;; ----------------------------- Emoji Fonts Per OS ----------------------------
+;; Set emoji fonts in priority order (first found wins)
+
+(cond
+ ;; Prefer Noto Color Emoji (Linux)
+ ((member "Noto Color Emoji" (font-family-list))
+ (set-fontset-font t 'symbol (font-spec :family "Noto Color Emoji") nil 'prepend))
+ ;; Then Apple Color Emoji (macOS)
+ ((member "Apple Color Emoji" (font-family-list))
+ (set-fontset-font t 'symbol (font-spec :family "Apple Color Emoji") nil 'prepend))
+ ;; Finally Segoe UI Emoji (Windows)
+ ((member "Segoe UI Emoji" (font-family-list))
+ (set-fontset-font t 'symbol (font-spec :family "Segoe UI Emoji") nil 'prepend)))
+
+;; ---------------------------------- Emojify ----------------------------------
+;; converts emoji identifiers into emojis; allows for easy emoji entry.
+
+(use-package emojify
+ :defer 1
+ :hook ((erc-mode . emojify-mode)
+ (org-mode . emojify-mode))
+ :custom
+ (emojify-download-emojis-p t) ;; don't ask, just download emojis
+ :bind
+ ("C-c E i" . emojify-insert-emoji) ;; emoji insert
+ ("C-c E l" . emojify-list-emojis) ;; emoji list
+ :config
+ (setq emojify-show-help nil)
+ (setq emojify-point-entered-behaviour 'uncover)
+ (setq emojify-display-style 'image)
+ (setq emojify-emoji-styles '(ascii unicode github))
+
+ ;; Disable emojify in programming and gptel modes
+ (defun cj/disable-emojify-mode ()
+ "Disable emojify-mode in the current buffer."
+ (emojify-mode -1))
+
+ (add-hook 'prog-mode-hook #'cj/disable-emojify-mode)
+ (add-hook 'gptel-mode-hook #'cj/disable-emojify-mode))
+
+;; -------------------------- 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)
+ (pop-to-buffer "*Available Fonts*"
+ '(display-buffer-in-side-window . ((side . right)(window-width . fit-window-to-buffer))))
+ (let ((font-list (font-family-list)))
+ (setq font-list (cl-remove-duplicates (cl-sort font-list 'string-lessp :key 'downcase)))
+ (with-current-buffer "*Available Fonts*"
+ (erase-buffer)
+ (dolist (font-family font-list)
+ (insert (propertize (concat font-family) 'face `((:foreground "Light Blue" :weight bold))))
+ (insert (concat "\n"(propertize "Regular: ")))
+ (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?")
+ 'face `((:family, font-family))))
+ (insert (concat "\n" (propertize "Bold: ")))
+ (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?")
+ 'face `((:family, font-family :weight bold))))
+ (insert (concat "\n" (propertize "Italic: ")))
+ (insert (propertize (concat "The quick brown fox jumps over the lazy dog I 1 l ! : ; . , 0 O o [ { ( ) } ] ?")
+ 'face `((:family, font-family :slant italic))))
+ (insert (concat "\n\n"))))
+ (move-to-window-line 0)
+ (special-mode)))
+
+(global-set-key (kbd "C-z F") 'cj/display-available-fonts)
+
+;; ----------------------- Increase / Decrease Font Size -----------------------
+;; make it easy to enlarge or shrink font sizes with keybindings
+
+(setq text-scale-mode-step 1.08)
+(global-set-key (kbd "C-+") 'text-scale-increase)
+(global-set-key (kbd "C-=") 'text-scale-increase)
+(global-set-key (kbd "C-_") 'text-scale-decrease)
+(global-set-key (kbd "C--") 'text-scale-decrease)
+
+;; --------------------------------- Ligatures ---------------------------------
+;; fancy programming glyphs make code easier to read
+
+(use-package ligature
+ :defer 1
+ :config
+ ;; Enable the www ligature in every possible major mode
+ (ligature-set-ligatures 't '("www"))
+ ;; Enable traditional ligature support in eww, if `variable-pitch' face supports it
+ (ligature-set-ligatures 'eww-mode '("ff" "fi" "ffi"))
+ ;; Enable ligatures in markdown mode
+ (ligature-set-ligatures 'markdown-mode '(("=" (rx (+ "=") (? (| ">" "<"))))
+ ("-" (rx (+ "-")))))
+ ;; Enable ligatures in programming modes
+ (ligature-set-ligatures 'prog-mode '("www" "**" "***" "**/" "*>" "*/" "\\\\" "\\\\\\" "{-" "::"
+ ":::" ":=" "!!" "!=" "!==" "-}" "----" "-->" "->" "->>"
+ "-<" "-<<" "-~" "#{" "#[" "##" "###" "####" "#(" "#?" "#_"
+ "#_(" ".-" ".=" ".." "..<" "..." "?=" "??" ";;" "/*" "/**"
+ "/=" "/==" "/>" "//" "///" "&&" "||" "||=" "|=" "|>" "^=" "$>"
+ "++" "+++" "+>" "=:=" "==" "===" "==>" "=>" "=>>" "<="
+ "=<<" "=/=" ">-" ">=" ">=>" ">>" ">>-" ">>=" ">>>" "<*"
+ "<*>" "<|" "<|>" "<$" "<$>" "<!--" "<-" "<--" "<->" "<+"
+ "<+>" "<=" "<==" "<=>" "<=<" "<>" "<<" "<<-" "<<=" "<<<"
+ "<~" "<~~" "</" "</>" "~@" "~-" "~>" "~~" "~~>" "%%"))
+ (global-ligature-mode t))
+
+(provide 'font-config)
+;;; font-config.el ends here
diff --git a/modules/games-config.el b/modules/games-config.el
new file mode 100644
index 00000000..7e2bebd6
--- /dev/null
+++ b/modules/games-config.el
@@ -0,0 +1,60 @@
+;;; games-config.el --- emacs games -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; The games menu is the easiest entry. "Shift-Alt-G" will get you there. Enjoy!
+;;
+
+;;; Code:
+
+
+;; ----------------------------------- Malyon ----------------------------------
+;; text based adventure player
+
+(use-package malyon
+ :defer 1
+ :config
+ (setq malyon-stories-directory (concat sync-dir "text.games/")))
+
+;; ------------------------------------ 2048 -----------------------------------
+;; combine numbered tiles to create the elusive number 2048.
+(use-package 2048-game
+ :defer 1)
+
+;; ----------------------------------- Chess -----------------------------------
+;; play the 64 squares and checkmate the opponent's king
+;; (use-package chess
+;; :defer 1
+;; :config
+;; (setq chess-default-display 'chess-images)
+;; (setq chess-images-directory
+;; (concat user-emacs-directory "assets/chess/pieces/xboard/"))
+;; (setq chess-images-dark-color "#779556")
+;; (setq chess-images-light-color "#EBECD0")
+;; (setq chess-images-default-size 100)
+;; (setq chess-full-name user-whole-name)
+;; (setq chess-default-engine 'chess-fruit))
+
+
+;; Notes from source code
+;; If you'd like to view or edit Portable Game Notation (PGN) files,
+;; `chess-pgn-mode' provides a text-mode derived mode which can display the
+;; chess position at point.
+
+;; To improve your chess ability, `M-x chess-tutorial' provides a simple knight
+;; movement exercise to get you started, and `M-x chess-puzzle' can be used
+;; to solve puzzle collections in EPD or PGN format.
+;; The variable `chess-default-display' controls which display modules
+;; are tried when a chessboard should be displayed. By default, chess-images
+;; is tried first. If Emacs is not running in a graphical environment,
+;; chess-ics1 is used instead. To enable the chess-plain display module,
+;; customize `chess-default-display' accordingly.
+
+;; Once this is working, the next thing to do is to customize
+;; `chess-default-modules'. This is a list of functionality modules used
+;; by chess.el to provide additional functionality. You can enable or
+;; disable modules so that Emacs Chess better suites your tastes.
+;; Those modules in turn often have configuration variables, and
+
+(provide 'games-config)
+;;; games-config.el ends here.
diff --git a/modules/help-config.el b/modules/help-config.el
new file mode 100644
index 00000000..6ef9b434
--- /dev/null
+++ b/modules/help-config.el
@@ -0,0 +1,108 @@
+;;; help-config --- Help Functionality Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; This module enhances Emacs' built-in help system and documentation features.
+;; It configures:
+;;
+;; 1. Helpful - A better help buffer that provides context, examples, and source code
+;; 2. Man - Man page viewing integration
+;; 3. Info - Enhanced Info mode with custom keybindings and directory configuration
+;;
+;; The configuration prioritizes discoverability and improves the experience of
+;; reading documentation within Emacs. Custom keybindings maintain the C-h prefix
+;; convention for help-related commands.
+
+;;; Code:
+
+
+(setq help-window-select t) ;; Always select the help buffer in a separate window
+
+(global-set-key (kbd "C-h P") 'list-packages) ;; bring up the package menu
+
+;; ---------------------------------- Helpful ----------------------------------
+
+(use-package helpful
+ :if (version< emacs-version "30")
+ :defer .5
+ :bind
+ ("C-h f" . helpful-callable)
+ ("C-h v" . helpful-variable)
+ ("C-h k" . helpful-key)
+ ("C-h F" . helpful-function)
+ ("C-h C" . helpful-command)
+ ("C-h ." . helpful-at-point)
+ ("C-h o" . helpful-symbol)) ;; overrides 'describe-symbol' keybinding
+
+;; ------------------------------------ Man ------------------------------------
+
+(use-package man
+ :defer 1
+ :ensure nil ;; built-in
+ :bind ("C-h M" . man))
+
+;; ------------------------------------ Info -----------------------------------
+
+ (defun cj/open-with-info-mode ()
+ "Open the current buffer's file in Info mode if it's a valid info file.
+
+Preserves any unsaved changes and checks if the file exists."
+ (interactive)
+ (let ((file-name (buffer-file-name)))
+ (when file-name
+ (if (and (file-exists-p file-name)
+ (string-match-p "\\.info\\'" file-name))
+ (progn
+ (when (buffer-modified-p)
+ (if (y-or-n-p "Buffer has unsaved changes. Save before opening in Info? ")
+ (save-buffer)
+ (message "Operation canceled")
+ (cl-return-from cj/open-with-info-mode)))
+ (kill-buffer (current-buffer))
+ (info file-name))
+ (message "Not a valid info file: %s" file-name)))))
+
+(defun cj/browse-info-files ()
+ "Browse and open .info or .info.gz files from user-emacs-directory."
+ (interactive)
+ (let* ((info-files (directory-files-recursively
+ user-emacs-directory
+ "\\.info\\(\\.gz\\)?$"))
+ (files-alist (mapcar (lambda (f)
+ (cons (file-name-nondirectory f) f))
+ info-files))
+ (chosen-name (completing-read
+ "Select Info file: "
+ (mapcar #'car files-alist)
+ nil t))
+ (chosen-file (cdr (assoc chosen-name files-alist))))
+ (when chosen-file
+ (info chosen-file))))
+
+(global-unset-key (kbd "C-h i"))
+(global-set-key (kbd "C-h i") #'cj/browse-info-files)
+
+
+(use-package info
+ :ensure nil ;; built-in
+ :bind
+ (:map Info-mode-map
+ ("m" . bookmark-set) ;; Rebind 'm' from Info-menu to bookmark-set
+ ("M" . Info-menu)) ;; Move Info-menu to 'M' instead
+ :preface
+ :init
+ ;; Add personal info files BEFORE Info mode initializes
+ ;; (let ((personal-info-dir (expand-file-name "assets/info" user-emacs-directory)))
+ ;; (when (file-directory-p personal-info-dir)
+ ;; (setq Info-directory-list (list personal-info-dir))))
+ ;; the above makes the directory the info list. the below adds it to the default list
+ ;; (add-to-list 'Info-default-directory-list personal-info-dir)))
+ :hook
+ (info-mode . info-persist-history-mode)
+ :config
+ ;; Make .info files open with our custom function
+ (add-to-list 'auto-mode-alist '("\\.info\\'" . cj/open-with-info-mode)))
+
+(provide 'help-config)
+;;; help-config.el ends here.
diff --git a/modules/help-utils.el b/modules/help-utils.el
new file mode 100644
index 00000000..dcb7a748
--- /dev/null
+++ b/modules/help-utils.el
@@ -0,0 +1,77 @@
+;;; help-utils --- Help Integrations and Searches -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; This module provides various utilities for accessing documentation and help resources
+;; directly within Emacs.
+;; DevDocs for programming documentation
+;; TLDR command line cheat sheets
+;; Wikipedia pages through the `wiki-summary` package.
+;; ArchWiki pages are also browsable via EWW.
+;;
+;;; Keybindings:
+;; - C-h D s: Search DevDocs for documentation.
+;; - C-h D b: Peruse DevDocs browsing through documentation.
+;; - C-h D l: Lookup specific documentation in DevDocs.
+;; - C-h D i: Install documentation for a chosen library in DevDocs.
+;; - C-h D d: Delete documentation from DevDocs.
+;; - C-h D u: Update all installed DevDocs documentation.
+;; - C-h T: Access TLDR (cheat sheets for command-line tools).
+;; - C-h W: Summarize wiki pages with the wiki-summary package.
+;; - C-h A: Search and browse local Arch Wiki topics in EWW.
+;;
+;;; Code:
+
+;; ---------------------------------- Devdocs ----------------------------------
+
+(use-package devdocs
+ :defer 1
+ :config
+ (global-set-key (kbd "C-h D s") 'devdocs-search)
+ (global-set-key (kbd "C-h D b") 'devdocs-peruse)
+ (global-set-key (kbd "C-h D l") 'devdocs-lookup)
+ (global-set-key (kbd "C-h D i") 'devdocs-install)
+ (global-set-key (kbd "C-h D d") 'devdocs-delete)
+ (global-set-key (kbd "C-h D u") 'devdocs-update-all)
+ (define-key devdocs-mode-map "b" 'devdocs-go-back)
+ (define-key devdocs-mode-map "f" 'devdocs-go-forward))
+
+;; ------------------------------------ TLDR -----------------------------------
+
+(use-package tldr
+ :defer 1
+ :bind ("C-h T" . tldr))
+
+;; -------------------------------- Wiki Summary -------------------------------
+
+(use-package wiki-summary
+ :defer 1
+ :bind ("C-h W" . wiki-summary))
+
+;; --------------------------- Browse Local Arch Wiki --------------------------
+;; on Arch: yay (or whatever your AUR package manager is) -S arch-wiki-docs
+;; browse the arch wiki topics offline
+
+(defun cj/local-arch-wiki-search ()
+ "Prompt for an ArchWiki topic and open its local HTML copy in EWW.
+
+Looks for “*.html” files under \"/usr/share/doc/arch-wiki/html/en\",
+lets you complete on their basenames, and displays the chosen file
+with `eww-browse-url'. If no file is found, reminds you to install
+arch-wiki-docs."
+ (interactive)
+ (let* ((dir "/usr/share/doc/arch-wiki/html/en")
+ (full-filenames (directory-files dir t "\\.html\\'"))
+ (basenames (mapcar 'file-name-base full-filenames))
+ (chosen (completing-read "Choose an ArchWiki Topic: " basenames)))
+ (if (member chosen basenames)
+ (let* ((idx (cl-position chosen basenames :test 'equal))
+ (fullname (nth idx full-filenames))
+ (url (concat "file://" fullname)))
+ (eww-browse-url url))
+ (message "File not found! Is arch-wiki-docs installed?"))))
+(global-set-key (kbd "C-h A") 'cj/local-arch-wiki-search)
+
+(provide 'help-utils)
+;;; help-utils.el ends here
diff --git a/modules/host-environment.el b/modules/host-environment.el
new file mode 100644
index 00000000..98caff28
--- /dev/null
+++ b/modules/host-environment.el
@@ -0,0 +1,116 @@
+;;; host-environment.el --- Host Environment Convenience Functions -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Convenience functions to report about the host environment
+
+;;; Code:
+
+(require 'battery)
+
+(defun env-laptop-p ()
+ "Return t if host is a laptop (i.e., has a battery), nil if not."
+ (when (and battery-status-function
+ (not (string-match-p "N/A"
+ (battery-format "%B"
+ (funcall battery-status-function)))))
+ t))
+
+(defun env-desktop-p ()
+ "Return t if host is a laptop (has a battery), nil if not."
+ (when (not (env-laptop-p))
+ t))
+
+(defun env-linux-p ()
+ "Return t if host system is GNU/Linux."
+ (eq system-type "gnu/linux"))
+
+(defun env-bsd-p ()
+ "Return t if host system is FreeBSD."
+ (eq system-type 'berkeley-unix))
+
+(defun env-macos-p ()
+ "Return t if host system is Mac OS (darwin-based)."
+ (eq system-type "darwin"))
+
+(defun env-windows-p ()
+ "Return t if host system is Windows."
+ (memq system-type '(cygwin windows-nt ms-dos)))
+
+(defun env-x-p ()
+ "Return t if host system is running the X Window System."
+ (string= (window-system) "x"))
+
+(defun env-terminal-p ()
+ "Return t if running in a terminal."
+ (not (display-graphic-p)))
+
+(defun env-gui-p ()
+ "Return t if running in graphical environment."
+ (display-graphic-p))
+
+;; ------------------------------- Timezone Info -------------------------------
+
+(defun cj/match-localtime-to-zoneinfo ()
+ "Detect system timezone by comparing /etc/localtime with zoneinfo files.
+This replicates the shell command:
+find /usr/share/zoneinfo -type f ! -name 'posixrules' \\
+ -exec cmp -s {} /etc/localtime \\; -print | sed -e 's@.*/zoneinfo/@@' | head -n1"
+ (when (and (file-exists-p "/etc/localtime")
+ (file-directory-p "/usr/share/zoneinfo"))
+ (let ((localtime-content
+ (with-temp-buffer
+ (insert-file-contents-literally "/etc/localtime")
+ (buffer-string)))
+ (result nil))
+ (catch 'found
+ (dolist (file (directory-files-recursively
+ "/usr/share/zoneinfo"
+ ".*"
+ nil
+ (lambda (name)
+ (not (string-match-p "posixrules" name)))))
+ (when (and (file-regular-p file)
+ (not (string-match-p "/posixrules$" file)))
+ (let ((file-content
+ (with-temp-buffer
+ (insert-file-contents-literally file)
+ (buffer-string))))
+ (when (string= localtime-content file-content)
+ (setq result (replace-regexp-in-string
+ "^.*/zoneinfo/" "" file))
+ (throw 'found result)))))
+ result))))
+
+(defun cj/detect-system-timezone ()
+ "Detect the system timezone in IANA format (e.g., 'America/Los_Angeles').
+Tries multiple methods in order of reliability:
+1. Environment variable TZ
+2. File comparison of /etc/localtime with zoneinfo database
+3. /etc/timezone file contents
+4. /etc/localtime symlink target"
+ (or
+ ;; Compare file contents (reliable on Arch/modern systems)
+ (cj/match-localtime-to-zoneinfo)
+
+ ;; Environment variable (most explicit if set)
+ (getenv "TZ")
+
+ ;; Read /etc/timezone (Debian/Ubuntu)
+ (when (file-exists-p "/etc/timezone")
+ (with-temp-buffer
+ (insert-file-contents "/etc/timezone")
+ (string-trim (buffer-string))))
+
+ ;; Method 4: Parse symlink (fallback for older systems)
+ (when (file-symlink-p "/etc/localtime")
+ (let ((target (file-truename "/etc/localtime")))
+ (when (string-match ".*/zoneinfo/\\(.+\\)" target)
+ (match-string 1 target))))
+
+ ;; Default to nil - let org-gcal use its default
+ nil))
+
+
+(provide 'host-environment)
+;;; host-environment.el ends here.
diff --git a/modules/httpd-config.el b/modules/httpd-config.el
new file mode 100644
index 00000000..ac1f7a0f
--- /dev/null
+++ b/modules/httpd-config.el
@@ -0,0 +1,26 @@
+;;; httpd-config --- Setup for a Simple HTTP Server -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+
+;;;; -------------------------- Simple-Httpd -------------------------
+
+(use-package simple-httpd
+ :defer 1
+ :preface
+ (defconst wwwdir (concat user-emacs-directory "www"))
+ (defun check-or-create-wwwdir ()
+ (unless (file-exists-p wwwdir)
+ (make-directory wwwdir)))
+ :init (check-or-create-wwwdir)
+ :config
+ (setq httpd-root wwwdir)
+ (setq httpd-show-backtrace-when-error t)
+ (setq httpd-serve-files t))
+
+
+(provide 'httpd-config)
+;;; httpd-config.el ends here
diff --git a/modules/jumper.el b/modules/jumper.el
new file mode 100644
index 00000000..787a4973
--- /dev/null
+++ b/modules/jumper.el
@@ -0,0 +1,177 @@
+;;; jumper.el --- Quick jump between locations using registers -*- lexical-binding: t -*-
+
+;; Author: Craig Jennings
+;; Version: 0.1
+;; Package-Requires: ((emacs "25.1"))
+;; Keywords: convenience
+;; URL: https://github.com/cjennings/jumper
+
+;;; Commentary:
+
+;; Jumper provides a simple way to store and jump between locations
+;; in your codebase without needing to remember register assignments.
+
+;;; Code:
+
+(defgroup jumper nil
+ "Quick navigation between stored locations."
+ :group 'convenience)
+
+(defcustom jumper-prefix-key "M-SPC"
+ "Prefix key for jumper commands.
+
+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)
+
+;; Internal variables
+(defvar jumper--registers (make-vector jumper-max-locations nil)
+ "Vector of used registers.")
+
+(defvar jumper--next-index 0
+ "Next available index in the jumper--registers vector.")
+
+(defvar jumper--last-location-register ?z
+ "Register used to store the last location.")
+
+(defun jumper--location-key ()
+ "Generate a key to identify the current location."
+ (format "%s:%d:%d"
+ (or (buffer-file-name) (buffer-name))
+ (line-number-at-pos)
+ (current-column)))
+
+(defun jumper--location-exists-p ()
+ "Check if current location is already stored."
+ (let ((key (jumper--location-key))
+ (found nil))
+ (dotimes (i
+ jumper--next-index found)
+ (let* ((reg (aref jumper--registers i))
+ (pos (get-register reg))
+ (marker (and pos (registerv-data pos))))
+ (when marker
+ (save-current-buffer
+ (set-buffer (marker-buffer marker))
+ (save-excursion
+ (goto-char marker)
+ (when (string= key (jumper--location-key))
+ (setq found t)))))))))
+
+(defun jumper--register-available-p ()
+ "Check if there are registers available."
+ (< jumper--next-index jumper-max-locations))
+
+(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
+ (save-current-buffer
+ (set-buffer (marker-buffer marker))
+ (save-excursion
+ (goto-char marker)
+ (format "[%d] %s:%d - %s"
+ index
+ (buffer-name)
+ (line-number-at-pos)
+ (buffer-substring-no-properties
+ (line-beginning-position)
+ (min (+ (line-beginning-position) 40)
+ (line-end-position)))))))))
+
+(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!"))))
+
+(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")))))
+
+(defun jumper--reorder-registers (removed-idx)
+ "Reorder registers after removing the one at REMOVED-IDX."
+ (when (< removed-idx (1- jumper--next-index))
+ ;; Shift all higher registers down
+ (cl-loop for i from removed-idx below (1- jumper--next-index)
+ do (let ((next-reg (aref jumper--registers (1+ i))))
+ (aset jumper--registers i next-reg))))
+ (setq jumper--next-index (1- jumper--next-index)))
+
+(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
+(defun jumper-setup-keys ()
+ "Setup default keybindings for jumper."
+ (interactive)
+ (global-set-key (kbd jumper-prefix-key) jumper-map))
+
+;; Call jumper-setup-keys when the package is loaded
+(jumper-setup-keys)
+
+(provide 'jumper)
+;;; jumper.el ends here.
diff --git a/modules/keybindings.el b/modules/keybindings.el
new file mode 100644
index 00000000..3d817013
--- /dev/null
+++ b/modules/keybindings.el
@@ -0,0 +1,100 @@
+;;; keybindings --- General Keyboard Shortcuts -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+
+
+;; Commonly used files should be easy to jump to. The "jump-to" keymap has the
+;; "C-c j" prefix and immediately opens files defined in user-constants.el.
+
+;; "Hostile Keybindings" are those that are close to keybindings I use commonly
+;; so they're easy to hit by accident, but they have painful results. I'd
+;; rather avoid the pain by unsetting they keybindings and view the error '<key>
+;; is undefined' message. Finally, I'm providing messages to train me to use
+;; faster keybindings and provide feedback when evaluating buffers.
+
+;;; Code:
+
+(require 'user-constants)
+
+;; make org-store-link binding global
+(global-set-key (kbd "C-c l") 'org-store-link)
+
+;; remap Shift Backspace to Delete
+(global-set-key (kbd "S-<backspace>") 'delete-forward-char)
+
+;; ------------------------------ Jump To Commands -----------------------------
+;; quick access for commonly used files
+
+(defvar jump-to-keymap (make-sparse-keymap)
+ "Jump-to commonly used files/directories/commands.")
+(global-set-key (kbd "C-c j") jump-to-keymap)
+
+(define-key jump-to-keymap (kbd "r")
+ #'(lambda () (interactive) (find-file reference-file)))
+(define-key jump-to-keymap (kbd "s")
+ #'(lambda () (interactive) (find-file schedule-file)))
+(define-key jump-to-keymap (kbd "i")
+ #'(lambda () (interactive) (find-file inbox-file)))
+(define-key jump-to-keymap (kbd "c")
+ #'(lambda () (interactive) (find-file contacts-file)))
+(define-key jump-to-keymap (kbd "m")
+ #'(lambda () (interactive) (find-file macros-file)))
+(define-key jump-to-keymap (kbd "n")
+ #'(lambda () (interactive) (find-file reading-notes-file)))
+(define-key jump-to-keymap (kbd "w")
+ #'(lambda () (interactive) (find-file webclipped-file)))
+(define-key jump-to-keymap (kbd "g")
+ #'(lambda () (interactive) (find-file gcal-file)))
+(define-key jump-to-keymap (kbd "I")
+ #'(lambda () (interactive) (find-file emacs-init-file)))
+
+
+;; ---------------------------- Keybinding Discovery ---------------------------
+
+(use-package free-keys
+ :defer 1
+ :bind ("C-h C-k" . free-keys))
+
+(use-package which-key
+ :defer 1
+ :config
+ ;; never show keybindings that have been 'cj/disabled'
+ (push '((nil . "cj/disabled") . t) which-key-replacement-alist)
+ (setq which-key-idle-delay 2.0
+ which-key-popup-type 'side-window)
+ (which-key-setup-side-window-bottom)
+ ;; (which-key-setup-side-window-right-bottom)
+ (which-key-mode 1))
+
+;; ---------------------------- General Keybindings ----------------------------
+
+;; Avoid hostile bindings
+(global-unset-key (kbd "C-x C-f")) ;; find-file-read-only
+(global-unset-key (kbd "C-z")) ;; suspend-frame is accidentally hit often
+(global-unset-key (kbd "M-o")) ;; facemenu-mode
+
+;; Add commonly-used general keybindings
+(global-set-key (kbd "C-x C-f") 'find-file)
+(global-set-key (kbd "C-c f") 'link-hint-open-link-at-point)
+(global-set-key (kbd "M-*") 'calculator)
+(global-set-key (kbd "M-Y") 'yank-media)
+
+;; Normally bound to ESC ESC ESC, hit ESC once to get out of unpleasant situations.
+(global-set-key (kbd "<escape>") 'keyboard-escape-quit)
+
+;; remap C-x \ to sort-lines (from remap activate-transient-input-method)
+(global-unset-key (kbd "C-x \\"))
+(global-set-key (kbd "C-x \\") 'sort-lines)
+
+;; training myself to use C-/ for undo (bound internally) as it's faster.
+(global-unset-key (kbd "C-x u"))
+(define-key global-map (kbd "C-x u")
+ #'(lambda () (interactive)
+ (message (concat "Seriously, " user-name
+ "? Use 'C-/'. It's faster."))))
+
+
+(provide 'keybindings)
+;;; keybindings.el ends here
diff --git a/modules/keyboard-macros.el b/modules/keyboard-macros.el
new file mode 100644
index 00000000..0fbf3d02
--- /dev/null
+++ b/modules/keyboard-macros.el
@@ -0,0 +1,97 @@
+;;; keyboard-macros.el --- Keyboard Macro Management -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; This library provides a simple, end-user–focused interface for
+;; creating, naming, saving, and replaying keyboard macros in Emacs.
+;; All commands are built on top of the built-in =kmacro= machinery, but
+;; add a lightweight workflow and persistence across sessions.
+;;
+;; Workflow:
+;;
+;; 1. Start recording with C-F3 (or M-x cj/kbd-macro-start-or-end)
+;; This toggles macro recording on.
+;; Now you can perform all the edits you want recorded in the macro.
+;;
+;; 2. Stop recording with C-F3 (or M-x cj/kbd-macro-start-or-end)
+;; This stops recording and the macro becomes the "last keyboard macro."
+;;
+;; 3. Replay your macro <f3> (or M-x call-last-kbd-macro)
+;;
+;; 4. Name your macro with M-<F3>
+;; You will be prompted for a short name (e.g. =align-comments=,
+;; =cleanup-trail-spaces=). This name is how you'll refer to it later.
+;;
+;; 5. Recall that macro later with M-x [the name you gave the macro]
+;;
+;; 6. View all your saved macros with s-<f3> (super-f3)
+;;
+;; 7. All macros reload at startup automatically.
+;; When this library is loaded, it will look for the save file and
+;; re-establish all your named macros in your current session.
+;;
+;;; Code:
+
+(require 'user-constants) ;; definitions of sync-dir and macros-file
+
+(defun ensure-macros-file (file)
+ "Ensure FILE exists and its first line enables lexical-binding."
+ (unless (file-exists-p file)
+ (with-temp-file file
+ (insert ";;; -*- lexical-binding: t -*-\n"))))
+
+(defun cj/kbd-macro-start-or-end ()
+ "Toggle start/end of keyboard macro definition."
+ (interactive)
+ (if defining-kbd-macro
+ (end-kbd-macro)
+ (start-kbd-macro nil)))
+
+(defun cj/save-maybe-edit-macro (name)
+ "Save last macro as NAME in `macros-file'; edit if prefix arg."
+ (interactive "SName of macro: ")
+ (kmacro-name-last-macro name)
+ (ensure-macros-file macros-file)
+ (find-file macros-file)
+ (goto-char (point-max))
+ (newline)
+ (insert-kbd-macro name)
+ (newline)
+ (save-buffer)
+ (switch-to-buffer (other-buffer (current-buffer) 1))
+ (when current-prefix-arg
+ (find-file macros-file)
+ (goto-char (point-max)))
+ name)
+
+(defun cj/open-macros-file ()
+ "Open the keyboard macros file."
+ (interactive)
+ (ensure-macros-file macros-file)
+ (find-file macros-file))
+
+;; Set up key bindings
+(global-set-key (kbd "C-<f3>") #'cj/kbd-macro-start-or-end)
+(global-set-key (kbd "<f3>") #'call-last-kbd-macro)
+(global-set-key (kbd "M-<f3>") #'cj/save-maybe-edit-macro)
+(global-set-key (kbd "s-<f3>") #'cj/open-macros-file)
+
+;; Add hook to save any unnamed macros on exit if desired
+(defun cj/save-last-kbd-macro-on-exit ()
+ "Save the last keyboard macro before exiting Emacs if it's not saved."
+ (when (and last-kbd-macro (not (kmacro-name-last-macro)))
+ (when (y-or-n-p "Save last keyboard macro before exiting? ")
+ (call-interactively #'cj/save-maybe-edit-macro))))
+
+(add-hook 'kill-emacs-hook #'cj/save-last-kbd-macro-on-exit)
+
+;; Load existing macros file with proper error handling
+(when (file-exists-p macros-file)
+ (condition-case err
+ (load macros-file)
+ (error
+ (message "Error loading keyboard macros file: %s" (error-message-string err)))))
+
+(provide 'keyboard-macros)
+;;; keyboard-macros.el ends here
diff --git a/modules/latex-config.el b/modules/latex-config.el
new file mode 100644
index 00000000..bb4cf510
--- /dev/null
+++ b/modules/latex-config.el
@@ -0,0 +1,56 @@
+;;; latex-config --- Setup for LaTeX and Related Software -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; WORKFLOW:
+;;
+;; Opening any tex file will put you into LaTeX mode.
+;;
+;; C-c C-m to enter macros
+;; C-c C-e to enter environment
+;;
+;; C-c C-c to compile a tex document using latexmk
+;; C-c C-v to view the resulting pdf
+;;
+;;; Code:
+
+;; ----------------------------- Auctex And Related ----------------------------
+
+(use-package tex
+ :ensure auctex
+ :defer t
+ :hook
+ (TeX-mode-hook . (lambda () (setq TeX-command-default "latexmk"))) ; use latexmk by default
+ (LaTeX-mode . (lambda () (TeX-fold-mode 1))) ; automatically activate TeX-fold-mode.
+ (LaTeX-mode . flyspell-mode) ; turn on flyspell-mode by default
+ (LaTeX-mode . TeX-PDF-mode)
+ (LaTeX-mode . (lambda () (push (list 'output-pdf "Zathura") TeX-view-program-selection)))
+ :mode
+ ("\\.tex\\'" . latex-mode)
+ :config
+ (setq TeX-auto-save t) ; auto save style info when saving buffer
+ (setq TeX-parse-self t) ; parse file after loading if it has no style hook
+ (setq TeX-save-query nil) ; don't ask to save files before starting TeX
+ (setq TeX-PDF-mode t) ; compile to PDF mode, rather than DVI
+ (setq-default TeX-master t)) ; Assume the file is the master file itself
+
+(use-package auctex-latexmk
+ :defer t
+ :config
+ (auctex-latexmk-setup)
+ (setq auctex-latexmk-inherit-TeX-PDF-mode t))
+
+(use-package company-auctex
+ :after tex
+ :init (company-auctex-init))
+
+;; ----------------------------- Graphviz Dot Mode -----------------------------
+
+(use-package graphviz-dot-mode
+ :defer t
+ :config
+ (setq graphviz-dot-indent-width 4))
+
+(provide 'latex-config)
+;;; latex-config.el ends here
diff --git a/modules/ledger-config.el b/modules/ledger-config.el
new file mode 100644
index 00000000..c268fa36
--- /dev/null
+++ b/modules/ledger-config.el
@@ -0,0 +1,50 @@
+;;; ledger-config.el --- Ledger Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; -------------------------------- Ledger Mode --------------------------------
+;; edit files in ledger format
+
+(use-package ledger-mode
+ :mode ("\\.dat\\'"
+ "\\.ledger\\'"
+ "\\.journal\\'")
+ :preface
+ (defun cj/ledger-save ()
+ "Automatically clean the ledger buffer at each save."
+ (interactive)
+ (save-excursion
+ (when (buffer-modified-p)
+ (with-demoted-errors (ledger-mode-clean-buffer))
+ (save-buffer))))
+ :bind
+ (:map ledger-mode-map
+ ("C-x C-s" . cj/ledger-save))
+ :custom
+ (ledger-clear-whole-transactions t)
+ (ledger-reconcile-default-commodity "$")
+ (ledger-report-use-header-line nil)
+ (ledger-reports
+ '(("bal" "%(binary) --strict -f %(ledger-file) bal")
+ ("bal this month" "%(binary) --strict -f %(ledger-file) bal -p %(month) -S amount")
+ ("bal this year" "%(binary) --strict -f %(ledger-file) bal -p 'this year'")
+ ("net worth" "%(binary) --strict -f %(ledger-file) bal Assets Liabilities")
+ ("account" "%(binary) --strict -f %(ledger-file) reg %(account)"))))
+
+;; ------------------------------ Flycheck Ledger ------------------------------
+;; syntax and unbalanced transaction linting
+
+(use-package flycheck-ledger
+ :after ledger-mode)
+
+;; ------------------------------- Company Ledger ------------------------------
+;; autocompletion for ledger
+
+(use-package company-ledger
+ :after (company ledger-mode)
+ :config
+ (add-to-list 'company-backends 'company-ledger))
+
+(provide 'ledger-config)
+;;; ledger-config.el ends here.
diff --git a/modules/lipsum-generator.el b/modules/lipsum-generator.el
new file mode 100644
index 00000000..b328b989
--- /dev/null
+++ b/modules/lipsum-generator.el
@@ -0,0 +1,239 @@
+;;; lipsum-generator.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings
+;; Version: 0.5
+
+;;; Commentary:
+;; Generate pseudo-Latin placeholder text using a simple order-two
+;; Markov chain. You can train the chain on region, buffer, or file.
+;; By default, it learns from a bundled Latin wordlist, which you can
+;; change via customization.
+;;
+;; Interactive commands:
+;; M-x cj/lipsum – Return N words
+;; M-x cj/lipsum-insert – Insert N words at point
+;; M-x cj/lipsum-title – Generate a pseudo-Latin heading
+;; M-x cj/lipsum-paragraphs – Insert multiple paragraphs
+;; M-x cj/lipsum-learn-* – Train the chain
+;; M-x cj/lipsum-reset – Clear chain
+
+;;; Code:
+
+(require 'cl-lib)
+
+(defcustom cj/lipsum-training-file
+ (expand-file-name "liber-primus.txt"
+ (expand-file-name "assets" user-emacs-directory))
+ "Training file for the cj-lipsum Markov chain.
+Defaults to 'liber-primus.txt' in the modules directory."
+ :type 'file
+ :group 'cj-lipsum)
+
+(cl-defstruct (cj/markov-chain
+ (:constructor cj/markov-chain-create))
+ "An order-two Markov chain."
+ (map (make-hash-table :test 'equal))
+ (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]+")))
+
+(defun cj/markov-learn (chain text)
+ "Add TEXT into the Markov CHAIN with tokenized input."
+ (let* ((words (cj/markov-tokenize text))
+ (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)
+ 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)))
+
+(defun cj/markov-fix-capitalization (sentence)
+ "Capitalize the first word and the first word after .!? in SENTENCE."
+ (let* ((tokens (split-string sentence "\\b" t)))
+ (cl-loop with capitalize-next = t
+ for i from 0 below (length tokens)
+ for tok = (nth i tokens)
+ do (when (and capitalize-next (string-match-p "^[[:alpha:]]" tok))
+ (setf (nth i tokens)
+ (concat (upcase (substring tok 0 1))
+ (substring tok 1)))
+ (setq capitalize-next nil))
+ do (when (string-match-p "[.!?]" tok) ; <-- Changed: removed $ anchor
+ (setq capitalize-next t)))
+ (mapconcat #'identity tokens "")))
+
+(defun cj/markov-join-tokens (tokens)
+ "Join TOKENS into a sentence with proper spacing/punctuation."
+ (let ((sentence "") (need-space nil))
+ (dolist (tok tokens)
+ (cond
+ ;; punctuation attaches directly
+ ((string-match-p "^[[:punct:]]+$" tok)
+ (setq sentence (concat sentence tok))
+ (setq need-space t))
+ ;; word
+ (t
+ (when need-space
+ (setq sentence (concat sentence " ")))
+ (setq sentence (concat sentence tok))
+ (setq need-space t))))
+ ;; fix capitalization of first word only
+ (when (string-match "\\`\\([[:alpha:]]\\)" sentence)
+ (setq sentence
+ (replace-match (upcase (match-string 1 sentence))
+ nil nil sentence)))
+ ;; ensure it ends with .!?
+ (unless (string-match-p "[.!?]$" sentence)
+ (setq sentence (concat (replace-regexp-in-string "[[:punct:]]+$" "" sentence) ".")))
+ (setq sentence (cj/markov-fix-capitalization sentence))
+ sentence))
+
+(defun cj/markov-generate (chain n &optional start)
+ "Generate a sentence of N tokens from CHAIN."
+ (when (cj/markov-chain-keys chain)
+ (let* ((state (or (and start
+ (gethash start (cj/markov-chain-map chain))
+ start)
+ (cj/markov-random-key chain)))
+ (w1 (car state))
+ (w2 (cadr state))
+ (tokens (list w1 w2)))
+ (dotimes (_ (- n 2))
+ (let ((next (cj/markov-next-word chain state)))
+ (if next
+ (setq tokens (append tokens (list next))
+ state (list w2 next)
+ w1 w2
+ w2 next)
+ (setq state (cj/markov-random-key chain)
+ w1 (car state)
+ w2 (cadr state)
+ tokens (append tokens (list w1 w2))))))
+ (cj/markov-join-tokens tokens))))
+
+(defun cj/markov-random-key (chain)
+ (nth (random (length (cj/markov-chain-keys chain)))
+ (cj/markov-chain-keys chain)))
+
+(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)
+ (cj/markov-learn cj/lipsum-chain
+ (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: ")
+ (with-temp-buffer
+ (insert-file-contents file)
+ (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: ")
+ (insert (cj/lipsum n)))
+
+;;; Title generation
+
+(defconst cj/lipsum-title-min 3)
+(defconst cj/lipsum-title-max 8)
+(defconst cj/lipsum-title-small 3)
+
+;;;###autoload
+(defun cj/lipsum-title ()
+ "Generate a pseudo-Latin title."
+ (interactive)
+ (let* ((n (+ cj/lipsum-title-min
+ (random (1+ (- cj/lipsum-title-max cj/lipsum-title-min)))))
+ (words
+ (cl-loop with state = (cj/markov-random-key cj/lipsum-chain)
+ for i from 0 below n
+ for w = (car state)
+ do (setq state (list (cadr state)
+ (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))))
+ (setq words (cl-remove-if #'string-empty-p words))
+ (mapconcat
+ (lambda (word idx)
+ (if (or (zerop idx) (> (length word) cj/lipsum-title-small))
+ (capitalize word)
+ word))
+ words " "))
+
+;;; 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.
+Defaults: MIN=30, MAX=80."
+ (interactive "nNumber of paragraphs: ")
+ (let ((min (or min 30))
+ (max (or max 80)))
+ (dotimes (_ count)
+ (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)
+
+;;; Initialization: train on default file
+(defun cj/lipsum--init ()
+ "Initialize cj-lipsum by learning from `cj/lipsum-training-file`."
+ (when (and cj/lipsum-training-file
+ (file-readable-p cj/lipsum-training-file))
+ (cj/lipsum-reset)
+ (cj/lipsum-learn-file cj/lipsum-training-file)
+ (message "cj-lipsum trained on %s" cj/lipsum-training-file)))
+
+(cj/lipsum--init)
+
+(provide 'lipsum-generator)
+;;; lipsum-generator.el ends here.
diff --git a/modules/local-repository.el b/modules/local-repository.el
new file mode 100644
index 00000000..c390ffbb
--- /dev/null
+++ b/modules/local-repository.el
@@ -0,0 +1,53 @@
+;;; local-repository.el --- local repository functionality -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+(require 'elpa-mirror)
+
+;; ------------------------------ Utility Function -----------------------------
+
+
+(defun car-member (value list)
+ "Check if VALUE exists as the car of any cons cell in LIST."
+ (member value (mapcar #'car list)))
+
+;; ------------------------------- Customizations ------------------------------
+
+(defcustom localrepo-repository-id "localrepo"
+ "The name used to identify the local repository internally.
+
+Used for the package-archive and package-archive-priorities lists.")
+
+(defcustom localrepo-repository-priority 100
+ "The value for the local repository in the package-archive-priority list.
+
+A higher value means higher priority. If you want your local packages to be
+preferred, this must be a higher number than any other repositories.")
+
+(defcustom localrepo-repository-location
+ (concat user-emacs-directory "/.localrepo")
+ "The location of the local repository.
+
+It's a good idea to keep this with the rest of your configuration files and
+keep them in source control.")
+
+(defun cj/update-localrepo-repository ()
+ "Update the local repository with currently installed packages."
+ (interactive)
+ (elpamr-create-mirror-for-installed localrepo-repository-location t))
+
+(defun localrepo-initialize ()
+"Add the repository to the package archives, then gives it a high priority."
+ (unless (car-member localrepo-repository-id package-archives)
+ (add-to-list 'package-archives
+ (localrepo-repository-id . localrepo-repository-location)))
+
+ (unless (car-member localrepo-repository-id package-archive-priorities)
+ (add-to-list 'package-archive-priorities
+ (localrepo-repository-id . localrepo-repository-priority))))
+
+(provide 'local-repository)
+;;; local-repository.el ends here.
diff --git a/modules/lorem-generator.el b/modules/lorem-generator.el
new file mode 100644
index 00000000..6148dfdc
--- /dev/null
+++ b/modules/lorem-generator.el
@@ -0,0 +1,244 @@
+;;; lorem-generator.el --- Fake Latin Text Generator -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings
+;; Version: 0.5
+;; Package-Requires: ((emacs "26.1") (cl-lib "0.6"))
+;; Keywords: text, lorem-ipsum, dummy, filler, markov
+;; URL: https://github.com/yourname/cj-lipsum
+
+;;; Commentary:
+;; Generate pseudo-Latin placeholder text using a simple order-two
+;; Markov chain. You can train the chain on region, buffer, or file.
+;; By default, it learns from a bundled Latin wordlist, which you can
+;; change via customization.
+;;
+;; Interactive commands:
+;; M-x cj/lipsum – Return N words
+;; M-x cj/lipsum-insert – Insert N words at point
+;; M-x cj/lipsum-title – Generate a pseudo-Latin heading
+;; M-x cj/lipsum-paragraphs – Insert multiple paragraphs
+;; M-x cj/lipsum-learn-* – Train the chain
+;; M-x cj/lipsum-reset – Clear chain
+
+;;; Code:
+
+(require 'cl-lib)
+
+(cl-defstruct (cj/markov-chain
+ (:constructor cj/markov-chain-create))
+ "An order-two Markov chain."
+ (map (make-hash-table :test 'equal))
+ (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]+")))
+
+(defun cj/markov-learn (chain text)
+ "Add TEXT into the Markov CHAIN with tokenized input."
+ (let* ((words (cj/markov-tokenize text))
+ (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)
+ 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)))
+
+(defun cj/markov-fix-capitalization (sentence)
+ "Capitalize the first word and the first word after .!? in SENTENCE."
+ (let* ((tokens (split-string sentence "\\b" t)))
+ (cl-loop with capitalize-next = t
+ for i from 0 below (length tokens)
+ for tok = (nth i tokens)
+ do (when (and capitalize-next (string-match-p "^[[:alpha:]]" tok))
+ (setf (nth i tokens)
+ (concat (upcase (substring tok 0 1))
+ (substring tok 1)))
+ (setq capitalize-next nil))
+ do (when (string-match-p "[.!?]" tok) ; <-- Changed: removed $ anchor
+ (setq capitalize-next t)))
+ (mapconcat #'identity tokens "")))
+
+(defun cj/markov-join-tokens (tokens)
+ "Join TOKENS into a sentence with proper spacing/punctuation."
+ (let ((sentence "") (need-space nil))
+ (dolist (tok tokens)
+ (cond
+ ;; punctuation attaches directly
+ ((string-match-p "^[[:punct:]]+$" tok)
+ (setq sentence (concat sentence tok))
+ (setq need-space t))
+ ;; word
+ (t
+ (when need-space
+ (setq sentence (concat sentence " ")))
+ (setq sentence (concat sentence tok))
+ (setq need-space t))))
+ ;; fix capitalization of first word only
+ (when (string-match "\\`\\([[:alpha:]]\\)" sentence)
+ (setq sentence
+ (replace-match (upcase (match-string 1 sentence))
+ nil nil sentence)))
+ ;; ensure it ends with .!?
+ (unless (string-match-p "[.!?]$" sentence)
+ (setq sentence (concat (replace-regexp-in-string "[[:punct:]]+$" "" sentence) ".")))
+ (setq sentence (cj/markov-fix-capitalization sentence))
+ sentence))
+
+(defun cj/markov-generate (chain n &optional start)
+ "Generate a sentence of N tokens from CHAIN."
+ (when (cj/markov-chain-keys chain)
+ (let* ((state (or (and start
+ (gethash start (cj/markov-chain-map chain))
+ start)
+ (cj/markov-random-key chain)))
+ (w1 (car state))
+ (w2 (cadr state))
+ (tokens (list w1 w2)))
+ (dotimes (_ (- n 2))
+ (let ((next (cj/markov-next-word chain state)))
+ (if next
+ (setq tokens (append tokens (list next))
+ state (list w2 next)
+ w1 w2
+ w2 next)
+ (setq state (cj/markov-random-key chain)
+ w1 (car state)
+ w2 (cadr state)
+ tokens (append tokens (list w1 w2))))))
+ (cj/markov-join-tokens tokens))))
+
+(defun cj/markov-random-key (chain)
+ (nth (random (length (cj/markov-chain-keys chain)))
+ (cj/markov-chain-keys chain)))
+
+(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)
+ (cj/markov-learn cj/lipsum-chain
+ (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: ")
+ (with-temp-buffer
+ (insert-file-contents file)
+ (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: ")
+ (insert (cj/lipsum n)))
+
+;;; Title generation
+
+(defconst cj/lipsum-title-min 3)
+(defconst cj/lipsum-title-max 8)
+(defconst cj/lipsum-title-small 3)
+
+;;;###autoload
+(defun cj/lipsum-title ()
+ "Generate a pseudo-Latin title."
+ (interactive)
+ (let* ((n (+ cj/lipsum-title-min
+ (random (1+ (- cj/lipsum-title-max cj/lipsum-title-min)))))
+ (words
+ (cl-loop with state = (cj/markov-random-key cj/lipsum-chain)
+ for i from 0 below n
+ for w = (car state)
+ do (setq state (list (cadr state)
+ (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))))
+ (setq words (cl-remove-if #'string-empty-p words))
+ (mapconcat
+ (lambda (word idx)
+ (if (or (zerop idx) (> (length word) cj/lipsum-title-small))
+ (capitalize word)
+ word))
+ words " "))
+
+;;; 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.
+Defaults: MIN=30, MAX=80."
+ (interactive "nNumber of paragraphs: ")
+ (let ((min (or min 30))
+ (max (or max 80)))
+ (dotimes (_ count)
+ (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`."
+ (when (and cj/lipsum-default-file
+ (file-readable-p cj/lipsum-default-file))
+ (cj/lipsum-reset)
+ (cj/lipsum-learn-file cj/lipsum-default-file)))
+
+(cj/lipsum--init)
+
+(provide 'lorem-generator)
+;;; lorem-generator.el ends here.
diff --git a/modules/mail-config.el b/modules/mail-config.el
new file mode 100644
index 00000000..76235ff1
--- /dev/null
+++ b/modules/mail-config.el
@@ -0,0 +1,341 @@
+;;; mail-config --- Settings for Mu4e Email -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; I found Aime Bertrand's blog post to be an excellent walkthrough of how to
+;; setup a Mu4e config.
+;;
+;; https://macowners.club/posts/email-emacs-mu4e-macos/
+;;
+;; on saving attachments:
+;; After running mu4e-view-save-attachments,
+;; - invoke embark-act-all in the completion menu
+;; - followed by RET (mu4e-view-save-attachments) to save all attachments
+;;
+;; - or TAB (vertico-insert)
+;; - followed by , (comma) next to each file you want to save,
+;; - then RET (vertico-exit), to save selected attachments.
+;;
+;;; Code:
+
+(require 'user-constants)
+
+;; ------------------------------ Mark All Headers -----------------------------
+;; convenience function to mark all headers for an action
+
+(defun cj/mu4e-mark-all-headers ()
+ "Mark all headers for a later action.
+
+Prompts user for the action when executing."
+ (interactive)
+ (mu4e-headers-mark-for-each-if
+ (cons 'something nil)
+ (lambda (_msg _param) t)))
+
+;; ---------------------------------- SMTPmail ---------------------------------
+;; send mail to smtp host from smtpmail temp buffer.
+
+(use-package smtpmail
+ :ensure nil ;; built-in
+ :defer .5
+ :config
+ (setq sendmail-program (executable-find "msmtp"))
+ (setq send-mail-function 'message-send-mail-with-sendmail
+ message-send-mail-function 'message-send-mail-with-sendmail)
+ (setq message-sendmail-envelope-from 'header)
+ (setq smtpmail-debug-info t))
+
+;; --------------------------------- Mu4e Email --------------------------------
+
+(use-package mu4e
+ :ensure nil ;; mu4e gets installed by installing 'mu' via the system package manager
+ :load-path "/usr/share/emacs/site-lisp/mu4e/"
+ :defer .5
+ :bind
+ ("C-c m". mu4e)
+ (:map mu4e-headers-mode-map
+ ("M" . cj/mu4e-mark-all-headers)
+ ("D" . mu4e-headers-mark-for-trash)
+ ("d" . mu4e-headers-mark-for-delete))
+ (:map mu4e-view-mode-map
+ ("r" . mu4e-compose-wide-reply)
+ ("R" . mu4e-compose-reply))
+ :hook
+ (mu4e-view-mode . turn-on-visual-line-mode)
+ :config
+ (setq gnus-blocked-images "http") ;; block external images (i.e., 1 px trackers)
+ (setq mail-user-agent 'mu4e-user-agent) ;; default to mu4e for email
+ (setq message-citation-line-format "On %a %d %b %Y at %R, %f wrote:\n") ;; helps show up properly in Outlook/Gmail threads
+ (setq message-citation-line-function 'message-insert-formatted-citation-line)
+ (setq message-kill-buffer-on-exit t) ;; don't keep message buffers around
+ (setq mu4e-change-filenames-when-moving t) ;; avoid gmail dup UID issues: https://goo.gl/RTCgVa
+ (setq mu4e-completing-read-function 'completing-read) ;; use generic completing read, rather than ido
+ (setq mu4e-compose-context-policy 'ask) ;; ask for context if no context matches
+
+ ;; (setq mu4e-compose-format-flowed t) ;; plain text mails must flow correctly for recipients
+ (setq mu4e-compose-keep-self-cc t) ;; keep me in the cc list
+ (setq mu4e-compose-signature-auto-include nil) ;; don't include signature by default
+ (setq mu4e-confirm-quit nil) ;; don't ask when quitting
+ (setq mu4e-context-policy 'pick-first) ;; start with the first (default) context
+ (setq mu4e-headers-auto-update nil) ;; updating headers buffer on email is too jarring
+ (setq mu4e-root-maildir mail-dir) ;; root directory for all email accounts
+ (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
+
+ ;; Format=flowed for better plain text email handling
+ ;; This will be automatically disabled when org-msg is active
+ (setq mu4e-compose-format-flowed t)
+
+ (setq mu4e-html2text-command 'mu4e-shr2text) ;; email conversion to html via shr2text
+ (setq mu4e-mu-binary (executable-find "mu"))
+ (setq mu4e-get-mail-command (concat (executable-find "mbsync") " -a")) ;; command to sync mail
+ (setq mu4e-user-mail-address-list '("c@cjennings.net" "craigmartinjennings@gmail.com"))
+ (setq mu4e-index-update-error-warning nil) ;; don't warn me about spurious sync issues
+
+ ;; ------------------------------ Mu4e Contexts ------------------------------
+
+ (setq mu4e-contexts
+ (list
+ (make-mu4e-context
+ :name "gmail.com"
+ :match-func
+ (lambda (msg)
+ (when msg
+ (string-prefix-p "/gmail" (mu4e-message-field msg :maildir))))
+ :vars '((user-mail-address . "craigmartinjennings@gmail.com")
+ (user-full-name . "Craig Jennings")
+ (mu4e-drafts-folder . "/gmail/Drafts")
+ (mu4e-sent-folder . "/gmail/Sent")
+ (mu4e-starred-folder . "/gmail/Starred")
+ (mu4e-trash-folder . "/gmail/Trash")))
+
+ (make-mu4e-context
+ :name "cjennings.net"
+ :match-func
+ (lambda (msg)
+ (when msg
+ (string-prefix-p "/cmail" (mu4e-message-field msg :maildir))))
+ :vars '((user-mail-address . "c@cjennings.net")
+ (user-full-name . "Craig Jennings")
+ (mu4e-drafts-folder . "/cmail/Drafts")
+ (mu4e-sent-folder . "/cmail/Sent")))))
+
+ (setq mu4e-maildir-shortcuts
+ '(("/cmail/Inbox" . ?i)
+ ("/cmail/Sent" . ?s)
+ ("/gmail/Inbox" . ?I)
+ ("/gmail/Sent" . ?S)))
+
+ ;; ------------------------------ Mu4e Bookmarks -----------------------------
+
+ (setq mu4e-bookmarks
+ `((:name "cjennings inbox"
+ :query "maildir:/cmail/INBOX"
+ :key ?i)
+ (:name "cjennings unread"
+ :query "maildir:/cmail/INBOX AND flag:unread AND NOT flag:trashed"
+ :key ?u)
+ (:name "cjennings starred"
+ :query "maildir:/cmail/INBOX AND flag:flagged"
+ :key ?s)
+ (:name "cjennings large"
+ :query "maildir:/cmail/INBOX AND size:5M..999M"
+ :key ?l)
+ (:name "gmail.com inbox"
+ :query "maildir:/gmail/INBOX"
+ :key ?I)
+ (:name "gmail.com unread"
+ :query "maildir:/gmail/INBOX AND flag:unread AND NOT flag:trashed"
+ :key ?U)
+ (:name "gmail.com starred"
+ :query "maildir:/gmail/INBOX AND flag:flagged"
+ :key ?S)
+ (:name "gmail.com large"
+ :query "maildir:/gmail/INBOX AND size:5M..999M"
+ :key ?L)))
+
+ (defun no-auto-fill ()
+ "Turn off \'auto-fill-mode\'."
+ (auto-fill-mode -1))
+ (add-hook 'mu4e-compose-mode-hook #'no-auto-fill)
+
+ ;; Always BCC myself
+ ;; http://www.djcbsoftware.nl/code/mu/mu4e/Compose-hooks.html
+ (defun cj/add-cc-bcc-header ()
+ "Add CC: and BCC: to myself header."
+ (save-excursion (message-add-header
+ (concat "CC: " "\n")
+ ;; pre hook above changes user-mail-address.
+ (concat "Bcc: " user-mail-address "\n"))))
+ (add-hook 'mu4e-compose-mode-hook 'cj/add-cc-bcc-header)
+
+ ;; remap the awkward mml-attach-file to the quicker mail-add-attachment
+ (define-key mu4e-compose-mode-map [remap mml-attach-file] 'mail-add-attachment)
+
+ ;; don't allow openwith to mess with your attachments
+ (add-to-list 'mm-inhibit-file-name-handlers 'openwith-file-handler)
+
+ ;; use imagemagick to render images, if available
+ (when (fboundp 'imagemagick-register-types)
+ (imagemagick-register-types))
+
+ ;; ------------------------------ HTML Settings ------------------------------
+ ;; also see org-msg below
+
+ ;; Prefer HTML over plain text when both are available
+ (setq mu4e-view-prefer-html t)
+
+ ;; Use a better HTML renderer with more control
+ (setq mu4e-html2text-command
+ (cond
+ ;; Best option: pandoc (if available)
+ ((executable-find "pandoc")
+ "pandoc -f html -t plain --reference-links")
+ ;; Good option: w3m (better tables/formatting)
+ ((executable-find "w3m")
+ "w3m -dump -T text/html -cols 72 -o display_link_number=true")
+ ;; Fallback: built-in shr
+ (t 'mu4e-shr2text)))
+
+ ;; Configure shr (built-in HTML renderer) for better display
+ (setq shr-use-colors nil) ; Don't use colors in terminal
+ (setq shr-use-fonts nil) ; Don't use variable fonts
+ (setq shr-max-image-proportion 0.7) ; Limit image size
+ (setq shr-width 72) ; Set width for HTML rendering
+ (setq shr-bullet "• ") ; Nice bullet points
+
+ ;; Block remote images by default (privacy/security)
+ (setq mu4e-view-show-images t)
+ (setq mu4e-view-image-max-width 800)
+
+ ;; ------------------------------- View Actions ------------------------------
+ ;; define view and article menus
+
+ (defun cj/search-for-sender (msg)
+ "Search for messages sent by the sender of the message at point."
+ (mu4e-search
+ (concat "from:"
+ (mu4e-contact-email (car (mu4e-message-field msg :from))))))
+
+ ;; Custom function to toggle remote content and bind it in view mode
+ (defun cj/mu4e-toggle-remote-images ()
+ "Toggle display of remote images in current message."
+ (interactive)
+ (require 'mu4e-view)
+ (setq-local gnus-blocked-images
+ (if (equal gnus-blocked-images "http")
+ nil
+ "http"))
+ (mu4e-view-refresh))
+
+ ;; first letter is the keybinding
+ (setq mu4e-headers-actions
+ '(("bview in browser" . mu4e-action-view-in-browser)
+ ("asave attachment" . mu4e-view-mime-part-action)
+ ("oorg-contact-add" . mu4e-action-add-org-contact)
+ ("xsearch for sender" . cj/search-for-sender)
+ ("tshow this thread" . mu4e-action-show-thread)
+ ))
+
+ ;; first letter is the keybinding
+ (setq mu4e-view-actions
+ '(("bview in browser" . mu4e-action-view-in-browser)
+ ("asave attachments" . mu4e-view-mime-part-action)
+ ("esave attachments" . mu4e-view-save-attachments)
+ ("oorg-contact-add" . mu4e-action-add-org-contact)
+ ("Itoggle remote images" . cj/mu4e-toggle-remote-images)
+ ))
+ ;; ("ssave message to attach later" . mu4e-action-capture-message)
+ (setq mu4e-compose-complete-addresses nil)
+
+ ;; ---------------------------- Address Completion ---------------------------
+
+ ;; Disable company-mode in compose buffers
+ (defun cj/disable-company-in-mu4e-compose ()
+ "Disable company mode in mu4e compose buffers."
+ (company-mode -1))
+
+ (add-hook 'mu4e-compose-mode-hook #'cj/disable-company-in-mu4e-compose)
+
+ ;; NOTE: Key bindings for TAB and comma are now handled by
+ ;; mu4e-org-contacts-integration module which provides:
+ ;; - Smart TAB completion in email headers
+ ;; - Comma-triggered completion
+ ;; - Integration with org-contacts database
+
+ ;; Also disable company in org-msg-edit-mode
+ (with-eval-after-load 'org-msg
+ (add-hook 'org-msg-edit-mode-hook #'cj/disable-company-in-mu4e-compose))
+
+ ;; Don't spell-check email addresses and headers
+ (defun cj/disable-ispell-in-email-headers ()
+ "Disable ispell in email header fields."
+ (make-local-variable 'ispell-skip-region-alist)
+ (add-to-list 'ispell-skip-region-alist
+ '("^To:\\|^Cc:\\|^Bcc:" . "^[^:]*$"))
+ (add-to-list 'ispell-skip-region-alist
+ '("^From:" . "^[^:]*$"))
+ (add-to-list 'ispell-skip-region-alist
+ '("^Subject:" . "^[^:]*$")))
+
+ (add-hook 'mu4e-compose-mode-hook #'cj/disable-ispell-in-email-headers)
+ (add-hook 'message-mode-hook #'cj/disable-ispell-in-email-headers)
+ (add-hook 'org-msg-edit-mode-hook #'cj/disable-ispell-in-email-headers)
+
+ (require 'mu4e-org-contacts-integration)
+ (cj/activate-mu4e-org-contacts-integration)) ;; end use-package mu4e
+
+
+;; ---------------------------------- Org-Msg ----------------------------------
+;; user composes org mode; recipient receives html
+
+(use-package org-msg
+ :after (org mu4e)
+ :load-path "~/code/org-msg/"
+ :config
+
+ ;; inline CSS, no postamble, no TOC, no stars or footers
+ (setq org-msg-options "html-postamble:nil H:5 num:nil ^:{} toc:nil author:nil email:nil")
+
+ ;; hide org markup, show inline images
+ (setq org-msg-startup "hidestars inlineimages")
+
+ ;; new and html emails get the option for both text and html,
+ ;; text emails get text only replies
+ (setq org-msg-default-alternatives
+ '((new . (text html))
+ (reply-to-html . (text html))
+ (reply-to-text . (text))))
+
+ ;; Convert Org Citations to Blockquote
+ (setq org-msg-convert-citation t)
+
+ ;; enforce css usage; default renders too small
+ (setq org-msg-enforce-css t)
+
+ ;; always kill buffers on exit
+ (setq message-kill-buffer-on-exit nil)
+
+ ;; Override just the problematic styles with important tags
+ (setq org-msg-extra-css
+ (concat
+ "<style type=\"text/css\">\n"
+ "body { font-size: 14px !important; line-height: 1.6 !important; }\n"
+ "p { font-size: 14px !important; margin: 10px 0 !important; }\n"
+ "li { font-size: 14px !important; }\n"
+ "pre { font-size: 13px !important; }\n"
+ "code { font-size: 13px !important; }\n"
+ "</style>"))
+
+ ;; turn on org-msg in all compose buffers
+ (org-msg-mode +1))
+
+(advice-add #'mu4e-compose-reply
+ :after (lambda (&rest _) (org-msg-edit-mode)))
+(advice-add #'mu4e-compose-wide-reply
+ :after (lambda (&rest _) (org-msg-edit-mode)))
+
+(provide 'mail-config)
+;;; mail-config.el ends here
diff --git a/modules/markdown-config.el b/modules/markdown-config.el
new file mode 100644
index 00000000..438aea7e
--- /dev/null
+++ b/modules/markdown-config.el
@@ -0,0 +1,47 @@
+;;; markdown-config --- Settings for Editing Markdown -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+;;;; ------------------------- Markdown-Mode -------------------------
+
+(use-package markdown-mode
+ :mode (("README\\.md\\'" . gfm-mode)
+ ("\\.md\\'" . markdown-mode)
+ ("\\.markdown\\'" . markdown-mode))
+ :bind (:map markdown-mode-map
+ ("<f2>" . markdown-preview)) ;; use same key as compile for consistency
+ :init (setq markdown-command "multimarkdown"))
+
+;;;; ------------------------- Impatient-Mode ------------------------
+
+;; allows for live previews of your html
+;; see: https://github.com/skeeto/impatient-mode
+(use-package impatient-mode
+ :defer t
+ :config
+ (setq imp-set-user-filter 'markdown-html))
+
+;;;; --------------------- WIP: Markdown-Preview ---------------------
+
+;; the filter to apply to markdown before impatient-mode pushes it to the server
+(defun markdown-preview ()
+ (interactive)
+ (httpd-start)
+ (impatient-mode 1)
+ (setq imp-user-filter #'cj/markdown-html)
+ (cl-incf imp-last-state)
+ (imp--notify-clients)
+ ;; (browse-url-generic-function 'browse-url-xdg-open)
+ (browse-url-generic "https://localhost:8080/imp" 1))
+
+(defun cj/markdown-html (buffer)
+ (princ (with-current-buffer buffer
+ (format "<!DOCTYPE html><html><title>Impatient Markdown</title><xmp theme=\"united\" style=\"display:none;\"> %s </xmp><script src=\"http://ndossougbe.github.io/strapdown/dist/strapdown.js\"></script></html>"
+ (buffer-substring-no-properties (point-min) (point-max))))
+ (current-buffer)))
+
+(provide 'markdown-config)
+;;; markdown-config.el ends here
diff --git a/modules/media-utils.el b/modules/media-utils.el
new file mode 100644
index 00000000..ebbd57f4
--- /dev/null
+++ b/modules/media-utils.el
@@ -0,0 +1,187 @@
+;;; media-utils.el --- Utilities for Downloading and Viewing Media -*- coding: utf-8; lexical-binding: t; -*-
+;; TASK: Update commentary to include default media selection
+;;
+;;; Commentary:
+;;
+;; This library provides reusable Emacs methods for working with online and
+;; local media, to support media download playback from Emacs.
+;;
+;; Main features:
+;;
+;; - Asynchronously download videos (e.g., from YouTube and similar sites) via
+;; yt-dlp, with queueing and background management handled by the
+;; task-spooler (tsp) utility.
+;;
+;; - Asynchronously play media URLs using a user-defined choice of external
+;; media players (mpv, VLC, etc.), with automatic stream resolution via
+;; yt-dlp when required, and dynamic configuration of playback options.
+;;
+;;; Code:
+
+;; ------------------------ Default Media Configurations -----------------------
+;; Common yt-dlp format codes:
+;; 18 - 360p MP4 (good for low bandwidth)
+;; 22 - 720p MP4 (good balance of quality and size)
+;; best - best single file format
+;; best[height<=720] - best format up to 720p
+;; best[height<=1080] - best format up to 1080p
+;; bestvideo+bestaudio - best video and audio (may require ffmpeg)
+;; For more formats, run: yt-dlp -F <youtube-url>
+
+(defcustom cj/media-players
+ '((mpv . (:command "mpv"
+ :args nil
+ :name "MPV"
+ :needs-stream-url nil
+ :yt-dlp-formats nil))
+ (vlc . (:command "vlc"
+ :args nil
+ :name "VLC"
+ :needs-stream-url t
+ :yt-dlp-formats ("22" "18" "best"))) ; Try formats in order
+ (cvlc . (:command "cvlc"
+ :args "-vvv"
+ :name "cvlc"
+ :needs-stream-url t
+ :yt-dlp-formats ("22" "18" "best")))
+ (mplayer . (:command "mplayer"
+ :args nil
+ :name "MPlayer"
+ :needs-stream-url t
+ :yt-dlp-formats ("18" "22" "best")))
+ (iina . (:command "iina"
+ :args nil
+ :name "IINA"
+ :needs-stream-url t
+ :yt-dlp-formats ("best[height<=1080]" "22" "best"))))
+ "Define media players and their configurations for yt-dlp and playing.
+Each entry is (SYMBOL . PLIST). The PLIST accepts the keys :command for the
+executable name, :args for optional arguments, :name for a human-readable
+label, :needs-stream-url for a boolean flag indicating whether to extract a
+stream URL with `yt-dlp', and :yt-dlp-formats for a prioritized list of format
+strings."
+ :type '(alist :key-type symbol
+ :value-type (plist :key-type keyword
+ :value-type sexp))
+ :group 'elfeed)
+
+(defcustom cj/default-media-player 'mpv
+ "The default media player to use for videos.
+
+Should be a key from `cj/media-players'."
+ :type 'symbol
+ :group 'elfeed)
+
+(defun cj/get-available-media-players ()
+ "Return a list of available media players from `cj/media-players'."
+ (cl-loop for (player . config) in cj/media-players
+ when (executable-find (plist-get config :command))
+ collect player))
+
+(defun cj/select-media-player ()
+ "Interactively select a media player from available options."
+ (interactive)
+ (let* ((available (cj/get-available-media-players))
+ (choices (mapcar (lambda (player)
+ (let ((config (alist-get player cj/media-players)))
+ (cons (plist-get config :name) player)))
+ available))
+ (selection (completing-read
+ (format "Select media player (current: %s): "
+ (plist-get (alist-get cj/default-media-player cj/media-players) :name))
+ choices nil t))
+ (player (alist-get selection choices nil nil #'string=)))
+ (when player
+ (setq cj/default-media-player player)
+ (message "Media player set to: %s" selection))))
+
+;; ---------------------- Playing Via Default Media Player ---------------------
+
+(defun cj/media-play-it (url)
+ "Play the URL with the configured media player in an async process."
+ (let* ((player-config (alist-get cj/default-media-player cj/media-players))
+ (command (plist-get player-config :command))
+ (args (plist-get player-config :args))
+ (player-name (plist-get player-config :name))
+ (needs-stream-url (plist-get player-config :needs-stream-url))
+ (yt-dlp-formats (plist-get player-config :yt-dlp-formats))
+ (url-display (truncate-string-to-width url 50)))
+
+ (unless (executable-find command)
+ (error "%s is not installed or not in PATH" player-name))
+
+ (let* ((buffer-name (format "*%s: %s*" player-name url-display))
+ (shell-command
+ (if needs-stream-url
+ ;; Use shell substitution with yt-dlp
+ (let ((format-string (if yt-dlp-formats
+ (format "-f %s"
+ (mapconcat #'shell-quote-argument
+ yt-dlp-formats
+ "/"))
+ "")))
+ (format "%s %s $(%s %s -g %s)"
+ command
+ (or args "")
+ "yt-dlp"
+ format-string
+ (shell-quote-argument url)))
+ ;; Direct playback without yt-dlp
+ (format "%s %s %s"
+ command
+ (or args "")
+ (shell-quote-argument url)))))
+
+ (message "Playing with %s: %s" player-name url-display)
+ (cj/log-silently "DEBUG: Executing: %s" shell-command)
+
+ (let ((process (start-process-shell-command
+ player-name
+ buffer-name
+ shell-command)))
+ (set-process-sentinel
+ process
+ (lambda (proc event)
+ (cond
+ ((string-match-p "finished" event)
+ (message "✓ Finished playing: %s" url-display))
+ ((string-match-p "exited abnormally" event)
+ (message "✗ Playback failed: %s" url-display)
+ (with-current-buffer (process-buffer proc)
+ (goto-char (point-min))
+ (when (re-search-forward "ERROR:" nil t)
+ (cj/log-silently "DEBUG: yt-dlp error: %s"
+ (buffer-substring-no-properties
+ (line-beginning-position)
+ (line-end-position)))))))
+ (when (string-match-p "finished\\|exited" event)
+ (kill-buffer (process-buffer proc)))))))))
+
+;; ------------------------- Media-Download Via yt-dlp -------------------------
+
+(defun cj/yt-dl-it (url)
+ "Downloads the URL in an async shell."
+ (unless (executable-find "yt-dlp")
+ (error "The program yt-dlp is not installed or not in PATH"))
+ (unless (executable-find "tsp")
+ (error "The tsp (task-spooler) program is not installed or not in PATH"))
+ (let* ((default-directory videos-dir)
+ (buffer-name (format "*yt-dlp: %s*" (truncate-string-to-width url 50)))
+ (output-template (format "%s/%%(channel)s-%%(title)s.%%(ext)s" videos-dir))
+ (url-display (truncate-string-to-width url 50))
+ (process (start-process "yt-dlp" buffer-name
+ "tsp" "yt-dlp" "--add-metadata" "-ic"
+ "-o" output-template url)))
+ (message "Started download: %s" url-display)
+ (set-process-sentinel process
+ (lambda (proc event)
+ (cond
+ ((string-match-p "finished" event)
+ (message "✓ Finished downloading: %s" url-display))
+ ((string-match-p "exited abnormally" event)
+ (message "✗ Download failed: %s" url-display)))
+ (when (string-match-p "finished\\|exited" event)
+ (kill-buffer (process-buffer proc)))))))
+
+(provide 'media-utils)
+;;; media-utils.el ends here.
diff --git a/modules/modeline-config.el b/modules/modeline-config.el
new file mode 100644
index 00000000..2e6ed6e6
--- /dev/null
+++ b/modules/modeline-config.el
@@ -0,0 +1,55 @@
+;;; modeline-config --- Modeline Settings -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+
+;;; 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 doom-modeline-refresh-rate 0.75)) ;; Update rate in seconds
+
+;; (setq read-process-output-max (* 1024 1024)) ;; 1MB process read size for better performance
+
+(provide 'modeline-config)
+;;; modeline-config.el ends here
diff --git a/modules/mu4e-org-contacts-integration.el b/modules/mu4e-org-contacts-integration.el
new file mode 100644
index 00000000..7fe89389
--- /dev/null
+++ b/modules/mu4e-org-contacts-integration.el
@@ -0,0 +1,167 @@
+;;; mu4e-org-contacts-integration.el --- Integrate org-contacts with mu4e completion -*- lexical-binding: t; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; This module provides seamless integration between org-contacts and mu4e's
+;; email composition, enabling automatic contact completion in email fields.
+
+;;; Code:
+
+(require 'mu4e)
+(require 'org-contacts)
+
+;; ---------------------- Completion at Point Function -------------------------
+
+(defun cj/org-contacts-completion-at-point ()
+ "Provide completion-at-point function for org-contacts emails.
+This function is designed to work with mu4e's compose buffers."
+ (when (and (derived-mode-p 'mu4e-compose-mode 'org-msg-edit-mode)
+ (mail-abbrev-in-expansion-header-p))
+ (let* ((end (point))
+ (start (save-excursion
+ (re-search-backward "\\(\\`\\|[\n:,]\\)[ \t]*" nil t)
+ (goto-char (match-end 0))
+ (point)))
+ (initial (buffer-substring-no-properties start end))
+ (contacts (cj/get-all-contact-emails)))
+ (when contacts
+ (list start end
+ (completion-table-dynamic
+ (lambda (_)
+ contacts))
+ :exclusive 'no ; Allow other completion sources
+ :annotation-function
+ (lambda (s)
+ (when-let* ((email-match (string-match "<\\([^>]+\\)>" s))
+ (email (match-string 1 s)))
+ (propertize (format " [%s]" (file-name-nondirectory contacts-file))
+ 'face 'completions-annotations))))))))
+
+;; ---------------------- Smart TAB Completion -------------------------
+
+(defun cj/mu4e-org-contacts-tab-complete ()
+ "Smart TAB completion for mu4e compose buffers.
+In email header fields (To, Cc, Bcc), complete using org-contacts.
+Elsewhere, perform the default TAB action."
+ (interactive)
+ (cond
+ ;; In email header fields, use completion-at-point
+ ((mail-abbrev-in-expansion-header-p)
+ (if (and (boundp 'completion-in-region-mode) completion-in-region-mode)
+ ;; If we're already in completion mode, cycle through candidates
+ (completion-at-point)
+ ;; Start new completion
+ (completion-at-point)))
+ ;; In org-msg-edit-mode body, use org-cycle
+ ((and (eq major-mode 'org-msg-edit-mode)
+ (not (mail-abbrev-in-expansion-header-p)))
+ (org-cycle))
+ ;; Default: indent
+ (t (indent-for-tab-command))))
+
+;; ---------------------- Comma-triggered Completion -------------------------
+
+(defun cj/mu4e-org-contacts-comma-complete ()
+ "Insert comma and optionally trigger contact completion.
+In email header fields, insert comma with space and offer completion.
+Elsewhere, just insert comma."
+ (interactive)
+ (if (mail-abbrev-in-expansion-header-p)
+ (progn
+ (insert ", ") ; Insert comma with space
+ ;; Trigger completion immediately if there's no text after the comma
+ (when (looking-at-p "\\s-*$")
+ (completion-at-point)))
+ (insert ",")))
+
+;; ---------------------- Direct Insertion (Alternative) -------------------------
+
+(defun cj/mu4e-org-contacts-insert-email ()
+ "Directly insert a contact email using completing-read.
+This bypasses the completion-at-point system for direct selection."
+ (interactive)
+ (when (mail-abbrev-in-expansion-header-p)
+ (let* ((contacts (cj/get-all-contact-emails))
+ (selected (completing-read "Contact: " contacts nil t)))
+ ;; If we're not at the beginning of a field, check if we need a comma
+ (when (and (not (save-excursion
+ (skip-chars-backward " \t")
+ (or (bolp) (looking-back "[:,]" 1))))
+ (not (looking-at-p "\\s-*$")))
+ (insert ", "))
+ (insert selected))))
+
+;; ---------------------- Setup Functions -------------------------
+
+(defun cj/mu4e-org-contacts-setup-completion ()
+ "Setup org-contacts completion for mu4e compose buffers."
+ ;; Add our completion function with high priority
+ (add-hook 'completion-at-point-functions
+ #'cj/org-contacts-completion-at-point
+ -10 t) ; High priority, buffer-local
+
+ ;; Setup completion behavior
+ (setq-local completion-ignore-case t)
+ (setq-local completion-cycle-threshold 7)
+
+ ;; Add substring matching if not already present
+ (unless (member 'substring completion-styles)
+ (setq-local completion-styles
+ (append '(substring) completion-styles))))
+
+(defun cj/mu4e-org-contacts-setup-keybindings ()
+ "Setup keybindings for org-contacts completion in compose buffers."
+ ;; TAB for smart completion
+ (local-set-key (kbd "TAB") #'cj/mu4e-org-contacts-tab-complete)
+ (local-set-key (kbd "<tab>") #'cj/mu4e-org-contacts-tab-complete)
+
+ ;; Comma for comma-triggered completion
+ (local-set-key (kbd ",") #'cj/mu4e-org-contacts-comma-complete)
+
+ ;; Optional: Direct insertion binding
+ (local-set-key (kbd "C-c e") #'cj/mu4e-org-contacts-insert-email))
+
+;; ---------------------- Mode Hooks -------------------------
+
+(defun cj/mu4e-org-contacts-compose-setup ()
+ "Setup function to be called in mu4e compose mode hooks."
+ (cj/mu4e-org-contacts-setup-completion)
+ (cj/mu4e-org-contacts-setup-keybindings))
+
+;; ---------------------- Activation -------------------------
+
+(defun cj/activate-mu4e-org-contacts-integration ()
+ "Activate org-contacts integration with mu4e email composition."
+ (interactive)
+
+ ;; Ensure mu4e's built-in completion is disabled
+ (setq mu4e-compose-complete-addresses nil)
+
+ ;; Setup hooks for mu4e-compose-mode
+ (add-hook 'mu4e-compose-mode-hook #'cj/mu4e-org-contacts-compose-setup)
+
+ ;; Setup hooks for org-msg-edit-mode (HTML email composition)
+ (with-eval-after-load 'org-msg
+ (add-hook 'org-msg-edit-mode-hook #'cj/mu4e-org-contacts-compose-setup))
+
+ ;; Remove any existing mu4e completion setup
+ (remove-hook 'mu4e-compose-mode-hook #'mu4e--compose-setup-completion)
+
+ (message "mu4e org-contacts integration activated"))
+
+(defun cj/deactivate-mu4e-org-contacts-integration ()
+ "Deactivate org-contacts integration with mu4e email composition."
+ (interactive)
+
+ ;; Remove our hooks
+ (remove-hook 'mu4e-compose-mode-hook #'cj/mu4e-org-contacts-compose-setup)
+ (remove-hook 'org-msg-edit-mode-hook #'cj/mu4e-org-contacts-compose-setup)
+
+ ;; Re-enable mu4e's built-in completion if desired
+ (setq mu4e-compose-complete-addresses t)
+ (add-hook 'mu4e-compose-mode-hook #'mu4e--compose-setup-completion)
+
+ (message "mu4e org-contacts integration deactivated"))
+
+(provide 'mu4e-org-contacts-integration)
+;;; mu4e-org-contacts-integration.el ends here \ No newline at end of file
diff --git a/modules/mu4e-org-contacts-setup.el b/modules/mu4e-org-contacts-setup.el
new file mode 100644
index 00000000..9936de95
--- /dev/null
+++ b/modules/mu4e-org-contacts-setup.el
@@ -0,0 +1,24 @@
+;;; mu4e-org-contacts-setup.el --- Setup mu4e with org-contacts -*- lexical-binding: t; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Simple setup file to enable org-contacts integration with mu4e.
+;; Add this to your mail-config.el or load it after both mu4e and org-contacts.
+
+;;; Code:
+
+;; Load the integration module
+(require 'mu4e-org-contacts-integration)
+
+;; Activate the integration
+(cj/activate-mu4e-org-contacts-integration)
+
+;; Optional: If you want to use org-contacts as the primary source,
+;; you might want to disable mu4e's contact caching to save memory
+(with-eval-after-load 'mu4e
+ ;; Disable mu4e's internal contact collection
+ (setq mu4e-compose-complete-only-personal nil)
+ (setq mu4e-compose-complete-only-after nil))
+
+(provide 'mu4e-org-contacts-setup)
+;;; mu4e-org-contacts-setup.el ends here \ No newline at end of file
diff --git a/modules/music-config.el b/modules/music-config.el
new file mode 100644
index 00000000..2ee2788b
--- /dev/null
+++ b/modules/music-config.el
@@ -0,0 +1,597 @@
+;;; music-config.el --- EMMS configuration with MPD integration -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;;; Commentary:
+;;
+;; This module provides a comprehensive music management system for Emacs using
+;; EMMS (Emacs MultiMedia System) with MPD (Music Player Daemon) as the backend.
+;; It streamlines music library navigation, playlist management, and playback
+;; control within Emacs.
+;;
+;; Features:
+;; - Fuzzy file/directory selection with case-insensitive, alphanumeric ordering
+;; - Recursive directory addition to playlists
+;; - Integration with Dired/Dirvish for adding files from file managers
+;; - M3U playlist saving and loading for playlist portability
+;; - Radio station URL to m3u playlist creation
+;; - Track reordering within playlists
+;; - MPD for playback control
+;;
+;; Setup:
+;; 1. Ensure MPD is installed and running on your system
+;; 2. Set `cj/music-root' to your music library directory (default: ~/music)
+;; 3. Set `cj/music-m3u-root' to your playlist directory (default: ~/music)
+;; 4. Customize `cj/music-file-extensions' if you use formats beyond the defaults
+;;
+;; Usage:
+;; All music commands are accessed through the prefix `C-; m' by default.
+;; - `C-; m m' - Show EMMS playlist buffer
+;; - `C-; m a' - Add music via fuzzy search (files or directories)
+;; - `C-; m l' - Load an existing M3U playlist
+;; - `C-; m s' - Save current playlist as M3U
+;; - `C-; m c' - Clear current playlist
+;; - `C-; m r' - Create a radio station playlist
+;; - `C-; m SPC' - Pause/resume playback
+;;
+;; When the playlist is active, omit the prefix and enter the keys directly.
+;; Control + up and down arrows will reorder the playlist files.
+;;
+;;
+;; The fuzzy search interface (`C-; m a') presents your music library in a
+;; hierarchical view with directories marked by trailing slashes. Selection
+;; maintains strict alphanumeric ordering even during narrowing, and matching
+;; is case-insensitive. Selecting a directory adds all music files within it
+;; recursively.
+;;
+;; Custom functions use the "cj/" namespace to avoid conflicts with built-in
+;; EMMS functions. The configuration is designed to be testable, with core
+;; functions defined separately from the use-package declaration.
+;;
+;; Requirements:
+;; - MPD (Music Player Daemon) running on localhost:6600
+;;
+;; Custom functions are defined separately from the use-package declaration to facilitate unit testing.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'subr-x)
+
+;;; Custom Variables
+
+(defgroup cj/music nil
+ "Music configuration settings."
+ :group 'multimedia)
+
+(defcustom cj/music-root (expand-file-name "~/music")
+ "Root directory of your music collection."
+ :type 'directory
+ :group 'cj/music)
+
+(defcustom cj/music-m3u-root cj/music-root
+ "Directory where M3U playlists are saved and loaded."
+ :type 'directory
+ :group 'cj/music)
+
+(defcustom cj/music-keymap-prefix (kbd "C-; m")
+ "Prefix keybinding for all music commands."
+ :type 'key-sequence
+ :group 'cj/music)
+
+(defcustom cj/music-file-extensions '("flac" "mp3" "opus" "wav" "m4a" "aac" "ogg")
+ "List of valid music file extensions."
+ :type '(repeat string)
+ :group 'cj/music)
+
+;;; Local Variables
+
+(defvar-local cj/music-playlist-file nil
+ "The M3U file associated with the current playlist buffer.
+Set when loading or saving a playlist.")
+
+;;; Helper Functions
+
+(defun cj/music--valid-file-p (file)
+ "Return t if FILE is a music file with accepted extensions.
+The check is case-insensitive."
+ (when-let ((ext (file-name-extension file)))
+ (member (downcase ext) cj/music-file-extensions)))
+
+(defun cj/music--valid-directory-p (dir)
+ "Return t if DIR is a directory and is not hidden.
+Hidden directories are those starting with a dot."
+ (and (file-directory-p dir)
+ (not (string-prefix-p "." (file-name-nondirectory (directory-file-name dir))))))
+
+(defun cj/music--collect-entries-recursive (root)
+ "Recursively collect all non-hidden directories and music files under ROOT.
+Return a list of relative paths (from ROOT) sorted alphanumerically.
+Directories and files are mixed and sorted together."
+ (let ((base (file-name-as-directory root))
+ (candidates '()))
+ (cl-labels ((collect (dir)
+ (when (cj/music--valid-directory-p dir)
+ (let ((entries (directory-files dir t "^[^.]" t)))
+ (dolist (entry (sort entries #'string-lessp))
+ (cond
+ ((cj/music--valid-directory-p entry)
+ (push (string-remove-prefix base entry) candidates)
+ (collect entry))
+ ((and (file-regular-p entry)
+ (cj/music--valid-file-p entry))
+ (push (string-remove-prefix base entry) candidates))))))))
+ (collect base))
+ (nreverse candidates)))
+
+(defun cj/music--ensure-playlist-buffer ()
+ "Ensure EMMS playlist buffer exists and is in playlist mode.
+Returns the buffer or signals an error if it cannot be created."
+ (let ((buffer (get-buffer-create "*EMMS Playlist*")))
+ (with-current-buffer buffer
+ (unless (eq major-mode 'emms-playlist-mode)
+ (emms-playlist-mode)))
+ buffer))
+
+;; Helper function to extract tracks from M3U file
+(defun cj/music--m3u-file-tracks (m3u-file)
+ "Extract a list of track filenames from M3U-FILE.
+Returns a list of absolute paths."
+ (when (file-exists-p m3u-file)
+ (with-temp-buffer
+ (insert-file-contents m3u-file)
+ (let ((dir (file-name-directory m3u-file))
+ (tracks '()))
+ (goto-char (point-min))
+ (while (re-search-forward "^[^#].*$" nil t)
+ (let ((track (match-string 0)))
+ (unless (string-empty-p (string-trim track))
+ (push (if (or (file-name-absolute-p track)
+ (string-match "\\=\\(https?\\|mms\\)://" track))
+ track
+ (expand-file-name track dir))
+ tracks))))
+ (nreverse tracks)))))
+
+;; Helper function to get current playlist tracks
+(defun cj/music--playlist-tracks ()
+ "Get list of track names from the current EMMS playlist buffer."
+ (let ((tracks '()))
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((track (emms-playlist-track-at (point))))
+ (push (emms-track-name track) tracks))
+ (forward-line 1))))
+ (nreverse tracks)))
+
+;;; Interactive Commands
+
+;;;###autoload
+(defun cj/music-add-directory-recursive (directory)
+ "Add all music files under DIRECTORY recursively to the EMMS playlist.
+DIRECTORY defaults to `cj/music-root' if called non-interactively."
+ (interactive
+ (list (read-directory-name "Add directory recursively: "
+ cj/music-root nil t)))
+ (unless (file-directory-p directory)
+ (user-error "Not a directory: %s" directory))
+ (emms-add-directory-tree directory)
+ (message "Added recursively: %s" directory))
+
+(defun cj/music--collect-entries-recursive (root)
+ "Recursively collect all non-hidden directories and music files under ROOT.
+Return a list of relative paths (from ROOT) sorted alphanumerically.
+Directories have trailing '/' and everything is sorted together."
+ (let ((base (file-name-as-directory root))
+ (candidates '()))
+ (cl-labels ((collect (dir)
+ (when (cj/music--valid-directory-p dir)
+ (let ((entries (directory-files dir t "^[^.]" t)))
+ (dolist (entry entries)
+ (cond
+ ;; If it's a directory, add it with trailing / and recurse
+ ((cj/music--valid-directory-p entry)
+ (let ((rel-path (string-remove-prefix base entry)))
+ (push (concat rel-path "/") candidates))
+ (collect entry))
+ ;; If it's a music file, add it
+ ((and (file-regular-p entry)
+ (cj/music--valid-file-p entry))
+ (push (string-remove-prefix base entry) candidates))))))))
+ (collect base))
+ ;; Sort all candidates together alphanumerically
+ (sort candidates #'string-lessp)))
+
+(defun cj/music--completion-table (candidates)
+ "Create a completion table that maintains the order of CANDIDATES.
+Provides case-insensitive matching while preserving sort order."
+ (lambda (string pred action)
+ (if (eq action 'metadata)
+ '(metadata
+ (display-sort-function . identity)
+ (cycle-sort-function . identity)
+ (completion-ignore-case . t))
+ (complete-with-action action candidates string pred))))
+
+;;;###autoload
+(defun cj/music-fuzzy-select-and-add ()
+ "Select a music file or directory using fuzzy completion and add to playlist.
+Shows relative paths from =cj/music-root' with directories having trailing slashes.
+Selecting a directory adds it recursively, selecting a file adds that single file.
+Matching is case-insensitive."
+ (interactive)
+ ;; Ensure case-insensitive completion locally
+ (let* ((completion-ignore-case t)
+ (candidates (cj/music--collect-entries-recursive cj/music-root))
+ (choice-rel (completing-read "Choose music file or directory: "
+ (cj/music--completion-table candidates)
+ nil t))
+ (cleaned-choice (if (string-suffix-p "/" choice-rel)
+ (substring choice-rel 0 -1)
+ choice-rel))
+ (choice-abs (expand-file-name cleaned-choice cj/music-root)))
+ (if (file-directory-p choice-abs)
+ (cj/music-add-directory-recursive choice-abs)
+ (emms-add-file choice-abs))
+ (message "Added %s to EMMS playlist" choice-rel)))
+
+;;;###autoload
+(defun cj/music-playlist-load ()
+ "Select and load an M3U playlist file from =cj/music-m3u-root'.
+Clears the current playlist before loading and tracks the source file."
+ (interactive)
+ (let* ((m3u-files (directory-files cj/music-m3u-root t "\\.m3u\\'" t))
+ (m3u-names (mapcar #'file-name-nondirectory m3u-files)))
+ (when (null m3u-files)
+ (user-error "No M3U files found in %s" cj/music-m3u-root))
+ (let* ((choice-name (completing-read "Select playlist: " m3u-names nil t))
+ (choice-file (expand-file-name choice-name cj/music-m3u-root)))
+ (unless (file-exists-p choice-file)
+ (user-error "Playlist file does not exist: %s" choice-file))
+ (emms-playlist-clear)
+ (emms-play-playlist choice-file)
+ ;; Track the loaded file
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (setq cj/music-playlist-file choice-file))
+ (message "Loaded playlist: %s" choice-name))))
+
+;;;###autoload
+
+(defun cj/music-playlist-save ()
+ "Save the current EMMS playlist to a file in =cj/music-m3u-root'.
+Offers existing playlist names for completion but allows entering new names.
+Automatically adds .m3u extension if not present.
+Tracks the saved file for future reference."
+ (interactive)
+ (let* ((m3u-files (directory-files cj/music-m3u-root nil "\\.m3u\\'" t))
+ (m3u-names-no-ext (mapcar (lambda (f)
+ (file-name-sans-extension f))
+ m3u-files))
+ (chosen-name (completing-read "Save playlist as: "
+ m3u-names-no-ext
+ nil nil nil nil
+ (format-time-string "playlist-%Y%m%d-%H%M%S")))
+ (filename (if (string-suffix-p ".m3u" chosen-name)
+ chosen-name
+ (concat chosen-name ".m3u")))
+ (full-path (expand-file-name filename cj/music-m3u-root)))
+
+ (when (and (file-exists-p full-path)
+ (not (yes-or-no-p (format "Overwrite %s? " filename))))
+ (user-error "Aborted saving playlist"))
+ (let ((buffer (cj/music--ensure-playlist-buffer)))
+ (with-current-buffer buffer
+ ;; Use 'never to never prompt for overwrite since we already asked
+ (let ((emms-source-playlist-ask-before-overwrite nil))
+ (emms-playlist-save 'm3u full-path))
+ (setq cj/music-playlist-file full-path)))
+
+ (message "Saved playlist to %s" filename)))
+
+;;;###autoload
+(defun cj/music-move-track-up ()
+ "Move the current track one line up in the EMMS playlist buffer."
+ (interactive)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (emms-playlist-mode-move-up)))
+
+;;;###autoload
+(defun cj/music-move-track-down ()
+ "Move the current track one line down in the EMMS playlist buffer."
+ (interactive)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (emms-playlist-mode-move-down)))
+
+;;;###autoload
+(defun cj/music-create-radio-station (name url)
+ "Create a radio station M3U playlist file with NAME and URL.
+The file is saved in `cj/music-m3u-root' as NAME.m3u.
+Prompts before overwriting an existing file."
+ (interactive
+ (list (read-string "Radio station name: ")
+ (read-string "Stream URL: ")))
+ (when (string-empty-p name)
+ (user-error "Radio station name cannot be empty"))
+ (when (string-empty-p url)
+ (user-error "Stream URL cannot be empty"))
+ (let* ((safe-name (replace-regexp-in-string "[^a-zA-Z0-9_-]" "_" name))
+ (file-path (expand-file-name (concat safe-name "_Radio.m3u") cj/music-m3u-root))
+ (content (format "#EXTM3U\n#EXTINF:-1,%s\n%s\n" name url)))
+ (when (and (file-exists-p file-path)
+ (not (yes-or-no-p (format "File %s exists. Overwrite? "
+ (file-name-nondirectory file-path)))))
+ (user-error "Aborted creating radio station file"))
+ (with-temp-file file-path
+ (insert content))
+ (message "Created radio station playlist: %s" (file-name-nondirectory file-path))))
+
+;;;###autoload
+(defun cj/music-playlist-clear ()
+ "Stops playing then empties the playlist."
+ (interactive)
+ (emms-stop)
+ (emms-playlist-clear)
+ (setq cj/music-playlist-file nil)
+ (message "EMMS playlist cleared."))
+
+(defun cj/music-playlist-reload ()
+ "Reload the current playlist from its associated M3U file.
+Clears the current playlist and reloads from disk without confirmation.
+Errors if no file is associated or if the file doesn't exist."
+ (interactive)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (cond
+ ;; No file associated
+ ((not cj/music-playlist-file)
+ (user-error "No playlist file to reload - playlist exists only in memory"))
+
+ ;; File doesn't exist
+ ((not (file-exists-p cj/music-playlist-file))
+ (user-error "Playlist file no longer exists: %s"
+ (file-name-nondirectory cj/music-playlist-file)))
+
+ ;; Reload the playlist
+ (t
+ (let ((file-name (file-name-nondirectory cj/music-playlist-file)))
+ (emms-playlist-clear)
+ (emms-play-playlist cj/music-playlist-file)
+ ;; Restore the file association (emms-playlist-clear might have cleared it)
+ (setq cj/music-playlist-file cj/music-playlist-file)
+ (message "Reloaded playlist: %s" file-name))))))
+
+;;;###autoload
+(defun cj/music-playlist-edit ()
+ "Open the playlist's M3U file in other window.
+If the playlist has been modified, prompt to save first.
+If no file is associated with the playlist, show an error."
+ (interactive)
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (cond
+ ;; No file associated
+ ((not cj/music-playlist-file)
+ (message "Playlist not yet saved."))
+
+ ;; File doesn't exist (was deleted?)
+ ((not (file-exists-p cj/music-playlist-file))
+ (message "Playlist file no longer exists: %s"
+ (file-name-nondirectory cj/music-playlist-file)))
+
+ ;; Check for modifications
+ (t
+ (let ((file-tracks (cj/music--m3u-file-tracks cj/music-playlist-file))
+ (current-tracks (cj/music--playlist-tracks)))
+ (if (equal file-tracks current-tracks)
+ ;; No changes, open directly
+ (find-file-other-window cj/music-playlist-file)
+ ;; Changes detected, prompt
+ (when (yes-or-no-p "Playlist has been modified. Save before editing? ")
+ (emms-playlist-save 'm3u cj/music-playlist-file)
+ (find-file-other-window cj/music-playlist-file))))))))
+
+;;;###autoload
+(defun cj/music-playlist-toggle ()
+ "Toggle the visibility of the EMMS playlist buffer in a side window.
+Opens the playlist in a right side window if not visible, or closes it if visible."
+ (interactive)
+ (let* ((buf-name "*EMMS Playlist*")
+ (buffer (get-buffer buf-name))
+ (win (and buffer (get-buffer-window buffer))))
+ (if win
+ ;; Window exists, close it
+ (progn
+ (delete-window win)
+ (message "EMMS Playlist window closed."))
+ ;; Window doesn't exist, create/show it
+ (progn
+ ;; Ensure EMMS is loaded
+ (unless (featurep 'emms)
+ (require 'emms)
+ (require 'emms-setup)
+ (require 'emms-playlist-mode)
+ (emms-all)
+ (emms-default-players))
+
+ ;; Ensure playlist buffer exists
+ (setq buffer (cj/music--ensure-playlist-buffer))
+
+ ;; Display in side window
+ (setq win
+ (display-buffer-in-side-window
+ buffer
+ '((side . right)
+ (window-width . 0.35)))) ; Slightly narrower than AI window
+
+ ;; Select the window and move to appropriate position
+ (select-window win)
+ (with-current-buffer buffer
+ ;; If there's a current track, go to it; otherwise go to beginning
+ (if (and (fboundp 'emms-playlist-current-selected-track)
+ (emms-playlist-current-selected-track))
+ (emms-playlist-mode-center-current)
+ (goto-char (point-min))))
+
+ ;; Provide feedback
+ (let ((track-count (with-current-buffer buffer
+ (count-lines (point-min) (point-max)))))
+ (if (> track-count 0)
+ (message "EMMS Playlist displayed (%d tracks)." track-count)
+ (message "EMMS Playlist displayed (empty).")))))))
+
+;;;###autoload
+(defun cj/music-playlist-show ()
+ "Show the EMMS playlist buffer, initializing EMMS if necessary.
+If EMMS is not loaded, loads it first. Switches to the playlist buffer
+in the current window and provides appropriate feedback."
+ (interactive)
+ (let ((emms-was-loaded (featurep 'emms))
+ (playlist-buffer-exists nil)
+ (playlist-has-content nil))
+
+ ;; Load EMMS if not already loaded
+ (unless emms-was-loaded
+ (require 'emms)
+ (require 'emms-setup)
+ (require 'emms-playlist-mode)
+ (emms-all)
+ (emms-default-players))
+
+ ;; Check if playlist buffer exists
+ (when (get-buffer "*EMMS Playlist*")
+ (setq playlist-buffer-exists t)
+ (with-current-buffer "*EMMS Playlist*"
+ (setq playlist-has-content (> (point-max) (point-min)))))
+
+ ;; Ensure playlist buffer exists and switch to it
+ (switch-to-buffer (cj/music--ensure-playlist-buffer))
+
+ ;; Provide appropriate feedback
+ (cond
+ ((not emms-was-loaded)
+ (message "EMMS started. Current Playlist empty."))
+ ((and playlist-buffer-exists playlist-has-content)
+ (message "EMMS running. Displaying Current Playlist."))
+ (t
+ (message "EMMS running. Current Playlist empty.")))))
+
+;; ------------------------------- EMMS Settings -------------------------------
+
+(use-package emms
+ :defer t
+ :init
+ ;; Create music keymap before package loads
+ (defvar cj/music-map (make-sparse-keymap)
+ "Keymap for music commands.")
+
+ :commands (emms-mode-line-mode)
+ :config
+ ;; Basic EMMS setup
+ (require 'emms-setup)
+ (require 'emms-player-mpd)
+ (require 'emms-playlist-mode)
+ (require 'emms-source-file)
+ (require 'emms-source-playlist)
+
+ (emms-all)
+ (emms-default-players)
+
+ ;; MPD configuration
+ (add-to-list 'emms-player-list 'emms-player-mpd)
+ (setq emms-player-mpd-server-name "localhost"
+ emms-player-mpd-server-port "6600"
+ emms-player-mpd-music-directory cj/music-root)
+
+ ;; EMMS settings
+ (setq emms-source-file-default-directory cj/music-root
+ emms-playlist-buffer-name "*EMMS Playlist*"
+ emms-playlist-default-major-mode 'emms-playlist-mode)
+
+ ;; modeline shows nothing
+ (emms-playing-time-disable-display)
+ (emms-mode-line-mode -1)
+
+ ;; if mpv, don't display album art (interruptive)
+ (add-to-list 'emms-player-mpv-parameters "--no-audio-display")
+
+ ;; Start MPD connection
+ (condition-case err
+ (emms-player-mpd-connect)
+ (error (message "Failed to connect to MPD: %s" err)))
+
+ :bind-keymap
+ ("C-; m" . cj/music-map)
+
+ :bind
+ (:map emms-playlist-mode-map
+
+ ;; playlist playing
+ ("p" . emms-playlist-mode-go) ;; start playing the playlist
+ ("SPC" . emms-pause) ;; pause playing the playlist
+ ("s" . emms-stop) ;; stop playing the playlist
+ ("x" . emms-shuffle) ;; shuffle the playlist
+ ("q" . emms-playlist-mode-bury-buffer) ;; quit the playlist
+
+ ;; playlist maniuplation
+ ("a" . cj/music-fuzzy-select-and-add) ;; add to playlist
+ ("C" . cj/music-playlist-clear) ;; clear playlist
+ ("L" . cj/music-playlist-load) ;; load an existing playlist
+ ("e" . cj/music-playlist-edit) ;; edit an existing playlist
+ ("R" . cj/music-playlist-reload) ;; reload an existing playlist
+ ("S" . cj/music-playlist-save) ;; save current playlist
+
+ ;; playlist track reordering
+ ("C-<up>" . emms-playlist-mode-shift-track-up) ;; move track earlier
+ ("C-<down>" . emms-playlist-mode-shift-track-down) ;; move track later
+
+ ;; Create Radio station m3u
+ ("r" . cj/music-create-radio-station)
+
+ ;; Volume controls (MPD)
+ ("-" . emms-volume-lower)
+ ("=" . emms-volume-raise))
+
+ (:map cj/music-map
+ ;; EMMS show playlist.
+ ("m" . cj/music-playlist-toggle)
+ ("M" . cj/music-playlist-show)
+
+ ;; Add artists and albums (directories) and songs (files) in fuzzy search
+ ("a" . cj/music-fuzzy-select-and-add)
+
+ ;; Create Radio station m3u
+ ("r" . cj/music-create-radio-station)
+
+ ;; Playback controls
+ ("SPC" . emms-pause)
+ ("s" . emms-stop)
+ ("p" . emms-playlist-mode-go)
+ ("x" . emms-shuffle)))
+
+(global-unset-key (kbd "<f10>"))
+(global-set-key (kbd "<f10>") #'cj/music-playlist-toggle)
+
+;; TASK: Complete the Dired / EMMS integration
+;; (defun cj/music-add-dired-selection ()
+;; "Add selected files or directories in Dired/Dirvish to the EMMS playlist.
+;; If region is active, add marked files. Otherwise, add file at point.
+;; Directories are added recursively."
+;; (interactive)
+;; (unless (derived-mode-p 'dired-mode)
+;; (user-error "This command must be run in a Dired buffer"))
+;; (let ((files (if (use-region-p)
+;; (dired-get-marked-files)
+;; (list (dired-get-file-for-visit)))))
+;; (when (null files)
+;; (user-error "No files selected"))
+;; (dolist (file files)
+;; (if (file-directory-p file)
+;; (cj/music-add-directory-recursive file)
+;; (if (cj/music--valid-file-p file)
+;; (emms-add-file file)
+;; (message "Skipping non-music file: %s" file))))
+;; (message "Added %d item(s) to EMMS playlist" (length files))))
+;;
+;; TASK: add the dired keymapping to the above function
+;; ("^" . cj/music-add-dired-selection)
+
+(provide 'music-config)
+;;; music-config.el ends here
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el
new file mode 100644
index 00000000..b2cb1c10
--- /dev/null
+++ b/modules/org-agenda-config.el
@@ -0,0 +1,290 @@
+;;; org-agenda-config --- Org-Agenda/Todo Config -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; Agenda views are tied to the F8 (fate) key.
+;;
+;; "We are what we repeatedly do.
+;; Excellence, then, is not an act, but a habit"
+;; -- Aristotle
+;;
+;; "...watch your actions, they become habits;
+;; watch your habits, they become character;
+;; watch your character, for it becomes your destiny."
+;; -- Lao Tzu
+;;
+;;
+;; f8 - MAIN AGENDA which organizes all tasks and events into:
+;; - all unfinished priority A tasks
+;; - the weekly schedule, including the habit consistency graph
+;; - all priority B tasks
+;;
+;; C-f8 - TASK LIST containing all tasks from all agenda targets.
+;;
+;; M-f8 - TASK LIST containing all tasks from just the current org-mode buffer.
+;;
+;; NOTE:
+;; Files that contain information relevant to the agenda will be found in the
+;; following places: the schedule-file, org-roam notes tagged as 'Projects' and
+;; project todo.org files found in project-dir and code-dir.
+
+;;; Code:
+(require 'user-constants)
+
+(use-package org-agenda
+ :ensure nil ;; built-in
+ :after (org org-roam)
+ :config
+ (setq org-agenda-prefix-format '((agenda . " %i %-25:c%?-12t% s")
+ (timeline . " % s")
+ (todo . " %i %-25:c")
+ (tags . " %i %-12:c")
+ (search . " %i %-12:c")))
+ (setq org-agenda-dim-blocked-tasks 'invisible)
+ (setq org-agenda-skip-scheduled-if-done nil)
+ (setq org-agenda-remove-tags t)
+ (setq org-agenda-compact-blocks t)
+
+ ;; display the agenda from the bottom
+ (add-to-list 'display-buffer-alist
+ '("\\*Org Agenda\\*"
+ (display-buffer-reuse-mode-window display-buffer-below-selected)
+ (dedicated . t)
+ (window-height . fit-window-to-buffer)))
+
+ ;; reset s-left/right each time org-agenda is enabled
+ (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)))
+
+ ;; build org-agenda-list for the first time after emacs init completes.
+ (add-hook 'emacs-startup-hook #'cj/build-org-agenda-list))
+
+;; ------------------------ Add Files To Org Agenda List -----------------------
+;; finds files named 'todo.org' (case insensitive) and adds them to
+;; org-agenda-files list.
+
+(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
+ (append (directory-files-recursively directory
+ "^[Tt][Oo][Dd][Oo]\\.[Oo][Rr][Gg]$" t)
+ org-agenda-files)))
+
+;; ---------------------------- Rebuild Org Agenda ---------------------------
+;; builds the org agenda list from all agenda targets.
+;; 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."
+ (interactive)
+ (let ((start-time (current-time)))
+ ;; reset org-agenda-files to inbox-file, schedule-file, contacts-file
+ (setq org-agenda-files (list inbox-file schedule-file gcal-file contacts-file))
+
+ (cj/add-files-to-org-agenda-files-list projects-dir)
+ (cj/add-files-to-org-agenda-files-list code-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)))
+
+(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)
+ (cj/build-org-agenda-list)
+ (org-agenda "a" "t"))
+(global-set-key (kbd "C-<f8>") #'cj/todo-list-all-agenda-files)
+
+;; ------------------------- Agenda List Current Buffer ------------------------
+;; an agenda listing tasks from just the current buffer.
+
+(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)
+ (let ((org-agenda-files (list buffer-file-name)))
+ (org-agenda "a" "t"))
+ (message (concat "Your org agenda request based on '" (buffer-name (current-buffer))
+ "' failed because it's not an org buffer."))))
+(global-set-key (kbd "M-<f8>") #'cj/todo-list-from-this-buffer)
+
+;; -------------------------------- Main Agenda --------------------------------
+;; my custom agenda command from all available agenda targets. adapted from:
+;; https://blog.aaronbieber.com/2016/09/24/an-agenda-for-life-with-org-mode.html
+
+(defvar cj/main-agenda-hipri-title "HIGH PRIORITY UNRESOLVED TASKS"
+ "String to announce the high priority section of the main agenda.")
+
+(defvar cj/main-agenda-overdue-title "OVERDUE"
+ "String to announce the overdue section of the main agenda.")
+
+(defvar cj/main-agenda-schedule-title "SCHEDULE"
+ "String to announce the schedule section of the main agenda.")
+
+(defvar cj/main-agenda-tasks-title "PRIORITY B"
+ "String to announce the schedule section of the main agenda.")
+
+(defun cj/org-skip-subtree-if-habit ()
+ "Skip an agenda entry if it has a STYLE property equal to \"habit\"."
+ (let ((subtree-end (save-excursion (org-end-of-subtree t))))
+ (if (string= (org-entry-get nil "STYLE") "habit")
+ subtree-end
+ nil)))
+
+(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)))
+ (todo-state (org-get-todo-state))
+ (style (org-entry-get nil "STYLE"))
+ (deadline (org-entry-get nil "DEADLINE"))
+ (scheduled (org-entry-get nil "SCHEDULED"))
+ (today (org-time-string-to-absolute (format-time-string "%Y-%m-%d")))
+ (deadline-day (and deadline (org-time-string-to-absolute deadline)))
+ (scheduled-day (and scheduled (org-time-string-to-absolute scheduled))))
+ (if (or (not todo-state) ; no todo keyword
+ (member todo-state org-done-keywords) ; done/completed tasks
+ (string= style "habit"))
+ subtree-end ; skip if done or habit
+ (let ((overdue (or (and deadline-day (< deadline-day today))
+ (and scheduled-day (< scheduled-day today)))))
+ (if overdue
+ nil ; do not skip, keep this entry
+ subtree-end))))) ; skip if not overdue
+
+(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)))
+ (pri-current (org-get-priority (thing-at-point 'line t))))
+ (if (= pri-value pri-current)
+ subtree-end
+ nil)))
+
+(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)
+ subtree-end
+ nil)))
+
+(setq org-agenda-custom-commands
+ '(("d" "Daily Agenda with Tasks"
+ ((alltodo ""
+ ((org-agenda-skip-function #'cj/org-agenda-skip-subtree-if-not-overdue)
+ (org-agenda-overriding-header cj/main-agenda-overdue-title)
+ (org-agenda-prefix-format " %i %-15:c%?-15t% s")))
+ (tags "PRIORITY=\"A\""
+ ((org-agenda-skip-function '(org-agenda-skip-entry-if 'todo 'done))
+ (org-agenda-overriding-header cj/main-agenda-hipri-title)
+ (org-agenda-prefix-format " %i %-15:c%?-15t% s")))
+ (agenda ""
+ ((org-agenda-start-day "0d")
+ (org-agenda-span 8)
+ (org-agenda-start-on-weekday nil)
+ (org-agenda-overriding-header cj/main-agenda-schedule-title)
+ (org-agenda-prefix-format " %i %-15:c%?-15t% s")))
+ (alltodo ""
+ ((org-agenda-skip-function '(or (cj/org-skip-subtree-if-habit)
+ (cj/org-skip-subtree-if-priority ?A)
+ (cj/org-skip-subtree-if-priority ?C)
+ (cj/org-skip-subtree-if-priority ?D)
+ (cj/org-skip-subtree-if-keyword '("PROJECT"))
+ (org-agenda-skip-if nil '(scheduled deadline))))
+ (org-agenda-overriding-header cj/main-agenda-tasks-title)
+ (org-agenda-prefix-format " %i %-15:c%?-15t% s"))))
+ ((org-agenda-compact-blocks nil)))))
+
+
+(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\"
+- All todo.org files in projects-dir and code-dir"
+ (interactive)
+ (cj/build-org-agenda-list)
+ (org-agenda "a" "d"))
+(global-set-key (kbd "<f8>") #'cj/main-agenda-display)
+
+;; ------------------------- Add Timestamp To Org Entry ------------------------
+;; simply adds a timestamp to put the org entry on an agenda
+
+(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")
+ (org-end-of-line)
+ (save-excursion
+ (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
+
+(use-package alert
+ :config
+ (setq alert-fade-time 10) ;; seconds to vanish alert
+ (setq alert-default-style 'libnotify)) ;; works well with dunst
+
+(use-package org-alert
+ :after alert org-agenda
+ :commands (org-alert-enable org-alert-check)
+ :bind
+ ("C-c A" . org-alert-check)
+ :config
+ ;; Set org-alert settings
+ (setq org-alert-interval 300) ;; seconds between agenda checks (5 minutes)
+ (setq org-alert-notify-cutoff 10) ;; minutes before a deadline to notify
+ (setq org-alert-notify-after-event-cutoff 5) ;; stop alerts 5 mins after deadline
+ (setq org-alert-notification-title "Reminder"))
+
+;; Enable org-alert timer with message
+(defun cj/org-alert-enable-with-message ()
+ (org-alert-enable)
+ (message "org-alert timer enabled with interval %d seconds" org-alert-interval))
+
+;; Alert when idle post Emacs startup
+(add-hook 'emacs-startup-hook
+ (lambda ()
+ (run-with-idle-timer 1 nil #'cj/org-alert-enable-with-message)))
+
+
+(provide 'org-agenda-config)
+;;; org-agenda-config.el ends here
diff --git a/modules/org-babel-config.el b/modules/org-babel-config.el
new file mode 100644
index 00000000..3ed9dabc
--- /dev/null
+++ b/modules/org-babel-config.el
@@ -0,0 +1,151 @@
+;;; org-babel-config.el --- Org Babel/Tempo Config -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; All Org-Babel and Org-Tempo Packages, Settings, and Languages.
+
+;;; Code:
+
+;; ------------------------------- Org Babel Core ------------------------------
+;; general org babel settings
+
+(use-package ob-core
+ :ensure nil ;; built-in
+ :after org
+ :defer .5
+ :hook
+ (org-babel-after-execute-hook . org-redisplay-inline-images) ;; for seeing inline dotgraph images
+ :config
+ (setq org-src-fontify-natively t) ;; fontify the code in blocks
+ (setq org-src-tab-acts-natively t) ;; tabs act like in language major mode buffer
+ (setq org-src-window-setup 'current-window) ;; don't split window when source editing wih C-c '
+ (setq org-confirm-babel-evaluate nil) ;; just evaluate the source code
+ (setq org-babel-default-header-args
+ (cons '(:tangle . "yes")
+ (assq-delete-all :tangle org-babel-default-header-args)))) ;; default header args for babel
+
+
+;; ------------------- Babel Execution Confirmation Toggle -------------------
+;; org-babel verifies before each execution
+
+(defun babel-confirm (flag)
+ "Report the setting of `org-confirm-babel-evaluate'.
+
+If invoked with \[universal-argument], toggle the setting based on FLAG.
+FLAG is the raw prefix argument passed interactively."
+ (interactive "P")
+ (if (equal flag '(4))
+ (setq org-confirm-babel-evaluate (not org-confirm-babel-evaluate)))
+ (message "Babel evaluation confirmation is %s"
+ (if org-confirm-babel-evaluate "on" "off")))
+
+
+;; ---------------------------- Org Babel Languages ----------------------------
+;; create executable code blocks in a language within org-mode
+
+(use-package ob-awk
+ :ensure nil ;; built-in
+ :defer .5
+ :after ob-core
+ :commands
+ (org-babel-execute:awk
+ org-babel-expand-body:awk))
+
+(use-package ob-dot
+ :ensure nil ;; built-in
+ :defer .5
+ :after ob-core
+ :commands
+ (org-babel-execute:dot
+ org-babel-expand-body:dot)
+ :config
+ ;; https://stackoverflow.com/questions/16770868/org-babel-doesnt-load-graphviz-editing-mode-for-dot-sources
+ (add-to-list 'org-src-lang-modes (quote ("dot" . graphviz-dot))))
+
+(use-package ob-emacs-lisp
+ :ensure nil ;; built-in
+ :defer .5
+ :after ob-core
+ :commands
+ (org-babel-execute:emacs-lisp
+ org-babel-expand-body:emacs-lisp))
+
+(use-package ob-latex
+ :ensure nil ;; built-in
+ :defer .5
+ :after ob-core
+ :commands
+ (org-babel-execute:latex
+ org-babel-expand-body:latex))
+
+(use-package ob-python
+ :ensure nil ;; built-in
+ :defer .5
+ :after ob-core
+ :commands
+ (org-babel-execute:python
+ org-babel-variable-assignments:python
+ org-babel-load-session:python
+ org-babel-prep-session:python))
+
+(use-package ob-scheme
+ :ensure nil ;; built-in
+ :defer .5
+ :after ob-core
+ :commands
+ (org-babel-execute:scheme
+ org-babel-expand-body:scheme))
+
+;; allows for shell, bash, and fish
+(use-package ob-shell
+ :ensure nil ;; built-in
+ :defer .5
+ :after ob-core
+ :commands
+ (org-babel-execute:shell
+ org-babel-prep-session:shell
+ org-babel-load-session:shell
+ org-babel-variable-assignments:shell))
+
+(use-package ob-sed
+ :ensure nil ;; built-in
+ :defer .5
+ :after ob-core
+ :commands (org-babel-execute:sed))
+
+;; --------------------------------- Org-Tempo ---------------------------------
+;; expands snippets to babel code blocks using templates
+
+(use-package org-tempo
+ :defer .5
+ :ensure nil ;; built-in
+ :after ob-core
+ :config
+ (add-to-list 'org-structure-template-alist '("awk" . "src awk"))
+ (add-to-list 'org-structure-template-alist '("sed" . "src sed"))
+ (add-to-list 'org-structure-template-alist '("bash" . "src bash"))
+ (add-to-list 'org-structure-template-alist '("zsh" . "src zsh"))
+ (add-to-list 'org-structure-template-alist '("dot" . "src dot :file temp.png :cmdline -Kdot -Tpng"))
+ (add-to-list 'org-structure-template-alist '("el" . "src emacs-lisp"))
+ (add-to-list 'org-structure-template-alist '("js" . "src javascript"))
+ (add-to-list 'org-structure-template-alist '("java" . "src javas"))
+ (add-to-list 'org-structure-template-alist '("json" . "src json"))
+ (add-to-list 'org-structure-template-alist '("latex" . "src latex"))
+ (add-to-list 'org-structure-template-alist '("py" . "src python"))
+ (add-to-list 'org-structure-template-alist '("scheme" . "src scheme"))
+ (add-to-list 'org-structure-template-alist '("shell" . "src shell"))
+ (add-to-list 'org-structure-template-alist '("yaml" . "src yaml"))
+ ;; miscellaneous
+ (add-to-list 'org-structure-template-alist '("example" . "example"))
+ (add-to-list 'org-structure-template-alist '("quote" . "quote"))
+ (add-to-list 'org-structure-template-alist '("response" . "response"))
+ (add-to-list 'org-structure-template-alist '("output" . "output")))
+
+;; requires ob-racket, not yet in repositories
+;; (add-to-list 'org-structure-template-alist '("sicp" . "src racket :lang sicp"))
+
+;; drop Org’s default footnote list at the end
+(setq org-html-footnote-separator "")
+
+(provide 'org-babel-config)
+;;; org-babel-config.el ends here.
diff --git a/modules/org-capture-config.el b/modules/org-capture-config.el
new file mode 100644
index 00000000..c9ee89a0
--- /dev/null
+++ b/modules/org-capture-config.el
@@ -0,0 +1,143 @@
+;;; org-capture-config.el --- Org Capture Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Customizations related to org-capture and org-refile is here.
+;; This includes 'cj/org-webpage-clipper' functionality.
+
+;; To ensure the code below is only loaded after org-mode, all code is wrapped in an
+;; eval-after-load function.
+
+;; bookmarklet code:
+;; text
+;; javascript:location.href='org-protocol://capture?template=L&url=%27+encodeURIComponent(location.href)+%27&title=%27+encodeURIComponent(document.title)+%27&body=%27+encodeURIComponent(window.getSelection())
+
+;; text + selection
+;; javascript:location.href='org-protocol://capture?template=p&url=%27+encodeURIComponent(location.href)+%27&title=%27+encodeURIComponent(document.title)+%27&body=%27+encodeURIComponent(window.getSelection())
+
+;;; Code:
+(require 'user-constants)
+;; -------------------------- Event Capture Formatting -------------------------
+
+;; Formats event headlines with YY-MM-DD prefix extracted from the scheduled date
+
+(defun cj/org-capture-format-event-headline ()
+ "Format the event headline with YY-MM-DD prefix from the WHEN timestamp.
+This function is called during org-capture finalization to prepend the date
+to the event title for better organization in the schedule file."
+ (when (string= (plist-get org-capture-plist :key) "e")
+ (save-excursion
+ (goto-char (point-min))
+ ;; Find the WHEN: line with timestamp
+ (when (re-search-forward "^WHEN: \\(<[^>]+>\\)" nil t)
+ (let* ((timestamp (match-string 1))
+ ;; Parse the timestamp to extract date components
+ (parsed (org-parse-time-string timestamp))
+ (year (nth 5 parsed))
+ (month (nth 4 parsed))
+ (day (nth 3 parsed))
+ ;; Format as YY-MM-DD
+ (date-prefix (format "%02d-%02d-%02d: "
+ (mod year 100) month day)))
+ ;; Go back to the headline
+ (goto-char (point-min))
+ ;; Insert date prefix after the asterisks
+ (when (looking-at "^\\(\\*+ \\)\\(.*\\)$")
+ (replace-match (concat "\\1" date-prefix "\\2"))))))))
+
+(defun cj/org-capture-event-content ()
+ "Get the appropriate content for event capture based on context.
+Returns the selected text from either Emacs or browser (via org-protocol)
+formatted appropriately for insertion into the capture template."
+ (cond
+ ;; If called from org-protocol (browser), get the initial from org-store-link-plist
+ ((and (boundp 'org-store-link-plist)
+ org-store-link-plist
+ (plist-get org-store-link-plist :initial))
+ (concat "\n" (plist-get org-store-link-plist :initial)))
+ ;; If there's a selected region in Emacs, use it from capture plist
+ ((and (stringp (plist-get org-capture-plist :initial))
+ (not (string= (plist-get org-capture-plist :initial) "")))
+ (concat "\n" (plist-get org-capture-plist :initial)))
+ ;; Otherwise, return empty string
+ (t "")))
+
+;; ----------------------- Org Capture PDF Active Region -----------------------
+;; allows capturing the selected region from within a PDF file.
+
+(defun cj/org-capture-pdf-active-region ()
+ "Capture the active region of the pdf-view buffer.
+
+Intended to be called within an org capture template."
+ (let* ((pdf-buf-name (plist-get org-capture-plist :original-buffer))
+ (pdf-buf (get-buffer pdf-buf-name)))
+ (if (buffer-live-p pdf-buf)
+ (with-current-buffer pdf-buf
+ (car (pdf-view-active-region-text)))
+ (user-error "Buffer %S not alive" pdf-buf-name))))
+
+;; --------------------------- Org-Capture Templates ---------------------------
+;; you can bring up the org capture menu with C-c c
+
+(use-package org-protocol
+ :ensure nil ;; built-in
+ :defer .5
+ :after org
+ :config
+ ;; ORG-CAPTURE TEMPLATES
+ (setq org-protocol-default-template-key "L")
+ (setq org-capture-templates
+ '(("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)
+Captured On: %U"
+ :prepend t
+ :prepare-finalize cj/org-capture-format-event-headline)
+
+ ("E" "Epub Text" entry (file+headline inbox-file "Inbox")
+ "* %?
+#+BEGIN_QUOTE\n %i\n#+END_QUOTE
+Source: [[%:link][%(buffer-name (org-capture-get :original-buffer))]]
+Captured On: %U" :prepend t)
+
+ ;; requires cj/org-capture-pdf-active-region function defined above
+ ("P" "PDF Text" entry (file+headline inbox-file "Inbox")
+ "* %?
+#+BEGIN_QUOTE\n%(cj/org-capture-pdf-active-region)\n#+END_QUOTE
+Source:[[%L][%(buffer-name (org-capture-get :original-buffer))]]
+Captured On: %U" :prepend t)
+
+ ("p" "Link with Selection" entry (file+headline inbox-file "Inbox")
+ "* %?%:description
+#+BEGIN_QUOTE\n%i\n#+END_QUOTE
+[[%:link][%:description]]
+Captured On: %U\n" :prepend t)
+
+ ("L" "Link" entry (file+headline inbox-file "Inbox")
+ "* %?%:description
+[[%:link][%:description]]\nCaptured On: %U" :prepend t :immediate-finish t)
+
+ ("m" "Mu4e Email" entry (file+headline inbox-file "Inbox")
+ "* TODO %?
+%(if (string= \"%i\" \"\") \"\" \"\n#+BEGIN_QUOTE\n%i\n#+END_QUOTE\")
+[[%:link][%:description]]
+Captured On: %U"
+ :prepend t)
+
+ )) ;; end setq
+ ) ;; end use-package org-protocol
+
+;; ---------------------------- Simple Task Capture ----------------------------
+;; the simplest way to capture a task. Also a simple way to write this function.
+
+(define-key global-map (kbd "C-S-t") (kbd "C-c c t"))
+
+(provide 'org-capture-config)
+;;; org-capture-config.el ends here.
diff --git a/modules/org-config.el b/modules/org-config.el
new file mode 100644
index 00000000..524ff290
--- /dev/null
+++ b/modules/org-config.el
@@ -0,0 +1,267 @@
+;;; org-config --- Settings and Enhancements to Org Mode -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;; Commentary:
+
+;; Setting org-modules to org-protocol, ol-eww, ol-w3m, and ol-info removes
+;; several modules that org would otherwise load automatically.
+
+;;; Code:
+
+;; ------------------------------- Org Constants -------------------------------
+
+;; note: some constants used here are defined in init.el
+(defvar org-archive-location
+ (concat sync-dir "/archives/archive.org::datetree/")
+ "Location of the archive file.
+
+The archive file is where org entries that are archived via
+org-archive-subtree-default are placed.")
+
+;; ---------------------------- Org General Settings ---------------------------
+
+(defun cj/org-general-settings ()
+ "All general \='org-mode\=' settings are grouped and set in this function."
+
+ ;; Unbind org-cycle-agenda-files keys for use elsewhere
+ (unbind-key "C-'" org-mode-map)
+ (unbind-key "C-," org-mode-map)
+
+ ;; ORG-MODULES
+ ;; enable recognition of org-protocol:// as a parameter
+ ;; add org-habits
+ (require 'org-protocol)
+ (setq org-modules '(org-protocol ol-eww ol-w3m ol-info org-habit))
+
+ ;; GENERAL
+ (setq org-startup-folded t) ;; all org files should start in the folded state
+ (setq org-cycle-open-archived-trees t) ;; re-enable opening headings with archive tags with TAB
+ (setq org-outline-path-complete-in-steps nil)
+ (setq org-return-follows-link t) ;; hit return to follow an org-link
+ (setq org-list-allow-alphabetical t) ;; allow alpha ordered lists (i.e., a), A), a., etc.)
+
+ ;; INDENTATION
+ (setq org-startup-indented t) ;; load org files indented
+ (setq org-adapt-indentation t) ;; adapt indentation to outline node level
+ (setq org-indent-indentation-per-level 2) ;; indent two character-widths per level
+ (setq tab-width 8) ;; org-mode complains when tabs aren't @ 8
+
+ ;; IMAGES / MEDIA
+ (setq org-startup-with-inline-images t) ;; preview images by default
+ (setq org-image-actual-width '(500)) ;; keep image sizes in check
+ (setq org-yank-image-save-method 'attach) ;; attach images; save to data directory
+
+
+ (setq org-bookmark-names-plist nil) ;; don't set org-capture bookmarks
+
+ ;; force pdfs exported from org to open in emacs
+ (add-to-list 'org-file-apps '("\\.pdf\\'" . emacs)))
+
+;; -------------------------- Org Appearance Settings --------------------------
+
+(defun cj/org-appearance-settings()
+ "Set foreground, background, and font styles for org mode."
+ (interactive)
+ ;; org-hide should use fix-pitch to align indents for proportional fonts
+ (set-face-attribute 'org-hide nil :inherit 'fixed-pitch)
+ (set-face-attribute 'org-meta-line nil :inherit 'shadow)
+
+ ;; Remove foreground and background from block faces
+ (set-face-attribute 'org-block nil :foreground 'unspecified :background 'unspecified)
+ (set-face-attribute 'org-block-begin-line nil :foreground 'unspecified :background 'unspecified)
+ (set-face-attribute 'org-block-end-line nil :foreground 'unspecified :background 'unspecified)
+
+ ;; Get rid of the background on column views
+ (set-face-attribute 'org-column nil :background 'unspecified)
+ (set-face-attribute 'org-column-title nil :background 'unspecified)
+
+ ;; make sure org-links are underlined
+ (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-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-whole-heading-line t) ;; fontify the whole line for headings (for face-backgrounds)
+ (add-hook 'org-mode-hook 'prettify-symbols-mode))
+
+;; ----------------------------- Org TODO Settings ---------------------------
+
+(defun cj/org-todo-settings ()
+ "All org-todo related settings are grouped and set in this function."
+
+ ;; logging task creation, task start, and task resolved states
+ (setq org-todo-keywords '((sequence "TODO(t)" "PROJECT(p)" "DOING(i)"
+ "WAITING(w)" "VERIFY(v)" "STALLED(s)"
+ "DELEGATED(x)" "|"
+ "FAILED(f!)" "DONE(d!)" "CANCELLED(c!)")))
+
+ (setq org-todo-keyword-faces
+ '(("TODO" . "green")
+ ("PROJECT" . "blue")
+ ("DOING" . "yellow")
+ ("WAITING" . "white")
+ ("VERIFY" . "orange")
+ ("STALLED" . "light blue")
+ ("DELEGATED" . "green")
+ ("FAILED" . "red")
+ ("DONE" . "dark grey")
+ ("CANCELLED" . "dark grey")))
+
+ (setq org-highest-priority ?A)
+ (setq org-lowest-priority ?D)
+ (setq org-default-priority ?D)
+ (setq org-priority-faces '((?A . (:foreground "Cyan" :weight bold))
+ (?B . (:foreground "Yellow"))
+ (?C . (:foreground "Green"))
+ (?D . (:foreground "Grey"))))
+
+ (setq org-enforce-todo-dependencies t)
+ (setq org-enforce-todo-checkbox-dependencies t)
+ (setq org-deadline-warning-days 7) ;; warn me w/in a week of deadlines
+ (setq org-treat-insert-todo-heading-as-state-change nil) ;; log task creation
+ (setq org-log-into-drawer nil) ;; log into the drawer
+ (setq org-log-done nil) ;; don't log completions
+ (setq org-habit-graph-column 75) ;; allow space for task name
+
+ ;; inherit parents properties (sadly not schedules or deadlines)
+ (setq org-use-property-inheritance t))
+
+;; ---------------------------------- Org Mode ---------------------------------
+
+(use-package org
+ :defer .5
+ :ensure nil ;; use the built-in package
+ :pin manual ;; never upgrade from the version built-into Emacs
+ :preface
+ ;; create an org-table-map so we can use C-c t as prefix
+ (define-prefix-command 'org-table-map)
+ (global-set-key (kbd "C-c T") '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 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)
+ (custom-set-variables
+ '(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 . flyspell-mode)
+ (org-mode . turn-on-visual-line-mode)
+ (org-mode . org-indent-mode)
+
+ :config
+ ;; bug workaround for org-element--get-category: Invalid function: org-element-with-disabled-cache
+ ;; https://github.com/doomemacs/doomemacs/issues/7347
+ ;;(load-library "org-element.el")
+
+ (cj/org-general-settings)
+ (cj/org-appearance-settings)
+ (cj/org-todo-settings))
+
+
+;; ------------------------------- Org Superstar -------------------------------
+
+;; nicer bullets than simple asterisks.
+(use-package org-superstar
+ :after org
+ :config
+ (org-superstar-configure-like-org-bullets)
+ (setq org-superstar-leading-bullet ?\s)
+ (add-hook 'org-mode-hook (lambda () (org-superstar-mode 1))))
+
+;; ------------------------------- Org-Checklist -------------------------------
+;; needed for org-habits to reset checklists once task is complete
+;; this was a part of org-contrib which was deprecated
+
+(use-package org-checklist
+ :ensure nil ;; in custom folder
+ :after org
+ :load-path "custom/org-checklist.el")
+
+;; -------------------------- Org Link To Current File -------------------------
+;; get a link to the file the current buffer is associated with.
+
+(defun cj/org-link-to-current-buffer-file ()
+ "Create an Org mode link to the current file and copy it to the clipboard.
+
+The link is formatted as [[file:<file-path>][<file-name>]],
+where <file-path> is the full path to the current file and <file-name>
+is the name of the current file without any directory information.
+
+If the current buffer is not associated with a file, the function will throw an
+error."
+ (interactive)
+ (if (buffer-file-name)
+ (let* ((filename (buffer-file-name))
+ (description (file-name-nondirectory filename))
+ (link (format "[[file:%s][%s]]" filename description)))
+ (kill-new link)
+ (message "Copied Org link to current file to clipboard: %s" link))
+ (user-error "Buffer isn't associated with a file, so no link sent to clipboard")))
+
+(provide 'org-config)
+;;; org-config.el ends here
diff --git a/modules/org-contacts-config.el b/modules/org-contacts-config.el
new file mode 100644
index 00000000..899df28d
--- /dev/null
+++ b/modules/org-contacts-config.el
@@ -0,0 +1,205 @@
+;;; org-contacts-config.el --- Org Contacts Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Configuration for org-contacts, providing contact management within org-mode.
+;; Integrates with mu4e for email address completion and org-roam for linking
+;; contacts to projects and notes.
+;;
+;; Email completion functionality has been moved to mu4e-org-contacts-integration.el
+
+;;; Code:
+
+(require 'user-constants)
+
+;; --------------------------- Org Agenda Integration --------------------------
+
+(with-eval-after-load 'org-agenda
+ ;; Remove the direct hook first (in case it's already added)
+ (remove-hook 'org-agenda-finalize-hook 'org-contacts-anniversaries)
+
+ ;; 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))))
+
+ ;; Use the safe wrapper instead
+ (add-hook 'org-agenda-finalize-hook 'cj/org-contacts-anniversaries-safe))
+
+;;; ---------------------------- Capture Templates ------------------------------
+
+(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)
+
+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: "))))
+
+(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: "))))
+
+;;; ------------------------- Quick Contact Functions ---------------------------
+
+(defun cj/org-contacts-find ()
+ "Find and open a contact."
+ (interactive)
+ (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)))
+
+(defun cj/org-contacts-new ()
+ "Create a new contact."
+ (interactive)
+ (org-capture nil "C"))
+
+(defun cj/org-contacts-view-all ()
+ "View all contacts in a column view."
+ (interactive)
+ (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
+ ;; Add birthdays to agenda
+ (setq org-agenda-include-diary t)
+
+ ;; 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))))))))
+
+;;; ---------------------------- 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))))
+
+(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))))
+
+;; Simple insertion function for use outside of mu4e
+(defun cj/insert-contact-email ()
+ "Select and insert a contact's email address at point.
+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)))
+
+;;; -------------------------------- Org Contacts --------------------------------
+
+(use-package org-contacts
+ :after (org mu4e)
+ :custom
+ (org-contacts-files (list contacts-file))
+ :config
+ (require 'mu4e)
+ ;; Basic settings
+ (setq org-contacts-icon-use-gravatar nil) ; Don't fetch gravatars
+
+ ;; Birthday and anniversary handling
+ (setq org-contacts-birthday-format "It's %l's birthday today! 🎂")
+ (setq org-contacts-anniversary-format "%l's anniversary 💑")
+
+ ;; Email address formatting
+ (setq org-contacts-email-link-description-format "%s <%e>")
+
+ (setq mu4e-org-contacts-file contacts-file)
+ (add-to-list 'mu4e-headers-actions
+ '("org-contact-add" . mu4e-action-add-org-contact) t)
+ (add-to-list 'mu4e-view-actions
+ '("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))
+
+;;; ---------------------------- Org-Contacts Keymap ----------------------------
+
+;; Keymap for `org-contacts' commands
+(defvar cj/org-contacts-map
+ (let ((map (make-sparse-keymap)))
+ (define-key map "f" 'cj/org-contacts-find) ;; find contact
+ (define-key map "n" 'cj/org-contacts-new) ;; new contact
+ (define-key map "e" 'cj/insert-contact-email) ;; inserts email from org-contact
+ (define-key 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
+(global-set-key (kbd "C-c C") cj/org-contacts-map)
+
+(provide 'org-contacts-config)
+;;; org-contacts-config.el ends here \ No newline at end of file
diff --git a/modules/org-drill-config.el b/modules/org-drill-config.el
new file mode 100644
index 00000000..ea3adebf
--- /dev/null
+++ b/modules/org-drill-config.el
@@ -0,0 +1,109 @@
+;;; org-drill-config.el --- Org Drill Settings -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;; Commentary:
+;;
+;; Notes: Org-Drill
+;; Start out your org-drill with C-d s, then select your file.
+
+;; the javascript bookmark I use to capture information from the web for org-drill files:
+;; javascript:location.href='org-protocol://capture?template=d&url=%27+encodeURIComponent(location.href)+%27&title=%27+encodeURIComponent(document.title)+%27&body=%27+encodeURIComponent(window.getSelection());void(0);
+
+;; create a new bookmark and add "Drill Entry" to the name field and the above
+;; snippet to the URL field.
+
+;;; Code:
+
+(require 'user-constants)
+(require 'org-capture-config) ;; for adding org-capture-templates
+(require 'cl-lib)
+
+;; --------------------------------- Org Drill ---------------------------------
+
+(use-package org-drill
+ :after org
+ :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-add-random-noise-to-intervals-p t) ;; slightly vary number of days to repetition
+
+ (defun cj/drill-start ()
+ "Prompt user to pick a drill org file, then start an org-drill session."
+ (interactive)
+ (let* ((choices (directory-files drill-dir nil "^[^.].*\\.org$"))
+ (chosen-drill-file (completing-read "Choose Flashcard File:" choices)))
+ (find-file (concat drill-dir chosen-drill-file))
+ (org-drill)))
+
+ (defun cj/drill-edit ()
+ "Prompts the user to pick a drill org file, then opens it for editing."
+ (interactive)
+ (let* ((choices (directory-files drill-dir nil "^[^.].*\\.org$")))
+ (find-file chosen-drill-file)))
+
+ (defun cj/drill-capture ()
+ "Quickly capture a drill question."
+ (interactive)
+ (org-capture nil "d"))
+
+ (defun cj/drill-refile ()
+ "Refile to a drill file."
+ (interactive)
+ (setq org-refile-targets '((nil :maxlevel . 1)
+ (drill-dir :maxlevel . 1)))
+ (call-interactively 'org-refile))
+
+ ;; add useful org drill capture templates
+ (setq org-capture-templates
+ (append org-capture-templates
+ '(("d" "Drill Question - Web" entry
+ (file (lambda ()
+ (let ((files (directory-files drill-dir nil "^[^.].*\\.org$")))
+ (expand-file-name
+ (completing-read "Choose file: " files)
+ drill-dir))))
+ "* Item :drill:\n%?\n** Answer\n%i\nSource: [[%:link][%:description]]\nCaptured On: %U" :prepend t)
+
+ ("b" "Drill Question - EPUB" entry
+ (file (lambda ()
+ (let ((files (directory-files drill-dir nil "^[^.].*\\.org$")))
+ (expand-file-name
+ (completing-read "Choose file: " files)
+ drill-dir))))
+ "* Item :drill:\n%?\n** Answer\n%i\nSource: [[%:link][%(buffer-name (org-capture-get :original-buffer))]]\nCaptured On: %U" :prepend t)
+
+ ("f" "Drill Question - PDF" entry
+ (file (lambda ()
+ (let ((files (directory-files drill-dir nil "^[^.].*\\.org$")))
+ (expand-file-name
+ (completing-read "Choose file: " files)
+ drill-dir))))
+ "* Item :drill:\n%?\n** Answer\n%(cj/org-capture-pdf-active-region)\nSource:[[%L][%(buffer-name (org-capture-get :original-buffer))]]\nCaptured On: %U" :prepend t)))))
+
+;; ------------------------------ Org Drill Keymap -----------------------------
+
+;; Buffer & file operations prefix and keymap
+(define-prefix-command 'cj/drill-map nil
+ "Keymap for org-drill.")
+(define-key cj/custom-keymap "D" 'cj/drill-map)
+(define-key cj/drill-map "s" 'cj/drill-start)
+(define-key cj/drill-map "e" 'cj/drill-edit)
+(define-key cj/drill-map "c" 'cj/drill-capture)
+(define-key cj/drill-map "r" 'cj/drill-refile)
+(define-key cj/drill-map "R" 'org-drill-resume)
+
+;;(define-key cj/drill-map "P" 'cj/disabled)
+;;(define-key cj/drill-map "b" 'cj/disabled)
+;;(define-key cj/drill-map "d" 'cj/disabled)
+;;(define-key cj/drill-map "l" 'cj/disabled)
+;;(define-key cj/drill-map "m" 'cj/disabled)
+;;(define-key cj/drill-map "p" 'cj/disabled)
+;;(define-key cj/drill-map "t" 'cj/disabled)
+;;(define-key cj/drill-map "x" 'cj/disabled)
+
+
+(provide 'org-drill-config)
+;;; org-drill-config.el ends here.
diff --git a/modules/org-export-config.el b/modules/org-export-config.el
new file mode 100644
index 00000000..43329cc3
--- /dev/null
+++ b/modules/org-export-config.el
@@ -0,0 +1,162 @@
+;;; org-export-config.el --- Org Export Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; This module configures Org mode's export capabilities, providing multiple
+;; backend options for converting Org documents to various formats.
+;;
+;; Built-in backends configured:
+;; - LaTeX/PDF: Academic documents and presentations
+;; - HTML: Web publishing with HTML5 support
+;; - Markdown: README files and web content
+;; - ODT: Office documents for LibreOffice/MS Word
+;; - Texinfo: GNU documentation and Info files
+;;
+;; Extended via Pandoc:
+;; - Additional formats: DOCX, EPUB, reveal.js presentations
+;; - Self-contained HTML exports with embedded resources
+;; - Custom PDF export with Zathura integration
+;;
+;; Key features:
+;; - UTF-8 encoding enforced across all backends
+;; - Subtree export as default scope
+;; - reveal.js presentations with CDN or local embedding options
+;;
+;;; Code:
+
+;; --------------------------------- Org Export --------------------------------
+
+(use-package ox
+ :defer .5
+ :ensure nil
+ :after org
+ :config
+ ;; load the built-in backends
+ (dolist (feat '(ox-odt ox-latex ox-md ox-ascii))
+ (require feat))
+
+ ;; now tell Org exactly which backends to use
+ (setq org-export-backends '(odt latex md ascii))
+
+ ;; Other Settings
+ (setq org-export-preserve-breaks t) ;; keep line breaks in all Org export back-ends
+ (setq org-export-coding-system 'utf-8) ;; force utf-8 in org
+ (setq org-export-headline-levels 6) ;; export headlines 6 levels deep
+ (setq org-export-with-section-numbers nil) ;; export without section numbers by default
+ (setq org-export-with-tags nil) ;; export without tags by default
+ (setq org-export-with-tasks '("TODO")) ;; export with tasks by default
+ (setq org-export-with-tasks nil) ;; export WITHOUT tasks by default
+ (setq org-export-with-toc t) ;; export WITH table of contents by default
+ (setq org-export-initial-scope 'subtree) ;; 'buffer is your other choice
+ (setq org-export-with-author nil)) ;; export without author by default
+
+(use-package ox-html
+ :ensure nil ; Built into Org
+ :defer t
+ :after ox
+ :config
+ (setq org-html-postamble nil)
+ (setq org-html-html5-fancy t)
+ (setq org-html-head-include-default-style nil))
+
+
+(use-package ox-texinfo
+ :ensure nil ; Built into Org
+ :defer t
+ :after ox
+ :config
+ (setq org-texinfo-coding-system 'utf-8)
+ (setq org-texinfo-default-class "info")
+ (add-to-list 'org-export-backends 'texinfo))
+
+(use-package ox-pandoc
+ :defer t
+ :after ox
+ :config
+ ;; Set default options for pandoc
+ (setq org-pandoc-options '((standalone . t)
+ (mathjax . t)))
+
+ ;; Configure reveal.js specific options
+ (setq org-pandoc-options-for-revealjs
+ '((standalone . t)
+ (variable . "revealjs-url=https://cdn.jsdelivr.net/npm/reveal.js")
+ (variable . "theme=black") ; or white, league, beige, sky, night, serif, simple, solarized
+ (variable . "transition=slide") ; none, fade, slide, convex, concave, zoom
+ (variable . "slideNumber=true")
+ (variable . "hash=true")
+ (self-contained . t))) ; This embeds CSS/JS when possible
+
+ ;; Custom function for self-contained reveal.js export
+ (defun my/org-pandoc-export-to-revealjs-standalone ()
+ "Export to reveal.js with embedded dependencies."
+ (interactive)
+ (let* ((org-pandoc-options-for-revealjs
+ (append org-pandoc-options-for-revealjs
+ '((self-contained . t)
+ (embed-resources . t)))) ; pandoc 3.0+ option
+ (html-file (org-pandoc-export-to-revealjs)))
+ (when html-file
+ (browse-url-of-file html-file)
+ (message "Opened reveal.js presentation: %s" html-file))))
+
+ ;; Alternative: Download and embed local reveal.js
+ (defun my/org-pandoc-export-to-revealjs-local ()
+ "Export to reveal.js using local copy of reveal.js."
+ (interactive)
+ (let* ((reveal-dir (expand-file-name "reveal.js" user-emacs-directory))
+ (org-pandoc-options-for-revealjs
+ `((standalone . t)
+ (variable . ,(format "revealjs-url=%s" reveal-dir))
+ (variable . "theme=black")
+ (variable . "transition=slide")
+ (variable . "slideNumber=true"))))
+ (unless (file-exists-p reveal-dir)
+ (message "Downloading reveal.js...")
+ (shell-command
+ (format "git clone https://github.com/hakimel/reveal.js.git %s" reveal-dir)))
+ (org-pandoc-export-to-revealjs)))
+
+ ;; Configure specific format options (your existing config)
+ (setq org-pandoc-options-for-latex-pdf '((pdf-engine . "pdflatex")))
+ (setq org-pandoc-options-for-html5 '((html-q-tags . t)
+ (self-contained . t)))
+ (setq org-pandoc-options-for-markdown '((atx-headers . t)))
+
+ ;; Custom function to export to PDF and open with Zathura
+ (defun my/org-pandoc-export-to-pdf-and-open ()
+ "Export to PDF via pandoc and open with Zathura."
+ (interactive)
+ (let ((pdf-file (org-pandoc-export-to-latex-pdf)))
+ (when pdf-file
+ (start-process "zathura-pdf" nil "zathura" pdf-file)
+ (message "Opened %s in Zathura" pdf-file))))
+
+ ;; Updated menu entries with reveal.js options
+ (setq org-pandoc-menu-entry
+ '((?4 "to html5 and open" org-pandoc-export-to-html5-and-open)
+ (?$ "as html5" org-pandoc-export-as-html5)
+ (?r "to reveal.js (CDN) and open" org-pandoc-export-to-revealjs-and-open)
+ (?R "to reveal.js (self-contained)" my/org-pandoc-export-to-revealjs-standalone)
+ (?< "to markdown" org-pandoc-export-to-markdown)
+ (?d "to docx and open" org-pandoc-export-to-docx-and-open)
+ (?z "to pdf and open (Zathura)" my/org-pandoc-export-to-pdf-and-open))))
+
+;; hugo markdown
+;; (use-package ox-hugo
+;; :after ox)
+
+;; github flavored markdown
+;; (use-package ox-gfm
+;; :after ox)
+
+;; JIRA markup
+;; (use-package ox-jira
+;; :after ox)
+
+;; Confluence Wiki markup
+;; (use-package ox-confluence
+;; :after ox)
+
+(provide 'org-export-config)
+;;; org-export-config.el ends here.
diff --git a/modules/org-gcal-config.el b/modules/org-gcal-config.el
new file mode 100644
index 00000000..7f9b656d
--- /dev/null
+++ b/modules/org-gcal-config.el
@@ -0,0 +1,92 @@
+;;; 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)
+
+(use-package org-gcal
+ :defer t ;; unless idle timer is set below (currently disabled in config)
+ :bind ("C-; g" . org-gcal-sync)
+ :preface
+ ;; org-gcal stumbles if this doesn't exist before initial sync
+ (let ((oauth-file (concat user-emacs-directory "oauth2-auto.plist")))
+ (unless (file-exists-p oauth-file)
+ (with-temp-buffer
+ (write-file oauth-file))))
+
+ :init
+ ;; 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
+ ;; Retrieve credentials from authinfo.gpg
+ (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)))))
+
+
+ ;; 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
+ (org-gcal-reload-client-id-secret))
+
+;; Set up automatic initial sync on boot with error handling
+(run-with-idle-timer
+ 5 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
new file mode 100644
index 00000000..a3968aa6
--- /dev/null
+++ b/modules/org-noter-config.el
@@ -0,0 +1,60 @@
+;;; org-noter-config.el --- -*- coding: utf-8; lexical-binding: t; -*-
+
+;;; Commentary:
+;; Open a PDF or DjVu file, hit F6, and org-noter splits the frame with notes beside the document.
+;; Notes live under ~/sync/org-noter/reading-notes.org by default; adjust the path when prompted the first time.
+;; Use org-noter capture keys while annotating—`C-c n c` checks linked documents, and `C-c n u` rewrites stale paths after moving files.
+;; Sessions resume where you stopped thanks to automatic location saves.
+
+;;; Code:
+
+(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))
+
+(use-package org-noter
+ :after (:any org pdf-tools djvu)
+ :commands org-noter
+ :bind ("<f6>" . org-noter)
+ :config
+ ;; 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 sync-dir "/org-noter/")))
+ (setq org-noter-default-notes-file-names '("reading-notes.org"))
+ (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-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
+
+ ;; Load the integration file if it exists in your config
+ (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)))
+
+ ;; If you want to use the org-noter-pdftools integration features
+ (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)
+
+ (org-noter-enable-org-roam-integration))
+
+(provide 'org-noter-config)
+;;; org-noter-config.el ends here.
diff --git a/modules/org-refile-config.el b/modules/org-refile-config.el
new file mode 100644
index 00000000..7b50604a
--- /dev/null
+++ b/modules/org-refile-config.el
@@ -0,0 +1,78 @@
+;;; org-refile-config.el --- Org Refile Customizations -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;; Commentary:
+;; Configuration and custom functions for org-mode refiling.
+
+;;; Code:
+
+;; ----------------------------- 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=."
+ (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)
+
+(defun cj/org-refile (&optional ARG DEFAULT-BUFFER RFLOC MSG)
+ "Simply rebuilds the refile targets before calling org-refile.
+
+ARG DEFAULT-BUFFER RFLOC and MSG parameters passed to org-refile."
+ (interactive "P")
+ (cj/build-org-refile-targets)
+ (org-refile ARG DEFAULT-BUFFER RFLOC MSG))
+
+;; ----------------------------- Org Refile In File ----------------------------
+;; convenience function for scoping the refile candidates to the current buffer.
+
+(defun cj/org-refile-in-file ()
+ "Refile to a target within the current file and save the buffer."
+ (interactive)
+ (let ((org-refile-targets `(((,(buffer-file-name)) :maxlevel . 6))))
+ (call-interactively 'org-refile)
+ (save-buffer)))
+
+
+;; --------------------------------- Org Refile --------------------------------
+
+(use-package org-refile
+ :ensure nil ;; built-in
+ :defer .5
+ :bind
+ (:map org-mode-map
+ ("C-c C-w" . cj/org-refile)
+ ("C-c w" . cj/org-refile-in-file))
+ :config
+ ;; save all open org buffers after a refile is complete
+ (advice-add 'org-refile :after
+ (lambda (&rest _)
+ (org-save-all-org-buffers))))
+
+(provide 'org-refile-config)
+;;; org-refile-config.el ends here.
diff --git a/modules/org-roam-config.el b/modules/org-roam-config.el
new file mode 100644
index 00000000..741e42cf
--- /dev/null
+++ b/modules/org-roam-config.el
@@ -0,0 +1,178 @@
+;;; 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:
+;; https://systemcrafters.net/build-a-second-brain-in-emacs/5-org-roam-hacks/
+
+;;; Code:
+
+(require 'user-constants)
+
+;; ---------------------------------- Org Roam ---------------------------------
+
+(use-package org-roam
+ :after org
+ :defer 1
+ :commands (org-roam-node-find org-roam-node-insert)
+ :hook (after-init . org-roam-db-autosync-mode)
+ :custom
+ (org-roam-directory roam-dir)
+ (org-roam-dailies-directory journals-dir)
+ (org-roam-completion-everywhere t)
+ (org-roam-dailies-capture-templates
+ '(("d" "default" entry "* %<%I:%M:%S %p %Z> %?"
+ :if-new (file+head "%<%Y-%m-%d>.org"
+ "#+FILETAGS: Journal #+TITLE: %<%Y-%m-%d>"))))
+
+ (org-roam-capture-templates
+ `(("d" "default" plain "%?"
+ :if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org"
+ "#+TITLE: ${title}\n")
+ :unnarrowed t)
+
+ ("v" "v2mom" plain
+ (file ,(concat user-emacs-directory "org-roam-templates/v2mom.org"))
+ :if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org" "")
+ :unnarrowed t)
+
+ ("r" "recipe" plain
+ (file ,(concat user-emacs-directory "org-roam-templates/recipe.org"))
+ :if-new (file+head "recipes/%<%Y%m%d%H%M%S>-${slug}.org" "")
+ :unnarrowed t)
+
+ ("t" "topic" plain
+ (file ,(concat user-emacs-directory "org-roam-templates/topic.org"))
+ :if-new (file+head "%<%Y%m%d%H%M%S>-${slug}.org" "")
+ :unnarrowed t)))
+
+ :bind (("C-c n l" . org-roam-buffer-toggle)
+ ("C-c n f" . org-roam-node-find)
+ ("C-c n p" . cj/org-roam-find-node-project)
+ ("C-c n r" . cj/org-roam-find-node-recipe)
+ ("C-c n t" . cj/org-roam-find-node-topic)
+ ("C-c n i" . org-roam-node-insert)
+ ("C-c n w" . cj/org-roam-find-node-webclip)
+ :map org-mode-map
+ ("C-M-i" . completion-at-point)
+ :map org-roam-dailies-map
+ ("Y" . org-roam-dailies-capture-yesterday)
+ ("T" . org-roam-dailies-capture-tomorrow))
+ :bind-keymap
+ ("C-c n d" . org-roam-dailies-map)
+ :config
+ (setq org-log-done 'time)
+ (setq org-agenda-timegrid-use-ampm t)
+
+ (when (fboundp 'cj/build-org-refile-targets)
+ (cj/build-org-refile-targets))
+
+ ;; remove/disable if performance slows
+ ;; (setq org-element-use-cache nil) ;; disables caching org files
+
+ ;; move closed tasks to today's journal when marked done
+ (add-to-list 'org-after-todo-state-change-hook
+ (lambda ()
+ (when (equal org-state "DONE")
+ (cj/org-roam-copy-todo-to-today))))
+
+ (require 'org-roam-dailies) ;; Ensures the keymap is available
+ (org-roam-db-autosync-mode))
+
+;; ------------------------- Org Roam Insert Immediate -------------------------
+
+(defun cj/org-roam-node-insert-immediate (arg &rest args)
+ "Create new node and insert its link immediately.
+This is mainly a wrapper around org-roam-node-insert to achieve immediate finish
+to the capture. The prefix ARG and ARGS are the filter function and the rest of
+the arguments that org-roam-node-insert expects."
+ (interactive "P")
+ (let ((args (cons arg args))
+ (org-roam-capture-templates (list (append (car org-roam-capture-templates)
+ '(:immediate-finish t)))))
+ (apply #'org-roam-node-insert args)))
+(global-set-key (kbd "C-c n I") 'cj/org-roam-node-insert-immediate)
+
+;; ------------------------- Tag Listing And Filtering -------------------------
+
+(defun cj/org-roam-filter-by-tag (tag-name)
+ (lambda (node)
+ (member tag-name (org-roam-node-tags node))))
+
+(defun cj/org-roam-list-notes-by-tag (tag-name)
+ (mapcar #'org-roam-node-file
+ (seq-filter
+ (cj/org-roam-filter-by-tag tag-name)
+ (org-roam-node-list))))
+
+;; -------------------------- Org Roam Find Functions --------------------------
+
+(defun cj/org-roam-find-node (tag template-key template-file &optional subdir)
+ "List all nodes of type TAG in completing read for selection or creation.
+Interactively find or create an Org-roam node with a given TAG. Newly
+created nodes are added to the agenda and follow a template defined by
+TEMPLATE-KEY and TEMPLATE-FILE."
+ (interactive)
+ (add-hook 'org-capture-after-finalize-hook
+ #'cj/org-roam-add-node-to-agenda-files-finalize-hook)
+ (org-roam-node-find
+ nil nil (cj/org-roam-filter-by-tag tag) nil
+ :templates
+ `((,template-key ,tag plain (file ,template-file)
+ :if-new (file+head ,(concat (or subdir "") "%<%Y%m%d%H%M%S>-${slug}.org") "")
+ :unnarrowed t))))
+
+(defun cj/org-roam-find-node-topic ()
+ "List nodes of type \=`topic\=` in completing read for selection or creation."
+ (interactive)
+ (cj/org-roam-find-node "Topic" "t" (concat roam-dir "templates/topic.org")))
+
+(defun cj/org-roam-find-node-recipe ()
+ (interactive)
+ (cj/org-roam-find-node "Recipe" "r" (concat roam-dir "templates/recipe.org") "recipes/"))
+
+(defun cj/org-roam-find-node-project ()
+ "List nodes of type \='project\=' in completing read for selection or creation."
+
+ (interactive)
+ (cj/org-roam-find-node "Project" "p" (concat roam-dir "templates/project.org")))
+
+;; ---------------------- Org Capture After Finalize Hook ----------------------
+
+(defun cj/org-roam-add-node-to-agenda-files-finalize-hook ()
+ "Add the captured project file to \='org-agenda-files\='."
+ ;; Remove the hook since it was added temporarily
+ (remove-hook 'org-capture-after-finalize-hook
+ #'cj/org-roam-add-node-to-agenda-files-finalize-hook)
+
+
+ ;; Add project file to the agenda list if the capture was confirmed
+ (unless org-note-abort
+ (with-current-buffer (org-capture-get :buffer)
+ (add-to-list 'org-agenda-files (buffer-file-name)))))
+
+;; ------------------------ Org Roam Copy Done To Daily ------------------------
+
+(defun cj/org-roam-copy-todo-to-today ()
+ "Copy completed tasks to today's daily org-roam node."
+ (interactive)
+ (let ((org-refile-keep t) ;; Set this to nil to delete the original!
+ (org-roam-dailies-capture-templates
+ '(("t" "tasks" entry "%?"
+ :if-new (file+head+olp "%<%Y-%m-%d>.org"
+ "#+FILETAGS: Journal
+#+TITLE: %<%Y-%m-%d>\n" ("Completed Tasks")))))
+ (org-after-refile-insert-hook #'save-buffer)
+ today-file
+ pos)
+ (save-window-excursion
+ (org-roam-dailies--capture (current-time) t)
+ (setq today-file (buffer-file-name))
+ (setq pos (point)))
+
+ ;; Only refile if the target file is different than the current file
+ (unless (equal (file-truename today-file)
+ (file-truename (buffer-file-name)))
+ (org-refile nil nil (list "Completed Tasks" today-file nil pos)))))
+
+(provide 'org-roam-config)
+;;; org-roam-config.el ends here.
diff --git a/modules/org-webclipper.el b/modules/org-webclipper.el
new file mode 100644
index 00000000..c7b80499
--- /dev/null
+++ b/modules/org-webclipper.el
@@ -0,0 +1,145 @@
+;;; org-webclipper.el --- Web Page Clipping Workflow to Org Roam -*- coding: utf-8; lexical-binding: t; -*-
+
+;;; Commentary:
+;;
+;; Allows saving a copy of the page EWW is visiting for offline reading.
+;; In other words, it's a "Pocket/Instapaper" that collects the articles in an Emacs org-mode file.
+;;
+;; I review the articles, then add the ones I want for future reference by moving it to an
+;; org-roam file.
+;;
+;;; Code:
+
+(require 'user-constants) ;; for location of 'webclipped-file'
+
+;; ---------------------------- Org Webpage Clipper ----------------------------
+
+(defun cj/org-webpage-clipper ()
+ "Capture the current web page for later viewing in an Org file.
+
+Return the yanked content as a string so templates can insert it."
+ (interactive)
+ (let* ((source-buffer (org-capture-get :original-buffer))
+ (source-mode (with-current-buffer source-buffer major-mode)))
+ (cond
+ ((eq source-mode 'w3m-mode)
+ (with-current-buffer source-buffer
+ (org-w3m-copy-for-org-mode)))
+ ((eq source-mode 'eww-mode)
+ (with-current-buffer source-buffer
+ (org-eww-copy-for-org-mode)))
+ (t
+ (error "Not valid -- must be in w3m or eww mode")))
+ ;; extract the webpage content from the kill ring
+ (car kill-ring)))
+
+;; ------------------------------ Capture Template -----------------------------
+
+(with-eval-after-load 'org-capture
+ ;; Ensure org-capture-templates exists before adding to it
+ (unless (boundp 'org-capture-templates)
+ (setq org-capture-templates nil))
+
+ ;; Add the webclipper template to org-capture-templates
+ (add-to-list 'org-capture-templates
+ '("w" "Web Page Clipper" entry
+ (file+headline webclipped-file "Webclipped Inbox")
+ "* %a\nURL: %L\nCaptured On:%U\n%(cj/org-webpage-clipper)\n"
+ :prepend t :immediate-finish t)
+ t))
+
+;; ------------------------ Org-Branch To Org-Roam-Node ------------------------
+
+(defun cj/org-link-get-description (text)
+ "Extract the description from an org link, or return the text unchanged.
+If TEXT contains an org link like [[url][description]], return description.
+If TEXT contains multiple links, only process the first one.
+Otherwise return TEXT unchanged."
+ (if (string-match "\\[\\[\\([^]]+\\)\\]\\(?:\\[\\([^]]+\\)\\]\\)?\\]" text)
+ (let ((description (match-string 2 text))
+ (url (match-string 1 text)))
+ ;; If there's a description, use it; otherwise use the URL
+ (or description url))
+ text))
+
+(defun cj/move-org-branch-to-roam ()
+ "Move the org subtree at point to a new org-roam node.
+The node filename will be timestamp-based with the heading name.
+The heading becomes the node title, and the entire subtree is demoted to level 1.
+If the heading contains a link, extract the description for the title."
+ (interactive)
+ (unless (org-at-heading-p)
+ (user-error "Not at an org heading"))
+
+ (let* ((heading-components (org-heading-components))
+ (current-level (nth 0 heading-components))
+ (raw-title (nth 4 heading-components))
+ ;; Extract clean title from potential link
+ (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))
+ (filename (format "%s-%s.org" timestamp title-slug))
+ (filepath (expand-file-name filename org-roam-directory))
+ ;; Generate a unique ID for the node
+ (node-id (org-id-new))
+ ;; Store the subtree in a temporary buffer
+ subtree-content)
+
+ ;; Copy the subtree content
+ (org-copy-subtree)
+ (setq subtree-content (current-kill 0))
+
+ ;; Now cut it to remove from original buffer
+ (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)))
+
+ ;; 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))
+
+ ;; Sync the org-roam database
+ (org-roam-db-sync)
+
+ ;; Message to user
+ (message "'%s' added as an org-roam node." title)))
+
+;; ----------------------------- Webclipper Keymap -----------------------------
+
+;; Buffer & file operations prefix and keymap
+(define-prefix-command 'cj/webclipper-map nil
+ "Keymap for weblipper operations.")
+(define-key cj/custom-keymap "w" 'cj/webclipper-map)
+(define-key cj/webclipper-map "N" 'cj/move-org-branch-to-roam) ;; for node
+
+(provide 'org-webclipper)
+;;; org-webclipper.el ends here.
diff --git a/modules/pdf-config.el b/modules/pdf-config.el
new file mode 100644
index 00000000..c89295bc
--- /dev/null
+++ b/modules/pdf-config.el
@@ -0,0 +1,59 @@
+;;; pdf-config --- PDF Viewer Setup -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+;; --------------------------------- PDF Tools ---------------------------------
+
+(use-package pdf-tools
+ :defer t
+ :mode (("\\.pdf\\'" . pdf-view-mode))
+ :hook
+ (pdf-view-mode . pdf-view-midnight-minor-mode)
+ :custom
+ (pdf-view-display-size 'fit-page)
+ (pdf-view-resize-factor 1.1)
+ (pdf-view-midnight-colors '("#F1D5AC" . "#0F0E06")) ;; fg . bg
+ ;; Avoid searching for unicodes to speed up pdf-tools.
+ ;; ... and yes, 'ligther' is not a typo
+ (pdf-view-use-unicode-ligther nil)
+ ;; Enable HiDPI support, at the cost of memory.
+ (pdf-view-use-scaling t)
+ :bind
+ (:map pdf-view-mode-map
+ ("M" . pdf-view-midnight-minor-mode)
+ ("m" . bookmark-set)
+ ("C-=" . pdf-view-enlarge)
+ ("C--" . pdf-view-shrink)
+ ("C-c l" . org-store-link)
+ ("z" . (lambda () (interactive) (cj/open-file-with-command "zathura")))
+ ("j" . pdf-view-next-line-or-next-page)
+ ("k" . pdf-view-previous-line-or-previous-page))
+ :config
+ (pdf-tools-install :no-query)) ;; automatically compile on first launch
+
+;; ------------------------------ PDF View Restore -----------------------------
+
+;; restores the last known position on opening a pdf file.
+(use-package pdf-view-restore
+ :after pdf-tools
+ :defer 1
+ :hook
+ (pdf-view-mode . pdf-view-restore-mode)
+ :config
+ (setq pdf-view-restore-filename (concat user-emacs-directory "/.pdf-view-restore")))
+
+;; --------------------------- PDF Continuous Scroll ---------------------------
+
+;; Note: This appears to behave badly in conjunction with org-noter
+;; provides continuous scrolling of PDF documents in PDF View
+;; (use-package pdf-continuous-scroll-mode
+;; :ensure nil ;; in custom folder
+;; :after pdf-tools
+;; :load-path "custom/pdf-continuous-scroll-mode-latest.el"
+;; :hook (pdf-view-mode . pdf-continuous-scroll-mode))
+
+(provide 'pdf-config)
+;;; pdf-config.el ends here.
diff --git a/modules/prog-c.el b/modules/prog-c.el
new file mode 100644
index 00000000..4dc4c5af
--- /dev/null
+++ b/modules/prog-c.el
@@ -0,0 +1,32 @@
+;;; prog-c --- C Programming Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+;;;; ------------------------------ C-Mode Settings ------------------------------
+
+(defun cj/c-mode-settings ()
+ "Settings for \='c-mode\='."
+ (setq-default indent-tabs-mode nil) ;; spaces, not tabs
+ (setq-default c-basic-offset 4) ;; 4 spaces offset
+ (setq c-default-style "stroustrup") ;; k&r c, 2nd edition
+ (setq c-basic-indent 4) ;; indent 4 spaces
+ (setq compile-command "CFLAGS=\"-Wall -g \" make ") ;; default make command
+ (setq display-line-numbers-type t) ;; disable relative line numbers in C
+ (setq comment-auto-fill-only-comments t) ;; only auto-fill inside comments
+ (auto-fill-mode) ;; auto-fill multiline comments
+ (electric-pair-mode)) ;; automatic parenthesis pairing
+(add-hook 'c-mode-common-hook 'cj/c-mode-settings)
+
+;;;; -------------------------- Keybindings --------------------------
+
+(add-hook 'c-mode-common-hook (lambda ()
+ (local-set-key (kbd "S-<f2>") #'compile)
+ (local-set-key (kbd "S-<f3>") #'gdb)))
+
+
+
+(provide 'prog-c)
+;;; prog-c.el ends here
diff --git a/modules/prog-general.el b/modules/prog-general.el
new file mode 100644
index 00000000..589a3146
--- /dev/null
+++ b/modules/prog-general.el
@@ -0,0 +1,299 @@
+;;; prog-general --- General Programming Settings -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; This module provides general programming functionality not related to a
+;; specific programming language, such as code-folding, project management,
+;; highlighting symbols, snippets, and whitespace management.
+
+;;; Code:
+
+(require 'seq)
+
+;; --------------------- General Programming Mode Settings ---------------------
+;; keybindings, minor-modes, and prog-mode settings
+
+(defun cj/general-prog-settings ()
+ "Keybindings, minor modes, and settings for programming mode."
+ (interactive)
+ (display-line-numbers-mode) ;; show line numbers
+ (setq display-line-numbers-type 'relative) ;; display numbers relative to 'the point'
+ (setq-default display-line-numbers-width 3) ;; 3 characters reserved for line numbers
+ (turn-on-visual-line-mode) ;; word-wrapping
+ (auto-fill-mode) ;; auto wrap at the fill column set
+ (local-set-key (kbd "M-;") 'comment-dwim)) ;; comment/uncomment region as appropriate
+
+(add-hook 'prog-mode-hook #'cj/general-prog-settings)
+(add-hook 'html-mode-hook #'cj/general-prog-settings)
+(add-hook 'yaml-mode-hook #'cj/general-prog-settings)
+(add-hook 'toml-mode-hook #'cj/general-prog-settings)
+
+
+;; --------------------------------- Treesitter --------------------------------
+;; incremental language syntax parser
+
+(use-package tree-sitter
+ :defer .5)
+
+;; installs tree-sitter grammars if they're absent
+(use-package treesit-auto
+ :defer .5
+ :custom
+ (treesit-auto-install t)
+ ;; (treesit-auto-install 'prompt) ;; optional prompt instead of auto-install
+ :config
+ (treesit-auto-add-to-auto-mode-alist 'all)
+ (global-treesit-auto-mode))
+
+;; -------------------------------- Code Folding -------------------------------
+
+;; BICYCLE
+;; cycle visibility of outline sections and code blocks.
+;; additionally it can make use of the hideshow package.
+(use-package bicycle
+ :after outline
+ :defer 1
+ :hook ((prog-mode . outline-minor-mode)
+ (prog-mode . hs-minor-mode))
+ :bind (:map outline-minor-mode-map
+ ("C-<tab>" . bicycle-cycle)
+ ;; backtab is shift-tab
+ ("<backtab>" . bicycle-cycle-global)))
+
+;; --------------------------------- Projectile --------------------------------
+;; project support
+
+;; only discover projects when there's no bookmarks file
+(defun cj/projectile-schedule-project-discovery ()
+ (let ((projectile-bookmark-file (concat user-emacs-directory "/projectile-bookmarks.eld")))
+ (unless (file-exists-p projectile-bookmark-file)
+ (run-at-time "3" nil 'projectile-discover-projects-in-search-path))))
+
+(use-package projectile
+ :defer .5
+ :bind-keymap
+ ("C-c p" . projectile-command-map)
+ :bind
+ (:map projectile-command-map
+ ("r" . projectile-replace-regexp)
+ ("t" . cj/open-project-root-todo))
+ :custom
+ (projectile-auto-discover nil)
+ (projectile-project-search-path `(,code-dir ,projects-dir))
+ :config
+ (defun cj/find-project-root-file (regexp)
+ "Return first file in the current Projectile project root matching REGEXP.
+
+Match is done against (downcase file) for case-insensitivity.
+REGEXP must be a string or an rx form."
+ (when-let ((root (projectile-project-root)))
+ (seq-find (lambda (file)
+ (string-match-p (if (stringp regexp)
+ regexp
+ (rx-to-string regexp))
+ (downcase file)))
+ (directory-files root))))
+
+ (defun cj/open-project-root-todo ()
+ "Open todo.org in the current Projectile project root.
+
+If no such file exists there, display a message."
+ (interactive)
+ (if-let ((root (projectile-project-root)))
+ (let ((file (cj/find-project-root-file "^todo\\.org$")))
+ (if file
+ (find-file (expand-file-name file root))
+ (message "No todo.org in project root: %s" root)))
+ (message "Not in a Projectile project")))
+
+ (defun cj/project-switch-actions ()
+ "On =projectile-after-switch-project-hook=, open TODO.{org,md,txt} or fall back to Magit."
+ (let ((file (cj/find-project-root-file
+ (rx bos "todo." (or "org" "md" "txt") eos))))
+ (if file
+ (find-file (expand-file-name file (projectile-project-root)))
+ (magit-status (projectile-project-root)))))
+
+ ;; scan for projects if none are defined
+ (cj/projectile-schedule-project-discovery)
+
+ ;; don't reuse comp buffers between projects
+ (setq projectile-per-project-compilation-buffer t)
+ (projectile-mode)
+ (setq projectile-switch-project-action #'cj/project-switch-actions))
+
+;; groups ibuffer by projects
+(use-package ibuffer-projectile
+ :defer .5
+ :after projectile
+ :hook (ibuffer-mode . ibuffer-projectile-set-filter-groups))
+
+;; list all errors project-wide
+(use-package flycheck-projectile
+ :defer .5
+ :after projectile
+ :commands flycheck-projectile-list-errors
+ :bind
+ (:map projectile-command-map
+ ("x" . flycheck-projectile-list-errors)))
+
+;; ---------------------------------- Ripgrep ----------------------------------
+
+(use-package deadgrep
+ :after projectile
+ :bind
+ (:map projectile-command-map
+ ("G" . deadgrep) ;; project-wide search
+ ("g" . cj/deadgrep-here) ;; search in context directory
+ ("d" . cj/deadgrep-in-dir)) ;; prompt for directory
+
+ :config
+ (require 'thingatpt)
+
+ (defun cj/deadgrep--initial-term ()
+ (cond
+ ((use-region-p)
+ (buffer-substring-no-properties (region-beginning) (region-end)))
+ (t (thing-at-point 'symbol t))))
+
+ (defun cj/deadgrep-here (&optional term)
+ "Search with Deadgrep in the most relevant directory at point."
+ (interactive)
+ (let* ((root
+ (cond
+ ((derived-mode-p 'dired-mode)
+ (let ((path (dired-get-filename nil t)))
+ (cond
+ ;; If point is on a directory entry, search within that directory.
+ ((and path (file-directory-p path)) path)
+ ;; If point is on a file, search in its containing directory.
+ ((and path (file-regular-p path)) (file-name-directory path))
+ (t default-directory))))
+ (buffer-file-name
+ (file-name-directory (file-truename buffer-file-name)))
+ (t default-directory)))
+ (root (file-name-as-directory (expand-file-name root)))
+ (term (or term (read-from-minibuffer "Search: " (cj/deadgrep--initial-term)))))
+ (deadgrep term root)))
+
+ (defun cj/deadgrep-in-dir (&optional dir term)
+ "Prompt for a directory, then search there with Deadgrep."
+ (interactive)
+ (let* ((dir (or dir (read-directory-name "Search in directory: " default-directory nil t)))
+ (dir (file-name-as-directory (expand-file-name dir)))
+ (term (or term (read-from-minibuffer "Search: " (cj/deadgrep--initial-term)))))
+ (deadgrep term dir))))
+
+(with-eval-after-load 'dired
+ (define-key dired-mode-map (kbd "d") #'cj/deadgrep-here))
+
+
+;; ---------------------------------- Snippets ---------------------------------
+;; reusable code and text
+
+(use-package yasnippet
+ :defer 1
+ :bind
+ ("C-c s n" . yas-new-snippet)
+ ("C-c s e" . yas-visit-snippet-file)
+ :config
+ (setq yas-snippet-dirs '(snippets-dir))
+ (yas-global-mode 1))
+
+(use-package ivy-yasnippet
+ :after yasnippet
+ :bind
+ ("C-c s i" . ivy-yasnippet))
+
+;; --------------------- Display Color On Color Declaration --------------------
+;; display the actual color as highlight to color hex code
+
+(use-package rainbow-mode
+ :defer .5
+ :hook (prog-mode . rainbow-mode))
+
+;; ---------------------------- Symbol Overlay Mode ----------------------------
+;; Highlight symbols with keymap-enabled overlays
+;; replaces highlight-symbol-mode
+
+(use-package symbol-overlay
+ :defer .5
+ :bind-keymap
+ ("C-c C-s" . symbol-overlay-map)
+ :hook
+ (prog-mode . symbol-overlay-mode))
+
+
+;; --------------------------- Highlight Indentation ---------------------------
+
+(use-package highlight-indent-guides
+ :ensure t
+ :hook (prog-mode . cj/highlight-indent-guides-enable)
+ :config
+ ;; Disable auto face coloring to use explicit faces for better visibility across themes
+ (setq highlight-indent-guides-auto-enabled nil)
+
+ ;; Set explicit face backgrounds and foreground for the indentation guides
+ (set-face-background 'highlight-indent-guides-odd-face "darkgray")
+ (set-face-background 'highlight-indent-guides-even-face "darkgray")
+ (set-face-foreground 'highlight-indent-guides-character-face "dimgray")
+
+ (defun cj/highlight-indent-guides-enable ()
+ "Enable highlight-indent-guides with preferred settings for programming modes."
+ (setq-local highlight-indent-guides-method 'bitmap)
+ (setq-local highlight-indent-guides-responsive nil)
+ (highlight-indent-guides-mode 1))
+
+ ;; Disable in non-prog-mode buffers
+ (defun cj/highlight-indent-guides-disable-in-non-prog-modes ()
+ "Disable highlight-indent-guides-mode outside programming modes."
+ (unless (derived-mode-p 'prog-mode)
+ (highlight-indent-guides-mode -1)))
+
+ (add-hook 'after-change-major-mode-hook
+ #'cj/highlight-indent-guides-disable-in-non-prog-modes))
+
+;; ------------------------------ Highlight TODOs ------------------------------
+;; Highlights todo keywords in code for easy spotting.
+
+(use-package hl-todo
+ :defer 1
+ :hook
+ (prog-mode . hl-todo-mode)
+ :config
+ (setq hl-todo-keyword-faces
+ '(("FIXME" . "#FF0000")
+ ("BUG" . "#FF0000")
+ ("HACK" . "#FF0000")
+ ("ISSUE" . "#DAA520")
+ ("TASK" . "#DAA520")
+ ("NOTE" . "#2C780E")
+ ("WIP" . "#1E90FF"))))
+
+;; --------------------------- Whitespace Management ---------------------------
+;; trims trailing whitespace only from lines you've modified when saving buffer
+
+(use-package ws-butler
+ :defer .5
+ :commands (ws-butler-mode)
+ :init
+ (add-hook 'prog-mode-hook #'ws-butler-mode)
+ ;; no org and text mode as org branches occasionally move up a line and become invalid
+ :config
+ (setq ws-butler-convert-leading-tabs-or-spaces t))
+
+;; ----------------- Auto-Close Successful Compilation Windows -----------------
+;; close compilation windows when successful. from 'enberg' on #emacs
+
+(add-hook 'compilation-finish-functions
+ (lambda (buf str)
+ (if (null (string-match ".*exited abnormally.*" str))
+ ;;no errors, make the compilation window go away in a few seconds
+ (progn
+ (run-at-time
+ "1.5 sec" nil 'delete-windows-on
+ (get-buffer-create "*compilation*"))))))
+
+
+(provide 'prog-general)
+;;; prog-general.el ends here
diff --git a/modules/prog-go.el b/modules/prog-go.el
new file mode 100644
index 00000000..cf12cb6f
--- /dev/null
+++ b/modules/prog-go.el
@@ -0,0 +1,41 @@
+;;; prog-go --- Golang Specific Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+;; ---------------------------------- Go Setup ---------------------------------
+;; golang preferences
+
+(defun cj/go-setup ()
+ "My default code preferences for Golang."
+ (require 'tree-sitter)
+ (require 'tree-sitter-langs)
+ (require 'tree-sitter-hl)
+ (tree-sitter-hl-mode)
+ (hs-minor-mode)
+ (company-mode)
+ (setq-default tab-width 4) ;; set the tab width to 4 spaces
+ (setq-default standard-indent 4) ;; indent 4 spaces
+ (setq-default indent-tabs-mode nil) ;; disable tab characters
+ (electric-pair-mode t)) ;; match delimiters automatically
+(add-hook 'go-mode-hook 'cj/go-setup)
+
+;; ---------------------------------- Go Mode ----------------------------------
+;; go mode configuration
+
+(use-package go-mode
+ :bind (:map go-mode-map
+ ("<f6>" . gofmt)
+ ("C-c 6" . gofmt)
+ ("<f4>" . golint)
+ ("C-c 4" . golint))
+ :config
+ (add-to-list 'exec-path "~/go/bin")
+ ;; allow adding/removing fmt lines; install with:
+ ;; go install golang.org/x/tools/cmd/goimports@latest
+ (setq gofmt-command "goimports"))
+
+(provide 'prog-go)
+;;; prog-go.el ends here
diff --git a/modules/prog-lisp.el b/modules/prog-lisp.el
new file mode 100644
index 00000000..11dc7851
--- /dev/null
+++ b/modules/prog-lisp.el
@@ -0,0 +1,126 @@
+;;; prog-lisp --- Lisp Specific Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; ==== Setting up Quicklisp ====
+;; Quicklisp is a library manager for Common Lisp. It works with your existing Common Lisp
+;; implementation to download, install, and load any of over 1,500 libraries with a few
+;; simple commands.
+;; https://www.quicklisp.org/beta/
+
+;; mostly from: https://gist.github.com/jteneycke/7947353
+;; * Install SBCL
+;; sudo pacman -S sbcl # arch
+;; doas pkg install sbcl # bsd
+;; sudo apt-get install sbcl # debian
+
+;; * Install QuickLisp
+;; curl -O http://beta.quicklisp.org/quicklisp.lisp
+;; sbcl --load quicklisp.lisp --eval "(quicklisp-quickstart:install)" --quit
+;; sbcl --load ~/quicklisp/setup.lisp --eval "(ql:add-to-init-file)" --quit
+
+;; * Emacs Config
+;; (load (expand-file-name "~/quicklisp/slime-helper.el"))
+;; ;; Replace "sbcl" with the path to your implementation
+;; (setq inferior-lisp-program "/usr/bin/sbcl")
+
+;; to get readline support in SBCL's REPL, install rlwrap and run it
+;; before sbcl like so:
+
+;; $ rlwrap sbcl
+
+;;; Code:
+(require 'ert)
+
+;; -------------------------------- Elisp Setup --------------------------------
+;; run this on editing an elisp file
+
+(defun cj/elisp-setup ()
+ "My default code preferences for emacs-lisp."
+ (setq-default tab-width 4) ;; set the tab width to 4 spaces
+ (setq-default indent-tabs-mode -1) ;; disable tab characters
+ (setq-default fill-column 120) ;; wrap code at this column
+ (display-fill-column-indicator-mode)) ;; show where the fill-column is
+(add-hook 'emacs-lisp-mode-hook 'cj/elisp-setup)
+
+;; ------------------------------ Emacs Lisp REPL ------------------------------
+
+(use-package ielm
+ :ensure nil ;; built-in
+ :hook (ielm-mode . eldoc-mode)
+ :config (setq ielm-prompt "elisp> "))
+
+;; ----------------------------------- Eldoc -----------------------------------
+
+(use-package eldoc
+ :ensure nil ;; built-in
+ :hook ((c-mode-common emacs-lisp-mode) . eldoc-mode)
+ :custom
+ (eldoc-echo-area-use-multiline-p 3)
+ (eldoc-echo-area-display-truncation-message nil))
+
+;; -------------------------- ERT + Testing Libraries --------------------------
+;; unit/regression testing framework
+;; basic introduction: https://nullprogram.com/blog/2012/08/15/
+;; https://www.gnu.org/software/emacs/manual/html_node/ert/
+;; or: [[info:ert#User Input]]
+
+(use-package ert
+ :ensure nil ;; built-into emacs
+ :defer 1)
+
+;; ---------------------------------- El-Mock ----------------------------------
+
+(use-package el-mock
+ :defer 1) ;; mock/stub framework
+
+;; --------------------------------- Elisp Lint --------------------------------
+
+(use-package elisp-lint
+ :defer 1)
+
+;; ------------------------------ Package Tooling ------------------------------
+
+(use-package package-lint
+ :defer 1)
+
+(use-package flycheck-package
+ :defer 1
+ :after (flycheck package-lint)
+ :config
+ (flycheck-package-setup))
+
+(use-package package-build
+ :defer 1)
+
+;; ----------------------------- Rainbow Delimiters ----------------------------
+
+(use-package rainbow-delimiters
+ :defer .5
+ :hook
+ (emacs-lisp-mode . rainbow-delimiters-mode)
+ (lisp-mode . rainbow-delimiters-mode)
+ :config
+ (set-face-foreground 'rainbow-delimiters-depth-1-face "#c66") ;; red
+ (set-face-foreground 'rainbow-delimiters-depth-2-face "#6c6") ;; green
+ (set-face-foreground 'rainbow-delimiters-depth-3-face "#69f") ;; blue
+ (set-face-foreground 'rainbow-delimiters-depth-4-face "#cc6") ;; yellow
+ (set-face-foreground 'rainbow-delimiters-depth-5-face "#6cc") ;; cyan
+ (set-face-foreground 'rainbow-delimiters-depth-6-face "#c6c") ;; magenta
+ (set-face-foreground 'rainbow-delimiters-depth-7-face "#ccc") ;; light gray
+ (set-face-foreground 'rainbow-delimiters-depth-8-face "#999") ;; medium gray
+ (set-face-foreground 'rainbow-delimiters-depth-9-face "#666")) ;; dark gray
+
+;; -------------------------------- Geiser Guile -------------------------------
+;; Guile support in Emacs
+
+(use-package geiser-guile
+ :defer 1
+ :commands (geiser-guile)
+ :bind ("C-c G" . geiser-guile)
+ :config
+ (setq geiser-guile-binary "/usr/bin/guile"))
+
+(provide 'prog-lisp)
+;;; prog-lisp.el ends here
diff --git a/modules/prog-lsp.el b/modules/prog-lsp.el
new file mode 100644
index 00000000..764d94df
--- /dev/null
+++ b/modules/prog-lsp.el
@@ -0,0 +1,55 @@
+;;; prog-lsp --- Setup for LSP Mode -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; good reference as to what to enable/disable in lsp-mode
+;; https://emacs-lsp.github.io/lsp-mode/tutorials/how-to-turn-off/
+
+;;; Code:
+
+
+;;;;; ---------------------------- LSP Mode ---------------------------
+
+(use-package lsp-mode
+ :hook
+ ((c-mode c++-mode go-mode js-mode js-jsx-mode typescript-mode python-mode web-mode) . lsp-deferred)
+ :commands (lsp)
+ :bind (:map lsp-mode-map
+ ("C-c d" . lsp-describe-thing-at-point)
+ ("C-c a" . lsp-execute-code-action))
+ :bind-keymap ("C-c L" . lsp-command-map)
+ :config
+ (setq lsp-auto-guess-root t)
+ (setq lsp-log-io nil)
+ (setq lsp-restart 'auto-restart)
+ (setq lsp-enable-symbol-highlighting nil)
+ (setq lsp-enable-on-type-formatting nil)
+ (setq lsp-signature-auto-activate nil)
+ (setq lsp-signature-render-documentation nil)
+ (setq lsp-eldoc-hook nil)
+ (setq lsp-modeline-code-actions-enable nil)
+ (setq lsp-modeline-diagnostics-enable nil)
+ (setq lsp-headerline-breadcrumb-enable nil)
+ (setq lsp-semantic-tokens-enable nil)
+ (setq lsp-enable-folding nil)
+ (setq lsp-enable-imenu nil)
+ (setq lsp-enable-snippet nil)
+ (setq read-process-output-max (* 1024 1024)) ;; 1MB
+ (setq lsp-idle-delay 0.5))
+
+;;;;; ----------------------------- LSP UI ----------------------------
+
+(use-package lsp-ui
+ :after lsp-mode
+ :commands lsp-ui-mode
+ :config
+ (setq lsp-ui-doc-enable nil)
+ (setq lsp-ui-doc-header t)
+ (setq lsp-ui-doc-include-signature t)
+ (setq lsp-ui-doc-border (face-foreground 'default))
+ (setq lsp-ui-sideline-show-code-actions nil) ;; turn off code actions in sidebar
+ (setq lsp-ui-sideline-delay 0.05))
+
+(provide 'prog-lsp)
+;;; prog-lsp.el ends here
diff --git a/modules/prog-python.el b/modules/prog-python.el
new file mode 100644
index 00000000..2dc1fb3b
--- /dev/null
+++ b/modules/prog-python.el
@@ -0,0 +1,81 @@
+;;; prog-python --- Python Specific Setup and Functionality -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+;; -------------------------------- Python Setup -------------------------------
+;; preferences for Python programming
+
+(defun cj/python-setup ()
+ "My default code preferences for Python coding."
+ (tree-sitter-hl-mode) ;; use tree-sitter's highlighting
+ (hs-minor-mode) ;; folding
+ (company-mode) ;; completion framework
+ (flyspell-prog-mode) ;; spell check comments
+ (superword-mode) ;; see-this-as-one-word
+ (setq-default fill-column 80) ;; wrap at 80 columns
+ (setq-default tab-width 4) ;; set the tab width to 4 spaces
+ (setq-default standard-indent 4) ;; indent 4 spaces
+ (setq-default indent-tabs-mode nil) ;; disable tab characters
+ (electric-pair-mode t)) ;; match delimiters automatically
+
+;; ----------------------------------- Python ----------------------------------
+;; configuration for Emacs' built-in Python editing support
+
+(use-package python
+ :ensure nil ;; built-in
+ :hook
+ (python-mode . cj/python-setup)
+ :custom
+ (python-shell-interpreter "python3")
+ :config
+ ;; remove the "guess indent" python message
+ (setq python-indent-guess-indent-offset-verbose nil))
+
+;; ----------------------------------- Poetry ----------------------------------
+;; virtual environments and dependencies
+
+(use-package poetry
+ :defer t
+ :after (python)
+ :hook (python-mode . poetry-tracking-mode)
+ :config
+ ;; Checks for the correct virtualenv. Better strategy IMO because the default
+ ;; one is quite slow.
+ (setq poetry-tracking-strategy 'switch-buffer))
+
+;; ---------------------------------- Blacken ----------------------------------
+;; formatting on save
+
+(use-package blacken
+ :defer 1
+ :custom
+ (blacken-allow-py36 t)
+ (blacken-skip-string-normalization t)
+ :hook (python-mode . blacken-mode))
+
+;; ---------------------------------- Numpydoc ---------------------------------
+;; automatically insert NumPy style docstrings in Python function definitions
+
+(use-package numpydoc
+ :defer 1
+ :custom
+ (numpydoc-insert-examples-block nil)
+ (numpydoc-template-long nil)
+ :bind (:map python-mode-map
+ ("C-c C-n" . numpydoc-generate)))
+
+;; ------------------------------------ TOML -----------------------------------
+;; editing support and documentation for TOML files
+
+(use-package toml-mode
+ :defer 1)
+
+(use-package eldoc-toml
+ :defer 1)
+
+
+(provide 'prog-python)
+;;; prog-python.el ends here
diff --git a/modules/prog-shell.el b/modules/prog-shell.el
new file mode 100644
index 00000000..e63387cf
--- /dev/null
+++ b/modules/prog-shell.el
@@ -0,0 +1,15 @@
+;;; prog-shell --- Shell Programming Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;; Open any *.sh buffer and sh-mode loads with Flycheck attached, so syntax errors appear immediately.
+;; Re-save or invoke C-c ! l to refresh diagnostics while you iterate on scripts.
+
+;;; Code:
+
+(use-package sh-script
+ :defer .5
+ :hook (sh-mode . flycheck-mode))
+
+(provide 'prog-shell)
+;;; prog-shell.el ends here
diff --git a/modules/prog-training.el b/modules/prog-training.el
new file mode 100644
index 00000000..9eb5a9a3
--- /dev/null
+++ b/modules/prog-training.el
@@ -0,0 +1,35 @@
+;;; prog-training.el --- Training -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;; Commentary:
+;; Use C-h E to launch Exercism when you want to fetch or submit practice problems.
+;; Use C-h L for LeetCode sessions; the package drops solved files under ~/code/leetcode in Go format.
+;; Both bindings autoload their packages, so invoking the key is the whole workflow.
+
+;;; Code:
+
+
+;; ----------------------------- Exercism ----------------------------
+
+(use-package exercism
+ :defer t
+ :commands (exercism)
+ :bind
+ ("C-h E" . exercism))
+
+
+;;; ----------------------------- Leetcode ----------------------------
+
+(use-package leetcode
+ :defer t
+ :commands (leetcode)
+ :bind ("C-h L" . leetcode)
+ :custom
+ (url-debug t)
+ :config
+ (setq leetcode-prefer-language "golang")
+ (setq leetcode-directory (concat code-dir "/leetcode"))
+ (setq leetcode-save-solutions t))
+
+
+(provide 'prog-training)
+;;; prog-training.el ends here.
diff --git a/modules/prog-webdev.el b/modules/prog-webdev.el
new file mode 100644
index 00000000..a45bd376
--- /dev/null
+++ b/modules/prog-webdev.el
@@ -0,0 +1,120 @@
+;;; prog-webdev.el --- Web Development Packages and Settings -*- lexical-binding: t; coding: utf-8; -*-
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Open a project file and Emacs selects the right helper:
+;; - *.json buffers drop into json-mode for quick structural edits.
+;; - *.js buffers jump into js2-mode for linty feedback.
+;; - Mixed HTML templates land in web-mode which chains Tide and CSS Eldoc.
+;;
+;; Workflow:
+;; - Hit C-RET in web-mode to ask for completions; the command routes to Tide,
+;; company-css, or dabbrev based on the language at point.
+;; - Eldoc messages come from `cj/eldoc-web-mode`, so keeping point over JS, CSS,
+;; or markup swaps the doc source automatically.
+;; - New web buffers call `cj/setup-web-mode-mixed`, enabling Tide so goto-definition
+;; and rename are ready without extra setup.
+
+;;; Code:
+
+;; --------------------------------- JSON Mode ---------------------------------
+;; mode for editing JavaScript Object Notation (JSON) data files
+
+(use-package json-mode
+ :mode ("\\.json\\'" . json-mode)
+ :defer .5)
+
+;; ---------------------------------- JS2 Mode ---------------------------------
+;; javascript editing mode
+
+(use-package js2-mode
+ :mode ("\\.js\\'" . js2-mode)
+ :defer .5)
+
+;; --------------------------------- CSS Eldoc ---------------------------------
+;; CSS info in the echo area
+
+(use-package css-eldoc
+ :defer .5)
+
+;; ------------------------------------ Tide -----------------------------------
+;; typescript interactive development environment
+
+(use-package tide
+ :defer .5)
+
+(defun cj/activate-tide ()
+ "Activate Tide mode for TypeScript development.
+Calls Tide's setup, enables `eldoc-mode, and activates identifier highlighting."
+ (interactive)
+ (tide-setup)
+ (eldoc-mode 1)
+ (tide-hl-identifier-mode 1))
+
+;; ---------------------------------- Web Mode ---------------------------------
+;; major mode for editing web templates
+
+(use-package web-mode
+ :defer .5
+ :after (tide css-eldoc)
+ :custom
+ (web-mode-enable-current-element-highlight t)
+ :bind
+ ([(control return)] . cj/complete-web-mode)
+ :mode
+ (("\\.html?$" . cj/setup-web-mode-mixed)))
+
+(defun cj/complete-web-mode ()
+ "Provide context-aware completion in `web-mode' buffers.
+Determines the language at point (JavaScript, CSS, or markup)
+and invokes the appropriate completion backend:
+- JavaScript: uses `company-tide'
+- CSS: uses `company-css'
+- Other markup: uses `company-dabbrev-code'
+
+This function is typically bound to \\[cj/complete-web-mode] in
+`web-mode' buffers to provide intelligent completions based on
+the current context."
+ (interactive)
+ (let ((current-scope (web-mode-language-at-pos (point))))
+ (cond ((string-equal "javascript" current-scope)
+ (company-tide 'interactive))
+ ((string-equal "css" current-scope)
+ (company-css 'interactive))
+ (t
+ (company-dabbrev-code 'interactive)))))
+
+(defun cj/eldoc-web-mode ()
+ "Provide context-aware eldoc documentation in `web-mode' buffers.
+Return appropriate documentation based on the language at point:
+- JavaScript: uses `tide-eldoc-function' for TypeScript/JavaScript docs
+- CSS: uses `css-eldoc-function' for CSS property documentation
+- Other markup: returns nil (no documentation)
+
+This function is designed to be used as the buffer-local value
+of `eldoc-documentation-function' in `web-mode' buffers with
+mixed content."
+ (let ((current-scope (web-mode-language-at-pos (point))))
+ (cond ((string-equal "javascript" current-scope)
+ (tide-eldoc-function))
+ ((string-equal "css" current-scope)
+ (css-eldoc-function))
+ (t
+ nil))))
+
+(defun cj/setup-web-mode-mixed ()
+ "Set up `web-mode' with Tide and context-aware eldoc support.
+Enable `web-mode' for the current buffer and configure it for
+mixed HTML/JavaScript/CSS content. Activate Tide for JavaScript
+support and configure eldoc to use `cj/eldoc-web-mode' for
+context-aware documentation.
+
+This function is typically used as an auto-mode entry point for
+HTML files that contain embedded JavaScript and CSS."
+ (web-mode)
+ (cj/activate-tide)
+ (setq-local eldoc-documentation-function #'cj/eldoc-web-mode))
+
+
+(provide 'prog-webdev)
+;;; prog-webdev.el ends here.
diff --git a/modules/prog-yaml.el b/modules/prog-yaml.el
new file mode 100644
index 00000000..1a970313
--- /dev/null
+++ b/modules/prog-yaml.el
@@ -0,0 +1,18 @@
+;;; prog-yaml --- YAML Settings -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+(use-package yaml-mode
+ :defer .5
+ :commands (yaml-mode)
+ :config
+ (add-to-list 'auto-mode-alist '("\\.yml\\'" . yaml-mode))
+ (add-to-list 'auto-mode-alist '("\\.yaml\\'" . yaml-mode)))
+
+(add-hook 'yaml-mode-hook ' flycheck-mode-hook)
+
+(provide 'prog-yaml)
+;;; prog-yaml.el ends here
diff --git a/modules/quick-video-capture.el b/modules/quick-video-capture.el
new file mode 100644
index 00000000..05ad70c8
--- /dev/null
+++ b/modules/quick-video-capture.el
@@ -0,0 +1,104 @@
+;;; quick-video-capture.el --- Video Capturing with Org Capture -*- coding: utf-8; lexical-binding: t; -*-
+
+;;; Commentary:
+
+;; This package provides a seamless "fire-and-forget" workflow for downloading
+;; videos from the browser to your local system using yt-dlp and task-spooler.
+;;
+;; Features:
+;; - Browser bookmarklet integration via org-protocol
+;; - Automatic queueing of downloads through task-spooler
+;; - Works with any yt-dlp supported site
+;; - Can be triggered manually via org-capture with URL prompt
+;;
+;; Setup:
+;; 1. Load this file and call (cj/setup-video-download)
+;; 2. Add the bookmarklet from `cj/video-download-bookmarklet' to your browser
+;; 3. Click the bookmarklet on any video page to queue a download
+;;
+;; Alternatively, trigger manually with C-c c v and enter a URL
+
+;;; Code:
+
+(require 'org-protocol)
+(require 'org-capture)
+(require 'media-utils)
+
+(defconst cj/video-download-bookmarklet
+ "javascript:location.href='org-protocol://video-download?url='+encodeURIComponent(location.href);void(0);"
+ "JavaScript bookmarklet for triggering video downloads from the browser.
+Add this as a bookmark in your browser to enable one-click video downloads.")
+
+(defun cj/org-protocol-video-download (info)
+ "Process org-protocol video download requests.
+INFO is a plist containing :url from the org-protocol call."
+ (let ((url (plist-get info :url)))
+ (when url
+ ;; Store the URL for the capture template to use
+ (setq cj/video-download-current-url url))
+ ;; Trigger the capture
+ (org-capture nil "v")
+ nil)) ; Return nil to indicate we handled it
+
+(defun cj/video-download-capture-handler ()
+ "Handle video download during org-capture.
+This function is called from the capture template."
+ (let ((url (or cj/video-download-current-url
+ (read-string "Video URL: "))))
+ ;; Clear the stored URL after using it
+ (setq cj/video-download-current-url nil)
+ (if (string-empty-p url)
+ (error "No URL provided for download")
+ (cj/yt-dl-it url)
+ ;; Return empty string to prevent capture from saving anything
+ "")))
+
+(defvar cj/video-download-current-url nil
+ "Temporary storage for URL passed via org-protocol.")
+
+;; register the handler and the capture template after org-protocol is loaded
+(with-eval-after-load 'org-protocol
+ ;; Register the org-protocol handler
+ (add-to-list 'org-protocol-protocol-alist
+ '("video-download"
+ :protocol "video-download"
+ :function cj/org-protocol-video-download
+ :kill-client t))
+
+ ;; Add the capture template
+ (add-to-list 'org-capture-templates
+ '("v" "Video Download" entry
+ (file "") ; No file needed since we're not saving
+ "%(cj/video-download-capture-handler)"
+ :immediate-finish t
+ :jump-to-captured nil)))
+
+(defun cj/video-download-bookmarklet-instructions ()
+ "Display instructions for setting up the browser bookmarklet."
+ (interactive)
+ (let ((buf (get-buffer-create "*Video Download Bookmarklet Setup*")))
+ (with-current-buffer buf
+ (erase-buffer)
+ (insert "Video Download Bookmarklet Setup\n")
+ (insert "=================================\n\n")
+ (insert "1. Create a new bookmark in your browser\n")
+ (insert "2. Set the name to: Download Video (or your preference)\n")
+ (insert "3. Set the URL to the following JavaScript code:\n\n")
+ (insert cj/video-download-bookmarklet)
+ (insert "\n\n")
+ (insert "4. Save the bookmark to your bookmarks bar\n")
+ (insert "5. Click the bookmark when viewing a video to download it\n\n")
+ (insert "Note: Make sure Emacs server is running (M-x server-start)\n")
+ (insert "and emacsclient is properly configured for org-protocol.\n"))
+ (switch-to-buffer buf)))
+
+;;; Commentary for bookmarklet:
+;;
+;; To use the browser bookmarklet, add this JavaScript as a bookmark URL:
+;; javascript:location.href='org-protocol://video-download?url='+encodeURIComponent(location.href);void(0);
+;;
+;; This will send the current page URL to Emacs via org-protocol, triggering
+;; the download through yt-dlp and task-spooler.
+
+(provide 'quick-video-capture)
+;;; quick-video-capture.el ends here.
diff --git a/modules/reconcile-open-repos.el b/modules/reconcile-open-repos.el
new file mode 100644
index 00000000..8a2eda5e
--- /dev/null
+++ b/modules/reconcile-open-repos.el
@@ -0,0 +1,72 @@
+;;; reconcile-open-repos.el --- reconcile open repos -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;; Commentary:
+;; I have tried to keep this Emacs config as general as possible, and move all
+;; config related to my personal workflows here.
+
+;; I typically work with multiple git repositories the day, and can forget to
+;; commit uncommitted work. Also, I often forget to start a session by pulling
+;; changes. So, making it a habit to run cj/check-for-open-work when starting
+;; and ending the work session allows me to know that I've iterated through all
+;; git repositories in my projects and code directories and have reconciled all
+;; changes.
+
+;;; Code:
+
+;; -------------------------- Reconcile Git Directory --------------------------
+
+(defun cj/reconcile-git-directory (directory)
+ "Reconcile unopened work in a git project directory leveraging magit."
+ (message "checking: %s" directory)
+ (let ((default-directory directory))
+ ;; Check for the presence of the .git directory
+ (if (file-directory-p (expand-file-name ".git" directory))
+ (progn
+ (let ((remote-url (shell-command-to-string "git config --get remote.origin.url")))
+ (setq remote-url (string-trim remote-url))
+
+ ;; skip local git repos, or remote URLs that are http or https,
+ ;; these are typically cloned for reference only
+ (unless (or (string-empty-p remote-url)
+ (string-match-p "^\\(http\\|https\\)://" remote-url))
+
+ ;; if git directory is clean, pulling generates no errors
+ (if (string-empty-p (shell-command-to-string "git status --porcelain"))
+ (progn
+ ;; (message "%s is a clean git repository" directory)
+ (shell-command "git pull --quiet"))
+
+ ;; if directory not clean, pull latest changes and display Magit for manual intervention
+ (progn
+ (message "%s contains uncommitted work" directory)
+ (shell-command "git stash --quiet")
+ (shell-command "git pull --quiet")
+ (shell-command "git stash pop --quiet")
+ (call-interactively #'magit-status)
+ ;; pause until magit buffer is closed
+ (while (buffer-live-p (get-buffer (format "*magit: %s*" (file-name-nondirectory directory))))
+ (sit-for 0.5))))))))))
+
+;; ---------------------------- Check For Open Work ----------------------------
+
+(defun cj/check-for-open-work ()
+ "Check all project directories for open work."
+ (interactive)
+ ;; these are constants defined in init.el
+ ;; children of these directories will be checked
+ (dolist (base-dir (list projects-dir code-dir))
+ (when (file-directory-p base-dir)
+ (dolist (child-dir (directory-files base-dir t "^[^.]+$" 'nosort))
+ (when (file-directory-p child-dir)
+ (cj/reconcile-git-directory child-dir)))))
+
+ ;; check these directories individually
+ (cj/reconcile-git-directory sync-dir)
+ (cj/reconcile-git-directory user-emacs-directory)
+
+ ;; communicate when finished.
+ (message "Complete. All project repositories checked for uncommitted work and code updated from remote repository"))
+(global-set-key (kbd "M-P") 'cj/check-for-open-work)
+
+(provide 'reconcile-open-repos)
+;;; reconcile-open-repos.el ends here.
diff --git a/modules/selection-framework.el b/modules/selection-framework.el
new file mode 100644
index 00000000..9e7e44a3
--- /dev/null
+++ b/modules/selection-framework.el
@@ -0,0 +1,264 @@
+;;; selection-framework.el --- Completion and Selection Framework -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; This module configures the completion and selection framework using:
+;; - Vertico: Vertical completion UI
+;; - Marginalia: Rich annotations in minibuffer
+;; - Consult: Practical commands based on completing-read
+;; - Orderless: Advanced completion style
+;; - Embark: Contextual actions
+;; - Company: In-buffer completion
+;;
+;; The configuration provides a modern, fast, and feature-rich completion
+;; experience that enhances Emacs' built-in completing-read functionality.
+;;
+;;; Code:
+
+;; ---------------------------------- Vertico ----------------------------------
+;; Vertical completion UI
+
+(use-package vertico
+ :demand t
+ :custom
+ (vertico-cycle t) ; Cycle through candidates
+ (vertico-count 10) ; Number of candidates to display
+ (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))
+ :init
+ (vertico-mode))
+
+(use-package marginalia
+ :demand t
+ :custom
+ (marginalia-max-relative-age 0)
+ (marginalia-align 'right)
+ :init
+ (marginalia-mode))
+
+(use-package nerd-icons-completion
+ :demand t
+ :hook (marginalia-mode nerd-icons-completion-marginalia-setup)
+ :after marginalia
+ :init
+ (nerd-icons-completion-mode))
+
+;; ---------------------------------- Consult ----------------------------------
+;; Practical commands based on completing-read
+
+(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))
+
+ :hook (completion-list-mode . consult-preview-at-point-mode)
+
+ :init
+ ;; Optionally configure the register formatting. This improves the register
+ ;; 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)
+
+ ;; 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)
+
+ :config
+ ;; Configure preview. Default is 'any.
+ (setq consult-preview-key 'any)
+
+ ;; Configure narrowing key
+ (setq consult-narrow-key "<")
+
+ ;; Reset to defaults to avoid issues
+ (setq consult-point-placement 'match-beginning)
+
+ ;; Use Consult for completion-at-point
+ (setq completion-in-region-function #'consult-completion-in-region))
+
+(global-unset-key (kbd "C-s"))
+(global-set-key (kbd "C-s") 'consult-line)
+
+;; Consult integration with Embark
+(use-package embark-consult
+ :after (embark consult)
+ :hook
+ (embark-collect-mode . consult-preview-at-point-mode))
+
+(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))
+ :config
+ (add-to-list 'consult-dir-sources 'consult-dir--source-tramp-ssh t)
+ (setq consult-dir-project-list-function #'consult-dir-projectile-dirs))
+
+;; --------------------------------- Orderless ---------------------------------
+;; Advanced completion style - provides space-separated, out-of-order matching
+
+
+(use-package orderless
+ :demand t
+ :custom
+ (completion-styles '(orderless))
+ (completion-category-defaults nil)
+ (completion-category-overrides '((file (styles partial-completion))
+ (multi-category (styles orderless))))
+ (orderless-matching-styles '(orderless-literal
+ orderless-regexp
+ orderless-initialism
+ orderless-prefixes)))
+
+;; ---------------------------------- Embark -----------------------------------
+;; Contextual actions - provides right-click like functionality
+
+(use-package embark
+ :demand t
+ :bind
+ (("C-." . embark-act) ;; pick an action to run
+ ("C->" . embark-act-all) ;; pick an action to run on all candidates
+ ("C-," . embark-dwim) ;; do what I mean
+ ("C-h B" . embark-bindings)) ;; alternative for =describe-bindings'
+
+ :init
+ ;; Optionally replace the key help with a completing-read interface
+ (setq prefix-help-command #'embark-prefix-help-command)
+
+ :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)))))
+
+;; --------------------------- Consult Integration ----------------------------
+;; Additional integrations for specific features
+
+;; Yasnippet integration - replaces ivy-yasnippet
+(use-package consult-yasnippet
+ :after yasnippet
+ :bind ("C-c s i" . consult-yasnippet))
+
+;; Flycheck integration
+(use-package consult-flycheck
+ :after flycheck
+ :bind (:map flycheck-mode-map
+ ("C-c ! c" . consult-flycheck)))
+
+;; ---------------------------------- Company ----------------------------------
+;; In-buffer completion (retained from original configuration)
+
+(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))
+ :custom
+ (company-backends '(company-capf company-files company-keywords))
+ (company-idle-delay 2)
+ (company-minimum-prefix-length 2)
+ (company-show-numbers t)
+ (company-tooltip-align-annotations t)
+ (company-tooltip-flip-when-above t)
+ (company-tooltip-limit 10)
+ (company-selection-wrap-around t)
+ (company-require-match nil)
+ :config
+ ;; Disable company in mail-related modes
+ (setq company-global-modes
+ '(not message-mode
+ mu4e-compose-mode
+ org-msg-edit-mode)))
+
+
+(use-package company-quickhelp
+ :after company
+ :config
+ (company-quickhelp-mode))
+
+;; Icons for company
+(use-package company-box
+ :after company
+ :hook (company-mode . company-box-mode))
+
+;; --------------------------------- Prescient ---------------------------------
+
+(use-package prescient
+ :demand t
+ :config
+ (prescient-persist-mode))
+
+(use-package vertico-prescient
+ :demand t
+ :config
+ (vertico-prescient-mode))
+
+(use-package company-prescient
+ :demand t
+ :config
+ (company-prescient-mode))
+
+(provide 'selection-framework)
+;;; selection-framework.el ends here
diff --git a/modules/show-kill-ring.el b/modules/show-kill-ring.el
new file mode 100644
index 00000000..c50e5dbb
--- /dev/null
+++ b/modules/show-kill-ring.el
@@ -0,0 +1,125 @@
+;;; show-kill-ring --- Displays Previous Kill Ring Entries -*- lexical-binding: t; coding: utf-8; -*-
+;; Show Kill Ring
+;; Stolen from Steve Yegge when he wasn't looking
+;; enhancements and bugs added by Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Browse items you've previously killed.
+;; Yank text using C-u, the index, then C-y.
+;;
+;; I've lovingly kept the nice 1970s aesthetic, complete with wood paneling.
+;; Maybe I'll give it a makeover at some point.
+;;
+;;; Code:
+
+(require 'cl-lib)
+
+(defvar show-kill-max-item-size 1000
+ "This represents the size of a \='kill ring\=' entry.
+
+A positive number means to limit the display of \='kill-ring\=' items to
+that number of characters.")
+
+(defun show-kill-ring-exit ()
+ "Exit the show-kill-ring buffer."
+ (interactive)
+ (quit-window t))
+
+(defun show-kill-ring ()
+ "Show the current contents of the kill ring in a separate buffer.
+
+This makes it easy to figure out which prefix to pass to yank."
+ (interactive)
+ ;; kill existing one, since erasing it doesn't work
+ (let ((buf (get-buffer "*Kill Ring*")))
+ (and buf (kill-buffer buf)))
+
+ (let* ((buf (get-buffer-create "*Kill Ring*"))
+ (temp kill-ring)
+ (count 1)
+ (bar (make-string 32 ?=))
+ (bar2 (concat " " bar))
+ (item " Item ")
+ (yptr nil) (ynum 1))
+ (set-buffer buf)
+ (erase-buffer)
+
+ (show-kill-insert-header)
+
+ ;; show each of the items in the kill ring, in order
+ (while temp
+ ;; insert our little divider
+ (insert (concat "\n" bar item (prin1-to-string count) " "
+ (if (< count 10) bar2 bar) "\n"))
+
+ ;; if this is the yank pointer target, grab it
+ (when (equal temp kill-ring-yank-pointer)
+ (setq yptr (car temp) ynum count))
+
+ ;; insert the item and loop
+ (show-kill-insert-item (car temp))
+ (cl-incf count)
+ (setq temp (cdr temp)))
+
+ ;; show info about yank item
+ (show-kill-insert-footer yptr ynum)
+
+ ;; use define-key instead of local-set-key
+ (use-local-map (make-sparse-keymap))
+ (define-key (current-local-map) "q" 'show-kill-ring-exit)
+
+ ;; show it
+ (goto-char (point-min))
+ (setq buffer-read-only t)
+ (set-buffer-modified-p nil)
+ ;; display-buffer rather than pop-to-buffer
+ ;; easier for user to C-u (item#) C-y
+ ;; while the point is where they want to yank
+ (display-buffer buf)))
+
+(defun show-kill-insert-item (item)
+ "Insert an ITEM from the kill ring into the current buffer.
+
+If it's too long, truncate it first."
+ (let ((max show-kill-max-item-size))
+ (cond
+ ((or (not (numberp max))
+ (< max 0)
+ (< (length item) max))
+ (insert item))
+ (t
+ ;; put ellipsis on its own line if item is longer than 1 line
+ (let ((preview (substring item 0 max)))
+ (if (< (length item) (- (frame-width) 5))
+ (insert (concat preview "..." ))
+ (insert (concat preview "\n..."))))))))
+
+(defun show-kill-insert-header ()
+ "Insert the show-kill-ring header or a notice if the kill ring is empty."
+ (if kill-ring
+ (insert "Contents of the kill ring:\n")
+ (insert "The kill ring is empty")))
+
+(defun show-kill-insert-footer (yptr ynum)
+ "Insert final divider and the yank-pointer (YPTR YNUM) info."
+ (when kill-ring
+ (save-excursion
+ (re-search-backward "^\\(=+ Item [0-9]+\\ +=+\\)$"))
+ (insert "\n")
+ (insert (make-string (length (match-string 1)) ?=))
+ ;; Use number-to-string instead of int-to-string
+ (insert (concat "\n\nItem " (number-to-string ynum)
+ " is the next to be yanked:\n\n"))
+ (show-kill-insert-item yptr)
+ (insert "\n\nThe prefix arg will yank relative to this item.")))
+
+(defun empty-kill-ring ()
+ "Force garbage collection of huge kill ring entries that I don't care about."
+ (interactive)
+ (setq kill-ring nil)
+ (garbage-collect))
+
+(global-set-key (kbd "M-K") 'show-kill-ring)
+
+(provide 'show-kill-ring)
+;;; show-kill-ring.el ends here
diff --git a/modules/system-defaults.el b/modules/system-defaults.el
new file mode 100644
index 00000000..c0879d51
--- /dev/null
+++ b/modules/system-defaults.el
@@ -0,0 +1,243 @@
+;;; system-defaults --- Non-UI Preferences -*- lexical-binding: t; coding: utf-8-unix; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Loads during init to set sane defaults: UTF-8 everywhere, quiet prompts, synced clipboards,
+;; and hands-off async shell buffers. Nothing to call—just launch Emacs and the environment is ready.
+;; Native compilation is tuned for performance and its warnings get logged to comp-warnings.log.
+
+;;; Implementation Notes:
+;; `cj/log-comp-warning` advices `display-warning` so native-comp notices land in the log instead of popping Messages.
+;; Remove the advice if you need stock warning buffers for debugging.
+
+;;; Code:
+
+(require 'host-environment)
+
+;; -------------------------- Native Comp Preferences --------------------------
+;; after async compiler starts, set preferences and warning level
+
+(with-eval-after-load 'comp-run
+ (setopt native-comp-async-jobs-number 8) ; parallel compile workers
+ (setopt native-comp-speed 3) ; highest optimization level
+ (setopt native-comp-always-compile t)) ; always native-compile
+
+;; -------------------------- Log Native Comp Warnings -------------------------
+;; log native comp warnings rather than cluttering the buffer
+
+(defvar comp-warnings-log
+ (expand-file-name "comp-warnings.log" user-emacs-directory)
+ "File where native-comp warnings will be appended.")
+
+(defun cj/log-comp-warning (type message &rest args)
+ "Log native-comp warnings of TYPE with MESSAGE & ARGS to 'comp-warnings-log'.
+Suppress them from appearing in the *Warnings* buffer. If TYPE contains 'comp',
+log the warning with a timestamp to the file specified by 'comp-warnings-log'.
+Return non-nil to indicate the warning was handled."
+ (when (memq 'comp (if (listp type) type (list type)))
+ (with-temp-buffer
+ (insert (format-time-string "[%Y-%m-%d %H:%M:%S] "))
+ (insert (if (stringp message)
+ (apply #'format message args)
+ (format "%S %S" message args)))
+ (insert "\n")
+ (append-to-file (point-min) (point-max) comp-warnings-log))
+ ;; Return non-nil to tell `display-warning' “we handled it.”
+ t))
+
+(advice-add 'display-warning :before-until #'cj/log-comp-warning)
+
+;; ---------------------------------- Unicode ----------------------------------
+;; unicode everywhere
+
+(set-locale-environment "en_US.UTF-8")
+(prefer-coding-system 'utf-8)
+(set-default-coding-systems 'utf-8)
+(set-terminal-coding-system 'utf-8)
+(set-keyboard-coding-system 'utf-8)
+(set-selection-coding-system 'utf-8)
+(setq locale-coding-system 'utf-8)
+(set-charset-priority 'unicode)
+(setq x-select-request-type
+ '(UTF8_STRING COMPOUND_TEXT TEXT STRING))
+
+;; -------------------------- Disabling Functionality --------------------------
+
+(defun cj/disabled ()
+ "Do absolutely nothing and do it quickly.
+Used to disable functionality with defalias 'somefunc 'cj/disabled)."
+ (interactive))
+
+;; VIEW EMACS NEWS
+;; no news is good news
+(defalias 'view-emacs-news 'cj/disabled)
+(global-unset-key (kbd "C-h n"))
+
+;; DESCRIBE GNU PROJECT
+;; no gnus is good gnus
+(defalias 'describe-gnu-project 'cj/disabled)
+(global-unset-key (kbd "C-h g"))
+
+;; CUSTOMIZATIONS
+;; All customizations should be declared in Emacs init files.
+;; Add accidental customizations via the customization interface to a temp file that's never read.
+(setq custom-file (make-temp-file
+ "emacs-customizations-trashbin-"))
+
+;; ------------------------- Re-Enabling Functionality -------------------------
+
+(put 'narrow-to-region 'disabled nil) ;; narrow-to-region is extremely useful!
+(put 'upcase-region 'disabled nil) ;; upcase region is useful
+(put 'erase-buffer 'disabled nil) ;; and so is erase-buffer
+
+;; ------------------------------ Non UI Settings ------------------------------
+
+(setq ring-bell-function 'ignore) ;; disable the bell ring.
+(setq default-directory user-home-dir) ;; consider user home the default directory
+
+(global-auto-revert-mode) ;; update the buffer when the associated file has changed
+(setq global-auto-revert-non-file-buffers t) ;; do so for all buffer types (e.g., ibuffer)
+(setq bidi-display-reordering nil) ;; don't reorder bidirectional text for display
+(setq bidi-paragraph-direction t) ;; forces directionality of text for performance.
+
+(setq system-time-locale "C") ;; use en_US locale to format time.
+
+;; --------------------------------- Clipboard ---------------------------------
+;; keep the clipboard and kill ring in sync
+
+(setq select-enable-clipboard t) ;; cut and paste using clipboard
+(setq yank-pop-change-selection t) ;; update system clipboard when yanking in emacs
+(setq save-interprogram-paste-before-kill t) ;; save existing clipboard to kill ring before replacing
+
+;; Additional settings for better clipboard integration
+(setq select-enable-primary nil) ;; don't use X11 primary selection (no middle-click paste)
+(setq mouse-drag-copy-region nil) ;; don't copy region to clipboard by selecting with mouse
+
+;; -------------------------------- Tab Settings -------------------------------
+;; spaces, not tabs
+
+(setq-default tab-width 4) ;; if tab, make them 4 spaces default
+(setq-default indent-tabs-mode nil) ;; but turn off tabs by default
+
+;; ------------------------------ Scroll Settings ------------------------------
+
+(setq mouse-wheel-scroll-amount '(1 ((shift) . 1))) ;; one line at a time
+(setq mouse-wheel-progressive-speed nil) ;; don't accelerate scrolling
+
+;; ----------------------------- Case Insensitivity ----------------------------
+;; make user interfaces case insensitive
+
+(setq case-fold-search t) ;; case-insensitive searches
+(setq completion-ignore-case t) ;; case-insensitive completion
+(setq read-file-name-completion-ignore-case t) ;; case-insensitive file completion
+
+;; ------------------------------- Async Commands ------------------------------
+;; always create new async command buffers silently
+
+(setq async-shell-command-buffer 'new-buffer)
+
+;; never automatically display async command output buffers
+;; but keep them in the buffer list for later inspection
+(add-to-list 'display-buffer-alist
+ '("*Async Shell Command*" display-buffer-no-window (nil)))
+
+;; ------------------------ Mouse And Trackpad Settings ------------------------
+;; provide smoothest scrolling and avoid accidental gestures
+
+(setq mouse-wheel-follow-mouse 't) ;; scroll window under mouse
+(setq scroll-margin 3) ;; start scrolling at 3 lines from top/bottom
+(setq scroll-step 1) ;; keyboard scroll one line at a time
+
+;; disable pasting with mouse-wheel click
+(global-unset-key (kbd "<mouse-2>"))
+
+;; disable pinching gesture or mouse-wheel changing font size
+(global-unset-key (kbd "<pinch>"))
+(global-set-key [remap mouse-wheel-text-scale] 'cj/disabled)
+
+;; ------------------------------- Be Quiet(er)! -------------------------------
+;; reduces "helpful" instructions that distract Emacs power users.
+
+(setq-default vc-follow-symlinks) ;; don't ask to follow symlinks if target is version controlled
+(setq kill-buffer-query-functions ;; don't ask about killing buffers with processes, just kill them
+ (remq 'process-kill-buffer-query-function
+ kill-buffer-query-functions))
+(setq confirm-kill-processes nil) ;; automatically kill running processes on exit
+(setq confirm-nonexistent-file-or-buffer nil) ;; don't ask if a file I visit with C-x C-f or C-x b doesn't exist
+(setq ad-redefinition-action 'accept) ;; silence warnings about advised functions getting redefined.
+(setq large-file-warning-threshold nil) ;; open files regardless of size
+(fset 'yes-or-no-p 'y-or-n-p) ;; require a single letter for binary answers
+(setq use-short-answers t) ;; same as above with Emacs 28+
+(setq auto-revert-verbose nil) ;; turn off auto revert messages
+(setq custom-safe-themes t) ;; treat all themes as safe (stop asking)
+(setq server-client-instructions nil) ;; I already know what to do when done with the frame
+
+;; ------------------ Reduce Garbage Collections In Minibuffer -----------------
+;; triggers garbage collection when it won't impact user minibuffer entries
+
+(defun cj/minibuffer-setup-hook ()
+ "Hook to prevent garbage collection while user's in minibuffer."
+ (setq gc-cons-threshold most-positive-fixnum))
+
+(defun cj/minibuffer-exit-hook ()
+ "Hook to trigger garbage collection when exiting minibuffer."
+ (setq gc-cons-threshold 800000))
+
+(add-hook 'minibuffer-setup-hook #'cj/minibuffer-setup-hook)
+(add-hook 'minibuffer-exit-hook #'cj/minibuffer-exit-hook)
+
+;; ----------------------------- Bookmark Settings -----------------------------
+;; keep bookmarks in sync location, and save the file whenever a mark is added
+
+;; place bookmark file sync'd org files
+(setq bookmark-default-file (concat sync-dir "emacs_bookmarks"))
+
+;; save bookmarks each (1) time it's modified.
+(setq bookmark-save-flag 1)
+
+;; -------------------------------- Recent Files -------------------------------
+;; don't suggest bookmarks, packages, indexes, or recentf in recent files.
+
+(use-package recentf
+ :init
+ (recentf-mode 1)
+ :ensure nil ;;built-in
+ :config
+ (setq recentf-max-saved-items 1000)
+ (setq recentf-max-menu-items 50)
+ (add-to-list 'recentf-exclude "emacs_bookmarks")
+ (add-to-list 'recentf-exclude "\\.emacs\\.d/elpa")
+ (add-to-list 'recentf-exclude "\\.emacs\\.d/recentf")
+ (add-to-list 'recentf-exclude "\\ElfeedDB/index"))
+
+;; -------------------------- Autosave And Lock Files --------------------------
+;; don't create lockfiles or autosave (i.e., filename~) files.
+
+(setq auto-save-default nil)
+(setq create-lockfiles nil)
+
+;; ------------------------------ Backup Settings ------------------------------
+;; per-save backups can be invaluable, so create them in ~/.emacs.d/backups
+
+;; BACKUP DIRECTORY CREATION
+(defvar cj/backup-directory (concat user-emacs-directory "backups"))
+(if (not (file-exists-p cj/backup-directory))
+ (make-directory cj/backup-directory t))
+
+;; BACKUP SETTINGS
+(setq make-backup-files t) ;; do make backup files
+(setq backup-directory-alist `(("." . ,cj/backup-directory))) ;; put all originals in backup directory
+(setq backup-by-copying t) ;; don't clobber symlinks
+(setq version-control t) ;; make numeric backup versions
+(setq delete-old-versions t) ;; delete excess backup files w/o asking
+(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
+
+;; ------------------------------- GNU 'ls' On BSD -------------------------------
+;; when on BSD use the ls from FSF sysutils/coreutils: pkg install coreutils
+
+(when (env-bsd-p)
+ (setq insert-directory-program "/usr/local/bin/gls"))
+
+(provide 'system-defaults)
+;;; system-defaults.el ends here
diff --git a/modules/system-utils.el b/modules/system-utils.el
new file mode 100644
index 00000000..c02947b8
--- /dev/null
+++ b/modules/system-utils.el
@@ -0,0 +1,202 @@
+;;; system-utils --- System-Wide Utilities -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;;
+;; A "system-util" is an enhancement to common a Emacs command, or the extension
+;; of an existing command. Perhaps this group can be better named.
+;;
+;; Eval-Buffer : This is a command I use frequently that's not mapped and
+;; doesn't give confirmation. Fixed it.
+;; Sudo-Edit : If you're using Emacs to edit system files, you'll want
+;; this so you don't have to run Emacs as root.
+;; Open-File-With : This set of methods will open a given file using the default application
+;; for the OS and detached from Emacs. Leveraged in Dired/Dirvish.
+;; Server Shutdown : Closes the running Emacs Server. Will close all attached Emacs clients.
+;; savehist : Persistence for recently opened files, and minibuffer history
+;; ibuffer : Better replacement for list-buffer with icons for easier recognition
+;; scratch-buffer : A little joy in the scratch buffer can go a long way.
+;; dictionary : look up words via sdcv
+;; log-silently : for debugging messages where you don't want to see activity in the echo area.
+;; proced : I was astonished to discover a process monitor application built into Emacs.
+;; Then again, given all that's here, how surprising is it really?
+;;
+;;; Code:
+
+(require 'cl-lib)
+(require 'host-environment)
+(require 'user-constants)
+
+;;; -------------------------------- Eval Buffer --------------------------------
+
+(defun cj/eval-buffer-with-confirmation-or-error-message ()
+ "Evaluate the buffer and display a message."
+ (interactive)
+ (let ((result (eval-buffer)))
+ (if (not (eq result 'error))
+ (message "Buffer evaluated.")
+ (message "error occurred during evaluation: %s" result))))
+(global-set-key (kbd "C-c b") 'cj/eval-buffer-with-confirmation-or-error-message)
+
+;;; ---------------------------- Edit A File With Sudo ----------------------------
+
+(use-package sudo-edit
+ :defer 1
+ :bind ("C-x M-f" . sudo-edit))
+
+;;; ------------------------------- Open File With ------------------------------
+;; TASK: Favor this method over cj/open-this-file-with and add to custom buffer funcs
+
+(defun cj/open-file-with-command (command)
+ "Open the current file with COMMAND.
+Works in both Dired buffers and regular file buffers. The command runs
+fully detached from Emacs."
+ (interactive "MOpen with command: ")
+ (let* ((file (cond
+ ;; In dired/dirvish mode, get file at point
+ ((derived-mode-p 'dired-mode)
+ (require 'dired)
+ (dired-get-file-for-visit))
+ ;; In a regular file buffer
+ (buffer-file-name
+ buffer-file-name)
+ ;; Fallback - prompt for file
+ (t
+ (read-file-name "File to open: "))))
+ ;; For xdg-open and similar launchers, we need special handling
+ (is-launcher (member command '("xdg-open" "open" "start"))))
+ ;; Validate file exists
+ (unless (and file (file-exists-p file))
+ (error "No valid file found or selected"))
+ ;; Use different approaches for launchers vs regular commands
+ (if is-launcher
+ ;; For launchers, use call-process with 0 to fully detach
+ (progn
+ (call-process command nil 0 nil file)
+ (message "Opening %s with %s..." (file-name-nondirectory file) command))
+ ;; For other commands, use start-process-shell-command for potential output
+ (let* ((output-buffer-name (format "*Open with %s: %s*"
+ command
+ (file-name-nondirectory file)))
+ (output-buffer (generate-new-buffer output-buffer-name)))
+ (start-process-shell-command
+ command
+ output-buffer
+ (format "%s %s" command (shell-quote-argument file)))
+ (message "Running %s on %s..." command (file-name-nondirectory file))))))
+
+
+(defun cj/identify-external-open-command ()
+ "Return the OS-default \"open\" command for this host.
+Signals an error if the host is unsupported."
+ (cond
+ ((env-linux-p) "xdg-open")
+ ((env-macos-p) "open")
+ ((env-windows-p) "start")
+ (t (error "external-open: unsupported host environment"))))
+
+(defun cj/xdg-open (&optional filename)
+ "Open FILENAME (or the file at point) with the OS default handler.
+Logs output and exit code to buffer *external-open.log*."
+ (interactive)
+ (let* ((file (expand-file-name (or filename (dired-file-name-at-point))))
+ (cmd (cj/identify-external-open-command))
+ (logbuf (get-buffer-create "*external-open.log*")))
+ (with-current-buffer logbuf
+ (goto-char (point-max))
+ (insert (format-time-string "[%Y-%m-%d %H:%M:%S] "))
+ (insert (format "Opening: %s\n" file)))
+ (cond
+ ;; Windows: let the shell handle association; fully detached.
+ ((env-windows-p)
+ (w32-shell-execute "open" file))
+ ;; macOS/Linux: run the opener synchronously; it returns immediately.
+ (t
+ (call-process cmd nil 0 nil file)
+ (with-current-buffer logbuf
+ (insert " → Launched asynchronously\n"))))
+ nil))
+
+;;; ------------------------------ Server Shutdown ------------------------------
+
+(defun server-shutdown ()
+ "Save buffers, kill Emacs and shutdown the server."
+ (interactive)
+ (save-some-buffers)
+ (kill-emacs))
+(global-set-key (kbd "C-<f10>") #'server-shutdown)
+
+;;; ---------------------------- History Persistence ----------------------------
+;; Persist history over Emacs restarts
+
+(use-package savehist
+ :ensure nil ; built-in
+ :init
+ (savehist-mode)
+ :config
+ (setq savehist-file "~/.emacs.d/.emacs-history"))
+
+;;; ------------------------ List Buffers With Nerd Icons -----------------------
+
+(global-set-key [remap list-buffers] #'ibuffer)
+(use-package nerd-icons-ibuffer
+ :defer 0.5
+ :after nerd-icons
+ :hook (ibuffer-mode . nerd-icons-ibuffer-mode)
+ :config
+ (setq nerd-icons-ibuffer-icon t
+ nerd-icons-ibuffer-color-icon t
+ nerd-icons-ibuffer-human-readable-size t))
+
+;;; -------------------------- 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"))
+(setq initial-scratch-message
+ (concat scratch-emacs-version-and-system scratch-greet))
+(setq initial-major-mode 'org-mode)
+
+;;; --------------------------------- Dictionary --------------------------------
+
+(use-package quick-sdcv
+ :defer 1
+ :bind
+ ("C-h d" . quick-sdcv-search-input)
+ :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
+ :ensure nil ;; built-in
+ :defer 0.5
+ :commands proced
+ :bind ("C-M-p" . proced)
+ :custom
+ (proced-auto-update-flag t)
+ (proced-show-remote-processes t)
+ (proced-enable-color-flag t)
+ (proced-format 'custom)
+ :config
+ (add-to-list 'proced-format-alist
+ '(custom user pid ppid sess tree pcpu pmem rss start time
+ state (args comm))))
+
+(provide 'system-utils)
+;;; system-utils.el ends here
diff --git a/modules/test-runner.el b/modules/test-runner.el
new file mode 100644
index 00000000..73c4063c
--- /dev/null
+++ b/modules/test-runner.el
@@ -0,0 +1,270 @@
+;;; test-runner.el --- Test Runner for Emacs Configuration -*- lexical-binding: t; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Provides utilities for running ERT tests with focus/unfocus workflow
+;;
+;; 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.
+;;
+;; The default mode is to load and run all tests.
+;;
+;; 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
+;;
+;; Don't forget to run all tests again in default mode at least once before finishing.
+;;
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;;; Variables
+
+(defvar cj/test-global-directory nil
+ "Fallback global test directory when not in a Projectile project.")
+
+(defvar cj/test-focused-files '()
+ "List of test files for focused test execution.
+
+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).")
+
+(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."
+ (require 'projectile)
+ (let ((project-root (ignore-errors (projectile-project-root))))
+ (if (not (and project-root (file-directory-p project-root)))
+ ;; fallback global test directory
+ cj/test-global-directory
+ (let ((test-dir (expand-file-name "test" project-root))
+ (tests-dir (expand-file-name "tests" project-root)))
+ (cond
+ ((file-directory-p test-dir) test-dir)
+ ((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."
+ (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-load-all ()
+ "Load all test files from the appropriate test directory."
+ (interactive)
+ (cj/test--ensure-test-dir-in-load-path)
+ (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
+(defun cj/test-focus-add ()
+ "Select test file(s) to add to the focused list."
+ (interactive)
+ (cj/test--ensure-test-dir-in-load-path)
+ (let* ((dir (cj/test--get-test-directory))
+ (available-files (when (file-directory-p dir)
+ (mapcar #'file-name-nondirectory
+ (directory-files dir t "^test-.*\\.el$")))))
+ (if (null available-files)
+ (user-error "No test files found in %s" dir)
+ (let* ((unfocused-files (cl-set-difference available-files
+ cj/test-focused-files
+ :test #'string=))
+ (selected (if unfocused-files
+ (completing-read "Add test file to focus: "
+ 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))))))
+
+;;;###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
+(defun cj/test-focus-remove ()
+ "Remove a test file from the focused list."
+ (interactive)
+ (if (null cj/test-focused-files)
+ (user-error "No focused files to remove")
+ (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)))))
+
+;;;###autoload
+(defun cj/test-focus-clear ()
+ "Clear all focused test files."
+ (interactive)
+ (setq cj/test-focused-files '())
+ (message "Cleared all focused test files"))
+
+(defun cj/test--extract-test-names (file)
+ "Extract test names from FILE.
+
+Returns a list of test name symbols defined in the file."
+ (let ((test-names '()))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (goto-char (point-min))
+ ;; Find all (ert-deftest NAME ...) forms
+;; (while (re-search-forward "^\s-*(ert-deftest\s-+\\(\\(?:\\sw\\|\\s_\\)+\\)" nil t)
+ (while (re-search-forward "^[[:space:]]*(ert-deftest[[:space:]]+\\(\\(?:\\sw\\|\\s_\\)+\\)" nil t)
+ (push (match-string 1) test-names)))
+ test-names))
+
+;;;###autoload
+(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 "$")))))))
+
+(defun cj/test--ensure-test-dir-in-load-path ()
+ "Ensure the directory returned by cj/test--get-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/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
+(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."
+ (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)
+ (if (null cj/test-focused-files)
+ (message "No focused test files")
+ (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)
+ (if (eq cj/test-mode 'all)
+ (cj/test-run-all)
+ (cj/test-run-focused)))
+
+;; Test runner operations prefix and keymap
+(define-prefix-command 'cj/test-map nil
+ "Keymap for test-runner operations.")
+(define-key cj/custom-keymap "t" 'cj/test-map)
+
+(define-key cj/test-map "L" 'cj/test-load-all)
+(define-key cj/test-map "R" 'cj/test-run-all)
+(define-key cj/test-map "." 'cj/run-test-at-point)
+(define-key cj/test-map "r" 'cj/test-run-smart)
+(define-key cj/test-map "a" 'cj/test-focus-add)
+(define-key cj/test-map "b" 'cj/test-focus-add-this-buffer-file)
+(define-key cj/test-map "c" 'cj/test-focus-clear)
+(define-key cj/test-map "v" 'cj/test-view-focused)
+(define-key cj/test-map "t" 'cj/test-toggle-mode)
+
+(provide 'test-runner)
+;;; test-runner.el ends here
diff --git a/modules/text-config.el b/modules/text-config.el
new file mode 100644
index 00000000..45f0e8b2
--- /dev/null
+++ b/modules/text-config.el
@@ -0,0 +1,121 @@
+;;; text-config --- Text Settings and Functionality -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; Configuration for text editing features including:
+;; - Basic text mode settings (visual line mode, indentation, spacing)
+;; - Text manipulation (move-text, expand-region, change-inner)
+;; - Selection behavior (delete-selection-mode)
+;; - Editing tools (edit-indirect, olivetti, accent)
+;; - Visual enhancements (prettify-symbols, visual-fill-column)
+
+;;; Code:
+
+;; ------------------------------- Text Settings -------------------------------
+
+;; Global text settings
+(setq-default indent-tabs-mode nil) ;; indentation should not insert tabs
+(setq require-final-newline nil) ;; don't require newlines at the end of files
+(setq sentence-end-double-space nil) ;; in the 21st century, sentences end w/ a single space
+
+(defun cj/text-mode-settings ()
+ "Personal settings for `text-mode'."
+ (turn-on-visual-line-mode)) ;; wrap text in text modes
+(add-hook 'text-mode-hook 'cj/text-mode-settings)
+
+;; --------------------------------- Move Text ---------------------------------
+;; move the current line or selected region up or down in the buffer
+
+(use-package move-text
+ :defer 0.5
+ :bind
+ (("C-<up>" . move-text-up)
+ ("C-<down>" . move-text-down)))
+
+;; ------------------------------- Expand Region -------------------------------
+;; increase the region by semantic units
+
+(use-package expand-region
+ :defer 0.5
+ :bind
+ (("M-=" . er/expand-region)
+ ("C->" . er/expand-region)
+ ("M--" . er/contract-region)
+ ("C-<" . er/contract-region)))
+
+;; ---------------------------- Change Inner / Outer ---------------------------
+;; change inner and outer, just like in vim.
+
+(use-package change-inner
+ :defer 0.5
+ :bind (("C-c i" . change-inner)
+ ("C-c o" . change-outer)))
+
+;; ------------------------------ Delete Selection -----------------------------
+;; delete the region on character insertion
+
+(use-package delsel
+ :ensure nil ;; built-in
+ :defer 0.5
+ :config
+ (delete-selection-mode t))
+
+;; ------------------------------- Edit Indirect -------------------------------
+;; edit selection in new buffer, C-c to finish; replaces with modifications
+
+(use-package edit-indirect
+ :defer 1
+ :bind ("M-I" . edit-indirect-region))
+
+;; ------------------------------ Prettify Symbols -----------------------------
+;; replacing the word l-a-m-b-d-a with a symbol, just because
+
+(setq-default prettify-symbols-alist
+ (let ((mapping (lambda (pair)
+ (let ((k (car pair))
+ (v (cdr pair)))
+ (list (cons (downcase k) v)
+ (cons (upcase k) v))))))
+ (apply #'append
+ (mapcar mapping
+ '(
+ ("#+begin_src" . "λ")
+ ("#+begin_src" . "λ")
+ ("#+end_src" . "λ")
+ ("#+begin_quote" . "")
+ ("#+end_quote" . "")
+ ("lambda" . "λ"))))))
+
+
+(add-hook 'prog-mode-hook 'turn-on-prettify-symbols-mode)
+(add-hook 'org-mode-hook 'turn-on-prettify-symbols-mode)
+
+;; ---------------------------------- Olivetti ---------------------------------
+;; center text in the middle of the screen.
+
+(use-package olivetti
+ :defer 1
+ :config
+ (setq-default olivetti-body-width 100))
+
+;; --------------------------- Accent (Diacriticals) ---------------------------
+;; an easy way to enter diacritical marks
+
+(use-package accent
+ :defer 1
+ :bind ("C-`" . accent-company))
+
+;; ----------------------------- Visual Fill Column ----------------------------
+;; text wrapping
+
+;; (use-package visual-fill-column
+;; :defer 0.5
+;; :config
+;; (setq-default visual-fill-column-center-text nil)
+;; (setq-default visual-fill-column-width 100)
+;; :hook
+;; (visual-line-mode . visual-fill-column-mode))
+
+(provide 'text-config)
+;;; text-config.el ends here
diff --git a/modules/tramp-config.el b/modules/tramp-config.el
new file mode 100644
index 00000000..5f58678a
--- /dev/null
+++ b/modules/tramp-config.el
@@ -0,0 +1,135 @@
+;;; tramp-config.el --- Tramp Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+;;
+;; TRAMP (Transparent Remote Access, Multiple Protocol)
+;;
+;; To handle fancy prompts on remote servers, add this to your shell configuration:
+;;
+;; [[ $TERM == "dumb" ]] && PS1='$ ' && return
+;; [[ $TERM == "tramp" ]] && PS1='$ ' && return
+;;
+;; For zsh users:
+;; [[ $TERM == "dumb" || $TERM == "tramp" ]] && unsetopt zle && PS1='$ ' && return
+
+;;; Code:
+
+(use-package tramp
+ :defer .5
+ :ensure nil ;; built-in
+ :config
+ ;; Debugging (uncomment when needed)
+ ;; (setq tramp-debug-buffer t)
+ ;; (setq tramp-verbose 10)
+
+ ;; Basic Settings
+ ;; Terminal type reported by tramp to host
+ (setq tramp-terminal-type "dumb")
+
+ ;; Use the path assigned to the remote user by the remote host
+ (add-to-list 'tramp-remote-path 'tramp-own-remote-path)
+
+ ;; Also check for a ~/.ssh/config host entry
+ (tramp-set-completion-function "ssh"
+ '((tramp-parse-sconfig "/etc/ssh/ssh_config")
+ (tramp-parse-sconfig "~/.ssh/config")))
+
+ ;; File Handling
+ ;; Store auto-save files locally
+ (setq tramp-auto-save-directory
+ (expand-file-name "tramp-auto-save" user-emacs-directory))
+
+ ;; Create directory if it doesn't exist
+ (unless (file-exists-p tramp-auto-save-directory)
+ (make-directory tramp-auto-save-directory t))
+
+ ;; Turn off the backup "$filename~" feature for remote files
+ (setq remote-file-name-inhibit-auto-save-visited t)
+ (add-to-list 'backup-directory-alist
+ (cons tramp-file-name-regexp nil))
+
+ ;; Performance Settings
+ ;; Set a more representative name for the persistency file
+ (setq tramp-persistency-file-name
+ (expand-file-name "tramp-connection-history" user-emacs-directory))
+
+ ;; Always use external program to copy (more efficient)
+ (setq tramp-copy-size-limit nil)
+
+ ;; Cache remote file attributes for better performance
+ (setq remote-file-name-inhibit-cache nil)
+
+ ;; Don't check for modified buffers before revert
+ ;; to avoid unnecessary remote operations
+ (setq revert-without-query '(".*"))
+
+ ;; Refresh buffers when needed rather than automatically
+ (setq auto-revert-remote-files nil)
+
+ ;; Security & Authentication
+ ;; Cache and don't expire passwords
+ (setq password-cache t)
+ (setq password-cache-expiry nil)
+
+ ;; Use SSH control connections for better performance
+ (setq tramp-use-ssh-controlmaster-options t)
+
+ ;; Connection Settings
+ ;; Set tramp-direct-async-process locally in all ssh connections
+ (connection-local-set-profile-variables
+ 'remote-direct-async-process
+ '((tramp-direct-async-process . t)))
+ (connection-local-set-profiles
+ '(:application tramp :protocol "ssh")
+ 'remote-direct-async-process)
+ (connection-local-set-profiles
+ '(:application tramp :protocol "sshx")
+ 'remote-direct-async-process)
+
+ ;; Set sane defaults for frequently used methods
+ (connection-local-set-profile-variables
+ 'remote-bash
+ '((shell-file-name . "/bin/bash")
+ (shell-command-switch . "-c")))
+ (connection-local-set-profiles
+ '(:application tramp)
+ 'remote-bash)
+
+ ;; Don't determine remote files VC status (for a performance gain)
+ (setq vc-ignore-dir-regexp (concat vc-ignore-dir-regexp "\\|" tramp-file-name-regexp))
+
+ ;; Method-specific settings
+ ;; Default transfer method (use scp for most efficient transfer)
+ (setq tramp-default-method "scp")
+
+ ;; Use different methods based on host/domain patterns
+ (add-to-list 'tramp-methods
+ '("sshfast"
+ (tramp-login-program "ssh")
+ (tramp-login-args (("-l" "%u") ("-p" "%p") ("%c")
+ ("-e" "none") ("-t" "-t") ("%h")))
+ (tramp-async-args (("-q")))
+ (tramp-remote-shell "/bin/sh")
+ (tramp-remote-shell-login ("-l"))
+ (tramp-remote-shell-args ("-c"))
+ (tramp-connection-timeout 10)))
+
+ ;; Remote shell and project settings
+ ;; Support for Docker containers
+ (add-to-list 'tramp-remote-path 'tramp-own-remote-path)
+ (add-to-list 'tramp-remote-path "/usr/local/bin")
+ (add-to-list 'tramp-remote-path "/usr/local/sbin")
+
+ ;; Enable directory tracking (useful for eshell)
+ (setq dirtrack-list '("^.*?\\([a-zA-Z]:.*\\|/.*\\)" 1))
+
+ ;; Avoid problems with Git on TRAMP
+ (setq magit-git-executable "/usr/bin/git")
+
+ ;; Cleanup settings
+ ;; Cleanup TRAMP buffers when idle (every 15 min)
+ (setq tramp-cleanup-idle-time 900))
+
+(provide 'tramp-config)
+;;; tramp-config.el ends here
diff --git a/modules/ui-config.el b/modules/ui-config.el
new file mode 100644
index 00000000..022a0574
--- /dev/null
+++ b/modules/ui-config.el
@@ -0,0 +1,141 @@
+;;; ui-config --- User Interface Preferences -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; This file centralizes user interface preferences, including:
+
+;; • Frame and window behavior
+;; – Start all frames maximized
+;; – Disable file‐ and dialog‐boxes
+;; – Pixel scroll precision
+;; – Show column numbers in the mode-line
+
+;; • Transparency controls
+;; – Customizable variables 'cj/enable-transparency' and 'cj/transparency-level'
+;; – Interactive 'cj/toggle-transparency' command
+
+;; • Cursor appearance
+;; – Cursor color changes on the buffer's write and insertion state
+;; (i.e., read-only, overwrite, normal)
+;; – Option to customize cursor shape with 'cj/set-cursor-type'
+
+;; • Icons
+;; – Load and enable 'nerd-icons' for UI glyphs
+
+;; Customize the transparency and cursor color options at the top of this file.
+
+;;; Code:
+
+;; -------------------------------- UI Constants -------------------------------
+
+(defcustom cj/enable-transparency nil
+ "Non-nil means use `cj/transparency-level' for frame transparency."
+ :type 'boolean
+ :group 'ui-config)
+
+(defcustom cj/transparency-level 84
+ "Opacity level for Emacs frames when `cj/enable-transparency' is non-nil.
+
+100 = fully opaque, 0 = fully transparent."
+ :type 'integer
+ :group 'ui-config)
+
+(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.")
+
+;; ----------------------------- System UI Settings ----------------------------
+
+(add-to-list 'initial-frame-alist '(fullscreen . maximized)) ;; start the initial frame maximized
+(add-to-list 'default-frame-alist '(fullscreen . maximized)) ;; start every frame maximized
+(setq pixel-scroll-precision-mode nil) ;; smooth scroll past images - enabled if nil!
+
+(setq-default frame-inhibit-implied-resize t) ;; don't resize frames when setting ui-elements
+(setq frame-title-format '("Emacs " emacs-version" : %b")) ;; the title is emacs with version and buffer name
+
+(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
+(setq switch-to-buffer-obey-display-actions t) ;; manual buffer switching obeys display action rules
+
+;; -------------------------------- Transparency -------------------------------
+
+(defun cj/apply-transparency ()
+ "Apply `cj/transparency-level' to the selected frame and future frames.
+
+When `cj/enable-transparency' is nil, reset alpha to fully opaque."
+ (let ((alpha (if cj/enable-transparency
+ (cons cj/transparency-level cj/transparency-level)
+ '(100 . 100))))
+ ;; apply to current frame
+ (set-frame-parameter nil 'alpha alpha)
+ ;; update default for new frames
+ (setq default-frame-alist
+ (assq-delete-all 'alpha default-frame-alist))
+ (add-to-list 'default-frame-alist `(alpha . ,alpha))))
+
+;; apply once at startup
+(cj/apply-transparency)
+
+(defun cj/toggle-transparency ()
+ "Toggle `cj/enable-transparency' and re-apply."
+ (interactive)
+ (setq cj/enable-transparency (not cj/enable-transparency))
+ (cj/apply-transparency)
+ (message "Transparency %s"
+ (if cj/enable-transparency "enabled" "disabled")))
+
+;; ----------------------------------- Cursor ----------------------------------
+;; set cursor color according to mode
+;;
+;; #f06a3f indicates a read-only document
+;; #c48702 indicates overwrite mode
+;; #64aa0f indicates insert and read/write mode
+
+;; ----------------------------------- Cursor ----------------------------------
+
+(defvar cj/-cursor-last-color nil
+ "Last color applied by `cj/set-cursor-color-according-to-mode'.")
+(defvar cj/-cursor-last-buffer nil
+ "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)))))
+
+(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)
+
+;; Initialize to box cursor (or any type you prefer)
+(defun cj/set-cursor-type (new-cursor-type)
+ "Set the cursor type of the selected frame to NEW-CURSOR-TYPE."
+ (interactive
+ (list (intern (completing-read
+ "Cursor type: "
+ (mapcar #'list '("box" "hollow" "bar" "hbar" nil))))))
+ (modify-frame-parameters nil `((cursor-type . ,new-cursor-type))))
+
+(cj/set-cursor-type 'box)
+
+;; --------------------------------- Nerd Icons --------------------------------
+;; use icons from nerd fonts in the Emacs UI
+
+(use-package nerd-icons
+ :demand t)
+
+(provide 'ui-config)
+;;; ui-config.el ends here
diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el
new file mode 100644
index 00000000..3dd0eeeb
--- /dev/null
+++ b/modules/ui-navigation.el
@@ -0,0 +1,154 @@
+;;; ui-navigation --- Managing Cursor Placement, Buffers, and Windows -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; Window Navigation
+
+;; This section handles situations where we're navigating or arranging windows
+
+;; Shift + arrow keys = move the cursors around the windows/buffers
+;; Control + Shift + arrow keys = resize the windows
+;; Meta + Shift + arrow keys = move the windows around
+
+;; M-H - split windows, creating a new window horizontally to the right
+;; M-V - split windows, creating a new window vertically to the bottom
+;; M-T - toggle the orientation of the split between horizontal and vertical
+;; M-S - swap window positions
+;; M-C - kill the current window
+;; M-O - kill the other window
+;; M-Z - undo kill buffer
+;; M-U - winner undo (revert to the previous layout)
+
+;; Adjusting Window Sizes
+;; Note: C-s is pressing Control + Super keys
+;; C-s-<left> move window left
+;; C-s-<right> move window right
+;; C-s-<up> move window up
+;; C-s-<down> move window down
+
+;;; Code:
+
+;; ------------------------------ Window Placement -----------------------------
+
+(use-package windmove
+ :defer .5
+ :config
+ (windmove-default-keybindings)) ; move cursor around with shift+arrows
+
+;; ------------------------------ Window Resizing ------------------------------
+
+(use-package windsize
+ :defer .5
+ :bind
+ ("C-s-<left>" . windsize-left)
+ ("C-s-<right>" . windsize-right)
+ ("C-s-<up>" . windsize-up)
+ ("C-s-<down>" . windsize-down))
+
+;; M-shift = to balance multiple split windows
+(global-set-key (kbd "M-+") 'balance-windows)
+
+;; ------------------------------ Window Splitting -----------------------------
+
+(defun cj/split-and-follow-right ()
+ "Split window horizontally and select a buffer to display."
+ (interactive)
+ (split-window-right)
+ (other-window 1)
+ (consult-buffer))
+(global-set-key (kbd "M-V") 'cj/split-and-follow-right)
+
+(defun cj/split-and-follow-below ()
+ "Split window vertically and select a buffer to display."
+ (interactive)
+ (split-window-below)
+ (other-window 1)
+ (consult-buffer))
+(global-set-key (kbd "M-H") 'cj/split-and-follow-below)
+
+;; ------------------------- Split Window Reorientation ------------------------
+
+(defun toggle-window-split ()
+ "Toggle the orientation of the current window split.
+
+If the window is split horizontally, change the split to vertical.
+If it's vertical, change the split to horizontal.
+This function won't work with more than one split window."
+ (interactive)
+ (if (= (count-windows) 2)
+ (let* ((this-win-buffer (window-buffer))
+ (next-win-buffer (window-buffer (next-window)))
+ (this-win-edges (window-edges (selected-window)))
+ (next-win-edges (window-edges (next-window)))
+ (this-win-2nd (not (and (<= (car this-win-edges)
+ (car next-win-edges))
+ (<= (cadr this-win-edges)
+ (cadr next-win-edges)))))
+ (splitter
+ (if (= (car this-win-edges)
+ (car (window-edges (next-window))))
+ 'split-window-horizontally
+ 'split-window-vertically)))
+ (delete-other-windows)
+ (let ((first-win (selected-window)))
+ (funcall splitter)
+ (if this-win-2nd (other-window 1))
+ (set-window-buffer (selected-window) this-win-buffer)
+ (set-window-buffer (next-window) next-win-buffer)
+ (select-window first-win)
+ (if this-win-2nd (other-window 1))))))
+(global-set-key (kbd "M-T") 'toggle-window-split)
+
+;; SWAP WINDOW POSITIONS
+(global-set-key (kbd "M-S") 'window-swap-states)
+
+;; ---------------------------- Buffer Manipulation ----------------------------
+
+;; MOVE BUFFER
+;; allows changing buffer positions in a layout.
+(use-package buffer-move
+ :bind
+ ("C-M-<down>" . buf-move-down)
+ ("C-M-<up>" . buf-move-up)
+ ("C-M-<left>" . buf-move-left)
+ ("C-M-<right>" . buf-move-right))
+
+
+;; UNDO KILL BUFFER
+(defun cj/undo-kill-buffer (arg)
+ "Re-open the last buffer killed. With ARG, re-open the nth buffer."
+ (interactive "p")
+ (require 'recentf)
+ (unless recentf-mode
+ (recentf-mode 1))
+ (let ((recently-killed-list (copy-sequence recentf-list))
+ (buffer-files-list
+ (delq nil (mapcar (lambda (buf)
+ (when (buffer-file-name buf)
+ (expand-file-name (buffer-file-name buf))))
+ (buffer-list)))))
+ (mapc
+ (lambda (buf-file)
+ (setq recently-killed-list
+ (delq buf-file recently-killed-list)))
+ buffer-files-list)
+ (when recently-killed-list
+ (find-file
+ (if arg (nth arg recently-killed-list)
+ (car recently-killed-list))))))
+(global-set-key (kbd "M-Z") 'cj/undo-kill-buffer)
+
+;; ---------------------------- Undo Layout Changes ----------------------------
+;; allows you to restore your window setup with C-c left-arrow
+;; or redo a window change with C-c right-arrow if you change your mind
+
+(use-package winner
+ :ensure nil ;; built-in
+ :defer .5
+ :bind ("M-U" . winner-undo)
+ :config
+ (winner-mode 1))
+
+(provide 'ui-navigation)
+;;; ui-navigation.el ends here
diff --git a/modules/ui-theme.el b/modules/ui-theme.el
new file mode 100644
index 00000000..d754b554
--- /dev/null
+++ b/modules/ui-theme.el
@@ -0,0 +1,135 @@
+;;; ui-theme.el --- UI Theme Configuration and Persistence -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+
+;; This module provides a theme management system with persistence across
+;; Emacs sessions.
+;;
+;; - Theme selection via interactive interface (M-L)
+;; - Automatic persistence of theme preference to a configurable file
+;; - Safe theme loading with fallback mechanism
+;; - Support for custom themes in the "themes" subdirectory
+;;
+;; The persistence mechanism saves theme choices to a file that can be
+;; synchronized across machines, ensuring consistent appearance across
+;; multiple computers.
+
+;;; Code:
+
+(require 'user-constants) ;; For sync-dir
+
+;; ----------------------------------- Themes ----------------------------------
+;; theme choices and settings
+
+;; downloaded custom themes go in themes subdirectory
+(setq custom-safe-themes t) ;; trust all custom themes
+(add-to-list 'custom-theme-load-path
+ (concat user-emacs-directory "themes"))
+
+;; ------------------------------- Switch Themes -------------------------------
+;; loads themes in completing read, then persists via the functions below
+
+(defun cj/switch-themes ()
+ "Function to switch themes and save chosen theme name for persistence.
+
+Unloads any other applied themes before applying the chosen theme."
+ (interactive)
+ (let ((chosentheme ""))
+ (setq chosentheme
+ (completing-read "Load custom theme: "
+ (mapcar #'symbol-name
+ (custom-available-themes))))
+ (mapc #'disable-theme custom-enabled-themes)
+ (load-theme (intern chosentheme) t))
+ (cj/save-theme-to-file))
+
+(global-set-key (kbd "M-L") 'cj/switch-themes)
+
+;; ----------------------------- Theme Persistence -----------------------------
+;; persistence utility functions used by switch themes.
+
+(defvar theme-file (concat sync-dir "emacs-theme.persist")
+ "The location of the file to persist the theme name.
+
+If you want your theme change to persist across instances, put this in a
+directory that is sync'd across machines with this configuration.")
+
+(defvar fallback-theme-name "modus-vivendi"
+ "The name of the theme to fallback on.
+
+This is used then there's no file, or the theme name doesn't match
+any of the installed themes. This should be a built-in theme. If theme name is
+'nil', there will be no theme.")
+
+(defun cj/read-file-contents (filename)
+ "Read FILENAME and return its content as a string.
+
+If FILENAME isn't readable, return nil."
+ (when (file-readable-p filename)
+ (with-temp-buffer
+ (insert-file-contents filename)
+ (string-trim (buffer-string)))))
+
+(defun cj/write-file-contents (content filename)
+ "Write CONTENT to FILENAME.
+
+If FILENAME isn't writeable, return nil. If successful, return t."
+ (when (file-writable-p filename)
+ (condition-case err
+ (progn
+ (with-temp-buffer
+ (insert content)
+ (write-file filename))
+ t)
+ (error
+ (message "Error writing to %s: %s" filename (error-message-string err))
+ nil))))
+
+(defun cj/get-active-theme-name ()
+ "Return the name of the active UI theme as a string.
+
+Returns fallback-theme-name if no theme is active."
+ (if custom-enabled-themes
+ (symbol-name (car custom-enabled-themes))
+ fallback-theme-name))
+
+(defun cj/save-theme-to-file ()
+ "Save the string representing the current theme to the theme-file."
+ (if (equal (cj/write-file-contents (cj/get-active-theme-name) theme-file) nil)
+ (message "Cannot save theme: %s is unwriteable" theme-file)
+ (message "%s theme saved to %s" (cj/get-active-theme-name) theme-file)))
+
+(defun cj/load-fallback-theme (msg)
+ "Display MSG and load ui-theme fallback-theme-name.
+
+Used to handle errors with loading persisted theme."
+ (message "%s Loading fallback theme %s" msg fallback-theme-name)
+ (load-theme (intern fallback-theme-name) t))
+
+(defun cj/load-theme-from-file ()
+ "Apply the theme name contained in theme-file as the active UI theme.
+
+If the theme is nil, it disables all current themes. If an error occurs
+loading the file name, the fallback-theme-name is applied and saved."
+ (let ((theme-name (cj/read-file-contents theme-file)))
+ ;; if theme-name is nil, unload all themes and load fallback theme
+ (if (not theme-name)
+ (progn
+ (mapc #'disable-theme custom-enabled-themes)
+ (cj/load-fallback-theme "Theme file not found or empty."))
+ ;; Check if theme is 'nil' string
+ (if (string= theme-name "nil")
+ (mapc #'disable-theme custom-enabled-themes)
+ ;; apply theme name or if error, load fallback theme
+ (condition-case err
+ (load-theme (intern theme-name) t)
+ (error
+ (cj/load-fallback-theme
+ (format "Error loading theme %s: %s."
+ theme-name (error-message-string err)))))))))
+
+(cj/load-theme-from-file)
+
+(provide 'ui-theme)
+;;; ui-theme.el ends here
diff --git a/modules/undead-buffers.el b/modules/undead-buffers.el
new file mode 100644
index 00000000..711b657b
--- /dev/null
+++ b/modules/undead-buffers.el
@@ -0,0 +1,181 @@
+;;; undead-buffers.el --- Bury Rather Than Kill These Buffers -*- lexical-binding: t; coding: utf-8; -*-
+
+;;; Commentary:
+;;
+;; 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.
+;; - 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.
+;;
+;; Add to the list of "undead buffers" by adding to the cj/buffer-bury-alive-list
+;; variable.
+;;
+;;; Code:
+
+(defvar cj/buffer-bury-alive-list
+ '("*dashboard*" "*scratch*" "*EMMS Playlist*" "*Messages*" "*ert*" "*AI-Assistant*")
+ "Buffers to bury instead of killing.")
+
+(defun cj/kill-buffer-or-bury-alive (buffer)
+ "Kill BUFFER or bury it if it's in `cj/buffer-bury-alive-list'."
+ (interactive "bBuffer to kill or bury: ")
+ (with-current-buffer buffer
+ (if current-prefix-arg
+ (progn
+ (add-to-list 'cj/buffer-bury-alive-list (buffer-name))
+ (message "Added %s to bury-alive-list" (buffer-name)))
+ (if (member (buffer-name) cj/buffer-bury-alive-list)
+ (bury-buffer)
+ (kill-buffer)))))
+(global-set-key [remap kill-buffer] #'cj/kill-buffer-or-bury-alive)
+
+(defun cj/undead-buffer-p ()
+ "Predicate for =save-some-buffers= that skips buffers in =cj/buffer-bury-alive-list=."
+ (let* ((buf (current-buffer))
+ (name (buffer-name buf)))
+ (and
+ (not (member name cj/buffer-bury-alive-list))
+ (buffer-file-name buf)
+ (buffer-modified-p buf))))
+
+(defun cj/save-some-buffers (&optional arg)
+ "Save some buffers, omitting those in =cj/buffer-bury-alive-list=.
+ARG is passed to =save-some-buffers=."
+ (interactive "P")
+ (save-some-buffers arg #'cj/undead-buffer-p))
+
+(defun cj/kill-buffer-and-window ()
+ "Delete window and kill or bury its buffer."
+ (interactive)
+ (let ((buf (current-buffer)))
+ (delete-window)
+ (cj/kill-buffer-or-bury-alive buf)))
+(global-set-key (kbd "M-C") #'cj/kill-buffer-and-window)
+
+(defun cj/kill-other-window ()
+ "Delete the next window and kill or bury its buffer."
+ (interactive)
+ (other-window 1)
+ (let ((buf (current-buffer)))
+ (unless (one-window-p)
+ (delete-window))
+ (cj/kill-buffer-or-bury-alive buf)))
+(global-set-key (kbd "M-O") #'cj/kill-other-window)
+
+(defun cj/kill-all-other-buffers-and-windows ()
+ "Kill or bury all other buffers, then delete other windows."
+ (interactive)
+ (cj/save-some-buffers)
+ (delete-other-windows)
+ (mapc #'cj/kill-buffer-or-bury-alive
+ (delq (current-buffer) (buffer-list))))
+(global-set-key (kbd "M-M") #'cj/kill-all-other-buffers-and-windows)
+
+(provide 'undead-buffers)
+;;; undead-buffers.el ends here.
+
+;; --------------------------------- ERT Tests ---------------------------------
+;; Run these tests with M-x ert RET t RET
+
+(require 'ert)
+(require 'cl-lib)
+
+(ert-deftest undead-buffers/kill-or-bury-when-not-in-list-kills ()
+ "cj/kill-buffer-or-bury-alive should kill a buffer not in `cj/buffer-bury-alive-list'."
+ (let* ((buf (generate-new-buffer "test-not-in-list"))
+ (orig (copy-sequence cj/buffer-bury-alive-list)))
+ (unwind-protect
+ (progn
+ (should (buffer-live-p buf))
+ (cj/kill-buffer-or-bury-alive (buffer-name buf))
+ (should-not (buffer-live-p buf)))
+ (setq cj/buffer-bury-alive-list orig)
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest undead-buffers/kill-or-bury-when-in-list-buries ()
+ "cj/kill-buffer-or-bury-alive should bury (not kill) a buffer in the list."
+ (let* ((name "*dashboard*") ; an element already in the default list
+ (buf (generate-new-buffer name))
+ (orig (copy-sequence cj/buffer-bury-alive-list))
+ win-was)
+ (unwind-protect
+ (progn
+ (add-to-list 'cj/buffer-bury-alive-list name)
+ ;; show it in a temporary window so we can detect bury
+ (setq win-was (display-buffer buf))
+ (cj/kill-buffer-or-bury-alive name)
+ ;; bury should leave it alive
+ (should (buffer-live-p buf))
+ ;; note: Emacs’s `bury-buffer` does not delete windows by default,
+ ;; so we no longer assert that no window shows it.
+ )
+ ;; cleanup
+ (setq cj/buffer-bury-alive-list orig)
+ (delete-windows-on buf)
+ (kill-buffer buf))))
+
+(ert-deftest undead-buffers/kill-or-bury-adds-to-list-with-prefix ()
+ "Calling `cj/kill-buffer-or-bury-alive' with a prefix arg should add the buffer to the list."
+ (let* ((buf (generate-new-buffer "test-add-prefix"))
+ (orig (copy-sequence cj/buffer-bury-alive-list)))
+ (unwind-protect
+ (progn
+ (let ((current-prefix-arg '(4)))
+ (cj/kill-buffer-or-bury-alive (buffer-name buf)))
+ (should (member (buffer-name buf) cj/buffer-bury-alive-list)))
+ (setq cj/buffer-bury-alive-list orig)
+ (kill-buffer buf))))
+
+(ert-deftest undead-buffers/kill-buffer-and-window-removes-window ()
+ "cj/kill-buffer-and-window should delete the current window and kill/bury its buffer."
+ (let* ((buf (generate-new-buffer "test-kill-and-win"))
+ (orig (copy-sequence cj/buffer-bury-alive-list)))
+ (split-window) ; now two windows
+ (let ((win (next-window)))
+ (set-window-buffer win buf)
+ (select-window win)
+ (cj/kill-buffer-and-window)
+ (should-not (window-live-p win))
+ (unless (member (buffer-name buf) orig)
+ (should-not (buffer-live-p buf))))
+ (setq cj/buffer-bury-alive-list orig)))
+
+(ert-deftest undead-buffers/kill-other-window-deletes-that-window ()
+ "cj/kill-other-window should delete the *other* window and kill/bury its buffer."
+ (let* ((buf1 (current-buffer))
+ (buf2 (generate-new-buffer "test-other-window"))
+ (orig (copy-sequence cj/buffer-bury-alive-list)))
+ (split-window)
+ (let* ((win1 (selected-window))
+ (win2 (next-window win1)))
+ (set-window-buffer win2 buf2)
+ ;; stay on the original window
+ (select-window win1)
+ (cj/kill-other-window)
+ (should-not (window-live-p win2))
+ (unless (member (buffer-name buf2) orig)
+ (should-not (buffer-live-p buf2))))
+ (setq cj/buffer-bury-alive-list orig)))
+
+(ert-deftest undead-buffers/kill-all-other-buffers-and-windows-keeps-only-current ()
+ "cj/kill-all-other-buffers-and-windows should delete other windows and kill/bury all other buffers."
+ (let* ((main (current-buffer))
+ (extra (generate-new-buffer "test-all-others"))
+ (orig (copy-sequence cj/buffer-bury-alive-list)))
+ (split-window)
+ (set-window-buffer (next-window) extra)
+ (cj/kill-all-other-buffers-and-windows)
+ (should (one-window-p))
+ ;; main buffer still exists
+ (should (buffer-live-p main))
+ ;; extra buffer either buried or killed
+ (unless (member (buffer-name extra) orig)
+ (should-not (buffer-live-p extra)))
+ ;; cleanup
+ (setq cj/buffer-bury-alive-list orig)
+ (when (buffer-live-p extra) (kill-buffer extra))))
diff --git a/modules/user-constants.el b/modules/user-constants.el
new file mode 100644
index 00000000..fd48ffe3
--- /dev/null
+++ b/modules/user-constants.el
@@ -0,0 +1,180 @@
+;;; user-constants.el --- User Constants -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;; Commentary:
+
+;; This module defines important file and directory paths used throughout the
+;; Emacs configuration, and ensures they exist during startup.
+;;
+;; WHY THIS EXISTS:
+;; 1. Centralizes all path definitions for easy reference and maintenance
+;; 2. Prevents startup errors when required directories or files are missing
+;; 3. Makes the configuration more portable across different machines
+;;
+;; The module first defines constants and variables for directories and files,
+;; then provides functions that verify their existence, creating them if needed.
+;; This happens automatically when the module loads.
+;;
+;; The paths are designed with a hierarchical structure, allowing child paths
+;; to reference their parents (e.g., roam-dir is inside sync-dir) for better
+;; maintainability.
+;;
+;;; Code:
+
+;; -------------------------------- Contact Info -------------------------------
+
+(defvar user-whole-name "Craig Jennings"
+ "The user's full name.")
+(defconst user-name (getenv "USER")
+ "The user's name retrieved from the environment variable.")
+(defvar user-mail-address "c@cjennings.net"
+ "The user's email address.")
+
+;; ------------------------ Directory And File Constants -----------------------
+
+;; DIRECTORIES
+(defconst emacs-init-file (expand-file-name "init.el" user-emacs-directory)
+ "The location of Emacs's main init file.")
+
+(defconst emacs-early-init-file (expand-file-name "early-init.el" user-emacs-directory)
+ "The location of Emacs's early init file.")
+
+(defconst user-home-dir (getenv "HOME")
+ "The user's home directory per the environment variable.")
+
+(defconst books-dir (expand-file-name "sync/books/" user-home-dir)
+ "The location of book files for CalibreDB.")
+
+(defconst code-dir (expand-file-name "code/" user-home-dir)
+ "Code repositories are located in this directory.")
+
+(defconst dl-dir (expand-file-name "downloads/" user-home-dir)
+ "Location of the general downloads directory.")
+
+(defconst pix-dir (expand-file-name "pictures/" user-home-dir)
+ "Location of where pictures and images are stored.")
+
+(defconst projects-dir (expand-file-name "projects/" user-home-dir)
+ "Non-code projects and repositories are located in this directory.")
+
+(defconst videos-dir (expand-file-name "videos/" user-home-dir)
+ "Location of where videos are stored.")
+
+(defconst mail-dir (expand-file-name ".mail/" user-home-dir)
+ "Root directory where the mail folders are located.")
+
+(defconst sync-dir (expand-file-name "sync/org/" user-home-dir)
+ "This directory is synchronized across machines.")
+
+(defconst roam-dir (expand-file-name "roam/" sync-dir)
+ "The location of org-roam files.")
+
+(defconst journals-dir (expand-file-name "journal/" roam-dir)
+ "The location of org-roam dailies or journals files.")
+
+(defconst drill-dir (expand-file-name "drill/" sync-dir)
+ "The location of org-drill org files.")
+
+(defconst snippets-dir (expand-file-name "snippets/" sync-dir)
+ "The location of ya-snippet snippets.")
+
+(defvar sounds-dir (expand-file-name "assets/sounds/" user-emacs-directory)
+ "Directory containing sound files for notifications and timers.")
+
+(defconst video-recordings-dir (expand-file-name "sync/recordings/" user-home-dir)
+ "The location to save video recordings.")
+
+(defconst audio-recordings-dir (expand-file-name "sync/recordings/" user-home-dir)
+ "The location to save audio recordings.")
+
+(defconst music-dir (expand-file-name "music/" user-home-dir)
+ "The location to save your music files.")
+
+
+;; FILES
+(defvar authinfo-file (expand-file-name ".authinfo.gpg" user-home-dir)
+ "The location of the encrypted .authinfo or .netrc file.")
+
+(defvar schedule-file (expand-file-name "schedule.org" sync-dir)
+ "The location of the org file containing scheduled events.")
+
+(defvar gcal-file (expand-file-name "gcal.org" sync-dir)
+ "The location of the org file containing Google Calendar information.")
+
+(defvar reference-file (expand-file-name "reference.org" sync-dir)
+ "The location of the org file containing reference information.")
+
+(defvar article-archive (expand-file-name "article-archive.org" sync-dir)
+ "The location of the org file that stores saved articles to keep.")
+
+(defvar inbox-file (expand-file-name "inbox.org" roam-dir)
+ "The location of the org file that serves as the task inbox.")
+
+(defvar reading-notes-file (expand-file-name "reading_notes.org" roam-dir)
+ "The default notes file for org-noter.")
+
+(defvar macros-file (concat sync-dir "macros.el")
+ "The location of the macros file for recorded saved macros via M-f3.")
+
+(defvar contacts-file (expand-file-name "contacts.org" sync-dir)
+ "The location of the org file containing contact information.")
+
+(defvar notification-sound (expand-file-name "BitWave.opus" sounds-dir)
+ "The location of the audio file to use as the default notification.")
+
+(defvar webclipped-file (expand-file-name "webclipped.org" sync-dir)
+ "The location of the org file that keeps webclips to read.
+
+For more information, see org webclipper section of org-capture-config.el")
+
+;; ------------------------- Verify Or Create Functions ------------------------
+
+(defun cj/directory-writable-p (dir)
+ "Check if DIR is writable."
+ (and (file-directory-p dir)
+ (file-writable-p dir)))
+
+(defun cj/verify-or-create-dir (dir)
+ "Verify the directory DIR exists; create it if it doesn't."
+ (condition-case err
+ (unless (file-directory-p dir)
+ (make-directory dir t)
+ (message "Created directory: %s" dir))
+ (error (message "Error creating directory %s: %s" dir (error-message-string err)))))
+
+(defun cj/verify-or-create-file (file)
+ "Verify the file FILE exists; create it if it doesn't."
+ (condition-case err
+ (let ((dir (file-name-directory file)))
+ (when dir (cj/verify-or-create-dir dir))
+ (unless (file-exists-p file)
+ (write-region "" nil file)
+ (message "Created file: %s" file)))
+ (error (message "Error creating file %s: %s" file (error-message-string err)))))
+
+(defun cj/initialize-user-directories-and-files ()
+ "Initialize all necessary directories and files.
+
+This ensures that all directories and files required by the Emacs configuration
+exist, creating them if necessary. This makes the configuration more robust
+and portable across different machines."
+ (interactive)
+ (mapc 'cj/verify-or-create-dir (list drill-dir
+ journals-dir
+ roam-dir
+ snippets-dir
+ video-recordings-dir
+ audio-recordings-dir
+ sync-dir))
+ (mapc 'cj/verify-or-create-file (list schedule-file
+ inbox-file
+ article-archive
+ reading-notes-file
+ contacts-file
+ webclipped-file
+ reference-file)))
+
+;; Initialize directories and files when this module is loaded
+(cj/initialize-user-directories-and-files)
+
+(provide 'user-constants)
+;;; user-constants.el ends here
diff --git a/modules/vc-config.el b/modules/vc-config.el
new file mode 100644
index 00000000..4a556085
--- /dev/null
+++ b/modules/vc-config.el
@@ -0,0 +1,131 @@
+;;; vc-config.el --- Version Control Configuration -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;; Commentary:
+;; C-x g is my general entry to Magit's version control via the status page.
+
+;; Navigating changes in file happens via git gutter
+;; - C-v d will allow jumping to changes
+;; - C-v n and p will bring to next/previous changes
+
+;; Reviewing previous versions happens through git timemahine
+;; - C-v t allows viewing this file by selecting a previous commit
+;; - Once in timemachine, n and p will take you to previous/next commits
+;; - To exit timemachine, press q in the read-only timemachine buffer
+
+;;; Code:
+
+;; ---------------------------- Magit Configuration ----------------------------
+
+(use-package magit
+ :defer 0.5
+ :bind ("C-x g" . magit-status)
+ :hook
+ (magit-log-mode . display-line-numbers-mode)
+ :custom
+ (magit-define-global-key-bindings 'default)
+ :config
+ (setq magit-bury-buffer-function 'magit-restore-window-configuration)
+ (setq git-commit-major-mode 'org-mode) ;; edit commit messages in org-mode
+ (setq magit-display-buffer-function
+ 'magit-display-buffer-fullframe-status-topleft-v1)
+
+ ;; CLONING
+ (setq magit-clone-default-directory code-dir) ;; cloned repositories go here by default
+ (setq magit-clone-set-remote-head t) ;; do as git does for remote heads
+ (setq magit-clone-set-remote.pushDefault 'ask) ;; ask if origin is default
+ ) ;; end use-package magit
+
+;; --------------------------------- Git Gutter --------------------------------
+;; mark changed lines since last commit in the margin
+
+(use-package git-gutter
+ :defer t
+ :hook (prog-mode . git-gutter-mode)
+ :custom
+ (git-gutter:modified-sign "~")
+ (git-gutter:added-sign "+")
+ (git-gutter:deleted-sign "-")
+ (git-gutter:update-interval 0.05))
+
+;; ------------------------------ Git Timemachine ------------------------------
+
+(defun cj/git-timemachine ()
+ "Open git snapshot with the selected version."
+ (interactive)
+ (unless (featurep 'git-timemachine)
+ (require 'git-timemachine))
+ (git-timemachine--start #'cj/git-timemachine-show-selected-revision))
+
+(use-package git-timemachine
+ :commands (git-timemachine
+ git-timemachine-show-revision
+ git-timemachine-show-selected-revision)
+ :init
+ (defun cj/git-timemachine-show-selected-revision ()
+ "Displays git revisions of file in chronological order adding metadata."
+ (interactive)
+ (let* ((revisions (git-timemachine--revisions))
+ (candidates (mapcar
+ (lambda (rev)
+ (concat (substring-no-properties (nth 0 rev) 0 7)
+ " | "
+ (or (nth 3 rev) "No date")
+ " | "
+ (nth 5 rev)
+ " | "
+ (nth 6 rev)))
+ revisions))
+ ;; Create completion table with metadata to prevent sorting
+ (completion-table
+ (lambda (string pred action)
+ (if (eq action 'metadata)
+ ;; Tell vertico not to sort these candidates
+ '(metadata (display-sort-function . identity)
+ (cycle-sort-function . identity))
+ (complete-with-action action candidates string pred)))))
+ (let* ((selected (completing-read "Select revision: " completion-table nil t))
+ (index (cl-position selected candidates :test #'string=)))
+ (when index
+ (git-timemachine-show-revision (nth index revisions))))))
+
+ :bind (:map cj/vc-map
+ ("t" . cj/git-timemachine)))
+
+;; -------------------------------- Magit Forge --------------------------------
+;; GitHub/GitLab/etc integration for Magit
+
+(use-package forge
+ :after magit
+ :init
+ ;; Set up forge database location
+ (setq forge-database-file
+ (expand-file-name "forge-database.sqlite" user-emacs-directory))
+
+ :config
+ (setq forge-pull-notifications nil) ;; Don't pull notifications by default
+ (setq forge-topic-list-limit '(60 . 10))) ;; Show 60 open and 10 closed items
+
+(defun cj/forge-create-issue ()
+ "Create a new issue in the current repository."
+ (interactive)
+ (if (forge-current-repository)
+ (forge-create-issue)
+ (user-error "Not in a forge repository")))
+
+;; --------------------------------- VC Keymap ---------------------------------
+
+;; Ordering & sorting prefix and keymap
+(define-prefix-command 'cj/vc-map nil
+ "Keymap for version control operations.")
+(define-key cj/custom-keymap "v" 'cj/vc-map)
+(define-key cj/vc-map "d" 'cj/goto-git-gutter-diff-hunks)
+(define-key cj/vc-map "c" 'cj/forge-create-issue)
+(define-key cj/vc-map "f" 'forge-pull)
+(define-key cj/vc-map "i" 'forge-list-issues)
+(define-key cj/vc-map "n" 'git-gutter:next-hunk)
+(define-key cj/vc-map "p" 'git-gutter:previous-hunk)
+(define-key cj/vc-map "r" 'forge-list-pullreqs)
+(define-key cj/vc-map "t" 'cj/git-timemachine)
+
+(provide 'vc-config)
+;;; vc-config.el ends here.
diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el
new file mode 100644
index 00000000..e2238949
--- /dev/null
+++ b/modules/video-audio-recording.el
@@ -0,0 +1,184 @@
+;;; video-audio-recording.el --- Video and Audio Recording -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;
+;;; 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.
+;;
+;; Note: video-recordings-dir and audio-recordings-dir are defined
+;; (and directory created) in user-constants.el
+;;
+;;
+;; To adjust volumes:
+;; - Use =M-x cj/recording-adjust-volumes= (or your keybinding =r l=)
+;; - Or customize permanently: =M-x customize-group RET cj-recording RET=
+;; - Or in your config:
+;; #+begin_src emacs-lisp
+;; (setq cj/recording-mic-boost 1.5) ; 50% louder
+;; (setq cj/recording-system-volume 0.7) ; 30% quieter
+;;
+;;; Code:
+
+(require 'user-constants)
+
+(defgroup cj-recording nil
+ "Settings for video and audio recording."
+ :group 'multimedia)
+
+(defcustom cj/recording-mic-boost 2.0
+ "Volume multiplier for microphone in recordings.
+
+1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB)."
+ :type 'number
+ :group 'cj-recording)
+
+(defcustom cj/recording-system-volume 0.5
+ "Volume multiplier for system audio in recordings.
+
+1.0 = normal volume, 2.0 = double volume (+6dB), 0.5 = half volume (-6dB)."
+ :type 'number
+ :group 'cj-recording)
+
+(defvar cj/video-recording-ffmpeg-process nil
+ "Variable to store the process of the ffmpeg video recording.")
+
+(defvar cj/audio-recording-ffmpeg-process nil
+ "Variable to store the process of the ffmpeg audio recording.")
+
+(defun cj/video-recording-start (arg)
+ "Starts the ffmpeg video recording.
+
+If called with a prefix arg C-u, choose the location on where to save the
+recording, 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)
+ "Starts the ffmpeg audio recording.
+
+If called with a prefix arg C-u, choose the location on where to save the
+recording, 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-audio location)))
+
+(defun cj/ffmpeg-record-video (directory)
+ "Start an ffmpeg video recording. Save output to DIRECTORY."
+ (unless cj/video-recording-ffmpeg-process
+ (let* ((location (expand-file-name directory))
+ (name (format-time-string "%Y-%m-%d-%H-%M-%S"))
+ (filename (expand-file-name (concat name ".mkv") location))
+ (ffmpeg-command
+ (format (concat "ffmpeg -framerate 30 -f x11grab -i :0.0+ "
+ "-f pulse -i "
+ "alsa_input.pci-0000_00_1b.0.analog-stereo "
+ "-ac 1 "
+ "-f pulse -i "
+ "alsa_output.pci-0000_00_1b.0.analog-stereo.monitor "
+ "-ac 2 "
+ "-filter_complex \"[1:a]volume=%.1f[mic];[2:a]volume=%.1f[sys];[mic][sys]amerge=inputs=2[out]\" "
+ "-map 0:v -map \"[out]\" "
+ "%s")
+ cj/recording-mic-boost
+ cj/recording-system-volume
+ filename)))
+ ;; start the recording
+ (setq cj/video-recording-ffmpeg-process
+ (start-process-shell-command "ffmpeg-video-recording"
+ "*ffmpeg-video-recording*"
+ ffmpeg-command))
+ (set-process-query-on-exit-flag cj/video-recording-ffmpeg-process nil)
+ (message "Started video recording process (mic boost: %.1fx, system volume: %.1fx)."
+ cj/recording-mic-boost cj/recording-system-volume))))
+
+(defun cj/ffmpeg-record-audio (directory)
+ "Start an ffmpeg audio recording. Save output to DIRECTORY."
+ (unless cj/audio-recording-ffmpeg-process
+ (let* ((location (expand-file-name directory))
+ (name (format-time-string "%Y-%m-%d-%H-%M-%S"))
+ (filename (expand-file-name (concat name ".opus") location))
+ (ffmpeg-command
+ (format (concat "ffmpeg "
+ "-f pulse -i "
+ "alsa_input.pci-0000_00_1b.0.analog-stereo "
+ "-ac 1 "
+ "-f pulse -i "
+ "alsa_output.pci-0000_00_1b.0.analog-stereo.monitor "
+ "-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 "
+ "%s")
+ cj/recording-mic-boost
+ cj/recording-system-volume
+ filename)))
+ ;; 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 process (mic boost: %.1fx, system volume: %.1fx)."
+ cj/recording-mic-boost cj/recording-system-volume))))
+
+(defun cj/video-recording-stop ()
+ "Stop the ffmpeg video recording process."
+ (interactive)
+ (if cj/video-recording-ffmpeg-process
+ (progn
+ ;; Use interrupt-process to send SIGINT (graceful termination)
+ (interrupt-process cj/video-recording-ffmpeg-process)
+ ;; Give ffmpeg a moment to finalize the file
+ (sit-for 1)
+ (setq cj/video-recording-ffmpeg-process nil)
+ (message "Stopped video recording."))
+ (message "No video recording in progress.")))
+
+(defun cj/audio-recording-stop ()
+ "Stop the ffmpeg audio recording process."
+ (interactive)
+ (if cj/audio-recording-ffmpeg-process
+ (progn
+ ;; Use interrupt-process to send SIGINT (graceful termination)
+ (interrupt-process cj/audio-recording-ffmpeg-process)
+ ;; Give ffmpeg a moment to finalize the file
+ (sit-for 1)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (message "Stopped audio recording."))
+ (message "No audio recording in progress.")))
+
+(defun cj/recording-adjust-volumes ()
+ "Interactively adjust recording volume levels."
+ (interactive)
+ (let ((mic (read-number "Microphone boost (1.0 = normal, 2.0 = double): "
+ cj/recording-mic-boost))
+ (sys (read-number "System audio level (1.0 = normal, 0.5 = half): "
+ cj/recording-system-volume)))
+ (customize-set-variable 'cj/recording-mic-boost mic)
+ (customize-set-variable 'cj/recording-system-volume sys)
+ (message "Recording levels updated - Mic: %.1fx, System: %.1fx" mic sys)))
+
+;; Recording operations prefix and keymap
+(define-prefix-command 'cj/record-map nil
+ "Keymap for video/audio recording operations.")
+(define-key cj/custom-keymap "r" 'cj/record-map)
+(define-key cj/record-map "V" 'cj/video-recording-stop)
+(define-key cj/record-map "v" 'cj/video-recording-start)
+(define-key cj/record-map "A" 'cj/audio-recording-stop)
+(define-key cj/record-map "a" 'cj/audio-recording-start)
+(define-key cj/record-map "l" 'cj/recording-adjust-volumes) ; 'l' for levels
+
+(provide 'video-audio-recording)
+;;; video-audio-recording.el ends here.
diff --git a/modules/weather-config.el b/modules/weather-config.el
new file mode 100644
index 00000000..526a0b41
--- /dev/null
+++ b/modules/weather-config.el
@@ -0,0 +1,40 @@
+;;; weather-config.el --- -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+;;; Commentary:
+;;
+;; Call M-W to open wttrin with your preferred location list immediately.
+;; Adjust the city list by editing `wttrin-default-locations` or answering wttrin prompts when asked.
+;; Forecasts arrive in an Emacs buffer, so you can stay keyboard-only while checking weather.
+;;
+;;; Code:
+
+;; ----------------------------------- Wttrin ----------------------------------
+
+(use-package wttrin
+ :defer t
+ :load-path ("~/code/wttrin")
+ :ensure nil ;; local package
+ :preface
+ ;; dependency for wttrin
+ (use-package xterm-color
+ :demand t)
+ :bind
+ ("M-W" . wttrin)
+ :custom
+ (wttrin-unit-system "u")
+ :config
+ (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"
+ )))
+
+(provide 'weather-config)
+;;; weather-config.el ends here.
diff --git a/modules/wip.el b/modules/wip.el
new file mode 100644
index 00000000..0fae57e3
--- /dev/null
+++ b/modules/wip.el
@@ -0,0 +1,121 @@
+;;; wip.el --- test code -*- lexical-binding: t; coding: utf-8; -*-
+;; author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; This is where to put config code you're working on before it's tested and stable.
+;; Include this at the very end of your init.el. This way, if something does break,
+;; and it will, most of your Emacs config is loaded.
+
+;; Once you've tested (and time-tested) the code here, graduate it into the proper
+;; section of your config above.
+
+;;; Code:
+
+(require 'user-constants)
+
+
+;; --------------------------- 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
+
+;; (add-to-list 'load-path "~/code/efrit/lisp")
+;; (require 'efrit)
+
+;; ------------------------------ Buffer Same Mode -----------------------------
+
+(defun cj/buffer-same-mode (&rest modes)
+ "Pop to a buffer with a mode among MODES, or the current one if not given."
+ (interactive)
+ (let* ((modes (or modes (list major-mode)))
+ (pred (lambda (b)
+ (let ((b (get-buffer (if (consp b) (car b) b))))
+ (member (buffer-local-value 'major-mode b) modes)))))
+ (pop-to-buffer (read-buffer "Buffer: " nil t pred))))
+(global-set-key (kbd "C-x B") 'cj/buffer-same-mode)
+
+;; ;; --------------------------------- Easy Hugo ---------------------------------
+
+;; (use-package easy-hugo
+;; :defer .5
+;; :init
+;; (setq easy-hugo-basedir "~/code/cjennings-net/")
+;; (setq easy-hugo-url "https://cjennings.net")
+;; (setq easy-hugo-sshdomain "cjennings.net")
+;; (setq easy-hugo-root "/var/www/cjennings/")
+;; (setq easy-hugo-previewtime "300")
+;; (setq easy-hugo-postdir "content")
+;; (setq easy-hugo-server-flags "-D --noHTTPCache --disableFastRender")
+;; (setq easy-hugo-default-ext ".md")
+;; :bind ("C-c H" . easy-hugo)
+;; :config
+;; (easy-hugo-enable-menu))
+
+;; ------------------------------------ Pomm -----------------------------------
+
+(use-package pomm
+ :defer .5
+ :bind ("M-p" . pomm)
+ :commands (pomm pomm-third-time))
+
+;; ------------------------------ 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.")
+
+(define-minor-mode mouse-trap-mode
+ "Globally disable most mouse and trackpad events.
+
+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."
+ :global t
+ :lighter " 🐭"
+ :keymap mouse-trap-mode-map)
+(global-set-key (kbd "C-c M") #'mouse-trap-mode)
+(mouse-trap-mode 1)
+
+;; --------------------- Debug Code For Package Signatures ---------------------
+;; from https://emacs.stackexchange.com/questions/233/how-to-proceed-on-package-el-signature-check-failure
+
+
+;; Set package-check-signature to nil, e.g., M-: (setq package-check-signature nil) RET.
+;; Download the package gnu-elpa-keyring-update and run the function with the same name, e.g., M-x package-install RET gnu-elpa-keyring-update RET.
+;; Reset package-check-signature to the default value allow-unsigned, e.g., M-: (setq package-check-signature 'allow-unsigned) RET.
+
+;; (setq package-check-signature nil)
+;; (setq package-check-signature 'allow-unsigned)
+
+
+(provide 'wip)
+;;; wip.el ends here.
diff --git a/modules/wrap-up.el b/modules/wrap-up.el
new file mode 100644
index 00000000..f1841189
--- /dev/null
+++ b/modules/wrap-up.el
@@ -0,0 +1,30 @@
+;;; wrapup --- Functions Run Before Init Completion -*- lexical-binding: t; coding: utf-8; -*-
+;; author Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;;; Code:
+
+;; -------------------------------- Bury Buffers -------------------------------
+;; wait a few seconds then bury compile-related buffers.
+
+(defun cj/bury-buffers ()
+ "Bury comint and compilation buffers."
+ (dolist (buf (buffer-list))
+ (with-current-buffer buf
+ (when (or (derived-mode-p 'comint-mode)
+ (derived-mode-p 'compilation-mode)
+ (derived-mode-p 'debugger-mode)
+ (derived-mode-p 'elisp-compile-mode)
+ (derived-mode-p 'messages-buffer-mode)
+ ) ;; byte-compilations
+ (bury-buffer)))))
+
+(defun cj/bury-buffers-after-delay ()
+ "Run cj/bury-buffers after a delay."
+ (run-with-timer 1 nil 'cj/bury-buffers))
+
+(add-hook 'emacs-startup-hook 'cj/bury-buffers-after-delay)
+
+(provide 'wrap-up)
+;;; wrap-up.el ends here